snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-16 04:01:20 +05:00
commit 702cc698a7
9990 changed files with 1073618 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,152 @@
# Snapshot report for `src/__tests__/worker.e2e.ts`
The actual snapshot is saved in `worker.e2e.ts.snap`.
Generated by [AVA](https://avajs.dev).
## should proxy image
> Snapshot 1
{}
> Snapshot 2
{
code: 'Bad Request',
message: 'Missing "url" parameter',
name: 'BAD_REQUEST',
status: 400,
type: 'BAD_REQUEST',
}
> Snapshot 3
{
code: 'Bad Request',
message: 'Invalid header',
name: 'BAD_REQUEST',
status: 400,
type: 'BAD_REQUEST',
}
> Snapshot 4
{
code: 'Bad Request',
message: 'Invalid header',
name: 'BAD_REQUEST',
status: 400,
type: 'BAD_REQUEST',
}
> Snapshot 5
Buffer @Uint8Array [
89504e47 0d0a1a0a 0000000d 49484452 00000001 00000001 08040000 00b51c0c
02000000 0b494441 5478da63 fcff1f00 03030200 efa37c9f 00000000 49454e44
ae426082
]
## should preview link
> Snapshot 1
{}
> Snapshot 2
{
code: 'Bad Request',
message: 'Invalid URL',
name: 'BAD_REQUEST',
status: 400,
type: 'BAD_REQUEST',
}
> Snapshot 3
{
code: 'Bad Request',
message: 'Invalid header',
name: 'BAD_REQUEST',
status: 400,
type: 'BAD_REQUEST',
}
> Snapshot 4
{
code: 'Bad Request',
message: 'Invalid URL',
name: 'BAD_REQUEST',
status: 400,
type: 'BAD_REQUEST',
}
> Snapshot 5
{
description: 'Test Description',
favicons: [
'http://example.com/favicon.ico?v=2',
],
images: [
'http://example.com/image.png',
],
title: 'Test Title',
url: 'http://example.com/page',
videos: [],
}
> Snapshot 6
{
charset: 'gbk',
favicons: [
'http://example.com/favicon.ico?v=2',
],
images: [],
title: '你好,世界。',
url: 'http://example.com/gb2312',
videos: [],
}
> Snapshot 7
{
charset: 'shift_jis',
favicons: [
'http://example.com/favicon.ico?v=2',
],
images: [],
title: 'こんにちは、世界。',
url: 'http://example.com/shift-jis',
videos: [],
}
> Snapshot 8
{
charset: 'big5',
favicons: [
'http://example.com/favicon.ico?v=2',
],
images: [],
title: '你好,世界。',
url: 'http://example.com/big5',
videos: [],
}
> Snapshot 9
{
charset: 'euc-kr',
favicons: [
'http://example.com/favicon.ico?v=2',
],
images: [],
title: '안녕하세요, 세계.',
url: 'http://example.com/euc-kr',
videos: [],
}

View File

@@ -0,0 +1,234 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { Controller, Post, RawBody } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import type { TestFn } from 'ava';
import ava from 'ava';
import request from 'supertest';
import { buildAppModule } from '../../app.module';
import { Public } from '../../core/auth';
import { ServerService } from '../../core/config';
import { createTestingApp, type TestingApp } from '../utils';
const test = ava as TestFn<{
app: TestingApp;
db: PrismaClient;
}>;
let originalDeploymentType: typeof env.DEPLOYMENT_TYPE;
const mobileUAString =
'Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36';
function initTestStaticFiles(staticPath: string) {
const files = {
'selfhost.html': `<!DOCTYPE html><html><body>AFFiNE</body><script src="main.a.js"/></html>`,
'main.a.js': `const name = 'affine'`,
'admin/selfhost.html': `<!DOCTYPE html><html><body>AFFiNE Admin</body><script src="/admin/main.b.js"/></html>`,
'admin/main.b.js': `const name = 'affine-admin'`,
'mobile/selfhost.html': `<!DOCTYPE html><html><body>AFFiNE mobile</body><script src="/mobile/main.c.js"/></html>`,
'mobile/main.c.js': `const name = 'affine-mobile'`,
};
for (const [filename, content] of Object.entries(files)) {
const filePath = path.join(staticPath, filename);
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, content);
}
}
@Controller('/')
export class TestResolver {
@Public()
@Post('/upload')
async upload(@RawBody() buffer: Buffer | undefined): Promise<number> {
return buffer?.length || 0;
}
}
test.before('init selfhost server', async t => {
originalDeploymentType = globalThis.env.DEPLOYMENT_TYPE;
// @ts-expect-error override
globalThis.env.DEPLOYMENT_TYPE = 'selfhosted';
const app = await createTestingApp({
imports: [buildAppModule(globalThis.env)],
controllers: [TestResolver],
});
t.context.app = app;
t.context.db = t.context.app.get(PrismaClient);
const staticPath = path.join(env.projectRoot, 'static');
initTestStaticFiles(staticPath);
});
test.beforeEach(async t => {
await t.context.app.initTestingDB();
const server = t.context.app.get(ServerService);
// @ts-expect-error disable cache
server._initialized = false;
});
test.after.always(async t => {
try {
await t.context.app.close();
} finally {
// @ts-expect-error restore mutable test env singleton
globalThis.env.DEPLOYMENT_TYPE = originalDeploymentType;
}
});
test('do not allow visit index.html directly', async t => {
let res = await request(t.context.app.getHttpServer())
.get('/index.html')
.expect(302);
t.is(res.header.location, '');
res = await request(t.context.app.getHttpServer())
.get('/admin/index.html')
.expect(302);
t.is(res.header.location, '/admin');
res = await request(t.context.app.getHttpServer())
.get('/mobile/index.html')
.expect(302);
});
test('should always return static asset files', async t => {
let res = await request(t.context.app.getHttpServer())
.get('/main.a.js')
.expect(200);
t.is(res.text, "const name = 'affine'");
res = await request(t.context.app.getHttpServer())
.get('/admin/main.b.js')
.expect(200);
t.is(res.text, "const name = 'affine-admin'");
res = await request(t.context.app.getHttpServer())
.get('/main.c.js')
.expect(200);
t.is(res.text, "const name = 'affine-mobile'");
await t.context.db.user.create({
data: {
name: 'test',
email: 'test@affine.pro',
},
});
res = await request(t.context.app.getHttpServer())
.get('/main.a.js')
.expect(200);
t.is(res.text, "const name = 'affine'");
res = await request(t.context.app.getHttpServer())
.get('/admin/main.b.js')
.expect(200);
t.is(res.text, "const name = 'affine-admin'");
res = await request(t.context.app.getHttpServer())
.get('/main.c.js')
.expect(200);
t.is(res.text, "const name = 'affine-mobile'");
});
test('should be able to call apis', async t => {
const res = await request(t.context.app.getHttpServer())
.get('/info')
.expect(200);
t.is(res.body.flavor, 'allinone');
});
const blockedPages = [
'/',
'/workspace',
'/admin',
'/admin/',
'/admin/accounts',
];
test('should redirect to setup if server is not initialized', async t => {
for (const path of blockedPages) {
const res = await request(t.context.app.getHttpServer()).get(path);
t.is(res.status, 302, `Failed to redirect ${path}`);
t.is(res.header.location, '/admin/setup');
}
t.pass();
});
test('should allow visiting all pages if initialized', async t => {
await t.context.db.user.create({
data: {
name: 'test',
email: 'test@affine.pro',
},
});
for (const path of blockedPages) {
const res = await request(t.context.app.getHttpServer()).get(path);
t.is(res.status, 200, `Failed to visit ${path}`);
}
t.pass();
});
test('should allow visiting setup page if not initialized', async t => {
const res = await request(t.context.app.getHttpServer())
.get('/admin/setup')
.expect(200);
t.true(res.text.includes('AFFiNE Admin'));
});
test('should redirect to admin if initialized', async t => {
await t.context.db.user.create({
data: {
name: 'test',
email: 'test@affine.pro',
},
});
const res = await request(t.context.app.getHttpServer())
.get('/admin/setup')
.expect(302);
t.is(res.header.location, '/admin');
});
// TODO(@forehalo): return mobile when it's ready
test.skip('should return web assets if visited by mobile', async t => {
await t.context.db.user.create({
data: {
name: 'test',
email: 'test@affine.pro',
},
});
const res = await request(t.context.app.getHttpServer())
.get('/')
.set('user-agent', mobileUAString)
.expect(200);
t.true(res.text.includes('AFFiNE mobile'));
});
test('should can send maximum size of body', async t => {
const { app } = t.context;
const body = 'a'.repeat(1 * 1024 * 1024);
const res = await app
.POST('/upload')
.set('Content-Type', 'application/octet-stream')
.send(body)
.expect(201);
t.is(Number(res.text), body.length);
});

View File

@@ -0,0 +1,85 @@
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import {
AccessTokenService,
AuthModule,
AuthSessionService,
AuthSigningKeyRing,
SessionAccessTokenError,
} from '../../core/auth';
import { createTestingApp, TestingApp } from '../utils';
const test = ava.serial as TestFn<{
app: TestingApp;
access: AccessTokenService;
sessions: AuthSessionService;
keys: AuthSigningKeyRing;
db: PrismaClient;
}>;
test.before(async t => {
const app = await createTestingApp({ imports: [AuthModule] });
t.context.app = app;
t.context.access = app.get(AccessTokenService);
t.context.sessions = app.get(AuthSessionService);
t.context.keys = app.get(AuthSigningKeyRing);
t.context.db = app.get(PrismaClient);
});
test.beforeEach(async t => {
await t.context.app.initTestingDB();
});
test.after.always(async t => {
await t.context.app.close();
});
test('native access-token verifier returns the canonical principal', async t => {
const user = await t.context.app.createUser('access-token@affine.pro');
const issued = await t.context.app.createNativeAuthSession(user.id, {
installationId: 'access-installation',
platform: 'android',
});
t.regex(issued.accessToken, /^eyJ/);
const principal = await t.context.access.verify(issued.accessToken);
t.is(principal.user.id, user.id);
t.is(principal.authSessionId, issued.session.id);
t.true(principal.authenticatedAt instanceof Date);
const error = await t.throwsAsync(() => t.context.access.verify('not-jwt'));
t.true(error instanceof SessionAccessTokenError);
t.is((error as SessionAccessTokenError).code, 'ACCESS_TOKEN_INVALID');
});
test('retiring keys verify old tokens while session state remains authoritative', async t => {
const user = await t.context.app.createUser('rotated-token@affine.pro');
const issued = await t.context.app.createNativeAuthSession(user.id);
const active = (await t.context.keys.metadata()).find(
key => key.status === 'active'
);
t.truthy(active);
if (!active) return;
await t.context.keys.rotate(user.id, active.id);
t.is((await t.context.access.verify(issued.accessToken)).user.id, user.id);
await t.context.sessions.revoke(issued.session.id, 'test', user.id);
const error = await t.throwsAsync(() =>
t.context.access.verify(issued.accessToken)
);
t.is((error as SessionAccessTokenError).code, 'AUTH_SESSION_REVOKED');
});
test('expired parent cookie lifetime invalidates native access tokens', async t => {
const user = await t.context.app.createUser('expired-token@affine.pro');
const issued = await t.context.app.createNativeAuthSession(user.id);
await t.context.db.userSession.updateMany({
where: { userId: user.id },
data: { expiresAt: new Date(0) },
});
const error = await t.throwsAsync(() =>
t.context.access.verify(issued.accessToken)
);
t.is((error as SessionAccessTokenError).code, 'AUTH_SESSION_EXPIRED');
});

View File

@@ -0,0 +1,126 @@
import ava, { TestFn } from 'ava';
import sinon from 'sinon';
import { type EventBus } from '../../base';
import { AuthSessionService, AuthSigningKeyRing } from '../../core/auth';
import { BackendRuntimeProvider } from '../../core/backend-runtime';
const test = ava as TestFn<{
runtime: sinon.SinonStubbedInstance<BackendRuntimeProvider>;
event: sinon.SinonStubbedInstance<EventBus>;
sessions: AuthSessionService;
keys: AuthSigningKeyRing;
}>;
test.beforeEach(t => {
const runtime = {
executeAuthSessionCommandV1: sinon.stub(),
} as unknown as sinon.SinonStubbedInstance<BackendRuntimeProvider>;
const event = {
emit: sinon.stub(),
} as unknown as sinon.SinonStubbedInstance<EventBus>;
t.context = {
runtime,
event,
sessions: new AuthSessionService(runtime, event),
keys: new AuthSigningKeyRing(runtime, event),
};
});
test('auth session adapter maps protocol dates and refresh events', async t => {
t.context.runtime.executeAuthSessionCommandV1.onFirstCall().resolves({
status: 'rotated',
userId: 'user-1',
tokenType: 'Bearer',
accessToken: 'access',
expiresIn: 300,
refreshToken: 'refresh',
refreshExpiresAt: '2026-09-08T00:00:00.000Z',
session: {
id: 'session-1',
absoluteExpiresAt: '2026-10-01T00:00:00.000Z',
},
authSessionId: 'session-1',
platform: 'ios',
grace: false,
});
const refreshed = await t.context.sessions.refresh('source-token', '1.0.1');
t.is(refreshed.status, 'rotated');
if (refreshed.status !== 'rotated') return;
t.true(refreshed.refreshExpiresAt instanceof Date);
t.true(refreshed.session.absoluteExpiresAt instanceof Date);
t.true(
t.context.runtime.executeAuthSessionCommandV1.calledWithExactly({
action: 'refresh',
refreshToken: 'source-token',
appVersion: '1.0.1',
})
);
t.true(
t.context.event.emit.calledWithExactly('auth.session.refreshed', {
authSessionId: 'session-1',
})
);
t.context.runtime.executeAuthSessionCommandV1.reset();
t.context.runtime.executeAuthSessionCommandV1.resolves([
{
id: 'session-1',
installationId: 'installation-1',
platform: 'ios',
createdAt: '2026-09-07T00:00:00.000Z',
lastSeenAt: '2026-09-07T01:00:00.000Z',
idleExpiresAt: '2026-09-08T00:00:00.000Z',
absoluteExpiresAt: '2026-10-01T00:00:00.000Z',
},
]);
const [listed] = await t.context.sessions.list('user-1');
t.true(listed.createdAt instanceof Date);
t.true(listed.lastSeenAt instanceof Date);
t.true(listed.idleExpiresAt instanceof Date);
t.true(listed.absoluteExpiresAt instanceof Date);
});
test('signing key adapter maps metadata and emits rotation', async t => {
t.context.runtime.executeAuthSessionCommandV1.onFirstCall().resolves([
{
id: 'old-key',
status: 'active',
source: 'auto',
createdAt: '2026-09-01T00:00:00.000Z',
canDelete: false,
},
]);
t.context.runtime.executeAuthSessionCommandV1.onSecondCall().resolves([
{
id: 'old-key',
status: 'retiring',
source: 'auto',
verifyUntil: '2026-09-08T00:00:00.000Z',
canDelete: false,
},
{
id: 'new-key',
status: 'active',
source: 'admin',
canDelete: false,
},
]);
const keys = await t.context.keys.rotate('actor-1', 'old-key');
t.true(keys[0].verifyUntil instanceof Date);
t.true(
t.context.runtime.executeAuthSessionCommandV1.secondCall.calledWithExactly({
action: 'rotate_signing_key',
actorId: 'actor-1',
expectedActiveKeyId: 'old-key',
})
);
t.true(
t.context.event.emit.calledWithExactly('auth.signing_key.rotated', {
actorId: 'actor-1',
previousKeyId: 'old-key',
activeKeyId: 'new-key',
})
);
});

View File

@@ -0,0 +1,194 @@
import { randomBytes } from 'node:crypto';
import { PrismaClient } from '@prisma/client';
import type { TestFn } from 'ava';
import ava from 'ava';
import supertest from 'supertest';
import {
changeEmail,
changePassword,
createTestingApp,
currentUser,
sendChangeEmail,
sendSetPasswordEmail,
sendVerifyChangeEmail,
TestingApp,
} from '../utils';
const test = ava.serial as TestFn<{
app: TestingApp;
}>;
async function lastAuthMail(app: TestingApp, name: string) {
const delivery = await app.get(PrismaClient).mailDelivery.findFirstOrThrow({
where: { mailName: name },
orderBy: { createdAt: 'desc' },
});
return delivery.payload as {
to: string;
props: Record<string, string>;
};
}
test.beforeEach(async t => {
const app = await createTestingApp();
t.context.app = app;
});
test.afterEach.always(async t => {
await t.context.app.close();
});
test('change email', async t => {
const { app } = t.context;
const u1Email = 'u1@affine.pro';
const u2Email = 'u2@affine.pro';
const user = await app.signupV1(u1Email);
const signedIn = await currentUser(app);
const jwt = signedIn?.token.token;
t.truthy(jwt);
await sendChangeEmail(app, '/email-change');
const changeMail = await lastAuthMail(app, 'ChangeEmail');
t.is(changeMail.to, u1Email);
let link = new URL(changeMail.props.url);
const changeEmailToken = link.searchParams.get('token');
t.not(
changeEmailToken,
null,
'fail to get change email token from email content'
);
await sendVerifyChangeEmail(
app,
changeEmailToken as string,
u2Email,
'/email-change-verify'
);
const verifyMail = await lastAuthMail(app, 'VerifyChangeEmail');
t.is(verifyMail.to, u2Email);
link = new URL(verifyMail.props.url);
const verifyEmailToken = link.searchParams.get('token');
t.not(
verifyEmailToken,
null,
'fail to get verify change email token from email content'
);
await changeEmail(app, verifyEmailToken as string, u2Email);
const changedMail = await lastAuthMail(app, 'EmailChanged');
t.is(changedMail.to, u2Email);
t.is(changedMail.props.to, u2Email);
const revokedCookieSession = await currentUser(app);
t.is(revokedCookieSession, null);
const revokedJwtSession = await supertest(app.getHttpServer())
.get('/api/auth/session')
.set('Authorization', `Bearer ${jwt}`)
.expect(200);
t.falsy(revokedJwtSession.body.user);
app.clearAuth();
await app.login({
...user,
email: u2Email,
});
const me = await currentUser(app);
t.not(me, null, 'failed to get current user');
t.is(me?.email, u2Email, 'failed to get current user');
});
test('set and change password', async t => {
const { app } = t.context;
const u1Email = 'u1@affine.pro';
const u1 = await app.signupV1(u1Email);
const authSession = await app.createNativeAuthSession(u1.id, {
installationId: 'password-change-device',
platform: 'ios',
});
await sendSetPasswordEmail(app, u1Email, '/password-change');
const setPasswordMail = await lastAuthMail(app, 'SetPassword');
const link = new URL(setPasswordMail.props.url);
const setPasswordToken = link.searchParams.get('token');
t.is(setPasswordMail.to, u1Email);
t.not(
setPasswordToken,
null,
'fail to get set password token from email content'
);
const newPassword = randomBytes(16).toString('hex');
const success = await changePassword(
app,
u1.id,
setPasswordToken as string,
newPassword
);
t.true(success, 'failed to change password');
t.is(
await app.get(PrismaClient).authSession.count({
where: { id: authSession.session.id },
}),
0
);
let user = await currentUser(app);
t.is(user, null);
await app.login({
...u1,
password: newPassword,
});
user = await currentUser(app);
t.not(user, null, 'failed to get current user');
t.is(user?.email, u1Email, 'failed to get current user');
});
test('should forbid graphql callbackUrl to external origin', async t => {
const { app } = t.context;
const u1Email = 'u1@affine.pro';
await app.signupV1(u1Email);
const res = await app
.POST('/graphql')
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation($callbackUrl: String!) {
sendChangeEmail(callbackUrl: $callbackUrl)
}
`,
variables: {
callbackUrl: 'https://evil.example',
},
})
.expect(200);
t.truthy(res.body.errors?.length);
t.is(res.body.errors[0].extensions?.name, 'ACTION_FORBIDDEN');
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,463 @@
import { Controller, Get, HttpStatus } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import Sinon from 'sinon';
import request from 'supertest';
import { CANARY_CLIENT_VERSION_MAX_AGE_DAYS, ConfigFactory } from '../../base';
import {
AuthModule,
AuthSessionService,
CurrentUser,
Public,
Session,
} from '../../core/auth';
import { AuthService } from '../../core/auth/service';
import { Models } from '../../models';
import { createTestingApp, TestingApp } from '../utils';
@Controller('/')
class TestController {
@Public()
@Get('/public')
home(@CurrentUser() user?: CurrentUser) {
return { user };
}
@Get('/private')
private(@CurrentUser() user: CurrentUser) {
return { user };
}
@Get('/session')
session(@Session() session: Session) {
return session;
}
}
function makeCanaryDateVersion(date: Date, build = '015') {
return `${date.getUTCFullYear()}.${date.getUTCMonth() + 1}.${date.getUTCDate()}-canary.${build}`;
}
const test = ava.serial as TestFn<{
app: TestingApp;
server: any;
auth: AuthService;
authSessions: AuthSessionService;
models: Models;
db: PrismaClient;
config: ConfigFactory;
u1: Pick<CurrentUser, 'id'>;
sessionId: string;
authSessionId: string;
accessToken: string;
}>;
test.before(async t => {
const app = await createTestingApp({
imports: [AuthModule],
controllers: [TestController],
});
t.context.app = app;
t.context.server = app.getHttpServer();
t.context.auth = app.get(AuthService);
t.context.authSessions = app.get(AuthSessionService);
t.context.models = app.get(Models);
t.context.db = app.get(PrismaClient);
t.context.config = app.get(ConfigFactory);
});
test.beforeEach(async t => {
Sinon.restore();
await t.context.app.initTestingDB();
t.context.config.override({
client: {
versionControl: {
enabled: false,
requiredVersion: '>=0.25.0',
},
},
});
t.context.u1 = await t.context.app.createUser('u1@affine.pro');
const issued = await t.context.app.createNativeAuthSession(t.context.u1.id, {
installationId: 'installation-1',
platform: 'ios',
});
t.context.authSessionId = issued.session.id;
t.context.accessToken = issued.accessToken;
t.context.sessionId = (
await t.context.db.authSession.findUniqueOrThrow({
where: { id: issued.session.id },
include: { userSession: true },
})
).userSession.sessionId;
});
test.after.always(async t => {
await t.context.app.close();
});
test('should be able to visit public api if not signed in', async t => {
const res = await request(t.context.server).get('/public').expect(200);
t.is(res.body.user, undefined);
});
test('should be able to visit public api if signed in', async t => {
const res = await request(t.context.server)
.get('/public')
.set('Cookie', `${AuthService.sessionCookieName}=${t.context.sessionId}`)
.expect(HttpStatus.OK);
t.is(res.body.user.id, t.context.u1.id);
});
test('should not be able to visit private api if not signed in', async t => {
await request(t.context.server)
.get('/private')
.expect(HttpStatus.UNAUTHORIZED)
.expect({
status: 401,
code: 'Unauthorized',
type: 'AUTHENTICATION_REQUIRED',
name: 'AUTHENTICATION_REQUIRED',
message: 'You must sign in first to access this resource.',
});
t.assert(true);
});
test('should be able to visit private api with cookie session', async t => {
const res = await request(t.context.server)
.get('/private')
.set('Cookie', `${AuthService.sessionCookieName}=${t.context.sessionId}`)
.expect(HttpStatus.OK);
t.is(res.body.user.id, t.context.u1.id);
});
test('should reject a legacy bearer session id', async t => {
await request(t.context.server)
.get('/private')
.set('Authorization', `Bearer ${t.context.sessionId}`)
.expect(HttpStatus.UNAUTHORIZED);
await request(t.context.server)
.get('/private')
.set('Authorization', 'Bearer aff_mcp_v1.selector.secret')
.expect(HttpStatus.UNAUTHORIZED);
t.pass();
});
test('should be able to visit private api with auth-session access jwt', async t => {
const res = await request(t.context.server)
.get('/private')
.set('Authorization', `Bearer ${t.context.accessToken}`)
.expect(HttpStatus.OK);
t.is(res.body.user.id, t.context.u1.id);
});
test('should prefer bearer jwt over cookie session', async t => {
const u2 = await t.context.app.createUser('u2@affine.pro');
const u2Session = await t.context.app.createNativeAuthSession(u2.id, {
installationId: 'installation-2',
platform: 'android',
});
const res = await request(t.context.server)
.get('/private')
.set('Cookie', `${AuthService.sessionCookieName}=${t.context.sessionId}`)
.set('Authorization', `Bearer ${u2Session.accessToken}`)
.expect(HttpStatus.OK);
t.is(res.body.user.id, u2.id);
});
test('should reject jwt after its user session is deleted', async t => {
await t.context.auth.signOut(t.context.sessionId, t.context.u1.id);
await request(t.context.server)
.get('/private')
.set('Authorization', `Bearer ${t.context.accessToken}`)
.expect(HttpStatus.UNAUTHORIZED);
t.pass();
});
test('should enforce client version for auth-session access jwt auth', async t => {
t.context.config.override({
client: {
versionControl: {
enabled: true,
requiredVersion: '>=0.25.0',
},
},
});
const authSession = await t.context.app.createNativeAuthSession(
t.context.u1.id,
{
installationId: 'version-installation',
platform: 'electron',
}
);
const token = authSession.accessToken;
const res = await request(t.context.server)
.get('/private')
.set('Authorization', `Bearer ${token}`)
.set('x-affine-version', '0.24.0')
.expect(HttpStatus.FORBIDDEN);
t.is(
res.body.message,
'Unsupported client with version [0.24.0], required version is [>=0.25.0].'
);
});
test('should not hide an invalid auth-session jwt behind a cookie session', async t => {
const res = await request(t.context.server)
.get('/public')
.set('Cookie', `${AuthService.sessionCookieName}=${t.context.sessionId}`)
.set('Authorization', 'Bearer invalid.jwt.token')
.expect(HttpStatus.UNAUTHORIZED);
t.is(res.body.code, 'ACCESS_TOKEN_INVALID');
});
test('should return a stable error for invalid jwt on public api', async t => {
const res = await request(t.context.server)
.get('/public')
.set('Authorization', 'Bearer invalid.jwt.token')
.expect(HttpStatus.UNAUTHORIZED);
t.is(res.body.code, 'ACCESS_TOKEN_INVALID');
});
test('should be able to parse session cookie', async t => {
const spy = Sinon.spy(t.context.auth, 'getUserSession');
await request(t.context.server)
.get('/public')
.set('cookie', `${AuthService.sessionCookieName}=${t.context.sessionId}`)
.expect(200);
t.deepEqual(spy.firstCall.args, [t.context.sessionId, undefined]);
spy.restore();
});
test('should not parse a legacy bearer session id', async t => {
const spy = Sinon.spy(t.context.auth, 'getUserSession');
await request(t.context.server)
.get('/public')
.auth(t.context.sessionId, { type: 'bearer' })
.expect(200);
t.false(spy.called);
spy.restore();
});
test('should expose auth-session version rejection on a public api', async t => {
t.context.config.override({
client: {
versionControl: {
enabled: true,
requiredVersion: '>=0.25.0',
},
},
});
const token = t.context.accessToken;
const res = await request(t.context.server)
.get('/public')
.set('Authorization', `Bearer ${token}`)
.set('x-affine-version', '0.24.0')
.expect(HttpStatus.FORBIDDEN);
t.is(res.body.name, 'UNSUPPORTED_CLIENT_VERSION');
});
test('should be able to refresh session if needed', async t => {
await t.context.app.get(PrismaClient).userSession.updateMany({
where: {
sessionId: t.context.sessionId,
},
data: {
expiresAt: new Date(Date.now() + 1000 * 60 * 60 /* expires in 1 hour */),
},
});
const res = await request(t.context.server)
.get('/session')
.set('cookie', `${AuthService.sessionCookieName}=${t.context.sessionId}`)
.expect(200);
const cookie = res
.get('Set-Cookie')
?.find(c => c.startsWith(AuthService.sessionCookieName));
t.truthy(cookie);
});
test('should record refresh client version when refreshed', async t => {
await t.context.db.userSession.updateMany({
where: { sessionId: t.context.sessionId },
data: {
expiresAt: new Date(Date.now() + 1000 * 60 * 60 /* expires in 1 hour */),
},
});
await request(t.context.server)
.get('/session')
.set('cookie', `${AuthService.sessionCookieName}=${t.context.sessionId}`)
.set('x-affine-version', '0.25.2')
.expect(200);
const userSession = await t.context.db.userSession.findFirst({
where: { sessionId: t.context.sessionId, userId: t.context.u1.id },
});
t.is(userSession?.refreshClientVersion, '0.25.2');
});
test('should allow auth when header is missing but stored version is valid', async t => {
t.context.config.override({
client: {
versionControl: {
enabled: true,
requiredVersion: '>=0.25.0',
},
},
});
await t.context.db.userSession.updateMany({
where: { sessionId: t.context.sessionId },
data: { signInClientVersion: '0.25.0' },
});
const res = await request(t.context.server)
.get('/private')
.set('Cookie', `${AuthService.sessionCookieName}=${t.context.sessionId}`)
.expect(200);
t.is(res.body.user.id, t.context.u1.id);
});
test('should kick out unsupported client version on non-public handler', async t => {
t.context.config.override({
client: {
versionControl: {
enabled: true,
requiredVersion: '>=0.25.0',
},
},
});
const res = await request(t.context.server)
.get('/private')
.set('Cookie', `${AuthService.sessionCookieName}=${t.context.sessionId}`)
.set('x-affine-version', '0.24.0')
.expect(403);
const setCookies = res.get('Set-Cookie') ?? [];
t.true(
setCookies.some(c => c.startsWith(`${AuthService.sessionCookieName}=`))
);
t.true(setCookies.some(c => c.startsWith(`${AuthService.userCookieName}=`)));
t.true(setCookies.some(c => c.startsWith(`${AuthService.csrfCookieName}=`)));
const session = await t.context.db.session.findFirst({
where: { id: t.context.sessionId },
});
t.is(session, null);
});
test('should not block public handler when client version is unsupported', async t => {
t.context.config.override({
client: {
versionControl: {
enabled: true,
requiredVersion: '>=0.25.0',
},
},
});
const res = await request(t.context.server)
.get('/public')
.set('Cookie', `${AuthService.sessionCookieName}=${t.context.sessionId}`)
.set('x-affine-version', '0.24.0')
.expect(200);
t.is(res.body.user, undefined);
const setCookies = res.get('Set-Cookie') ?? [];
t.true(
setCookies.some(c => c.startsWith(`${AuthService.sessionCookieName}=`))
);
t.true(setCookies.some(c => c.startsWith(`${AuthService.userCookieName}=`)));
t.true(setCookies.some(c => c.startsWith(`${AuthService.csrfCookieName}=`)));
});
test('should allow recent canary date version in canary namespace', async t => {
t.context.config.override({
client: {
versionControl: {
enabled: true,
requiredVersion: '>=0.25.0',
},
},
});
const prevNamespace = env.NAMESPACE;
// @ts-expect-error test
env.NAMESPACE = 'dev';
try {
const res = await request(t.context.server)
.get('/private')
.set('Cookie', `${AuthService.sessionCookieName}=${t.context.sessionId}`)
.set('x-affine-version', makeCanaryDateVersion(new Date(), '015'))
.expect(200);
t.is(res.body.user.id, t.context.u1.id);
} finally {
// @ts-expect-error test
env.NAMESPACE = prevNamespace;
}
});
test('should kick out old canary date version in canary namespace', async t => {
t.context.config.override({
client: {
versionControl: {
enabled: true,
requiredVersion: '>=0.25.0',
},
},
});
const prevNamespace = env.NAMESPACE;
// @ts-expect-error test
env.NAMESPACE = 'dev';
try {
const old = new Date(
Date.now() -
(CANARY_CLIENT_VERSION_MAX_AGE_DAYS + 1) * 24 * 60 * 60 * 1000
);
const oldVersion = makeCanaryDateVersion(old, '015');
const res = await request(t.context.server)
.get('/private')
.set('Cookie', `${AuthService.sessionCookieName}=${t.context.sessionId}`)
.set('x-affine-version', oldVersion)
.expect(403);
t.is(
res.body.message,
`Unsupported client with version [${oldVersion}], required version is [canary (within 2 months)].`
);
} finally {
// @ts-expect-error test
env.NAMESPACE = prevNamespace;
}
});

View File

@@ -0,0 +1,69 @@
import { ScheduleModule } from '@nestjs/schedule';
import { TestingModule } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import test from 'ava';
import Sinon from 'sinon';
import { AuthWorkerModule } from '../../core/auth';
import { AuthCronJob } from '../../core/auth/job';
import { BackendRuntimeProvider } from '../../core/backend-runtime';
import { Models } from '../../models';
import { createTestingModule } from '../utils';
let m: TestingModule;
let db: PrismaClient;
const runtime = {
cleanupExpiredUserSessions: Sinon.stub(),
executeAuthSessionCommandV1: Sinon.stub(),
};
test.before(async () => {
m = await createTestingModule({
imports: [ScheduleModule.forRoot(), AuthWorkerModule],
tapModule: builder => {
builder.overrideProvider(BackendRuntimeProvider).useValue(runtime);
},
});
db = m.get(PrismaClient);
});
test.after.always(async () => {
await m.close();
});
test('should clean expired user sessions', async t => {
const job = m.get(AuthCronJob);
const models = m.get(Models);
const user1 = await models.user.create({ email: 'u1@affine.pro' });
const user2 = await models.user.create({ email: 'u2@affine.pro' });
const session1 = await db.session.create({ data: {} });
const session2 = await db.session.create({ data: {} });
await db.userSession.createMany({
data: [
{ sessionId: session1.id, userId: user1.id },
{ sessionId: session2.id, userId: user2.id },
],
});
let userSessions = await db.userSession.findMany();
t.is(userSessions.length, 2);
runtime.cleanupExpiredUserSessions.reset();
runtime.executeAuthSessionCommandV1.reset();
runtime.cleanupExpiredUserSessions.resolves(0);
runtime.executeAuthSessionCommandV1.resolves(0);
await job.cleanExpiredUserSessions();
t.true(runtime.cleanupExpiredUserSessions.calledOnce);
t.deepEqual(runtime.cleanupExpiredUserSessions.firstCall.args, [1000]);
t.deepEqual(runtime.executeAuthSessionCommandV1.lastCall.args, [
{ action: 'cleanup', limit: 1000 },
]);
runtime.cleanupExpiredUserSessions.reset();
runtime.cleanupExpiredUserSessions.onCall(0).resolves(1000);
runtime.cleanupExpiredUserSessions.onCall(1).resolves(2);
await job.cleanExpiredUserSessions();
t.is(runtime.cleanupExpiredUserSessions.callCount, 2);
t.deepEqual(runtime.cleanupExpiredUserSessions.firstCall.args, [1000]);
t.deepEqual(runtime.cleanupExpiredUserSessions.secondCall.args, [1000]);
});

View File

@@ -0,0 +1,165 @@
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import { CurrentUser } from '../../core/auth';
import { AuthService } from '../../core/auth/service';
import { EntitlementModule } from '../../core/entitlement';
import { FeatureModule } from '../../core/features';
import { QuotaModule } from '../../core/quota';
import { UserModule } from '../../core/user';
import { Models } from '../../models';
import { createTestingModule, type TestingModule } from '../utils';
const test = ava.serial as TestFn<{
auth: AuthService;
u1: CurrentUser;
db: PrismaClient;
models: Models;
m: TestingModule;
}>;
test.before(async t => {
const m = await createTestingModule({
imports: [EntitlementModule, QuotaModule, FeatureModule, UserModule],
providers: [AuthService],
});
t.context.auth = m.get(AuthService);
t.context.db = m.get(PrismaClient);
t.context.models = m.get(Models);
t.context.m = m;
});
test.beforeEach(async t => {
await t.context.m.initTestingDB();
t.context.u1 = await t.context.models.user
.create({ email: 'u1@affine.pro', password: '1' })
.then(user => ({
...user,
hasPassword: true,
emailVerified: user.emailVerifiedAt !== null,
}));
});
test.after.always(async t => {
await t.context.m.close();
});
// Tests for Session
test('should be able to create user session', async t => {
const { auth, u1 } = t.context;
const session = await auth.issueUser(u1.id, { type: 'cookie' });
t.is(session.user.id, u1.id);
});
test('should be able to get user from session', async t => {
const { auth, u1 } = t.context;
const session = await auth.issueUser(u1.id, { type: 'cookie' });
const userSession = await auth.getUserSession(session.sessionId!);
t.not(userSession, null);
t.is(userSession!.user.id, u1.id);
});
test('should be able to sign out session', async t => {
const { auth, u1 } = t.context;
const session = await auth.issueUser(u1.id, { type: 'cookie' });
await auth.signOut(session.sessionId!);
const userSession = await auth.getUserSession(session.sessionId!);
t.is(userSession, null);
});
test('should not return expired session', async t => {
const { auth, u1, db } = t.context;
const session = await auth.issueUser(u1.id, { type: 'cookie' });
const row = await db.userSession.findUniqueOrThrow({
where: {
sessionId_userId: { sessionId: session.sessionId!, userId: u1.id },
},
});
await db.userSession.update({
where: { id: row.id },
data: {
expiresAt: new Date(Date.now() - 1000),
},
});
const userSession = await auth.getUserSession(session.sessionId!);
t.is(userSession, null);
});
// Tests for Multi-Accounts Session
test('should be able to sign in different user in a same session', async t => {
const { auth, u1 } = t.context;
const u2 = await t.context.models.user.create({
email: 'u2@affine.pro',
password: '1',
});
const first = await auth.issueUser(u1.id, { type: 'cookie' });
const sessionId = first.sessionId!;
let userList = await auth.getUserList(sessionId);
t.is(userList.length, 1);
t.is(userList[0]!.id, u1.id);
await auth.issueUser(u2.id, { type: 'cookie', sessionId });
userList = await auth.getUserList(sessionId);
t.is(userList.length, 2);
const [signedU1, signedU2] = userList;
t.not(signedU1, null);
t.not(signedU2, null);
t.is(signedU1!.id, u1.id);
t.is(signedU2!.id, u2.id);
});
test('should be able to signout multi accounts session', async t => {
const { auth, u1 } = t.context;
const u2 = await t.context.models.user.create({
email: 'u2@affine.pro',
password: '1',
});
const userSession1 = await auth.issueUser(u1.id, { type: 'cookie' });
const sessionId = userSession1.sessionId!;
const userSession2 = await auth.issueUser(u2.id, {
type: 'cookie',
sessionId,
});
t.is(userSession1.sessionId, userSession2.sessionId);
await auth.signOut(sessionId, u1.id);
let list = await auth.getUserList(sessionId);
t.is(list.length, 1);
t.is(list[0]!.id, u2.id);
const u2Session = await auth.getUserSession(sessionId, u1.id);
t.is(u2Session?.session.sessionId, sessionId);
t.is(u2Session?.user.id, u2.id);
await auth.signOut(sessionId, u2.id);
list = await auth.getUserList(sessionId);
t.is(list.length, 0);
const nullSession = await auth.getUserSession(sessionId, u2.id);
t.is(nullSession, null);
});

View File

@@ -0,0 +1,112 @@
import { randomUUID } from 'node:crypto';
import { TestingModule } from '@nestjs/testing';
import test from 'ava';
import { FunctionalityModules } from '../app.module';
import { Cache } from '../base/cache';
import { createTestingModule } from './utils';
let cache: Cache;
let module: TestingModule;
const keyPrefix = `test:${randomUUID()}:`;
const key = (name: string) => `${keyPrefix}${name}`;
test.before(async () => {
module = await createTestingModule({
imports: FunctionalityModules,
});
cache = module.get(Cache);
});
test.after.always(async () => {
await module.close();
});
test('should be able to set normal cache', async t => {
t.true(await cache.set(key('test'), 1));
t.is(await cache.get<number>(key('test')), 1);
t.true(await cache.has(key('test')));
t.true(await cache.delete(key('test')));
t.is(await cache.get(key('test')), undefined);
t.true(await cache.set(key('test'), { a: 1 }));
t.deepEqual(await cache.get(key('test')), { a: 1 });
});
test('should be able to set cache with non-exiting flag', async t => {
t.true(await cache.setnx(key('test-nx'), 1));
t.false(await cache.setnx(key('test-nx'), 2));
t.is(await cache.get(key('test-nx')), 1);
});
test('should be able to set cache with ttl', async t => {
t.true(await cache.set(key('test-ttl'), 1));
t.is(await cache.get(key('test-ttl')), 1);
t.true(await cache.expire(key('test-ttl'), 1 * 1000));
const ttl = await cache.ttl(key('test-ttl'));
t.true(ttl <= 1 * 1000);
t.true(ttl > 0);
});
test('should reject invalid ttl options', async t => {
t.false(await cache.set(key('test-invalid-ttl'), 1, { ttl: 0 }));
t.is(await cache.get(key('test-invalid-ttl')), undefined);
t.false(await cache.setnx(key('test-invalid-ttl-nx'), 1, { ttl: 0 }));
t.is(await cache.get(key('test-invalid-ttl-nx')), undefined);
});
test('should be able to incr/decr number cache', async t => {
t.true(await cache.set(key('test-incr'), 1));
t.is(await cache.increase(key('test-incr')), 2);
t.is(await cache.increase(key('test-incr')), 3);
t.is(await cache.decrease(key('test-incr')), 2);
t.is(await cache.decrease(key('test-incr')), 1);
// increase an nonexists number
t.is(await cache.increase(key('test-incr2')), 1);
t.is(await cache.increase(key('test-incr2')), 2);
const expiringKey = key('test-incr-with-ttl');
t.is(await cache.increaseWithTtl(expiringKey, 1000), 1);
t.is(await cache.increaseWithTtl(expiringKey, 1000), 2);
t.true((await cache.ttl(expiringKey)) > 0);
});
test('should be able to manipulate list cache', async t => {
t.is(await cache.pushBack(key('test-list'), 1), 1);
t.is(await cache.pushBack(key('test-list'), 2, 3, 4), 4);
t.is(await cache.len(key('test-list')), 4);
t.deepEqual(await cache.list(key('test-list'), 1, -1), [2, 3, 4]);
t.deepEqual(await cache.popFront(key('test-list'), 2), [1, 2]);
t.deepEqual(await cache.popBack(key('test-list'), 1), [4]);
t.is(await cache.pushBack(key('test-list2'), { a: 1 }), 1);
t.deepEqual(await cache.popFront(key('test-list2'), 1), [{ a: 1 }]);
});
test('should be able to manipulate map cache', async t => {
t.is(await cache.mapSet(key('test-map'), 'a', 1), true);
t.is(await cache.mapSet(key('test-map'), 'b', 2), true);
t.is(await cache.mapLen(key('test-map')), 2);
t.is(await cache.mapGet(key('test-map'), 'a'), 1);
t.is(await cache.mapGet(key('test-map'), 'b'), 2);
t.is(await cache.mapIncrease(key('test-map'), 'a'), 2);
t.is(await cache.mapIncrease(key('test-map'), 'a'), 3);
t.is(await cache.mapDecrease(key('test-map'), 'b', 3), -1);
const keys = await cache.mapKeys(key('test-map'));
t.deepEqual(keys, ['a', 'b']);
const randomKey = await cache.mapRandomKey(key('test-map'));
t.truthy(randomKey);
t.true(keys.includes(randomKey!));
t.is(await cache.mapDelete(key('test-map'), 'a'), true);
t.is(await cache.mapGet(key('test-map'), 'a'), undefined);
});

View File

@@ -0,0 +1,22 @@
# Snapshot report for `src/__tests__/copilot/conversation-host.spec.ts`
The actual snapshot is saved in `conversation-host.spec.ts.snap`.
Generated by [AVA](https://avajs.dev).
## workspace context fails closed for personal scope and revoked canonical access
> Snapshot 1
{
canonical: {
artifactWrites: 0,
durableAppends: 0,
error: 'permission denied',
},
personal: {
artifactWrites: 0,
durableAppends: 0,
error: 'Local workspaces don\'t support attachments or references.',
},
}

View File

@@ -0,0 +1,21 @@
# Snapshot report for `src/__tests__/copilot/copilot.e2e.ts`
The actual snapshot is saved in `copilot.e2e.ts.snap`.
Generated by [AVA](https://avajs.dev).
## session, message, local context restriction and durable history share one public contract
> Snapshot 1
{
read: {
body: 'canonical attachment',
cacheControl: 'private, no-store',
contentLength: '20',
contentType: 'text/plain',
},
upload: {
url: '/api/copilot/chat/:session/attachments/V-w0PBufYbKfzLbY0riBPQTkMEqpD-bF3Ob-3jX8q-0?workspaceId=:workspace&fileName=note.txt',
},
}

View File

@@ -0,0 +1,132 @@
# Snapshot report for `src/__tests__/copilot/runtime-boundaries.spec.ts`
The actual snapshot is saved in `runtime-boundaries.spec.ts.snap`.
Generated by [AVA](https://avajs.dev).
## copilot personal scope never bypasses a canonical workspace decision
> Snapshot 1
{
queryTrace: [
'workspace',
'workspace',
'workspace',
'workspace',
'actor-resource',
'workspace',
],
}
## title policy and cron retain background-work invariants
> Snapshot 1
[
{
sessionId: 'session-1',
userId: 'user-1',
workspaceId: 'workspace-1',
},
{
sessionId: 'session-2',
userId: 'user-2',
workspaceId: 'workspace-2',
},
]
## copilot storage deletes objects when result signing fails
> Snapshot 1
{
deleted: [
'user-1/workspace-1/missing',
'user-1/workspace-1/failed',
],
failed: 'signer unavailable',
missing: 'Copilot blob signing is required',
written: [
'user-1/workspace-1/missing',
'user-1/workspace-1/failed',
],
}
## copilot image artifacts require canonical scoped storage
> Snapshot 1
{
canonical: {
generated: [
'https://signed.invalid/user-1/workspace-1/YQXWzHavQAMl6U1YjOURvlv9u3O0N9xR7KQ5F9ekPj0',
],
written: [
'user-1/workspace-1/YQXWzHavQAMl6U1YjOURvlv9u3O0N9xR7KQ5F9ekPj0',
],
},
personal: {
action: 'Local workspaces don\'t support generated images.',
host: 'Local workspaces don\'t support generated images.',
request: 'Local workspaces don\'t support generated images.',
},
}
## copilot attachments require canonical session scope
> Snapshot 1
{
accessTrace: [
'scope:user-1:session-1',
'acl:canonical',
'session:workspace-1',
'scope:user-1:session-1',
'acl:canonical',
'session:workspace-1',
'admission-acl',
'scope:user-1:session-1',
'acl:canonical',
'session:workspace-1',
'scope:user-1:session-1',
'acl:personal',
'scope:user-1:session-1',
'acl:personal',
],
admitted: {
data: 'dHlwZWQgYXR0YWNobWVudA==',
encoding: 'base64',
fileName: 'notes.txt',
hash: '789928033f2fe7eb2040ae6b1d928d91baa054d3e2cb3635e78031d27367b640',
id: 'att_789928033f2fe7eb2040ae6b',
kind: 'bytes',
mimeType: 'text/plain',
providerHint: undefined,
size: 16,
},
attachmentReads: [
'user-1/workspace-1/eJkoAz8v5-sgQK5rHZKNkbqgVNPiyzY154Ax0nNntkA',
'user-1/workspace-1/eJkoAz8v5-sgQK5rHZKNkbqgVNPiyzY154Ax0nNntkA',
],
attachmentWrites: [
'user-1/workspace-1/eJkoAz8v5-sgQK5rHZKNkbqgVNPiyzY154Ax0nNntkA',
],
failures: {
crossSession: 'Copilot attachment scope mismatch',
read: 'Local workspaces don\'t support attachments or references.',
upload: 'Local workspaces don\'t support attachments or references.',
},
response: {
body: 'typed attachment',
headers: {
'cache-control': 'private, no-store',
'content-length': '16',
'content-type': 'text/plain',
'x-content-type-options': 'nosniff',
},
},
uploaded: {
url: '/api/copilot/chat/session-1/attachments/eJkoAz8v5-sgQK5rHZKNkbqgVNPiyzY154Ax0nNntkA?workspaceId=workspace-1&fileName=notes.txt',
},
}

View File

@@ -0,0 +1,22 @@
# Snapshot report for `src/__tests__/copilot/transcript-contract.spec.ts`
The actual snapshot is saved in `transcript-contract.spec.ts.snap`.
Generated by [AVA](https://avajs.dev).
## transcriptTask revalidates terminal scope before claim and action-run attachment
> Snapshot 1
{
afterClaim: {
accessChecks: 3,
attachCalls: 0,
completeCalls: 0,
error: 'You do not have permission to access this resource.',
},
beforeClaim: {
claimCalls: 0,
error: 'Transcription job not found.',
},
}

View File

@@ -0,0 +1,216 @@
import { generateKeyPairSync, randomUUID } from 'node:crypto';
import { PrismaClient } from '@prisma/client';
import type { TestFn } from 'ava';
import ava from 'ava';
import Sinon from 'sinon';
import { SpaceOwnerNotFound } from '../../base';
import type { CurrentUser } from '../../core/auth';
import { BackendRuntimeProvider } from '../../core/backend-runtime';
import type { WorkspaceType } from '../../core/workspaces';
import { Models, WorkspaceMemberStatus, WorkspaceRole } from '../../models';
import { ByokEntitlementPolicy } from '../../plugins/copilot/byok/policy';
import { WorkspaceByokResolver } from '../../plugins/copilot/byok/resolver';
import {
ByokEndpointKind,
ByokModelInput,
ByokModelOutput,
ByokProvider,
} from '../../plugins/copilot/byok/types';
import { createTestingModule, type TestingModule } from '../utils';
type Context = {
module: TestingModule;
db: PrismaClient;
models: Models;
runtime: BackendRuntimeProvider;
resolver: WorkspaceByokResolver;
policy: ByokEntitlementPolicy;
};
const test = ava.serial as TestFn<Context>;
const previousKey = process.env.AFFINE_PRIVATE_KEY;
const { privateKey } = generateKeyPairSync('ec', {
namedCurve: 'P-256',
});
const testPrivateKey = privateKey
.export({
format: 'pem',
type: 'pkcs8',
})
.toString();
test.before(async t => {
process.env.AFFINE_PRIVATE_KEY = testPrivateKey;
t.context.module = await createTestingModule();
t.context.db = t.context.module.get(PrismaClient);
t.context.models = t.context.module.get(Models);
t.context.runtime = t.context.module.get(BackendRuntimeProvider);
t.context.resolver = t.context.module.get(WorkspaceByokResolver);
t.context.policy = t.context.module.get(ByokEntitlementPolicy);
});
test.beforeEach(async t => {
await t.context.module.initTestingDB();
});
test.afterEach.always(() => {
Sinon.restore();
});
test.after.always(async t => {
await t.context.module?.close();
if (previousKey === undefined) delete process.env.AFFINE_PRIVATE_KEY;
else process.env.AFFINE_PRIVATE_KEY = previousKey;
});
test('BYOK settings expose the native effective policy', async t => {
const user = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
const workspace = await t.context.models.workspace.create(user.id);
const policy = {
enabled: true,
allowedProviders: ['openai'],
customEndpointMode: 'disabled',
privateEndpointSupported: false,
};
Sinon.stub(t.context.runtime, 'getByokPolicy').resolves(policy);
const settings = await t.context.resolver.settings(
{
id: user.id,
email: user.email,
avatarUrl: user.avatarUrl,
name: user.name,
disabled: user.disabled,
hasPassword: null,
emailVerified: true,
} satisfies CurrentUser,
{ id: workspace.id } as WorkspaceType
);
t.deepEqual(settings.policy, policy);
});
test('BYOK local lease maps workspace permission and entitlement results', async t => {
const user = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
const currentUser = {
id: user.id,
email: user.email,
avatarUrl: user.avatarUrl,
name: user.name,
disabled: user.disabled,
hasPassword: null,
emailVerified: true,
} satisfies CurrentUser;
await t.throwsAsync(
t.context.resolver.createWorkspaceByokLocalLease(currentUser, {
workspaceId: randomUUID(),
providers: [],
}),
{ message: /permission to access Space/ }
);
const workspace = await t.context.models.workspace.create(user.id);
const leaseInput = {
workspaceId: workspace.id,
providers: [
{
provider: ByokProvider.openai,
name: 'Local OpenAI',
description: null,
credential: 'local-secret',
definition: {
endpoint: {
kind: ByokEndpointKind.provider_default,
url: null,
dialect: null,
},
models: [
{
modelId: 'gpt-4o-mini',
enabled: true,
capabilities: [
{
input: [ByokModelInput.text],
output: [ByokModelOutput.text],
features: [],
attachmentKinds: [],
attachmentSources: [],
},
],
},
],
},
enabled: true,
},
],
};
await t.throwsAsync(
t.context.resolver.createWorkspaceByokLocalLease(currentUser, leaseInput),
{ message: /BYOK requires/ }
);
await t.context.db.entitlement.create({
data: {
targetType: 'user',
targetId: user.id,
source: 'cloud_subscription',
subjectId: `active:${user.id}`,
plan: 'pro',
status: 'active',
},
});
t.truthy(
await t.context.resolver.createWorkspaceByokLocalLease(
currentUser,
leaseInput
)
);
const member = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
await t.context.models.workspaceUser.set(
workspace.id,
member.id,
WorkspaceRole.Collaborator,
{ status: WorkspaceMemberStatus.Accepted }
);
await t.context.db.entitlement.create({
data: {
targetType: 'user',
targetId: member.id,
source: 'cloud_subscription',
subjectId: `active:${member.id}`,
plan: 'pro',
status: 'active',
},
});
await t.throwsAsync(
t.context.resolver.createWorkspaceByokLocalLease(
{ ...currentUser, id: member.id, email: member.email },
leaseInput
),
{ message: /permission to access Space/ }
);
await t.context.db.workspaceMember.deleteMany({
where: { workspaceId: workspace.id, role: 'owner' },
});
await t.throwsAsync(t.context.policy.hasServerEntitlement(workspace.id), {
instanceOf: SpaceOwnerNotFound,
});
});
test('BYOK owner lookup preserves infrastructure errors', async t => {
Sinon.stub(t.context.runtime, 'getByokEntitlementV1').rejects(
new Error('database unavailable')
);
await t.throwsAsync(t.context.policy.hasServerEntitlement('workspace'), {
message: 'database unavailable',
});
});

View File

@@ -0,0 +1,531 @@
import { Readable } from 'node:stream';
import ava from 'ava';
import Sinon from 'sinon';
import { z } from 'zod';
import { Config, CopilotQuotaExceeded } from '../../base';
import type { BackendRuntimeProvider } from '../../core/backend-runtime';
import { StorageRuntimeProvider } from '../../core/storage-runtime';
import type { Models } from '../../models';
import type { ByokEntitlementPolicy } from '../../plugins/copilot/byok';
import type { ConversationPolicy } from '../../plugins/copilot/conversation/policy';
import { CapabilityRuntime } from '../../plugins/copilot/runtime/capability-runtime';
import { CopilotRuntimeEventConsumer } from '../../plugins/copilot/runtime/copilot-runtime-event-consumer';
import { AttachmentAdmissionHost } from '../../plugins/copilot/runtime/hosts/attachment-admission';
import { AttachmentMaterializer } from '../../plugins/copilot/runtime/hosts/attachment-materializer';
import { executeToolCall } from '../../plugins/copilot/runtime/tool/bridge';
import type { ToolRuntime } from '../../plugins/copilot/runtime/tool-runtime';
import { CopilotStorage } from '../../plugins/copilot/storage';
const test = ava;
async function collect<T>(source: AsyncIterable<T>) {
const values: T[] = [];
for await (const value of source) values.push(value);
return values;
}
function runtimeFixture(streamError?: string, enabled = true) {
const calls: Array<{
slot: string;
request?: unknown;
targetOverride?: { profileId: string; modelId: string };
}> = [];
const backend = {
executeCopilot: async (input: {
slot: string;
request: unknown;
targetOverride?: { profileId: string; modelId: string };
}) => {
calls.push(input);
if (input.slot === 'index.embedding')
return { events: [], result: { embeddings: [[1, 2]] } };
if (input.slot === 'search.rerank')
return { events: [], result: { scores: [0.9] } };
if (input.slot === 'prompt.structured')
return {
events: [],
result: {
output_json: { text: 'hello' },
output_text: '{"text":"hello"}',
},
};
if (
input.slot === 'image.generate' ||
input.slot === 'action.image.filter.sketch'
) {
return {
events: [],
result: {
images: [
{ url: 'https://example.com/image.png', media_type: 'image/png' },
],
},
};
}
throw new Error(`unexpected slot ${input.slot}`);
},
streamCopilot: (input: {
slot: string;
request: unknown;
targetOverride?: { profileId: string; modelId: string };
}) => {
calls.push(input);
async function* events() {
if (streamError) {
yield { type: 'error', message: streamError };
return;
}
yield { type: 'message_start', model: 'opaque/model' };
yield { type: 'text_delta', text: 'hello' };
yield { type: 'done', finish_reason: 'stop' };
}
return events();
},
assertCopilotRoute: async () => {
throw new Error('access_unavailable');
},
} as unknown as BackendRuntimeProvider;
const entitlement = {
hasServerEntitlement: async () => true,
hasLocalEntitlement: async () => true,
hasAiPlan: async () => false,
} as unknown as ByokEntitlementPolicy;
const conversation = {
hasQuota: async () => true,
} as unknown as ConversationPolicy;
const tools = { getTools: async () => ({}) } as unknown as ToolRuntime;
const consumer = {
consume: async () => {},
} as unknown as CopilotRuntimeEventConsumer;
const config = { copilot: { enabled } } as Config;
const storageRuntime = Sinon.createStubInstance(StorageRuntimeProvider);
storageRuntime.getObject.callsFake(async () => ({
body: Readable.from(Buffer.from('image')),
metadata: {
contentType: 'image/png',
contentLength: 5,
lastModified: new Date(0),
},
}));
const storage = new CopilotStorage(storageRuntime);
const materializer = Sinon.createStubInstance(AttachmentMaterializer);
materializer.fetchRemoteAttachment.resolves({
data: 'aW1hZ2U=',
mimeType: 'image/png',
});
return {
calls,
storageRuntime,
storage,
materializer,
runtime: new CapabilityRuntime(
backend,
entitlement,
conversation,
tools,
consumer,
config,
new AttachmentAdmissionHost(materializer, storage)
),
};
}
test('disabled copilot rejects native execution before route access', async t => {
const { runtime, calls, materializer } = runtimeFixture(undefined, false);
t.false(await runtime.embeddingConfigured('ignored'));
await t.throwsAsync(runtime.embed('ignored', ['text']), {
message: 'Copilot is disabled.',
});
await t.throwsAsync(
async () =>
await collect(
runtime.streamText({}, [
{
role: 'user',
content: 'hello',
attachments: ['https://example.com/image.png'],
},
])
),
{
message: 'Copilot is disabled.',
}
);
t.false(materializer.fetchRemoteAttachment.called);
t.deepEqual(calls, []);
});
test('all operation kinds enter the native slot pipeline', async t => {
const { runtime, calls } = runtimeFixture();
t.deepEqual(await runtime.embed('ignored', ['text']), [[1, 2]]);
t.deepEqual(
await runtime.rerank('ignored', {
query: 'query',
candidates: [{ id: 'one', text: 'text' }],
}),
[0.9]
);
t.deepEqual(
await collect(
runtime.streamImageArtifacts(
{},
[{ role: 'user', content: 'draw' }],
{},
undefined,
'action.image.filter.sketch'
)
),
[{ url: 'https://example.com/image.png', media_type: 'image/png' }]
);
t.deepEqual(
calls.map(call => call.slot),
['index.embedding', 'search.rerank', 'action.image.filter.sketch']
);
});
test('image request builder receives serializable options and materialized attachments', async t => {
const { runtime, calls, storage, storageRuntime, materializer } =
runtimeFixture();
const controller = new AbortController();
await collect(
runtime.streamImageArtifacts({}, [{ role: 'user', content: 'draw' }], {
quality: 'high',
seed: 42,
modelName: 'stabilityai/stable-diffusion-xl-base-1.0',
loras: [{ path: 'https://example.com/sketch.safetensors', scale: 1 }],
signal: controller.signal,
user: 'user-1',
})
);
t.deepEqual(calls[0].request, {
model: 'route-selected',
prompt: 'draw',
operation: 'generate',
options: {
quality: 'high',
outputFormat: 'webp',
seed: 42,
},
providerOptions: {
provider: 'fal',
options: {
model_name: 'stabilityai/stable-diffusion-xl-base-1.0',
loras: [{ path: 'https://example.com/sketch.safetensors', scale: 1 }],
},
},
});
const locator = storage.sessionAttachmentUrl(
'session-1',
'workspace-1',
'image'
);
for (const attachment of [
locator,
{ attachment: locator, mimeType: 'image/png' },
{ kind: 'url' as const, url: locator },
'https://example.com/input.png',
]) {
await collect(
runtime.streamImageArtifacts(
{},
[{ role: 'user', content: 'edit', attachments: [attachment] }],
{
user: 'user-1',
workspace: 'workspace-1',
session: 'session-1',
}
)
);
t.like(calls.at(-1)?.request, {
operation: 'edit',
images: [
{
kind: 'bytes',
data: [...Buffer.from('image')],
mediaType: 'image/png',
},
],
});
}
t.is(storageRuntime.getObject.callCount, 3);
t.true(
storageRuntime.getObject.alwaysCalledWithExactly(
'copilot',
'user-1/workspace-1/image'
)
);
t.true(materializer.fetchRemoteAttachment.calledOnce);
await t.throwsAsync(
async () =>
await collect(
runtime.streamImageArtifacts(
{},
[{ role: 'user', content: 'edit', attachments: [locator] }],
{
user: 'user-1',
workspace: 'workspace-2',
session: 'session-1',
}
)
),
{ message: 'Copilot attachment scope mismatch' }
);
t.is(calls.length, 5);
});
test('chat and structured requests materialize attachments and consume native results', async t => {
const { runtime, calls, storage } = runtimeFixture();
const chunks = await collect(
runtime.streamText({ profileId: 'profile-1', modelId: 'vendor/model:B' }, [
{ role: 'user', content: 'hello' },
])
);
t.is(chunks.join(''), 'hello');
t.is(calls[0].slot, 'chat.default');
t.deepEqual(calls[0].targetOverride, {
profileId: 'profile-1',
modelId: 'vendor/model:B',
});
const messages = [
{
role: 'user' as const,
content: 'hello',
attachments: [
storage.sessionAttachmentUrl('session-1', 'workspace-1', 'image'),
],
},
];
const options = {
user: 'user-1',
workspace: 'workspace-1',
session: 'session-1',
};
await collect(runtime.streamText({}, messages, options));
await collect(runtime.streamObject({}, messages, options));
await runtime.generateStructured({}, messages, options, undefined, {
responseSchemaJson: {
type: 'object',
properties: { text: { type: 'string' } },
required: ['text'],
},
schemaHash: 'test',
});
for (const call of calls.slice(1)) {
t.like(call.request, {
messages: [
{
role: 'user',
content: [
{ type: 'text', text: 'hello' },
{
type: 'image',
source: { media_type: 'image/png', data: 'aW1hZ2U=' },
},
],
},
],
});
t.false(JSON.stringify(call.request).includes('/api/copilot/'));
}
t.true(messages[0].attachments[0].startsWith('/api/copilot/'));
await t.throwsAsync(runtime.assertRoute('chat.default', {}, {}), {
instanceOf: CopilotQuotaExceeded,
});
const denied = runtimeFixture('access_unavailable').runtime;
await t.throwsAsync(
async () =>
await collect(
denied.streamText({}, [{ role: 'user', content: 'denied' }])
),
{ instanceOf: CopilotQuotaExceeded }
);
});
test('product event consumer records route activity and real usage', async t => {
const records: unknown[] = [];
const activity: string[] = [];
const failures: string[] = [];
const models = {
copilotUsage: { create: async (value: unknown) => records.push(value) },
copilotWorkspaceByokConfig: {
touchUsed: async (_workspaceId: string, profileId: string) =>
activity.push(profileId),
markFailure: async (
_workspaceId: string,
_profileId: string,
errorKind: string
) => failures.push(errorKind),
},
} as unknown as Models;
const consumer = new CopilotRuntimeEventConsumer(models);
const route = {
profileId: 'profile-1',
source: 'server' as const,
provider: 'openai',
model: 'opaque/model:B',
};
await consumer.consume(
[
{
type: 'usage',
route,
usage: { input_tokens: 3, output_tokens: 2, total_tokens: 5 },
},
{ type: 'route_selected', route },
],
{ workspaceId: 'workspace-1', featureKind: 'chat' }
);
t.like(records[0], {
workspaceId: 'workspace-1',
provider: 'openai',
providerSource: 'byok_server',
model: 'opaque/model:B',
promptTokens: 3,
completionTokens: 2,
totalTokens: 5,
});
t.deepEqual(activity, ['profile-1']);
await consumer.consume(
[{ type: 'route_selected', route: { ...route, profileId: 'profile-2' } }],
{ workspaceId: 'workspace-1', featureKind: 'chat' }
);
t.is(records.length, 1);
t.deepEqual(activity, ['profile-1', 'profile-2']);
await consumer.consume(
[
{
type: 'usage',
route: { ...route, source: 'local', profileId: 'local-1' },
usage: {
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
cached_tokens: 0,
},
},
{
type: 'route_selected',
route: { ...route, source: 'local', profileId: 'local-1' },
},
{
type: 'route_selected',
route: { ...route, source: 'affine_cloud', profileId: 'managed-1' },
},
{ type: 'route_failed', route, errorKind: 'upstream_error' },
],
{ workspaceId: 'workspace-1', featureKind: 'chat' }
);
t.like(records[1], {
providerSource: 'byok_local',
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
cachedTokens: 0,
});
t.deepEqual(activity, ['profile-1', 'profile-2']);
t.deepEqual(failures, ['upstream_error']);
});
test('tool callback validates arguments and preserves call identity', async t => {
const result = await executeToolCall(
{
echo: {
description: 'echo',
inputSchema: z.object({ value: z.string() }),
execute: async ({ value }) => ({ value }),
},
},
{ callId: 'call-1', name: 'echo', args: { value: 'ok' } },
{}
);
t.deepEqual(result, {
callId: 'call-1',
name: 'echo',
args: { value: 'ok' },
rawArgumentsText: undefined,
argumentParseError: undefined,
output: { value: 'ok' },
});
});
test('tool callback reports missing tools and invalid argument JSON', async t => {
const missing = await executeToolCall(
{},
{ callId: 'call-1', name: 'missing', args: {} },
{}
);
const invalid = await executeToolCall(
{},
{
callId: 'call-2',
name: 'missing',
args: {},
rawArgumentsText: '{',
argumentParseError: 'unexpected end',
},
{}
);
t.true(missing.isError);
t.true(invalid.isError);
t.deepEqual(invalid.output, {
message: 'Invalid tool arguments JSON',
rawArguments: '{',
error: 'unexpected end',
});
});
test('tool callback rejects invalid zod args without execution', async t => {
let executed = false;
const result = await executeToolCall(
{
echo: {
description: 'echo',
inputSchema: z.object({ value: z.string().trim() }),
execute: async () => {
executed = true;
},
},
},
{
callId: 'call-1',
name: 'echo',
args: { value: 42 },
rawArgumentsText: '{"value":42}',
},
{}
);
t.true(result.isError);
t.false(executed);
});
test('tool callback passes transformed args without prototype pollution', async t => {
const received: unknown[] = [];
const result = await executeToolCall(
{
echo: {
description: 'echo',
inputSchema: z.object({ value: z.string().trim() }).passthrough(),
execute: async args => received.push(args),
},
},
{
callId: 'call-1',
name: 'echo',
args: JSON.parse('{"value":" AFFiNE ","__proto__":{"polluted":true}}'),
},
{}
);
t.false(result.isError ?? false);
t.deepEqual(received, [{ value: 'AFFiNE' }]);
t.is((Object.prototype as Record<string, unknown>).polluted, undefined);
});

View File

@@ -0,0 +1,46 @@
import test from 'ava';
import { CitationFootnoteFormatter } from '../../plugins/copilot/providers/utils';
test('CitationFootnoteFormatter should format sorted footnotes from citation events', t => {
const formatter = new CitationFootnoteFormatter();
formatter.consume({
type: 'citation',
index: 2,
url: 'https://example.com/b',
});
formatter.consume({
type: 'citation',
index: 1,
url: 'https://example.com/a',
});
t.is(
formatter.end(),
[
'[^1]: {"type":"url","url":"https%3A%2F%2Fexample.com%2Fa"}',
'[^2]: {"type":"url","url":"https%3A%2F%2Fexample.com%2Fb"}',
].join('\n')
);
});
test('CitationFootnoteFormatter should overwrite duplicated index with latest url', t => {
const formatter = new CitationFootnoteFormatter();
formatter.consume({
type: 'citation',
index: 1,
url: 'https://example.com/old',
});
formatter.consume({
type: 'citation',
index: 1,
url: 'https://example.com/new',
});
t.is(
formatter.end(),
'[^1]: {"type":"url","url":"https%3A%2F%2Fexample.com%2Fnew"}'
);
});

View File

@@ -0,0 +1,424 @@
import '../../plugins/copilot/runtime/capability-runtime';
import ava from 'ava';
import {
CopilotMessageNotFound,
CopilotSessionNotFound,
type Mutex,
} from '../../base';
import type { CompatSubmissionStore } from '../../plugins/copilot/compat/submission-store';
import type { ConversationPolicy } from '../../plugins/copilot/conversation/policy';
import type { Turn } from '../../plugins/copilot/core';
import type { AdmittedAttachmentSource } from '../../plugins/copilot/runtime/hosts/attachment-admission';
import { ConversationHost } from '../../plugins/copilot/runtime/hosts/conversation-host';
import {
ChatSession,
type ChatSessionService,
} from '../../plugins/copilot/session';
const test = ava;
function fixture(
options: {
failFirstAcceptedWrite?: boolean;
failFirstAppend?: boolean;
mode?: 'canonical' | 'personal';
admittedAttachments?: AdmittedAttachmentSource[];
revokeDuringAdmission?: boolean;
promptAction?: string;
} = {}
) {
const sessionId = 'session-1';
const token = 'submission-1';
const durable = new Map<string, Turn>();
const accepted = new Map<
string,
{ userId: string; sessionId: string; turnId: string }
>();
const submissions = new Map([
[
token,
{
id: token,
userId: 'user-1',
workspaceId: 'workspace-1',
sessionId,
content: 'hello',
attachments: options.admittedAttachments?.length
? ['data:text/plain;base64,aGVsbG8=']
: [],
params: {
tone: 'brief',
scopeSelectors: [{ kind: 'document', id: 'doc-2' }],
},
createdAt: new Date('2026-01-01T00:00:00.000Z'),
},
],
]);
let appendCount = 0;
let quota = true;
let accessAllowed = true;
let acceptedWriteCount = 0;
let artifactWrites = 0;
const titleCalls: unknown[] = [];
const chatSession = new ChatSession(
{
sessionId,
userId: 'user-1',
workspaceId: 'workspace-1',
docId: 'doc-1',
focus: { selectors: [] },
prompt: {
name: 'Chat With AFFiNE AI',
action: options.promptAction,
config: {},
paramKeys: [],
params: {},
},
turns: [],
},
() => []
);
const sessions = {
getOwnedScope: async (id: string, userId: string) =>
id === sessionId && userId === 'user-1'
? { workspaceId: 'workspace-1', docId: 'doc-1' }
: undefined,
getInScope: async (input: { sessionId: string; userId: string }) =>
input.sessionId === sessionId && input.userId === 'user-1'
? chatSession
: undefined,
findTurnByCompatSubmissionId: async (
_sessionId: string,
_userId: string,
_workspaceId: string,
id: string
) => durable.get(id),
appendTurn: async (input: { compatSubmissionId: string; turn: Turn }) => {
appendCount += 1;
if (options.failFirstAppend && appendCount === 1) {
throw new Error('durable append failed');
}
const stored = { ...input.turn, id: `turn-${appendCount}` };
durable.set(input.compatSubmissionId, stored);
return stored;
},
getMessage: async (
_sessionId: string,
_userId: string,
_workspaceId: string,
turnId: string
) => [...durable.values()].find(turn => turn.id === turnId),
revertLatestMessage: async () => {},
generateSessionTitle: async (input: unknown) => {
titleCalls.push(input);
},
} as unknown as ChatSessionService;
const submissionStore = {
get: async (id: string, userId: string) => {
const value = submissions.get(id);
return value?.userId === userId ? value : undefined;
},
getAccepted: async (id: string, userId: string) => {
const value = accepted.get(id);
return value?.userId === userId
? { ...value, acceptedAt: new Date('2026-01-01T00:00:00.000Z') }
: undefined;
},
markAccepted: async (
id: string,
userId: string,
value: { sessionId: string; turnId: string }
) => {
acceptedWriteCount += 1;
if (options.failFirstAcceptedWrite && acceptedWriteCount === 1) {
throw new Error('accepted cache write failed');
}
accepted.set(id, { ...value, userId });
submissions.delete(id);
},
} as unknown as CompatSubmissionStore;
const mutex = {
acquire: async () => ({ async [Symbol.asyncDispose]() {} }),
} as unknown as Mutex;
const policy = {
hasQuota: async () => quota,
shouldScheduleTitle: (prompt: { action?: string }) => !prompt.action,
} as unknown as ConversationPolicy;
const runtime = {
putWorkspaceArtifact: async () => {
artifactWrites++;
return {
id: 'artifact-1',
canonicalMediaType: 'text/plain',
};
},
compileTurnScope: async (input: {
selectors: unknown[];
preferredSourceIds?: string[];
}) => ({
version: 1,
resolvedAt: '2026-01-01T00:00:00.000Z',
selectors: input.selectors,
requiredDocIds: [],
requiredArtifactIds: [],
preferredSourceIds: input.preferredSourceIds ?? [],
retrieval: {
mode: input.selectors.length ? 'required' : 'workspace',
requiredDocIds: [],
requiredArtifactIds: [],
preferredSourceIds: input.preferredSourceIds ?? [],
},
}),
};
const attachmentAdmission = {
admitPromptAttachments: async () => {
if (options.revokeDuringAdmission) accessAllowed = false;
return options.admittedAttachments ?? [];
},
};
return {
host: new ConversationHost(
sessions,
submissionStore,
mutex,
policy,
runtime as never,
attachmentAdmission as never,
{
sessionResource: async () => {
if (!accessAllowed) throw new Error('permission denied');
return options.mode ?? 'canonical';
},
} as never
),
sessionId,
token,
durable,
accepted,
submissions,
session: chatSession,
appendCount: () => appendCount,
artifactWrites: () => artifactWrites,
titleCalls,
setQuota: (value: boolean) => {
quota = value;
},
revokeAccess: () => {
accessAllowed = false;
},
};
}
test('compat submission becomes one durable user turn and replays idempotently', async t => {
const state = fixture();
const first = await state.host.prepareTurn('user-1', state.sessionId, {
messageId: state.token,
});
t.is(first.latestTurn?.content, 'hello');
t.deepEqual(first.latestTurn?.metadata, { tone: 'brief' });
t.deepEqual(first.latestTurn?.scopeSnapshot?.selectors, [
{ kind: 'document', id: 'doc-2', source: 'draft' },
]);
t.is(state.appendCount(), 1);
t.false(state.submissions.has(state.token));
t.truthy(state.accepted.get(state.token));
state.setQuota(false);
const replay = await state.host.prepareTurn('user-1', state.sessionId, {
messageId: state.token,
});
t.is(replay.latestTurn?.id, first.latestTurn?.id);
t.true(replay.quotaBackedRoutesAllowed);
t.is(state.appendCount(), 1);
});
test('durable compat turn recovers after accepted-cache write failure', async t => {
const state = fixture({ failFirstAcceptedWrite: true });
await t.throwsAsync(
state.host.prepareTurn('user-1', state.sessionId, {
messageId: state.token,
}),
{ message: 'accepted cache write failed' }
);
t.is(state.appendCount(), 1);
t.truthy(state.durable.get(state.token));
const recovered = await state.host.prepareTurn('user-1', state.sessionId, {
messageId: state.token,
});
t.is(recovered.latestTurn?.id, state.durable.get(state.token)?.id);
t.is(state.appendCount(), 1);
t.truthy(state.accepted.get(state.token));
});
test('compat submission remains retryable when durable append fails', async t => {
const state = fixture({ failFirstAppend: true });
await t.throwsAsync(
state.host.prepareTurn('user-1', state.sessionId, {
messageId: state.token,
}),
{ message: 'durable append failed' }
);
t.true(state.submissions.has(state.token));
t.false(state.accepted.has(state.token));
const recovered = await state.host.prepareTurn('user-1', state.sessionId, {
messageId: state.token,
});
t.is(recovered.latestTurn?.content, 'hello');
t.is(state.durable.size, 1);
});
test('compat submission cannot be consumed by another session', async t => {
const state = fixture();
const other = fixture();
other.submissions.set(state.token, {
id: state.token,
userId: 'user-1',
workspaceId: 'workspace-1',
sessionId: 'session-other',
content: 'secret',
attachments: [],
params: { tone: 'brief', scopeSelectors: [] },
createdAt: new Date(),
});
await t.throwsAsync(
other.host.prepareTurn('user-1', other.sessionId, {
messageId: state.token,
}),
{ instanceOf: CopilotMessageNotFound }
);
await t.throwsAsync(
state.host.prepareTurn('user-2', state.sessionId, {
messageId: state.token,
}),
{ instanceOf: CopilotSessionNotFound }
);
t.is(other.appendCount(), 0);
});
test('direct conversation rejects revoked canonical access before durable append', async t => {
const state = fixture();
state.revokeAccess();
await t.throwsAsync(
state.host.prepareTurn('user-1', state.sessionId, {
messageId: state.token,
}),
{ message: 'permission denied' }
);
t.is(state.appendCount(), 0);
t.true(state.submissions.has(state.token));
});
test('workspace context fails closed for personal scope and revoked canonical access', async t => {
const personal = fixture({ mode: 'personal' });
const personalError = await t.throwsAsync(
personal.host.prepareTurn('user-1', personal.sessionId, {
messageId: personal.token,
})
);
const canonical = fixture({
admittedAttachments: [
{
id: 'attachment-1',
kind: 'bytes',
mimeType: 'text/plain',
size: 5,
fileName: 'note.txt',
hash: 'hash-1',
data: 'aGVsbG8=',
encoding: 'base64',
},
],
revokeDuringAdmission: true,
});
const canonicalError = await t.throwsAsync(
canonical.host.prepareTurn('user-1', canonical.sessionId, {
messageId: canonical.token,
})
);
t.snapshot({
personal: {
error: personalError?.message,
artifactWrites: personal.artifactWrites(),
durableAppends: personal.appendCount(),
},
canonical: {
error: canonicalError?.message,
artifactWrites: canonical.artifactWrites(),
durableAppends: canonical.appendCount(),
},
});
});
test('assistant persistence rechecks access and schedules eligible titles', async t => {
const state = fixture();
state.revokeAccess();
await t.throwsAsync(
state.host.persistAssistantTurn(
state.session,
{
conversationId: state.session.config.sessionId,
role: 'assistant',
content: 'must not persist',
attachments: [],
metadata: {},
renderTrace: [],
toolEvents: [],
createdAt: new Date(),
},
false
),
{ message: 'permission denied' }
);
t.is(state.appendCount(), 0);
const eligible = fixture();
const assistantTurn = {
conversationId: eligible.sessionId,
role: 'assistant' as const,
content: 'answer',
attachments: [],
metadata: {},
renderTrace: [],
toolEvents: [],
createdAt: new Date(),
};
await eligible.host.persistAssistantTurn(
eligible.session,
assistantTurn,
false
);
await eligible.host.persistAssistantTurn(
eligible.session,
{ ...assistantTurn, content: 'aborted' },
true
);
const action = fixture({ promptAction: 'edit' });
await action.host.persistAssistantTurn(
action.session,
{ ...assistantTurn, conversationId: action.sessionId },
false
);
t.deepEqual(eligible.titleCalls, [
{
sessionId: 'session-1',
userId: 'user-1',
workspaceId: 'workspace-1',
},
]);
t.deepEqual(action.titleCalls, []);
});

View File

@@ -0,0 +1,240 @@
import { randomUUID } from 'node:crypto';
import type { TestFn } from 'ava';
import ava from 'ava';
import { Models } from '../../models';
import { CapabilityRuntime } from '../../plugins/copilot/runtime/capability-runtime';
import { CopilotTranscriptionService } from '../../plugins/copilot/transcript';
import { TranscriptPayloadSchema } from '../../plugins/copilot/transcript/schema';
import { createTestingApp, createWorkspace, type TestingApp } from '../utils';
import {
chatWithActionStream,
chatWithImages,
chatWithStreamObject,
chatWithText,
createCopilotMessage,
createCopilotSession,
sse2array,
} from '../utils/copilot';
type Context = {
app: TestingApp;
models: Models;
runtime: CapabilityRuntime;
transcript: CopilotTranscriptionService;
};
const test = ava.serial as TestFn<Context>;
const providerTest =
process.env.AFFINE_TEST_COPILOT_PROVIDER === 'true' ? test : test.skip;
test.before(async t => {
const app = await createTestingApp();
t.context = {
app,
models: app.get(Models),
runtime: app.get(CapabilityRuntime),
transcript: app.get(CopilotTranscriptionService),
};
});
test.beforeEach(async t => {
await t.context.app.initTestingDB();
});
test.after.always(async t => {
await t.context.app?.close();
});
async function assertManagedRoute(
runtime: CapabilityRuntime,
slot: string,
builtInRouteId?: string
) {
await runtime.assertRoute(
slot,
{},
{
builtInRouteId,
quotaBackedRoutesAllowed: true,
}
);
}
providerTest(
'managed text and object routes satisfy the public SSE contract',
async t => {
const { app, runtime } = t.context;
await assertManagedRoute(runtime, 'chat.default', 'Chat With AFFiNE AI');
await app.signupV1();
const workspace = await createWorkspace(app);
const textSession = await createCopilotSession(
app,
workspace.id,
randomUUID(),
'Chat With AFFiNE AI'
);
const textToken = await createCopilotMessage(
app,
textSession,
'Explain AFFiNE in one sentence.'
);
t.truthy((await chatWithText(app, textSession, textToken)).trim());
const objectSession = await createCopilotSession(
app,
workspace.id,
randomUUID(),
'Chat With AFFiNE AI'
);
const objectToken = await createCopilotMessage(
app,
objectSession,
'Explain AFFiNE in one sentence.'
);
const events = sse2array(
await chatWithStreamObject(app, objectSession, objectToken)
);
t.false(events.some(event => event.event === 'error'));
t.true(events.some(event => event.event === 'message'));
}
);
providerTest(
'managed embedding and rerank routes expose final result shapes',
async t => {
const { app, runtime } = t.context;
await assertManagedRoute(runtime, 'index.embedding');
await assertManagedRoute(runtime, 'search.rerank');
const user = await app.signupV1();
const workspace = await createWorkspace(app);
const options = { user: user.id, workspace: workspace.id };
const embeddings = await runtime.embed('route-selected', ['AFFiNE'], {
...options,
featureKind: 'embedding',
});
t.is(embeddings.length, 1);
t.true(embeddings[0].length > 0);
const scores = await runtime.rerank(
'route-selected',
{
query: 'collaborative editor',
candidates: [
{ id: 'relevant', text: 'AFFiNE is a collaborative editor.' },
{ id: 'irrelevant', text: 'A recipe for apple pie.' },
],
},
{ ...options, featureKind: 'rerank' }
);
t.is(scores.length, 2);
t.true(scores.every(score => Number.isFinite(score)));
}
);
providerTest(
'managed image and action routes preserve public event contracts',
async t => {
const { app, runtime } = t.context;
await assertManagedRoute(runtime, 'image.generate', 'Generate image');
await assertManagedRoute(
runtime,
'action.mindmap.generate',
'mindmap.generate'
);
await app.signupV1();
const workspace = await createWorkspace(app);
const imageSession = await createCopilotSession(
app,
workspace.id,
randomUUID(),
'Generate image'
);
const imageToken = await createCopilotMessage(
app,
imageSession,
'A simple panda icon.'
);
const imageEvents = sse2array(
await chatWithImages(app, imageSession, imageToken)
);
t.false(imageEvents.some(event => event.event === 'error'));
t.true(imageEvents.some(event => event.event === 'attachment'));
const actionSession = await createCopilotSession(
app,
workspace.id,
randomUUID(),
'mindmap.generate'
);
const actionToken = await createCopilotMessage(
app,
actionSession,
'AFFiNE product architecture'
);
const actionEvents = sse2array(
await chatWithActionStream(app, actionSession, {
actionId: 'mindmap.generate',
messageId: actionToken,
})
);
t.false(actionEvents.some(event => event.event === 'error'));
t.true(
actionEvents.some(
event =>
event.event === 'message' || event.data?.includes('action_done')
)
);
}
);
providerTest(
'managed transcript route executes the provider-neutral job port',
async t => {
const { models, runtime, transcript } = t.context;
await assertManagedRoute(runtime, 'transcript.audio', 'Transcript audio');
const user = await models.user.create({
email: `copilot-provider-transcript-${randomUUID()}@affine.pro`,
});
const workspace = await models.workspace.create(user.id);
const blobId = `copilot-provider-transcript-${randomUUID()}`;
const payload = TranscriptPayloadSchema.parse({
sourceAudio: { blobId, mimeType: 'audio/mpeg' },
infos: [
{
url: 'https://cdn.affine.pro/copilot-test/MP9qDGuYgnY+ILoEAmHpp3h9Npuw2403EAYMEA.mp3',
mimeType: 'audio/mpeg',
index: 0,
},
],
});
const task = await models.copilotTranscriptTask.create({
userId: user.id,
workspaceId: workspace.id,
blobId,
recipeId: 'transcript.audio',
recipeVersion: 'v1',
dispatchGeneration: 'provider-test-generation',
inputSnapshot: payload,
publicMeta: { sourceAudio: payload.sourceAudio, infos: payload.infos },
});
await transcript.transcriptTask({
taskId: task.id,
payload,
generation: 'provider-test-generation',
scopeMode: 'canonical',
});
const ready = await models.copilotTranscriptTask.get(task.id);
t.is(ready?.status, 'ready');
t.is(
typeof TranscriptPayloadSchema.parse(ready?.protectedResult)
.normalizedTranscript,
'string'
);
}
);

View File

@@ -0,0 +1,319 @@
import '../../plugins/copilot';
import { createHash, randomUUID } from 'node:crypto';
import { createCopilotMessageMutation } from '@affine/graphql';
import { McpAccessMode, PrismaClient } from '@prisma/client';
import type { TestFn } from 'ava';
import ava from 'ava';
import { Config } from '../../base';
import { ServerFeature, ServerService } from '../../core';
import { Models } from '../../models';
import { CopilotFeatureService } from '../../plugins/copilot/feature';
import { McpCredentialService } from '../../plugins/copilot/mcp/credential';
import { WorkspaceMcpProvider } from '../../plugins/copilot/mcp/provider';
import { installMockCopilotRuntime } from '../mocks';
import { createTestingApp, createWorkspace, type TestingApp } from '../utils';
import {
chatWithImages,
chatWithText,
createCopilotMessage,
createCopilotSession,
getCopilotSession,
getHistories,
sse2array,
} from '../utils/copilot';
type Context = {
app: TestingApp;
restoreRuntime: () => void;
};
const test = ava.serial as TestFn<Context>;
test.before(async t => {
const restoreRuntime = installMockCopilotRuntime();
t.context = {
app: await createTestingApp(),
restoreRuntime,
};
});
test.beforeEach(async t => {
await t.context.app.initTestingDB();
});
test.after.always(async t => {
t.context.restoreRuntime?.();
await t.context.app?.close();
});
test('disabled copilot hides its server feature and rejects every API transport', async t => {
const { app } = t.context;
const config = app.get(Config);
const feature = app.get(CopilotFeatureService);
const server = app.get(ServerService);
await app.signupV1();
const workspace = await createWorkspace(app);
config.copilot.enabled = false;
feature.onConfigChanged({ updates: { copilot: { enabled: false } } });
try {
t.false(server.features.includes(ServerFeature.Copilot));
await t.throwsAsync(
createCopilotSession(
app,
workspace.id,
randomUUID(),
'Chat With AFFiNE AI'
)
);
await app.GET('/api/copilot/unsplash/photos').expect(403);
await app
.POST(`/api/workspaces/${workspace.id}/mcp`)
.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
.expect(403);
} finally {
config.copilot.enabled = true;
feature.onConfigChanged({ updates: { copilot: { enabled: true } } });
}
});
test('session, message, local context restriction and durable history share one public contract', async t => {
const { app } = t.context;
await app.signupV1();
const workspace = await createWorkspace(app);
const docId = randomUUID();
const sessionId = await createCopilotSession(
app,
workspace.id,
docId,
'Chat With AFFiNE AI'
);
t.deepEqual(await getCopilotSession(app, workspace.id, sessionId), {
id: sessionId,
docId,
parentSessionId: null,
pinned: false,
promptName: 'Chat With AFFiNE AI',
});
const token = await createCopilotMessage(app, sessionId, 'hello');
const [beforeStream] = await getHistories(app, {
workspaceId: workspace.id,
docId,
});
t.is(beforeStream.sessionId, sessionId);
t.deepEqual(beforeStream.messages, []);
t.is(
await chatWithText(app, sessionId, token),
'generate text to text stream'
);
t.is(
await chatWithText(app, sessionId, token),
'generate text to text stream'
);
const [history] = await getHistories(app, {
workspaceId: workspace.id,
docId,
});
t.deepEqual(
history.messages.map(message => [message.role, message.content]),
[
['user', 'hello'],
['assistant', 'generate text to text stream'],
['assistant', 'generate text to text stream'],
]
);
t.is(history.messages.filter(message => message.role === 'user').length, 1);
t.not(history.messages[0].id, token);
const attachment = Buffer.from('canonical attachment');
const attachmentKey = createHash('sha256')
.update(attachment)
.digest('base64url');
const attachmentPath = `/api/copilot/chat/${sessionId}/attachments/${attachmentKey}?workspaceId=${workspace.id}&mimeType=text%2Fplain&fileName=note.txt`;
const attachmentUpload = await app
.PUT(attachmentPath)
.set('content-type', 'application/octet-stream')
.send(attachment)
.expect(200);
const attachmentRead = await app.GET(attachmentUpload.body.url).expect(200);
t.snapshot({
upload: {
url: attachmentUpload.body.url
.replace(sessionId, ':session')
.replace(workspace.id, ':workspace'),
},
read: {
body: attachmentRead.text,
contentType: attachmentRead.headers['content-type'],
contentLength: attachmentRead.headers['content-length'],
cacheControl: attachmentRead.headers['cache-control'],
},
});
const localWorkspaceId = randomUUID();
const localSessionId = await createCopilotSession(
app,
localWorkspaceId,
null,
'Chat With AFFiNE AI'
);
t.truthy(await createCopilotMessage(app, localSessionId, 'local hello'));
const localContextResponse = await app
.POST('/graphql')
.set('x-operation-name', createCopilotMessageMutation.op)
.send({
query: createCopilotMessageMutation.query,
variables: {
options: {
sessionId: localSessionId,
content: 'local context',
params: {
scopeSelectors: [{ kind: 'document', id: randomUUID() }],
},
},
},
})
.expect(200);
t.is(
localContextResponse.body.errors?.[0]?.message,
"Local workspaces don't support attachments or references."
);
await t.throwsAsync(
app.gql({
query: createCopilotMessageMutation,
variables: {
options: {
sessionId: localSessionId,
content: 'local attachment',
blobs: [new File(['attachment'], 'attachment.txt')],
},
},
}),
{
message: "Local workspaces don't support attachments or references.",
}
);
const localAttachmentUpload = await app
.PUT(
`/api/copilot/chat/${localSessionId}/attachments/${attachmentKey}?workspaceId=${localWorkspaceId}&mimeType=text%2Fplain`
)
.set('content-type', 'application/octet-stream')
.send(attachment);
t.is(localAttachmentUpload.status, 400);
});
test('chat and history endpoints reject a different user', async t => {
const { app } = t.context;
const owner = await app.signupV1();
const workspace = await createWorkspace(app);
const sessionId = await createCopilotSession(
app,
workspace.id,
randomUUID(),
'Chat With AFFiNE AI'
);
const token = await createCopilotMessage(app, sessionId, 'private');
await app.signupV1();
await t.throwsAsync(chatWithText(app, sessionId, token));
await t.throwsAsync(getHistories(app, { workspaceId: workspace.id }));
await app.switchUser(owner);
t.is(
await chatWithText(app, sessionId, token),
'generate text to text stream'
);
});
test('image SSE emits persisted attachment events for action sessions', async t => {
const { app } = t.context;
await app.signupV1();
const workspace = await createWorkspace(app);
const sessionId = await createCopilotSession(
app,
workspace.id,
randomUUID(),
'Generate image'
);
const token = await createCopilotMessage(app, sessionId, 'Panda');
const events = sse2array(await chatWithImages(app, sessionId, token));
const attachment = events.find(event => event.event === 'attachment');
t.truthy(attachment?.data);
});
test('MCP credentials remain endpoint-bound through rotate, revoke and expiry', async t => {
const { app } = t.context;
const credentials = app.get(McpCredentialService);
const db = app.get(PrismaClient);
const models = app.get(Models);
const provider = app.get(WorkspaceMcpProvider);
const user = await models.user.create({
email: `mcp-${randomUUID()}@affine.pro`,
password: '123456',
});
const target = await models.workspace.create(user.id);
const other = await models.workspace.create(user.id);
const issued = await credentials.create({
userId: user.id,
workspaceId: target.id,
name: 'Claude Desktop',
accessMode: McpAccessMode.READ_ONLY,
expirationDays: 90,
});
t.like(await credentials.authenticate(issued.token, target.id), {
userId: user.id,
workspaceId: target.id,
accessMode: McpAccessMode.READ_ONLY,
});
await t.throwsAsync(credentials.authenticate(issued.token, other.id));
const response = await app
.POST(`/api/workspaces/${target.id}/mcp`)
.set('Authorization', `Bearer ${issued.token}`)
.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
.expect(200);
t.like(response.body, { jsonrpc: '2.0', id: 1 });
t.deepEqual(
(await provider.for(user.id, target.id, McpAccessMode.READ_ONLY)).tools.map(
tool => tool.name
),
['read_document', 'doc_search']
);
const rotated = await credentials.rotate(
issued.credential.id,
user.id,
target.id,
30
);
t.like((await credentials.list(user.id, target.id))[0], {
status: 'ROTATING',
});
await credentials.authenticate(issued.token, target.id);
await credentials.revoke(rotated.credential.id, user.id, target.id);
await t.throwsAsync(credentials.authenticate(issued.token, target.id));
await t.throwsAsync(credentials.authenticate(rotated.token, target.id));
const disabled = await credentials.create({
userId: user.id,
workspaceId: target.id,
name: 'Disabled user',
accessMode: McpAccessMode.READ_ONLY,
expirationDays: 30,
});
await db.user.update({ where: { id: user.id }, data: { disabled: true } });
await t.throwsAsync(credentials.authenticate(disabled.token, target.id));
await db.user.update({ where: { id: user.id }, data: { disabled: false } });
await db.mcpCredential.update({
where: { id: disabled.credential.id },
data: { expiresAt: new Date(0) },
});
await t.throwsAsync(credentials.authenticate(disabled.token, target.id));
});

View File

@@ -0,0 +1,73 @@
import type { LlmRequest } from '../../native';
import type { PromptMessage } from '../../plugins/copilot/providers/types';
function createPromptMessage(
role: PromptMessage['role'],
content: string,
extra: Omit<PromptMessage, 'role' | 'content'> = {}
): PromptMessage {
return {
role,
content,
...extra,
};
}
export function userPrompt(
content: string,
extra: Omit<PromptMessage, 'role' | 'content'> = {}
): PromptMessage {
return createPromptMessage('user', content, extra);
}
export function assistantPrompt(
content: string,
extra: Omit<PromptMessage, 'role' | 'content'> = {}
): PromptMessage {
return createPromptMessage('assistant', content, extra);
}
export function systemPrompt(
content: string,
extra: Omit<PromptMessage, 'role' | 'content'> = {}
): PromptMessage {
return createPromptMessage('system', content, extra);
}
export function promptMessages(...messages: PromptMessage[]) {
return messages;
}
export function singleUserPromptMessages(
content: string,
extra: Omit<PromptMessage, 'role' | 'content'> = {}
) {
return promptMessages(userPrompt(content, extra));
}
export function jsonOnlyPromptMessages(userContent: string) {
return promptMessages(
systemPrompt('Return JSON only.'),
userPrompt(userContent)
);
}
type NativeTextMessage = LlmRequest['messages'][number];
export function nativeUserText(text: string): NativeTextMessage {
return {
role: 'user',
content: [{ type: 'text', text }],
};
}
export function nativeAssistantText(text: string): NativeTextMessage {
return {
role: 'assistant',
content: [{ type: 'text', text }],
};
}
export function nativeMessages(...messages: NativeTextMessage[]) {
return messages;
}

View File

@@ -0,0 +1,26 @@
import ava from 'ava';
import { ChatQuerySchema } from '../../plugins/copilot/types';
const test = ava;
test('target override is all-or-nothing and preserves opaque model ids', t => {
const parsed = ChatQuerySchema.parse({
profileId: 'profile-1',
modelId: 'vendor/model:B',
});
t.is(parsed.profileId, 'profile-1');
t.is(parsed.modelId, 'vendor/model:B');
t.throws(() => ChatQuerySchema.parse({ profileId: 'profile-1' }));
t.throws(() => ChatQuerySchema.parse({ modelId: 'vendor/model:B' }));
t.is(
ChatQuerySchema.parse({ routeTargetId: 'terra' }).routeTargetId,
'terra'
);
});
test('caller supplied route policy facts are rejected', t => {
for (const field of ['requirements', 'deployment', 'profiles', 'presets']) {
t.throws(() => ChatQuerySchema.parse({ [field]: 'caller-value' }));
}
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,754 @@
import { AiJobStatus } from '@prisma/client';
import test from 'ava';
import Sinon from 'sinon';
import { AccessDenied } from '../../base';
import { buildLegacyProjection } from '../../plugins/copilot/transcript/projection';
import { CopilotTranscriptionRetryService } from '../../plugins/copilot/transcript/retry';
import { TranscriptPayloadSchema } from '../../plugins/copilot/transcript/schema';
import { CopilotTranscriptionService } from '../../plugins/copilot/transcript/service';
test('buildLegacyProjection backfills summary, actions and transcription', t => {
const legacy = buildLegacyProjection({
normalizedSegments: [
{
speaker: 'A',
startSec: 10,
endSec: 12,
start: '00:00:10',
end: '00:00:12',
text: 'Kickoff',
},
],
summaryJson: {
title: 'Weekly Sync',
durationMinutes: 30,
attendees: ['A', 'B'],
keyPoints: ['Reviewed launch status'],
actionItems: [
{
description: 'Send recap',
owner: 'A',
deadline: 'Friday',
},
],
decisions: ['Ship on Monday'],
openQuestions: ['Need final QA sign-off'],
blockers: ['Missing analytics dashboard'],
},
});
t.is(legacy.title, 'Weekly Sync');
t.true(legacy.summary?.includes('Reviewed launch status') ?? false);
t.true(legacy.summary?.includes('## Decisions') ?? false);
t.is(legacy.actions, '- [ ] Send recap (A · Friday)');
t.deepEqual(legacy.transcription, [
{
speaker: 'A',
start: '00:00:10',
end: '00:00:12',
transcription: 'Kickoff',
},
]);
});
test('TranscriptPayloadSchema rejects empty payloads', t => {
const emptyError = t.throws(() => TranscriptPayloadSchema.parse({}));
t.truthy(emptyError);
const unknownOnlyError = t.throws(() =>
TranscriptPayloadSchema.parse({ foo: 'bar' })
);
t.truthy(unknownOnlyError);
});
function createTranscriptPromptService() {
return {
get: Sinon.stub().callsFake(async name => ({ name })),
finish: Sinon.stub().callsFake((_prompt, params) => [
{
role: 'user',
content: params.content,
},
]),
};
}
function createSuccessfulTranscriptBridge(
runId: string,
bridgeInputs: unknown[]
) {
return {
runStream: (input: any, executor: (input: any) => Promise<any>) =>
(async function* () {
await input.onRunCreated?.({ runId, attempt: 1 });
const { result } = await executor(input);
bridgeInputs.push(input);
yield {
type: 'action_done' as const,
actionId: 'transcript.audio',
actionVersion: 'v1',
status: 'succeeded' as const,
runId,
result,
};
})(),
};
}
function createCopilotTranscriptionService(...deps: unknown[]) {
const retry = new CopilotTranscriptionRetryService(
deps[0] as never,
(deps[6] ?? { assertRoute: Sinon.stub().resolves() }) as never,
(deps[7] ?? { publish: Sinon.stub() }) as never
);
return new CopilotTranscriptionService(
deps[0] as never,
deps[2] as never,
deps[4] as never,
deps[5] as never,
(deps[6] ?? { assertRoute: Sinon.stub().resolves() }) as never,
(deps[7] ?? { publish: Sinon.stub() }) as never,
retry,
(deps[8] ?? {
transcriptResource: Sinon.stub().resolves('canonical'),
}) as never
);
}
test('queryTask hides ready transcript task result until settlement', async t => {
const payload = TranscriptPayloadSchema.parse({
infos: [
{
url: 'https://example.com/audio-0.m4a',
mimeType: 'audio/m4a',
index: 0,
},
],
normalizedTranscript: '00:00:05 A: Kickoff',
});
const service = createCopilotTranscriptionService(
{
copilotTranscriptTask: {
getWithUser: Sinon.stub().resolves({
id: 'task-1',
status: 'ready',
protectedResult: payload,
}),
},
} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never
);
const result = await service.queryTask('user-1', 'workspace-1', 'task-1');
t.is(result?.status, AiJobStatus.finished);
t.deepEqual(result?.infos, payload.infos);
t.is(result?.transcription, undefined);
});
test('settleTask unlocks ready transcript task result idempotently', async t => {
const payload = TranscriptPayloadSchema.parse({
normalizedTranscript: '00:00:05 A: Kickoff',
});
const settle = Sinon.stub().resolves({
id: 'task-1',
status: 'settled',
protectedResult: payload,
});
const service = createCopilotTranscriptionService(
{
copilotTranscriptTask: {
getWithUser: Sinon.stub().resolves({
id: 'task-1',
status: 'ready',
protectedResult: payload,
}),
settle,
},
} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never
);
const result = await service.settleTask('user-1', 'workspace-1', 'task-1');
t.is(result?.status, AiJobStatus.finished);
t.is(result?.transcription?.normalizedTranscript, '00:00:05 A: Kickoff');
Sinon.assert.calledOnceWithExactly(settle, 'task-1', 'user-1', 'workspace-1');
});
test('retryTask rejects ready transcript tasks', async t => {
const service = createCopilotTranscriptionService(
{
copilotTranscriptTask: {
getWithUser: Sinon.stub().resolves({
id: 'task-1',
status: 'ready',
protectedResult: {},
}),
},
} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never
);
await t.throwsAsync(
() => service.retryTask('user-1', 'workspace-1', 'task-1'),
{ message: /cannot be retried/ }
);
});
test('retryTask rejects settled transcript tasks', async t => {
const service = createCopilotTranscriptionService(
{
copilotTranscriptTask: {
getWithUser: Sinon.stub().resolves({
id: 'task-1',
status: 'settled',
protectedResult: {},
}),
},
} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never
);
await t.throwsAsync(
() => service.retryTask('user-1', 'workspace-1', 'task-1'),
{ message: /cannot be retried/ }
);
});
test('retryTask reuses a failed task as durable pending work', async t => {
const assertRoute = Sinon.stub().resolves();
const claimRetry = Sinon.stub();
claimRetry.onFirstCall().resolves(true);
claimRetry.onSecondCall().resolves(false);
const payload = TranscriptPayloadSchema.parse({
normalizedTranscript: '00:00:05 A: Kickoff',
summaryJson: null,
});
const service = createCopilotTranscriptionService(
{
copilotTranscriptTask: {
getWithUser: Sinon.stub().resolves({
id: 'task-1',
status: 'failed',
actionRunId: 'run-failed',
protectedResult: payload,
}),
claimRetry,
},
} as never,
{} as never,
{} as never,
{
resolveTranscriptionModel: Sinon.stub().resolves('gemini-3.7-flash'),
} as never,
{} as never,
{} as never,
{ assertRoute } as never
);
const result = await service.retryTask('user-1', 'workspace-1', 'task-1');
t.is(result?.status, AiJobStatus.pending);
await t.throwsAsync(
() => service.retryTask('user-1', 'workspace-1', 'task-1'),
{ message: /Only failed transcript tasks/ }
);
Sinon.assert.alwaysCalledWithExactly(
assertRoute,
'transcript.audio',
{},
{
user: 'user-1',
workspace: 'workspace-1',
featureKind: 'transcript',
builtInRouteId: 'Transcript audio',
}
);
t.is(assertRoute.callCount, 2);
const recovery = new CopilotTranscriptionRetryService(
{
copilotTranscriptTask: {
pendingDispatches: Sinon.stub().resolves([
{
id: 'task-3',
workspaceId: 'workspace-1',
dispatchGeneration: 'generation-recovery',
actionRunId: 'run-failed',
protectedResult: payload,
inputSnapshot: null,
},
]),
staleRunningDispatches: Sinon.stub().resolves([]),
},
} as never,
{} as never,
{ publish: Sinon.stub() } as never
);
const [recovered] = await recovery.collectPendingDispatches();
t.like(recovered, {
taskId: 'task-3',
generation: 'generation-recovery',
retryOf: 'run-failed',
scopeMode: 'canonical',
});
});
for (const status of ['ready', 'settled']) {
test(`submitTask allows a new task for the same blob after ${status} task`, async t => {
const createdTasks: unknown[] = [];
const assertRoute = Sinon.stub().resolves();
const service = createCopilotTranscriptionService(
{
copilotTranscriptTask: {
getWithUser: Sinon.stub().resolves({
id: `task-${status}`,
status,
}),
create: Sinon.stub().callsFake(async input => {
createdTasks.push(input);
return { id: 'task-next' };
}),
markRunning: Sinon.stub().resolves({ id: 'task-next' }),
},
} as never,
{} as never,
{} as never,
{
resolveTranscriptionModel: Sinon.stub().resolves('gemini-3.7-flash'),
} as never,
{} as never,
{} as never,
{ assertRoute } as never
);
const result = await service.submitTask(
'user-1',
'workspace-1',
'blob-1',
[]
);
t.is(result.id, 'task-next');
t.is(result.status, AiJobStatus.pending);
t.like(createdTasks[0] as Record<string, unknown>, {
blobId: 'blob-1',
recipeId: 'transcript.audio',
});
t.is(
typeof (createdTasks[0] as Record<string, unknown>).dispatchGeneration,
'string'
);
Sinon.assert.calledOnceWithExactly(
assertRoute,
'transcript.audio',
{},
{
user: 'user-1',
workspace: 'workspace-1',
featureKind: 'transcript',
builtInRouteId: 'Transcript audio',
}
);
});
}
test('transcriptTask transcribes each audio slice and merges absolute timestamps', async t => {
const payload = TranscriptPayloadSchema.parse({
sourceAudio: { blobId: 'blob-1', mimeType: 'audio/opus' },
sliceManifest: [
{
index: 0,
fileName: 'audio-0.opus',
mimeType: 'audio/opus',
startSec: 12,
durationSec: 30,
},
{
index: 1,
fileName: 'audio-1.opus',
mimeType: 'audio/opus',
startSec: 42,
durationSec: 300,
},
],
infos: [
{
key: 'blob-1-0',
url: 'https://affine.fail/api/copilot/blob/user-1/workspace-1/blob-1-0',
mimeType: 'audio/opus',
index: 0,
},
{
key: 'blob-1-1',
url: 'https://affine.fail/api/copilot/blob/user-1/workspace-1/blob-1-1',
mimeType: 'audio/opus',
index: 1,
},
],
});
const bridgeInputs: unknown[] = [];
const clock = Sinon.useFakeTimers();
t.teardown(() => clock.restore());
const structuredCalls: {
messages: { content?: string; attachments?: unknown[] }[];
options: { builtInRouteId?: string };
slot?: string;
}[] = [];
let transientFailure = true;
const generateStructuredValue = Sinon.stub().callsFake(
async (
_conditions: unknown,
messages: { content?: string; attachments?: unknown[] }[],
options: { builtInRouteId?: string },
_contract: unknown,
_filter: unknown,
slot?: string
) => {
structuredCalls.push({ messages, options, slot });
if (options.builtInRouteId === 'Summarize the meeting structured') {
return {
value: {
title: 'Weekly Sync',
durationMinutes: 1,
attendees: ['A', 'B'],
keyPoints: ['Kickoff', 'Follow-up'],
actionItems: [],
decisions: [],
openQuestions: [],
blockers: [],
},
};
}
const attachment = messages
.flatMap(message => message.attachments ?? [])
.at(0) as { attachment: string };
if (attachment.attachment.includes('blob-1-1') && transientFailure) {
transientFailure = false;
throw new Error('upstream returned status 503: UNAVAILABLE');
}
return {
value: attachment.attachment.includes('blob-1-0')
? [{ a: 'A', s: 5, e: 9, t: 'Kickoff' }]
: [{ a: 'B', s: 100, e: 500, t: 'Follow-up' }],
};
}
);
const claimDispatch = Sinon.stub();
claimDispatch.onFirstCall().resolves(true);
claimDispatch.onSecondCall().resolves(false);
const attachActionRun = Sinon.stub().resolves(true);
const completeDispatch = Sinon.stub().resolves(true);
const service = createCopilotTranscriptionService(
{
copilotTranscriptTask: {
get: Sinon.stub().resolves({
id: 'task-1',
userId: 'user-1',
workspaceId: 'workspace-1',
blobId: 'blob-1',
status: 'pending',
actionRunId: null,
}),
claimDispatch,
attachActionRun,
completeDispatch,
},
} as never,
{} as never,
{
presignGet: Sinon.stub().callsFake(
async (_userId, _workspaceId, key) =>
`https://canary.copilotcontent.affine.pro/${key}?sig=test`
),
} as never,
{} as never,
createTranscriptPromptService() as never,
createSuccessfulTranscriptBridge('run-bridge', bridgeInputs) as never,
{ generateStructuredValue } as never
);
const run = service.transcriptTask({
taskId: 'task-1',
payload,
generation: 'generation-1',
scopeMode: 'canonical',
});
await clock.tickAsync(5_000);
await run;
await service.transcriptTask({
taskId: 'task-1',
payload,
generation: 'generation-1',
scopeMode: 'canonical',
});
t.is(bridgeInputs.length, 1);
t.like(bridgeInputs[0] as Record<string, unknown>, {
actionId: 'transcript.audio',
actionVersion: 'v1',
});
t.like((bridgeInputs[0] as { step: Record<string, unknown> }).step, {
slot: 'transcript.audio',
builtInRouteId: 'Transcript audio',
});
t.deepEqual(
(
bridgeInputs[0] as {
inputSnapshot: { infos: unknown[] };
}
).inputSnapshot.infos,
[
{
url: 'https://canary.copilotcontent.affine.pro/blob-1-0?sig=test',
mimeType: 'audio/opus',
index: 0,
},
{
url: 'https://canary.copilotcontent.affine.pro/blob-1-1?sig=test',
mimeType: 'audio/opus',
index: 1,
},
]
);
t.is(structuredCalls.length, 4);
const transcriptCalls = structuredCalls.filter(
call => call.options.builtInRouteId === 'Transcript audio'
);
t.is(transcriptCalls.length, 3);
t.true(transcriptCalls.every(call => call.slot === 'transcript.audio'));
t.deepEqual(
transcriptCalls.map(call => call.messages.at(-1)?.attachments),
[
[
{
attachment:
'https://canary.copilotcontent.affine.pro/blob-1-0?sig=test',
mimeType: 'audio/opus',
},
],
[
{
attachment:
'https://canary.copilotcontent.affine.pro/blob-1-1?sig=test',
mimeType: 'audio/opus',
},
],
[
{
attachment:
'https://canary.copilotcontent.affine.pro/blob-1-1?sig=test',
mimeType: 'audio/opus',
},
],
]
);
t.is(
structuredCalls.at(-1)?.messages.at(-1)?.content,
'00:00:17 A: Kickoff\n00:01:42 B: Follow-up'
);
t.like(completeDispatch.firstCall.args[5], {
status: 'ready',
errorCode: null,
});
Sinon.assert.calledWith(
attachActionRun,
'task-1',
'user-1',
'workspace-1',
'generation-1',
null,
'run-bridge',
false
);
t.is(
completeDispatch.firstCall.args[5].protectedResult.normalizedTranscript,
'00:00:17 A: Kickoff\n00:01:42 B: Follow-up'
);
t.like(
completeDispatch.firstCall.args[5].protectedResult.normalizedSegments[1],
{
startSec: 102,
endSec: 342,
start: '00:01:42',
end: '00:05:42',
}
);
t.deepEqual(
completeDispatch.firstCall.args[5].protectedResult.infos,
payload.infos
);
});
test('transcriptTask fails task when native action bridge reports an error event', async t => {
const payload = TranscriptPayloadSchema.parse({
normalizedTranscript: '00:00:05 A: Kickoff',
});
const completeDispatch = Sinon.stub().resolves(true);
const service = createCopilotTranscriptionService(
{
copilotTranscriptTask: {
get: Sinon.stub().resolves({
id: 'task-1',
userId: 'user-1',
workspaceId: 'workspace-1',
blobId: 'blob-1',
status: 'pending',
actionRunId: null,
}),
claimDispatch: Sinon.stub().resolves(true),
attachActionRun: Sinon.stub().resolves(true),
completeDispatch,
},
} as never,
{} as never,
{} as never,
{} as never,
createTranscriptPromptService() as never,
{
runStream: (input: any) =>
(async function* () {
await input.onRunCreated?.({ runId: 'run-bridge', attempt: 1 });
yield {
type: 'error' as const,
actionId: 'transcript.audio',
actionVersion: 'v1',
status: 'failed' as const,
runId: 'run-bridge',
errorCode: 'native_failed',
};
})(),
} as never
);
await t.throwsAsync(
() =>
service.transcriptTask({
taskId: 'task-1',
payload,
generation: 'generation-1',
scopeMode: 'canonical',
}),
{ message: /native_failed/ }
);
t.like(completeDispatch.firstCall.args[5], {
status: 'failed',
});
});
test('transcriptTask revalidates terminal scope before claim and action-run attachment', async t => {
const claimDispatch = Sinon.stub();
const service = createCopilotTranscriptionService(
{
copilotTranscriptTask: {
get: Sinon.stub().resolves({
id: 'task-personal',
userId: 'user-1',
workspaceId: 'workspace-1',
status: 'pending',
}),
claimDispatch,
},
} as never,
{} as never,
{} as never,
{} as never,
createTranscriptPromptService() as never,
{} as never,
{} as never,
{} as never,
{ transcriptResource: Sinon.stub().resolves('canonical') } as never
);
const beforeClaim = await t.throwsAsync(
() =>
service.transcriptTask({
taskId: 'task-personal',
payload: TranscriptPayloadSchema.parse({
normalizedTranscript: '00:00:05 A: Kickoff',
}),
generation: 'generation-1',
scopeMode: 'personal',
}),
{ message: /not found/i }
);
const attachActionRun = Sinon.stub().resolves(true);
const completeDispatch = Sinon.stub().resolves(true);
const terminalAccess = Sinon.stub();
terminalAccess.onFirstCall().resolves('canonical');
terminalAccess.rejects(new AccessDenied());
const claimedService = createCopilotTranscriptionService(
{
copilotTranscriptTask: {
get: Sinon.stub().resolves({
id: 'task-canonical',
userId: 'user-1',
workspaceId: 'workspace-1',
status: 'pending',
}),
claimDispatch: Sinon.stub().resolves(true),
attachActionRun,
completeDispatch,
},
} as never,
{} as never,
{} as never,
{} as never,
createTranscriptPromptService() as never,
{
runStream: (input: {
onRunCreated?: (event: { runId: string }) => Promise<void>;
}) =>
(async function* () {
await input.onRunCreated?.({ runId: 'run-after-claim' });
yield { type: 'done' };
})(),
} as never,
{} as never,
{} as never,
{ transcriptResource: terminalAccess } as never
);
const afterClaim = await t.throwsAsync(() =>
claimedService.transcriptTask({
taskId: 'task-canonical',
payload: TranscriptPayloadSchema.parse({
normalizedTranscript: '00:00:05 A: Kickoff',
}),
generation: 'generation-1',
scopeMode: 'canonical',
})
);
t.snapshot({
beforeClaim: {
error: beforeClaim?.message,
claimCalls: claimDispatch.callCount,
},
afterClaim: {
error: afterClaim?.message,
accessChecks: terminalAccess.callCount,
attachCalls: attachActionRun.callCount,
completeCalls: completeDispatch.callCount,
},
});
});

View File

@@ -0,0 +1,105 @@
import { ModuleMetadata } from '@nestjs/common';
import {
Test,
TestingModule as NestjsTestingModule,
TestingModuleBuilder,
} from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import { FunctionalityModules } from '../app.module';
import { AFFiNELogger, ConfigFactory, EventBus } from '../base';
import {
BACKEND_RUNTIME_CONFIG_PATHS,
BackendRuntimeProvider,
} from '../core/backend-runtime';
import { StorageRuntimeProvider } from '../core/storage-runtime';
import { createFactory, MockEventBus } from './mocks';
import { TEST_LOG_LEVEL } from './utils';
import { createTestRuntimeConfig } from './utils/runtime-config';
interface TestingModuleMetadata extends ModuleMetadata {
tapModule?(m: TestingModuleBuilder): void;
}
export interface TestingModule extends NestjsTestingModule {
[Symbol.asyncDispose](): Promise<void>;
create: ReturnType<typeof createFactory>;
event: MockEventBus;
}
export async function createModule(
metadata: TestingModuleMetadata = {}
): Promise<TestingModule> {
const config = new ConfigFactory().config;
const runtimeConfig = await createTestRuntimeConfig(
config.db.datasourceUrl,
config.indexer
);
const { tapModule, ...meta } = metadata;
const functionalityModules = [...FunctionalityModules];
const builder = Test.createTestingModule({
...meta,
imports: [...functionalityModules, ...(meta.imports ?? [])],
});
builder
.overrideProvider(EventBus)
.useValue(new MockEventBus())
.overrideProvider(BACKEND_RUNTIME_CONFIG_PATHS)
.useValue([runtimeConfig.configPath]);
// when custom override happens
if (tapModule) {
tapModule(builder);
}
let module: TestingModule;
try {
module = (await builder.compile()) as TestingModule;
} catch (error) {
await runtimeConfig.cleanup();
throw error;
}
const logger = new AFFiNELogger();
// we got a lot smoking tests try to break nestjs
// can't tolerate the noisy logs
logger.setLogLevels([TEST_LOG_LEVEL]);
module.useLogger(logger);
const close = module.close.bind(module);
let closePromise: Promise<void> | undefined;
module.close = () => {
return (closePromise ??= (async () => {
try {
await close();
} finally {
await runtimeConfig.cleanup();
}
})());
};
try {
await module.init();
} catch (error) {
await module.close();
throw error;
}
const backendRuntime = module.get(BackendRuntimeProvider);
if (backendRuntime instanceof BackendRuntimeProvider) {
await backendRuntime.runMigrations();
await backendRuntime.onConfigChanged({ updates: { indexer: {} } });
}
const storageRuntime = module.get(StorageRuntimeProvider);
if (storageRuntime instanceof StorageRuntimeProvider) {
await storageRuntime.runMigrations();
}
module[Symbol.asyncDispose] = async () => {
await module.close();
};
module.create = createFactory(module.get(PrismaClient));
module.event = module.get(EventBus);
return module;
}

View File

@@ -0,0 +1,94 @@
import { ScheduleModule } from '@nestjs/schedule';
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import Sinon from 'sinon';
import { BackendRuntimeProvider } from '../../core/backend-runtime';
import { DocStorageModule, DocStorageWorkerModule } from '../../core/doc';
import { DocStorageCronJob } from '../../core/doc/job';
import { createTestingModule, type TestingModule } from '../utils';
interface Context {
module: TestingModule;
db: PrismaClient;
cronJob: DocStorageCronJob;
runtime: {
cleanupExpiredSnapshotHistories: Sinon.SinonStub;
executeAuthSessionCommandV1: Sinon.SinonStub;
};
}
const test = ava as TestFn<Context>;
// cleanup database before each test
test.before(async t => {
t.context.runtime = {
cleanupExpiredSnapshotHistories: Sinon.stub(),
executeAuthSessionCommandV1: Sinon.stub().resolves({}),
};
t.context.module = await createTestingModule({
imports: [
ScheduleModule.forRoot(),
DocStorageModule,
DocStorageWorkerModule,
],
tapModule: builder => {
builder
.overrideProvider(BackendRuntimeProvider)
.useValue(t.context.runtime);
},
});
t.context.db = t.context.module.get(PrismaClient);
t.context.cronJob = t.context.module.get(DocStorageCronJob);
});
test.beforeEach(async t => {
await t.context.module.initTestingDB();
t.context.runtime.cleanupExpiredSnapshotHistories.reset();
});
test.after.always(async t => {
await t.context.module.close();
});
test('should be able to cleanup expired history', async t => {
const { db, runtime } = t.context;
const timestamp = Date.now();
// insert expired data
await db.snapshotHistory.createMany({
data: Array.from({ length: 10 })
.fill(0)
.map((_, i) => ({
workspaceId: '1',
id: '1',
blob: Buffer.from([1, 1]),
timestamp: new Date(timestamp - 10 - i),
expiredAt: new Date(timestamp - 1),
})),
});
// insert available data
await db.snapshotHistory.createMany({
data: Array.from({ length: 10 })
.fill(0)
.map((_, i) => ({
workspaceId: '1',
id: '1',
blob: Buffer.from([1, 1]),
timestamp: new Date(timestamp + i),
expiredAt: new Date(timestamp + 1000),
})),
});
let count = await db.snapshotHistory.count();
t.is(count, 20);
runtime.cleanupExpiredSnapshotHistories.onCall(0).resolves(1000);
runtime.cleanupExpiredSnapshotHistories.onCall(1).resolves(10);
await t.context.cronJob.nightlyJob();
t.is(runtime.cleanupExpiredSnapshotHistories.callCount, 2);
});

View File

@@ -0,0 +1,295 @@
import type { Snapshot } from '@prisma/client';
import { PrismaClient } from '@prisma/client';
import test from 'ava';
import * as Sinon from 'sinon';
import { DocStorageModule, PgWorkspaceDocStorageAdapter } from '../../core/doc';
import { DocStorageOptions } from '../../core/doc/options';
import { DocRecord } from '../../core/doc/storage';
import { createTestingModule, type TestingModule } from '../utils';
let m: TestingModule;
let adapter: PgWorkspaceDocStorageAdapter;
let db: PrismaClient;
// cleanup database before each test
test.before(async () => {
m = await createTestingModule({
imports: [DocStorageModule],
});
adapter = m.get(PgWorkspaceDocStorageAdapter);
db = m.get(PrismaClient);
});
test.beforeEach(async () => {
await m.initTestingDB();
const options = m.get(DocStorageOptions);
Sinon.stub(options, 'historyMaxAge').resolves(1000);
});
test.afterEach(async () => {
Sinon.restore();
});
test.after.always(async () => {
await m.close();
});
const snapshot: Snapshot = {
workspaceId: '1',
id: 'doc1',
blob: Uint8Array.from([1, 0]),
state: Uint8Array.from([0]),
size: BigInt(2),
updatedAt: new Date(),
createdAt: new Date(),
createdBy: null,
updatedBy: null,
};
function getSnapshot(timestamp: number = Date.now()): DocRecord {
return {
spaceId: snapshot.workspaceId,
docId: snapshot.id,
bin: snapshot.blob,
timestamp,
};
}
test('history max age converts quota seconds to milliseconds', async t => {
Sinon.restore();
const options = m.get(DocStorageOptions);
// @ts-expect-error private service boundary is asserted here
Sinon.stub(options.quota, 'getWorkspaceQuota').resolves({
name: 'Pro',
blobLimit: 1,
storageQuota: 1,
historyPeriod: 30,
memberLimit: 1,
});
t.is(await options.historyMaxAge('1'), 30_000);
});
test('should create doc history if never created before', async t => {
// @ts-expect-error private method
Sinon.stub(adapter, 'lastDocHistory').resolves(null);
const timestamp = Date.now();
// @ts-expect-error private method
await adapter.createDocHistory(getSnapshot(timestamp));
const history = await db.snapshotHistory.findFirst({
where: {
workspaceId: '1',
id: 'doc1',
},
});
t.truthy(history);
t.is(history?.timestamp.getTime(), timestamp);
});
test('should not create history if timestamp equals to last record', async t => {
const timestamp = new Date();
// @ts-expect-error private method
Sinon.stub(adapter, 'lastDocHistory').resolves({ timestamp, state: null });
// @ts-expect-error private method
await adapter.createDocHistory(getSnapshot(timestamp));
const history = await db.snapshotHistory.findFirst({
where: {
workspaceId: '1',
id: 'doc1',
},
});
t.falsy(history);
});
test('should not create history if time diff is less than interval config', async t => {
const timestamp = new Date();
// @ts-expect-error private method
Sinon.stub(adapter, 'lastDocHistory').resolves({
timestamp: new Date(timestamp.getTime() - 1000),
state: Buffer.from([0, 1]),
});
// @ts-expect-error private method
await adapter.createDocHistory(getSnapshot(timestamp));
const history = await db.snapshotHistory.findFirst({
where: {
workspaceId: '1',
id: 'doc1',
},
});
t.falsy(history);
});
test('should create history if time diff is larger than interval config and state diff', async t => {
const timestamp = new Date();
// @ts-expect-error private method
Sinon.stub(adapter, 'lastDocHistory').resolves({
timestamp: new Date(timestamp.getTime() - 1000 * 60 * 20),
state: Buffer.from([0, 1]),
});
// @ts-expect-error private method
await adapter.createDocHistory(getSnapshot(timestamp));
const history = await db.snapshotHistory.findFirst({
where: {
workspaceId: '1',
id: 'doc1',
},
});
t.truthy(history);
});
test('should create history with force flag even if time diff in small', async t => {
const timestamp = new Date();
// @ts-expect-error private method
Sinon.stub(adapter, 'lastDocHistory').resolves({
timestamp: new Date(timestamp.getTime() - 1),
state: Buffer.from([0, 1]),
});
// @ts-expect-error private method
await adapter.createDocHistory(getSnapshot(timestamp), true);
const history = await db.snapshotHistory.findFirst({
where: {
workspaceId: '1',
id: 'doc1',
},
});
t.truthy(history);
});
test('should correctly list all history records', async t => {
const timestamp = Date.now();
// insert expired data
await db.snapshotHistory.createMany({
data: Array.from({ length: 10 })
.fill(0)
.map((_, i) => ({
workspaceId: snapshot.workspaceId,
id: snapshot.id,
blob: snapshot.blob,
state: snapshot.state,
timestamp: new Date(timestamp - 10 - i),
expiredAt: new Date(timestamp - 1),
})),
});
// insert available data
await db.snapshotHistory.createMany({
data: Array.from({ length: 10 })
.fill(0)
.map((_, i) => ({
workspaceId: snapshot.workspaceId,
id: snapshot.id,
blob: snapshot.blob,
state: snapshot.state,
timestamp: new Date(timestamp + i),
expiredAt: new Date(timestamp + 1000),
})),
});
const list = await adapter.listDocHistories(
snapshot.workspaceId,
snapshot.id,
{ before: timestamp + 20, limit: 8 }
);
const count = await db.snapshotHistory.count();
t.is(list.length, 8);
t.is(count, 20);
});
test('should be able to get history data', async t => {
const timestamp = Date.now();
// @ts-expect-error private method
await adapter.createDocHistory(getSnapshot(timestamp), true);
const history = await adapter.getDocHistory(
snapshot.workspaceId,
snapshot.id,
timestamp
);
t.truthy(history);
t.deepEqual(history?.bin, snapshot.blob);
});
test('should be able to get last history record', async t => {
const timestamp = Date.now();
// insert available data
await db.snapshotHistory.createMany({
data: Array.from({ length: 10 })
.fill(0)
.map((_, i) => ({
workspaceId: snapshot.workspaceId,
id: snapshot.id,
blob: snapshot.blob,
state: snapshot.state,
timestamp: new Date(timestamp + i),
expiredAt: new Date(timestamp + 1000),
})),
});
// @ts-expect-error private method
const history = await adapter.lastDocHistory(
snapshot.workspaceId,
snapshot.id
);
t.truthy(history);
t.is(history?.timestamp, timestamp + 9);
});
test('should be able to recover from history', async t => {
await db.snapshot.create({
data: {
...snapshot,
blob: Buffer.from([1, 1]),
state: Buffer.from([1, 1]),
},
});
const history1Timestamp = snapshot.updatedAt.getTime() - 10;
// @ts-expect-error private method
await adapter.createDocHistory(getSnapshot(history1Timestamp));
await adapter.rollbackDoc(
snapshot.workspaceId,
snapshot.id,
history1Timestamp
);
const [history1, history2] = await db.snapshotHistory.findMany({
where: {
workspaceId: snapshot.workspaceId,
id: snapshot.id,
},
});
t.is(history1.timestamp.getTime(), history1Timestamp);
t.is(history2.timestamp.getTime(), snapshot.updatedAt.getTime());
// new history data force created with snapshot state before recovered
t.deepEqual(history2.blob, Uint8Array.from([1, 1]));
});

View File

@@ -0,0 +1,86 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { Package } from '@affine-tools/utils/workspace';
import type { INestApplication } from '@nestjs/common';
import type { TestFn } from 'ava';
import ava from 'ava';
import request from 'supertest';
import { createTestingApp } from '../utils';
const test = ava as TestFn<{
app: INestApplication;
}>;
const mobileUAString =
'Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36';
function initTestStaticFiles(staticPath: string) {
const files = {
'main.a.js': `const name = 'affine'`,
'assets-manifest.json': JSON.stringify({
js: ['main.a.js'],
css: [],
publicPath: 'https://app.affine.pro/',
gitHash: '',
description: '',
}),
'admin/main.b.js': `const name = 'affine-admin'`,
'mobile/main.c.js': `const name = 'affine-mobile'`,
'mobile/assets-manifest.json': JSON.stringify({
js: ['main.c.js'],
css: [],
publicPath: 'https://app.affine.pro/',
gitHash: '',
description: '',
}),
};
for (const [filename, content] of Object.entries(files)) {
const filePath = path.join(staticPath, filename);
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, content);
}
}
test.before(async t => {
const staticPath = new Package('@affine/server').join('static').value;
initTestStaticFiles(staticPath);
const app = await createTestingApp();
t.context.app = app;
});
test.after.always(async t => {
await t.context.app.close();
});
test('should render correct html', async t => {
const res = await request(t.context.app.getHttpServer())
.get('/workspace/xxxx/xxx')
.expect(200);
t.true(
res.text.includes(
`<script src="https://app.affine.pro/main.a.js" crossorigin></script>`
)
);
});
// TODO(@forehalo): enable it when mobile version is ready
test.skip('should render correct mobile html', async t => {
const res = await request(t.context.app.getHttpServer())
.get('/workspace/xxxx/xxx')
.set('user-agent', mobileUAString)
.expect(200);
t.true(
res.text.includes(
`<script src="https://app.affine.pro/main.c.js" crossorigin></script>`
)
);
});
test.todo('should render correct page preview');

View File

@@ -0,0 +1,135 @@
import { randomUUID } from 'node:crypto';
import ava, { TestFn } from 'ava';
import { applyUpdate, Doc as YDoc } from 'yjs';
import {
DocStorageModule,
PgUserspaceDocStorageAdapter as Adapter,
} from '../../core/doc';
import { Models, type User } from '../../models';
import { createTestingModule, type TestingModule } from '../utils';
interface Context {
module: TestingModule;
models: Models;
adapter: Adapter;
}
const test = ava as TestFn<Context>;
test.before(async t => {
const module = await createTestingModule({
imports: [DocStorageModule],
});
t.context.models = module.get(Models);
t.context.adapter = module.get(Adapter);
t.context.module = module;
});
let user: User;
test.beforeEach(async t => {
await t.context.module.initTestingDB();
user = await t.context.models.user.create({
email: 'test@affine.pro',
});
});
test.after(async t => {
await t.context.module.close();
});
test('should push user doc updates work', async t => {
const docId = randomUUID();
const doc = new YDoc();
const text = doc.getText('content');
const updates: Uint8Array[] = [];
doc.on('update', update => {
updates.push(update);
});
text.insert(0, 'hello');
text.insert(5, 'world');
text.insert(5, ' ');
let timestamp = await t.context.adapter.pushDocUpdates(
user.id,
docId,
updates
);
t.truthy(timestamp);
let record = await t.context.adapter.getDoc(user.id, docId);
const newDoc = new YDoc();
applyUpdate(newDoc, record!.bin);
t.is(newDoc.getText('content').toString(), 'hello world');
// find all timestamps
const timestamps = await t.context.adapter.getSpaceDocTimestamps(user.id);
t.deepEqual(timestamps, {
[docId]: timestamp,
});
});
test('should delete user doc work', async t => {
const docId = randomUUID();
const doc = new YDoc();
const text = doc.getText('content');
const updates: Uint8Array[] = [];
doc.on('update', update => {
updates.push(update);
});
text.insert(0, 'hello');
text.insert(5, 'world');
text.insert(5, ' ');
let timestamp = await t.context.adapter.pushDocUpdates(
user.id,
docId,
updates
);
t.truthy(timestamp);
let record = await t.context.adapter.getDoc(user.id, docId);
t.truthy(record);
await t.context.adapter.deleteDoc(user.id, docId);
record = await t.context.adapter.getDoc(user.id, docId);
t.falsy(record);
});
test('should delete all user docs work', async t => {
const docId = randomUUID();
const doc = new YDoc();
const text = doc.getText('content');
const updates: Uint8Array[] = [];
doc.on('update', update => {
updates.push(update);
});
text.insert(0, 'hello');
text.insert(5, 'world');
text.insert(5, ' ');
let timestamp = await t.context.adapter.pushDocUpdates(
user.id,
docId,
updates
);
t.truthy(timestamp);
let record = await t.context.adapter.getDoc(user.id, docId);
t.truthy(record);
await t.context.adapter.deleteSpace(user.id);
record = await t.context.adapter.getDoc(user.id, docId);
t.falsy(record);
});

View File

@@ -0,0 +1,308 @@
import { PrismaClient } from '@prisma/client';
import test from 'ava';
import * as Sinon from 'sinon';
import { applyUpdate, Doc as YDoc, encodeStateAsUpdate } from 'yjs';
import { EventBus } from '../../base';
import { BackendRuntimeProvider } from '../../core/backend-runtime';
import {
DocStorageModule,
PgWorkspaceDocStorageAdapter as Adapter,
} from '../../core/doc';
import { createTestingModule, type TestingModule } from '../utils';
let m: TestingModule;
let db: PrismaClient;
let adapter: Adapter;
let runtime: BackendRuntimeProvider;
test.before('init testing module', async () => {
m = await createTestingModule({
imports: [DocStorageModule],
});
db = m.get(PrismaClient);
adapter = m.get(Adapter);
runtime = m.get(BackendRuntimeProvider);
// @ts-expect-error private method
Sinon.stub(adapter, 'createDocHistory');
});
test.beforeEach(async () => {
await m.initTestingDB();
});
test.after.always(async () => {
await m?.close();
});
test('should not compact from a stale update view', async t => {
const doc = new YDoc();
const text = doc.getText('content');
const updates: Buffer[] = [];
doc.on('update', update => {
updates.push(Buffer.from(update));
});
text.insert(0, 'hello');
text.insert(5, 'world');
text.insert(5, ' ');
await adapter.pushDocUpdatesTrusted('2', '2', updates);
await adapter.getDoc('2', '2');
await adapter.pushDocUpdatesTrusted('2', '2', updates);
const records = await db.update.findMany({
where: {
workspaceId: '2',
id: '2',
},
});
await adapter.pushDocUpdatesTrusted('2', '2', updates.slice(0, 1));
// @ts-expect-error private method
const stub = Sinon.stub(adapter, 'getDocUpdates').resolves(
records.map(record => ({
bin: record.blob,
timestamp: record.createdAt.getTime(),
}))
);
await adapter.getDoc('2', '2');
stub.restore();
t.not(await db.update.count(), 0);
});
test('should finish doc creation after retrying failed updates', async t => {
const stub = Sinon.stub(runtime, 'appendWorkspaceDocUpdatesTrustedV1');
t.teardown(() => stub.restore());
let creationCompleted = false;
t.teardown(
m.get(EventBus).on('doc.created', async () => {
await new Promise<void>(resolve => setImmediate(resolve));
creationCompleted = true;
})
);
stub.onCall(0).rejects(new Error());
stub.onCall(1).resolves(Date.now());
await t.notThrowsAsync(() =>
adapter.pushDocUpdatesTrusted('1', '1', [Buffer.from([0, 0])])
);
t.is(stub.callCount, 2);
t.true(creationCompleted);
});
test('should throw if meet max retry times', async t => {
const stub = Sinon.stub(runtime, 'appendWorkspaceDocUpdatesTrustedV1');
stub.rejects(new Error());
await t.throwsAsync(
() => adapter.pushDocUpdatesTrusted('1', '1', [Buffer.from([0, 0])]),
{ message: 'Failed to store doc updates.' }
);
t.is(stub.callCount, 4);
stub.restore();
});
test('should be able to merge updates as snapshot', async t => {
const doc = new YDoc();
const text = doc.getText('content');
text.insert(0, 'hello');
const update = encodeStateAsUpdate(doc);
await db.workspace.create({
data: {
id: '1',
accessPolicy: { create: {} },
},
});
await db.update.createMany({
data: [
{
id: '1',
workspaceId: '1',
blob: Buffer.from(update),
createdAt: new Date(Date.now() + 1),
createdBy: null,
},
],
});
t.deepEqual(
Buffer.from((await adapter.getDoc('1', '1'))!.bin),
Buffer.from(update)
);
let appendUpdate = Buffer.from([]);
doc.on('update', update => {
appendUpdate = Buffer.from(update);
});
text.insert(5, 'world');
await db.update.create({
data: {
workspaceId: '1',
id: '1',
blob: appendUpdate,
createdAt: new Date(),
createdBy: null,
},
});
{
const { bin } = (await adapter.getDoc('1', '1'))!;
const dbDoc = new YDoc();
applyUpdate(dbDoc, bin);
t.is(dbDoc.getText('content').toString(), 'helloworld');
t.deepEqual(encodeStateAsUpdate(dbDoc), encodeStateAsUpdate(doc));
}
});
test('should be able to merge updates into snapshot', async t => {
const updates: Buffer[] = [];
{
const doc = new YDoc();
doc.on('update', data => {
updates.push(Buffer.from(data));
});
const text = doc.getText('content');
text.insert(0, 'hello');
text.insert(5, 'world');
text.insert(5, ' ');
text.insert(11, '!');
}
{
await adapter.pushDocUpdatesTrusted('1', '1', updates.slice(0, 2));
// merge
const { bin } = (await adapter.getDoc('1', '1'))!;
const doc = new YDoc();
applyUpdate(doc, bin);
t.is(doc.getText('content').toString(), 'helloworld');
}
{
await adapter.pushDocUpdatesTrusted('1', '1', updates.slice(2));
// merge
const { bin } = (await adapter.getDoc('1', '1'))!;
const doc = new YDoc();
applyUpdate(doc, bin);
t.is(doc.getText('content').toString(), 'hello world!');
}
t.is(await db.update.count(), 0);
});
test('should merge updates after a future-dated snapshot', async t => {
const updates: Buffer[] = [];
{
const doc = new YDoc();
doc.on('update', data => {
updates.push(Buffer.from(data));
});
const text = doc.getText('content');
text.insert(0, 'hello');
text.insert(5, 'world');
text.insert(5, ' ');
text.insert(11, '!');
}
await adapter.pushDocUpdatesTrusted('2', '1', updates.slice(0, 2)); // 'helloworld'
// merge
await adapter.getDoc('2', '1');
// fake the snapshot is a lot newer
await db.snapshot.update({
where: {
workspaceId_id: {
workspaceId: '2',
id: '1',
},
},
data: {
updatedAt: new Date(Date.now() + 10000),
},
});
{
await adapter.pushDocUpdatesTrusted('2', '1', updates.slice(2)); // 'hello world!'
const { bin } = (await adapter.getDoc('2', '1'))!;
const doc = new YDoc();
applyUpdate(doc, bin);
t.is(doc.getText('content').toString(), 'hello world!');
}
{
const doc = new YDoc();
applyUpdate(doc, (await adapter.getDoc('2', '1'))!.bin);
t.is(doc.getText('content').toString(), 'hello world!');
t.is(await db.update.count(), 0);
}
});
test('should not recreate a doc deleted before compaction acquires its database lock', async t => {
const workspaceId = 'delete-before-compaction';
const docId = 'doc';
const doc = new YDoc();
doc.getText('content').insert(0, 'hello');
const blob = Buffer.from(encodeStateAsUpdate(doc));
const timestamp = new Date();
await db.snapshot.create({
data: {
workspaceId,
id: docId,
blob,
size: blob.byteLength,
updatedAt: timestamp,
},
});
await db.update.create({
data: {
workspaceId,
id: docId,
blob,
createdAt: new Date(timestamp.getTime() + 1),
},
});
let releaseDelete!: () => void;
const mayCommit = new Promise<void>(resolve => {
releaseDelete = resolve;
});
let rowsDeleted!: () => void;
const deleteHasLock = new Promise<void>(resolve => {
rowsDeleted = resolve;
});
const deleting = db.$transaction(async tx => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`workspace-doc-update:${workspaceId}/${docId}`}, 0))`;
await tx.snapshot.deleteMany({ where: { workspaceId, id: docId } });
await tx.update.deleteMany({ where: { workspaceId, id: docId } });
rowsDeleted();
await mayCommit;
});
await deleteHasLock;
const compacting = adapter.getDoc(workspaceId, docId);
await new Promise(resolve => setTimeout(resolve, 20));
releaseDelete();
await deleting;
t.is(await compacting, null);
t.is(await db.snapshot.count({ where: { workspaceId, id: docId } }), 0);
t.is(await db.update.count({ where: { workspaceId, id: docId } }), 0);
});

View File

@@ -0,0 +1,93 @@
import { getCurrentUserQuery } from '@affine/graphql';
import type { RawBodyRequest } from '@nestjs/common';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { Test } from '@nestjs/testing';
import type { Request, Response } from 'express';
import request from 'supertest';
import { configureBodyParsers } from '../../../server';
import { Mockers } from '../../mocks';
import { app, e2e } from '../test';
e2e('should create test app correctly', async t => {
t.truthy(app);
});
e2e('should mock mails work', async t => {
t.is(app.mails.count('MemberInvitation'), 0);
});
e2e('should handle http request', async t => {
const res = await app.GET('/info');
t.is(res.status, 200);
t.is(res.body.compatibility, env.version);
});
for (const [serverPath, prefix] of [
['', ''],
['///', ''],
['/affine.v1/', '/affine.v1'],
['///affine.v1///', '/affine.v1'],
['affine//nested', '/affine//nested'],
]) {
e2e(
`attachment body limit follows server prefix ${serverPath || '/'}`,
async t => {
const module = await Test.createTestingModule({}).compile();
const http = module.createNestApplication<NestExpressApplication>({
logger: false,
rawBody: true,
});
configureBodyParsers(http, serverPath);
http.use((req: RawBodyRequest<Request>, res: Response) =>
res.json({ size: req.body.length, raw: req.rawBody?.equals(req.body) })
);
await http.init();
await http.listen(0);
try {
const large = Buffer.alloc(21 * 1024 * 1024);
for (const attachmentPath of [
`${prefix}/api/copilot/chat/session/attachments/key`,
`${prefix}/API/COPILOT/CHAT/session/attachments/key/?upload=1`,
]) {
const response = await request(http.getHttpServer())
.put(attachmentPath)
.set('content-type', 'application/octet-stream')
.send(large);
t.is(response.status, 413);
}
const accepted = await request(http.getHttpServer())
.put(`${prefix}/api/copilot/chat/session/attachments/key`)
.set('content-type', 'application/octet-stream')
.send(Buffer.alloc(1024));
t.is(accepted.status, 200);
t.is(accepted.body.size, 1024);
t.true(accepted.body.raw);
const other = await request(http.getHttpServer())
.put(`${prefix}/api/workspaces/blob`)
.set('content-type', 'application/octet-stream')
.send(large);
t.is(other.status, 200);
t.is(other.body.size, large.length);
} finally {
await http.close();
}
}
);
}
e2e('should create workspace with owner', async t => {
const user = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: user.id },
});
t.truthy(workspace);
});
e2e('should get current user', async t => {
const user = await app.signup();
await app.switchUser(user);
const res = await app.gql({ query: getCurrentUserQuery });
t.truthy(res.currentUser);
t.is(res.currentUser!.id, user.id);
});

View File

@@ -0,0 +1,84 @@
import { getCurrentUserQuery } from '@affine/graphql';
import { DatabaseDocReader, DocReader } from '../../../core/doc';
import { RealtimeGateway } from '../../../core/realtime/gateway';
import { createApp } from '../create-app';
import { e2e } from '../test';
type TestFlavor =
| 'allinone'
| 'worker'
| 'graphql'
| 'sync'
| 'renderer'
| 'front';
const withFlavor = async <T>(
flavor: TestFlavor,
run: (app: Awaited<ReturnType<typeof createApp>>) => Promise<T>
) => {
const mutableEnv = globalThis.env as unknown as { FLAVOR: string };
const previousFlavor = mutableEnv.FLAVOR;
// @ts-expect-error override
globalThis.env.FLAVOR = flavor;
try {
await using app = await createApp();
return await run(app);
} finally {
mutableEnv.FLAVOR = previousFlavor;
}
};
e2e('should init worker service', async t => {
await withFlavor('worker', async app => {
const res = await app.GET('/info').expect(200);
t.is(res.body.flavor, 'worker');
t.throws(() => app.get(RealtimeGateway));
await t.throwsAsync(app.gql({ query: getCurrentUserQuery }));
await app.PUT('/api/storage/upload').expect(404);
});
});
e2e('should init allinone service with worker handlers', async t => {
await withFlavor('allinone', async app => {
const res = await app.GET('/info').expect(200);
t.is(res.body.flavor, 'allinone');
});
});
e2e('should init graphql service', async t => {
await withFlavor('graphql', async app => {
const res = await app.GET('/info').expect(200);
t.is(res.body.flavor, 'graphql');
const user = await app.gql({ query: getCurrentUserQuery });
t.is(user.currentUser, null);
t.truthy(app.get(RealtimeGateway));
});
});
e2e('should init sync service', async t => {
await withFlavor('sync', async app => {
const res = await app.GET('/info').expect(200);
t.is(res.body.flavor, 'sync');
});
});
e2e('should init renderer service', async t => {
await withFlavor('renderer', async app => {
const res = await app.GET('/info').expect(200);
t.is(res.body.flavor, 'renderer');
});
});
e2e('should init front service', async t => {
await withFlavor('front', async app => {
const res = await app.GET('/info').expect(200);
t.is(res.body.flavor, 'front');
const docReader = app.get(DocReader);
t.true(docReader instanceof DatabaseDocReader);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
import { serverConfigQuery, ServerFeature } from '@affine/graphql';
import { Config } from '../../../base';
import { app, e2e } from '../test';
e2e('should expose the indexer feature when it is enabled', async t => {
const enabled = app.get(Config).indexer.enabled;
const { serverConfig } = await app.gql({ query: serverConfigQuery });
t.is(
serverConfig.features.includes(ServerFeature.Indexer),
enabled,
JSON.stringify(serverConfig, null, 2)
);
});
e2e('should comment feature enabled by default', async t => {
const { serverConfig } = await app.gql({ query: serverConfigQuery });
t.is(
serverConfig.features.includes(ServerFeature.Comment),
true,
JSON.stringify(serverConfig, null, 2)
);
});
e2e('should enable local workspace feature by default', async t => {
const { serverConfig } = await app.gql({ query: serverConfigQuery });
t.is(
serverConfig.features.includes(ServerFeature.LocalWorkspace),
true,
JSON.stringify(serverConfig, null, 2)
);
});

View File

@@ -0,0 +1,371 @@
import assert from 'node:assert';
import { gqlFetcherFactory } from '@affine/graphql';
import { INestApplication, ModuleMetadata, Type } from '@nestjs/common';
import { NestApplication } from '@nestjs/core';
import {
Test,
type TestingModule,
TestingModuleBuilder,
} from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import cookieParser from 'cookie-parser';
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import supertest from 'supertest';
import {
AFFiNELogger,
CacheInterceptor,
CloudThrottlerGuard,
ConfigFactory,
EventBus,
GlobalExceptionFilter,
OneMB,
} from '../../base';
import { ThrottlerStorage } from '../../base/throttler';
import { SocketIoAdapter } from '../../base/websocket';
import { AuthGuard, AuthService } from '../../core/auth';
import {
BACKEND_RUNTIME_CONFIG_PATHS,
BackendRuntimeProvider,
} from '../../core/backend-runtime';
import { Mailer } from '../../core/mail';
import { StorageRuntimeProvider } from '../../core/storage-runtime';
import { ServerRole } from '../../env';
import { Models } from '../../models';
import { IndexerService } from '../../plugins/indexer/service';
import {
createFactory,
MockedUser,
MockMailer,
MockUser,
MockUserInput,
} from '../mocks';
import { parseCookies, TEST_LOG_LEVEL } from '../utils';
import { createTestRuntimeConfig } from '../utils/runtime-config';
interface TestingAppMetadata {
tapModule?(m: TestingModuleBuilder): void;
tapApp?(app: INestApplication): void;
imports?: ModuleMetadata['imports'];
}
export class TestingApp extends NestApplication {
private sessionCookie: string | null = null;
private currentUserCookie: string | null = null;
private csrfCookie: string | null = null;
private readonly userCookies: Set<string> = new Set();
private getOptional<T>(token: Type<T>) {
try {
return this.get(token, { strict: false });
} catch {
return undefined;
}
}
create = createFactory(this.get(PrismaClient, { strict: false }));
mails = this.getOptional(Mailer) as unknown as MockMailer;
eventBus = this.get(EventBus, { strict: false });
models = this.get(Models, { strict: false });
get url() {
const server = this.getHttpServer();
if (!server.address()) {
server.listen();
}
return `http://localhost:${server.address().port}`;
}
async [Symbol.asyncDispose]() {
await this.close();
}
clearAuth() {
this.resetRateLimit();
this.sessionCookie = null;
this.currentUserCookie = null;
this.csrfCookie = null;
this.userCookies.clear();
}
request(
method: 'options' | 'get' | 'post' | 'put' | 'delete' | 'patch',
path: string
): supertest.Test {
const cookies = [
`${AuthService.sessionCookieName}=${this.sessionCookie ?? ''}`,
`${AuthService.userCookieName}=${this.currentUserCookie ?? ''}`,
];
if (this.csrfCookie) {
cookies.push(`${AuthService.csrfCookieName}=${this.csrfCookie}`);
}
const req = supertest(this.getHttpServer())
[method](path)
.set('Cookie', cookies);
if (this.csrfCookie) {
req.set('x-affine-csrf-token', this.csrfCookie);
}
return req;
}
gql = gqlFetcherFactory('', async (_input, init) => {
assert(init, 'no request content');
assert(init.body, 'body is required for gql request');
assert(init.headers, 'headers is required for gql request');
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,
});
});
OPTIONS(path: string): supertest.Test {
return this.request('options', path);
}
GET(path: string): supertest.Test {
return this.request('get', path);
}
POST(path: string): supertest.Test {
return this.request('post', path).on(
'response',
(res: supertest.Response) => {
const cookies = parseCookies(res);
if (AuthService.sessionCookieName in cookies) {
if (this.sessionCookie !== cookies[AuthService.sessionCookieName]) {
this.userCookies.clear();
}
this.sessionCookie = cookies[AuthService.sessionCookieName];
this.currentUserCookie = cookies[AuthService.userCookieName];
if (AuthService.csrfCookieName in cookies) {
this.csrfCookie = cookies[AuthService.csrfCookieName] || null;
}
if (this.currentUserCookie) {
this.userCookies.add(this.currentUserCookie);
}
}
return res;
}
);
}
PUT(path: string): supertest.Test {
return this.request('put', path);
}
DELETE(path: string): supertest.Test {
return this.request('delete', path);
}
PATCH(path: string): supertest.Test {
return this.request('patch', path);
}
async createUser(overrides?: Partial<MockUserInput>) {
return await this.create(MockUser, overrides);
}
resetRateLimit() {
this.get(ThrottlerStorage, { strict: false }).storage.clear();
}
async signup(overrides?: Partial<MockUserInput>) {
const user = await this.create(MockUser, overrides);
await this.login(user);
return user;
}
async login(user: MockedUser) {
this.resetRateLimit();
return await this.POST('/api/auth/sign-in').send({
email: user.email,
password: user.password,
});
}
async switchUser(userOrId: string | { id: string }) {
if (!this.sessionCookie) {
throw new Error('No user is logged in.');
}
const userId = typeof userOrId === 'string' ? userOrId : userOrId.id;
if (userId === this.currentUserCookie) {
return;
}
if (this.userCookies.has(userId)) {
this.currentUserCookie = userId;
} else {
throw new Error(`User [${userId}] is not logged in.`);
}
}
async logout(userId?: string) {
this.resetRateLimit();
const res = await this.POST(
'/api/auth/sign-out' + (userId ? `?user_id=${userId}` : '')
).expect(200);
const cookies = parseCookies(res);
this.sessionCookie = cookies[AuthService.sessionCookieName];
if (AuthService.csrfCookieName in cookies) {
this.csrfCookie = cookies[AuthService.csrfCookieName] || null;
}
if (!this.sessionCookie) {
this.currentUserCookie = null;
this.csrfCookie = null;
this.userCookies.clear();
} else {
this.currentUserCookie = cookies[AuthService.userCookieName];
if (userId) {
this.userCookies.delete(userId);
}
}
}
}
export async function createApp(
metadata: TestingAppMetadata = {}
): Promise<TestingApp> {
const config = new ConfigFactory().config;
const runtimeConfig = await createTestRuntimeConfig(
config.db.datasourceUrl,
config.indexer
);
const { buildAppModule } = await import('../../app.module');
const { tapModule, tapApp } = metadata;
const builder = Test.createTestingModule({
imports: [buildAppModule(globalThis.env), ...(metadata.imports ?? [])],
});
builder.overrideProvider(Mailer).useValue(new MockMailer());
builder
.overrideProvider(BACKEND_RUNTIME_CONFIG_PATHS)
.useValue([runtimeConfig.configPath]);
// when custom override happens
if (tapModule) {
tapModule(builder);
}
let module: TestingModule;
try {
module = await builder.compile();
} catch (error) {
await runtimeConfig.cleanup();
throw error;
}
module.get(ConfigFactory).override({
storages: {
avatar: {
storage: {
provider: 'assetpack',
bucket: 'avatars',
config: { path: runtimeConfig.storagePath },
},
},
blob: {
storage: {
provider: 'assetpack',
bucket: 'blobs',
config: { path: runtimeConfig.storagePath },
},
},
},
copilot: {
storage: {
provider: 'assetpack',
bucket: 'copilot',
config: { path: runtimeConfig.storagePath },
},
},
});
module.useCustomApplicationConstructor(TestingApp);
const app = module.createNestApplication<TestingApp>({
cors: true,
bodyParser: true,
rawBody: true,
});
const close = app.close.bind(app);
let closePromise: Promise<void> | undefined;
app.close = () => {
return (closePromise ??= (async () => {
try {
await close();
} finally {
await runtimeConfig.cleanup();
}
})());
};
const logger = new AFFiNELogger();
logger.setLogLevels([TEST_LOG_LEVEL]);
app.useLogger(logger);
app.use(cookieParser());
app.useBodyParser('raw', { limit: 1 * OneMB });
app.use(
graphqlUploadExpress({
maxFileSize: 100 * OneMB,
maxFiles: 5,
})
);
if (globalThis.env.role === ServerRole.Worker) {
app.useGlobalGuards(app.get(CloudThrottlerGuard));
} else {
app.useGlobalGuards(app.get(AuthGuard), app.get(CloudThrottlerGuard));
}
app.useGlobalInterceptors(app.get(CacheInterceptor));
app.useGlobalFilters(new GlobalExceptionFilter(app.getHttpAdapter()));
const adapter = new SocketIoAdapter(app);
app.useWebSocketAdapter(adapter);
app.enableShutdownHooks();
if (tapApp) {
tapApp(app);
}
try {
await app.init();
await app.get(BackendRuntimeProvider, { strict: false }).runMigrations();
await app.get(StorageRuntimeProvider, { strict: false }).runMigrations();
if (globalThis.env.isApi || globalThis.env.isFrontend) {
await app.get(IndexerService, { strict: false }).onApplicationBootstrap();
}
await app.listen(0);
} catch (error) {
await app.close();
throw error;
}
return app;
}

View File

@@ -0,0 +1,70 @@
import { app, createApp, e2e, Mockers } from '../test';
e2e('should render doc share page with apple-itunes-app meta tag', async t => {
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner,
});
const docSnapshot = await app.create(Mockers.DocSnapshot, {
workspaceId: workspace.id,
user: owner,
});
// set public to true
await app.create(Mockers.DocMeta, {
workspaceId: workspace.id,
docId: docSnapshot.id,
public: true,
});
const res = await app
.GET(`/workspace/${workspace.id}/${docSnapshot.id}`)
.expect(200)
.expect('Content-Type', 'text/html; charset=utf-8');
t.regex(
res.text,
/<meta name="apple-itunes-app" content="app-id=6736937980" \/>/
);
});
e2e.serial(
'should render doc share page without apple-itunes-app meta tag when selfhosted',
async t => {
const previousDeploymentType = globalThis.env.DEPLOYMENT_TYPE;
// @ts-expect-error override
globalThis.env.DEPLOYMENT_TYPE = 'selfhosted';
try {
await using app = await createApp();
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner,
});
const docSnapshot = await app.create(Mockers.DocSnapshot, {
workspaceId: workspace.id,
user: owner,
});
// set public to true
await app.create(Mockers.DocMeta, {
workspaceId: workspace.id,
docId: docSnapshot.id,
public: true,
});
const res = await app
.GET(`/workspace/${workspace.id}/${docSnapshot.id}`)
.expect(200)
.expect('Content-Type', 'text/html; charset=utf-8');
t.notRegex(
res.text,
/<meta name="apple-itunes-app" content="app-id=6736937980" \/>/
);
} finally {
// @ts-expect-error restore mutable test env singleton
globalThis.env.DEPLOYMENT_TYPE = previousDeploymentType;
}
}
);

View File

@@ -0,0 +1,349 @@
import { randomUUID } from 'node:crypto';
import {
getRecentlyUpdatedDocsQuery,
getWorkspacePageByIdQuery,
type GraphQLQuery,
publishPageMutation,
} from '@affine/graphql';
import { DocRole, WorkspaceRole } from '../../../models';
import { Mockers } from '../../mocks';
import { app, e2e } from '../test';
e2e('should get recently updated docs', async t => {
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
const docSnapshot1 = await app.create(Mockers.DocSnapshot, {
workspaceId: workspace.id,
user: owner,
});
const doc1 = await app.create(Mockers.DocMeta, {
workspaceId: workspace.id,
docId: docSnapshot1.id,
title: 'doc1',
});
const docSnapshot2 = await app.create(Mockers.DocSnapshot, {
workspaceId: workspace.id,
user: owner,
});
const doc2 = await app.create(Mockers.DocMeta, {
workspaceId: workspace.id,
docId: docSnapshot2.id,
title: 'doc2',
});
const docSnapshot3 = await app.create(Mockers.DocSnapshot, {
workspaceId: workspace.id,
user: owner,
});
const doc3 = await app.create(Mockers.DocMeta, {
workspaceId: workspace.id,
docId: docSnapshot3.id,
title: 'doc3',
});
const {
workspace: { recentlyUpdatedDocs },
} = await app.gql({
query: getRecentlyUpdatedDocsQuery,
variables: {
workspaceId: workspace.id,
pagination: {
first: 10,
},
},
});
t.is(recentlyUpdatedDocs.totalCount, 3);
t.is(recentlyUpdatedDocs.edges[0].node.id, doc3.docId);
t.is(recentlyUpdatedDocs.edges[0].node.title, doc3.title);
t.is(recentlyUpdatedDocs.edges[1].node.id, doc2.docId);
t.is(recentlyUpdatedDocs.edges[1].node.title, doc2.title);
t.is(recentlyUpdatedDocs.edges[2].node.id, doc1.docId);
t.is(recentlyUpdatedDocs.edges[2].node.title, doc1.title);
});
e2e('should filter recently updated docs by doc read permission', async t => {
const owner = await app.signup();
const member = await app.createUser();
await app.login(member);
await app.switchUser(owner);
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: member.id,
type: WorkspaceRole.Collaborator,
});
const privateSnapshot = await app.create(Mockers.DocSnapshot, {
workspaceId: workspace.id,
user: owner,
});
await app.create(Mockers.DocMeta, {
workspaceId: workspace.id,
docId: privateSnapshot.id,
title: 'private-doc',
defaultRole: DocRole.None,
});
const publicSnapshot = await app.create(Mockers.DocSnapshot, {
workspaceId: workspace.id,
user: owner,
});
const publicDoc = await app.create(Mockers.DocMeta, {
workspaceId: workspace.id,
docId: publicSnapshot.id,
title: 'public-doc',
defaultRole: DocRole.None,
public: true,
});
await app.switchUser(member);
const {
workspace: { recentlyUpdatedDocs },
} = await app.gql({
query: getRecentlyUpdatedDocsQuery,
variables: {
workspaceId: workspace.id,
pagination: {
first: 10,
},
},
});
t.is(recentlyUpdatedDocs.totalCount, 1);
t.deepEqual(
recentlyUpdatedDocs.edges.map(edge => edge.node.id),
[publicDoc.docId]
);
});
e2e('should reject publishing when doc snapshot does not exist', async t => {
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
const docId = randomUUID();
const result1 = await app.gql({
query: getWorkspacePageByIdQuery,
variables: { workspaceId: workspace.id, pageId: docId },
});
t.is(result1.workspace.doc.public, false);
await t.throwsAsync(
app.gql({
query: publishPageMutation,
variables: { workspaceId: workspace.id, pageId: docId },
}),
{
message: `Doc ${docId} under Space ${workspace.id} not found.`,
}
);
});
e2e('should get doc with title and summary', async t => {
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
const docSnapshot = await app.create(Mockers.DocSnapshot, {
workspaceId: workspace.id,
user: owner,
});
const doc = await app.create(Mockers.DocMeta, {
workspaceId: workspace.id,
docId: docSnapshot.id,
title: 'doc1',
summary: 'summary1',
});
const result = await app.gql({
query: getWorkspacePageByIdQuery,
variables: { workspaceId: workspace.id, pageId: doc.docId },
});
t.is(result.workspace.doc.title, doc.title);
t.is(result.workspace.doc.summary, doc.summary);
});
e2e('should get doc with title and null summary', async t => {
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
const docSnapshot = await app.create(Mockers.DocSnapshot, {
workspaceId: workspace.id,
user: owner,
});
const doc = await app.create(Mockers.DocMeta, {
workspaceId: workspace.id,
docId: docSnapshot.id,
title: 'doc1',
});
const result = await app.gql({
query: getWorkspacePageByIdQuery,
variables: { workspaceId: workspace.id, pageId: doc.docId },
});
t.is(result.workspace.doc.title, doc.title);
t.is(result.workspace.doc.summary, null);
});
e2e('should require owner or admin to query workspace docs', async t => {
const owner = await app.signup();
const member = await app.createUser();
await app.login(member);
await app.switchUser(owner);
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: member.id,
type: WorkspaceRole.Collaborator,
});
const docSnapshot = await app.create(Mockers.DocSnapshot, {
workspaceId: workspace.id,
user: owner,
});
await app.create(Mockers.DocMeta, {
workspaceId: workspace.id,
docId: docSnapshot.id,
title: 'private-doc',
defaultRole: DocRole.None,
});
await app.switchUser(member);
await t.throwsAsync(
app.gql({
query: {
id: 'workspaceDocsPermissionCheck',
op: 'workspaceDocsPermissionCheck',
query: `
query {
workspace(id: "${workspace.id}") {
docs(pagination: { first: 10 }) {
totalCount
}
}
}
`,
} satisfies GraphQLQuery,
variables: undefined,
})
);
});
e2e('should require Doc.Read to query workspace page meta', async t => {
const owner = await app.signup();
const member = await app.createUser();
await app.login(member);
await app.switchUser(owner);
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: member.id,
type: WorkspaceRole.Collaborator,
});
const docSnapshot = await app.create(Mockers.DocSnapshot, {
workspaceId: workspace.id,
user: owner,
});
const doc = await app.create(Mockers.DocMeta, {
workspaceId: workspace.id,
docId: docSnapshot.id,
title: 'private-doc',
defaultRole: DocRole.None,
});
await app.switchUser(member);
await t.throwsAsync(
app.gql({
query: {
id: 'workspacePageMetaPermissionCheck',
op: 'workspacePageMetaPermissionCheck',
query: `
query {
workspace(id: "${workspace.id}") {
pageMeta(pageId: "${doc.docId}") {
createdAt
}
}
}
`,
} satisfies GraphQLQuery,
variables: undefined,
})
);
});
e2e('should require Doc.Read to query doc histories', async t => {
const owner = await app.signup();
const member = await app.createUser();
await app.login(member);
await app.switchUser(owner);
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: member.id,
type: WorkspaceRole.Collaborator,
});
const docSnapshot = await app.create(Mockers.DocSnapshot, {
workspaceId: workspace.id,
user: owner,
});
const doc = await app.create(Mockers.DocMeta, {
workspaceId: workspace.id,
docId: docSnapshot.id,
title: 'private-doc',
defaultRole: DocRole.None,
});
await app.switchUser(member);
await t.throwsAsync(
app.gql({
query: {
id: 'workspaceDocHistoriesPermissionCheck',
op: 'workspaceDocHistoriesPermissionCheck',
query: `
query {
workspace(id: "${workspace.id}") {
histories(guid: "space:${doc.docId}") {
timestamp
}
}
}
`,
} satisfies GraphQLQuery,
variables: undefined,
})
);
});

View File

@@ -0,0 +1,96 @@
# Snapshot report for `src/__tests__/e2e/indexer/aggregate.spec.ts`
The actual snapshot is saved in `aggregate.spec.ts.snap`.
Generated by [AVA](https://avajs.dev).
## should aggregate by docId
> Snapshot 1
[
{
count: 3,
hits: {
nodes: [
{
fields: {
blockId: [
'block-2',
],
flavour: [
'affine:page',
],
},
highlights: {
content: [
'test3 <b>hello</b> title top1',
],
},
},
{
fields: {
blockId: [
'block-0',
],
flavour: [
'affine:text',
],
},
highlights: {
content: [
'test1 <b>hello world</b> top2',
],
},
},
],
},
key: 'doc-0',
},
{
count: 1,
hits: {
nodes: [
{
fields: {
blockId: [
'block-3',
],
flavour: [
'affine:text',
],
},
highlights: {
content: [
'test4 <b>hello world</b>',
],
},
},
],
},
key: 'doc-1',
},
{
count: 1,
hits: {
nodes: [
{
fields: {
blockId: [
'block-4',
],
flavour: [
'affine:text',
],
},
highlights: {
content: [
'test5 <b>hello</b>',
],
},
},
],
},
key: 'doc-2',
},
]

View File

@@ -0,0 +1,51 @@
# Snapshot report for `src/__tests__/e2e/indexer/search-docs.spec.ts`
The actual snapshot is saved in `search-docs.spec.ts.snap`.
Generated by [AVA](https://avajs.dev).
## should search docs by keyword
> Snapshot 1
[
{
blockId: 'block-0',
createdAt: '2025-04-22T00:00:00.000Z',
docId: 'doc-0',
highlight: 'test1 <b>hello</b>',
title: '',
updatedAt: '2025-04-22T00:00:00.000Z',
},
{
blockId: 'block-2',
createdAt: '2025-03-22T00:00:00.000Z',
docId: 'doc-2',
highlight: 'test3 <b>hello</b>',
title: '',
updatedAt: '2025-03-22T03:00:01.000Z',
},
{
blockId: 'block-1',
createdAt: '2021-04-22T00:00:00.000Z',
docId: 'doc-1',
highlight: 'test2 <b>hello</b>',
title: '',
updatedAt: '2021-04-22T00:00:00.000Z',
},
]
## should search docs by keyword with limit 1
> Snapshot 1
[
{
blockId: 'block-0',
createdAt: '2025-04-22T00:00:00.000Z',
docId: 'doc-0',
highlight: 'test1 <b>hello</b>',
title: '',
updatedAt: '2025-04-22T00:00:00.000Z',
},
]

View File

@@ -0,0 +1,172 @@
# Snapshot report for `src/__tests__/e2e/indexer/search.spec.ts`
The actual snapshot is saved in `search.spec.ts.snap`.
Generated by [AVA](https://avajs.dev).
## should search with query
> Snapshot 1
[
{
fields: {
ref: [
'{"foo": "bar1"}',
'{"foo": "bar3"}',
],
refDocId: [
'doc-0',
'doc-2',
],
},
highlights: null,
},
{
fields: {
ref: [
'{"foo": "bar1"}',
],
refDocId: [
'doc-0',
],
},
highlights: null,
},
]
## should filter no read permission docs on team workspace
> Snapshot 1
[
{
fields: {
blockId: [
'block-0',
],
docId: [
'doc-0',
],
},
highlights: null,
},
{
fields: {
blockId: [
'block-2',
],
docId: [
'doc-2',
],
ref: [
'{"foo": "bar1"}',
'{"foo": "bar3"}',
],
refDocId: [
'doc-0',
'doc-2',
],
},
highlights: null,
},
{
fields: {
blockId: [
'block-1',
],
docId: [
'doc-1',
],
ref: [
'{"foo": "bar1"}',
],
refDocId: [
'doc-0',
],
},
highlights: null,
},
]
> Snapshot 2
[
{
fields: {
blockId: [
'block-0',
],
docId: [
'doc-0',
],
},
highlights: null,
},
{
fields: {
blockId: [
'block-1',
],
docId: [
'doc-1',
],
ref: [
'{"foo": "bar1"}',
],
refDocId: [
'doc-0',
],
},
highlights: null,
},
]
## should return empty results when search not match any docs
> Snapshot 1
{
workspace: {
search: {
nodes: [],
pagination: {
count: 0,
hasMore: false,
nextCursor: null,
},
},
},
}
## should return empty nodes when docId not exists
> Snapshot 1
{
workspace: {
search: {
nodes: [],
pagination: {
count: 0,
hasMore: false,
nextCursor: null,
},
},
},
}
## should empty doc summary string when doc exists but no summary
> Snapshot 1
[
{
fields: {
summary: [
'',
],
},
highlights: null,
},
]

View File

@@ -0,0 +1,68 @@
import {
indexerAggregateQuery,
SearchQueryType,
SearchTable,
} from '@affine/graphql';
import { Config } from '../../../base';
import { createDocWithMarkdown } from '../../../native';
import { Mockers } from '../../mocks';
import {
addDocumentToRoot,
app,
e2e,
reconcileSearchProjection,
} from '../test';
const indexerE2e = app.get(Config).indexer.enabled ? e2e.serial : e2e.skip;
indexerE2e('should aggregate by docId', async t => {
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner,
snapshot: true,
});
for (const [docId, markdown] of [
['doc-0', 'hello world\n\nhello again'],
['doc-1', 'hello world'],
] as const) {
await app.create(Mockers.DocMeta, { workspaceId: workspace.id, docId });
await addDocumentToRoot(workspace.id, docId);
await app.create(Mockers.DocSnapshot, {
workspaceId: workspace.id,
docId,
user: owner,
blob: createDocWithMarkdown(docId, markdown, docId),
});
await reconcileSearchProjection();
}
const result = await app.gql({
query: indexerAggregateQuery,
variables: {
id: workspace.id,
input: {
table: SearchTable.block,
query: {
type: SearchQueryType.match,
field: 'content',
match: 'hello',
},
field: 'docId',
options: {
pagination: { limit: 50, skip: 0 },
hits: {
pagination: { limit: 2, skip: 0 },
fields: ['docId', 'blockId', 'content'],
},
},
},
},
});
t.is(result.workspace.aggregate.pagination.count, 2);
t.deepEqual(
result.workspace.aggregate.buckets.map(bucket => bucket.key).sort(),
['doc-0', 'doc-1']
);
});

View File

@@ -0,0 +1,182 @@
import {
indexerSearchDocsQuery,
indexerSearchQuery,
SearchQueryType,
SearchTable,
} from '@affine/graphql';
import { Config } from '../../../base';
import { DocRole } from '../../../models';
import { createDocWithMarkdown } from '../../../native';
import { Mockers } from '../../mocks';
import {
addDocumentToRoot,
app,
e2e,
reconcileSearchProjection,
} from '../test';
const manticoreSearchEnabled =
app.get(Config).indexer.enabled &&
app.get(Config).indexer.provider.type === 'manticoresearch';
const manticoreSearchE2e = manticoreSearchEnabled ? e2e.serial : e2e.skip;
async function indexDocument(
workspaceId: string,
user: { id: string },
docId: string,
markdown: string,
defaultRole = DocRole.Manager
) {
await app.create(Mockers.DocMeta, { workspaceId, docId, defaultRole });
await addDocumentToRoot(workspaceId, docId);
await app.create(Mockers.DocSnapshot, {
workspaceId,
docId,
user,
blob: createDocWithMarkdown(docId, markdown, docId),
});
await reconcileSearchProjection();
}
async function searchPage(
workspaceId: string,
match: string,
pagination: { limit: number; cursor?: string }
) {
const result = await app.gql({
query: indexerSearchQuery,
variables: {
id: workspaceId,
input: {
table: SearchTable.block,
query: {
type: SearchQueryType.match,
field: 'content',
match,
},
options: {
fields: ['docId', 'blockId'],
pagination,
},
},
},
});
return result.workspace.search;
}
async function searchCount(workspaceId: string, match: string) {
return (await searchPage(workspaceId, match, { limit: 20 })).pagination.count;
}
async function searchDocsCount(workspaceId: string, keyword: string) {
const result = await app.gql({
query: indexerSearchDocsQuery,
variables: {
id: workspaceId,
input: { keyword, limit: 20 },
},
});
return result.workspace.searchDocs.length;
}
manticoreSearchE2e(
'indexes and searches through the Manticore Search provider',
async t => {
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner,
snapshot: true,
});
const docId = `manticore-basic-${Date.now()}`;
const marker = 'manticorebasicmarker';
await indexDocument(
workspace.id,
owner,
docId,
`${marker} first block\n\nsecond block`
);
await indexDocument(
workspace.id,
owner,
`${docId}-second`,
`${marker} from a second document`
);
t.is(await searchCount(workspace.id, marker), 2);
const basicDocs = await app.gql({
query: indexerSearchDocsQuery,
variables: {
id: workspace.id,
input: { keyword: marker, limit: 2 },
},
});
t.is(basicDocs.workspace.searchDocs.length, 2);
await t.throwsAsync(
app.gql({
query: indexerSearchDocsQuery,
variables: {
id: workspace.id,
input: { keyword: marker, limit: 0 },
},
})
);
const firstPage = await searchPage(workspace.id, marker, { limit: 1 });
const nextCursor = firstPage.pagination.nextCursor;
t.truthy(nextCursor);
const secondPage = await searchPage(workspace.id, marker, {
limit: 1,
cursor: nextCursor ?? undefined,
});
t.is(secondPage.nodes.length, 1);
t.not(
firstPage.nodes[0]?.fields.docId[0],
secondPage.nodes[0]?.fields.docId[0]
);
}
);
manticoreSearchE2e(
'enforces ACL changes through the Manticore Search provider',
async t => {
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner,
snapshot: true,
});
await app.create(Mockers.TeamWorkspace, { id: workspace.id });
const marker = `manticore-acl-${Date.now()}`;
await indexDocument(
workspace.id,
owner,
`${marker}-doc`,
marker,
DocRole.None
);
const member = await app.signup();
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: member.id,
});
await reconcileSearchProjection();
t.is(await searchCount(workspace.id, marker), 0);
t.is(await searchDocsCount(workspace.id, marker), 0);
await app.create(Mockers.DocUser, {
workspaceId: workspace.id,
docId: `${marker}-doc`,
userId: member.id,
type: DocRole.Reader,
});
await reconcileSearchProjection();
t.true((await searchCount(workspace.id, marker)) > 0);
t.true((await searchDocsCount(workspace.id, marker)) > 0);
await app.models.docUser.delete(workspace.id, `${marker}-doc`, member.id);
await reconcileSearchProjection();
t.is(await searchCount(workspace.id, marker), 0);
t.is(await searchDocsCount(workspace.id, marker), 0);
}
);

View File

@@ -0,0 +1,85 @@
import {
indexerAggregateQuery,
SearchQueryType,
SearchTable,
} from '@affine/graphql';
import { Config } from '../../../base';
import { createDocWithMarkdown } from '../../../native';
import { Mockers } from '../../mocks';
import {
addDocumentToRoot,
app,
e2e,
reconcileSearchProjection,
} from '../test';
const indexer = app.get(Config).indexer;
const remoteE2e =
indexer.enabled && indexer.provider.type === 'elasticsearch'
? e2e.serial
: e2e.skip;
async function indexDocument(
workspaceId: string,
user: { id: string },
docId: string,
markdown: string
) {
await app.create(Mockers.DocMeta, { workspaceId, docId });
await addDocumentToRoot(workspaceId, docId);
await app.create(Mockers.DocSnapshot, {
workspaceId,
docId,
user,
blob: createDocWithMarkdown(docId, markdown, docId),
});
await reconcileSearchProjection();
}
remoteE2e(
'exposes remote aggregation through the GraphQL contract',
async t => {
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner,
snapshot: true,
});
const suffix = Date.now();
for (const index of [0, 1, 2]) {
await indexDocument(
workspace.id,
owner,
`remote-aggregate-${suffix}-${index}`,
`remote aggregate marker ${index}`
);
}
const result = await app.gql({
query: indexerAggregateQuery,
variables: {
id: workspace.id,
input: {
table: SearchTable.block,
query: {
type: SearchQueryType.match,
field: 'content',
match: 'remote aggregate marker',
},
field: 'docId',
options: {
pagination: { limit: 2, skip: 0 },
hits: {
pagination: { limit: 1, skip: 0 },
fields: ['docId', 'blockId'],
},
},
},
},
});
t.is(result.workspace.aggregate.buckets.length, 2);
t.is(result.workspace.aggregate.pagination.count, 2);
t.true(result.workspace.aggregate.pagination.hasMore);
}
);

View File

@@ -0,0 +1,59 @@
import { indexerSearchDocsQuery } from '@affine/graphql';
import { Config } from '../../../base';
import { createDocWithMarkdown } from '../../../native';
import { Mockers } from '../../mocks';
import {
addDocumentToRoot,
app,
e2e,
reconcileSearchProjection,
} from '../test';
const indexerE2e = app.get(Config).indexer.enabled ? e2e.serial : e2e.skip;
indexerE2e('should search docs by keyword', async t => {
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner,
snapshot: true,
});
for (const docId of ['doc-0', 'doc-1', 'doc-2']) {
await app.create(Mockers.DocMeta, { workspaceId: workspace.id, docId });
await addDocumentToRoot(workspace.id, docId);
await app.create(Mockers.DocSnapshot, {
workspaceId: workspace.id,
docId,
user: owner,
blob: createDocWithMarkdown(docId, `${docId} hello`, docId),
});
await reconcileSearchProjection();
}
const search = app.gql({
query: indexerSearchDocsQuery,
variables: { id: workspace.id, input: { keyword: 'hello', limit: 2 } },
});
const result = await search;
t.is(result.workspace.searchDocs.length, 2);
t.true(result.workspace.searchDocs.every(doc => doc.highlight.length > 0));
});
indexerE2e(
'should search docs by keyword failed when workspace is no permission',
async t => {
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner,
snapshot: true,
});
await app.signup();
await t.throwsAsync(
app.gql({
query: indexerSearchDocsQuery,
variables: { id: workspace.id, input: { keyword: 'hello' } },
}),
{ message: /You do not have permission to access Space/ }
);
}
);

View File

@@ -0,0 +1,240 @@
import {
indexerSearchQuery,
SearchQueryType,
SearchTable,
} from '@affine/graphql';
import { Config } from '../../../base';
import { DocRole } from '../../../models';
import { createDocWithMarkdown } from '../../../native';
import { Mockers } from '../../mocks';
import {
addDocumentToRoot,
app,
e2e,
reconcileSearchProjection,
} from '../test';
const indexerE2e = app.get(Config).indexer.enabled ? e2e.serial : e2e.skip;
async function indexDoc(
workspaceId: string,
user: { id: string },
docId: string,
markdown: string,
defaultRole = DocRole.Manager
) {
await app.create(Mockers.DocMeta, { workspaceId, docId, defaultRole });
await addDocumentToRoot(workspaceId, docId);
await app.create(Mockers.DocSnapshot, {
workspaceId,
docId,
user,
blob: createDocWithMarkdown(docId, markdown, docId),
});
await reconcileSearchProjection();
}
indexerE2e('should search with query', async t => {
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner,
snapshot: true,
});
await indexDoc(
workspace.id,
owner,
'doc-0',
'searchable first\n\nsearchable second'
);
const result = await app.gql({
query: indexerSearchQuery,
variables: {
id: workspace.id,
input: {
table: SearchTable.block,
query: {
type: SearchQueryType.match,
field: 'content',
match: 'searchable',
},
options: {
fields: ['docId', 'blockId', 'content'],
highlights: [{ field: 'content', before: '<b>', end: '</b>' }],
pagination: { limit: 100 },
},
},
},
});
t.true(result.workspace.search.pagination.count > 0);
t.true(
result.workspace.search.nodes.every(node =>
node.fields.docId.includes('doc-0')
)
);
t.true(
result.workspace.search.nodes.some(node =>
node.highlights?.content?.some((value: string) => value.includes('<b>'))
)
);
const firstPage = await app.gql({
query: indexerSearchQuery,
variables: {
id: workspace.id,
input: {
table: SearchTable.block,
query: {
type: SearchQueryType.match,
field: 'content',
match: 'searchable',
},
options: {
fields: ['docId', 'blockId'],
pagination: { limit: 1 },
},
},
},
});
const secondPage = await app.gql({
query: indexerSearchQuery,
variables: {
id: workspace.id,
input: {
table: SearchTable.block,
query: {
type: SearchQueryType.match,
field: 'content',
match: 'searchable',
},
options: {
fields: ['docId', 'blockId'],
pagination: {
limit: 1,
cursor: firstPage.workspace.search.pagination.nextCursor,
},
},
},
},
});
t.not(
firstPage.workspace.search.nodes[0].fields.blockId[0],
secondPage.workspace.search.nodes[0].fields.blockId[0]
);
});
indexerE2e(
'should filter no read permission docs on team workspace',
async t => {
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner,
snapshot: true,
});
await app.create(Mockers.TeamWorkspace, { id: workspace.id });
await indexDoc(
workspace.id,
owner,
'private-doc',
'team secret searchable',
DocRole.None
);
const member = await app.signup();
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: member.id,
});
await reconcileSearchProjection();
const denied = await app.gql({
query: indexerSearchQuery,
variables: {
id: workspace.id,
input: {
table: SearchTable.block,
query: {
type: SearchQueryType.match,
field: 'content',
match: 'secret',
},
options: { fields: ['docId'], pagination: { limit: 10 } },
},
},
});
t.is(denied.workspace.search.pagination.count, 0);
await app.create(Mockers.DocUser, {
workspaceId: workspace.id,
docId: 'private-doc',
userId: member.id,
type: DocRole.Reader,
});
await reconcileSearchProjection();
const allowed = await app.gql({
query: indexerSearchQuery,
variables: {
id: workspace.id,
input: {
table: SearchTable.block,
query: {
type: SearchQueryType.match,
field: 'content',
match: 'secret',
},
options: { fields: ['docId'], pagination: { limit: 10 } },
},
},
});
t.true(allowed.workspace.search.pagination.count > 0);
await app.models.docUser.delete(workspace.id, 'private-doc', member.id);
await reconcileSearchProjection();
const revoked = await app.gql({
query: indexerSearchQuery,
variables: {
id: workspace.id,
input: {
table: SearchTable.block,
query: {
type: SearchQueryType.match,
field: 'content',
match: 'secret',
},
options: { fields: ['docId'], pagination: { limit: 10 } },
},
},
});
t.is(revoked.workspace.search.pagination.count, 0);
}
);
indexerE2e(
'should return empty results when search not match any docs',
async t => {
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner,
snapshot: true,
});
await reconcileSearchProjection();
const result = await app.gql({
query: indexerSearchQuery,
variables: {
id: workspace.id,
input: {
table: SearchTable.doc,
query: {
type: SearchQueryType.match,
field: 'title',
match: 'absent',
},
options: { fields: ['docId'], pagination: { limit: 10 } },
},
},
});
t.is(result.workspace.search.pagination.count, 0);
t.deepEqual(result.workspace.search.nodes, []);
}
);

View File

@@ -0,0 +1,301 @@
import { installLicenseMutation, SubscriptionVariant } from '@affine/graphql';
import Sinon from 'sinon';
import { EventBus } from '../../../base';
import { BackendRuntimeProvider } from '../../../core/backend-runtime';
import { WorkspaceRole } from '../../../models';
import { LicenseService } from '../../../plugins/license/service';
import {
app as sharedApp,
createApp,
e2e,
MockedUser,
Mockers,
refreshEnv,
type TestingApp,
} from '../test';
let app: TestingApp;
let owner: MockedUser;
e2e.before(async () => {
await sharedApp.close();
process.env.DEPLOYMENT_TYPE = 'selfhosted';
refreshEnv();
app = await createApp();
owner = await app.signup();
});
e2e.beforeEach(async () => {
await app.login(owner);
});
e2e.after.always(async () => {
await app.close();
});
e2e(
'license upload maps native output and publishes only after completion',
async t => {
const workspace = await app.create(Mockers.Workspace, { owner });
const bytes = Buffer.from('opaque license upload');
const installed = {
workspaceId: workspace.id,
key: 'license-key',
validateKey: 'generation',
quantity: 20,
recurring: 'lifetime',
variant: 'onetime',
installedAt: '2026-09-08T00:00:00.000Z',
validatedAt: '2026-09-08T00:00:00.000Z',
expiredAt: '2027-09-08T00:00:00.000Z',
license: bytes,
};
const install = Sinon.stub(
app.get(BackendRuntimeProvider),
'installTeamLicenseFileV1'
);
const emit = Sinon.stub(app.get(EventBus), 'emitAsync').resolves([]);
t.teardown(() => {
install.restore();
emit.restore();
});
let complete!: (value: typeof installed) => void;
install.returns(
new Promise(resolve => {
complete = resolve;
})
);
const pending = app.get(LicenseService).installLicense(workspace.id, bytes);
t.false(emit.called);
complete(installed);
await pending;
t.true(emit.calledOnce);
t.like(emit.firstCall.args[1], {
workspaceId: workspace.id,
quantity: 20,
recurring: 'lifetime',
});
install.resetHistory();
install.resolves(installed);
const result = await app.gql({
query: installLicenseMutation,
variables: {
workspaceId: workspace.id,
license: new File([bytes], 'license.lic'),
},
});
t.deepEqual(install.lastCall.args, [workspace.id, bytes]);
t.is(result.installLicense.variant, SubscriptionVariant.Onetime);
t.is(result.installLicense.quantity, installed.quantity);
t.is(result.installLicense.expiredAt, installed.expiredAt);
}
);
e2e(
'license upload enforces payment permission before invoking native',
async t => {
const workspace = await app.create(Mockers.Workspace, { owner });
const user = await app.signup();
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: user.id,
type: WorkspaceRole.Collaborator,
});
const install = Sinon.stub(
app.get(BackendRuntimeProvider),
'installTeamLicenseFileV1'
);
t.teardown(() => install.restore());
await t.throwsAsync(
app.gql({
query: installLicenseMutation,
variables: {
workspaceId: workspace.id,
license: new File(['opaque'], 'license.lic'),
},
}),
{ message: `You do not have permission to access Space ${workspace.id}.` }
);
t.false(install.called);
}
);
e2e(
'license upload translates native errors without publishing activation',
async t => {
const workspace = await app.create(Mockers.Workspace, { owner });
const install = Sinon.stub(
app.get(BackendRuntimeProvider),
'installTeamLicenseFileV1'
);
const emit = Sinon.stub(app.get(EventBus), 'emitAsync').resolves([]);
t.teardown(() => {
install.restore();
emit.restore();
});
for (const [code, message] of [
[
'license_workspace_mismatch',
'Invalid license to activate. Workspace mismatched with license.',
],
['license_expired', 'Invalid license to activate. license expired'],
]) {
install.rejects(new Error(code));
await t.throwsAsync(
app.gql({
query: installLicenseMutation,
variables: {
workspaceId: workspace.id,
license: new File(['opaque'], 'license.lic'),
},
}),
{ message }
);
}
t.false(emit.calledWith('workspace.subscription.activated'));
}
);
e2e(
'license HTTP routes keep the published protocol separate from signed requests',
async t => {
const runtime = app.get(BackendRuntimeProvider);
const command = Sinon.stub(runtime, 'executePaymentCommandV1');
const portal = Sinon.stub(
runtime,
'createLicenseCustomerPortalV1'
).resolves('https://billing.example/renew');
t.teardown(() => {
command.restore();
portal.restore();
});
const generation = 'ac8f50e4-6113-4a1e-b46e-0c0a3f99e1cf';
const legacy = {
plan: 'selfhostedteam',
recurring: 'monthly',
quantity: 10,
endAt: 2_000_000_000_000,
};
command.resolves({ validateKey: generation, license: legacy });
for (const endpoint of ['activate', 'health']) {
const request =
endpoint === 'activate'
? app.POST('/api/team/licenses/key/activate')
: app
.GET('/api/team/licenses/key/health')
.set('x-validate-key', generation);
const response = await request.expect(200);
t.deepEqual(response.body, legacy);
t.is(response.headers['x-next-validate-key'], generation);
t.like(command.lastCall.args[0], {
action:
endpoint === 'activate'
? 'activate_legacy_license'
: 'check_legacy_license_health',
licenseKey: 'key',
});
}
const bytes = Buffer.from('opaque signed envelope');
command.resolves({
license: bytes.toString('base64'),
validateKey: generation,
recurring: 'monthly',
});
const signed = await app
.POST('/api/team/v1/licenses/key/health')
.set('x-validate-key', generation)
.send({ workspaceId: 'workspace' })
.expect(200);
t.deepEqual(signed.body, bytes);
t.deepEqual(command.lastCall.args[0], {
action: 'check_license_health',
licenseKey: 'key',
validateKey: generation,
workspaceId: 'workspace',
});
t.is(signed.headers['x-license-recurring'], 'monthly');
await app
.POST('/api/team/v1/licenses/key/activate')
.send({ workspaceId: 'workspace', operationId: generation })
.expect(200);
t.deepEqual(command.lastCall.args[0], {
action: 'activate_license',
licenseKey: 'key',
workspaceId: 'workspace',
operationId: generation,
});
for (const version of ['', '/v1']) {
for (const endpoint of ['seats', 'recurring']) {
command.resolves({ status: 'pending' });
const request = app.POST(
`/api/team${version}/licenses/key/${endpoint}`
);
if (version) request.set('x-validate-key', generation);
await request
.send(endpoint === 'seats' ? { seats: 12 } : { recurring: 'yearly' })
.expect(201);
t.is(
command.lastCall.args[0].validateKey,
version ? generation : undefined
);
}
const request = app.POST(
`/api/team${version}/licenses/key/create-customer-portal`
);
if (version) request.set('x-validate-key', generation);
const response = await request.expect(201);
t.deepEqual(response.body, { url: 'https://billing.example/renew' });
t.is(portal.lastCall.args[1], version ? generation : undefined);
const deactivate = app.POST(
`/api/team${version}/licenses/key/deactivate`
);
if (version) deactivate.set('x-validate-key', generation);
await deactivate.expect(201);
t.like(command.lastCall.args[0], {
action: version ? 'deactivate_license' : 'deactivate_legacy_license',
licenseKey: 'key',
});
t.is(
command.lastCall.args[0].validateKey,
version ? generation : undefined
);
}
for (const reason of [
'payment_busy',
'license_private_key_missing',
'database unavailable',
]) {
command.rejects(new Error(reason));
for (const version of ['', '/v1']) {
const request = version
? app
.POST('/api/team/v1/licenses/key/health')
.send({ workspaceId: 'workspace' })
: app.GET('/api/team/licenses/key/health');
const response = await request
.set('x-validate-key', generation)
.expect(500);
t.is(response.body.name, 'INTERNAL_SERVER_ERROR');
}
}
command.rejects(new Error('license_expired'));
const expired = await app
.POST('/api/team/v1/licenses/key/health')
.set('x-validate-key', generation)
.send({ workspaceId: 'workspace' })
.expect(400);
t.is(expired.body.name, 'LICENSE_EXPIRED');
command.rejects(new Error('same_subscription_recurring'));
const unchanged = await app
.POST('/api/team/v1/licenses/key/recurring')
.set('x-validate-key', generation)
.send({ recurring: 'monthly' })
.expect(400);
t.is(unchanged.body.name, 'INVALID_LICENSE_UPDATE_PARAMS');
command.resetHistory();
await app.POST('/api/team/v1/licenses/key/deactivate').expect(400);
t.false(command.called);
}
);

View File

@@ -0,0 +1,763 @@
import { randomUUID } from 'node:crypto';
import {
DocMode,
listNotificationsQuery,
MentionNotificationBodyType,
mentionUserMutation,
NotificationObjectType,
NotificationType,
readAllNotificationsMutation,
readNotificationMutation,
} from '@affine/graphql';
import { Mockers } from '../../mocks';
import { createRealtimeClient, realtimeRequest } from '../realtime';
import { app, e2e } from '../test';
async function init() {
const member = await app.create(Mockers.User);
const owner = await app.create(Mockers.User);
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
name: 'test-workspace-name',
avatarKey: 'test-avatar-key',
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: member.id,
});
return {
member,
owner,
workspace,
};
}
e2e('should mention user in a doc', async t => {
const { member, owner, workspace } = await init();
await app.login(owner);
const { mentionUser: mentionId } = await app.gql({
query: mentionUserMutation,
variables: {
input: {
userId: member.id,
workspaceId: workspace.id,
doc: {
id: 'doc-id-1',
title: 'doc-title-1',
blockId: 'block-id-1',
mode: DocMode.page,
},
},
},
});
t.truthy(mentionId);
// mention user at another doc
const { mentionUser: mentionId2 } = await app.gql({
query: mentionUserMutation,
variables: {
input: {
userId: member.id,
workspaceId: workspace.id,
doc: {
id: 'doc-id-2',
title: 'doc-title-2',
elementId: 'element-id-2',
mode: DocMode.edgeless,
},
},
},
});
t.truthy(mentionId2);
await app.login(member);
const result = await app.gql({
query: listNotificationsQuery,
variables: {
pagination: {
first: 10,
offset: 0,
},
},
});
const notifications = result.currentUser!.notifications.edges.map(
edge => edge.node
);
t.is(notifications.length, 2);
const notification = notifications[1] as NotificationObjectType;
t.is(notification.read, false);
t.truthy(notification.createdAt);
t.truthy(notification.updatedAt);
const body = notification.body as MentionNotificationBodyType;
t.is(body.workspace!.id, workspace.id);
t.is(body.doc.id, 'doc-id-1');
t.is(body.doc.title, 'doc-title-1');
t.is(body.doc.blockId, 'block-id-1');
t.is(body.doc.mode, DocMode.page);
t.is(body.createdByUser!.id, owner.id);
t.is(body.createdByUser!.name, owner.name);
t.is(body.workspace!.id, workspace.id);
t.is(body.workspace!.name, 'test-workspace-name');
t.falsy(body.workspace!.avatarUrl);
const notification2 = notifications[0] as NotificationObjectType;
t.is(notification2.read, false);
t.truthy(notification2.createdAt);
t.truthy(notification2.updatedAt);
const body2 = notification2.body as MentionNotificationBodyType;
t.is(body2.workspace!.id, workspace.id);
t.is(body2.doc.id, 'doc-id-2');
t.is(body2.doc.title, 'doc-title-2');
t.is(body2.doc.elementId, 'element-id-2');
t.is(body2.doc.mode, DocMode.edgeless);
t.is(body2.createdByUser!.id, owner.id);
t.is(body2.workspace!.id, workspace.id);
t.is(body2.workspace!.name, 'test-workspace-name');
t.falsy(body2.workspace!.avatarUrl);
});
e2e(
'notification totalCount selection does not load the notification list',
async t => {
const { member, owner, workspace } = await init();
await app.login(owner);
await app.gql({
query: mentionUserMutation,
variables: {
input: {
userId: member.id,
workspaceId: workspace.id,
doc: {
id: 'count-only-doc',
title: 'count-only-doc',
mode: DocMode.page,
},
},
},
});
await app.login(member);
const result = (await app.gql({
query: {
...listNotificationsQuery,
op: 'CountOnlyNotifications',
query: `
query CountOnlyNotifications($pagination: PaginationInput!) {
currentUser {
notifications(pagination: $pagination) {
totalCount
}
}
}
`,
},
variables: { pagination: { first: 10, offset: 0 } },
})) as unknown as {
currentUser: { notifications: { totalCount: number } };
};
t.is(result.currentUser.notifications.totalCount, 1);
}
);
e2e('should mention doc mode support string value', async t => {
const { member, owner, workspace } = await init();
await app.login(owner);
const { mentionUser: mentionId } = await app.gql({
query: mentionUserMutation,
variables: {
input: {
userId: member.id,
workspaceId: workspace.id,
doc: {
id: 'doc-id-1',
title: 'doc-title-1',
blockId: 'block-id-1',
mode: 'page' as DocMode,
},
},
},
});
t.truthy(mentionId);
});
e2e('should throw error when mention user has no Doc.Read role', async t => {
const { owner, workspace } = await init();
const otherUser = await app.create(Mockers.User);
await app.login(owner);
const docId = randomUUID();
await t.throwsAsync(
app.gql({
query: mentionUserMutation,
variables: {
input: {
userId: otherUser.id,
workspaceId: workspace.id,
doc: {
id: docId,
title: 'doc-title-1',
blockId: 'block-id-1',
mode: DocMode.page,
},
},
},
}),
{
message: `Mentioned user can not access doc ${docId}.`,
}
);
});
e2e('should throw error when mention a not exists user', async t => {
const { owner, workspace } = await init();
await app.login(owner);
const docId = randomUUID();
await t.throwsAsync(
app.gql({
query: mentionUserMutation,
variables: {
input: {
userId: 'user-id-not-exists',
workspaceId: workspace.id,
doc: {
id: docId,
title: 'doc-title-1',
blockId: 'block-id-1',
mode: DocMode.page,
},
},
},
}),
{
message: `Mentioned user can not access doc ${docId}.`,
}
);
});
e2e('should not mention user oneself', async t => {
const { owner, workspace } = await init();
await app.login(owner);
await t.throwsAsync(
app.gql({
query: mentionUserMutation,
variables: {
input: {
userId: owner.id,
workspaceId: workspace.id,
doc: {
id: 'doc-id-1',
title: 'doc-title-1',
blockId: 'block-id-1',
mode: DocMode.page,
},
},
},
}),
{
message: 'You can not mention yourself.',
}
);
});
e2e('should mark notification as read', async t => {
const { member, owner, workspace } = await init();
await app.login(owner);
const { mentionUser: mentionId } = await app.gql({
query: mentionUserMutation,
variables: {
input: {
userId: member.id,
workspaceId: workspace.id,
doc: {
id: 'doc-id-1',
title: 'doc-title-1',
blockId: 'block-id-1',
mode: DocMode.page,
},
},
},
});
t.truthy(mentionId);
await app.login(member);
const result = await app.gql({
query: listNotificationsQuery,
variables: {
pagination: {
first: 10,
offset: 0,
},
},
});
t.truthy(mentionId);
const notifications = result.currentUser!.notifications.edges.map(
edge => edge.node
);
for (const notification of notifications) {
t.is(notification.read, false);
await app.gql({
query: readNotificationMutation,
variables: {
id: notification.id,
},
});
}
const socket = await createRealtimeClient(app, member);
t.teardown(() => socket.disconnect());
const count = await realtimeRequest(socket, 'notification.count.get', {});
t.is(count.count, 0);
// read again should work
for (const notification of notifications) {
t.is(notification.read, false);
await app.gql({
query: readNotificationMutation,
variables: {
id: notification.id,
},
});
}
});
e2e('should throw error when read the other user notification', async t => {
const { member, owner, workspace } = await init();
await app.login(owner);
const { mentionUser: mentionId } = await app.gql({
query: mentionUserMutation,
variables: {
input: {
userId: member.id,
workspaceId: workspace.id,
doc: {
id: 'doc-id-1',
title: 'doc-title-1',
blockId: 'block-id-1',
mode: DocMode.page,
},
},
},
});
t.truthy(mentionId);
await app.login(member);
const result = await app.gql({
query: listNotificationsQuery,
variables: {
pagination: {
first: 10,
offset: 0,
},
},
});
const notifications = result.currentUser!.notifications.edges.map(
edge => edge.node
);
t.is(notifications[0].read, false);
await app.login(owner);
await t.throwsAsync(
app.gql({
query: readNotificationMutation,
variables: {
id: notifications[0].id,
},
}),
{
message: 'Notification not found.',
}
);
// notification not exists
await t.throwsAsync(
app.gql({
query: readNotificationMutation,
variables: {
id: 'notification-id-not-exists',
},
}),
{
message: 'Notification not found.',
}
);
});
e2e('should throw error when mention call with invalid params', async t => {
await app.signup();
await t.throwsAsync(
app.gql({
query: mentionUserMutation,
variables: {
input: {
userId: '1',
workspaceId: '1',
doc: {
id: '1',
title: 'doc-title-1'.repeat(100),
blockId: '1',
mode: DocMode.page,
},
},
},
}),
{
message: /Validation error/,
}
);
});
e2e('should throw error when mention mode value is invalid', async t => {
await app.signup();
await t.throwsAsync(
app.gql({
query: mentionUserMutation,
variables: {
input: {
userId: randomUUID(),
workspaceId: randomUUID(),
doc: {
id: randomUUID(),
title: 'doc-title-1',
blockId: 'block-id-1',
mode: 'invalid-mode' as DocMode,
},
},
},
}),
{
message:
'Variable "$input" got invalid value "invalid-mode" at "input.doc.mode"; Value "invalid-mode" does not exist in "DocMode" enum.',
}
);
});
e2e('should get empty notifications', async t => {
await app.signup();
const result = await app.gql({
query: listNotificationsQuery,
variables: {
pagination: {
first: 10,
offset: 0,
},
},
});
const notifications = result.currentUser!.notifications.edges.map(
edge => edge.node
);
t.is(notifications.length, 0);
t.is(result.currentUser!.notifications.totalCount, 0);
});
e2e('should list and count notifications', async t => {
const { member, owner, workspace } = await init();
await app.login(owner);
await app.gql({
query: mentionUserMutation,
variables: {
input: {
userId: member.id,
workspaceId: workspace.id,
doc: {
id: 'doc-id-1',
title: 'doc-title-1',
blockId: 'block-id-1',
mode: DocMode.page,
},
},
},
});
await app.gql({
query: mentionUserMutation,
variables: {
input: {
userId: member.id,
workspaceId: workspace.id,
doc: {
id: 'doc-id-2',
title: 'doc-title-2',
blockId: 'block-id-2',
mode: DocMode.page,
},
},
},
});
await app.gql({
query: mentionUserMutation,
variables: {
input: {
userId: member.id,
workspaceId: workspace.id,
doc: {
id: 'doc-id-3',
title: 'doc-title-3',
blockId: 'block-id-3',
mode: DocMode.page,
},
},
},
});
// mention user in another workspace
const workspace2 = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
name: 'test-workspace-name2',
avatarKey: 'test-avatar-key2',
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace2.id,
userId: member.id,
});
await app.gql({
query: mentionUserMutation,
variables: {
input: {
userId: member.id,
workspaceId: workspace2.id,
doc: {
id: 'doc-id-4',
title: 'doc-title-4',
blockId: 'block-id-4',
mode: DocMode.page,
},
},
},
});
{
await app.login(member);
const result = await app.gql({
query: listNotificationsQuery,
variables: {
pagination: {
first: 10,
offset: 0,
},
},
});
const notifications = result.currentUser!.notifications.edges.map(
edge => edge.node
) as NotificationObjectType[];
t.is(notifications.length, 4);
t.is(result.currentUser!.notifications.totalCount, 4);
const notification = notifications[0];
t.is(notification.read, false);
const body = notification.body as MentionNotificationBodyType;
t.is(body.type, NotificationType.Mention);
t.is(body.workspace!.name, 'test-workspace-name2');
t.is(body.workspace!.id, workspace2.id);
t.falsy(body.workspace!.avatarUrl);
t.is(body.doc.id, 'doc-id-4');
t.is(body.doc.title, 'doc-title-4');
t.is(body.doc.blockId, 'block-id-4');
t.is(body.createdByUser!.id, owner.id);
const notification2 = notifications[1];
t.is(notification2.read, false);
const body2 = notification2.body as MentionNotificationBodyType;
t.is(body2.type, NotificationType.Mention);
t.is(body2.workspace!.id, workspace.id);
t.is(body2.doc.id, 'doc-id-3');
t.is(body2.doc.title, 'doc-title-3');
t.is(body2.doc.blockId, 'block-id-3');
t.is(body2.createdByUser!.id, owner.id);
t.is(body2.workspace!.id, workspace.id);
t.is(body2.workspace!.name, 'test-workspace-name');
t.falsy(body2.workspace!.avatarUrl);
}
{
await app.login(member);
const result = await app.gql({
query: listNotificationsQuery,
variables: {
pagination: {
first: 10,
offset: 2,
},
},
});
t.is(result.currentUser!.notifications.totalCount, 4);
t.is(result.currentUser!.notifications.pageInfo.hasNextPage, false);
t.is(result.currentUser!.notifications.pageInfo.hasPreviousPage, true);
const notifications = result.currentUser!.notifications.edges.map(
edge => edge.node
) as NotificationObjectType[];
t.is(notifications.length, 2);
const notification = notifications[0];
t.is(notification.read, false);
const body = notification.body as MentionNotificationBodyType;
t.is(body.workspace!.id, workspace.id);
t.is(body.doc.id, 'doc-id-2');
t.is(body.doc.title, 'doc-title-2');
t.is(body.doc.blockId, 'block-id-2');
t.is(body.createdByUser!.id, owner.id);
t.is(body.workspace!.id, workspace.id);
t.is(body.workspace!.name, 'test-workspace-name');
t.falsy(body.workspace!.avatarUrl);
const notification2 = notifications[1];
t.is(notification2.read, false);
const body2 = notification2.body as MentionNotificationBodyType;
t.is(body2.workspace!.id, workspace.id);
t.is(body2.doc.id, 'doc-id-1');
t.is(body2.doc.title, 'doc-title-1');
t.is(body2.doc.blockId, 'block-id-1');
t.is(body2.createdByUser!.id, owner.id);
t.is(body2.workspace!.id, workspace.id);
t.is(body2.workspace!.name, 'test-workspace-name');
t.falsy(body2.workspace!.avatarUrl);
}
{
await app.login(member);
const result = await app.gql({
query: listNotificationsQuery,
variables: {
pagination: {
first: 2,
offset: 0,
},
},
});
t.is(result.currentUser!.notifications.totalCount, 4);
t.is(result.currentUser!.notifications.pageInfo.hasNextPage, true);
t.is(result.currentUser!.notifications.pageInfo.hasPreviousPage, false);
const notifications = result.currentUser!.notifications.edges.map(
edge => edge.node
) as NotificationObjectType[];
t.is(notifications.length, 2);
const notification = notifications[0];
t.is(notification.read, false);
const body = notification.body as MentionNotificationBodyType;
t.is(body.workspace!.id, workspace2.id);
t.is(body.doc.id, 'doc-id-4');
t.is(body.doc.title, 'doc-title-4');
t.is(body.doc.blockId, 'block-id-4');
t.is(body.createdByUser!.id, owner.id);
t.is(body.workspace!.id, workspace2.id);
t.is(body.workspace!.name, 'test-workspace-name2');
t.falsy(body.workspace!.avatarUrl);
t.is(
notification.createdAt.toString(),
Buffer.from(
result.currentUser!.notifications.pageInfo.startCursor!,
'base64'
).toString('utf-8')
);
const notification2 = notifications[1];
t.is(notification2.read, false);
const body2 = notification2.body as MentionNotificationBodyType;
t.is(body2.workspace!.id, workspace.id);
t.is(body2.doc.id, 'doc-id-3');
t.is(body2.doc.title, 'doc-title-3');
t.is(body2.doc.blockId, 'block-id-3');
t.is(body2.createdByUser!.id, owner.id);
t.is(body2.workspace!.id, workspace.id);
t.is(body2.workspace!.name, 'test-workspace-name');
t.falsy(body2.workspace!.avatarUrl);
// load more notifications
await app.login(member);
const result2 = await app.gql({
query: listNotificationsQuery,
variables: {
pagination: {
first: 2,
offset: 0,
after: result.currentUser!.notifications.pageInfo.endCursor,
},
},
});
t.is(result2.currentUser!.notifications.totalCount, 4);
t.is(result2.currentUser!.notifications.pageInfo.hasNextPage, true);
t.is(result2.currentUser!.notifications.pageInfo.hasPreviousPage, true);
const notifications2 = result2.currentUser!.notifications.edges.map(
edge => edge.node
) as NotificationObjectType[];
t.is(notifications2.length, 2);
const notification3 = notifications2[0];
t.is(notification3.read, false);
const body3 = notification3.body as MentionNotificationBodyType;
t.is(body3.workspace!.id, workspace.id);
t.is(body3.doc.id, 'doc-id-2');
t.is(body3.doc.title, 'doc-title-2');
t.is(body3.doc.blockId, 'block-id-2');
t.is(body3.createdByUser!.id, owner.id);
t.is(body3.createdByUser!.name, owner.name);
t.is(body3.workspace!.id, workspace.id);
t.is(body3.workspace!.name, 'test-workspace-name');
t.falsy(body3.workspace!.avatarUrl);
// no notifications
const result3 = await app.gql({
query: listNotificationsQuery,
variables: {
pagination: {
first: 2,
offset: 0,
after: result2.currentUser!.notifications.pageInfo.endCursor,
},
},
});
t.is(result3.currentUser!.notifications.totalCount, 4);
t.is(result3.currentUser!.notifications.pageInfo.hasNextPage, false);
t.is(result3.currentUser!.notifications.pageInfo.hasPreviousPage, true);
t.is(result3.currentUser!.notifications.pageInfo.startCursor, null);
t.is(result3.currentUser!.notifications.pageInfo.endCursor, null);
t.is(result3.currentUser!.notifications.edges.length, 0);
}
});
e2e('should mark all notifications as read', async t => {
const { member, owner, workspace } = await init();
await app.login(owner);
await app.gql({
query: mentionUserMutation,
variables: {
input: {
userId: member.id,
workspaceId: workspace.id,
doc: {
id: 'doc-id-1',
title: 'doc-title-1',
blockId: 'block-id-1',
mode: DocMode.page,
},
},
},
});
await app.login(member);
await app.gql({
query: readAllNotificationsMutation,
});
const result = await app.gql({
query: listNotificationsQuery,
variables: {
pagination: {
first: 10,
offset: 0,
},
},
});
t.is(result.currentUser!.notifications.totalCount, 0);
});

View File

@@ -0,0 +1,53 @@
# Snapshot report for `src/__tests__/e2e/oauth/resolver.spec.ts`
The actual snapshot is saved in `resolver.spec.ts.snap`.
Generated by [AVA](https://avajs.dev).
## should return apple oauth provider in version >= 0.22.0
> Snapshot 1
{
serverConfig: {
oauthProviders: [
'Google',
'Apple',
],
},
}
> Snapshot 2
{
serverConfig: {
oauthProviders: [
'Google',
'Apple',
],
},
}
> Snapshot 3
{
serverConfig: {
oauthProviders: [
'Google',
'Apple',
],
},
}
## should return apple oauth provider when client version is not specified
> Snapshot 1
{
serverConfig: {
oauthProviders: [
'Google',
'Apple',
],
},
}

View File

@@ -0,0 +1,82 @@
import { oauthProvidersQuery } from '@affine/graphql';
import { ConfigModule } from '../../../base/config';
import { createApp, e2e, TestingApp } from '../test';
let app: TestingApp;
e2e.before(async () => {
app = await createApp({
imports: [
ConfigModule.override({
oauth: {
providers: {
apple: {
clientId: 'test',
clientSecret: 'test',
args: {
redirectUri: 'test',
},
},
google: {
clientId: 'test',
clientSecret: 'test',
args: {
redirectUri: 'test',
},
},
},
},
}),
],
});
});
e2e.after.always(async () => {
await app.close();
});
e2e('should return apple oauth provider in version >= 0.22.0', async t => {
const res = await app.gql({
query: oauthProvidersQuery,
context: {
headers: {
'x-affine-version': '0.22.0',
},
},
});
t.snapshot(res);
const res2 = await app.gql({
query: oauthProvidersQuery,
context: {
headers: {
'x-affine-version': '0.23.0-beta.1',
},
},
});
t.snapshot(res2);
const res3 = await app.gql({
query: oauthProvidersQuery,
context: {
headers: {
'x-affine-version': '2025.6.29-canary.93',
},
},
});
t.snapshot(res3);
});
e2e(
'should return apple oauth provider when client version is not specified',
async t => {
const res = await app.gql({
query: oauthProvidersQuery,
});
t.snapshot(res);
}
);

View File

@@ -0,0 +1,11 @@
import { getBuildConfig } from '@affine-tools/utils/build-config';
import { Package } from '@affine-tools/utils/workspace';
import { createApp } from './create-app';
globalThis.BUILD_CONFIG = getBuildConfig(new Package('@affine/web'), {
mode: 'development',
channel: 'canary',
});
// @ts-expect-error testing
globalThis.app = await createApp();

View File

@@ -0,0 +1,92 @@
import type {
RealtimeAck,
RealtimeRequestInputOf,
RealtimeRequestName,
RealtimeRequestOutputOf,
} from '@affine/realtime';
import { io, type Socket as SocketIOClient } from 'socket.io-client';
import type { Response } from 'supertest';
import type { MockedUser } from '../mocks';
import type { TestingApp } from './create-app';
const REALTIME_CLIENT_VERSION = '0.26.0';
const WS_TIMEOUT_MS = 5_000;
function cookieHeader(res: Response) {
return (res.get('Set-Cookie') ?? [])
.map(cookie => cookie.split(';')[0])
.join('; ');
}
async function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
label: string
) {
let timer: NodeJS.Timeout | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
reject(new Error(`Timeout (${timeoutMs}ms): ${label}`));
}, timeoutMs);
});
try {
return await Promise.race([promise, timeout]);
} finally {
if (timer) clearTimeout(timer);
}
}
async function waitForConnect(socket: SocketIOClient) {
if (socket.connected) {
return;
}
await withTimeout(
new Promise<void>((resolve, reject) => {
socket.once('connect', resolve);
socket.once('connect_error', reject);
}),
WS_TIMEOUT_MS,
'realtime socket connect'
);
}
export async function createRealtimeClient(app: TestingApp, user: MockedUser) {
const login = await app.login(user);
const socket = io(app.url, {
transports: ['websocket'],
reconnection: false,
forceNew: true,
extraHeaders: {
cookie: cookieHeader(login),
},
});
await waitForConnect(socket);
return socket;
}
export async function realtimeRequest<Op extends RealtimeRequestName>(
socket: SocketIOClient,
op: Op,
input: RealtimeRequestInputOf<Op>
): Promise<RealtimeRequestOutputOf<Op>> {
const ack = await withTimeout(
new Promise<RealtimeAck<RealtimeRequestOutputOf<Op>>>(resolve => {
socket.emit(
'realtime:request',
{ op, input, clientVersion: REALTIME_CLIENT_VERSION },
(res: RealtimeAck<RealtimeRequestOutputOf<Op>>) => resolve(res)
);
}),
WS_TIMEOUT_MS,
`realtime request ${op}`
);
if ('error' in ack) {
throw new Error(`${ack.error.name}: ${ack.error.message}`);
}
return ack.data;
}

View File

@@ -0,0 +1,616 @@
import { createHash } from 'node:crypto';
import { mock } from 'node:test';
import {
Config,
ConfigFactory,
PROXY_MULTIPART_PATH,
PROXY_UPLOAD_PATH,
type R2StorageConfig,
SIGNED_URL_EXPIRED,
type StorageProviderConfig,
URLHelper,
} from '../../../base';
import { EntitlementService } from '../../../core/entitlement';
import {
MULTIPART_PART_SIZE,
MULTIPART_THRESHOLD,
} from '../../../core/storage/constants';
import { StorageRuntimeProvider } from '../../../core/storage-runtime';
import { SubscriptionPlan } from '../../../plugins/payment/types';
import { app, e2e, Mockers } from '../test';
class MockStorageRuntime {
createMultipartCalls = 0;
putCalls: {
key: string;
body: Buffer;
contentType?: string;
contentLength?: number;
}[] = [];
partCalls: {
key: string;
uploadId: string;
partNumber: number;
etag: string;
body: Buffer;
contentLength?: number;
}[] = [];
async providerCapabilities() {
const storage = app.get(Config).storages.blob.storage;
if (storage.provider !== 'cloudflare-r2') {
return {
put: true,
get: true,
head: true,
list: true,
delete: true,
presignPut: false,
presignGet: false,
multipartDirect: false,
proxyUpload: false,
assetpack: false,
serverMediatedOnly: true,
};
}
return {
put: true,
get: true,
head: true,
list: true,
delete: true,
presignPut: true,
presignGet: false,
multipartDirect: true,
proxyUpload: false,
assetpack: false,
serverMediatedOnly: false,
};
}
async presignPut(
_scope: string,
key: string,
metadata: { contentType?: string; contentLength?: number } = {}
) {
const url = new URL(`https://test-bucket.r2.example.com/${key}`);
url.searchParams.set('X-Amz-Algorithm', 'AWS4-HMAC-SHA256');
url.searchParams.set(
'X-Amz-SignedHeaders',
metadata.contentType ? 'content-type;host' : 'host'
);
return {
url: url.toString(),
headers: {},
expiresAt: new Date(Date.now() + SIGNED_URL_EXPIRED * 1000),
};
}
async createMultipartUpload() {
this.createMultipartCalls += 1;
return {
uploadId: 'upload-id',
expiresAt: new Date(Date.now() + SIGNED_URL_EXPIRED * 1000),
};
}
async presignUploadPart(
_scope: string,
key: string,
uploadId: string,
partNumber: number
) {
const url = new URL(`https://test-bucket.r2.example.com/${key}`);
url.searchParams.set('uploadId', uploadId);
url.searchParams.set('partNumber', partNumber.toString());
url.searchParams.set('X-Amz-Algorithm', 'AWS4-HMAC-SHA256');
return {
url: url.toString(),
headers: {},
expiresAt: new Date(Date.now() + SIGNED_URL_EXPIRED * 1000),
};
}
async listMultipartUploadParts(
_scope: string,
key: string,
uploadId: string
) {
const latest = new Map<number, string>();
for (const part of this.partCalls) {
if (part.key !== key || part.uploadId !== uploadId) {
continue;
}
latest.set(part.partNumber, part.etag);
}
return [...latest.entries()]
.sort((left, right) => left[0] - right[0])
.map(([partNumber, etag]) => ({ partNumber, etag }));
}
async putObject(
_scope: string,
key: string,
body: Buffer,
options: { contentType?: string; contentLength?: number } = {}
) {
this.putCalls.push({
key,
body,
contentType: options.contentType,
contentLength: options.contentLength,
});
return {
contentType: options.contentType ?? 'application/octet-stream',
contentLength: options.contentLength ?? body.length,
lastModified: new Date(),
};
}
async proxyUploadPart(
_scope: string,
key: string,
uploadId: string,
partNumber: number,
body: Buffer,
contentLength?: number
) {
const etag = `etag-${partNumber}`;
this.partCalls.push({
key,
uploadId,
partNumber,
etag,
body,
contentLength,
});
return etag;
}
}
const baseR2Storage: StorageProviderConfig = {
provider: 'cloudflare-r2',
bucket: 'test-bucket',
config: {
accountId: 'test-account',
region: 'auto',
credentials: {
accessKeyId: 'test',
secretAccessKey: 'test',
},
usePresignedURL: {
enabled: true,
urlPrefix: 'https://cdn.example.com',
signKey: 'r2-sign-key',
},
},
};
let defaultBlobStorage: StorageProviderConfig;
let runtime: MockStorageRuntime;
e2e.before(() => {
defaultBlobStorage = structuredClone(app.get(Config).storages.blob.storage);
});
e2e.beforeEach(async () => {
runtime = new MockStorageRuntime();
const rt = app.get(StorageRuntimeProvider);
for (const method of [
'providerCapabilities',
'presignPut',
'createMultipartUpload',
'presignUploadPart',
'listMultipartUploadParts',
'putObject',
'proxyUploadPart',
] as const) {
mock.method(rt, method, (...args: any[]) =>
(runtime[method] as any)(...args)
);
}
await useR2Storage();
});
e2e.afterEach.always(async () => {
await setBlobStorage(defaultBlobStorage);
mock.reset();
});
async function setBlobStorage(storage: StorageProviderConfig) {
const configFactory = app.get(ConfigFactory);
configFactory.override({ storages: { blob: { storage } } });
}
async function useR2Storage(
overrides?: Partial<R2StorageConfig['usePresignedURL']>
) {
const storage = structuredClone(baseR2Storage) as StorageProviderConfig;
const usePresignedURL = {
...(structuredClone(
((baseR2Storage as StorageProviderConfig).config as R2StorageConfig)
.usePresignedURL ?? {}
) as R2StorageConfig['usePresignedURL']),
...overrides,
};
(storage.config as R2StorageConfig).usePresignedURL =
usePresignedURL as R2StorageConfig['usePresignedURL'];
await setBlobStorage(storage);
return storage;
}
function getRuntime(): MockStorageRuntime {
return runtime;
}
async function createBlobUpload(
workspaceId: string,
key: string,
size: number,
mime: string
) {
const data = await gql(
`
mutation createBlobUpload($workspaceId: String!, $key: String!, $size: Int!, $mime: String!) {
createBlobUpload(workspaceId: $workspaceId, key: $key, size: $size, mime: $mime) {
method
blobKey
alreadyUploaded
uploadUrl
uploadId
partSize
uploadedParts {
partNumber
etag
}
}
}
`,
{ workspaceId, key, size, mime },
'createBlobUpload'
);
return data.createBlobUpload;
}
async function getBlobUploadPartUrl(
workspaceId: string,
key: string,
uploadId: string,
partNumber: number
) {
const data = await gql(
`
query getBlobUploadPartUrl($workspaceId: String!, $key: String!, $uploadId: String!, $partNumber: Int!) {
workspace(id: $workspaceId) {
blobUploadPartUrl(key: $key, uploadId: $uploadId, partNumber: $partNumber) {
uploadUrl
headers
expiresAt
}
}
}
`,
{ workspaceId, key, uploadId, partNumber },
'getBlobUploadPartUrl'
);
return data.workspace.blobUploadPartUrl;
}
async function setupWorkspace() {
const owner = await app.signup();
await app.get(EntitlementService).upsertAdminGrant({
targetType: 'user',
targetId: owner.id,
plan: SubscriptionPlan.Pro,
});
const workspace = await app.create(Mockers.Workspace, { owner });
return { owner, workspace };
}
async function gql<QueryData = any>(
query: string,
variables: Record<string, any>,
operationName: string
): Promise<QueryData> {
const res = await app
.POST('/graphql')
.set({ 'x-request-id': 'test', 'x-operation-name': operationName })
.send({ query, variables })
.expect(200);
if (res.body.errors?.length) {
throw new Error(res.body.errors[0].message);
}
return res.body.data;
}
e2e.serial('should proxy single upload with valid signature', async t => {
const { workspace } = await setupWorkspace();
const buffer = Buffer.from('r2-proxy');
const key = sha256Base64urlWithPadding(buffer);
const init = await createBlobUpload(
workspace.id,
key,
buffer.length,
'text/plain'
);
t.is(init.method, 'PRESIGNED');
t.truthy(init.uploadUrl);
const uploadUrl = new URL(init.uploadUrl, app.url);
t.is(uploadUrl.origin, 'https://cdn.example.com');
t.is(uploadUrl.pathname, PROXY_UPLOAD_PATH);
const res = await app
.PUT(uploadUrl.pathname + uploadUrl.search)
.set('content-type', 'text/plain')
.set('content-length', buffer.length.toString())
.send(buffer);
t.is(res.status, 200);
const calls = getRuntime().putCalls;
t.is(calls.length, 1);
t.regex(
calls[0].key,
new RegExp(`^${workspace.id}/\\.reservations/[0-9a-f-]{36}/${key}$`)
);
t.is(calls[0].contentType, 'text/plain');
t.is(calls[0].contentLength, buffer.length);
t.deepEqual(calls[0].body, buffer);
});
e2e.serial('should proxy multipart upload and return etag', async t => {
const { workspace } = await setupWorkspace();
const key = sha256Base64urlWithPadding(Buffer.from('multipart-object'));
const totalSize = MULTIPART_THRESHOLD + 1024;
const init = await createBlobUpload(workspace.id, key, totalSize, 'bin');
t.is(init.method, 'MULTIPART');
t.is(init.uploadId, 'upload-id');
t.deepEqual(init.uploadedParts, []);
const part = await getBlobUploadPartUrl(workspace.id, key, init.uploadId, 3);
const partUrl = new URL(part.uploadUrl, app.url);
t.is(partUrl.origin, 'https://cdn.example.com');
t.is(partUrl.pathname, PROXY_MULTIPART_PATH);
const payload = Buffer.alloc(1024);
const res = await app
.PUT(partUrl.pathname + partUrl.search)
.set('content-length', payload.length.toString())
.send(payload);
t.is(res.status, 200);
t.is(res.get('etag'), 'etag-3');
const calls = getRuntime().partCalls;
t.is(calls.length, 1);
t.regex(
calls[0].key,
new RegExp(`^${workspace.id}/\\.reservations/[0-9a-f-]{36}/${key}$`)
);
t.is(calls[0].uploadId, 'upload-id');
t.is(calls[0].partNumber, 3);
t.is(calls[0].contentLength, payload.length);
t.deepEqual(calls[0].body, payload);
});
e2e.serial(
'should resume multipart upload and return uploaded parts',
async t => {
const { workspace } = await setupWorkspace();
const key = sha256Base64urlWithPadding(Buffer.from('multipart-resume'));
const totalSize = MULTIPART_THRESHOLD + 1024;
const init1 = await createBlobUpload(workspace.id, key, totalSize, 'bin');
t.is(init1.method, 'MULTIPART');
t.is(init1.uploadId, 'upload-id');
t.deepEqual(init1.uploadedParts, []);
t.is(getRuntime().createMultipartCalls, 1);
const part = await getBlobUploadPartUrl(
workspace.id,
key,
init1.uploadId,
1
);
const payload = Buffer.alloc(MULTIPART_PART_SIZE);
const partUrl = new URL(part.uploadUrl, app.url);
await app
.PUT(partUrl.pathname + partUrl.search)
.set('content-length', payload.length.toString())
.send(payload)
.expect(200);
const init2 = await createBlobUpload(workspace.id, key, totalSize, 'bin');
t.is(init2.method, 'MULTIPART');
t.is(init2.uploadId, 'upload-id');
t.deepEqual(init2.uploadedParts, [{ partNumber: 1, etag: 'etag-1' }]);
t.is(getRuntime().createMultipartCalls, 1);
}
);
e2e.serial('should reject upload when token is invalid', async t => {
const { workspace } = await setupWorkspace();
const buffer = Buffer.from('payload');
const init = await createBlobUpload(
workspace.id,
sha256Base64urlWithPadding(buffer),
buffer.length,
'text/plain'
);
const uploadUrl = new URL(init.uploadUrl, app.url);
uploadUrl.searchParams.set('token', 'invalid-token');
const res = await app
.PUT(uploadUrl.pathname + uploadUrl.search)
.set('content-type', 'text/plain')
.set('content-length', buffer.length.toString())
.send(buffer);
t.is(res.status, 400);
t.is(res.body.message, 'Invalid upload token');
t.is(getRuntime().putCalls.length, 0);
});
e2e.serial('should reject upload when url is expired', async t => {
const { workspace } = await setupWorkspace();
const buffer = Buffer.from('expired');
const init = await createBlobUpload(
workspace.id,
sha256Base64urlWithPadding(buffer),
buffer.length,
'text/plain'
);
const uploadUrl = new URL(init.uploadUrl, app.url);
uploadUrl.searchParams.set(
'expiresAt',
(Math.floor(Date.now() / 1000) - 1).toString()
);
const res = await app
.PUT(uploadUrl.pathname + uploadUrl.search)
.set('content-type', 'text/plain')
.set('content-length', buffer.length.toString())
.send(buffer);
t.is(res.status, 400);
t.is(res.body.message, 'Upload URL expired');
t.is(getRuntime().putCalls.length, 0);
});
e2e.serial('should use graphql when upload urls are disabled', async t => {
await useR2Storage({
enabled: false,
urlPrefix: undefined,
signKey: undefined,
});
const { workspace } = await setupWorkspace();
const buffer = Buffer.from('plain');
const init = await createBlobUpload(
workspace.id,
sha256Base64urlWithPadding(buffer),
buffer.length,
'text/plain'
);
t.is(init.method, 'GRAPHQL');
});
e2e.serial('should use the server origin with only a signing key', async t => {
await useR2Storage({ urlPrefix: undefined });
const { workspace } = await setupWorkspace();
const buffer = Buffer.from('server-proxy');
const init = await createBlobUpload(
workspace.id,
sha256Base64urlWithPadding(buffer),
buffer.length,
'text/plain'
);
const uploadUrl = new URL(init.uploadUrl, app.url);
t.is(init.method, 'PRESIGNED');
t.is(uploadUrl.origin, new URL(app.get(URLHelper).baseUrl).origin);
t.is(uploadUrl.pathname, PROXY_UPLOAD_PATH);
});
e2e.serial(
'should use a custom origin for provider presigned urls without a signing key',
async t => {
await useR2Storage({ signKey: undefined });
const { workspace } = await setupWorkspace();
const buffer = Buffer.from('custom-presign');
const init = await createBlobUpload(
workspace.id,
sha256Base64urlWithPadding(buffer),
buffer.length,
'text/plain'
);
const uploadUrl = new URL(init.uploadUrl, app.url);
t.is(init.method, 'PRESIGNED');
t.is(uploadUrl.origin, 'https://cdn.example.com');
t.not(uploadUrl.pathname, PROXY_UPLOAD_PATH);
t.is(uploadUrl.searchParams.get('X-Amz-Algorithm'), 'AWS4-HMAC-SHA256');
}
);
e2e.serial(
'should use provider presigned urls without custom settings',
async t => {
await useR2Storage({ urlPrefix: undefined, signKey: undefined });
const { workspace } = await setupWorkspace();
const buffer = Buffer.from('provider-presign');
const init = await createBlobUpload(
workspace.id,
sha256Base64urlWithPadding(buffer),
buffer.length,
'text/plain'
);
const uploadUrl = new URL(init.uploadUrl, app.url);
t.is(init.method, 'PRESIGNED');
t.is(uploadUrl.origin, 'https://test-bucket.r2.example.com');
t.is(uploadUrl.searchParams.get('X-Amz-Algorithm'), 'AWS4-HMAC-SHA256');
}
);
e2e.serial(
'should fall back to graphql when creating an upload url fails',
async t => {
await useR2Storage({ urlPrefix: 'invalid-url', signKey: undefined });
const { workspace } = await setupWorkspace();
const buffer = Buffer.from('fallback');
const init = await createBlobUpload(
workspace.id,
sha256Base64urlWithPadding(buffer),
buffer.length,
'text/plain'
);
t.is(init.method, 'GRAPHQL');
}
);
e2e.serial(
'should still fallback to graphql when provider does not support presign',
async t => {
await setBlobStorage({
provider: 'fs',
bucket: 'test-fallback-bucket',
config: {
path: '/tmp/affine-r2-proxy-test',
},
});
const { workspace } = await setupWorkspace();
const buffer = Buffer.from('graph');
const init = await createBlobUpload(
workspace.id,
sha256Base64urlWithPadding(buffer),
buffer.length,
'text/plain'
);
t.is(init.method, 'GRAPHQL');
}
);
function sha256Base64urlWithPadding(buffer: Buffer) {
return createHash('sha256')
.update(buffer)
.digest('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_');
}

View File

@@ -0,0 +1,57 @@
import { PrismaClient } from '@prisma/client';
import test, { registerCompletionHandler } from 'ava';
import { BackendRuntimeProvider } from '../../core/backend-runtime';
import { Env } from '../../env';
import { addDocToRootDoc, mergeUpdatesInApplyWay } from '../../native';
import { type TestingApp } from './create-app';
export const e2e = test;
// @ts-expect-error created in prelude.ts
export const app: TestingApp = globalThis.app;
registerCompletionHandler(async () => {
await app.close();
});
export function refreshEnv() {
globalThis.env = new Env();
}
export async function addDocumentToRoot(workspaceId: string, docId: string) {
const db = app.get(PrismaClient);
const where = { workspaceId_id: { workspaceId, id: workspaceId } };
const root = await db.snapshot.findUniqueOrThrow({ where });
const rootBlob = Buffer.from(root.blob);
const update = addDocToRootDoc(rootBlob, docId, docId);
await db.snapshot.update({
where,
data: {
blob: mergeUpdatesInApplyWay([rootBlob, update]),
updatedAt: new Date(),
},
});
}
export async function reconcileSearchProjection() {
const runtime = app.get(BackendRuntimeProvider);
for (let attempt = 0; attempt < 50; attempt++) {
const reconciled = await runtime.reconcileSearchProjection(1000);
const status = (await runtime.searchStatus()) as {
ready?: boolean;
metrics?: { pendingPublications?: number };
};
if (
reconciled === 0 &&
status.ready &&
status.metrics?.pendingPublications === 0
) {
return;
}
}
throw new Error('search projection did not converge');
}
export * from '../mocks';
export { createApp } from './create-app';
export type { TestingApp };

View File

@@ -0,0 +1,205 @@
import {
deleteAccountMutation,
disableUserMutation,
getCurrentUserQuery,
getWorkspaceQuery,
} from '@affine/graphql';
import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client';
import Sinon from 'sinon';
import { BackendRuntimeProvider } from '../../../core/backend-runtime';
import { WorkspaceRole } from '../../../models';
import { app, e2e, Mockers } from '../test';
const admin = await app.create(Mockers.User, {
feature: 'administrator',
});
e2e('should be able to delete account', async t => {
const user = await app.signup();
const user2 = await app.create(Mockers.User);
const ws = await app.create(Mockers.Workspace, {
owner: user,
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: ws.id,
userId: user2.id,
});
await app.gql({
query: deleteAccountMutation,
});
// assert session removed
const { currentUser } = await app.gql({
query: getCurrentUserQuery,
});
t.is(currentUser, null);
// assert login failed
const res = await app.login(user);
t.is(res.status, 400);
t.like(res.body, {
message: `Wrong user email or password: ${user.email}`,
});
// assert workspace access deleted
await app.login(user2);
await t.throwsAsync(
app.gql({
query: getWorkspaceQuery,
variables: {
id: ws.id,
},
}),
{
message: `You do not have permission to access Space ${ws.id}.`,
}
);
});
e2e('should not delete account if is owner of team workspace', async t => {
const user = await app.signup();
const ws = await app.create(Mockers.Workspace, {
owner: user,
});
await app.create(Mockers.TeamWorkspace, {
id: ws.id,
});
await t.throwsAsync(
app.gql({
query: deleteAccountMutation,
}),
{
message:
'Cannot delete account. You are the owner of one or more team workspaces. Please transfer ownership or delete them first.',
}
);
});
e2e('should register deleted account again', async t => {
const user = await app.signup();
await app.gql({
query: deleteAccountMutation,
});
const res = await app.POST('/api/auth/sign-in').send({
email: user.email,
});
t.is(res.status, 200);
const delivery = await app.get(PrismaClient).mailDelivery.findFirstOrThrow({
where: { mailName: 'SignUp', recipientEmail: user.email },
});
t.is(delivery.recipientEmail, user.email);
});
e2e('should ban account', async t => {
const interrupted = await app.create(Mockers.User);
await app.login(admin);
const runtime = app.get(BackendRuntimeProvider);
const originalExecute = runtime.executeAuthSessionCommandV1.bind(runtime);
const execute = Sinon.stub(runtime, 'executeAuthSessionCommandV1').callsFake(
async input => {
if (input.action === 'set_user_disabled') {
throw new Error('injected disable failure');
}
return await originalExecute(input);
}
);
try {
await t.throwsAsync(
app.gql({
query: disableUserMutation,
variables: { id: interrupted.id },
})
);
} finally {
execute.restore();
}
const preserved = await app.get(PrismaClient).user.findUniqueOrThrow({
where: { email: interrupted.email },
});
t.true(preserved.disabled);
const user = await app.create(Mockers.User);
const { banUser } = await app.gql({
query: disableUserMutation,
variables: {
id: user.id,
},
});
t.is(banUser.disabled, true);
});
e2e('should ban account with pending workspace invitation', async t => {
const owner = await app.create(Mockers.User);
const user = await app.create(Mockers.User);
const workspace = await app.create(Mockers.Workspace, { owner });
await app.models.workspaceInvitation.set(
workspace.id,
user.id,
WorkspaceRole.Collaborator,
WorkspaceMemberStatus.Pending,
{ inviterId: owner.id }
);
await app.login(admin);
const { banUser } = await app.gql({
query: disableUserMutation,
variables: {
id: user.id,
},
});
t.is(banUser.disabled, true);
});
e2e('should not login banned account', async t => {
const user = await app.create(Mockers.User);
await app.login(admin);
await app.gql({
query: disableUserMutation,
variables: {
id: user.id,
},
});
await app.logout();
const res = await app.login(user);
t.is(res.status, 400);
t.like(res.body, {
message: `Wrong user email or password: ${user.email}`,
});
});
e2e('should not signup banned account', async t => {
const user = await app.create(Mockers.User);
await app.login(admin);
await app.gql({
query: disableUserMutation,
variables: {
id: user.id,
},
});
const res = await app.POST('/api/auth/sign-in').send({
email: user.email,
});
t.is(res.status, 400);
t.like(res.body, {
message: `Wrong user email or password: ${user.email}`,
});
});

View File

@@ -0,0 +1,89 @@
import { getUserQuery } from '@affine/graphql';
import Sinon from 'sinon';
import { ThrottlerStorage } from '../../../base/throttler';
import { app, e2e, Mockers } from '../test';
e2e('user(email) should return null without auth', async t => {
const user = await app.create(Mockers.User);
app.clearAuth();
const res = await app.gql({
query: getUserQuery,
variables: { email: user.email },
});
t.is(res.user, null);
});
e2e('user(email) should return null outside workspace scope', async t => {
app.clearAuth();
const me = await app.signup();
const other = await app.create(Mockers.User);
const res = await app.gql({
query: getUserQuery,
variables: { email: other.email },
});
t.is(res.user, null);
// sanity: querying self is always allowed
const self = await app.gql({
query: getUserQuery,
variables: { email: me.email },
});
t.truthy(self.user);
if (!self.user) return;
t.is(self.user.__typename, 'UserType');
if (self.user.__typename === 'UserType') {
t.is(self.user.id, me.id);
}
});
e2e('user(email) should return user within workspace scope', async t => {
app.clearAuth();
const me = await app.signup();
const other = await app.create(Mockers.User);
const ws = await app.create(Mockers.Workspace, { owner: me });
await app.create(Mockers.WorkspaceUser, {
workspaceId: ws.id,
userId: other.id,
});
const res = await app.gql({
query: getUserQuery,
variables: { email: other.email },
});
t.truthy(res.user);
if (!res.user) return;
t.is(res.user.__typename, 'UserType');
if (res.user.__typename === 'UserType') {
t.is(res.user.id, other.id);
}
});
e2e('user(email) should be rate limited', async t => {
app.clearAuth();
const me = await app.signup();
const stub = Sinon.stub(app.get(ThrottlerStorage), 'increment').resolves({
timeToExpire: 10,
totalHits: 21,
isBlocked: true,
timeToBlockExpire: 10,
});
await t.throwsAsync(
app.gql({
query: getUserQuery,
variables: { email: me.email },
}),
{ message: /too many requests/i }
);
stub.restore();
});

View File

@@ -0,0 +1,51 @@
# Snapshot report for `src/__tests__/e2e/workspace/controller.spec.ts`
The actual snapshot is saved in `controller.spec.ts.snap`.
Generated by [AVA](https://avajs.dev).
## should get comment attachment not found when key is not exists
> Snapshot 1
{
body: {
code: 'Not Found',
message: 'Comment attachment not found.',
name: 'COMMENT_ATTACHMENT_NOT_FOUND',
status: 404,
type: 'RESOURCE_NOT_FOUND',
},
status: 404,
}
## should get comment attachment no permission when user is not member
> Snapshot 1
{
body: {
code: 'Forbidden',
data: {
action: 'Doc.Read',
},
message: 'You do not have permission to perform Doc.Read action on doc <doc-id>.',
name: 'DOC_ACTION_DENIED',
status: 403,
type: 'NO_PERMISSION',
},
status: 403,
}
## should get comment attachment body
> Snapshot 1
{
body: 'test',
cacheControl: 'private, no-store',
contentLength: '4',
contentType: 'text/plain',
hasLastModified: true,
status: 200,
}

View File

@@ -0,0 +1,886 @@
import { PrismaClient } from '@prisma/client';
import Sinon from 'sinon';
import { app, e2e, Mockers } from '../test';
async function gql(query: string, variables?: Record<string, unknown>) {
const res = await app.POST('/graphql').send({ query, variables }).expect(200);
return res.body as {
data?: Record<string, any>;
errors?: Array<{ message: string; extensions: Record<string, any> }>;
};
}
async function ensureAnalyticsTables(db: PrismaClient) {
await db.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS workspace_admin_stats_daily (
workspace_id VARCHAR NOT NULL,
date DATE NOT NULL,
snapshot_size BIGINT NOT NULL DEFAULT 0,
blob_size BIGINT NOT NULL DEFAULT 0,
member_count BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(),
PRIMARY KEY (workspace_id, date)
);
`);
await db.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS sync_active_users_minutely (
minute_ts TIMESTAMPTZ(3) NOT NULL PRIMARY KEY,
active_users INTEGER NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW()
);
`);
await db.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS workspace_doc_view_daily (
workspace_id VARCHAR NOT NULL,
doc_id VARCHAR NOT NULL,
date DATE NOT NULL,
total_views BIGINT NOT NULL DEFAULT 0,
unique_views BIGINT NOT NULL DEFAULT 0,
guest_views BIGINT NOT NULL DEFAULT 0,
last_accessed_at TIMESTAMPTZ(3),
updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(),
PRIMARY KEY (workspace_id, doc_id, date)
);
`);
await db.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS workspace_member_last_access (
workspace_id VARCHAR NOT NULL,
user_id VARCHAR NOT NULL,
last_accessed_at TIMESTAMPTZ(3) NOT NULL,
last_doc_id VARCHAR,
updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(),
PRIMARY KEY (workspace_id, user_id)
);
`);
}
async function createPublicDoc(input: {
workspaceId: string;
ownerId: string;
title: string;
updatedAt: Date;
publishedAt: Date;
}) {
const snapshot = await app.create(Mockers.DocSnapshot, {
workspaceId: input.workspaceId,
user: { id: input.ownerId },
});
await app.create(Mockers.DocMeta, {
workspaceId: input.workspaceId,
docId: snapshot.id,
title: input.title,
public: true,
publishedAt: input.publishedAt,
});
const db = app.get(PrismaClient);
await db.snapshot.update({
where: {
workspaceId_id: {
workspaceId: input.workspaceId,
id: snapshot.id,
},
},
data: {
updatedAt: input.updatedAt,
updatedBy: input.ownerId,
},
});
return snapshot.id;
}
e2e(
'adminAllSharedLinks should support stable pagination and includeTotal',
async t => {
const admin = await app.create(Mockers.User, {
feature: 'administrator',
});
await app.login(admin);
const owner = await app.create(Mockers.User);
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
const newerDocId = await createPublicDoc({
workspaceId: workspace.id,
ownerId: owner.id,
title: 'newer-doc',
updatedAt: new Date('2026-02-11T10:00:00.000Z'),
publishedAt: new Date('2026-02-11T10:00:00.000Z'),
});
const olderDocId = await createPublicDoc({
workspaceId: workspace.id,
ownerId: owner.id,
title: 'older-doc',
updatedAt: new Date('2026-02-10T10:00:00.000Z'),
publishedAt: new Date('2026-02-10T10:00:00.000Z'),
});
const db = app.get(PrismaClient);
await ensureAnalyticsTables(db);
await db.$executeRaw`
INSERT INTO workspace_doc_view_daily (
workspace_id, doc_id, date, total_views, unique_views, guest_views, last_accessed_at, updated_at
)
VALUES
(${workspace.id}, ${newerDocId}, CURRENT_DATE, 10, 8, 2, NOW(), NOW()),
(${workspace.id}, ${olderDocId}, CURRENT_DATE, 5, 4, 1, NOW(), NOW())
ON CONFLICT (workspace_id, doc_id, date)
DO UPDATE SET
total_views = EXCLUDED.total_views,
unique_views = EXCLUDED.unique_views,
guest_views = EXCLUDED.guest_views,
last_accessed_at = EXCLUDED.last_accessed_at,
updated_at = EXCLUDED.updated_at
`;
const query = `
query AdminAllSharedLinks($pagination: PaginationInput!, $filter: AdminAllSharedLinksFilterInput) {
adminAllSharedLinks(pagination: $pagination, filter: $filter) {
totalCount
analyticsWindow {
requestedSize
effectiveSize
}
pageInfo {
hasNextPage
endCursor
}
edges {
cursor
node {
workspaceId
docId
title
shareUrl
views
uniqueViews
guestViews
}
}
}
}
`;
const firstPage = await gql(query, {
pagination: { first: 1, offset: 0 },
filter: {
includeTotal: false,
orderBy: 'UpdatedAtDesc',
workspaceId: workspace.id,
},
});
t.falsy(firstPage.errors);
const first = firstPage.data!.adminAllSharedLinks;
t.is(first.totalCount, null);
t.true(first.pageInfo.hasNextPage);
t.is(first.edges.length, 1);
t.true([newerDocId, olderDocId].includes(first.edges[0].node.docId));
t.true(
first.edges[0].node.shareUrl.includes(`/workspace/${workspace.id}/`)
);
const secondPage = await gql(query, {
pagination: { first: 1, offset: 0, after: first.pageInfo.endCursor },
filter: {
includeTotal: true,
orderBy: 'UpdatedAtDesc',
workspaceId: workspace.id,
},
});
t.falsy(secondPage.errors);
const second = secondPage.data!.adminAllSharedLinks;
t.is(second.totalCount, 2);
t.is(second.edges.length, 1);
t.not(second.edges[0].node.docId, first.edges[0].node.docId);
const conflict = await gql(query, {
pagination: {
first: 1,
offset: 1,
after: first.pageInfo.endCursor,
},
filter: {
includeTotal: false,
orderBy: 'UpdatedAtDesc',
workspaceId: workspace.id,
},
});
t.truthy(conflict.errors?.length);
t.is(conflict.errors![0].extensions.name, 'BAD_REQUEST');
const malformedDateCursor = await gql(query, {
pagination: {
first: 1,
offset: 0,
after: JSON.stringify({
orderBy: 'UpdatedAtDesc',
sortValue: 'not-a-date',
workspaceId: workspace.id,
docId: newerDocId,
}),
},
filter: {
includeTotal: false,
orderBy: 'UpdatedAtDesc',
workspaceId: workspace.id,
},
});
t.truthy(malformedDateCursor.errors?.length);
t.is(malformedDateCursor.errors![0].extensions.name, 'BAD_REQUEST');
const malformedViewsCursor = await gql(query, {
pagination: {
first: 1,
offset: 0,
after: JSON.stringify({
orderBy: 'ViewsDesc',
sortValue: 'NaN',
workspaceId: workspace.id,
docId: newerDocId,
}),
},
filter: {
includeTotal: false,
orderBy: 'ViewsDesc',
workspaceId: workspace.id,
},
});
t.truthy(malformedViewsCursor.errors?.length);
t.is(malformedViewsCursor.errors![0].extensions.name, 'BAD_REQUEST');
}
);
e2e(
'adminDashboard should clamp window inputs and return expected buckets',
async t => {
const admin = await app.create(Mockers.User, {
feature: 'administrator',
});
await app.login(admin);
const owner = await app.create(Mockers.User);
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
const docId = await createPublicDoc({
workspaceId: workspace.id,
ownerId: owner.id,
title: 'dashboard-doc',
updatedAt: new Date(),
publishedAt: new Date(),
});
const db = app.get(PrismaClient);
await ensureAnalyticsTables(db);
const minute = new Date();
minute.setSeconds(0, 0);
await db.$executeRaw`
INSERT INTO sync_active_users_minutely (minute_ts, active_users, updated_at)
VALUES (${minute}, 7, NOW())
ON CONFLICT (minute_ts)
DO UPDATE SET active_users = EXCLUDED.active_users, updated_at = EXCLUDED.updated_at
`;
await db.$executeRaw`
INSERT INTO workspace_admin_stats (
workspace_id, snapshot_count, snapshot_size, blob_count, blob_size, member_count, public_page_count, updated_at
)
VALUES (${workspace.id}, 1, 100, 1, 50, 1, 1, NOW())
ON CONFLICT (workspace_id)
DO UPDATE SET
snapshot_count = EXCLUDED.snapshot_count,
snapshot_size = EXCLUDED.snapshot_size,
blob_count = EXCLUDED.blob_count,
blob_size = EXCLUDED.blob_size,
member_count = EXCLUDED.member_count,
public_page_count = EXCLUDED.public_page_count,
updated_at = EXCLUDED.updated_at
`;
await db.$executeRaw`
INSERT INTO workspace_admin_stats_daily (
workspace_id, date, snapshot_size, blob_size, member_count, updated_at
)
VALUES (${workspace.id}, CURRENT_DATE, 100, 50, 1, NOW())
ON CONFLICT (workspace_id, date)
DO UPDATE SET
snapshot_size = EXCLUDED.snapshot_size,
blob_size = EXCLUDED.blob_size,
member_count = EXCLUDED.member_count,
updated_at = EXCLUDED.updated_at
`;
await db.$executeRaw`
INSERT INTO workspace_doc_view_daily (
workspace_id, doc_id, date, total_views, unique_views, guest_views, last_accessed_at, updated_at
)
VALUES (${workspace.id}, ${docId}, CURRENT_DATE, 3, 2, 1, NOW(), NOW())
ON CONFLICT (workspace_id, doc_id, date)
DO UPDATE SET
total_views = EXCLUDED.total_views,
unique_views = EXCLUDED.unique_views,
guest_views = EXCLUDED.guest_views,
last_accessed_at = EXCLUDED.last_accessed_at,
updated_at = EXCLUDED.updated_at
`;
const dashboardQuery = `
query AdminDashboard($input: AdminDashboardInput) {
adminDashboard(input: $input) {
syncWindow {
bucket
requestedSize
effectiveSize
}
storageWindow {
bucket
requestedSize
effectiveSize
}
topSharedLinksWindow {
bucket
requestedSize
effectiveSize
}
copilotWindow {
to
bucket
requestedSize
effectiveSize
}
syncActiveUsersTimeline {
minute
activeUsers
}
workspaceStorageHistory {
date
value
}
generatedAt
}
}
`;
const result = await gql(dashboardQuery, {
input: {
storageHistoryDays: -10,
syncHistoryHours: -10,
sharedLinkWindowDays: -10,
copilotWindowDays: 500,
},
});
t.falsy(result.errors);
const dashboard = result.data!.adminDashboard;
t.is(dashboard.syncWindow.bucket, 'Minute');
t.is(dashboard.syncWindow.effectiveSize, 1);
t.is(dashboard.storageWindow.bucket, 'Day');
t.is(dashboard.storageWindow.effectiveSize, 1);
t.is(dashboard.topSharedLinksWindow.effectiveSize, 1);
t.is(dashboard.copilotWindow.bucket, 'Day');
t.is(dashboard.copilotWindow.effectiveSize, 90);
t.is(
new Date(dashboard.copilotWindow.to).getTime(),
new Date(dashboard.generatedAt).getTime()
);
t.is(dashboard.syncActiveUsersTimeline.length, 1);
t.is(dashboard.workspaceStorageHistory.length, 1);
}
);
e2e('adminWorkspace should serialize member roles as enum names', async t => {
const admin = await app.create(Mockers.User, {
feature: 'administrator',
});
await app.login(admin);
const owner = await app.create(Mockers.User);
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
const result = await gql(
`
query AdminWorkspace($id: String!) {
adminWorkspace(id: $id) {
id
members(skip: 0, take: 20) {
id
role
status
}
}
}
`,
{ id: workspace.id }
);
t.falsy(result.errors);
t.is(result.data!.adminWorkspace.id, workspace.id);
t.true(
result.data!.adminWorkspace.members.some(
(member: { id: string; role: string }) =>
member.id === owner.id && member.role === 'Owner'
)
);
});
e2e(
'adminDashboard should carry forward missing sync and storage samples',
async t => {
const now = new Date('2026-04-05T08:55:00.000Z');
const clock = Sinon.useFakeTimers({ now, toFake: ['Date'] });
try {
const admin = await app.create(Mockers.User, {
feature: 'administrator',
});
await app.login(admin);
const owner = await app.create(Mockers.User);
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
const db = app.get(PrismaClient);
await ensureAnalyticsTables(db);
const minute = new Date();
minute.setSeconds(0, 0);
const sampleStartMinute = new Date(minute.getTime() - 30 * 60 * 1000);
const sampleEndMinute = new Date(
sampleStartMinute.getTime() + 2 * 60 * 1000
);
await db.$executeRaw`
INSERT INTO sync_active_users_minutely (minute_ts, active_users, updated_at)
VALUES
(${sampleStartMinute}, 5, NOW()),
(${sampleEndMinute}, 7, NOW())
ON CONFLICT (minute_ts)
DO UPDATE SET active_users = EXCLUDED.active_users, updated_at = EXCLUDED.updated_at
`;
await db.$executeRaw`
INSERT INTO workspace_admin_stats (
workspace_id, snapshot_count, snapshot_size, blob_count, blob_size, member_count, public_page_count, updated_at
)
VALUES (${workspace.id}, 1, 130, 1, 70, 1, 0, NOW())
ON CONFLICT (workspace_id)
DO UPDATE SET
snapshot_count = EXCLUDED.snapshot_count,
snapshot_size = EXCLUDED.snapshot_size,
blob_count = EXCLUDED.blob_count,
blob_size = EXCLUDED.blob_size,
member_count = EXCLUDED.member_count,
public_page_count = EXCLUDED.public_page_count,
updated_at = EXCLUDED.updated_at
`;
const today = new Date();
const currentDay = new Date(
Date.UTC(
today.getUTCFullYear(),
today.getUTCMonth(),
today.getUTCDate()
)
);
const twoDaysAgo = new Date(
currentDay.getTime() - 2 * 24 * 60 * 60 * 1000
);
await db.$executeRaw`
INSERT INTO workspace_admin_stats_daily (
workspace_id, date, snapshot_size, blob_size, member_count, updated_at
)
VALUES (${workspace.id}, ${twoDaysAgo}, 100, 50, 1, NOW())
ON CONFLICT (workspace_id, date)
DO UPDATE SET
snapshot_size = EXCLUDED.snapshot_size,
blob_size = EXCLUDED.blob_size,
member_count = EXCLUDED.member_count,
updated_at = EXCLUDED.updated_at
`;
const result = await gql(
`
query AdminDashboard($input: AdminDashboardInput) {
adminDashboard(input: $input) {
syncActiveUsers
syncActiveUsersTimeline {
minute
activeUsers
}
workspaceStorageBytes
blobStorageBytes
workspaceStorageHistory {
date
value
}
blobStorageHistory {
date
value
}
}
}
`,
{
input: {
storageHistoryDays: 3,
syncHistoryHours: 2,
},
}
);
t.falsy(result.errors);
const dashboard = result.data!.adminDashboard;
t.is(dashboard.syncActiveUsers, 7);
const missingMinute = new Date(sampleStartMinute.getTime() + 60 * 1000);
t.is(
dashboard.syncActiveUsersTimeline.find(
(point: { minute: string }) =>
point.minute === missingMinute.toISOString()
)?.activeUsers,
5
);
const workspaceHistory = dashboard.workspaceStorageHistory.map(
(point: { value: number }) => point.value
);
const blobHistory = dashboard.blobStorageHistory.map(
(point: { value: number }) => point.value
);
t.is(workspaceHistory.length, 3);
t.is(blobHistory.length, 3);
t.is(workspaceHistory[0], workspaceHistory[1]);
t.is(blobHistory[0], blobHistory[1]);
t.is(
workspaceHistory[workspaceHistory.length - 1],
dashboard.workspaceStorageBytes
);
t.is(blobHistory[blobHistory.length - 1], dashboard.blobStorageBytes);
const baselineResult = await gql(
`
query AdminDashboard($input: AdminDashboardInput) {
adminDashboard(input: $input) {
workspaceStorageHistory {
value
}
blobStorageHistory {
value
}
}
}
`,
{
input: {
storageHistoryDays: 2,
},
}
);
t.falsy(baselineResult.errors);
t.is(
baselineResult.data!.adminDashboard.workspaceStorageHistory[0].value,
workspaceHistory[1]
);
t.is(
baselineResult.data!.adminDashboard.blobStorageHistory[0].value,
blobHistory[1]
);
} finally {
clock.restore();
}
}
);
e2e(
'adminDashboard should not backfill sync samples older than the requested window',
async t => {
const now = new Date('2026-04-05T08:55:00.000Z');
const clock = Sinon.useFakeTimers({ now, toFake: ['Date'] });
try {
const admin = await app.create(Mockers.User, {
feature: 'administrator',
});
await app.login(admin);
const db = app.get(PrismaClient);
await ensureAnalyticsTables(db);
const staleMinute = new Date('2026-04-05T05:55:00.000Z');
await db.$executeRaw`
INSERT INTO sync_active_users_minutely (minute_ts, active_users, updated_at)
VALUES (${staleMinute}, 9, NOW())
ON CONFLICT (minute_ts)
DO UPDATE SET active_users = EXCLUDED.active_users, updated_at = EXCLUDED.updated_at
`;
const result = await gql(
`
query AdminDashboard($input: AdminDashboardInput) {
adminDashboard(input: $input) {
syncActiveUsers
syncActiveUsersTimeline {
activeUsers
}
}
}
`,
{
input: {
syncHistoryHours: 1,
},
}
);
t.falsy(result.errors);
const dashboard = result.data!.adminDashboard;
t.is(dashboard.syncActiveUsers, 0);
t.true(
dashboard.syncActiveUsersTimeline.every(
(point: { activeUsers: number }) => point.activeUsers === 0
)
);
} finally {
clock.restore();
}
}
);
e2e(
'Doc analytics and lastAccessedMembers should enforce permissions and privacy',
async t => {
const owner = await app.signup();
const member = await app.create(Mockers.User);
const staleMember = await app.create(Mockers.User);
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await app.create(Mockers.TeamWorkspace, { id: workspace.id });
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: member.id,
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: staleMember.id,
});
const docId = await createPublicDoc({
workspaceId: workspace.id,
ownerId: owner.id,
title: 'page-analytics-doc',
updatedAt: new Date(),
publishedAt: new Date(),
});
const db = app.get(PrismaClient);
await ensureAnalyticsTables(db);
await db.$executeRaw`
INSERT INTO workspace_doc_view_daily (
workspace_id, doc_id, date, total_views, unique_views, guest_views, last_accessed_at, updated_at
)
VALUES (${workspace.id}, ${docId}, CURRENT_DATE, 9, 5, 2, NOW(), NOW())
ON CONFLICT (workspace_id, doc_id, date)
DO UPDATE SET
total_views = EXCLUDED.total_views,
unique_views = EXCLUDED.unique_views,
guest_views = EXCLUDED.guest_views,
last_accessed_at = EXCLUDED.last_accessed_at,
updated_at = EXCLUDED.updated_at
`;
await db.$executeRaw`
INSERT INTO workspace_member_last_access (
workspace_id, user_id, last_accessed_at, last_doc_id, updated_at
)
VALUES
(${workspace.id}, ${owner.id}, NOW(), ${docId}, NOW()),
(${workspace.id}, ${member.id}, NOW() - interval '1 minute', ${docId}, NOW()),
(${workspace.id}, ${staleMember.id}, NOW() - interval '8 day', ${docId}, NOW())
ON CONFLICT (workspace_id, user_id)
DO UPDATE SET
last_accessed_at = EXCLUDED.last_accessed_at,
last_doc_id = EXCLUDED.last_doc_id,
updated_at = EXCLUDED.updated_at
`;
const analyticsQuery = `
query DocAnalytics($workspaceId: String!, $docId: String!) {
workspace(id: $workspaceId) {
doc(docId: $docId) {
analytics(input: { windowDays: 999 }) {
window {
effectiveSize
}
series {
date
totalViews
}
summary {
totalViews
uniqueViews
guestViews
}
}
lastAccessedMembers(
pagination: { first: 100, offset: 0 }
includeTotal: true
) {
totalCount
edges {
node {
user {
id
name
avatarUrl
}
lastAccessedAt
lastDocId
}
}
}
}
}
}
`;
await app.login(owner);
const ownerResult = await gql(analyticsQuery, {
workspaceId: workspace.id,
docId,
});
t.falsy(ownerResult.errors);
t.is(ownerResult.data!.workspace.doc.analytics.window.effectiveSize, 90);
t.true(ownerResult.data!.workspace.doc.analytics.series.length > 0);
t.is(ownerResult.data!.workspace.doc.lastAccessedMembers.totalCount, 3);
t.is(ownerResult.data!.workspace.doc.lastAccessedMembers.edges.length, 3);
t.true(
ownerResult.data!.workspace.doc.lastAccessedMembers.edges.some(
(edge: { node: { user: { id: string } } }) =>
edge.node.user.id === staleMember.id
)
);
const malformedMembersCursor = await gql(
`
query DocMembersCursor($workspaceId: String!, $docId: String!, $after: String) {
workspace(id: $workspaceId) {
doc(docId: $docId) {
lastAccessedMembers(
pagination: { first: 10, offset: 0, after: $after }
) {
edges {
node {
user {
id
}
}
}
}
}
}
}
`,
{
workspaceId: workspace.id,
docId,
after: JSON.stringify({
lastAccessedAt: 'not-a-date',
userId: owner.id,
}),
}
);
t.truthy(malformedMembersCursor.errors?.length);
t.is(malformedMembersCursor.errors![0].extensions.name, 'BAD_REQUEST');
const privacyQuery = `
query DocMembersPrivacy($workspaceId: String!, $docId: String!) {
workspace(id: $workspaceId) {
doc(docId: $docId) {
lastAccessedMembers(pagination: { first: 10, offset: 0 }) {
edges {
node {
user {
id
email
}
}
}
}
}
}
}
`;
const privacyRes = await app
.POST('/graphql')
.send({
query: privacyQuery,
variables: {
workspaceId: workspace.id,
docId,
},
})
.expect(400);
const privacyResult = privacyRes.body as {
errors?: Array<{ message: string }>;
};
t.truthy(privacyResult.errors?.length);
t.true(
privacyResult.errors![0].message.includes(
'Cannot query field "email" on type "PublicUserType"'
)
);
await app.login(member);
const memberDeniedRes = await app
.POST('/graphql')
.send({
query: `
query DocMembersDenied($workspaceId: String!, $docId: String!) {
workspace(id: $workspaceId) {
doc(docId: $docId) {
lastAccessedMembers(pagination: { first: 10, offset: 0 }) {
edges {
node {
user {
id
}
}
}
}
}
}
}
`,
variables: { workspaceId: workspace.id, docId },
})
.expect(200);
const memberDenied = memberDeniedRes.body as {
errors?: Array<{ extensions: Record<string, unknown> }>;
};
t.truthy(memberDenied.errors?.length);
t.is(memberDenied.errors![0].extensions.name, 'DOC_ACTION_DENIED');
}
);

View File

@@ -0,0 +1,201 @@
import { randomUUID } from 'node:crypto';
import { Readable } from 'node:stream';
import { mock } from 'node:test';
import { PrismaClient } from '@prisma/client';
import { StorageRuntimeProvider } from '../../../core/storage-runtime';
import { DocRole, WorkspaceRole } from '../../../models';
import { getMime } from '../../../native';
import { Mockers } from '../../mocks';
import { app, e2e } from '../test';
async function createWorkspace() {
const owner = await app.create(Mockers.User);
const workspace = await app.create(Mockers.Workspace, {
owner,
});
return {
owner,
workspace,
};
}
e2e.afterEach.always(() => {
mock.reset();
});
const objects = new Map<
string,
{
body: Buffer;
metadata?: {
contentLength?: number;
contentType?: string;
checksumCRC32?: string;
lastModified?: Date;
};
}
>();
e2e.beforeEach(() => {
objects.clear();
const rt = app.get(StorageRuntimeProvider);
mock.method(
rt,
'putObject',
async (
_scope: string,
key: string,
body: Buffer,
metadata?: {
contentLength?: number;
contentType?: string;
checksumCRC32?: string;
}
) => {
const object = {
body,
metadata: {
...metadata,
contentType: metadata?.contentType ?? getMime(body),
contentLength: metadata?.contentLength ?? body.length,
lastModified: new Date(),
},
};
objects.set(key, object);
return object.metadata;
}
);
mock.method(rt, 'getObject', async (_scope: string, key: string) => {
const object = objects.get(key);
if (!object) {
return {};
}
return {
body: Readable.from(object.body),
metadata: object.metadata,
};
});
mock.method(rt, 'presignGet', async () => undefined);
});
e2e(
'document aliases use the canonical subject for authorization and I/O',
async t => {
const { owner, workspace } = await createWorkspace();
const member = await app.create(Mockers.User);
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: member.id,
type: WorkspaceRole.Collaborator,
});
const snapshot = await app.create(Mockers.DocSnapshot, {
workspaceId: workspace.id,
user: owner,
});
await app.create(Mockers.DocMeta, {
workspaceId: workspace.id,
docId: snapshot.id,
defaultRole: DocRole.None,
});
await app.login(member);
const res = await app.GET(
`/api/workspaces/${workspace.id}/docs/space:${snapshot.id}`
);
t.is(res.status, 403);
t.is(res.body.data.docId, snapshot.id);
}
);
// #region comment attachment
e2e.serial(
'should get comment attachment not found when key is not exists',
async t => {
const { owner, workspace } = await createWorkspace();
await app.login(owner);
const docId = randomUUID();
const res = await app.GET(
`/api/workspaces/${workspace.id}/docs/${docId}/comment-attachments/not-exists`
);
t.snapshot({ status: res.status, body: res.body });
}
);
e2e.serial(
'should get comment attachment no permission when user is not member',
async t => {
const { workspace } = await createWorkspace();
// signup a new user
await app.signup();
const docId = randomUUID();
const res = await app.GET(
`/api/workspaces/${workspace.id}/docs/${docId}/comment-attachments/some-key`
);
t.is(
res.body.message,
`You do not have permission to perform Doc.Read action on doc ${docId}.`
);
t.snapshot({
status: res.status,
body: {
...res.body,
message: res.body.message.replace(docId, '<doc-id>'),
data: { action: res.body.data.action },
},
});
}
);
e2e.serial('should get comment attachment body', async t => {
const { owner, workspace } = await createWorkspace();
await app.login(owner);
const docId = randomUUID();
const key = randomUUID();
const body = Buffer.from('test');
objects.set(`comment-attachments/${workspace.id}/${docId}/${key}`, {
body,
metadata: {
contentType: 'text/plain',
contentLength: body.length,
lastModified: new Date(),
},
});
await app.get(PrismaClient).commentAttachment.create({
data: {
workspaceId: workspace.id,
docId,
key,
name: 'test.txt',
mime: 'text/plain',
size: body.length,
status: 'completed',
createdBy: owner.id,
},
});
const res = await app.GET(
`/api/workspaces/${workspace.id}/docs/${docId}/comment-attachments/${key}`
);
t.snapshot({
status: res.status,
contentType: res.headers['content-type'],
contentLength: res.headers['content-length'],
cacheControl: res.headers['cache-control'],
hasLastModified: Boolean(res.headers['last-modified']),
body: res.text,
});
});
// #endregion

View File

@@ -0,0 +1,717 @@
import { randomUUID } from 'node:crypto';
import {
acceptInviteByInviteIdMutation,
approveWorkspaceTeamMemberMutation,
createInviteLinkMutation,
getInviteInfoQuery,
grantWorkspaceTeamMemberMutation,
inviteByEmailsMutation,
leaveWorkspaceMutation,
Permission,
revokeMemberPermissionMutation,
WorkspaceInviteLinkExpireTime,
WorkspaceMemberStatus,
} from '@affine/graphql';
import { faker } from '@faker-js/faker';
import {
WorkspaceMemberSource,
WorkspaceMemberStatus as PrismaWorkspaceMemberStatus,
} from '@prisma/client';
import { EntitlementService } from '../../../core/entitlement';
import {
type InvitationNotification,
type InvitationReviewDeclinedNotification,
Models,
NotificationType,
WorkspaceRole as ModelWorkspaceRole,
} from '../../../models';
import { SubscriptionPlan } from '../../../plugins/payment/types';
import { Mockers } from '../../mocks';
import { createRealtimeClient, realtimeRequest } from '../realtime';
import { app, e2e } from '../test';
async function createWorkspace() {
const owner = await app.create(Mockers.User);
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
return {
owner,
workspace,
};
}
async function grantTeamPlan(workspaceId: string, quantity: number) {
await app.get(EntitlementService).upsertAdminGrant({
targetType: 'workspace',
targetId: workspaceId,
plan: SubscriptionPlan.Team,
quantity,
});
}
async function revokeTeamPlan(workspaceId: string) {
await app.get(EntitlementService).revokeAdminGrant('workspace', workspaceId);
}
e2e('should invite a user', async t => {
const { owner, workspace } = await createWorkspace();
const u2 = await app.create(Mockers.User);
await app.login(owner);
const result = await app.gql({
query: inviteByEmailsMutation,
variables: {
emails: [u2.email],
workspaceId: workspace.id,
},
});
t.truthy(result, 'failed to invite user');
const [invitationNotification] =
await app.models.notification.findManyByUserId(u2.id, {
includeRead: true,
first: 1,
offset: 0,
});
const invitation = invitationNotification as InvitationNotification;
t.is(invitation.type, NotificationType.Invitation);
t.is(invitation.body.createdByUserId, owner.id);
t.is(invitation.body.inviteId, result.inviteMembers[0].inviteId!);
await t.throwsAsync(
app.gql({
query: getInviteInfoQuery,
variables: {
inviteId: invitation.body.inviteId,
},
}),
{ message: 'This invitation belongs to another account.' }
);
await t.throwsAsync(
app.gql({
query: acceptInviteByInviteIdMutation,
variables: {
workspaceId: workspace.id,
inviteId: invitation.body.inviteId,
},
}),
{ message: 'This invitation belongs to another account.' }
);
await app.logout();
await t.throwsAsync(
app.gql({
query: getInviteInfoQuery,
variables: {
inviteId: invitation.body.inviteId,
},
}),
{ message: 'You must sign in first to access this resource.' }
);
await t.throwsAsync(
app.gql({
query: acceptInviteByInviteIdMutation,
variables: {
workspaceId: workspace.id,
inviteId: invitation.body.inviteId,
},
}),
{ message: 'You must sign in first to access this resource.' }
);
await app.login(u2);
// invitation status is pending
const { getInviteInfo } = await app.gql({
query: getInviteInfoQuery,
variables: {
inviteId: invitation.body.inviteId,
},
});
t.is(getInviteInfo.status, WorkspaceMemberStatus.Pending);
// u2 accept invite
await app.gql({
query: acceptInviteByInviteIdMutation,
variables: {
workspaceId: workspace.id,
inviteId: invitation.body.inviteId,
},
});
// invitation status is accepted
const { getInviteInfo: getInviteInfo2 } = await app.gql({
query: getInviteInfoQuery,
variables: {
inviteId: invitation.body.inviteId,
},
});
t.is(getInviteInfo2.status, WorkspaceMemberStatus.Accepted);
});
e2e(
'should remove charged invitations when the team entitlement is revoked',
async t => {
const { owner, workspace } = await createWorkspace();
const member = await app.create(Mockers.User);
await grantTeamPlan(workspace.id, 12);
await Promise.all(
Array.from({ length: 10 }).map(async () => {
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: (await app.create(Mockers.User)).id,
});
})
);
await app.login(owner);
const invite = await app.gql({
query: inviteByEmailsMutation,
variables: {
emails: [member.email],
workspaceId: workspace.id,
},
});
await revokeTeamPlan(workspace.id);
await app.login(member);
await t.throwsAsync(
app.gql({
query: acceptInviteByInviteIdMutation,
variables: {
workspaceId: workspace.id,
inviteId: invite.inviteMembers[0].inviteId!,
},
})
);
await t.throwsAsync(
app.gql({
query: getInviteInfoQuery,
variables: {
inviteId: invite.inviteMembers[0].inviteId!,
},
}),
{ message: 'Invitation not found' }
);
}
);
e2e('should leave a workspace', async t => {
const { owner, workspace } = await createWorkspace();
const u2 = await app.create(Mockers.User);
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: u2.id,
});
await app.login(u2);
const { leaveWorkspace } = await app.gql({
query: leaveWorkspaceMutation,
variables: {
workspaceId: workspace.id,
},
});
t.true(leaveWorkspace, 'failed to leave workspace');
const leaveMail = await app.mails.waitFor('MemberLeave');
t.is(leaveMail.to, owner.email);
t.is(leaveMail.props.user.$$userId, u2.id);
});
e2e('should revoke a user', async t => {
const u2 = await app.signup();
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: u2.id,
});
const { revokeMember } = await app.gql({
query: revokeMemberPermissionMutation,
variables: {
workspaceId: workspace.id,
userId: u2.id,
},
});
t.true(revokeMember, 'failed to revoke user');
});
e2e('should map every workspace role transition without fallback', async t => {
const roles = [
{
graphql: Permission.Owner,
model: ModelWorkspaceRole.Owner,
},
{
graphql: Permission.Admin,
model: ModelWorkspaceRole.Admin,
},
{
graphql: Permission.Collaborator,
model: ModelWorkspaceRole.Collaborator,
},
{
graphql: Permission.External,
model: ModelWorkspaceRole.External,
},
];
for (const current of roles) {
for (const next of roles) {
const actor = await app.create(Mockers.User);
const target =
current.model === ModelWorkspaceRole.Owner
? actor
: await app.create(Mockers.User);
const workspace = await app.create(Mockers.Workspace, {
owner: { id: actor.id },
});
await grantTeamPlan(workspace.id, 2);
if (
current.model !== ModelWorkspaceRole.External &&
current.model !== ModelWorkspaceRole.Owner
) {
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: target.id,
type: current.model,
});
}
await app.login(actor);
const mutation = app.gql({
query: grantWorkspaceTeamMemberMutation,
variables: {
workspaceId: workspace.id,
userId: target.id,
permission: next.graphql,
},
});
const allowed =
current.model === ModelWorkspaceRole.Admin ||
current.model === ModelWorkspaceRole.Collaborator ||
(current.model === ModelWorkspaceRole.Owner &&
next.model === ModelWorkspaceRole.Owner);
if (!allowed) {
await t.throwsAsync(mutation);
continue;
}
const { grantMember } = await mutation;
t.true(grantMember);
const member = await app
.get(Models)
.workspaceUser.get(workspace.id, target.id);
if (next.model === ModelWorkspaceRole.External) {
t.falsy(member);
} else {
t.is(member?.type, next.model);
}
}
}
});
e2e('should approve a user on under review', async t => {
const { owner, workspace } = await createWorkspace();
const user = await app.create(Mockers.User);
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: user.id,
status: WorkspaceMemberStatus.UnderReview,
kind: 'link',
});
await app.login(owner);
const { approveMember } = await app.gql({
query: approveWorkspaceTeamMemberMutation,
variables: {
workspaceId: workspace.id,
userId: user.id,
},
});
t.true(approveMember, 'failed to approve member');
t.is(
(await app.get(Models).workspaceUser.get(workspace.id, user.id))?.status,
WorkspaceMemberStatus.Accepted
);
});
e2e('should revoke a user on under review', async t => {
const { owner, workspace } = await createWorkspace();
const user = await app.create(Mockers.User);
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: user.id,
status: WorkspaceMemberStatus.UnderReview,
});
await app.login(owner);
const { revokeMember } = await app.gql({
query: revokeMemberPermissionMutation,
variables: {
workspaceId: workspace.id,
userId: user.id,
},
});
t.true(revokeMember, 'failed to revoke user');
const [requestDeclinedNotification] =
await app.models.notification.findManyByUserId(user.id, {
includeRead: true,
first: 1,
offset: 0,
});
const declined =
requestDeclinedNotification as InvitationReviewDeclinedNotification;
t.is(declined.type, NotificationType.InvitationReviewDeclined);
t.is(declined.userId, user.id);
t.is(declined.body.workspaceId, workspace.id);
t.is(declined.body.createdByUserId, owner.id);
});
e2e('should create user if not exist', async t => {
const { owner, workspace } = await createWorkspace();
const email = faker.internet.email();
await app.login(owner);
await app.gql({
query: inviteByEmailsMutation,
variables: {
emails: [email],
workspaceId: workspace.id,
},
});
const u2 = await app.get(Models).user.getUserByEmail(email);
t.truthy(u2, 'failed to create user');
});
e2e('should support pagination for member', async t => {
const { owner, workspace } = await createWorkspace();
const u1 = await app.create(Mockers.User);
const u2 = await app.create(Mockers.User);
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: u1.id,
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: u2.id,
});
const socket = await createRealtimeClient(app, owner);
t.teardown(() => socket.disconnect());
let result = await realtimeRequest(socket, 'workspace.members.get', {
workspaceId: workspace.id,
skip: 0,
take: 2,
});
t.is(result.memberCount, 3);
t.is(result.members.length, 2);
result = await realtimeRequest(socket, 'workspace.members.get', {
workspaceId: workspace.id,
skip: 2,
take: 2,
});
t.is(result.memberCount, 3);
t.is(result.members.length, 1);
result = await realtimeRequest(socket, 'workspace.members.get', {
workspaceId: workspace.id,
skip: 3,
take: 2,
});
t.is(result.memberCount, 3);
t.is(result.members.length, 0);
});
e2e('should limit member count correctly', async t => {
const { owner, workspace } = await createWorkspace();
await Promise.all(
Array.from({ length: 10 }).map(async () => {
const user = await app.create(Mockers.User);
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: user.id,
});
})
);
const socket = await createRealtimeClient(app, owner);
t.teardown(() => socket.disconnect());
const result = await realtimeRequest(socket, 'workspace.members.get', {
workspaceId: workspace.id,
skip: 0,
take: 10,
});
t.is(result.memberCount, 11);
t.is(result.members.length, 10);
});
e2e('should get invite link info with status', async t => {
const { owner, workspace } = await createWorkspace();
await app.login(owner);
const { createInviteLink } = await app.gql({
query: createInviteLinkMutation,
variables: {
workspaceId: workspace.id,
expireTime: WorkspaceInviteLinkExpireTime.OneDay,
},
});
t.truthy(createInviteLink, 'failed to create invite link');
const link = createInviteLink.link;
const inviteId = link.split('/').pop()!;
// owner/member see accept status
const { getInviteInfo } = await app.gql({
query: getInviteInfoQuery,
variables: {
inviteId,
},
});
t.truthy(getInviteInfo, 'failed to get invite info');
t.is(getInviteInfo.status, WorkspaceMemberStatus.Accepted);
// non-member see null status
await app.signup();
const { getInviteInfo: getInviteInfo2 } = await app.gql({
query: getInviteInfoQuery,
variables: {
inviteId,
},
});
t.truthy(getInviteInfo2, 'failed to get invite info');
t.is(getInviteInfo2.status, null);
// pending-member see under review status
await app.signup();
await app.gql({
query: acceptInviteByInviteIdMutation,
variables: {
workspaceId: workspace.id,
inviteId,
},
});
const { getInviteInfo: getInviteInfo3 } = await app.gql({
query: getInviteInfoQuery,
variables: {
inviteId,
},
});
t.truthy(getInviteInfo3, 'failed to get invite info');
t.is(getInviteInfo3.status, WorkspaceMemberStatus.UnderReview);
});
e2e(
'should accept invitation by link directly if status is pending',
async t => {
const { owner, workspace } = await createWorkspace();
const member = await app.create(Mockers.User);
await app.login(owner);
// create a pending invitation
const invite = await app.gql({
query: inviteByEmailsMutation,
variables: {
emails: [member.email],
workspaceId: workspace.id,
},
});
t.truthy(invite, 'failed to create invitation');
const { createInviteLink } = await app.gql({
query: createInviteLinkMutation,
variables: {
workspaceId: workspace.id,
expireTime: WorkspaceInviteLinkExpireTime.OneDay,
},
});
t.truthy(createInviteLink, 'failed to create invite link');
const link = createInviteLink.link;
const inviteLinkId = link.split('/').pop()!;
// member accept invitation by link
await app.login(member);
await app.gql({
query: acceptInviteByInviteIdMutation,
variables: {
inviteId: inviteLinkId,
workspaceId: workspace.id,
},
});
const { getInviteInfo } = await app.gql({
query: getInviteInfoQuery,
variables: {
inviteId: invite.inviteMembers[0].inviteId!,
},
});
t.is(getInviteInfo.status, WorkspaceMemberStatus.Accepted);
}
);
e2e(
'should invite by link and send review request notification below quota limit',
async t => {
const { owner, workspace } = await createWorkspace();
await app.login(owner);
const { createInviteLink } = await app.gql({
query: createInviteLinkMutation,
variables: {
workspaceId: workspace.id,
expireTime: WorkspaceInviteLinkExpireTime.OneDay,
},
});
t.truthy(createInviteLink, 'failed to create invite link');
const link = createInviteLink.link;
const inviteId = link.split('/').pop()!;
// accept invite by link
await app.signup();
const result = await app.gql({
query: acceptInviteByInviteIdMutation,
variables: {
workspaceId: workspace.id,
inviteId,
},
});
t.truthy(result, 'failed to accept invite');
const [notification] = await app.models.notification.findManyByUserId(
owner.id,
{ includeRead: true, first: 1, offset: 0 }
);
const review = notification as InvitationNotification;
t.is(review.type, NotificationType.InvitationReviewRequest);
t.is(review.userId, owner.id);
t.truthy(review.body.inviteId);
}
);
e2e(
'should invite by link and send review request notification over quota limit',
async t => {
const { owner, workspace } = await createWorkspace();
await grantTeamPlan(workspace.id, 3);
await app.login(owner);
const { createInviteLink } = await app.gql({
query: createInviteLinkMutation,
variables: {
workspaceId: workspace.id,
expireTime: WorkspaceInviteLinkExpireTime.OneDay,
},
});
t.truthy(createInviteLink, 'failed to create invite link');
const link = createInviteLink.link;
const inviteId = link.split('/').pop()!;
// accept invite by link
await app.signup();
const result = await app.gql({
query: acceptInviteByInviteIdMutation,
variables: {
workspaceId: workspace.id,
inviteId,
},
});
t.truthy(result, 'failed to accept invite');
const [notification] = await app.models.notification.findManyByUserId(
owner.id,
{ includeRead: true, first: 1, offset: 0 }
);
const review = notification as InvitationNotification;
t.is(review.type, NotificationType.InvitationReviewRequest);
t.is(review.userId, owner.id);
t.truthy(review.body.inviteId);
}
);
e2e(
'should search members by name and email support case insensitive',
async t => {
const { owner, workspace } = await createWorkspace();
const user1 = await app.create(Mockers.User, {
name: faker.internet.displayName({ firstName: 'Lucy' }),
});
const user2 = await app.create(Mockers.User, {
email: `jeanne_doe.${randomUUID()}@affine.pro`,
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: user1.id,
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: user2.id,
});
const socket = await createRealtimeClient(app, owner);
t.teardown(() => socket.disconnect());
let result = await realtimeRequest(socket, 'workspace.members.get', {
workspaceId: workspace.id,
query: 'lucy',
});
t.is(result.memberCount, 3);
t.is(result.members.length, 1);
t.is(result.members[0].name, user1.name);
result = await realtimeRequest(socket, 'workspace.members.get', {
workspaceId: workspace.id,
query: 'LUCY',
});
t.is(result.memberCount, 3);
t.is(result.members.length, 1);
t.is(result.members[0].name, user1.name);
result = await realtimeRequest(socket, 'workspace.members.get', {
workspaceId: workspace.id,
query: 'jeanne_doe',
});
t.is(result.memberCount, 3);
t.is(result.members.length, 1);
t.is(result.members[0].email, user2.email);
const pendingEmail = `pending_search.${randomUUID()}@affine.pro`;
const pendingUser = await app.create(Mockers.User, {
email: pendingEmail,
});
await app
.get(Models)
.workspaceUser.set(
workspace.id,
pendingUser.id,
ModelWorkspaceRole.Collaborator,
{
status: PrismaWorkspaceMemberStatus.Pending,
source: WorkspaceMemberSource.Email,
}
);
result = await realtimeRequest(socket, 'workspace.members.get', {
workspaceId: workspace.id,
query: 'pending_search',
});
t.is(result.memberCount, 4);
t.is(result.members.length, 1);
t.is(result.members[0].email, pendingEmail);
t.is(result.members[0].status, WorkspaceMemberStatus.Pending);
}
);

View File

@@ -0,0 +1,256 @@
import {
inviteByEmailsMutation,
publishPageMutation,
revokeMemberPermissionMutation,
revokePublicPageMutation,
WorkspaceMemberStatus,
} from '@affine/graphql';
import { BackendRuntimeProvider } from '../../../core/backend-runtime';
import { EntitlementService } from '../../../core/entitlement';
import { QuotaService } from '../../../core/quota/service';
import { WorkspaceRole } from '../../../models';
import {
SubscriptionPlan,
SubscriptionRecurring,
} from '../../../plugins/payment/types';
import { Mockers } from '../../mocks';
import { app, e2e } from '../test';
const teamE2E = e2e.serial;
const createTeamWorkspace = async (memberLimit = 3) => {
const owner = await app.create(Mockers.User);
const workspace = await app.create(Mockers.Workspace, {
owner: {
id: owner.id,
},
});
await app.create(Mockers.TeamWorkspace, {
id: workspace.id,
quantity: memberLimit,
});
const writer = await app.create(Mockers.User);
await app.create(Mockers.WorkspaceUser, {
userId: writer.id,
workspaceId: workspace.id,
});
const admin = await app.create(Mockers.User);
await app.create(Mockers.WorkspaceUser, {
userId: admin.id,
workspaceId: workspace.id,
type: WorkspaceRole.Admin,
});
const external = await app.create(Mockers.User);
return {
workspace,
owner,
admin,
writer,
external,
};
};
const publishDoc = async (workspaceId: string, docId: string) => {
const { publishDoc } = await app.gql({
query: publishPageMutation,
variables: {
workspaceId,
pageId: docId,
},
});
return publishDoc;
};
const revokePublicDoc = async (workspaceId: string, docId: string) => {
const { revokePublicDoc } = await app.gql({
query: revokePublicPageMutation,
variables: {
workspaceId,
pageId: docId,
},
});
return revokePublicDoc;
};
const revokeMember = async (workspaceId: string, userId: string) => {
const { revokeMember } = await app.gql({
query: revokeMemberPermissionMutation,
variables: {
workspaceId,
userId,
},
});
return revokeMember;
};
const cancelTeamWorkspace = async (workspaceId: string) => {
await app.get(EntitlementService).revokeAdminGrant('workspace', workspaceId);
await app.eventBus.emitAsync('workspace.subscription.canceled', {
workspaceId,
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Monthly,
});
};
teamE2E('should reject invitations beyond the charged seat limit', async t => {
const { owner, workspace } = await createTeamWorkspace();
await app.login(owner);
const u1 = await app.createUser();
await t.throwsAsync(
app.gql({
query: inviteByEmailsMutation,
variables: {
workspaceId: workspace.id,
emails: [u1.email],
},
}),
{ message: /No more seat available/ }
);
});
teamE2E(
'should cleanup non-accepted members when team workspace is downgraded',
async t => {
const { workspace } = await createTeamWorkspace();
const pending = await app.create(Mockers.User);
await app.create(Mockers.WorkspaceUser, {
userId: pending.id,
workspaceId: workspace.id,
status: WorkspaceMemberStatus.Pending,
});
const allocating = await app.create(Mockers.User);
await app.create(Mockers.WorkspaceUser, {
userId: allocating.id,
workspaceId: workspace.id,
status: WorkspaceMemberStatus.AllocatingSeat,
});
const underReview = await app.create(Mockers.User);
await app.create(Mockers.WorkspaceUser, {
userId: underReview.id,
workspaceId: workspace.id,
status: WorkspaceMemberStatus.UnderReview,
});
await cancelTeamWorkspace(workspace.id);
const [members] = await app.models.workspaceUser.paginate(workspace.id, {
first: 20,
offset: 0,
});
t.deepEqual(
members.map(member => member.status),
[
WorkspaceMemberStatus.Accepted,
WorkspaceMemberStatus.Accepted,
WorkspaceMemberStatus.Accepted,
]
);
t.false(await app.models.workspace.isTeamWorkspace(workspace.id));
}
);
teamE2E(
'should demote accepted admins and keep workspace writable when downgrade stays within owner quota',
async t => {
const { workspace, owner, admin } = await createTeamWorkspace();
await cancelTeamWorkspace(workspace.id);
t.false(await app.models.workspace.isTeamWorkspace(workspace.id));
t.false(
(
await app
.get(BackendRuntimeProvider)
.getWorkspaceQuotaStateV1(workspace.id)
).readonly
);
t.is(
(await app.models.workspaceUser.get(workspace.id, admin.id))?.type,
WorkspaceRole.Collaborator
);
await app.create(Mockers.DocSnapshot, {
workspaceId: workspace.id,
docId: 'doc-1',
user: owner,
});
await app.login(owner);
await t.notThrowsAsync(publishDoc(workspace.id, 'doc-1'));
}
);
teamE2E(
'should enter readonly mode on over-quota team downgrade and recover through cleanup actions',
async t => {
const { workspace, owner, admin } = await createTeamWorkspace(20);
const extraMembers = await Promise.all(
Array.from({ length: 8 }).map(async () => {
const member = await app.create(Mockers.User);
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: member.id,
});
return member;
})
);
await app.login(owner);
await Promise.all(
['published-doc', 'blocked-doc'].map(docId =>
app.create(Mockers.DocSnapshot, {
workspaceId: workspace.id,
docId,
user: owner,
})
)
);
await publishDoc(workspace.id, 'published-doc');
await cancelTeamWorkspace(workspace.id);
t.false(await app.models.workspace.isTeamWorkspace(workspace.id));
t.true(
(
await app
.get(BackendRuntimeProvider)
.getWorkspaceQuotaStateV1(workspace.id)
).readonly
);
t.is(
(await app.models.workspaceUser.get(workspace.id, admin.id))?.type,
WorkspaceRole.Collaborator
);
await t.throwsAsync(publishDoc(workspace.id, 'blocked-doc'));
await t.notThrowsAsync(revokePublicDoc(workspace.id, 'published-doc'));
const quota = await app
.get(QuotaService)
.getWorkspaceQuotaWithUsage(workspace.id);
for (const member of extraMembers.slice(0, quota.overcapacityMemberCount)) {
await revokeMember(workspace.id, member.id);
}
t.false(
(
await app
.get(BackendRuntimeProvider)
.getWorkspaceQuotaStateV1(workspace.id)
).readonly
);
}
);

View File

@@ -0,0 +1,186 @@
import test from 'ava';
import { Env } from '../env';
const envs = { ...process.env };
test.beforeEach(() => {
process.env = { ...envs };
});
test('should init env', t => {
t.true(globalThis.env.testing);
});
test('should read NODE_ENV', t => {
process.env.NODE_ENV = 'test';
t.deepEqual(
['test', 'development', 'production'].map(envVal => {
process.env.NODE_ENV = envVal;
const env = new Env();
return env.NODE_ENV;
}),
['test', 'development', 'production']
);
t.throws(
() => {
process.env.NODE_ENV = 'unknown';
new Env();
},
{
message:
'Invalid NODE_ENV environment. `unknown` is not a valid NODE_ENV value.',
}
);
});
test('should read NAMESPACE', t => {
t.deepEqual(
['dev', 'beta', 'production'].map(envVal => {
process.env.AFFINE_ENV = envVal;
const env = new Env();
return env.NAMESPACE;
}),
['dev', 'beta', 'production']
);
t.throws(() => {
process.env.AFFINE_ENV = 'unknown';
new Env();
});
});
test('should read DEPLOYMENT_TYPE', t => {
t.deepEqual(
['affine', 'selfhosted'].map(envVal => {
process.env.DEPLOYMENT_TYPE = envVal;
const env = new Env();
return env.DEPLOYMENT_TYPE;
}),
['affine', 'selfhosted']
);
t.throws(() => {
process.env.DEPLOYMENT_TYPE = 'unknown';
new Env();
});
});
test('should read FLAVOR', t => {
t.deepEqual(
[
'allinone',
'graphql',
'sync',
'renderer',
'front',
'worker',
'script',
].map(envVal => {
process.env.SERVER_FLAVOR = envVal;
const env = new Env();
return env.FLAVOR;
}),
['allinone', 'graphql', 'sync', 'renderer', 'front', 'worker', 'script']
);
t.throws(
() => {
process.env.SERVER_FLAVOR = 'unknown';
new Env();
},
{
message:
'Invalid value "unknown" for environment variable SERVER_FLAVOR, expected one of ["allinone","graphql","sync","renderer","front","worker","script"]',
}
);
});
test('should read platform', t => {
t.deepEqual(
['gcp', 'unknown'].map(envVal => {
process.env.DEPLOYMENT_PLATFORM = envVal;
const env = new Env();
return env.platform;
}),
['gcp', 'unknown']
);
t.notThrows(() => {
process.env.PLATFORM = 'unknown';
new Env();
});
});
test('should tell flavors correctly', t => {
process.env.SERVER_FLAVOR = 'allinone';
t.deepEqual(new Env().flavors, {
graphql: true,
sync: true,
renderer: true,
front: false,
worker: true,
script: false,
});
process.env.SERVER_FLAVOR = 'graphql';
t.deepEqual(new Env().flavors, {
graphql: true,
sync: false,
renderer: false,
front: false,
worker: false,
script: false,
});
process.env.SERVER_FLAVOR = 'front';
t.deepEqual(new Env().flavors, {
graphql: false,
sync: false,
renderer: false,
front: true,
worker: false,
script: false,
});
process.env.SERVER_FLAVOR = 'script';
t.deepEqual(new Env().flavors, {
graphql: false,
sync: false,
renderer: false,
front: false,
worker: false,
script: true,
});
});
test('should tell selfhosted correctly', t => {
process.env.DEPLOYMENT_TYPE = 'selfhosted';
t.true(new Env().selfhosted);
process.env.DEPLOYMENT_TYPE = 'affine';
t.false(new Env().selfhosted);
});
test('should tell namespaces correctly', t => {
process.env.AFFINE_ENV = 'dev';
t.deepEqual(new Env().namespaces, {
canary: true,
beta: false,
production: false,
});
process.env.AFFINE_ENV = 'beta';
t.deepEqual(new Env().namespaces, {
canary: false,
beta: true,
production: false,
});
process.env.AFFINE_ENV = 'production';
t.deepEqual(new Env().namespaces, {
canary: false,
beta: false,
production: true,
});
});

View File

@@ -0,0 +1,97 @@
import { INestApplication } from '@nestjs/common';
import ava, { TestFn } from 'ava';
import { CLS_ID, ClsServiceManager } from 'nestjs-cls';
import Sinon from 'sinon';
import { EventBus } from '../../base';
import { SocketIoAdapter } from '../../base/websocket';
import { createTestingModule } from '../utils';
import { Listeners } from './provider';
const test = ava as TestFn<{
app1: INestApplication;
app2: INestApplication;
}>;
async function createApp() {
const m = await createTestingModule(
{
providers: [Listeners],
},
false
);
const app = m.createNestApplication({ logger: false });
app.useWebSocketAdapter(new SocketIoAdapter(app));
await app.init();
return app;
}
test.before(async t => {
t.context.app1 = await createApp();
t.context.app2 = await createApp();
});
test.after(async t => {
await t.context.app1.close();
await t.context.app2.close();
});
test('should broadcast event to cluster instances', async t => {
const { app1, app2 } = t.context;
// app 1 for listening
const eventbus1 = app1.get(EventBus);
const listener = Sinon.spy(app1.get(Listeners), 'onTestEvent');
const runtimeListener = Sinon.stub().returns({ count: 2 });
const off = eventbus1.on('__test__.event', runtimeListener);
// app 2 for broadcasting
const eventbus2 = app2.get(EventBus);
eventbus2.broadcast('__test__.event', { count: 0 });
// cause the cross instances broadcasting is asynchronization calling
// we should wait for the event's arriving before asserting
await eventbus1.waitFor('__test__.event');
t.true(listener.calledOnceWith({ count: 0 }));
t.true(runtimeListener.calledOnceWith({ count: 0 }));
off();
});
test('should preserve encoded binary updates across cluster instances', async t => {
const { app1, app2 } = t.context;
const eventbus1 = app1.get(EventBus);
const eventbus2 = app2.get(EventBus);
const listener = Sinon.spy(app1.get(Listeners), 'onEncodedBinaryEvent');
const payload = {
updates: [Buffer.from(new Uint8Array([1, 2, 3])).toString('base64')],
};
eventbus2.broadcast('__test__.encodedBinary', payload);
await eventbus1.waitFor('__test__.encodedBinary');
t.true(listener.calledOnceWith(payload));
});
test('should continuously use the same request id', async t => {
const { app1, app2 } = t.context;
const eventbus1 = app1.get(EventBus);
const eventbus2 = app2.get(EventBus);
const listener = Sinon.spy(app1.get(Listeners), 'onRequestId');
const cls = ClsServiceManager.getClsService();
cls.run(() => {
cls.set(CLS_ID, 'test-request-id');
eventbus2.broadcast('__test__.requestId', {});
});
await eventbus1.waitFor('__test__.requestId');
t.true(listener.lastCall.returned('test-request-id'));
});

View File

@@ -0,0 +1,259 @@
import { TestingModule } from '@nestjs/testing';
import ava, { TestFn } from 'ava';
import { CLS_ID, ClsServiceManager } from 'nestjs-cls';
import Sinon from 'sinon';
import { EventBus, metrics } from '../../base';
import { createTestingModule, sleep } from '../utils';
import { Listeners } from './provider';
export const test = ava as TestFn<{
module: TestingModule;
eventbus: EventBus;
listeners: Sinon.SinonSpiedInstance<Listeners>;
}>;
test.before(async t => {
const m = await createTestingModule({
providers: [Listeners],
});
const eventbus = m.get(EventBus);
t.context.module = m;
t.context.eventbus = eventbus;
});
test.beforeEach(t => {
Sinon.restore();
const { module } = t.context;
t.context.listeners = Sinon.spy(module.get(Listeners));
});
test.after(async t => {
await t.context.module.close();
});
test('should dispatch event listener', t => {
const { eventbus, listeners } = t.context;
const runtimeListener = Sinon.stub();
const off = eventbus.on('__test__.event', runtimeListener);
const payload = { count: 0 };
eventbus.emit('__test__.event', payload);
t.true(listeners.onTestEvent.calledOnceWithExactly(payload));
t.true(runtimeListener.calledOnceWithExactly(payload));
off();
});
for (const method of ['emitAsync', 'emitDetachedAsync'] as const) {
test(`should await event listeners with ${method}`, async t => {
const { eventbus, listeners } = t.context;
const runtimeListener = Sinon.stub().callsFake(async payload => {
await new Promise<void>(resolve => setImmediate(resolve));
return payload;
});
const off = eventbus.on('__test__.event', runtimeListener);
t.teardown(off);
const payload = { count: 0 };
const returns = await eventbus[method]('__test__.event', payload);
t.true(listeners.onTestEvent.calledOnceWithExactly(payload));
t.true(listeners.onTestEventAndEvent2.calledOnceWithExactly(payload));
t.true(runtimeListener.calledOnceWithExactly(payload));
t.deepEqual(returns, [payload, payload, payload]);
});
}
test('should dispatch multiple event handlers with same name', async t => {
const { eventbus, listeners } = t.context;
const payload = { count: 0 };
await eventbus.emitAsync('__test__.event', payload);
t.true(listeners.onTestEvent.calledOnceWithExactly(payload));
t.true(listeners.onTestEventAndEvent2.calledOnceWithExactly(payload));
});
test('should dispatch event listener with multiple event names', async t => {
const { eventbus, listeners } = t.context;
const payload = { count: 0 };
await eventbus.emitAsync('__test__.event', payload);
t.like(listeners.onTestEventAndEvent2.lastCall.args[0], payload);
await eventbus.emitAsync('__test__.event2', payload);
t.like(listeners.onTestEventAndEvent2.lastCall.args[0], payload);
});
test('should record event handler call metrics', async t => {
const { eventbus } = t.context;
const timerStub = Sinon.stub(
metrics.event.histogram('function_timer'),
'record'
);
const counterStub = Sinon.stub(
metrics.event.counter('function_calls'),
'add'
);
await eventbus.emitAsync('__test__.event', { count: 0 });
t.true(timerStub.calledTwice);
t.deepEqual(timerStub.firstCall.args[1], {
name: 'event_handler',
event: '__test__.event',
namespace: '__test__',
handler: 'Listeners.onTestEvent',
error: false,
});
t.deepEqual(timerStub.lastCall.args[1], {
name: 'event_handler',
event: '__test__.event',
namespace: '__test__',
handler: 'Listeners.onTestEventAndEvent2',
error: false,
});
t.true(counterStub.calledTwice);
t.deepEqual(counterStub.firstCall.args[1], {
name: 'event_handler',
event: '__test__.event',
namespace: '__test__',
handler: 'Listeners.onTestEvent',
error: false,
});
t.deepEqual(counterStub.lastCall.args[1], {
name: 'event_handler',
event: '__test__.event',
namespace: '__test__',
handler: 'Listeners.onTestEventAndEvent2',
error: false,
});
timerStub.reset();
counterStub.reset();
await eventbus.emitAsync('__test__.event2', { count: 0 });
t.true(timerStub.calledOnce);
t.deepEqual(timerStub.firstCall.args[1], {
name: 'event_handler',
event: '__test__.event2',
namespace: '__test__',
handler: 'Listeners.onTestEventAndEvent2',
error: false,
});
t.true(counterStub.calledOnce);
t.deepEqual(counterStub.firstCall.args[1], {
name: 'event_handler',
event: '__test__.event2',
namespace: '__test__',
handler: 'Listeners.onTestEventAndEvent2',
error: false,
});
timerStub.reset();
counterStub.reset();
try {
await eventbus.emitAsync('__test__.throw', { count: 0 });
} catch {
// noop
}
t.true(timerStub.calledOnce);
t.deepEqual(timerStub.firstCall.args[1], {
name: 'event_handler',
event: '__test__.throw',
namespace: '__test__',
handler: 'Listeners.onThrow',
error: true,
});
t.true(counterStub.calledOnce);
t.deepEqual(counterStub.firstCall.args[1], {
name: 'event_handler',
event: '__test__.throw',
namespace: '__test__',
handler: 'Listeners.onThrow',
error: true,
});
});
test('should generate request id for event', async t => {
const { eventbus, listeners } = t.context;
await eventbus.emitAsync('__test__.requestId', {});
t.true(listeners.onRequestId.lastCall.returnValue.includes(':event:'));
});
test('should continuously use the same request id', async t => {
const { eventbus, listeners } = t.context;
const cls = ClsServiceManager.getClsService();
await cls.run(async () => {
cls.set(CLS_ID, 'test-request-id');
await eventbus.emitAsync('__test__.requestId', {});
});
t.true(listeners.onRequestId.lastCall.returned('test-request-id'));
});
test('should throw when emitting async event with uncaught error', async t => {
const { eventbus } = t.context;
await t.throwsAsync(
() => eventbus.emitAsync('__test__.throw', { count: 0 }),
{
message: 'Error in event handler',
}
);
});
test('should suppress thrown error when emitting async event', async t => {
const { eventbus } = t.context;
const spy = Sinon.spy();
// @ts-expect-error internal event
const off = eventbus.on('error', spy);
const promise = eventbus.emitAsync('__test__.suppressThrow', {});
await t.notThrowsAsync(promise);
t.true(spy.calledOnce);
const args = spy.firstCall.args[0];
t.is(args.event, '__test__.suppressThrow');
t.deepEqual(args.payload, {});
t.is(args.error.message, 'Error in event handler');
const returns = await promise;
t.deepEqual(returns, [undefined]);
off();
});
test('should catch thrown error when emitting sync event', async t => {
const { eventbus } = t.context;
const spy = Sinon.spy();
// @ts-expect-error internal event
const off = eventbus.on('error', spy);
t.notThrows(() => eventbus.emit('__test__.throw', { count: 0 }));
// wait a tick
await sleep(1);
t.true(spy.calledOnce);
const args = spy.firstCall.args[0];
t.is(args.event, '__test__.throw');
t.deepEqual(args.payload, { count: 0 });
t.is(args.error.message, 'Error in event handler');
off();
});

View File

@@ -0,0 +1,52 @@
import { Injectable } from '@nestjs/common';
import { ClsServiceManager } from 'nestjs-cls';
import { genRequestId, OnEvent } from '../../base';
declare global {
interface Events {
'__test__.event': { count: number };
'__test__.event2': { count: number };
'__test__.encodedBinary': { updates: string[] };
'__test__.throw': { count: number };
'__test__.suppressThrow': {};
'__test__.requestId': {};
}
}
@Injectable()
export class Listeners {
@OnEvent('__test__.event')
onTestEvent(payload: Events['__test__.event']) {
return payload;
}
@OnEvent('__test__.event')
@OnEvent('__test__.event2')
onTestEventAndEvent2(
payload: Events['__test__.event'] | Events['__test__.event2']
) {
return payload;
}
@OnEvent('__test__.encodedBinary')
onEncodedBinaryEvent(payload: Events['__test__.encodedBinary']) {
return payload;
}
@OnEvent('__test__.throw')
onThrow() {
throw new Error('Error in event handler');
}
@OnEvent('__test__.suppressThrow', { suppressError: true })
onSuppressThrow() {
throw new Error('Error in event handler');
}
@OnEvent('__test__.requestId')
onRequestId() {
const cls = ClsServiceManager.getClsService();
return cls.getId() ?? genRequestId('event');
}
}

View File

@@ -0,0 +1,45 @@
import { pretty } from '@react-email/render';
import test from 'ava';
import { normalizeSMTPHeloHostname } from '../core/mail/utils';
import { Renderers } from '../mails';
import { TEST_DOC, TEST_USER } from '../mails/common';
test('should render emails', async t => {
for (const render of Object.values(Renderers)) {
// @ts-expect-error use [PreviewProps]
const content = await render();
t.snapshot(await pretty(content.html), content.subject);
}
});
test('should render mention email with empty doc title', async t => {
const content = await Renderers.Mention({
user: TEST_USER,
doc: {
...TEST_DOC,
title: '',
},
});
t.snapshot(await pretty(content.html), content.subject);
});
test('should normalize valid SMTP HELO hostnames', t => {
t.is(normalizeSMTPHeloHostname('mail.example.com'), 'mail.example.com');
t.is(normalizeSMTPHeloHostname(' localhost '), 'localhost');
t.is(normalizeSMTPHeloHostname('[127.0.0.1]'), '[127.0.0.1]');
t.is(normalizeSMTPHeloHostname('[IPv6:2001:db8::1]'), '[IPv6:2001:db8::1]');
});
test('should reject invalid SMTP HELO hostnames', t => {
t.is(normalizeSMTPHeloHostname(), undefined);
t.is(normalizeSMTPHeloHostname(''), undefined);
t.is(normalizeSMTPHeloHostname(' '), undefined);
t.is(normalizeSMTPHeloHostname('AFFiNE Server'), undefined);
t.is(normalizeSMTPHeloHostname('-example.com'), undefined);
t.is(normalizeSMTPHeloHostname('example-.com'), undefined);
t.is(normalizeSMTPHeloHostname('example..com'), undefined);
t.is(normalizeSMTPHeloHostname('[bad host]'), undefined);
t.is(normalizeSMTPHeloHostname('[foo]'), undefined);
t.is(normalizeSMTPHeloHostname('[IPv6:foo]'), undefined);
});

View File

@@ -0,0 +1,101 @@
import serverNativeModule from '@affine/server-native';
import { EMBEDDING_DIMENSIONS } from '../../models';
const STREAM_END = '__AFFINE_COPILOT_STREAM_END__';
const TEXT = 'generate text to text';
const STREAM_TEXT = 'generate text to text stream';
function structuredValue(schema: unknown, key?: string): unknown {
if (!schema || typeof schema !== 'object') return TEXT;
const value = schema as Record<string, unknown>;
if (Array.isArray(value.enum)) return value.enum[0];
if (Array.isArray(value.anyOf)) return structuredValue(value.anyOf[0], key);
if (Array.isArray(value.oneOf)) return structuredValue(value.oneOf[0], key);
if (value.type === 'object') {
return Object.fromEntries(
Object.entries((value.properties as Record<string, unknown>) ?? {}).map(
([name, property]) => [name, structuredValue(property, name)]
)
);
}
if (value.type === 'array') return [structuredValue(value.items, key)];
if (value.type === 'boolean') return true;
if (value.type === 'number' || value.type === 'integer') return 1;
if (key === 'title') return 'Weekly Sync';
if (key === 'speaker' || key === 'a') return 'A';
if (key === 'text' || key === 'transcription' || key === 't') {
return 'Hello, everyone.';
}
return TEXT;
}
function executionResult(input: { slot: string; request: unknown }) {
const request = input.request as Record<string, unknown>;
let result: unknown;
if (input.slot === 'index.embedding') {
const inputs = request.inputs as unknown[];
const dimensions =
(request.dimensions as number | undefined) ?? EMBEDDING_DIMENSIONS;
result = {
embeddings: inputs.map(() =>
Array.from({ length: dimensions }, (_, index) => index + 1)
),
};
} else if (input.slot === 'search.rerank') {
const candidates = request.candidates as unknown[];
result = {
scores: candidates.map((_, index) => candidates.length - index),
};
} else if (input.slot === 'image.generate') {
result = {
images: [
{
data_base64: Buffer.from('generated image').toString('base64'),
media_type: 'image/jpeg',
},
],
};
} else if (input.slot.includes('structured')) {
const outputJson = structuredValue(request.schema);
result = {
output_json: outputJson,
output_text: JSON.stringify(outputJson),
};
} else {
result = { output_text: TEXT };
}
return JSON.stringify({ events: [], result });
}
export function installMockCopilotRuntime() {
const prototype = serverNativeModule.BackendRuntime.prototype;
const execute = prototype.executeCopilot;
const stream = prototype.executeCopilotStream;
prototype.executeCopilot = async input => executionResult(input);
prototype.executeCopilotStream = async (
_input,
_maxSteps,
callback,
_toolCallback
) => {
callback(null, JSON.stringify({ type: 'message_start', model: 'test' }));
for (const text of STREAM_TEXT) {
callback(null, JSON.stringify({ type: 'text_delta', text }));
}
callback(
null,
JSON.stringify({
type: 'done',
finish_reason: 'stop',
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
})
);
callback(null, STREAM_END);
return { abort() {} };
};
return () => {
prototype.executeCopilot = execute;
prototype.executeCopilotStream = stream;
};
}

View File

@@ -0,0 +1,51 @@
import type { WorkspaceDoc } from '@prisma/client';
import { Prisma } from '@prisma/client';
import { DocRole } from '../../models';
import { Mocker } from './factory';
export type MockDocMetaInput = Prisma.WorkspaceDocUncheckedCreateInput & {
public?: boolean;
defaultRole?: DocRole;
};
export type MockedDocMeta = WorkspaceDoc & {
public: boolean;
defaultRole: DocRole;
};
export class MockDocMeta extends Mocker<MockDocMetaInput, MockedDocMeta> {
override async create(input: MockDocMetaInput) {
const {
public: isPublic = false,
defaultRole = DocRole.Manager,
...meta
} = input;
const publishedAt = isPublic ? (meta.publishedAt ?? new Date()) : null;
const doc = await this.db.workspaceDoc.create({
data: { ...meta, publishedAt },
});
await this.db.docAccessPolicy.create({
data: {
workspaceId: input.workspaceId,
docId: input.docId,
visibility: isPublic ? 'public' : 'private',
publicRole: isPublic ? 'external' : null,
publishedAt,
memberDefaultRole:
defaultRole === DocRole.None
? 'none'
: defaultRole === DocRole.Reader
? 'reader'
: defaultRole === DocRole.Commenter
? 'commenter'
: defaultRole === DocRole.Editor
? 'editor'
: defaultRole === DocRole.Owner
? 'owner'
: 'manager',
},
});
return { ...doc, public: isPublic, defaultRole };
}
}

View File

@@ -0,0 +1,47 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { faker } from '@faker-js/faker';
import type { Snapshot } from '@prisma/client';
import { Mocker } from './factory';
export type MockDocSnapshotInput = {
user: { id: string };
workspaceId: string;
docId?: string;
blob?: Uint8Array;
updatedAt?: Date;
snapshotFile?: string;
};
export type MockedDocSnapshot = Snapshot;
export class MockDocSnapshot extends Mocker<
MockDocSnapshotInput,
MockedDocSnapshot
> {
override async create(input: MockDocSnapshotInput) {
if (!input.blob) {
const snapshot = await readFile(
path.join(
import.meta.dirname,
`../__fixtures__/${input.snapshotFile ?? 'test-doc.snapshot.bin'}`
)
);
input.blob = snapshot;
}
const snapshot = await this.db.snapshot.create({
data: {
id: input.docId ?? faker.string.nanoid(),
workspaceId: input.workspaceId,
blob: input.blob,
createdAt: new Date(),
updatedAt: input.updatedAt ?? new Date(),
createdBy: input.user.id,
updatedBy: input.user.id,
},
});
return snapshot;
}
}

View File

@@ -0,0 +1,35 @@
import { DocRole } from '../../models';
import { Mocker } from './factory';
export type MockDocUserInput = {
workspaceId: string;
docId: string;
userId: string;
type: DocRole;
};
export type MockedDocUser = MockDocUserInput & { createdAt: Date };
export class MockDocUser extends Mocker<MockDocUserInput, MockedDocUser> {
override async create(input: MockDocUserInput) {
const grant = await this.db.docGrant.create({
data: {
workspaceId: input.workspaceId,
docId: input.docId,
principalType: 'user',
principalId: input.userId,
role:
input.type === DocRole.Owner
? 'owner'
: input.type === DocRole.Manager
? 'manager'
: input.type === DocRole.Editor
? 'editor'
: input.type === DocRole.Commenter
? 'commenter'
: 'reader',
},
});
return { ...input, createdAt: grant.createdAt };
}
}

View File

@@ -0,0 +1,36 @@
import Sinon from 'sinon';
import { EventBus } from '../../base';
import { EventName } from '../../base/event/def';
export class MockEventBus {
private readonly stub = Sinon.createStubInstance(EventBus);
emit: Sinon.SinonStub = this.stub.emitAsync;
emitAsync: Sinon.SinonStub = this.stub.emitAsync;
emitDetached: Sinon.SinonStub = this.stub.emitAsync;
emitDetachedAsync: Sinon.SinonStub = this.stub.emitAsync;
broadcast: Sinon.SinonStub = this.stub.broadcast;
last<Event extends EventName>(
name: Event
): { name: Event; payload: Events[Event] } {
const call = this.emitAsync
.getCalls()
.reverse()
.find(call => call.args[0] === name);
if (!call) {
throw new Error(`Event ${name} never called`);
}
return {
name,
payload: call.args[1],
};
}
count(name: EventName) {
return this.emitAsync.getCalls().filter(call => call.args[0] === name)
.length;
}
}

View File

@@ -0,0 +1,90 @@
import { Type } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
export abstract class Mocker<In, Out> {
// NOTE(@forehalo):
// The reason why we don't inject [Models] to Mocker for more easier data creation with built in logic is,
// the method in [Models] may introduce side effects like 'events',
// which may break the tests with event emitting asserts.
protected db!: PrismaClient;
abstract create(input?: Partial<In>): Promise<Out>;
}
type MockerConstructor<In, Out> = Type<Mocker<In, Out>>;
type MockerInput<Ctor extends MockerConstructor<any, any>> =
Ctor extends MockerConstructor<infer In, any> ? In : never;
type MockerOutput<Ctor extends MockerConstructor<any, any>> =
Ctor extends MockerConstructor<any, infer Out> ? Out : never;
const FACTORIES = new Map<string, Mocker<any, any>>();
interface FactoryOptions {
logger: ((val: any) => void) | boolean;
}
export function createFactory(
db: PrismaClient,
opts: FactoryOptions = { logger: false }
) {
const log = (val: any) => {
if (typeof opts.logger === 'function') {
opts.logger(val);
} else if (opts.logger) {
console.log(val);
}
};
class Inner {
static create<Ctor extends MockerConstructor<any, any>>(
Factory: Ctor,
overrides?: Partial<MockerInput<Ctor>>
): Promise<MockerOutput<Ctor>>;
static create<Ctor extends MockerConstructor<any, any>>(
Factory: Ctor,
count: number
): Promise<MockerOutput<Ctor>[]>;
static create<Ctor extends MockerConstructor<any, any>>(
Factory: Ctor,
overrides: Partial<MockerInput<Ctor>>,
count: number
): Promise<MockerOutput<Ctor>[]>;
static async create<Ctor extends MockerConstructor<any, any>>(
Factory: Ctor,
overridesOrCount?: Partial<MockerInput<Ctor>> | number,
count?: number
): Promise<MockerOutput<Ctor> | MockerOutput<Ctor>[]> {
let factory = FACTORIES.get(Factory.name);
if (!factory) {
factory = new Factory();
// @ts-expect-error private
factory.db = db;
FACTORIES.set(Factory.name, factory);
}
let overrides: Partial<MockerInput<Ctor>> | undefined = undefined;
if (typeof overridesOrCount === 'number') {
count = overridesOrCount;
} else {
overrides = overridesOrCount;
}
if (typeof count === 'number') {
return await Promise.all(
Array.from({ length: count }).map(async () => {
const row = await factory.create(overrides);
log(row);
return row;
})
);
}
const row = await factory.create(overrides);
log(row);
return row;
}
}
return Inner.create;
}

View File

@@ -0,0 +1,30 @@
export { createFactory } from './factory';
export * from './team-workspace.mock';
export * from './user.mock';
export * from './workspace.mock';
export * from './workspace-user.mock';
import { installMockCopilotRuntime } from './copilot.mock';
import { MockDocMeta } from './doc-meta.mock';
import { MockDocSnapshot } from './doc-snapshot.mock';
import { MockDocUser } from './doc-user.mock';
import { MockEventBus } from './eventbus.mock';
import { MockMailer } from './mailer.mock';
import { MockTeamWorkspace } from './team-workspace.mock';
import { MockUser } from './user.mock';
import { MockUserSettings } from './user-settings.mock';
import { MockWorkspace } from './workspace.mock';
import { MockWorkspaceUser } from './workspace-user.mock';
export const Mockers = {
User: MockUser,
Workspace: MockWorkspace,
TeamWorkspace: MockTeamWorkspace,
WorkspaceUser: MockWorkspaceUser,
UserSettings: MockUserSettings,
DocMeta: MockDocMeta,
DocSnapshot: MockDocSnapshot,
DocUser: MockDocUser,
};
export { installMockCopilotRuntime, MockEventBus, MockMailer };

View File

@@ -0,0 +1,66 @@
import { interval, map, take, takeUntil } from 'rxjs';
import Sinon from 'sinon';
import { Mailer } from '../../core/mail';
import type { SendMailCommand } from '../../core/mail/types';
import { MailName } from '../../mails';
export class MockMailer {
send = Sinon.createStubInstance(Mailer).send.resolves(true);
skip = Sinon.createStubInstance(Mailer).skip.resolves(false);
trySend(command: SendMailCommand) {
return this.send(command, true);
}
last<Mail extends MailName>(name: Mail): SendMailCommand & { name: Mail } {
const last = this.send.lastCall.args[0];
if (!last) {
throw new Error('No mail ever sent');
}
if (last.name !== name) {
throw new Error(`Mail name mismatch: ${last.name} !== ${name}`);
}
return last as SendMailCommand & { name: Mail };
}
waitFor<Mail extends MailName>(
name: Mail,
timeout: number = 1000
): Promise<SendMailCommand & { name: Mail }> {
const { promise, reject, resolve } = Promise.withResolvers<
SendMailCommand & { name: Mail }
>();
interval(10)
.pipe(
take(Math.floor(timeout / 10)),
takeUntil(promise),
map(() => {
const last = this.send.lastCall?.args[0];
return last?.name === name
? (last as SendMailCommand & { name: Mail })
: undefined;
})
)
.subscribe({
next: val => {
if (val) {
resolve(val);
}
},
complete: () => {
reject(new Error('Timeout wait for job coming'));
},
});
return promise;
}
count(name: MailName) {
return this.send.getCalls().filter(call => call.args[0].name === name)
.length;
}
}

View File

@@ -0,0 +1,32 @@
import { faker } from '@faker-js/faker';
import { Mocker } from './factory';
interface MockTeamWorkspaceInput {
id: string;
quantity: number;
}
export class MockTeamWorkspace extends Mocker<
MockTeamWorkspaceInput,
{ id: string }
> {
override async create(input?: Partial<MockTeamWorkspaceInput>) {
const id = input?.id ?? faker.string.uuid();
const quantity = input?.quantity ?? 10;
await this.db.entitlement.create({
data: {
targetType: 'workspace',
targetId: id,
source: 'admin_grant',
subjectId: `admin_grant:workspace:${id}`,
plan: 'team',
status: 'active',
quantity,
},
});
return { id };
}
}

View File

@@ -0,0 +1,27 @@
import type { UserSettings } from '@prisma/client';
import { omit } from 'lodash-es';
import { UserSettingsInput } from '../../models';
import { Mocker } from './factory';
export type MockUserSettingsInput = UserSettingsInput & {
userId: string;
};
export type MockedUserSettings = UserSettings;
export class MockUserSettings extends Mocker<
MockUserSettingsInput,
MockedUserSettings
> {
override async create(input: MockUserSettingsInput) {
return await this.db.userSettings.create({
data: {
userId: input.userId,
payload: {
...omit(input, 'userId'),
},
},
});
}
}

View File

@@ -0,0 +1,46 @@
import { faker } from '@faker-js/faker';
import { hashSync } from '@node-rs/argon2';
import type { Prisma, User } from '@prisma/client';
import { FeatureConfigs, type UserFeatureName } from '../../models';
import { Mocker } from './factory';
export type MockUserInput = Prisma.UserCreateInput & {
feature?: UserFeatureName;
};
export type MockedUser = Omit<User, 'password'> & {
password: string;
};
export class MockUser extends Mocker<MockUserInput, MockedUser> {
override async create(input?: Partial<MockUserInput>) {
const { feature, ...userInput } = input ?? {};
const password = input?.password ?? faker.internet.password();
const user = await this.db.user.create({
data: {
createdAt: new Date(Date.now() - 25 * 60 * 60 * 1000),
email: faker.internet.email(),
name: faker.person.fullName(),
password: password ? hashSync(password) : undefined,
...userInput,
},
});
if (feature) {
const config = FeatureConfigs[feature];
await this.db.userFeature.create({
data: {
userId: user.id,
name: feature,
type: config.type,
reason: 'test',
activated: true,
},
});
}
// return raw password for later usage, for example 'signIn'
return { ...user, password };
}
}

View File

@@ -0,0 +1,68 @@
import {
WorkspaceMemberStatus,
WorkspaceRole,
type WorkspaceUserCompat,
} from '../../models';
import { workspaceInvitationToCompat } from '../../models/workspace-user-compat';
import { Mocker } from './factory';
export type MockWorkspaceUserInput = {
workspaceId: string;
userId: string;
type?: WorkspaceRole;
status?: WorkspaceMemberStatus;
inviterId?: string | null;
kind?: 'email' | 'link';
};
export class MockWorkspaceUser extends Mocker<
MockWorkspaceUserInput,
WorkspaceUserCompat
> {
override async create(input: MockWorkspaceUserInput) {
const type = input.type ?? WorkspaceRole.Collaborator;
const status = input.status ?? WorkspaceMemberStatus.Accepted;
if (status === WorkspaceMemberStatus.Accepted) {
const member = await this.db.workspaceMember.create({
data: {
workspaceId: input.workspaceId,
userId: input.userId,
role:
type === WorkspaceRole.Owner
? 'owner'
: type === WorkspaceRole.Admin
? 'admin'
: 'member',
state: 'active',
source: 'legacy',
},
});
return {
...member,
type,
status,
source: 'Email' as const,
inviterId: null,
};
}
const invitation = await this.db.workspaceInvitation.create({
data: {
workspaceId: input.workspaceId,
inviteeUserId: input.userId,
inviterUserId: input.inviterId,
requestedRole: type === WorkspaceRole.Admin ? 'admin' : 'member',
status:
status === WorkspaceMemberStatus.UnderReview
? 'waiting_review'
: status === WorkspaceMemberStatus.NeedMoreSeat ||
status === WorkspaceMemberStatus.AllocatingSeat ||
status === WorkspaceMemberStatus.NeedMoreSeatAndReview
? 'waiting_seat'
: 'pending',
kind: input.kind ?? 'email',
},
});
return workspaceInvitationToCompat(invitation);
}
}

View File

@@ -0,0 +1,89 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { faker } from '@faker-js/faker';
import type { Prisma } from '@prisma/client';
import { omit } from 'lodash-es';
import type { Workspace } from '../../models';
import { Mocker } from './factory';
export type MockWorkspaceInput = Prisma.WorkspaceCreateInput & {
owner?: { id: string };
snapshot?: Uint8Array | true;
public?: boolean;
enableSharing?: boolean;
enableUrlPreview?: boolean;
};
export type MockedWorkspace = Workspace;
export class MockWorkspace extends Mocker<MockWorkspaceInput, MockedWorkspace> {
override async create(input?: Partial<MockWorkspaceInput>) {
const owner = input?.owner;
if (input?.snapshot === true) {
const snapshot = await readFile(
path.join(
import.meta.dirname,
'../__fixtures__/test-root-doc.snapshot.bin'
)
);
input.snapshot = snapshot;
}
const snapshot = input?.snapshot;
const isPublic = input?.public ?? false;
const enableSharing = input?.enableSharing ?? true;
const enableUrlPreview = input?.enableUrlPreview ?? false;
input = omit(
input,
'owner',
'snapshot',
'public',
'enableSharing',
'enableUrlPreview'
);
const workspace = await this.db.workspace.create({
data: {
name: faker.animal.cat(),
...input,
accessPolicy: {
create: {
visibility: isPublic ? 'public' : 'private',
sharingEnabled: enableSharing,
urlPreviewEnabled: enableUrlPreview,
},
},
members: owner
? {
create: {
userId: owner.id,
role: 'owner',
state: 'active',
source: 'legacy',
},
}
: undefined,
},
});
// create a rootDoc snapshot
if (snapshot) {
await this.db.snapshot.create({
data: {
id: workspace.id,
workspaceId: workspace.id,
blob: snapshot,
createdAt: new Date(),
updatedAt: new Date(),
createdBy: owner?.id,
updatedBy: owner?.id,
},
});
}
return {
...workspace,
public: isPublic,
enableSharing,
enableUrlPreview,
};
}
}

View File

@@ -0,0 +1,640 @@
# Snapshot report for `src/__tests__/models/copilot-session.spec.ts`
The actual snapshot is saved in `copilot-session.spec.ts.snap`.
Generated by [AVA](https://avajs.dev).
## should list and filter session type
> workspace sessions should include workspace and pinned sessions
[
{
docId: null,
pinned: true,
},
{
docId: null,
pinned: false,
},
]
> doc sessions should only include sessions with matching docId
[
{
docId: 'doc-id-1',
pinned: false,
promptName: 'action-prompt',
},
{
docId: 'doc-id-1',
pinned: false,
promptName: 'test-prompt',
},
]
> session type identification results
[
{
session: {
docId: null,
pinned: false,
},
type: 'workspace',
},
{
session: {
docId: undefined,
pinned: false,
},
type: 'workspace',
},
{
session: {
docId: null,
pinned: true,
},
type: 'pinned',
},
{
session: {
docId: 'doc-id-1',
pinned: false,
},
type: 'doc',
},
]
## should validate session prompt compatibility
> session prompt validation results
[
{
promptType: 'non-action',
result: 'success',
sessionType: 'workspace',
shouldThrow: false,
},
{
promptType: 'action',
result: 'CopilotPromptInvalid',
sessionType: 'workspace',
shouldThrow: true,
},
{
promptType: 'non-action',
result: 'success',
sessionType: 'pinned',
shouldThrow: false,
},
{
promptType: 'action',
result: 'CopilotPromptInvalid',
sessionType: 'pinned',
shouldThrow: true,
},
{
promptType: 'non-action',
result: 'success',
sessionType: 'doc',
shouldThrow: false,
},
{
promptType: 'action',
result: 'success',
sessionType: 'doc',
shouldThrow: false,
},
]
## should pin and unpin sessions
> session states after creating second pinned session
[
{
docId: null,
id: 'first-session-id',
pinned: false,
},
{
docId: null,
id: 'second-session-id',
pinned: true,
},
]
> should return false when no sessions to unpin
false
> all sessions should be unpinned after unpin operation
[
{
id: 'first-session-id',
pinned: false,
},
{
id: 'second-session-id',
pinned: false,
},
{
id: 'third-session-id',
pinned: false,
},
]
## should handle session updates and type conversions
> session update validation results
[
{
result: 'rejected',
sessionType: 'action',
update: {
docId: 'new-doc',
},
},
{
result: 'rejected',
sessionType: 'action',
update: {
pinned: true,
},
},
{
result: 'rejected',
sessionType: 'action',
update: {
promptName: 'test-prompt',
},
},
{
result: 'success',
sessionType: 'forked',
update: {
pinned: true,
},
},
{
result: 'success',
sessionType: 'forked',
update: {
promptAction: null,
promptName: 'test-prompt',
},
},
{
result: 'rejected',
sessionType: 'forked',
update: {
docId: 'new-doc',
},
},
{
result: 'success',
sessionType: 'regular',
update: {
promptAction: null,
promptName: 'test-prompt',
},
},
{
result: 'rejected',
sessionType: 'regular',
update: {
promptAction: 'edit',
promptName: 'action-prompt',
},
},
{
result: 'rejected',
sessionType: 'regular',
update: {
promptName: 'non-existent-prompt',
},
},
]
> pinning behavior - should unpin existing when pinning new
{
onlyOneSessionPinned: true,
pinnedSessions: 1,
totalSessions: 2,
unpinnedSessions: 1,
}
> session type conversion steps
[
{
sessionState: {
hasDocId: true,
pinned: false,
},
step: 'workspace_to_doc',
type: 'doc',
},
{
sessionState: {
hasDocId: false,
pinned: false,
},
step: 'doc_to_workspace',
type: 'workspace',
},
{
sessionState: {
hasDocId: false,
pinned: true,
},
step: 'workspace_to_pinned',
type: 'pinned',
},
]
## should handle session queries, ordering, and filtering
> comprehensive session query results
{
all_workspace_sessions: {
count: 7,
sessionTypes: [
{
hasMessages: false,
isAction: false,
isFork: true,
messageCount: 0,
type: 'doc',
},
{
hasMessages: false,
isAction: false,
isFork: false,
messageCount: 0,
type: 'doc',
},
{
hasMessages: false,
isAction: false,
isFork: false,
messageCount: 0,
type: 'doc',
},
{
hasMessages: false,
isAction: true,
isFork: false,
messageCount: 0,
type: 'doc',
},
{
hasMessages: false,
isAction: false,
isFork: false,
messageCount: 0,
type: 'doc',
},
{
hasMessages: false,
isAction: false,
isFork: false,
messageCount: 0,
type: 'pinned',
},
{
hasMessages: false,
isAction: false,
isFork: false,
messageCount: 0,
type: 'workspace',
},
],
},
doc_sessions_with_messages: {
count: 5,
sessionTypes: [
{
hasMessages: false,
isAction: false,
isFork: true,
messageCount: 0,
type: 'doc',
},
{
hasMessages: true,
isAction: false,
isFork: false,
messageCount: 1,
type: 'doc',
},
{
hasMessages: true,
isAction: false,
isFork: false,
messageCount: 1,
type: 'doc',
},
{
hasMessages: false,
isAction: true,
isFork: false,
messageCount: 0,
type: 'doc',
},
{
hasMessages: true,
isAction: false,
isFork: false,
messageCount: 1,
type: 'doc',
},
],
},
latest_valid_session: {
count: 1,
sessionTypes: [
{
hasMessages: false,
isAction: false,
isFork: false,
messageCount: 0,
type: 'doc',
},
],
},
non_action_sessions: {
count: 4,
sessionTypes: [
{
hasMessages: false,
isAction: false,
isFork: true,
messageCount: 0,
type: 'doc',
},
{
hasMessages: false,
isAction: false,
isFork: false,
messageCount: 0,
type: 'doc',
},
{
hasMessages: false,
isAction: false,
isFork: false,
messageCount: 0,
type: 'doc',
},
{
hasMessages: false,
isAction: false,
isFork: false,
messageCount: 0,
type: 'doc',
},
],
},
non_fork_sessions: {
count: 4,
sessionTypes: [
{
hasMessages: false,
isAction: false,
isFork: false,
messageCount: 0,
type: 'doc',
},
{
hasMessages: false,
isAction: false,
isFork: false,
messageCount: 0,
type: 'doc',
},
{
hasMessages: false,
isAction: true,
isFork: false,
messageCount: 0,
type: 'doc',
},
{
hasMessages: false,
isAction: false,
isFork: false,
messageCount: 0,
type: 'doc',
},
],
},
recent_top3_sessions: {
count: 3,
sessionTypes: [
{
hasMessages: false,
isAction: false,
isFork: true,
messageCount: 0,
type: 'doc',
},
{
hasMessages: false,
isAction: false,
isFork: false,
messageCount: 0,
type: 'doc',
},
{
hasMessages: false,
isAction: false,
isFork: false,
messageCount: 0,
type: 'doc',
},
],
},
workspace_sessions_with_messages: {
count: 2,
sessionTypes: [
{
hasMessages: false,
isAction: false,
isFork: false,
messageCount: 0,
type: 'pinned',
},
{
hasMessages: false,
isAction: false,
isFork: false,
messageCount: 0,
type: 'workspace',
},
],
},
}
> canonical session reads require workspace scope
{
expectedWorkspace: true,
wrongWorkspace: false,
wrongWorkspaceMessage: false,
}
> session type identification results
[
{
session: {
docId: null,
pinned: false,
},
type: 'workspace',
},
{
session: {
docId: undefined,
pinned: false,
},
type: 'workspace',
},
{
session: {
docId: null,
pinned: true,
},
type: 'pinned',
},
{
session: {
docId: 'test-doc-id',
pinned: false,
},
type: 'doc',
},
]
## should handle fork and session attachment operations
> fork operation results
{
existingPinnedSessionUnpinned: true,
forkResults: [
{
actualState: {
hasDocId: false,
hasParent: true,
isDocIdCorrect: true,
pinned: false,
},
description: 'workspace fork',
success: true,
},
{
actualState: {
hasDocId: true,
hasParent: true,
isDocIdCorrect: true,
pinned: false,
},
description: 'doc fork',
success: true,
},
{
actualState: {
hasDocId: false,
hasParent: true,
isDocIdCorrect: true,
pinned: true,
},
description: 'pinned fork',
success: true,
},
],
}
> attach and detach operation results
{
afterAttach: {
bothSessionsPresent: true,
docSessionCount: 2,
},
afterDetach: {
originalDocSessionRemains: true,
workspaceSessionExists: true,
},
}
## should cleanup empty sessions correctly
> cleanup empty sessions results
{
cleanupResult: {
cleaned: 0,
removed: 0,
},
remainingSessions: [
{
deleted: false,
pinned: false,
type: 'zeroCost',
},
{
deleted: false,
pinned: false,
type: 'zeroCost',
},
{
deleted: false,
pinned: false,
type: 'noMessages',
},
{
deleted: false,
pinned: false,
type: 'noMessages',
},
{
deleted: false,
pinned: false,
type: 'recent',
},
{
deleted: false,
pinned: false,
type: 'withMessages',
},
],
}
## should get sessions for title generation correctly
> sessions for title generation results
{
onlyValidSessionsReturned: true,
sessions: [
{
assistantMessageCount: 2,
isValid: true,
},
{
assistantMessageCount: 1,
isValid: true,
},
],
total: 2,
}

View File

@@ -0,0 +1,57 @@
# Snapshot report for `src/__tests__/models/feature-user.spec.ts`
The actual snapshot is saved in `feature-user.spec.ts.snap`.
Generated by [AVA](https://avajs.dev).
## should get user quota
> free plan
{
blobLimit: 10485760,
businessBlobLimit: 104857600,
copilotActionLimit: 10,
historyPeriod: 604800000,
memberLimit: 3,
name: 'Free',
storageQuota: 10737418240,
}
## should switch user quota
> switch to pro plan
{
blobLimit: 104857600,
copilotActionLimit: 10,
historyPeriod: 2592000000,
memberLimit: 10,
name: 'Pro',
storageQuota: 107374182400,
}
> switch to free plan
{
blobLimit: 10485760,
businessBlobLimit: 104857600,
copilotActionLimit: 10,
historyPeriod: 604800000,
memberLimit: 3,
name: 'Free',
storageQuota: 10737418240,
}
## should use pro plan as free for selfhost instance
> use pro plan as free plan for selfhosted instance
{
blobLimit: 104857600,
copilotActionLimit: 10,
historyPeriod: 2592000000,
memberLimit: 10,
name: 'Pro',
storageQuota: 107374182400,
}

Some files were not shown because too many files have changed in this diff Show More