feat(server): support installable license (#12181)

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Added support for installing self-hosted team licenses via encrypted license files.
  - Introduced a new "Onetime" license variant for self-hosted environments.
  - Added a GraphQL mutation to upload and install license files.
  - License details now display the license variant.

- **Bug Fixes**
  - Improved error messages for license activation and expiration, including dynamic reasons.

- **Localization**
  - Updated and improved license-related error messages for better clarity.

- **Tests**
  - Added comprehensive end-to-end tests for license installation scenarios.

- **Chores**
  - Enhanced environment variable handling and public key management for license verification.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
forehalo
2025-05-09 04:16:05 +00:00
parent 3db91bdc8e
commit 93e01b4442
34 changed files with 718 additions and 187 deletions
@@ -77,11 +77,24 @@ export class TestingApp extends NestApplication {
assert(init.body, 'body is required for gql request');
assert(init.headers, 'headers is required for gql request');
const res = await this.request('post', '/graphql')
.send(init?.body)
const req = this.request('post', '/graphql')
.set('accept', 'application/json')
.set(init.headers as Record<string, string>);
if (init.body instanceof FormData) {
for (const [key, value] of init.body.entries()) {
if (value instanceof File) {
req.attach(key, Buffer.from(await value.arrayBuffer()));
} else {
req.field(key, value);
}
}
} else {
req.send(init.body);
}
const res = await req;
return new Response(Buffer.from(JSON.stringify(res.body)), {
status: res.status,
headers: res.headers,
@@ -0,0 +1,144 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { installLicenseMutation, SubscriptionVariant } from '@affine/graphql';
import { Workspace, WorkspaceRole } from '../../../models';
import {
createApp,
e2e,
MockedUser,
Mockers,
refreshEnv,
type TestingApp,
} from '../test';
const testWorkspaceId = 'd6f52bc7-d62a-4822-804a-335fa7dfe5a6';
const testPublicKey = `-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEkXqe9HR32rSgaNxaePXbwHxoanUq
1DcQTV1twn+G47/HiY+rj7oOw3cLNzOVe7+4Uxn8SMZ/XLImtqFQ6I4WVg==
-----END PUBLIC KEY-----`;
const fixturesDir = join(import.meta.dirname, '__fixtures__');
function getLicense(file: string) {
return new File([readFileSync(join(fixturesDir, file))], 'test-license.lic', {
type: 'application/octet-stream',
});
}
const licenses = {
valid: getLicense('valid.license'),
expired: getLicense('expired.license'),
expiredEndAt: getLicense('expired-end-at.license'),
};
let app: TestingApp;
let workspace: Workspace;
let owner: MockedUser;
e2e.before(async () => {
process.env.DEPLOYMENT_TYPE = 'selfhosted';
process.env.AFFiNE_PRO_PUBLIC_KEY = testPublicKey;
refreshEnv();
app = await createApp();
await app.models.workspace.delete(testWorkspaceId);
owner = await app.signup();
workspace = await app.create(Mockers.Workspace, {
id: testWorkspaceId,
owner,
});
});
e2e.beforeEach(async () => {
await app.login(owner);
});
e2e.after.always(async () => {
await app.close();
});
e2e('should install file license', async t => {
const res = await app.gql({
query: installLicenseMutation,
variables: {
workspaceId: workspace.id,
license: licenses.valid,
},
});
t.is(res.installLicense.variant, SubscriptionVariant.Onetime);
});
e2e('should not allow to install license if not owner', async t => {
const user = await app.signup();
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: user.id,
type: WorkspaceRole.Collaborator,
});
await t.throwsAsync(
app.gql({
query: installLicenseMutation,
variables: {
workspaceId: workspace.id,
license: licenses.valid,
},
}),
{
message: `You do not have permission to access Space ${workspace.id}.`,
}
);
});
e2e(`should not install other workspace's license file`, async t => {
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner,
});
await t.throwsAsync(
app.gql({
query: installLicenseMutation,
variables: {
workspaceId: workspace.id,
license: licenses.valid,
},
}),
{
message:
'Invalid license to activate. Workspace mismatched with license.',
}
);
});
e2e('should not install expired license', async t => {
await t.throwsAsync(
app.gql({
query: installLicenseMutation,
variables: {
workspaceId: workspace.id,
license: licenses.expired,
},
}),
{
message: 'License has expired.',
}
);
});
e2e('should not install license with expired end date', async t => {
await t.throwsAsync(
app.gql({
query: installLicenseMutation,
variables: {
workspaceId: workspace.id,
license: licenses.expiredEndAt,
},
}),
{
message: 'License has expired.',
}
);
});
@@ -1,5 +1,6 @@
import test, { registerCompletionHandler } from 'ava';
import { Env } from '../../env';
import { type TestingApp } from './create-app';
export const e2e = test;
@@ -9,3 +10,11 @@ export const app: TestingApp = globalThis.app;
registerCompletionHandler(async () => {
await app.close();
});
export function refreshEnv() {
globalThis.env = new Env();
}
export * from '../mocks';
export { createApp } from './create-app';
export type { TestingApp };