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
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:
Binary file not shown.
Binary file not shown.
Binary file not shown.
2785
packages/backend/server/src/__tests__/__snapshots__/mails.spec.ts.md
Normal file
2785
packages/backend/server/src/__tests__/__snapshots__/mails.spec.ts.md
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -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: [],
|
||||
}
|
||||
Binary file not shown.
234
packages/backend/server/src/__tests__/app/selfhost.e2e.ts
Normal file
234
packages/backend/server/src/__tests__/app/selfhost.e2e.ts
Normal 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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
126
packages/backend/server/src/__tests__/auth/auth-session.spec.ts
Normal file
126
packages/backend/server/src/__tests__/auth/auth-session.spec.ts
Normal 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',
|
||||
})
|
||||
);
|
||||
});
|
||||
194
packages/backend/server/src/__tests__/auth/auth.e2e.ts
Normal file
194
packages/backend/server/src/__tests__/auth/auth.e2e.ts
Normal 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');
|
||||
});
|
||||
1556
packages/backend/server/src/__tests__/auth/controller.spec.ts
Normal file
1556
packages/backend/server/src/__tests__/auth/controller.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
463
packages/backend/server/src/__tests__/auth/guard.spec.ts
Normal file
463
packages/backend/server/src/__tests__/auth/guard.spec.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
69
packages/backend/server/src/__tests__/auth/job.spec.ts
Normal file
69
packages/backend/server/src/__tests__/auth/job.spec.ts
Normal 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]);
|
||||
});
|
||||
165
packages/backend/server/src/__tests__/auth/service.spec.ts
Normal file
165
packages/backend/server/src/__tests__/auth/service.spec.ts
Normal 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);
|
||||
});
|
||||
112
packages/backend/server/src/__tests__/cache.spec.ts
Normal file
112
packages/backend/server/src/__tests__/cache.spec.ts
Normal 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);
|
||||
});
|
||||
@@ -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.',
|
||||
},
|
||||
}
|
||||
Binary file not shown.
@@ -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',
|
||||
},
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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',
|
||||
},
|
||||
}
|
||||
Binary file not shown.
@@ -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.',
|
||||
},
|
||||
}
|
||||
Binary file not shown.
216
packages/backend/server/src/__tests__/copilot/byok.spec.ts
Normal file
216
packages/backend/server/src/__tests__/copilot/byok.spec.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"}'
|
||||
);
|
||||
});
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
);
|
||||
319
packages/backend/server/src/__tests__/copilot/copilot.e2e.ts
Normal file
319
packages/backend/server/src/__tests__/copilot/copilot.e2e.ts
Normal 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));
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
105
packages/backend/server/src/__tests__/create-module.ts
Normal file
105
packages/backend/server/src/__tests__/create-module.ts
Normal 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;
|
||||
}
|
||||
94
packages/backend/server/src/__tests__/doc/cron.spec.ts
Normal file
94
packages/backend/server/src/__tests__/doc/cron.spec.ts
Normal 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);
|
||||
});
|
||||
295
packages/backend/server/src/__tests__/doc/history.spec.ts
Normal file
295
packages/backend/server/src/__tests__/doc/history.spec.ts
Normal 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]));
|
||||
});
|
||||
86
packages/backend/server/src/__tests__/doc/renderer.spec.ts
Normal file
86
packages/backend/server/src/__tests__/doc/renderer.spec.ts
Normal 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');
|
||||
135
packages/backend/server/src/__tests__/doc/userspace.spec.ts
Normal file
135
packages/backend/server/src/__tests__/doc/userspace.spec.ts
Normal 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);
|
||||
});
|
||||
308
packages/backend/server/src/__tests__/doc/workspace.spec.ts
Normal file
308
packages/backend/server/src/__tests__/doc/workspace.spec.ts
Normal 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);
|
||||
});
|
||||
93
packages/backend/server/src/__tests__/e2e/apps/app.spec.ts
Normal file
93
packages/backend/server/src/__tests__/e2e/apps/app.spec.ts
Normal 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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
1870
packages/backend/server/src/__tests__/e2e/comment/resolver.spec.ts
Normal file
1870
packages/backend/server/src/__tests__/e2e/comment/resolver.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
);
|
||||
});
|
||||
371
packages/backend/server/src/__tests__/e2e/create-app.ts
Normal file
371
packages/backend/server/src/__tests__/e2e/create-app.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
);
|
||||
349
packages/backend/server/src/__tests__/e2e/doc/resolver.spec.ts
Normal file
349
packages/backend/server/src/__tests__/e2e/doc/resolver.spec.ts
Normal 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,
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
Binary file not shown.
@@ -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',
|
||||
},
|
||||
]
|
||||
Binary file not shown.
@@ -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,
|
||||
},
|
||||
]
|
||||
Binary file not shown.
@@ -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']
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
@@ -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/ }
|
||||
);
|
||||
}
|
||||
);
|
||||
240
packages/backend/server/src/__tests__/e2e/indexer/search.spec.ts
Normal file
240
packages/backend/server/src/__tests__/e2e/indexer/search.spec.ts
Normal 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, []);
|
||||
}
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
}
|
||||
Binary file not shown.
@@ -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);
|
||||
}
|
||||
);
|
||||
11
packages/backend/server/src/__tests__/e2e/prelude.ts
Normal file
11
packages/backend/server/src/__tests__/e2e/prelude.ts
Normal 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();
|
||||
92
packages/backend/server/src/__tests__/e2e/realtime.ts
Normal file
92
packages/backend/server/src/__tests__/e2e/realtime.ts
Normal 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;
|
||||
}
|
||||
@@ -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, '_');
|
||||
}
|
||||
57
packages/backend/server/src/__tests__/e2e/test.ts
Normal file
57
packages/backend/server/src/__tests__/e2e/test.ts
Normal 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 };
|
||||
205
packages/backend/server/src/__tests__/e2e/user/account.spec.ts
Normal file
205
packages/backend/server/src/__tests__/e2e/user/account.spec.ts
Normal 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}`,
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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,
|
||||
}
|
||||
Binary file not shown.
@@ -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');
|
||||
}
|
||||
);
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
256
packages/backend/server/src/__tests__/e2e/workspace/team.spec.ts
Normal file
256
packages/backend/server/src/__tests__/e2e/workspace/team.spec.ts
Normal 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
|
||||
);
|
||||
}
|
||||
);
|
||||
186
packages/backend/server/src/__tests__/env.spec.ts
Normal file
186
packages/backend/server/src/__tests__/env.spec.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
97
packages/backend/server/src/__tests__/event/cluster.spec.ts
Normal file
97
packages/backend/server/src/__tests__/event/cluster.spec.ts
Normal 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'));
|
||||
});
|
||||
259
packages/backend/server/src/__tests__/event/eventbus.spec.ts
Normal file
259
packages/backend/server/src/__tests__/event/eventbus.spec.ts
Normal 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();
|
||||
});
|
||||
52
packages/backend/server/src/__tests__/event/provider.ts
Normal file
52
packages/backend/server/src/__tests__/event/provider.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
45
packages/backend/server/src/__tests__/mails.spec.ts
Normal file
45
packages/backend/server/src/__tests__/mails.spec.ts
Normal 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);
|
||||
});
|
||||
101
packages/backend/server/src/__tests__/mocks/copilot.mock.ts
Normal file
101
packages/backend/server/src/__tests__/mocks/copilot.mock.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
51
packages/backend/server/src/__tests__/mocks/doc-meta.mock.ts
Normal file
51
packages/backend/server/src/__tests__/mocks/doc-meta.mock.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
35
packages/backend/server/src/__tests__/mocks/doc-user.mock.ts
Normal file
35
packages/backend/server/src/__tests__/mocks/doc-user.mock.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
36
packages/backend/server/src/__tests__/mocks/eventbus.mock.ts
Normal file
36
packages/backend/server/src/__tests__/mocks/eventbus.mock.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
90
packages/backend/server/src/__tests__/mocks/factory.ts
Normal file
90
packages/backend/server/src/__tests__/mocks/factory.ts
Normal 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;
|
||||
}
|
||||
30
packages/backend/server/src/__tests__/mocks/index.ts
Normal file
30
packages/backend/server/src/__tests__/mocks/index.ts
Normal 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 };
|
||||
66
packages/backend/server/src/__tests__/mocks/mailer.mock.ts
Normal file
66
packages/backend/server/src/__tests__/mocks/mailer.mock.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
46
packages/backend/server/src/__tests__/mocks/user.mock.ts
Normal file
46
packages/backend/server/src/__tests__/mocks/user.mock.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
Binary file not shown.
@@ -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,
|
||||
}
|
||||
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user