mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-15 05:37:32 +00:00
chore(server): move server tests folder (#9614)
This commit is contained in:
79
packages/backend/server/src/__tests__/app/graphql.e2e.ts
Normal file
79
packages/backend/server/src/__tests__/app/graphql.e2e.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import type { TestFn } from 'ava';
|
||||
import ava from 'ava';
|
||||
import request from 'supertest';
|
||||
|
||||
import { buildAppModule } from '../../app.module';
|
||||
import { createTestingApp } from '../utils';
|
||||
|
||||
const gql = '/graphql';
|
||||
|
||||
const test = ava as TestFn<{
|
||||
app: INestApplication;
|
||||
}>;
|
||||
|
||||
test.before('start app', async t => {
|
||||
// @ts-expect-error override
|
||||
AFFiNE.flavor = {
|
||||
type: 'graphql',
|
||||
allinone: false,
|
||||
graphql: true,
|
||||
sync: false,
|
||||
renderer: false,
|
||||
} satisfies typeof AFFiNE.flavor;
|
||||
const { app } = await createTestingApp({
|
||||
imports: [buildAppModule()],
|
||||
});
|
||||
|
||||
t.context.app = app;
|
||||
});
|
||||
|
||||
test.after.always(async t => {
|
||||
await t.context.app.close();
|
||||
});
|
||||
|
||||
test('should init app', async t => {
|
||||
await request(t.context.app.getHttpServer())
|
||||
.post(gql)
|
||||
.send({
|
||||
query: `
|
||||
query {
|
||||
error
|
||||
}
|
||||
`,
|
||||
})
|
||||
.expect(400);
|
||||
|
||||
const response = await request(t.context.app.getHttpServer())
|
||||
.post(gql)
|
||||
.send({
|
||||
query: `query {
|
||||
serverConfig {
|
||||
name
|
||||
version
|
||||
type
|
||||
features
|
||||
}
|
||||
}`,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const config = response.body.data.serverConfig;
|
||||
|
||||
t.is(config.type, 'Affine');
|
||||
t.true(Array.isArray(config.features));
|
||||
});
|
||||
|
||||
test('should return 404 for unknown path', async t => {
|
||||
await request(t.context.app.getHttpServer()).get('/unknown').expect(404);
|
||||
|
||||
t.pass();
|
||||
});
|
||||
|
||||
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, 'graphql');
|
||||
});
|
||||
39
packages/backend/server/src/__tests__/app/renderer.e2e.ts
Normal file
39
packages/backend/server/src/__tests__/app/renderer.e2e.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import type { TestFn } from 'ava';
|
||||
import ava from 'ava';
|
||||
import request from 'supertest';
|
||||
|
||||
import { buildAppModule } from '../../app.module';
|
||||
import { createTestingApp } from '../utils';
|
||||
|
||||
const test = ava as TestFn<{
|
||||
app: INestApplication;
|
||||
}>;
|
||||
|
||||
test.before('start app', async t => {
|
||||
// @ts-expect-error override
|
||||
AFFiNE.flavor = {
|
||||
type: 'renderer',
|
||||
allinone: false,
|
||||
graphql: false,
|
||||
sync: false,
|
||||
renderer: true,
|
||||
} satisfies typeof AFFiNE.flavor;
|
||||
const { app } = await createTestingApp({
|
||||
imports: [buildAppModule()],
|
||||
});
|
||||
|
||||
t.context.app = app;
|
||||
});
|
||||
|
||||
test.after.always(async t => {
|
||||
await t.context.app.close();
|
||||
});
|
||||
|
||||
test('should init app', async t => {
|
||||
const res = await request(t.context.app.getHttpServer())
|
||||
.get('/info')
|
||||
.expect(200);
|
||||
|
||||
t.is(res.body.flavor, 'renderer');
|
||||
});
|
||||
205
packages/backend/server/src/__tests__/app/selfhost.e2e.ts
Normal file
205
packages/backend/server/src/__tests__/app/selfhost.e2e.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { INestApplication } 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 { Config } from '../../base';
|
||||
import { ServerService } from '../../core/config';
|
||||
import { createTestingApp, initTestingDB } from '../utils';
|
||||
|
||||
const test = ava as TestFn<{
|
||||
app: INestApplication;
|
||||
db: PrismaClient;
|
||||
}>;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
test.before('init selfhost server', async t => {
|
||||
// @ts-expect-error override
|
||||
AFFiNE.isSelfhosted = true;
|
||||
AFFiNE.flavor.renderer = true;
|
||||
const { app } = await createTestingApp({
|
||||
imports: [buildAppModule()],
|
||||
});
|
||||
|
||||
t.context.app = app;
|
||||
t.context.db = t.context.app.get(PrismaClient);
|
||||
const config = app.get(Config);
|
||||
|
||||
const staticPath = path.join(config.projectRoot, 'static');
|
||||
initTestStaticFiles(staticPath);
|
||||
});
|
||||
|
||||
test.beforeEach(async t => {
|
||||
await initTestingDB(t.context.db);
|
||||
const server = t.context.app.get(ServerService);
|
||||
// @ts-expect-error disable cache
|
||||
server._initialized = false;
|
||||
});
|
||||
|
||||
test.after.always(async t => {
|
||||
await t.context.app.close();
|
||||
});
|
||||
|
||||
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('/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('/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');
|
||||
});
|
||||
|
||||
test('should return mobile 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'));
|
||||
});
|
||||
39
packages/backend/server/src/__tests__/app/sync.e2e.ts
Normal file
39
packages/backend/server/src/__tests__/app/sync.e2e.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import type { TestFn } from 'ava';
|
||||
import ava from 'ava';
|
||||
import request from 'supertest';
|
||||
|
||||
import { buildAppModule } from '../../app.module';
|
||||
import { createTestingApp } from '../utils';
|
||||
|
||||
const test = ava as TestFn<{
|
||||
app: INestApplication;
|
||||
}>;
|
||||
|
||||
test.before('start app', async t => {
|
||||
// @ts-expect-error override
|
||||
AFFiNE.flavor = {
|
||||
type: 'sync',
|
||||
allinone: false,
|
||||
graphql: false,
|
||||
sync: true,
|
||||
renderer: false,
|
||||
} satisfies typeof AFFiNE.flavor;
|
||||
const { app } = await createTestingApp({
|
||||
imports: [buildAppModule()],
|
||||
});
|
||||
|
||||
t.context.app = app;
|
||||
});
|
||||
|
||||
test.after.always(async t => {
|
||||
await t.context.app.close();
|
||||
});
|
||||
|
||||
test('should init app', async t => {
|
||||
const res = await request(t.context.app.getHttpServer())
|
||||
.get('/info')
|
||||
.expect(200);
|
||||
|
||||
t.is(res.body.flavor, 'sync');
|
||||
});
|
||||
219
packages/backend/server/src/__tests__/auth/auth.e2e.ts
Normal file
219
packages/backend/server/src/__tests__/auth/auth.e2e.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
import {
|
||||
getCurrentMailMessageCount,
|
||||
getTokenFromLatestMailMessage,
|
||||
} from '@affine-test/kit/utils/cloud';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import type { TestFn } from 'ava';
|
||||
import ava from 'ava';
|
||||
|
||||
import { MailService } from '../../base/mailer';
|
||||
import { AuthService } from '../../core/auth/service';
|
||||
import {
|
||||
changeEmail,
|
||||
changePassword,
|
||||
createTestingApp,
|
||||
currentUser,
|
||||
sendChangeEmail,
|
||||
sendSetPasswordEmail,
|
||||
sendVerifyChangeEmail,
|
||||
signUp,
|
||||
} from '../utils';
|
||||
|
||||
const test = ava as TestFn<{
|
||||
app: INestApplication;
|
||||
auth: AuthService;
|
||||
mail: MailService;
|
||||
}>;
|
||||
|
||||
test.beforeEach(async t => {
|
||||
const { app } = await createTestingApp();
|
||||
const auth = app.get(AuthService);
|
||||
const mail = app.get(MailService);
|
||||
t.context.app = app;
|
||||
t.context.auth = auth;
|
||||
t.context.mail = mail;
|
||||
});
|
||||
|
||||
test.afterEach.always(async t => {
|
||||
await t.context.app.close();
|
||||
});
|
||||
|
||||
test('change email', async t => {
|
||||
const { mail, app } = t.context;
|
||||
if (mail.hasConfigured()) {
|
||||
const u1Email = 'u1@affine.pro';
|
||||
const u2Email = 'u2@affine.pro';
|
||||
|
||||
const u1 = await signUp(app, 'u1', u1Email, '1');
|
||||
|
||||
const primitiveMailCount = await getCurrentMailMessageCount();
|
||||
|
||||
await sendChangeEmail(app, u1.token.token, u1Email, 'affine.pro');
|
||||
|
||||
const afterSendChangeMailCount = await getCurrentMailMessageCount();
|
||||
t.is(
|
||||
primitiveMailCount + 1,
|
||||
afterSendChangeMailCount,
|
||||
'failed to send change email'
|
||||
);
|
||||
|
||||
const changeEmailToken = await getTokenFromLatestMailMessage();
|
||||
|
||||
t.not(
|
||||
changeEmailToken,
|
||||
null,
|
||||
'fail to get change email token from email content'
|
||||
);
|
||||
|
||||
await sendVerifyChangeEmail(
|
||||
app,
|
||||
u1.token.token,
|
||||
changeEmailToken as string,
|
||||
u2Email,
|
||||
'affine.pro'
|
||||
);
|
||||
|
||||
const afterSendVerifyMailCount = await getCurrentMailMessageCount();
|
||||
|
||||
t.is(
|
||||
afterSendChangeMailCount + 1,
|
||||
afterSendVerifyMailCount,
|
||||
'failed to send verify email'
|
||||
);
|
||||
|
||||
const verifyEmailToken = await getTokenFromLatestMailMessage();
|
||||
|
||||
t.not(
|
||||
verifyEmailToken,
|
||||
null,
|
||||
'fail to get verify change email token from email content'
|
||||
);
|
||||
|
||||
await changeEmail(app, u1.token.token, verifyEmailToken as string, u2Email);
|
||||
|
||||
const afterNotificationMailCount = await getCurrentMailMessageCount();
|
||||
|
||||
t.is(
|
||||
afterSendVerifyMailCount + 1,
|
||||
afterNotificationMailCount,
|
||||
'failed to send notification email'
|
||||
);
|
||||
}
|
||||
t.pass();
|
||||
});
|
||||
|
||||
test('set and change password', async t => {
|
||||
const { mail, app, auth } = t.context;
|
||||
if (mail.hasConfigured()) {
|
||||
const u1Email = 'u1@affine.pro';
|
||||
|
||||
const u1 = await signUp(app, 'u1', u1Email, '1');
|
||||
|
||||
const primitiveMailCount = await getCurrentMailMessageCount();
|
||||
|
||||
await sendSetPasswordEmail(app, u1.token.token, u1Email, 'affine.pro');
|
||||
|
||||
const afterSendSetMailCount = await getCurrentMailMessageCount();
|
||||
|
||||
t.is(
|
||||
primitiveMailCount + 1,
|
||||
afterSendSetMailCount,
|
||||
'failed to send set email'
|
||||
);
|
||||
|
||||
const setPasswordToken = await getTokenFromLatestMailMessage();
|
||||
|
||||
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');
|
||||
|
||||
const ret = auth.signIn(u1Email, newPassword);
|
||||
t.notThrowsAsync(ret, 'failed to check password');
|
||||
t.is((await ret).id, u1.id, 'failed to check password');
|
||||
}
|
||||
t.pass();
|
||||
});
|
||||
test('should revoke token after change user identify', async t => {
|
||||
const { mail, app, auth } = t.context;
|
||||
if (mail.hasConfigured()) {
|
||||
// change email
|
||||
{
|
||||
const u1Email = 'u1@affine.pro';
|
||||
const u2Email = 'u2@affine.pro';
|
||||
|
||||
const u1 = await signUp(app, 'u1', u1Email, '1');
|
||||
|
||||
{
|
||||
const user = await currentUser(app, u1.token.token);
|
||||
t.is(user?.email, u1Email, 'failed to get current user');
|
||||
}
|
||||
|
||||
await sendChangeEmail(app, u1.token.token, u1Email, 'affine.pro');
|
||||
|
||||
const changeEmailToken = await getTokenFromLatestMailMessage();
|
||||
await sendVerifyChangeEmail(
|
||||
app,
|
||||
u1.token.token,
|
||||
changeEmailToken as string,
|
||||
u2Email,
|
||||
'affine.pro'
|
||||
);
|
||||
|
||||
const verifyEmailToken = await getTokenFromLatestMailMessage();
|
||||
await changeEmail(
|
||||
app,
|
||||
u1.token.token,
|
||||
verifyEmailToken as string,
|
||||
u2Email
|
||||
);
|
||||
|
||||
const user = await currentUser(app, u1.token.token);
|
||||
t.is(user, null, 'token should be revoked');
|
||||
|
||||
const newUserSession = await auth.signIn(u2Email, '1');
|
||||
t.is(newUserSession?.email, u2Email, 'failed to sign in with new email');
|
||||
}
|
||||
|
||||
// change password
|
||||
{
|
||||
const u3Email = 'u3@affine.pro';
|
||||
|
||||
const u3 = await signUp(app, 'u1', u3Email, '1');
|
||||
|
||||
{
|
||||
const user = await currentUser(app, u3.token.token);
|
||||
t.is(user?.email, u3Email, 'failed to get current user');
|
||||
}
|
||||
|
||||
await sendSetPasswordEmail(app, u3.token.token, u3Email, 'affine.pro');
|
||||
const token = await getTokenFromLatestMailMessage();
|
||||
const newPassword = randomBytes(16).toString('hex');
|
||||
await changePassword(app, u3.id, token as string, newPassword);
|
||||
|
||||
const user = await currentUser(app, u3.token.token);
|
||||
t.is(user, null, 'token should be revoked');
|
||||
|
||||
const newUserSession = await auth.signIn(u3Email, newPassword);
|
||||
t.is(
|
||||
newUserSession?.email,
|
||||
u3Email,
|
||||
'failed to sign in with new password'
|
||||
);
|
||||
}
|
||||
}
|
||||
t.pass();
|
||||
});
|
||||
315
packages/backend/server/src/__tests__/auth/controller.spec.ts
Normal file
315
packages/backend/server/src/__tests__/auth/controller.spec.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
import { HttpStatus, INestApplication } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import ava, { TestFn } from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
import request from 'supertest';
|
||||
|
||||
import { MailService } from '../../base';
|
||||
import { AuthModule, CurrentUser } from '../../core/auth';
|
||||
import { AuthService } from '../../core/auth/service';
|
||||
import { FeatureModule } from '../../core/features';
|
||||
import { UserModule, UserService } from '../../core/user';
|
||||
import { createTestingApp, getSession, sessionCookie } from '../utils';
|
||||
|
||||
const test = ava as TestFn<{
|
||||
auth: AuthService;
|
||||
user: UserService;
|
||||
u1: CurrentUser;
|
||||
db: PrismaClient;
|
||||
mailer: Sinon.SinonStubbedInstance<MailService>;
|
||||
app: INestApplication;
|
||||
}>;
|
||||
|
||||
test.before(async t => {
|
||||
const { app } = await createTestingApp({
|
||||
imports: [FeatureModule, UserModule, AuthModule],
|
||||
tapModule: m => {
|
||||
m.overrideProvider(MailService).useValue(
|
||||
Sinon.createStubInstance(MailService)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
t.context.auth = app.get(AuthService);
|
||||
t.context.user = app.get(UserService);
|
||||
t.context.db = app.get(PrismaClient);
|
||||
t.context.mailer = app.get(MailService);
|
||||
t.context.app = app;
|
||||
|
||||
t.context.u1 = await t.context.auth.signUp('u1@affine.pro', '1');
|
||||
});
|
||||
|
||||
test.beforeEach(() => {
|
||||
Sinon.reset();
|
||||
});
|
||||
|
||||
test.after.always(async t => {
|
||||
await t.context.app.close();
|
||||
});
|
||||
|
||||
test('should be able to sign in with credential', async t => {
|
||||
const { app, u1 } = t.context;
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/api/auth/sign-in')
|
||||
.send({ email: u1.email, password: '1' })
|
||||
.expect(200);
|
||||
|
||||
const session = await getSession(app, res);
|
||||
t.is(session.user!.id, u1.id);
|
||||
});
|
||||
|
||||
test('should be able to sign in with email', async t => {
|
||||
const { app, u1, mailer } = t.context;
|
||||
|
||||
// @ts-expect-error mock
|
||||
mailer.sendSignInMail.resolves({ rejected: [] });
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/api/auth/sign-in')
|
||||
.send({ email: u1.email })
|
||||
.expect(200);
|
||||
|
||||
t.is(res.body.email, u1.email);
|
||||
t.true(mailer.sendSignInMail.calledOnce);
|
||||
|
||||
const [signInLink] = mailer.sendSignInMail.firstCall.args;
|
||||
const url = new URL(signInLink);
|
||||
const email = url.searchParams.get('email');
|
||||
const token = url.searchParams.get('token');
|
||||
|
||||
const signInRes = await request(app.getHttpServer())
|
||||
.post('/api/auth/magic-link')
|
||||
.send({ email, token })
|
||||
.expect(201);
|
||||
|
||||
const session = await getSession(app, signInRes);
|
||||
t.is(session.user!.id, u1.id);
|
||||
});
|
||||
|
||||
test('should be able to sign up with email', async t => {
|
||||
const { app, mailer } = t.context;
|
||||
|
||||
// @ts-expect-error mock
|
||||
mailer.sendSignUpMail.resolves({ rejected: [] });
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/api/auth/sign-in')
|
||||
.send({ email: 'u2@affine.pro' })
|
||||
.expect(200);
|
||||
|
||||
t.is(res.body.email, 'u2@affine.pro');
|
||||
t.true(mailer.sendSignUpMail.calledOnce);
|
||||
|
||||
const [signUpLink] = mailer.sendSignUpMail.firstCall.args;
|
||||
const url = new URL(signUpLink);
|
||||
const email = url.searchParams.get('email');
|
||||
const token = url.searchParams.get('token');
|
||||
|
||||
const signInRes = await request(app.getHttpServer())
|
||||
.post('/api/auth/magic-link')
|
||||
.send({ email, token })
|
||||
.expect(201);
|
||||
|
||||
const session = await getSession(app, signInRes);
|
||||
t.is(session.user!.email, 'u2@affine.pro');
|
||||
});
|
||||
|
||||
test('should not be able to sign in if email is invalid', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/api/auth/sign-in')
|
||||
.send({ email: '' })
|
||||
.expect(400);
|
||||
|
||||
t.is(res.body.message, 'An invalid email provided: ');
|
||||
});
|
||||
|
||||
test('should not be able to sign in if forbidden', async t => {
|
||||
const { app, auth, u1, mailer } = t.context;
|
||||
|
||||
const canSignInStub = Sinon.stub(auth, 'canSignIn').resolves(false);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/auth/sign-in')
|
||||
.send({ email: u1.email })
|
||||
.expect(HttpStatus.FORBIDDEN);
|
||||
|
||||
t.true(mailer.sendSignInMail.notCalled);
|
||||
|
||||
canSignInStub.restore();
|
||||
});
|
||||
|
||||
test('should be able to sign out', async t => {
|
||||
const { app, u1 } = t.context;
|
||||
|
||||
const signInRes = await request(app.getHttpServer())
|
||||
.post('/api/auth/sign-in')
|
||||
.send({ email: u1.email, password: '1' })
|
||||
.expect(200);
|
||||
|
||||
const cookie = sessionCookie(signInRes.headers);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/auth/sign-out')
|
||||
.set('cookie', cookie)
|
||||
.expect(200);
|
||||
|
||||
const session = await getSession(app, signInRes);
|
||||
|
||||
t.falsy(session.user);
|
||||
});
|
||||
|
||||
test('should be able to correct user id cookie', async t => {
|
||||
const { app, u1 } = t.context;
|
||||
|
||||
const signInRes = await request(app.getHttpServer())
|
||||
.post('/api/auth/sign-in')
|
||||
.send({ email: u1.email, password: '1' })
|
||||
.expect(200);
|
||||
|
||||
const cookie = sessionCookie(signInRes.headers);
|
||||
|
||||
let session = await request(app.getHttpServer())
|
||||
.get('/api/auth/session')
|
||||
.set('cookie', cookie)
|
||||
.expect(200);
|
||||
|
||||
let userIdCookie = session.get('Set-Cookie')?.find(c => {
|
||||
return c.startsWith(`${AuthService.userCookieName}=`);
|
||||
});
|
||||
|
||||
t.true(userIdCookie?.startsWith(`${AuthService.userCookieName}=${u1.id}`));
|
||||
|
||||
session = await request(app.getHttpServer())
|
||||
.get('/api/auth/session')
|
||||
.set('cookie', `${cookie};${AuthService.userCookieName}=invalid_user_id`)
|
||||
.expect(200);
|
||||
|
||||
userIdCookie = session.get('Set-Cookie')?.find(c => {
|
||||
return c.startsWith(`${AuthService.userCookieName}=`);
|
||||
});
|
||||
|
||||
t.true(userIdCookie?.startsWith(`${AuthService.userCookieName}=${u1.id}`));
|
||||
t.is(session.body.user.id, u1.id);
|
||||
});
|
||||
|
||||
// multiple accounts session tests
|
||||
test('should be able to sign in another account in one session', async t => {
|
||||
const { app, u1, auth } = t.context;
|
||||
|
||||
const u2 = await auth.signUp('u3@affine.pro', '3');
|
||||
|
||||
// sign in u1
|
||||
const signInRes = await request(app.getHttpServer())
|
||||
.post('/api/auth/sign-in')
|
||||
.send({ email: u1.email, password: '1' })
|
||||
.expect(200);
|
||||
|
||||
const cookie = sessionCookie(signInRes.headers);
|
||||
|
||||
// avoid create session at the exact same time, leads to same random session users order
|
||||
await new Promise(resolve => setTimeout(resolve, 1));
|
||||
|
||||
// sign in u2 in the same session
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/auth/sign-in')
|
||||
.set('cookie', cookie)
|
||||
.send({ email: u2.email, password: '3' })
|
||||
.expect(200);
|
||||
|
||||
// list [u1, u2]
|
||||
const sessions = await request(app.getHttpServer())
|
||||
.get('/api/auth/sessions')
|
||||
.set('cookie', cookie)
|
||||
.expect(200);
|
||||
|
||||
t.is(sessions.body.users.length, 2);
|
||||
t.is(sessions.body.users[0].id, u1.id);
|
||||
t.is(sessions.body.users[1].id, u2.id);
|
||||
|
||||
// default to latest signed in user: u2
|
||||
let session = await request(app.getHttpServer())
|
||||
.get('/api/auth/session')
|
||||
.set('cookie', cookie)
|
||||
.expect(200);
|
||||
|
||||
t.is(session.body.user.id, u2.id);
|
||||
|
||||
// switch to u1
|
||||
session = await request(app.getHttpServer())
|
||||
.get('/api/auth/session')
|
||||
.set('cookie', `${cookie};${AuthService.userCookieName}=${u1.id}`)
|
||||
.expect(200);
|
||||
|
||||
t.is(session.body.user.id, u1.id);
|
||||
});
|
||||
|
||||
test('should be able to sign out multiple accounts in one session', async t => {
|
||||
const { app, u1, auth } = t.context;
|
||||
|
||||
const u2 = await auth.signUp('u4@affine.pro', '4');
|
||||
|
||||
// sign in u1
|
||||
const signInRes = await request(app.getHttpServer())
|
||||
.post('/api/auth/sign-in')
|
||||
.send({ email: u1.email, password: '1' })
|
||||
.expect(200);
|
||||
|
||||
const cookie = sessionCookie(signInRes.headers);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1));
|
||||
|
||||
// sign in u2 in the same session
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/auth/sign-in')
|
||||
.set('cookie', cookie)
|
||||
.send({ email: u2.email, password: '4' })
|
||||
.expect(200);
|
||||
|
||||
// sign out u2
|
||||
let signOut = await request(app.getHttpServer())
|
||||
.get(`/api/auth/sign-out?user_id=${u2.id}`)
|
||||
.set('cookie', `${cookie};${AuthService.userCookieName}=${u2.id}`)
|
||||
.expect(200);
|
||||
|
||||
// auto switch to u1 after sign out u2
|
||||
const userIdCookie = signOut.get('Set-Cookie')?.find(c => {
|
||||
return c.startsWith(`${AuthService.userCookieName}=`);
|
||||
});
|
||||
|
||||
t.true(userIdCookie?.startsWith(`${AuthService.userCookieName}=${u1.id}`));
|
||||
|
||||
// list [u1]
|
||||
const session = await request(app.getHttpServer())
|
||||
.get('/api/auth/session')
|
||||
.set('cookie', cookie)
|
||||
.expect(200);
|
||||
|
||||
t.is(session.body.user.id, u1.id);
|
||||
|
||||
// sign in u2 in the same session
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/auth/sign-in')
|
||||
.set('cookie', cookie)
|
||||
.send({ email: u2.email, password: '4' })
|
||||
.expect(200);
|
||||
|
||||
// sign out all account in session
|
||||
signOut = await request(app.getHttpServer())
|
||||
.get('/api/auth/sign-out')
|
||||
.set('cookie', cookie)
|
||||
.expect(200);
|
||||
|
||||
t.true(
|
||||
signOut
|
||||
.get('Set-Cookie')
|
||||
?.some(c => c.startsWith(`${AuthService.sessionCookieName}=;`))
|
||||
);
|
||||
t.true(
|
||||
signOut
|
||||
.get('Set-Cookie')
|
||||
?.some(c => c.startsWith(`${AuthService.userCookieName}=;`))
|
||||
);
|
||||
});
|
||||
142
packages/backend/server/src/__tests__/auth/guard.spec.ts
Normal file
142
packages/backend/server/src/__tests__/auth/guard.spec.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { Controller, Get, HttpStatus, INestApplication } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import ava, { TestFn } from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
import request from 'supertest';
|
||||
|
||||
import { AuthModule, CurrentUser, Public, Session } from '../../core/auth';
|
||||
import { AuthService } from '../../core/auth/service';
|
||||
import { createTestingApp } 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;
|
||||
}
|
||||
}
|
||||
|
||||
const test = ava as TestFn<{
|
||||
app: INestApplication;
|
||||
}>;
|
||||
|
||||
let server!: any;
|
||||
let auth!: AuthService;
|
||||
let u1!: CurrentUser;
|
||||
|
||||
test.before(async t => {
|
||||
const { app } = await createTestingApp({
|
||||
imports: [AuthModule],
|
||||
controllers: [TestController],
|
||||
});
|
||||
|
||||
auth = app.get(AuthService);
|
||||
u1 = await auth.signUp('u1@affine.pro', '1');
|
||||
|
||||
const db = app.get(PrismaClient);
|
||||
await db.session.create({
|
||||
data: {
|
||||
id: '1',
|
||||
},
|
||||
});
|
||||
await auth.createUserSession(u1.id, '1');
|
||||
|
||||
server = app.getHttpServer();
|
||||
t.context.app = app;
|
||||
});
|
||||
|
||||
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(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(server)
|
||||
.get('/public')
|
||||
.set('Cookie', `${AuthService.sessionCookieName}=1`)
|
||||
.expect(HttpStatus.OK);
|
||||
|
||||
t.is(res.body.user.id, u1.id);
|
||||
});
|
||||
|
||||
test('should not be able to visit private api if not signed in', async t => {
|
||||
await request(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 if signed in', async t => {
|
||||
const res = await request(server)
|
||||
.get('/private')
|
||||
.set('Cookie', `${AuthService.sessionCookieName}=1`)
|
||||
.expect(HttpStatus.OK);
|
||||
|
||||
t.is(res.body.user.id, u1.id);
|
||||
});
|
||||
|
||||
test('should be able to parse session cookie', async t => {
|
||||
const spy = Sinon.spy(auth, 'getUserSession');
|
||||
await request(server)
|
||||
.get('/public')
|
||||
.set('cookie', `${AuthService.sessionCookieName}=1`)
|
||||
.expect(200);
|
||||
|
||||
t.deepEqual(spy.firstCall.args, ['1', undefined]);
|
||||
spy.restore();
|
||||
});
|
||||
|
||||
test('should be able to parse bearer token', async t => {
|
||||
const spy = Sinon.spy(auth, 'getUserSession');
|
||||
|
||||
await request(server)
|
||||
.get('/public')
|
||||
.auth('1', { type: 'bearer' })
|
||||
.expect(200);
|
||||
|
||||
t.deepEqual(spy.firstCall.args, ['1', undefined]);
|
||||
spy.restore();
|
||||
});
|
||||
|
||||
test('should be able to refresh session if needed', async t => {
|
||||
await t.context.app.get(PrismaClient).userSession.updateMany({
|
||||
where: {
|
||||
sessionId: '1',
|
||||
},
|
||||
data: {
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 60 /* expires in 1 hour */),
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(server)
|
||||
.get('/session')
|
||||
.set('cookie', `${AuthService.sessionCookieName}=1`)
|
||||
.expect(200);
|
||||
|
||||
const cookie = res
|
||||
.get('Set-Cookie')
|
||||
?.find(c => c.startsWith(AuthService.sessionCookieName));
|
||||
|
||||
t.truthy(cookie);
|
||||
});
|
||||
218
packages/backend/server/src/__tests__/auth/service.spec.ts
Normal file
218
packages/backend/server/src/__tests__/auth/service.spec.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { TestingModule } from '@nestjs/testing';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import ava, { TestFn } from 'ava';
|
||||
|
||||
import { CurrentUser } from '../../core/auth';
|
||||
import { AuthService } from '../../core/auth/service';
|
||||
import { FeatureModule } from '../../core/features';
|
||||
import { QuotaModule } from '../../core/quota';
|
||||
import { UserModule, UserService } from '../../core/user';
|
||||
import { createTestingModule, initTestingDB } from '../utils';
|
||||
|
||||
const test = ava as TestFn<{
|
||||
auth: AuthService;
|
||||
user: UserService;
|
||||
u1: CurrentUser;
|
||||
db: PrismaClient;
|
||||
m: TestingModule;
|
||||
}>;
|
||||
|
||||
test.before(async t => {
|
||||
const m = await createTestingModule({
|
||||
imports: [QuotaModule, FeatureModule, UserModule],
|
||||
providers: [AuthService],
|
||||
});
|
||||
|
||||
t.context.auth = m.get(AuthService);
|
||||
t.context.user = m.get(UserService);
|
||||
t.context.db = m.get(PrismaClient);
|
||||
t.context.m = m;
|
||||
});
|
||||
|
||||
test.beforeEach(async t => {
|
||||
await initTestingDB(t.context.db);
|
||||
t.context.u1 = await t.context.auth.signUp('u1@affine.pro', '1');
|
||||
});
|
||||
|
||||
test.after.always(async t => {
|
||||
await t.context.m.close();
|
||||
});
|
||||
|
||||
test('should be able to sign in by password', async t => {
|
||||
const { auth } = t.context;
|
||||
|
||||
const signedInUser = await auth.signIn('u1@affine.pro', '1');
|
||||
|
||||
t.is(signedInUser.email, 'u1@affine.pro');
|
||||
});
|
||||
|
||||
test('should throw if user not found', async t => {
|
||||
const { auth } = t.context;
|
||||
|
||||
await t.throwsAsync(() => auth.signIn('u2@affine.pro', '1'), {
|
||||
message: 'Wrong user email or password: u2@affine.pro',
|
||||
});
|
||||
});
|
||||
|
||||
test('should throw if password not set', async t => {
|
||||
const { user, auth } = t.context;
|
||||
|
||||
await user.createUser({
|
||||
email: 'u2@affine.pro',
|
||||
name: 'u2',
|
||||
});
|
||||
|
||||
await t.throwsAsync(() => auth.signIn('u2@affine.pro', '1'), {
|
||||
message:
|
||||
'You are trying to sign in by a different method than you signed up with.',
|
||||
});
|
||||
});
|
||||
|
||||
test('should throw if password not match', async t => {
|
||||
const { auth } = t.context;
|
||||
|
||||
await t.throwsAsync(() => auth.signIn('u1@affine.pro', '2'), {
|
||||
message: 'Wrong user email or password: u1@affine.pro',
|
||||
});
|
||||
});
|
||||
|
||||
test('should be able to change password', async t => {
|
||||
const { auth, u1 } = t.context;
|
||||
|
||||
let signedInU1 = await auth.signIn('u1@affine.pro', '1');
|
||||
t.is(signedInU1.email, u1.email);
|
||||
|
||||
await auth.changePassword(u1.id, 'hello world affine');
|
||||
|
||||
await t.throwsAsync(
|
||||
() => auth.signIn('u1@affine.pro', '1' /* old password */),
|
||||
{
|
||||
message: 'Wrong user email or password: u1@affine.pro',
|
||||
}
|
||||
);
|
||||
|
||||
signedInU1 = await auth.signIn('u1@affine.pro', 'hello world affine');
|
||||
t.is(signedInU1.email, u1.email);
|
||||
});
|
||||
|
||||
test('should be able to change email', async t => {
|
||||
const { auth, u1 } = t.context;
|
||||
|
||||
let signedInU1 = await auth.signIn('u1@affine.pro', '1');
|
||||
t.is(signedInU1.email, u1.email);
|
||||
|
||||
await auth.changeEmail(u1.id, 'u2@affine.pro');
|
||||
|
||||
await t.throwsAsync(() => auth.signIn('u1@affine.pro' /* old email */, '1'), {
|
||||
message: 'Wrong user email or password: u1@affine.pro',
|
||||
});
|
||||
|
||||
signedInU1 = await auth.signIn('u2@affine.pro', '1');
|
||||
t.is(signedInU1.email, 'u2@affine.pro');
|
||||
});
|
||||
|
||||
// Tests for Session
|
||||
test('should be able to create user session', async t => {
|
||||
const { auth, u1 } = t.context;
|
||||
|
||||
const session = await auth.createUserSession(u1.id);
|
||||
|
||||
t.is(session.userId, u1.id);
|
||||
});
|
||||
|
||||
test('should be able to get user from session', async t => {
|
||||
const { auth, u1 } = t.context;
|
||||
|
||||
const session = await auth.createUserSession(u1.id);
|
||||
|
||||
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.createUserSession(u1.id);
|
||||
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.createUserSession(u1.id);
|
||||
|
||||
await db.userSession.update({
|
||||
where: { id: session.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 auth.signUp('u2@affine.pro', '1');
|
||||
|
||||
const session = await auth.createSession();
|
||||
|
||||
await auth.createUserSession(u1.id, session.id);
|
||||
|
||||
let userList = await auth.getUserList(session.id);
|
||||
t.is(userList.length, 1);
|
||||
t.is(userList[0]!.id, u1.id);
|
||||
|
||||
await auth.createUserSession(u2.id, session.id);
|
||||
|
||||
userList = await auth.getUserList(session.id);
|
||||
|
||||
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 auth.signUp('u2@affine.pro', '1');
|
||||
|
||||
const session = await auth.createSession();
|
||||
|
||||
await auth.createUserSession(u1.id, session.id);
|
||||
await auth.createUserSession(u2.id, session.id);
|
||||
|
||||
await auth.signOut(session.id, u1.id);
|
||||
|
||||
let list = await auth.getUserList(session.id);
|
||||
|
||||
t.is(list.length, 1);
|
||||
t.is(list[0]!.id, u2.id);
|
||||
|
||||
const u2Session = await auth.getUserSession(session.id, u1.id);
|
||||
|
||||
t.is(u2Session?.session.sessionId, session.id);
|
||||
t.is(u2Session?.user.id, u2.id);
|
||||
|
||||
await auth.signOut(session.id, u2.id);
|
||||
list = await auth.getUserList(session.id);
|
||||
|
||||
t.is(list.length, 0);
|
||||
|
||||
const nullSession = await auth.getUserSession(session.id, u2.id);
|
||||
|
||||
t.is(nullSession, null);
|
||||
});
|
||||
93
packages/backend/server/src/__tests__/auth/token.spec.ts
Normal file
93
packages/backend/server/src/__tests__/auth/token.spec.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { TestingModule } from '@nestjs/testing';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import ava, { TestFn } from 'ava';
|
||||
|
||||
import { TokenService, TokenType } from '../../core/auth';
|
||||
import { createTestingModule } from '../utils';
|
||||
|
||||
const test = ava as TestFn<{
|
||||
ts: TokenService;
|
||||
m: TestingModule;
|
||||
}>;
|
||||
|
||||
test.before(async t => {
|
||||
const m = await createTestingModule({
|
||||
providers: [TokenService],
|
||||
});
|
||||
|
||||
t.context.ts = m.get(TokenService);
|
||||
t.context.m = m;
|
||||
});
|
||||
|
||||
test.after.always(async t => {
|
||||
await t.context.m.close();
|
||||
});
|
||||
|
||||
test('should be able to create token', async t => {
|
||||
const { ts } = t.context;
|
||||
const token = await ts.createToken(TokenType.SignIn, 'user@affine.pro');
|
||||
|
||||
t.truthy(
|
||||
await ts.verifyToken(TokenType.SignIn, token, {
|
||||
credential: 'user@affine.pro',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should fail the verification if the token is invalid', async t => {
|
||||
const { ts } = t.context;
|
||||
|
||||
const token = await ts.createToken(TokenType.SignIn, 'user@affine.pro');
|
||||
|
||||
// wrong type
|
||||
t.falsy(
|
||||
await ts.verifyToken(TokenType.ChangeEmail, token, {
|
||||
credential: 'user@affine.pro',
|
||||
})
|
||||
);
|
||||
|
||||
// no credential
|
||||
t.falsy(await ts.verifyToken(TokenType.SignIn, token));
|
||||
|
||||
// wrong credential
|
||||
t.falsy(
|
||||
await ts.verifyToken(TokenType.SignIn, token, {
|
||||
credential: 'wrong@affine.pro',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should fail if the token expired', async t => {
|
||||
const { ts } = t.context;
|
||||
const token = await ts.createToken(TokenType.SignIn, 'user@affine.pro');
|
||||
|
||||
await t.context.m.get(PrismaClient).verificationToken.updateMany({
|
||||
data: {
|
||||
expiresAt: new Date(Date.now() - 1000),
|
||||
},
|
||||
});
|
||||
|
||||
t.falsy(
|
||||
await ts.verifyToken(TokenType.SignIn, token, {
|
||||
credential: 'user@affine.pro',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should be able to verify only once', async t => {
|
||||
const { ts } = t.context;
|
||||
const token = await ts.createToken(TokenType.SignIn, 'user@affine.pro');
|
||||
|
||||
t.truthy(
|
||||
await ts.verifyToken(TokenType.SignIn, token, {
|
||||
credential: 'user@affine.pro',
|
||||
})
|
||||
);
|
||||
|
||||
// will be invalid after the first time of verification
|
||||
t.falsy(
|
||||
await ts.verifyToken(TokenType.SignIn, token, {
|
||||
credential: 'user@affine.pro',
|
||||
})
|
||||
);
|
||||
});
|
||||
106
packages/backend/server/src/__tests__/cache.spec.ts
Normal file
106
packages/backend/server/src/__tests__/cache.spec.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { TestingModule } from '@nestjs/testing';
|
||||
import test from 'ava';
|
||||
|
||||
import { Cache } from '../base/cache';
|
||||
import { createTestingModule } from './utils';
|
||||
|
||||
let cache: Cache;
|
||||
let module: TestingModule;
|
||||
test.beforeEach(async () => {
|
||||
module = await createTestingModule();
|
||||
const prefix = Math.random().toString(36).slice(2, 7);
|
||||
cache = new Proxy(module.get(Cache), {
|
||||
get(target, prop) {
|
||||
// @ts-expect-error safe
|
||||
const fn = target[prop];
|
||||
if (typeof fn === 'function') {
|
||||
// replase first parameter of fn with prefix
|
||||
return (...args: any[]) =>
|
||||
fn.call(target, `${prefix}:${args[0]}`, ...args.slice(1));
|
||||
}
|
||||
|
||||
return fn;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await module.close();
|
||||
});
|
||||
|
||||
test('should be able to set normal cache', async t => {
|
||||
t.true(await cache.set('test', 1));
|
||||
t.is(await cache.get<number>('test'), 1);
|
||||
|
||||
t.true(await cache.has('test'));
|
||||
t.true(await cache.delete('test'));
|
||||
t.is(await cache.get('test'), undefined);
|
||||
|
||||
t.true(await cache.set('test', { a: 1 }));
|
||||
t.deepEqual(await cache.get('test'), { a: 1 });
|
||||
});
|
||||
|
||||
test('should be able to set cache with non-exiting flag', async t => {
|
||||
t.true(await cache.setnx('test', 1));
|
||||
t.false(await cache.setnx('test', 2));
|
||||
t.is(await cache.get('test'), 1);
|
||||
});
|
||||
|
||||
test('should be able to set cache with ttl', async t => {
|
||||
t.true(await cache.set('test', 1));
|
||||
t.is(await cache.get('test'), 1);
|
||||
|
||||
t.true(await cache.expire('test', 1 * 1000));
|
||||
const ttl = await cache.ttl('test');
|
||||
t.true(ttl <= 1 * 1000);
|
||||
t.true(ttl > 0);
|
||||
});
|
||||
|
||||
test('should be able to incr/decr number cache', async t => {
|
||||
t.true(await cache.set('test', 1));
|
||||
t.is(await cache.increase('test'), 2);
|
||||
t.is(await cache.increase('test'), 3);
|
||||
t.is(await cache.decrease('test'), 2);
|
||||
t.is(await cache.decrease('test'), 1);
|
||||
|
||||
// increase an nonexists number
|
||||
t.is(await cache.increase('test2'), 1);
|
||||
t.is(await cache.increase('test2'), 2);
|
||||
});
|
||||
|
||||
test('should be able to manipulate list cache', async t => {
|
||||
t.is(await cache.pushBack('test', 1), 1);
|
||||
t.is(await cache.pushBack('test', 2, 3, 4), 4);
|
||||
t.is(await cache.len('test'), 4);
|
||||
|
||||
t.deepEqual(await cache.list('test', 1, -1), [2, 3, 4]);
|
||||
|
||||
t.deepEqual(await cache.popFront('test', 2), [1, 2]);
|
||||
t.deepEqual(await cache.popBack('test', 1), [4]);
|
||||
|
||||
t.is(await cache.pushBack('test2', { a: 1 }), 1);
|
||||
t.deepEqual(await cache.popFront('test2', 1), [{ a: 1 }]);
|
||||
});
|
||||
|
||||
test('should be able to manipulate map cache', async t => {
|
||||
t.is(await cache.mapSet('test', 'a', 1), true);
|
||||
t.is(await cache.mapSet('test', 'b', 2), true);
|
||||
t.is(await cache.mapLen('test'), 2);
|
||||
|
||||
t.is(await cache.mapGet('test', 'a'), 1);
|
||||
t.is(await cache.mapGet('test', 'b'), 2);
|
||||
|
||||
t.is(await cache.mapIncrease('test', 'a'), 2);
|
||||
t.is(await cache.mapIncrease('test', 'a'), 3);
|
||||
t.is(await cache.mapDecrease('test', 'b', 3), -1);
|
||||
|
||||
const keys = await cache.mapKeys('test');
|
||||
t.deepEqual(keys, ['a', 'b']);
|
||||
|
||||
const randomKey = await cache.mapRandomKey('test');
|
||||
t.truthy(randomKey);
|
||||
t.true(keys.includes(randomKey!));
|
||||
|
||||
t.is(await cache.mapDelete('test', 'a'), true);
|
||||
t.is(await cache.mapGet('test', 'a'), undefined);
|
||||
});
|
||||
39
packages/backend/server/src/__tests__/config.spec.ts
Normal file
39
packages/backend/server/src/__tests__/config.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { TestingModule } from '@nestjs/testing';
|
||||
import test from 'ava';
|
||||
|
||||
import { Config, ConfigModule } from '../base/config';
|
||||
import { createTestingModule } from './utils';
|
||||
|
||||
let config: Config;
|
||||
let module: TestingModule;
|
||||
test.beforeEach(async () => {
|
||||
module = await createTestingModule({}, false);
|
||||
config = module.get(Config);
|
||||
});
|
||||
|
||||
test.afterEach.always(async () => {
|
||||
await module.close();
|
||||
});
|
||||
|
||||
test('should be able to get config', t => {
|
||||
t.true(typeof config.server.host === 'string');
|
||||
t.is(config.projectRoot, process.cwd());
|
||||
t.is(config.NODE_ENV, 'test');
|
||||
});
|
||||
|
||||
test('should be able to override config', async t => {
|
||||
const module = await createTestingModule({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
server: {
|
||||
host: 'testing',
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
const config = module.get(Config);
|
||||
|
||||
t.is(config.server.host, 'testing');
|
||||
|
||||
await module.close();
|
||||
});
|
||||
513
packages/backend/server/src/__tests__/copilot-provider.spec.ts
Normal file
513
packages/backend/server/src/__tests__/copilot-provider.spec.ts
Normal file
@@ -0,0 +1,513 @@
|
||||
/// <reference types="../global.d.ts" />
|
||||
|
||||
import { TestingModule } from '@nestjs/testing';
|
||||
import type { ExecutionContext, TestFn } from 'ava';
|
||||
import ava from 'ava';
|
||||
|
||||
import { ConfigModule } from '../base/config';
|
||||
import { AuthService } from '../core/auth';
|
||||
import { QuotaModule } from '../core/quota';
|
||||
import { CopilotModule } from '../plugins/copilot';
|
||||
import { prompts, PromptService } from '../plugins/copilot/prompt';
|
||||
import {
|
||||
CopilotProviderService,
|
||||
FalProvider,
|
||||
OpenAIProvider,
|
||||
PerplexityProvider,
|
||||
registerCopilotProvider,
|
||||
unregisterCopilotProvider,
|
||||
} from '../plugins/copilot/providers';
|
||||
import {
|
||||
CopilotChatTextExecutor,
|
||||
CopilotWorkflowService,
|
||||
GraphExecutorState,
|
||||
} from '../plugins/copilot/workflow';
|
||||
import {
|
||||
CopilotChatImageExecutor,
|
||||
CopilotCheckHtmlExecutor,
|
||||
CopilotCheckJsonExecutor,
|
||||
} from '../plugins/copilot/workflow/executor';
|
||||
import { createTestingModule } from './utils';
|
||||
import { TestAssets } from './utils/copilot';
|
||||
|
||||
type Tester = {
|
||||
auth: AuthService;
|
||||
module: TestingModule;
|
||||
prompt: PromptService;
|
||||
provider: CopilotProviderService;
|
||||
workflow: CopilotWorkflowService;
|
||||
executors: {
|
||||
image: CopilotChatImageExecutor;
|
||||
text: CopilotChatTextExecutor;
|
||||
html: CopilotCheckHtmlExecutor;
|
||||
json: CopilotCheckJsonExecutor;
|
||||
};
|
||||
};
|
||||
const test = ava as TestFn<Tester>;
|
||||
|
||||
const isCopilotConfigured =
|
||||
!!process.env.COPILOT_OPENAI_API_KEY &&
|
||||
!!process.env.COPILOT_FAL_API_KEY &&
|
||||
!!process.env.COPILOT_PERPLEXITY_API_KEY &&
|
||||
process.env.COPILOT_OPENAI_API_KEY !== '1' &&
|
||||
process.env.COPILOT_FAL_API_KEY !== '1' &&
|
||||
process.env.COPILOT_PERPLEXITY_API_KEY !== '1';
|
||||
const runIfCopilotConfigured = test.macro(
|
||||
async (
|
||||
t,
|
||||
callback: (t: ExecutionContext<Tester>) => Promise<void> | void
|
||||
) => {
|
||||
if (isCopilotConfigured) {
|
||||
await callback(t);
|
||||
} else {
|
||||
t.log('Skip test because copilot is not configured');
|
||||
t.pass();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
test.serial.before(async t => {
|
||||
const module = await createTestingModule({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
plugins: {
|
||||
copilot: {
|
||||
openai: {
|
||||
apiKey: process.env.COPILOT_OPENAI_API_KEY,
|
||||
},
|
||||
fal: {
|
||||
apiKey: process.env.COPILOT_FAL_API_KEY,
|
||||
},
|
||||
perplexity: {
|
||||
apiKey: process.env.COPILOT_PERPLEXITY_API_KEY,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
QuotaModule,
|
||||
CopilotModule,
|
||||
],
|
||||
});
|
||||
|
||||
const auth = module.get(AuthService);
|
||||
const prompt = module.get(PromptService);
|
||||
const provider = module.get(CopilotProviderService);
|
||||
const workflow = module.get(CopilotWorkflowService);
|
||||
|
||||
t.context.module = module;
|
||||
t.context.auth = auth;
|
||||
t.context.prompt = prompt;
|
||||
t.context.provider = provider;
|
||||
t.context.workflow = workflow;
|
||||
t.context.executors = {
|
||||
image: module.get(CopilotChatImageExecutor),
|
||||
text: module.get(CopilotChatTextExecutor),
|
||||
html: module.get(CopilotCheckHtmlExecutor),
|
||||
json: module.get(CopilotCheckJsonExecutor),
|
||||
};
|
||||
});
|
||||
|
||||
test.serial.before(async t => {
|
||||
const { prompt, executors } = t.context;
|
||||
|
||||
executors.image.register();
|
||||
executors.text.register();
|
||||
executors.html.register();
|
||||
executors.json.register();
|
||||
|
||||
registerCopilotProvider(OpenAIProvider);
|
||||
registerCopilotProvider(FalProvider);
|
||||
registerCopilotProvider(PerplexityProvider);
|
||||
|
||||
for (const name of await prompt.listNames()) {
|
||||
await prompt.delete(name);
|
||||
}
|
||||
|
||||
for (const p of prompts) {
|
||||
await prompt.set(p.name, p.model, p.messages, p.config);
|
||||
}
|
||||
});
|
||||
|
||||
test.after(async _ => {
|
||||
unregisterCopilotProvider(OpenAIProvider.type);
|
||||
unregisterCopilotProvider(FalProvider.type);
|
||||
unregisterCopilotProvider(PerplexityProvider.type);
|
||||
});
|
||||
|
||||
test.after(async t => {
|
||||
await t.context.module.close();
|
||||
});
|
||||
|
||||
const assertNotWrappedInCodeBlock = (
|
||||
t: ExecutionContext<Tester>,
|
||||
result: string
|
||||
) => {
|
||||
t.assert(
|
||||
!result.replaceAll('\n', '').trim().startsWith('```') &&
|
||||
!result.replaceAll('\n', '').trim().endsWith('```'),
|
||||
'should not wrap in code block'
|
||||
);
|
||||
};
|
||||
|
||||
const checkMDList = (text: string) => {
|
||||
const lines = text.split('\n');
|
||||
const listItemRegex = /^( {2})*(-|\u2010-\u2015|\*|\+)? .+$/;
|
||||
let prevIndent = null;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim() === '') continue;
|
||||
if (!listItemRegex.test(line)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentIndent = line.match(/^( *)/)?.[0].length!;
|
||||
if (Number.isNaN(currentIndent) || currentIndent % 2 !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prevIndent !== null && currentIndent > 0) {
|
||||
const indentDiff = currentIndent - prevIndent;
|
||||
// allow 1 level of indentation difference
|
||||
if (indentDiff > 2) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (line.trim().startsWith('-')) {
|
||||
prevIndent = currentIndent;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const checkUrl = (url: string) => {
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const retry = async (
|
||||
action: string,
|
||||
t: ExecutionContext<Tester>,
|
||||
callback: (t: ExecutionContext<Tester>) => void
|
||||
) => {
|
||||
let i = 3;
|
||||
while (i--) {
|
||||
const ret = await t.try(callback);
|
||||
if (ret.passed) {
|
||||
return ret.commit();
|
||||
} else {
|
||||
ret.discard();
|
||||
t.log(ret.errors.map(e => e.message).join('\n'));
|
||||
t.log(`retrying ${action} ${3 - i}/3 ...`);
|
||||
}
|
||||
}
|
||||
t.fail(`failed to run ${action}`);
|
||||
};
|
||||
|
||||
// ==================== utils ====================
|
||||
|
||||
test('should validate markdown list', t => {
|
||||
t.true(
|
||||
checkMDList(`
|
||||
- item 1
|
||||
- item 2
|
||||
`)
|
||||
);
|
||||
t.true(
|
||||
checkMDList(`
|
||||
- item 1
|
||||
- item 1.1
|
||||
- item 2
|
||||
`)
|
||||
);
|
||||
t.true(
|
||||
checkMDList(`
|
||||
- item 1
|
||||
- item 1.1
|
||||
- item 1.1.1
|
||||
- item 2
|
||||
`)
|
||||
);
|
||||
t.true(
|
||||
checkMDList(`
|
||||
- item 1
|
||||
- item 1.1
|
||||
- item 1.1.1
|
||||
- item 1.1.2
|
||||
- item 2
|
||||
`)
|
||||
);
|
||||
t.true(
|
||||
checkMDList(`
|
||||
- item 1
|
||||
- item 1.1
|
||||
- item 1.1.1
|
||||
- item 1.2
|
||||
`)
|
||||
);
|
||||
t.false(
|
||||
checkMDList(`
|
||||
- item 1
|
||||
- item 1.1
|
||||
- item 1.1.1.1
|
||||
`)
|
||||
);
|
||||
t.true(
|
||||
checkMDList(`
|
||||
- item 1
|
||||
- item 1.1
|
||||
- item 1.1.1.1
|
||||
item 1.1.1.1 line breaks
|
||||
- item 1.1.1.2
|
||||
`),
|
||||
'should allow line breaks'
|
||||
);
|
||||
});
|
||||
|
||||
// ==================== action ====================
|
||||
|
||||
const actions = [
|
||||
{
|
||||
promptName: [
|
||||
'Summary',
|
||||
'Explain this',
|
||||
'Write an article about this',
|
||||
'Write a twitter about this',
|
||||
'Write a poem about this',
|
||||
'Write a blog post about this',
|
||||
'Write outline',
|
||||
'Change tone to',
|
||||
'Improve writing for it',
|
||||
'Improve grammar for it',
|
||||
'Fix spelling for it',
|
||||
'Create headings',
|
||||
'Make it longer',
|
||||
'Make it shorter',
|
||||
'Continue writing',
|
||||
'Chat With AFFiNE AI',
|
||||
'Search With AFFiNE AI',
|
||||
],
|
||||
messages: [{ role: 'user' as const, content: TestAssets.SSOT }],
|
||||
verifier: (t: ExecutionContext<Tester>, result: string) => {
|
||||
assertNotWrappedInCodeBlock(t, result);
|
||||
t.assert(
|
||||
result.toLowerCase().includes('single source of truth'),
|
||||
'should include original keyword'
|
||||
);
|
||||
},
|
||||
type: 'text' as const,
|
||||
},
|
||||
{
|
||||
promptName: ['Brainstorm ideas about this', 'Brainstorm mindmap'],
|
||||
messages: [{ role: 'user' as const, content: TestAssets.SSOT }],
|
||||
verifier: (t: ExecutionContext<Tester>, result: string) => {
|
||||
assertNotWrappedInCodeBlock(t, result);
|
||||
t.assert(checkMDList(result), 'should be a markdown list');
|
||||
},
|
||||
type: 'text' as const,
|
||||
},
|
||||
{
|
||||
promptName: 'Expand mind map',
|
||||
messages: [{ role: 'user' as const, content: '- Single source of truth' }],
|
||||
verifier: (t: ExecutionContext<Tester>, result: string) => {
|
||||
assertNotWrappedInCodeBlock(t, result);
|
||||
t.assert(checkMDList(result), 'should be a markdown list');
|
||||
},
|
||||
type: 'text' as const,
|
||||
},
|
||||
{
|
||||
promptName: 'Find action items from it',
|
||||
messages: [{ role: 'user' as const, content: TestAssets.TODO }],
|
||||
verifier: (t: ExecutionContext<Tester>, result: string) => {
|
||||
assertNotWrappedInCodeBlock(t, result);
|
||||
t.assert(checkMDList(result), 'should be a markdown list');
|
||||
},
|
||||
type: 'text' as const,
|
||||
},
|
||||
{
|
||||
promptName: ['Explain this code', 'Check code error'],
|
||||
messages: [{ role: 'user' as const, content: TestAssets.Code }],
|
||||
verifier: (t: ExecutionContext<Tester>, result: string) => {
|
||||
assertNotWrappedInCodeBlock(t, result);
|
||||
t.assert(
|
||||
result.toLowerCase().includes('distance'),
|
||||
'explain code result should include keyword'
|
||||
);
|
||||
},
|
||||
type: 'text' as const,
|
||||
},
|
||||
{
|
||||
promptName: 'Translate to',
|
||||
messages: [
|
||||
{
|
||||
role: 'user' as const,
|
||||
content: TestAssets.SSOT,
|
||||
params: { language: 'Simplified Chinese' },
|
||||
},
|
||||
],
|
||||
verifier: (t: ExecutionContext<Tester>, result: string) => {
|
||||
assertNotWrappedInCodeBlock(t, result);
|
||||
t.assert(
|
||||
result.toLowerCase().includes('单一事实来源'),
|
||||
'explain code result should include keyword'
|
||||
);
|
||||
},
|
||||
type: 'text' as const,
|
||||
},
|
||||
{
|
||||
promptName: ['Generate a caption', 'Explain this image'],
|
||||
messages: [
|
||||
{
|
||||
role: 'user' as const,
|
||||
content: '',
|
||||
attachments: [
|
||||
'https://cdn.affine.pro/copilot-test/Qgqy9qZT3VGIEuMIotJYoCCH.jpg',
|
||||
],
|
||||
},
|
||||
],
|
||||
verifier: (t: ExecutionContext<Tester>, result: string) => {
|
||||
assertNotWrappedInCodeBlock(t, result);
|
||||
const content = result.toLowerCase();
|
||||
t.assert(
|
||||
content.includes('classroom') ||
|
||||
content.includes('school') ||
|
||||
content.includes('sky'),
|
||||
'explain code result should include keyword'
|
||||
);
|
||||
},
|
||||
type: 'text' as const,
|
||||
},
|
||||
{
|
||||
promptName: [
|
||||
'debug:action:fal-face-to-sticker',
|
||||
'debug:action:fal-remove-bg',
|
||||
'debug:action:fal-sd15',
|
||||
'debug:action:fal-upscaler',
|
||||
],
|
||||
messages: [
|
||||
{
|
||||
role: 'user' as const,
|
||||
content: '',
|
||||
attachments: [
|
||||
'https://cdn.affine.pro/copilot-test/Zkas098lkjdf-908231.jpg',
|
||||
],
|
||||
},
|
||||
],
|
||||
verifier: (t: ExecutionContext<Tester>, link: string) => {
|
||||
t.truthy(checkUrl(link), 'should be a valid url');
|
||||
},
|
||||
type: 'image' as const,
|
||||
},
|
||||
];
|
||||
for (const { promptName, messages, verifier, type } of actions) {
|
||||
const prompts = Array.isArray(promptName) ? promptName : [promptName];
|
||||
for (const promptName of prompts) {
|
||||
test(
|
||||
`should be able to run action: ${promptName}`,
|
||||
runIfCopilotConfigured,
|
||||
async t => {
|
||||
const { provider: providerService, prompt: promptService } = t.context;
|
||||
const prompt = (await promptService.get(promptName))!;
|
||||
t.truthy(prompt, 'should have prompt');
|
||||
const provider = (await providerService.getProviderByModel(
|
||||
prompt.model
|
||||
))!;
|
||||
t.truthy(provider, 'should have provider');
|
||||
await retry(`action: ${promptName}`, t, async t => {
|
||||
if (type === 'text' && 'generateText' in provider) {
|
||||
const result = await provider.generateText(
|
||||
[
|
||||
...prompt.finish(
|
||||
messages.reduce(
|
||||
// @ts-expect-error
|
||||
(acc, m) => Object.assign(acc, m.params),
|
||||
{}
|
||||
)
|
||||
),
|
||||
...messages,
|
||||
],
|
||||
prompt.model
|
||||
);
|
||||
t.truthy(result, 'should return result');
|
||||
verifier?.(t, result);
|
||||
} else if (type === 'image' && 'generateImages' in provider) {
|
||||
const result = await provider.generateImages(
|
||||
[
|
||||
...prompt.finish(
|
||||
messages.reduce(
|
||||
// @ts-expect-error
|
||||
(acc, m) => Object.assign(acc, m.params),
|
||||
{}
|
||||
)
|
||||
),
|
||||
...messages,
|
||||
],
|
||||
prompt.model
|
||||
);
|
||||
t.truthy(result.length, 'should return result');
|
||||
for (const r of result) {
|
||||
verifier?.(t, r);
|
||||
}
|
||||
} else {
|
||||
t.fail('unsupported provider type');
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== workflow ====================
|
||||
|
||||
const workflows = [
|
||||
{
|
||||
name: 'brainstorm',
|
||||
content: 'apple company',
|
||||
verifier: (t: ExecutionContext, result: string) => {
|
||||
t.assert(checkMDList(result), 'should be a markdown list');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'presentation',
|
||||
content: 'apple company',
|
||||
verifier: (t: ExecutionContext, result: string) => {
|
||||
for (const l of result.split('\n')) {
|
||||
t.notThrows(() => {
|
||||
JSON.parse(l.trim());
|
||||
}, 'should be valid json');
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const { name, content, verifier } of workflows) {
|
||||
test(
|
||||
`should be able to run workflow: ${name}`,
|
||||
runIfCopilotConfigured,
|
||||
async t => {
|
||||
const { workflow } = t.context;
|
||||
|
||||
await retry(`workflow: ${name}`, t, async t => {
|
||||
let result = '';
|
||||
for await (const ret of workflow.runGraph({ content }, name)) {
|
||||
if (ret.status === GraphExecutorState.EnterNode) {
|
||||
t.log('enter node:', ret.node.name);
|
||||
} else if (ret.status === GraphExecutorState.ExitNode) {
|
||||
t.log('exit node:', ret.node.name);
|
||||
} else if (ret.status === GraphExecutorState.EmitAttachment) {
|
||||
t.log('stream attachment:', ret);
|
||||
} else {
|
||||
result += ret.content;
|
||||
}
|
||||
}
|
||||
t.truthy(result, 'should return result');
|
||||
verifier?.(t, result);
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
728
packages/backend/server/src/__tests__/copilot.e2e.ts
Normal file
728
packages/backend/server/src/__tests__/copilot.e2e.ts
Normal file
@@ -0,0 +1,728 @@
|
||||
/// <reference types="../global.d.ts" />
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import type { TestFn } from 'ava';
|
||||
import ava from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import { ConfigModule } from '../base/config';
|
||||
import { AuthService } from '../core/auth';
|
||||
import { WorkspaceModule } from '../core/workspaces';
|
||||
import { CopilotModule } from '../plugins/copilot';
|
||||
import { prompts, PromptService } from '../plugins/copilot/prompt';
|
||||
import {
|
||||
CopilotProviderService,
|
||||
FalProvider,
|
||||
OpenAIProvider,
|
||||
PerplexityProvider,
|
||||
registerCopilotProvider,
|
||||
unregisterCopilotProvider,
|
||||
} from '../plugins/copilot/providers';
|
||||
import { CopilotStorage } from '../plugins/copilot/storage';
|
||||
import {
|
||||
acceptInviteById,
|
||||
createTestingApp,
|
||||
createWorkspace,
|
||||
inviteUser,
|
||||
signUp,
|
||||
} from './utils';
|
||||
import {
|
||||
array2sse,
|
||||
chatWithImages,
|
||||
chatWithText,
|
||||
chatWithTextStream,
|
||||
chatWithWorkflow,
|
||||
createCopilotMessage,
|
||||
createCopilotSession,
|
||||
forkCopilotSession,
|
||||
getHistories,
|
||||
MockCopilotTestProvider,
|
||||
sse2array,
|
||||
textToEventStream,
|
||||
unsplashSearch,
|
||||
updateCopilotSession,
|
||||
} from './utils/copilot';
|
||||
|
||||
const test = ava as TestFn<{
|
||||
auth: AuthService;
|
||||
app: INestApplication;
|
||||
prompt: PromptService;
|
||||
provider: CopilotProviderService;
|
||||
storage: CopilotStorage;
|
||||
}>;
|
||||
|
||||
test.beforeEach(async t => {
|
||||
const { app } = await createTestingApp({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
plugins: {
|
||||
copilot: {
|
||||
openai: {
|
||||
apiKey: '1',
|
||||
},
|
||||
fal: {
|
||||
apiKey: '1',
|
||||
},
|
||||
perplexity: {
|
||||
apiKey: '1',
|
||||
},
|
||||
unsplashKey: process.env.UNSPLASH_ACCESS_KEY || '1',
|
||||
},
|
||||
},
|
||||
}),
|
||||
WorkspaceModule,
|
||||
CopilotModule,
|
||||
],
|
||||
});
|
||||
|
||||
const auth = app.get(AuthService);
|
||||
const prompt = app.get(PromptService);
|
||||
const storage = app.get(CopilotStorage);
|
||||
|
||||
t.context.app = app;
|
||||
t.context.auth = auth;
|
||||
t.context.prompt = prompt;
|
||||
t.context.storage = storage;
|
||||
});
|
||||
|
||||
let token: string;
|
||||
const promptName = 'prompt';
|
||||
test.beforeEach(async t => {
|
||||
const { app, prompt } = t.context;
|
||||
const user = await signUp(app, 'test', 'darksky@affine.pro', '123456');
|
||||
token = user.token.token;
|
||||
|
||||
unregisterCopilotProvider(OpenAIProvider.type);
|
||||
unregisterCopilotProvider(FalProvider.type);
|
||||
unregisterCopilotProvider(PerplexityProvider.type);
|
||||
registerCopilotProvider(MockCopilotTestProvider);
|
||||
|
||||
await prompt.set(promptName, 'test', [
|
||||
{ role: 'system', content: 'hello {{word}}' },
|
||||
]);
|
||||
});
|
||||
|
||||
test.afterEach.always(async t => {
|
||||
await t.context.app.close();
|
||||
});
|
||||
|
||||
// ==================== session ====================
|
||||
|
||||
test('should create session correctly', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const assertCreateSession = async (
|
||||
workspaceId: string,
|
||||
error: string,
|
||||
asserter = async (x: any) => {
|
||||
t.truthy(await x, error);
|
||||
}
|
||||
) => {
|
||||
await asserter(
|
||||
createCopilotSession(app, token, workspaceId, randomUUID(), promptName)
|
||||
);
|
||||
};
|
||||
|
||||
{
|
||||
const { id } = await createWorkspace(app, token);
|
||||
await assertCreateSession(
|
||||
id,
|
||||
'should be able to create session with cloud workspace that user can access'
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
await assertCreateSession(
|
||||
randomUUID(),
|
||||
'should be able to create session with local workspace'
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const {
|
||||
token: { token },
|
||||
} = await signUp(app, 'test', 'test@affine.pro', '123456');
|
||||
const { id } = await createWorkspace(app, token);
|
||||
await assertCreateSession(id, '', async x => {
|
||||
await t.throwsAsync(
|
||||
x,
|
||||
{ instanceOf: Error },
|
||||
'should not able to create session with cloud workspace that user cannot access'
|
||||
);
|
||||
});
|
||||
|
||||
const inviteId = await inviteUser(app, token, id, 'darksky@affine.pro');
|
||||
await acceptInviteById(app, id, inviteId, false);
|
||||
await assertCreateSession(
|
||||
id,
|
||||
'should able to create session after user have permission'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('should update session correctly', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const assertUpdateSession = async (
|
||||
sessionId: string,
|
||||
error: string,
|
||||
asserter = async (x: any) => {
|
||||
t.truthy(await x, error);
|
||||
}
|
||||
) => {
|
||||
await asserter(updateCopilotSession(app, token, sessionId, promptName));
|
||||
};
|
||||
|
||||
{
|
||||
const { id: workspaceId } = await createWorkspace(app, token);
|
||||
const docId = randomUUID();
|
||||
const sessionId = await createCopilotSession(
|
||||
app,
|
||||
token,
|
||||
workspaceId,
|
||||
docId,
|
||||
promptName
|
||||
);
|
||||
await assertUpdateSession(
|
||||
sessionId,
|
||||
'should be able to update session with cloud workspace that user can access'
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const sessionId = await createCopilotSession(
|
||||
app,
|
||||
token,
|
||||
randomUUID(),
|
||||
randomUUID(),
|
||||
promptName
|
||||
);
|
||||
await assertUpdateSession(
|
||||
sessionId,
|
||||
'should be able to update session with local workspace'
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const aToken = (await signUp(app, 'test', 'test@affine.pro', '123456'))
|
||||
.token.token;
|
||||
const { id: workspaceId } = await createWorkspace(app, aToken);
|
||||
const inviteId = await inviteUser(
|
||||
app,
|
||||
aToken,
|
||||
workspaceId,
|
||||
'darksky@affine.pro'
|
||||
);
|
||||
await acceptInviteById(app, workspaceId, inviteId, false);
|
||||
const sessionId = await createCopilotSession(
|
||||
app,
|
||||
token,
|
||||
workspaceId,
|
||||
randomUUID(),
|
||||
promptName
|
||||
);
|
||||
await assertUpdateSession(
|
||||
sessionId,
|
||||
'should able to update session after user have permission'
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const sessionId = '123456';
|
||||
await assertUpdateSession(sessionId, '', async x => {
|
||||
await t.throwsAsync(
|
||||
x,
|
||||
{ instanceOf: Error },
|
||||
'should not able to update invalid session id'
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('should fork session correctly', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const assertForkSession = async (
|
||||
token: string,
|
||||
workspaceId: string,
|
||||
sessionId: string,
|
||||
lastMessageId: string,
|
||||
error: string,
|
||||
asserter = async (x: any) => {
|
||||
const forkedSessionId = await x;
|
||||
t.truthy(forkedSessionId, error);
|
||||
return forkedSessionId;
|
||||
}
|
||||
) =>
|
||||
await asserter(
|
||||
forkCopilotSession(
|
||||
app,
|
||||
token,
|
||||
workspaceId,
|
||||
randomUUID(),
|
||||
sessionId,
|
||||
lastMessageId
|
||||
)
|
||||
);
|
||||
|
||||
// prepare session
|
||||
const { id } = await createWorkspace(app, token);
|
||||
const sessionId = await createCopilotSession(
|
||||
app,
|
||||
token,
|
||||
id,
|
||||
randomUUID(),
|
||||
promptName
|
||||
);
|
||||
|
||||
let forkedSessionId: string;
|
||||
// should be able to fork session
|
||||
{
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const messageId = await createCopilotMessage(app, token, sessionId);
|
||||
await chatWithText(app, token, sessionId, messageId);
|
||||
}
|
||||
const histories = await getHistories(app, token, { workspaceId: id });
|
||||
const latestMessageId = histories[0].messages.findLast(
|
||||
m => m.role === 'assistant'
|
||||
)?.id;
|
||||
t.truthy(latestMessageId, 'should find last message id');
|
||||
|
||||
// should be able to fork session
|
||||
forkedSessionId = await assertForkSession(
|
||||
token,
|
||||
id,
|
||||
sessionId,
|
||||
latestMessageId!,
|
||||
'should be able to fork session with cloud workspace that user can access'
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const {
|
||||
token: { token: newToken },
|
||||
} = await signUp(app, 'test', 'test@affine.pro', '123456');
|
||||
await assertForkSession(
|
||||
newToken,
|
||||
id,
|
||||
sessionId,
|
||||
randomUUID(),
|
||||
'',
|
||||
async x => {
|
||||
await t.throwsAsync(
|
||||
x,
|
||||
{ instanceOf: Error },
|
||||
'should not able to fork session with cloud workspace that user cannot access'
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const inviteId = await inviteUser(app, token, id, 'test@affine.pro');
|
||||
await acceptInviteById(app, id, inviteId, false);
|
||||
await assertForkSession(
|
||||
newToken,
|
||||
id,
|
||||
sessionId,
|
||||
randomUUID(),
|
||||
'',
|
||||
async x => {
|
||||
await t.throwsAsync(
|
||||
x,
|
||||
{ instanceOf: Error },
|
||||
'should not able to fork a root session from other user'
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const histories = await getHistories(app, token, { workspaceId: id });
|
||||
const latestMessageId = histories
|
||||
.find(h => h.sessionId === forkedSessionId)
|
||||
?.messages.findLast(m => m.role === 'assistant')?.id;
|
||||
t.truthy(latestMessageId, 'should find latest message id');
|
||||
|
||||
await assertForkSession(
|
||||
newToken,
|
||||
id,
|
||||
forkedSessionId,
|
||||
latestMessageId!,
|
||||
'should able to fork a forked session created by other user'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('should be able to use test provider', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const { id } = await createWorkspace(app, token);
|
||||
t.truthy(
|
||||
await createCopilotSession(app, token, id, randomUUID(), promptName),
|
||||
'failed to create session'
|
||||
);
|
||||
});
|
||||
|
||||
// ==================== message ====================
|
||||
|
||||
test('should create message correctly', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
{
|
||||
const { id } = await createWorkspace(app, token);
|
||||
const sessionId = await createCopilotSession(
|
||||
app,
|
||||
token,
|
||||
id,
|
||||
randomUUID(),
|
||||
promptName
|
||||
);
|
||||
const messageId = await createCopilotMessage(app, token, sessionId);
|
||||
t.truthy(messageId, 'should be able to create message with valid session');
|
||||
}
|
||||
|
||||
{
|
||||
await t.throwsAsync(
|
||||
createCopilotMessage(app, token, randomUUID()),
|
||||
{ instanceOf: Error },
|
||||
'should not able to create message with invalid session'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== chat ====================
|
||||
|
||||
test('should be able to chat with api', async t => {
|
||||
const { app, storage } = t.context;
|
||||
|
||||
Sinon.stub(storage, 'handleRemoteLink').resolvesArg(2);
|
||||
|
||||
const { id } = await createWorkspace(app, token);
|
||||
const sessionId = await createCopilotSession(
|
||||
app,
|
||||
token,
|
||||
id,
|
||||
randomUUID(),
|
||||
promptName
|
||||
);
|
||||
const messageId = await createCopilotMessage(app, token, sessionId);
|
||||
const ret = await chatWithText(app, token, sessionId, messageId);
|
||||
t.is(ret, 'generate text to text', 'should be able to chat with text');
|
||||
|
||||
const ret2 = await chatWithTextStream(app, token, sessionId, messageId);
|
||||
t.is(
|
||||
ret2,
|
||||
textToEventStream('generate text to text stream', messageId),
|
||||
'should be able to chat with text stream'
|
||||
);
|
||||
|
||||
const ret3 = await chatWithImages(app, token, sessionId, messageId);
|
||||
t.is(
|
||||
array2sse(sse2array(ret3).filter(e => e.event !== 'event')),
|
||||
textToEventStream(
|
||||
['https://example.com/test.jpg', 'hello '],
|
||||
messageId,
|
||||
'attachment'
|
||||
),
|
||||
'should be able to chat with images'
|
||||
);
|
||||
|
||||
Sinon.restore();
|
||||
});
|
||||
|
||||
test('should be able to chat with api by workflow', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const { id } = await createWorkspace(app, token);
|
||||
const sessionId = await createCopilotSession(
|
||||
app,
|
||||
token,
|
||||
id,
|
||||
randomUUID(),
|
||||
'workflow:presentation'
|
||||
);
|
||||
const messageId = await createCopilotMessage(
|
||||
app,
|
||||
token,
|
||||
sessionId,
|
||||
'apple company'
|
||||
);
|
||||
const ret = await chatWithWorkflow(app, token, sessionId, messageId);
|
||||
t.is(
|
||||
array2sse(sse2array(ret).filter(e => e.event !== 'event')),
|
||||
textToEventStream(['generate text to text stream'], messageId),
|
||||
'should be able to chat with workflow'
|
||||
);
|
||||
});
|
||||
|
||||
test('should be able to chat with special image model', async t => {
|
||||
const { app, storage } = t.context;
|
||||
|
||||
Sinon.stub(storage, 'handleRemoteLink').resolvesArg(2);
|
||||
|
||||
const { id } = await createWorkspace(app, token);
|
||||
|
||||
const testWithModel = async (promptName: string, finalPrompt: string) => {
|
||||
const model = prompts.find(p => p.name === promptName)?.model;
|
||||
const sessionId = await createCopilotSession(
|
||||
app,
|
||||
token,
|
||||
id,
|
||||
randomUUID(),
|
||||
promptName
|
||||
);
|
||||
const messageId = await createCopilotMessage(
|
||||
app,
|
||||
token,
|
||||
sessionId,
|
||||
'some-tag',
|
||||
[`https://example.com/${promptName}.jpg`]
|
||||
);
|
||||
const ret3 = await chatWithImages(app, token, sessionId, messageId);
|
||||
t.is(
|
||||
ret3,
|
||||
textToEventStream(
|
||||
[`https://example.com/${model}.jpg`, finalPrompt],
|
||||
messageId,
|
||||
'attachment'
|
||||
),
|
||||
'should be able to chat with images'
|
||||
);
|
||||
};
|
||||
|
||||
await testWithModel('debug:action:fal-sd15', 'some-tag');
|
||||
await testWithModel(
|
||||
'debug:action:fal-upscaler',
|
||||
'best quality, 8K resolution, highres, clarity, some-tag'
|
||||
);
|
||||
await testWithModel('debug:action:fal-remove-bg', 'some-tag');
|
||||
|
||||
Sinon.restore();
|
||||
});
|
||||
|
||||
test('should be able to retry with api', async t => {
|
||||
const { app, storage } = t.context;
|
||||
|
||||
Sinon.stub(storage, 'handleRemoteLink').resolvesArg(2);
|
||||
|
||||
// normal chat
|
||||
{
|
||||
const { id } = await createWorkspace(app, token);
|
||||
const sessionId = await createCopilotSession(
|
||||
app,
|
||||
token,
|
||||
id,
|
||||
randomUUID(),
|
||||
promptName
|
||||
);
|
||||
const messageId = await createCopilotMessage(app, token, sessionId);
|
||||
// chat 2 times
|
||||
await chatWithText(app, token, sessionId, messageId);
|
||||
await chatWithText(app, token, sessionId, messageId);
|
||||
|
||||
const histories = await getHistories(app, token, { workspaceId: id });
|
||||
t.deepEqual(
|
||||
histories.map(h => h.messages.map(m => m.content)),
|
||||
[['generate text to text', 'generate text to text']],
|
||||
'should be able to list history'
|
||||
);
|
||||
}
|
||||
|
||||
// retry chat
|
||||
{
|
||||
const { id } = await createWorkspace(app, token);
|
||||
const sessionId = await createCopilotSession(
|
||||
app,
|
||||
token,
|
||||
id,
|
||||
randomUUID(),
|
||||
promptName
|
||||
);
|
||||
const messageId = await createCopilotMessage(app, token, sessionId);
|
||||
await chatWithText(app, token, sessionId, messageId);
|
||||
// retry without message id
|
||||
await chatWithText(app, token, sessionId);
|
||||
|
||||
// should only have 1 message
|
||||
const histories = await getHistories(app, token, { workspaceId: id });
|
||||
t.deepEqual(
|
||||
histories.map(h => h.messages.map(m => m.content)),
|
||||
[['generate text to text']],
|
||||
'should be able to list history'
|
||||
);
|
||||
}
|
||||
|
||||
Sinon.restore();
|
||||
});
|
||||
|
||||
test('should reject message from different session', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const { id } = await createWorkspace(app, token);
|
||||
const sessionId = await createCopilotSession(
|
||||
app,
|
||||
token,
|
||||
id,
|
||||
randomUUID(),
|
||||
promptName
|
||||
);
|
||||
const anotherSessionId = await createCopilotSession(
|
||||
app,
|
||||
token,
|
||||
id,
|
||||
randomUUID(),
|
||||
promptName
|
||||
);
|
||||
const anotherMessageId = await createCopilotMessage(
|
||||
app,
|
||||
token,
|
||||
anotherSessionId
|
||||
);
|
||||
await t.throwsAsync(
|
||||
chatWithText(app, token, sessionId, anotherMessageId),
|
||||
{ instanceOf: Error },
|
||||
'should reject message from different session'
|
||||
);
|
||||
});
|
||||
|
||||
test('should reject request from different user', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const { id } = await createWorkspace(app, token);
|
||||
const sessionId = await createCopilotSession(
|
||||
app,
|
||||
token,
|
||||
id,
|
||||
randomUUID(),
|
||||
promptName
|
||||
);
|
||||
|
||||
// should reject message from different user
|
||||
{
|
||||
const { token } = await signUp(app, 'a1', 'a1@affine.pro', '123456');
|
||||
await t.throwsAsync(
|
||||
createCopilotMessage(app, token.token, sessionId),
|
||||
{ instanceOf: Error },
|
||||
'should reject message from different user'
|
||||
);
|
||||
}
|
||||
|
||||
// should reject chat from different user
|
||||
{
|
||||
const messageId = await createCopilotMessage(app, token, sessionId);
|
||||
{
|
||||
const { token } = await signUp(app, 'a2', 'a2@affine.pro', '123456');
|
||||
await t.throwsAsync(
|
||||
chatWithText(app, token.token, sessionId, messageId),
|
||||
{ instanceOf: Error },
|
||||
'should reject chat from different user'
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== history ====================
|
||||
|
||||
test('should be able to list history', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const { id: workspaceId } = await createWorkspace(app, token);
|
||||
const sessionId = await createCopilotSession(
|
||||
app,
|
||||
token,
|
||||
workspaceId,
|
||||
randomUUID(),
|
||||
promptName
|
||||
);
|
||||
|
||||
const messageId = await createCopilotMessage(app, token, sessionId, 'hello');
|
||||
await chatWithText(app, token, sessionId, messageId);
|
||||
|
||||
{
|
||||
const histories = await getHistories(app, token, { workspaceId });
|
||||
t.deepEqual(
|
||||
histories.map(h => h.messages.map(m => m.content)),
|
||||
[['hello', 'generate text to text']],
|
||||
'should be able to list history'
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const histories = await getHistories(app, token, {
|
||||
workspaceId,
|
||||
options: { messageOrder: 'desc' },
|
||||
});
|
||||
t.deepEqual(
|
||||
histories.map(h => h.messages.map(m => m.content)),
|
||||
[['generate text to text', 'hello']],
|
||||
'should be able to list history'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('should reject request that user have not permission', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const {
|
||||
token: { token: anotherToken },
|
||||
} = await signUp(app, 'a1', 'a1@affine.pro', '123456');
|
||||
const { id: workspaceId } = await createWorkspace(app, anotherToken);
|
||||
|
||||
// should reject request that user have not permission
|
||||
{
|
||||
await t.throwsAsync(
|
||||
getHistories(app, token, { workspaceId }),
|
||||
{ instanceOf: Error },
|
||||
'should reject request that user have not permission'
|
||||
);
|
||||
}
|
||||
|
||||
// should able to list history after user have permission
|
||||
{
|
||||
const inviteId = await inviteUser(
|
||||
app,
|
||||
anotherToken,
|
||||
workspaceId,
|
||||
'darksky@affine.pro'
|
||||
);
|
||||
await acceptInviteById(app, workspaceId, inviteId, false);
|
||||
|
||||
t.deepEqual(
|
||||
await getHistories(app, token, { workspaceId }),
|
||||
[],
|
||||
'should able to list history after user have permission'
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const sessionId = await createCopilotSession(
|
||||
app,
|
||||
anotherToken,
|
||||
workspaceId,
|
||||
randomUUID(),
|
||||
promptName
|
||||
);
|
||||
|
||||
const messageId = await createCopilotMessage(app, anotherToken, sessionId);
|
||||
await chatWithText(app, anotherToken, sessionId, messageId);
|
||||
|
||||
const histories = await getHistories(app, anotherToken, { workspaceId });
|
||||
t.deepEqual(
|
||||
histories.map(h => h.messages.map(m => m.content)),
|
||||
[['generate text to text']],
|
||||
'should able to list history'
|
||||
);
|
||||
|
||||
t.deepEqual(
|
||||
await getHistories(app, token, { workspaceId }),
|
||||
[],
|
||||
'should not list history created by another user'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('should be able to search image from unsplash', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const resp = await unsplashSearch(app, token);
|
||||
t.not(resp.status, 404, 'route should be exists');
|
||||
});
|
||||
1176
packages/backend/server/src/__tests__/copilot.spec.ts
Normal file
1176
packages/backend/server/src/__tests__/copilot.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
104
packages/backend/server/src/__tests__/doc/cron.spec.ts
Normal file
104
packages/backend/server/src/__tests__/doc/cron.spec.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { mock } from 'node:test';
|
||||
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { TestingModule } from '@nestjs/testing';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import test from 'ava';
|
||||
import * as Sinon from 'sinon';
|
||||
|
||||
import { Config } from '../../base/config';
|
||||
import { DocStorageModule } from '../../core/doc';
|
||||
import { DocStorageCronJob } from '../../core/doc/job';
|
||||
import { createTestingModule } from '../utils';
|
||||
|
||||
let m: TestingModule;
|
||||
let timer: Sinon.SinonFakeTimers;
|
||||
let db: PrismaClient;
|
||||
|
||||
// cleanup database before each test
|
||||
test.before(async () => {
|
||||
timer = Sinon.useFakeTimers({
|
||||
toFake: ['setInterval'],
|
||||
});
|
||||
m = await createTestingModule({
|
||||
imports: [ScheduleModule.forRoot(), DocStorageModule],
|
||||
});
|
||||
|
||||
db = m.get(PrismaClient);
|
||||
});
|
||||
|
||||
test.after.always(async () => {
|
||||
await m.close();
|
||||
timer.restore();
|
||||
});
|
||||
|
||||
test('should poll when intervel due', async t => {
|
||||
const manager = m.get(DocStorageCronJob);
|
||||
const interval = m.get(Config).doc.manager.updatePollInterval;
|
||||
|
||||
let resolve: any;
|
||||
const fake = mock.method(manager, 'autoMergePendingDocUpdates', () => {
|
||||
return new Promise(_resolve => {
|
||||
resolve = _resolve;
|
||||
});
|
||||
});
|
||||
|
||||
timer.tick(interval);
|
||||
t.is(fake.mock.callCount(), 1);
|
||||
|
||||
// busy
|
||||
timer.tick(interval);
|
||||
// @ts-expect-error private member
|
||||
t.is(manager.busy, true);
|
||||
t.is(fake.mock.callCount(), 1);
|
||||
|
||||
resolve();
|
||||
await timer.tickAsync(1);
|
||||
|
||||
// @ts-expect-error private member
|
||||
t.is(manager.busy, false);
|
||||
timer.tick(interval);
|
||||
t.is(fake.mock.callCount(), 2);
|
||||
});
|
||||
|
||||
test('should be able to cleanup expired history', async t => {
|
||||
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);
|
||||
|
||||
await m.get(DocStorageCronJob).cleanupExpiredHistory();
|
||||
|
||||
count = await db.snapshotHistory.count();
|
||||
t.is(count, 10);
|
||||
|
||||
const example = await db.snapshotHistory.findFirst();
|
||||
t.truthy(example);
|
||||
t.true(example!.expiredAt > new Date());
|
||||
});
|
||||
281
packages/backend/server/src/__tests__/doc/history.spec.ts
Normal file
281
packages/backend/server/src/__tests__/doc/history.spec.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import { TestingModule } from '@nestjs/testing';
|
||||
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, initTestingDB } 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 initTestingDB(db);
|
||||
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: Buffer.from([1, 0]),
|
||||
state: Buffer.from([0]),
|
||||
seq: 0,
|
||||
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('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.getTime(), 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, Buffer.from([1, 1]));
|
||||
});
|
||||
91
packages/backend/server/src/__tests__/doc/renderer.spec.ts
Normal file
91
packages/backend/server/src/__tests__/doc/renderer.spec.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
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 { PrismaClient } from '@prisma/client';
|
||||
import type { TestFn } from 'ava';
|
||||
import ava from 'ava';
|
||||
import request from 'supertest';
|
||||
|
||||
import { DocRendererModule } from '../../core/doc-renderer';
|
||||
import { createTestingApp } from '../utils';
|
||||
|
||||
const test = ava as TestFn<{
|
||||
app: INestApplication;
|
||||
db: PrismaClient;
|
||||
}>;
|
||||
|
||||
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('init selfhost server', async t => {
|
||||
const staticPath = new Package('@affine/server').join('static').value;
|
||||
initTestStaticFiles(staticPath);
|
||||
|
||||
const { app } = await createTestingApp({
|
||||
imports: [DocRendererModule],
|
||||
});
|
||||
|
||||
t.context.app = app;
|
||||
t.context.db = t.context.app.get(PrismaClient);
|
||||
});
|
||||
|
||||
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"></script>`
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test('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"></script>`
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test.todo('should render correct page preview');
|
||||
@@ -0,0 +1,5 @@
|
||||
import test from 'ava';
|
||||
|
||||
test('should test through userspace', t => {
|
||||
t.pass();
|
||||
});
|
||||
306
packages/backend/server/src/__tests__/doc/workspace.spec.ts
Normal file
306
packages/backend/server/src/__tests__/doc/workspace.spec.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
import { TestingModule } from '@nestjs/testing';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import test from 'ava';
|
||||
import * as Sinon from 'sinon';
|
||||
import { applyUpdate, Doc as YDoc, encodeStateAsUpdate } from 'yjs';
|
||||
|
||||
import { ConfigModule } from '../../base/config';
|
||||
import {
|
||||
DocStorageModule,
|
||||
PgWorkspaceDocStorageAdapter as Adapter,
|
||||
} from '../../core/doc';
|
||||
import { createTestingModule, initTestingDB } from '../utils';
|
||||
|
||||
let m: TestingModule;
|
||||
let db: PrismaClient;
|
||||
let adapter: Adapter;
|
||||
|
||||
test.before('init testing module', async () => {
|
||||
m = await createTestingModule({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
doc: {
|
||||
manager: {
|
||||
enableUpdateAutoMerging: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
DocStorageModule,
|
||||
],
|
||||
});
|
||||
db = m.get(PrismaClient);
|
||||
adapter = m.get(Adapter);
|
||||
// @ts-expect-error private method
|
||||
Sinon.stub(adapter, 'createDocHistory');
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await initTestingDB(db);
|
||||
});
|
||||
|
||||
test.after.always(async () => {
|
||||
await m?.close();
|
||||
});
|
||||
|
||||
/**
|
||||
* @deprecated `seq` would be removed
|
||||
*/
|
||||
test('should have sequential update number', 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.pushDocUpdates('2', '2', updates);
|
||||
|
||||
// [1,2,3]
|
||||
let records = await db.update.findMany({
|
||||
where: {
|
||||
workspaceId: '2',
|
||||
id: '2',
|
||||
},
|
||||
});
|
||||
|
||||
t.deepEqual(
|
||||
records.map(({ seq }) => seq),
|
||||
[1, 2, 3]
|
||||
);
|
||||
|
||||
// merge
|
||||
await adapter.getDoc('2', '2');
|
||||
|
||||
// fake the seq num is about to overflow
|
||||
await db.snapshot.update({
|
||||
where: {
|
||||
workspaceId_id: {
|
||||
id: '2',
|
||||
workspaceId: '2',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
seq: 0x3ffffffe,
|
||||
},
|
||||
});
|
||||
|
||||
await adapter.pushDocUpdates('2', '2', updates);
|
||||
|
||||
records = await db.update.findMany({
|
||||
where: {
|
||||
workspaceId: '2',
|
||||
id: '2',
|
||||
},
|
||||
});
|
||||
|
||||
t.deepEqual(
|
||||
records.map(({ seq }) => seq),
|
||||
[0x3ffffffe + 1, 0x3ffffffe + 2, 0x3ffffffe + 3]
|
||||
);
|
||||
|
||||
// push a new update with new seq num
|
||||
await adapter.pushDocUpdates('2', '2', updates.slice(0, 1));
|
||||
|
||||
// let the manager ignore update with the new seq num
|
||||
// @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();
|
||||
|
||||
// should not merge in one run
|
||||
t.not(await db.update.count(), 0);
|
||||
});
|
||||
|
||||
test('should retry if failed to insert updates', async t => {
|
||||
const stub = Sinon.stub();
|
||||
const createMany = db.update.createMany;
|
||||
db.update.createMany = stub;
|
||||
|
||||
stub.onCall(0).rejects(new Error());
|
||||
stub.onCall(1).resolves();
|
||||
|
||||
await t.notThrowsAsync(() =>
|
||||
adapter.pushDocUpdates('1', '1', [Buffer.from([0, 0])])
|
||||
);
|
||||
t.is(stub.callCount, 2);
|
||||
|
||||
stub.reset();
|
||||
db.update.createMany = createMany;
|
||||
});
|
||||
|
||||
test('should throw if meet max retry times', async t => {
|
||||
const stub = Sinon.stub();
|
||||
const createMany = db.update.createMany;
|
||||
db.update.createMany = stub;
|
||||
|
||||
stub.rejects(new Error());
|
||||
|
||||
await t.throwsAsync(
|
||||
() => adapter.pushDocUpdates('1', '1', [Buffer.from([0, 0])]),
|
||||
{ message: 'Failed to store doc updates.' }
|
||||
);
|
||||
t.is(stub.callCount, 4);
|
||||
|
||||
stub.reset();
|
||||
db.update.createMany = createMany;
|
||||
});
|
||||
|
||||
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',
|
||||
public: false,
|
||||
},
|
||||
});
|
||||
|
||||
await db.update.createMany({
|
||||
data: [
|
||||
{
|
||||
id: '1',
|
||||
workspaceId: '1',
|
||||
blob: Buffer.from(update),
|
||||
seq: 1,
|
||||
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,
|
||||
seq: 2,
|
||||
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.pushDocUpdates('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.pushDocUpdates('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 not update snapshot if doc is outdated', 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.pushDocUpdates('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.pushDocUpdates('2', '1', updates.slice(2)); // 'hello world!'
|
||||
const { bin } = (await adapter.getDoc('2', '1'))!;
|
||||
|
||||
// all updated will merged into doc not matter it's timestamp is outdated or not,
|
||||
// but the snapshot record will not be updated
|
||||
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);
|
||||
// the snapshot will not get touched if the new doc's timestamp is outdated
|
||||
t.is(doc.getText('content').toString(), 'helloworld');
|
||||
|
||||
// the updates are known as outdated, so they will be deleted
|
||||
t.is(await db.update.count(), 0);
|
||||
}
|
||||
});
|
||||
176
packages/backend/server/src/__tests__/feature.spec.ts
Normal file
176
packages/backend/server/src/__tests__/feature.spec.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/// <reference types="../global.d.ts" />
|
||||
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import type { TestFn } from 'ava';
|
||||
import ava from 'ava';
|
||||
|
||||
import { Runtime } from '../base';
|
||||
import { AuthService } from '../core/auth/service';
|
||||
import {
|
||||
FeatureManagementService,
|
||||
FeatureModule,
|
||||
FeatureService,
|
||||
FeatureType,
|
||||
} from '../core/features';
|
||||
import { WorkspaceResolver } from '../core/workspaces/resolvers';
|
||||
import { createTestingApp } from './utils';
|
||||
import { WorkspaceResolverMock } from './utils/feature';
|
||||
|
||||
const test = ava as TestFn<{
|
||||
auth: AuthService;
|
||||
feature: FeatureService;
|
||||
workspace: WorkspaceResolver;
|
||||
management: FeatureManagementService;
|
||||
app: INestApplication;
|
||||
}>;
|
||||
|
||||
test.beforeEach(async t => {
|
||||
const { app } = await createTestingApp({
|
||||
imports: [FeatureModule],
|
||||
providers: [WorkspaceResolver],
|
||||
tapModule: module => {
|
||||
module
|
||||
.overrideProvider(WorkspaceResolver)
|
||||
.useClass(WorkspaceResolverMock);
|
||||
},
|
||||
});
|
||||
|
||||
const runtime = app.get(Runtime);
|
||||
await runtime.set('flags/earlyAccessControl', true);
|
||||
t.context.app = app;
|
||||
t.context.auth = app.get(AuthService);
|
||||
t.context.feature = app.get(FeatureService);
|
||||
t.context.workspace = app.get(WorkspaceResolver);
|
||||
t.context.management = app.get(FeatureManagementService);
|
||||
});
|
||||
|
||||
test.afterEach.always(async t => {
|
||||
await t.context.app.close();
|
||||
});
|
||||
|
||||
test('should be able to set user feature', async t => {
|
||||
const { auth, feature } = t.context;
|
||||
|
||||
const u1 = await auth.signUp('test@test.com', '123456');
|
||||
|
||||
const f1 = await feature.getUserFeatures(u1.id);
|
||||
t.is(f1.length, 0, 'should be empty');
|
||||
|
||||
await feature.addUserFeature(u1.id, FeatureType.EarlyAccess, 'test');
|
||||
|
||||
const f2 = await feature.getUserFeatures(u1.id);
|
||||
t.is(f2.length, 1, 'should have 1 feature');
|
||||
t.is(f2[0].feature.name, FeatureType.EarlyAccess, 'should be early access');
|
||||
});
|
||||
|
||||
test('should be able to check early access', async t => {
|
||||
const { auth, feature, management } = t.context;
|
||||
const u1 = await auth.signUp('test@test.com', '123456');
|
||||
|
||||
const f1 = await management.canEarlyAccess(u1.email);
|
||||
t.false(f1, 'should not have early access');
|
||||
|
||||
await management.addEarlyAccess(u1.id);
|
||||
const f2 = await management.canEarlyAccess(u1.email);
|
||||
t.true(f2, 'should have early access');
|
||||
|
||||
const f3 = await feature.listUsersByFeature(FeatureType.EarlyAccess);
|
||||
t.is(f3.length, 1, 'should have 1 user');
|
||||
t.is(f3[0].id, u1.id, 'should be the same user');
|
||||
});
|
||||
|
||||
test('should be able revert user feature', async t => {
|
||||
const { auth, feature, management } = t.context;
|
||||
const u1 = await auth.signUp('test@test.com', '123456');
|
||||
|
||||
const f1 = await management.canEarlyAccess(u1.email);
|
||||
t.false(f1, 'should not have early access');
|
||||
|
||||
await management.addEarlyAccess(u1.id);
|
||||
const f2 = await management.canEarlyAccess(u1.email);
|
||||
t.true(f2, 'should have early access');
|
||||
const q1 = await management.listEarlyAccess();
|
||||
t.is(q1.length, 1, 'should have 1 user');
|
||||
t.is(q1[0].id, u1.id, 'should be the same user');
|
||||
|
||||
await management.removeEarlyAccess(u1.id);
|
||||
const f3 = await management.canEarlyAccess(u1.email);
|
||||
t.false(f3, 'should not have early access');
|
||||
const q2 = await management.listEarlyAccess();
|
||||
t.is(q2.length, 0, 'should have no user');
|
||||
|
||||
const q3 = await feature.getUserFeatures(u1.id);
|
||||
t.is(q3.length, 1, 'should have 1 feature');
|
||||
t.is(q3[0].feature.name, FeatureType.EarlyAccess, 'should be early access');
|
||||
t.is(q3[0].activated, false, 'should be deactivated');
|
||||
});
|
||||
|
||||
test('should be same instance after reset the user feature', async t => {
|
||||
const { auth, feature, management } = t.context;
|
||||
const u1 = await auth.signUp('test@test.com', '123456');
|
||||
|
||||
await management.addEarlyAccess(u1.id);
|
||||
const f1 = (await feature.getUserFeatures(u1.id))[0];
|
||||
|
||||
await management.removeEarlyAccess(u1.id);
|
||||
|
||||
await management.addEarlyAccess(u1.id);
|
||||
const f2 = (await feature.getUserFeatures(u1.id))[1];
|
||||
|
||||
t.is(f1.feature, f2.feature, 'should be same instance');
|
||||
});
|
||||
|
||||
test('should be able to set workspace feature', async t => {
|
||||
const { auth, feature, workspace } = t.context;
|
||||
|
||||
const u1 = await auth.signUp('test@test.com', '123456');
|
||||
const w1 = await workspace.createWorkspace(u1, null);
|
||||
|
||||
const f1 = await feature.getWorkspaceFeatures(w1.id);
|
||||
t.is(f1.length, 0, 'should be empty');
|
||||
|
||||
await feature.addWorkspaceFeature(w1.id, FeatureType.Copilot, 'test');
|
||||
|
||||
const f2 = await feature.getWorkspaceFeatures(w1.id);
|
||||
t.is(f2.length, 1, 'should have 1 feature');
|
||||
t.is(f2[0].feature.name, FeatureType.Copilot, 'should be copilot');
|
||||
});
|
||||
|
||||
test('should be able to check workspace feature', async t => {
|
||||
const { auth, feature, workspace, management } = t.context;
|
||||
const u1 = await auth.signUp('test@test.com', '123456');
|
||||
const w1 = await workspace.createWorkspace(u1, null);
|
||||
|
||||
const f1 = await management.hasWorkspaceFeature(w1.id, FeatureType.Copilot);
|
||||
t.false(f1, 'should not have copilot');
|
||||
|
||||
await management.addWorkspaceFeatures(w1.id, FeatureType.Copilot, 'test');
|
||||
const f2 = await management.hasWorkspaceFeature(w1.id, FeatureType.Copilot);
|
||||
t.true(f2, 'should have copilot');
|
||||
|
||||
const f3 = await feature.listWorkspacesByFeature(FeatureType.Copilot);
|
||||
t.is(f3.length, 1, 'should have 1 workspace');
|
||||
t.is(f3[0].id, w1.id, 'should be the same workspace');
|
||||
});
|
||||
|
||||
test('should be able revert workspace feature', async t => {
|
||||
const { auth, feature, workspace, management } = t.context;
|
||||
const u1 = await auth.signUp('test@test.com', '123456');
|
||||
const w1 = await workspace.createWorkspace(u1, null);
|
||||
|
||||
const f1 = await management.hasWorkspaceFeature(w1.id, FeatureType.Copilot);
|
||||
t.false(f1, 'should not have feature');
|
||||
|
||||
await management.addWorkspaceFeatures(w1.id, FeatureType.Copilot, 'test');
|
||||
const f2 = await management.hasWorkspaceFeature(w1.id, FeatureType.Copilot);
|
||||
t.true(f2, 'should have feature');
|
||||
|
||||
await management.removeWorkspaceFeature(w1.id, FeatureType.Copilot);
|
||||
const f3 = await management.hasWorkspaceFeature(w1.id, FeatureType.Copilot);
|
||||
t.false(f3, 'should not have feature');
|
||||
|
||||
const q3 = await feature.getWorkspaceFeatures(w1.id);
|
||||
t.is(q3.length, 1, 'should have 1 feature');
|
||||
t.is(q3[0].feature.name, FeatureType.Copilot, 'should be copilot');
|
||||
t.is(q3[0].activated, false, 'should be deactivated');
|
||||
});
|
||||
54
packages/backend/server/src/__tests__/mailer.e2e.ts
Normal file
54
packages/backend/server/src/__tests__/mailer.e2e.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/// <reference types="../global.d.ts" />
|
||||
// This test case is for testing the mailer service.
|
||||
// Please use local SMTP server for testing.
|
||||
// See: https://github.com/mailhog/MailHog
|
||||
import {
|
||||
getCurrentMailMessageCount,
|
||||
getLatestMailMessage,
|
||||
} from '@affine-test/kit/utils/cloud';
|
||||
import { TestingModule } from '@nestjs/testing';
|
||||
import type { TestFn } from 'ava';
|
||||
import ava from 'ava';
|
||||
|
||||
import { ConfigModule } from '../base/config';
|
||||
import { AuthService } from '../core/auth/service';
|
||||
import { createTestingModule } from './utils';
|
||||
|
||||
const test = ava as TestFn<{
|
||||
auth: AuthService;
|
||||
module: TestingModule;
|
||||
skip: boolean;
|
||||
}>;
|
||||
|
||||
test.beforeEach(async t => {
|
||||
t.context.module = await createTestingModule({
|
||||
imports: [ConfigModule.forRoot({})],
|
||||
});
|
||||
t.context.auth = t.context.module.get(AuthService);
|
||||
});
|
||||
|
||||
test.afterEach.always(async t => {
|
||||
await t.context.module.close();
|
||||
});
|
||||
|
||||
test('should include callbackUrl in sending email', async t => {
|
||||
const { auth } = t.context;
|
||||
await auth.signUp('test@affine.pro', '123456');
|
||||
for (const fn of [
|
||||
'sendSetPasswordEmail',
|
||||
'sendChangeEmail',
|
||||
'sendChangePasswordEmail',
|
||||
'sendVerifyChangeEmail',
|
||||
] as const) {
|
||||
const prev = await getCurrentMailMessageCount();
|
||||
await auth[fn]('test@affine.pro', 'https://test.com/callback');
|
||||
const current = await getCurrentMailMessageCount();
|
||||
const mail = await getLatestMailMessage();
|
||||
t.regex(
|
||||
mail?.Content?.Body,
|
||||
/https:\/\/test.com\/callback/,
|
||||
`should include callbackUrl when calling ${fn}`
|
||||
);
|
||||
t.is(current, prev + 1, `calling ${fn}`);
|
||||
}
|
||||
});
|
||||
61
packages/backend/server/src/__tests__/mailer.spec.ts
Normal file
61
packages/backend/server/src/__tests__/mailer.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import type { TestFn } from 'ava';
|
||||
import ava from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import { AppModule } from '../app.module';
|
||||
import { MailService } from '../base/mailer';
|
||||
import { FeatureManagementService } from '../core/features';
|
||||
import { createTestingApp, createWorkspace, inviteUser, signUp } from './utils';
|
||||
const test = ava as TestFn<{
|
||||
app: INestApplication;
|
||||
mail: MailService;
|
||||
}>;
|
||||
|
||||
test.beforeEach(async t => {
|
||||
const { module, app } = await createTestingApp({
|
||||
imports: [AppModule],
|
||||
tapModule: module => {
|
||||
module.overrideProvider(FeatureManagementService).useValue({
|
||||
hasWorkspaceFeature() {
|
||||
return false;
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const mail = module.get(MailService);
|
||||
t.context.app = app;
|
||||
t.context.mail = mail;
|
||||
});
|
||||
|
||||
test.afterEach.always(async t => {
|
||||
await t.context.app.close();
|
||||
});
|
||||
|
||||
test('should send invite email', async t => {
|
||||
const { mail, app } = t.context;
|
||||
|
||||
if (mail.hasConfigured()) {
|
||||
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
|
||||
const u2 = await signUp(app, 'u2', 'u2@affine.pro', '1');
|
||||
|
||||
const workspace = await createWorkspace(app, u1.token.token);
|
||||
|
||||
const stub = Sinon.stub(mail, 'sendMail');
|
||||
|
||||
await inviteUser(app, u1.token.token, workspace.id, u2.email, true);
|
||||
|
||||
t.true(stub.calledOnce);
|
||||
|
||||
const args = stub.args[0][0];
|
||||
|
||||
t.is(args.to, u2.email);
|
||||
t.true(
|
||||
args.subject!.startsWith(
|
||||
`${u1.name} invited you to join` /* we don't know the name of mocked workspace */
|
||||
)
|
||||
);
|
||||
}
|
||||
t.pass();
|
||||
});
|
||||
301
packages/backend/server/src/__tests__/models/user.spec.ts
Normal file
301
packages/backend/server/src/__tests__/models/user.spec.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { TestingModule } from '@nestjs/testing';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import ava, { TestFn } from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import { EmailAlreadyUsed } from '../../base';
|
||||
import { Permission } from '../../core/permission';
|
||||
import { UserModel } from '../../models/user';
|
||||
import { createTestingModule, initTestingDB } from '../utils';
|
||||
|
||||
interface Context {
|
||||
module: TestingModule;
|
||||
user: UserModel;
|
||||
}
|
||||
|
||||
const test = ava as TestFn<Context>;
|
||||
|
||||
test.before(async t => {
|
||||
const module = await createTestingModule({
|
||||
providers: [UserModel],
|
||||
});
|
||||
|
||||
t.context.user = module.get(UserModel);
|
||||
t.context.module = module;
|
||||
});
|
||||
|
||||
test.beforeEach(async t => {
|
||||
await initTestingDB(t.context.module.get(PrismaClient));
|
||||
});
|
||||
|
||||
test.after(async t => {
|
||||
await t.context.module.close();
|
||||
});
|
||||
|
||||
test('should create a new user', async t => {
|
||||
const user = await t.context.user.create({
|
||||
email: 'test@affine.pro',
|
||||
});
|
||||
|
||||
t.is(user.email, 'test@affine.pro');
|
||||
|
||||
const user2 = await t.context.user.getUserByEmail('test@affine.pro');
|
||||
|
||||
t.not(user2, null);
|
||||
t.is(user2!.email, 'test@affine.pro');
|
||||
});
|
||||
|
||||
test('should trigger user.created event', async t => {
|
||||
const event = t.context.module.get(EventEmitter2);
|
||||
const spy = Sinon.spy();
|
||||
event.on('user.created', spy);
|
||||
|
||||
const user = await t.context.user.create({
|
||||
email: 'test@affine.pro',
|
||||
});
|
||||
|
||||
t.true(spy.calledOnceWithExactly(user));
|
||||
});
|
||||
|
||||
test('should sign in user with password', async t => {
|
||||
const user = await t.context.user.create({
|
||||
email: 'test@affine.pro',
|
||||
password: 'password',
|
||||
});
|
||||
|
||||
const signedInUser = await t.context.user.signIn(user.email, 'password');
|
||||
|
||||
t.is(signedInUser.id, user.id);
|
||||
// Password is encrypted
|
||||
t.not(signedInUser.password, 'password');
|
||||
});
|
||||
|
||||
test('should update an user', async t => {
|
||||
const user = await t.context.user.create({
|
||||
email: 'test@affine.pro',
|
||||
});
|
||||
|
||||
const user2 = await t.context.user.update(user.id, {
|
||||
email: 'test2@affine.pro',
|
||||
});
|
||||
|
||||
t.is(user2.email, 'test2@affine.pro');
|
||||
});
|
||||
|
||||
test('should update password', async t => {
|
||||
const user = await t.context.user.create({
|
||||
email: 'test@affine.pro',
|
||||
password: 'password',
|
||||
});
|
||||
|
||||
const updatedUser = await t.context.user.update(user.id, {
|
||||
password: 'new password',
|
||||
});
|
||||
|
||||
t.not(updatedUser.password, user.password);
|
||||
// password is encrypted
|
||||
t.not(updatedUser.password, 'new password');
|
||||
});
|
||||
|
||||
test('should not update email to an existing one', async t => {
|
||||
const user = await t.context.user.create({
|
||||
email: 'test@affine.pro',
|
||||
});
|
||||
const user2 = await t.context.user.create({
|
||||
email: 'test2@affine.pro',
|
||||
});
|
||||
|
||||
await t.throwsAsync(
|
||||
() =>
|
||||
t.context.user.update(user.id, {
|
||||
email: user2.email,
|
||||
}),
|
||||
{
|
||||
instanceOf: EmailAlreadyUsed,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('should trigger user.updated event', async t => {
|
||||
const event = t.context.module.get(EventEmitter2);
|
||||
const spy = Sinon.spy();
|
||||
event.on('user.updated', spy);
|
||||
|
||||
const user = await t.context.user.create({
|
||||
email: 'test@affine.pro',
|
||||
});
|
||||
|
||||
const updatedUser = await t.context.user.update(user.id, {
|
||||
email: 'test2@affine.pro',
|
||||
name: 'new name',
|
||||
});
|
||||
|
||||
t.true(spy.calledOnceWithExactly(updatedUser));
|
||||
});
|
||||
|
||||
test('should get user by id', async t => {
|
||||
const user = await t.context.user.create({
|
||||
email: 'test@affine.pro',
|
||||
});
|
||||
|
||||
const user2 = await t.context.user.get(user.id);
|
||||
|
||||
t.not(user2, null);
|
||||
t.is(user2!.id, user.id);
|
||||
});
|
||||
|
||||
test('should get public user by id', async t => {
|
||||
const user = await t.context.user.create({
|
||||
email: 'test@affine.pro',
|
||||
});
|
||||
|
||||
const publicUser = await t.context.user.getPublicUser(user.id);
|
||||
|
||||
t.not(publicUser, null);
|
||||
t.is(publicUser!.id, user.id);
|
||||
t.true(!('password' in publicUser!));
|
||||
});
|
||||
|
||||
test('should get public user by email', async t => {
|
||||
const user = await t.context.user.create({
|
||||
email: 'test@affine.pro',
|
||||
});
|
||||
|
||||
const publicUser = await t.context.user.getPublicUserByEmail(user.email);
|
||||
|
||||
t.not(publicUser, null);
|
||||
t.is(publicUser!.id, user.id);
|
||||
t.true(!('password' in publicUser!));
|
||||
});
|
||||
|
||||
test('should get user by email', async t => {
|
||||
const user = await t.context.user.create({
|
||||
email: 'test@affine.pro',
|
||||
});
|
||||
|
||||
const user2 = await t.context.user.getUserByEmail(user.email);
|
||||
|
||||
t.not(user2, null);
|
||||
t.is(user2!.id, user.id);
|
||||
});
|
||||
|
||||
test('should ignore case when getting user by email', async t => {
|
||||
const user = await t.context.user.create({
|
||||
email: 'test@affine.pro',
|
||||
});
|
||||
|
||||
const user2 = await t.context.user.getUserByEmail('TEST@affine.pro');
|
||||
|
||||
t.not(user2, null);
|
||||
t.is(user2!.id, user.id);
|
||||
});
|
||||
|
||||
test('should return null for non existing user', async t => {
|
||||
const user = await t.context.user.getUserByEmail('test@affine.pro');
|
||||
|
||||
t.is(user, null);
|
||||
});
|
||||
|
||||
test('should fulfill user', async t => {
|
||||
let user = await t.context.user.create({
|
||||
email: 'test@affine.pro',
|
||||
registered: false,
|
||||
});
|
||||
|
||||
t.is(user.registered, false);
|
||||
t.is(user.emailVerifiedAt, null);
|
||||
|
||||
user = await t.context.user.fulfill(user.email);
|
||||
|
||||
t.is(user.registered, true);
|
||||
t.not(user.emailVerifiedAt, null);
|
||||
|
||||
const user2 = await t.context.user.fulfill('test2@affine.pro');
|
||||
|
||||
t.is(user2.registered, true);
|
||||
t.not(user2.emailVerifiedAt, null);
|
||||
});
|
||||
|
||||
test('should trigger user.updated event when fulfilling user', async t => {
|
||||
const event = t.context.module.get(EventEmitter2);
|
||||
const createSpy = Sinon.spy();
|
||||
const updateSpy = Sinon.spy();
|
||||
event.on('user.created', createSpy);
|
||||
event.on('user.updated', updateSpy);
|
||||
|
||||
const user2 = await t.context.user.fulfill('test2@affine.pro');
|
||||
|
||||
t.true(createSpy.calledOnceWithExactly(user2));
|
||||
|
||||
let user = await t.context.user.create({
|
||||
email: 'test@affine.pro',
|
||||
registered: false,
|
||||
});
|
||||
|
||||
user = await t.context.user.fulfill(user.email);
|
||||
|
||||
t.true(updateSpy.calledOnceWithExactly(user));
|
||||
});
|
||||
|
||||
test('should delete user', async t => {
|
||||
const user = await t.context.user.create({
|
||||
email: 'test@affine.pro',
|
||||
});
|
||||
|
||||
await t.context.user.delete(user.id);
|
||||
|
||||
const user2 = await t.context.user.get(user.id);
|
||||
|
||||
t.is(user2, null);
|
||||
});
|
||||
|
||||
test('should trigger user.deleted event', async t => {
|
||||
const event = t.context.module.get(EventEmitter2);
|
||||
const spy = Sinon.spy();
|
||||
event.on('user.deleted', spy);
|
||||
|
||||
const user = await t.context.user.create({
|
||||
email: 'test@affine.pro',
|
||||
workspacePermissions: {
|
||||
create: {
|
||||
workspace: {
|
||||
create: {
|
||||
id: 'test-workspace',
|
||||
public: false,
|
||||
},
|
||||
},
|
||||
type: Permission.Owner,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await t.context.user.delete(user.id);
|
||||
|
||||
t.true(
|
||||
spy.calledOnceWithExactly({ ...user, ownedWorkspaces: ['test-workspace'] })
|
||||
);
|
||||
});
|
||||
|
||||
test('should paginate users', async t => {
|
||||
const db = t.context.module.get(PrismaClient);
|
||||
const now = Date.now();
|
||||
await Promise.all(
|
||||
Array.from({ length: 100 }).map((_, i) =>
|
||||
db.user.create({
|
||||
data: {
|
||||
name: `test${i}`,
|
||||
email: `test${i}@affine.pro`,
|
||||
createdAt: new Date(now + i),
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const users = await t.context.user.pagination(0, 10);
|
||||
t.is(users.length, 10);
|
||||
t.deepEqual(
|
||||
users.map(user => user.email),
|
||||
Array.from({ length: 10 }).map((_, i) => `test${i}@affine.pro`)
|
||||
);
|
||||
});
|
||||
81
packages/backend/server/src/__tests__/mutex.spec.ts
Normal file
81
packages/backend/server/src/__tests__/mutex.spec.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { TestingModule } from '@nestjs/testing';
|
||||
import ava, { TestFn } from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import { Locker, Mutex } from '../base/mutex';
|
||||
import { SessionRedis } from '../base/redis';
|
||||
import { createTestingModule, sleep } from './utils';
|
||||
|
||||
const test = ava as TestFn<{
|
||||
module: TestingModule;
|
||||
mutex: Mutex;
|
||||
locker: Locker;
|
||||
session: SessionRedis;
|
||||
}>;
|
||||
|
||||
test.beforeEach(async t => {
|
||||
const module = await createTestingModule();
|
||||
|
||||
t.context.module = module;
|
||||
t.context.mutex = module.get(Mutex);
|
||||
t.context.locker = module.get(Locker);
|
||||
t.context.session = module.get(SessionRedis);
|
||||
});
|
||||
|
||||
test.afterEach(async t => {
|
||||
await t.context.module.close();
|
||||
});
|
||||
|
||||
const lockerPrefix = randomUUID();
|
||||
test('should be able to acquire lock', async t => {
|
||||
const { mutex } = t.context;
|
||||
|
||||
{
|
||||
t.truthy(
|
||||
await mutex.acquire(`${lockerPrefix}1`),
|
||||
'should be able to acquire lock'
|
||||
);
|
||||
t.falsy(
|
||||
await mutex.acquire(`${lockerPrefix}1`),
|
||||
'should not be able to acquire lock again'
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const lock1 = await mutex.acquire(`${lockerPrefix}2`);
|
||||
t.truthy(lock1);
|
||||
await lock1?.release();
|
||||
const lock2 = await mutex.acquire(`${lockerPrefix}2`);
|
||||
t.truthy(lock2);
|
||||
}
|
||||
});
|
||||
|
||||
test('should be able to acquire lock parallel', async t => {
|
||||
const { mutex, locker } = t.context;
|
||||
const spyedLocker = Sinon.spy(locker, 'lock');
|
||||
const requestLock = async (key: string) => {
|
||||
const lock = mutex.acquire(key);
|
||||
await using _lock = await lock;
|
||||
const lastCall = spyedLocker.lastCall.returnValue;
|
||||
try {
|
||||
// in rare cases, the lock can be acquired
|
||||
// in which case skip the error message check
|
||||
await lastCall;
|
||||
} catch {
|
||||
await t.throwsAsync(lastCall, {
|
||||
message: `Failed to acquire lock for resource [${key}]`,
|
||||
});
|
||||
}
|
||||
|
||||
await sleep(100);
|
||||
};
|
||||
|
||||
await t.notThrowsAsync(
|
||||
Promise.all(
|
||||
Array.from({ length: 10 }, _ => requestLock(`${lockerPrefix}3`))
|
||||
),
|
||||
'should be able to acquire lock parallel'
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
import {
|
||||
applyDecorators,
|
||||
Controller,
|
||||
Get,
|
||||
HttpStatus,
|
||||
INestApplication,
|
||||
Logger,
|
||||
LoggerService,
|
||||
} from '@nestjs/common';
|
||||
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
import {
|
||||
SubscribeMessage as RawSubscribeMessage,
|
||||
WebSocketGateway,
|
||||
} from '@nestjs/websockets';
|
||||
import testFn, { TestFn } from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
import request from 'supertest';
|
||||
|
||||
import {
|
||||
AccessDenied,
|
||||
GatewayErrorWrapper,
|
||||
UserFriendlyError,
|
||||
} from '../../base';
|
||||
import { Public } from '../../core/auth';
|
||||
import { createTestingApp } from '../utils';
|
||||
|
||||
@Public()
|
||||
@Resolver(() => String)
|
||||
class TestResolver {
|
||||
greating = 'hello world';
|
||||
|
||||
@Query(() => String)
|
||||
hello() {
|
||||
return this.greating;
|
||||
}
|
||||
|
||||
@Mutation(() => String)
|
||||
update(@Args('greating') greating: string) {
|
||||
this.greating = greating;
|
||||
return this.greating;
|
||||
}
|
||||
|
||||
@Query(() => String)
|
||||
errorQuery() {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
|
||||
@Query(() => String)
|
||||
unknownErrorQuery() {
|
||||
throw new Error('unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Controller('/')
|
||||
class TestController {
|
||||
@Get('/ok')
|
||||
ok() {
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
@Get('/throw-known-error')
|
||||
throwKnownError() {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
|
||||
@Get('/throw-unknown-error')
|
||||
throwUnknownError() {
|
||||
throw new Error('Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
const SubscribeMessage = (event: string) =>
|
||||
applyDecorators(GatewayErrorWrapper(event), RawSubscribeMessage(event));
|
||||
|
||||
@WebSocketGateway({ transports: ['websocket'], path: '/ws' })
|
||||
class TestGateway {
|
||||
@SubscribeMessage('event:ok')
|
||||
async ok() {
|
||||
return {
|
||||
data: 'ok',
|
||||
};
|
||||
}
|
||||
|
||||
@SubscribeMessage('event:throw-known-error')
|
||||
async throwKnownError() {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
|
||||
@SubscribeMessage('event:throw-unknown-error')
|
||||
async throwUnknownError() {
|
||||
throw new Error('Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
const test = testFn as TestFn<{
|
||||
app: INestApplication;
|
||||
logger: Sinon.SinonStubbedInstance<LoggerService>;
|
||||
}>;
|
||||
|
||||
function gql(app: INestApplication, query: string) {
|
||||
return request(app.getHttpServer())
|
||||
.post('/graphql')
|
||||
.send({ query })
|
||||
.expect(200);
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
const { app } = await createTestingApp({
|
||||
providers: [TestResolver, TestGateway],
|
||||
controllers: [TestController],
|
||||
});
|
||||
|
||||
context.logger = Sinon.stub(new Logger().localInstance);
|
||||
|
||||
context.app = app;
|
||||
});
|
||||
|
||||
test.afterEach.always(async ctx => {
|
||||
await ctx.context.app.close();
|
||||
});
|
||||
|
||||
test('should be able to execute query', async t => {
|
||||
const res = await gql(t.context.app, `query { hello }`);
|
||||
t.is(res.body.data.hello, 'hello world');
|
||||
});
|
||||
|
||||
test('should be able to handle known user error in graphql query', async t => {
|
||||
const res = await gql(t.context.app, `query { errorQuery }`);
|
||||
const err = res.body.errors[0];
|
||||
t.is(err.message, 'You do not have permission to access this resource.');
|
||||
t.is(err.extensions.status, HttpStatus.FORBIDDEN);
|
||||
t.is(err.extensions.name, 'ACCESS_DENIED');
|
||||
t.true(t.context.logger.error.notCalled);
|
||||
});
|
||||
|
||||
test('should be able to handle unknown internal error in graphql query', async t => {
|
||||
const res = await gql(t.context.app, `query { unknownErrorQuery }`);
|
||||
const err = res.body.errors[0];
|
||||
t.is(err.message, 'An internal error occurred.');
|
||||
t.is(err.extensions.status, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
t.is(err.extensions.name, 'INTERNAL_SERVER_ERROR');
|
||||
t.true(t.context.logger.error.calledOnceWith('Internal server error'));
|
||||
});
|
||||
|
||||
test('should be able to respond request', async t => {
|
||||
const res = await request(t.context.app.getHttpServer())
|
||||
.get('/ok')
|
||||
.expect(200);
|
||||
t.is(res.text, 'ok');
|
||||
});
|
||||
|
||||
test('should be able to handle known user error in http request', async t => {
|
||||
const res = await request(t.context.app.getHttpServer())
|
||||
.get('/throw-known-error')
|
||||
.expect(HttpStatus.FORBIDDEN);
|
||||
|
||||
t.is(res.body.message, 'You do not have permission to access this resource.');
|
||||
t.is(res.body.name, 'ACCESS_DENIED');
|
||||
t.true(t.context.logger.error.notCalled);
|
||||
});
|
||||
|
||||
test('should be able to handle unknown internal error in http request', async t => {
|
||||
const res = await request(t.context.app.getHttpServer())
|
||||
.get('/throw-unknown-error')
|
||||
.expect(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
t.is(res.body.message, 'An internal error occurred.');
|
||||
t.is(res.body.name, 'INTERNAL_SERVER_ERROR');
|
||||
t.true(t.context.logger.error.calledOnceWith('Internal server error'));
|
||||
});
|
||||
|
||||
// Hard to test through websocket, will call event handler directly
|
||||
test('should be able to response websocket event', async t => {
|
||||
const gateway = t.context.app.get(TestGateway);
|
||||
|
||||
const res = await gateway.ok();
|
||||
t.is(res.data, 'ok');
|
||||
});
|
||||
|
||||
test('should be able to handle known user error in websocket event', async t => {
|
||||
const gateway = t.context.app.get(TestGateway);
|
||||
|
||||
const { error } = (await gateway.throwKnownError()) as unknown as {
|
||||
error: UserFriendlyError;
|
||||
};
|
||||
t.is(error.message, 'You do not have permission to access this resource.');
|
||||
t.is(error.name, 'ACCESS_DENIED');
|
||||
t.true(t.context.logger.error.notCalled);
|
||||
});
|
||||
|
||||
test('should be able to handle unknown internal error in websocket event', async t => {
|
||||
const gateway = t.context.app.get(TestGateway);
|
||||
|
||||
const { error } = (await gateway.throwUnknownError()) as unknown as {
|
||||
error: UserFriendlyError;
|
||||
};
|
||||
t.is(error.message, 'An internal error occurred.');
|
||||
t.is(error.name, 'INTERNAL_SERVER_ERROR');
|
||||
t.true(t.context.logger.error.calledOnceWith('Internal server error'));
|
||||
});
|
||||
361
packages/backend/server/src/__tests__/nestjs/throttler.spec.ts
Normal file
361
packages/backend/server/src/__tests__/nestjs/throttler.spec.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
import '../../plugins/config';
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
HttpStatus,
|
||||
INestApplication,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import ava, { TestFn } from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
import request, { type Response } from 'supertest';
|
||||
|
||||
import { AppModule } from '../../app.module';
|
||||
import { ConfigModule } from '../../base/config';
|
||||
import {
|
||||
CloudThrottlerGuard,
|
||||
SkipThrottle,
|
||||
Throttle,
|
||||
ThrottlerStorage,
|
||||
} from '../../base/throttler';
|
||||
import { AuthService, Public } from '../../core/auth';
|
||||
import { createTestingApp, initTestingDB, internalSignIn } from '../utils';
|
||||
|
||||
const test = ava as TestFn<{
|
||||
storage: ThrottlerStorage;
|
||||
cookie: string;
|
||||
app: INestApplication;
|
||||
}>;
|
||||
|
||||
@UseGuards(CloudThrottlerGuard)
|
||||
@Throttle()
|
||||
@Controller('/throttled')
|
||||
class ThrottledController {
|
||||
@Get('/default')
|
||||
default() {
|
||||
return 'default';
|
||||
}
|
||||
|
||||
@Get('/default2')
|
||||
default2() {
|
||||
return 'default2';
|
||||
}
|
||||
|
||||
@Get('/default3')
|
||||
@Throttle('default', { limit: 10 })
|
||||
default3() {
|
||||
return 'default3';
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('/authenticated')
|
||||
@Throttle('authenticated')
|
||||
none() {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
@Throttle('strict')
|
||||
@Get('/strict')
|
||||
strict() {
|
||||
return 'strict';
|
||||
}
|
||||
|
||||
@Public()
|
||||
@SkipThrottle()
|
||||
@Get('/skip')
|
||||
skip() {
|
||||
return 'skip';
|
||||
}
|
||||
}
|
||||
|
||||
@UseGuards(CloudThrottlerGuard)
|
||||
@Controller('/nonthrottled')
|
||||
class NonThrottledController {
|
||||
@Public()
|
||||
@SkipThrottle()
|
||||
@Get('/skip')
|
||||
skip() {
|
||||
return 'skip';
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('/default')
|
||||
default() {
|
||||
return 'default';
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Throttle('strict')
|
||||
@Get('/strict')
|
||||
strict() {
|
||||
return 'strict';
|
||||
}
|
||||
}
|
||||
|
||||
test.before(async t => {
|
||||
const { app } = await createTestingApp({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
throttler: {
|
||||
default: {
|
||||
ttl: 60,
|
||||
limit: 120,
|
||||
},
|
||||
},
|
||||
}),
|
||||
AppModule,
|
||||
],
|
||||
controllers: [ThrottledController, NonThrottledController],
|
||||
});
|
||||
|
||||
t.context.storage = app.get(ThrottlerStorage);
|
||||
t.context.app = app;
|
||||
});
|
||||
|
||||
test.beforeEach(async t => {
|
||||
await initTestingDB(t.context.app.get(PrismaClient));
|
||||
const { app } = t.context;
|
||||
const auth = app.get(AuthService);
|
||||
const u1 = await auth.signUp('u1@affine.pro', 'test');
|
||||
t.context.cookie = await internalSignIn(app, u1.id);
|
||||
});
|
||||
|
||||
test.after.always(async t => {
|
||||
await t.context.app.close();
|
||||
});
|
||||
|
||||
function rateLimitHeaders(res: Response) {
|
||||
return {
|
||||
limit: res.header['x-ratelimit-limit'],
|
||||
remaining: res.header['x-ratelimit-remaining'],
|
||||
reset: res.header['x-ratelimit-reset'],
|
||||
retryAfter: res.header['retry-after'],
|
||||
};
|
||||
}
|
||||
|
||||
test('should be able to prevent requests if limit is reached', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const stub = Sinon.stub(app.get(ThrottlerStorage), 'increment').resolves({
|
||||
timeToExpire: 10,
|
||||
totalHits: 21,
|
||||
isBlocked: true,
|
||||
timeToBlockExpire: 10,
|
||||
});
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/nonthrottled/strict')
|
||||
.expect(HttpStatus.TOO_MANY_REQUESTS);
|
||||
|
||||
const headers = rateLimitHeaders(res);
|
||||
|
||||
t.is(headers.retryAfter, '10');
|
||||
|
||||
stub.restore();
|
||||
});
|
||||
|
||||
// ====== unauthenticated user visits ======
|
||||
test('should use default throttler for unauthenticated user when not specified', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/nonthrottled/default')
|
||||
.expect(200);
|
||||
|
||||
const headers = rateLimitHeaders(res);
|
||||
|
||||
t.is(headers.limit, '120');
|
||||
t.is(headers.remaining, '119');
|
||||
});
|
||||
|
||||
test('should skip throttler for unauthenticated user when specified', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
let res = await request(app.getHttpServer())
|
||||
.get('/nonthrottled/skip')
|
||||
.expect(200);
|
||||
|
||||
let headers = rateLimitHeaders(res);
|
||||
|
||||
t.is(headers.limit, undefined!);
|
||||
t.is(headers.remaining, undefined!);
|
||||
t.is(headers.reset, undefined!);
|
||||
|
||||
res = await request(app.getHttpServer()).get('/throttled/skip').expect(200);
|
||||
|
||||
headers = rateLimitHeaders(res);
|
||||
|
||||
t.is(headers.limit, undefined!);
|
||||
t.is(headers.remaining, undefined!);
|
||||
t.is(headers.reset, undefined!);
|
||||
});
|
||||
|
||||
test('should use specified throttler for unauthenticated user', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/nonthrottled/strict')
|
||||
.expect(200);
|
||||
|
||||
const headers = rateLimitHeaders(res);
|
||||
|
||||
t.is(headers.limit, '20');
|
||||
t.is(headers.remaining, '19');
|
||||
});
|
||||
|
||||
// ==== authenticated user visits ====
|
||||
test('should not protect unspecified routes', async t => {
|
||||
const { app, cookie } = t.context;
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/nonthrottled/default')
|
||||
.set('Cookie', cookie)
|
||||
.expect(200);
|
||||
|
||||
const headers = rateLimitHeaders(res);
|
||||
|
||||
t.is(headers.limit, undefined!);
|
||||
t.is(headers.remaining, undefined!);
|
||||
t.is(headers.reset, undefined!);
|
||||
});
|
||||
|
||||
test('should use default throttler for authenticated user when not specified', async t => {
|
||||
const { app, cookie } = t.context;
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/throttled/default')
|
||||
.set('Cookie', cookie)
|
||||
.expect(200);
|
||||
|
||||
const headers = rateLimitHeaders(res);
|
||||
|
||||
t.is(headers.limit, '120');
|
||||
t.is(headers.remaining, '119');
|
||||
});
|
||||
|
||||
test('should use same throttler for multiple routes', async t => {
|
||||
const { app, cookie } = t.context;
|
||||
|
||||
let res = await request(app.getHttpServer())
|
||||
.get('/throttled/default')
|
||||
.set('Cookie', cookie)
|
||||
.expect(200);
|
||||
|
||||
let headers = rateLimitHeaders(res);
|
||||
|
||||
t.is(headers.limit, '120');
|
||||
t.is(headers.remaining, '119');
|
||||
|
||||
res = await request(app.getHttpServer())
|
||||
.get('/throttled/default2')
|
||||
.set('Cookie', cookie)
|
||||
.expect(200);
|
||||
|
||||
headers = rateLimitHeaders(res);
|
||||
|
||||
t.is(headers.limit, '120');
|
||||
t.is(headers.remaining, '118');
|
||||
});
|
||||
|
||||
test('should use different throttler if specified', async t => {
|
||||
const { app, cookie } = t.context;
|
||||
|
||||
let res = await request(app.getHttpServer())
|
||||
.get('/throttled/default')
|
||||
.set('Cookie', cookie)
|
||||
.expect(200);
|
||||
|
||||
let headers = rateLimitHeaders(res);
|
||||
|
||||
t.is(headers.limit, '120');
|
||||
t.is(headers.remaining, '119');
|
||||
|
||||
res = await request(app.getHttpServer())
|
||||
.get('/throttled/default3')
|
||||
.set('Cookie', cookie)
|
||||
.expect(200);
|
||||
|
||||
headers = rateLimitHeaders(res);
|
||||
|
||||
t.is(headers.limit, '10');
|
||||
t.is(headers.remaining, '9');
|
||||
});
|
||||
|
||||
test('should skip throttler for authenticated if `authenticated` throttler used', async t => {
|
||||
const { app, cookie } = t.context;
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/throttled/authenticated')
|
||||
.set('Cookie', cookie)
|
||||
.expect(200);
|
||||
|
||||
const headers = rateLimitHeaders(res);
|
||||
|
||||
t.is(headers.limit, undefined!);
|
||||
t.is(headers.remaining, undefined!);
|
||||
t.is(headers.reset, undefined!);
|
||||
});
|
||||
|
||||
test('should apply `default` throttler for authenticated user if `authenticated` throttler used', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/throttled/authenticated')
|
||||
.expect(200);
|
||||
|
||||
const headers = rateLimitHeaders(res);
|
||||
|
||||
t.is(headers.limit, '120');
|
||||
t.is(headers.remaining, '119');
|
||||
});
|
||||
|
||||
test('should skip throttler for authenticated user when specified', async t => {
|
||||
const { app, cookie } = t.context;
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/throttled/skip')
|
||||
.set('Cookie', cookie)
|
||||
.expect(200);
|
||||
|
||||
const headers = rateLimitHeaders(res);
|
||||
|
||||
t.is(headers.limit, undefined!);
|
||||
t.is(headers.remaining, undefined!);
|
||||
t.is(headers.reset, undefined!);
|
||||
});
|
||||
|
||||
test('should use specified throttler for authenticated user', async t => {
|
||||
const { app, cookie } = t.context;
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/throttled/strict')
|
||||
.set('Cookie', cookie)
|
||||
.expect(200);
|
||||
|
||||
const headers = rateLimitHeaders(res);
|
||||
|
||||
t.is(headers.limit, '20');
|
||||
t.is(headers.remaining, '19');
|
||||
});
|
||||
|
||||
test('should separate anonymous and authenticated user throttlers', async t => {
|
||||
const { app, cookie } = t.context;
|
||||
|
||||
const authenticatedUserRes = await request(app.getHttpServer())
|
||||
.get('/throttled/default')
|
||||
.set('Cookie', cookie)
|
||||
.expect(200);
|
||||
const unauthenticatedUserRes = await request(app.getHttpServer())
|
||||
.get('/nonthrottled/default')
|
||||
.expect(200);
|
||||
|
||||
const authenticatedResHeaders = rateLimitHeaders(authenticatedUserRes);
|
||||
const unauthenticatedResHeaders = rateLimitHeaders(unauthenticatedUserRes);
|
||||
|
||||
t.is(authenticatedResHeaders.limit, '120');
|
||||
t.is(authenticatedResHeaders.remaining, '119');
|
||||
|
||||
t.is(unauthenticatedResHeaders.limit, '120');
|
||||
t.is(unauthenticatedResHeaders.remaining, '119');
|
||||
});
|
||||
339
packages/backend/server/src/__tests__/oauth/controller.spec.ts
Normal file
339
packages/backend/server/src/__tests__/oauth/controller.spec.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
import '../../plugins/config';
|
||||
|
||||
import { HttpStatus, INestApplication } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import ava, { TestFn } from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
import request from 'supertest';
|
||||
|
||||
import { AppModule } from '../../app.module';
|
||||
import { URLHelper } from '../../base';
|
||||
import { ConfigModule } from '../../base/config';
|
||||
import { CurrentUser } from '../../core/auth';
|
||||
import { AuthService } from '../../core/auth/service';
|
||||
import { UserService } from '../../core/user';
|
||||
import { OAuthProviderName } from '../../plugins/oauth/config';
|
||||
import { GoogleOAuthProvider } from '../../plugins/oauth/providers/google';
|
||||
import { OAuthService } from '../../plugins/oauth/service';
|
||||
import { createTestingApp, getSession, initTestingDB } from '../utils';
|
||||
|
||||
const test = ava as TestFn<{
|
||||
auth: AuthService;
|
||||
oauth: OAuthService;
|
||||
user: UserService;
|
||||
u1: CurrentUser;
|
||||
db: PrismaClient;
|
||||
app: INestApplication;
|
||||
}>;
|
||||
|
||||
test.before(async t => {
|
||||
const { app } = await createTestingApp({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
plugins: {
|
||||
oauth: {
|
||||
providers: {
|
||||
google: {
|
||||
clientId: 'google-client-id',
|
||||
clientSecret: 'google-client-secret',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
AppModule,
|
||||
],
|
||||
});
|
||||
|
||||
t.context.auth = app.get(AuthService);
|
||||
t.context.oauth = app.get(OAuthService);
|
||||
t.context.user = app.get(UserService);
|
||||
t.context.db = app.get(PrismaClient);
|
||||
t.context.app = app;
|
||||
});
|
||||
|
||||
test.beforeEach(async t => {
|
||||
Sinon.restore();
|
||||
await initTestingDB(t.context.db);
|
||||
t.context.u1 = await t.context.auth.signUp('u1@affine.pro', '1');
|
||||
});
|
||||
|
||||
test.after.always(async t => {
|
||||
await t.context.app.close();
|
||||
});
|
||||
|
||||
test("should be able to redirect to oauth provider's login page", async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/api/oauth/preflight')
|
||||
.send({ provider: 'Google' })
|
||||
.expect(HttpStatus.OK);
|
||||
|
||||
const { url } = res.body;
|
||||
|
||||
const redirect = new URL(url);
|
||||
t.is(redirect.origin, 'https://accounts.google.com');
|
||||
|
||||
t.is(redirect.pathname, '/o/oauth2/v2/auth');
|
||||
t.is(redirect.searchParams.get('client_id'), 'google-client-id');
|
||||
t.is(
|
||||
redirect.searchParams.get('redirect_uri'),
|
||||
app.get(URLHelper).link('/oauth/callback')
|
||||
);
|
||||
t.is(redirect.searchParams.get('response_type'), 'code');
|
||||
t.is(redirect.searchParams.get('prompt'), 'select_account');
|
||||
t.truthy(redirect.searchParams.get('state'));
|
||||
});
|
||||
|
||||
test('should throw if provider is invalid', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/oauth/preflight')
|
||||
.send({ provider: 'Invalid' })
|
||||
.expect(HttpStatus.BAD_REQUEST)
|
||||
.expect({
|
||||
status: 400,
|
||||
code: 'Bad Request',
|
||||
type: 'INVALID_INPUT',
|
||||
name: 'UNKNOWN_OAUTH_PROVIDER',
|
||||
message: 'Unknown authentication provider Invalid.',
|
||||
data: { name: 'Invalid' },
|
||||
});
|
||||
|
||||
t.pass();
|
||||
});
|
||||
|
||||
test('should be able to save oauth state', async t => {
|
||||
const { oauth } = t.context;
|
||||
|
||||
const id = await oauth.saveOAuthState({
|
||||
provider: OAuthProviderName.Google,
|
||||
});
|
||||
|
||||
const state = await oauth.getOAuthState(id);
|
||||
|
||||
t.truthy(state);
|
||||
t.is(state!.provider, OAuthProviderName.Google);
|
||||
});
|
||||
|
||||
test('should be able to get registered oauth providers', async t => {
|
||||
const { oauth } = t.context;
|
||||
|
||||
const providers = oauth.availableOAuthProviders();
|
||||
|
||||
t.deepEqual(providers, [OAuthProviderName.Google]);
|
||||
});
|
||||
|
||||
test('should throw if code is missing in callback uri', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/oauth/callback')
|
||||
.send({})
|
||||
.expect(HttpStatus.BAD_REQUEST)
|
||||
.expect({
|
||||
status: 400,
|
||||
code: 'Bad Request',
|
||||
type: 'BAD_REQUEST',
|
||||
name: 'MISSING_OAUTH_QUERY_PARAMETER',
|
||||
message: 'Missing query parameter `code`.',
|
||||
data: { name: 'code' },
|
||||
});
|
||||
|
||||
t.pass();
|
||||
});
|
||||
|
||||
test('should throw if state is missing in callback uri', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/oauth/callback')
|
||||
.send({ code: '1' })
|
||||
.expect(HttpStatus.BAD_REQUEST)
|
||||
.expect({
|
||||
status: 400,
|
||||
code: 'Bad Request',
|
||||
type: 'BAD_REQUEST',
|
||||
name: 'MISSING_OAUTH_QUERY_PARAMETER',
|
||||
message: 'Missing query parameter `state`.',
|
||||
data: { name: 'state' },
|
||||
});
|
||||
|
||||
t.pass();
|
||||
});
|
||||
|
||||
test('should throw if state is expired', async t => {
|
||||
const { app, oauth } = t.context;
|
||||
Sinon.stub(oauth, 'isValidState').resolves(true);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/oauth/callback')
|
||||
.send({ code: '1', state: '1' })
|
||||
.expect(HttpStatus.BAD_REQUEST)
|
||||
.expect({
|
||||
status: 400,
|
||||
code: 'Bad Request',
|
||||
type: 'BAD_REQUEST',
|
||||
name: 'OAUTH_STATE_EXPIRED',
|
||||
message: 'OAuth state expired, please try again.',
|
||||
});
|
||||
|
||||
t.pass();
|
||||
});
|
||||
|
||||
test('should throw if state is invalid', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/oauth/callback')
|
||||
.send({ code: '1', state: '1' })
|
||||
.expect(HttpStatus.BAD_REQUEST)
|
||||
.expect({
|
||||
status: 400,
|
||||
code: 'Bad Request',
|
||||
type: 'BAD_REQUEST',
|
||||
name: 'INVALID_OAUTH_CALLBACK_STATE',
|
||||
message: 'Invalid callback state parameter.',
|
||||
});
|
||||
|
||||
t.pass();
|
||||
});
|
||||
|
||||
test('should throw if provider is missing in state', async t => {
|
||||
const { app, oauth } = t.context;
|
||||
|
||||
// @ts-expect-error mock
|
||||
Sinon.stub(oauth, 'getOAuthState').resolves({});
|
||||
Sinon.stub(oauth, 'isValidState').resolves(true);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/oauth/callback')
|
||||
.send({ code: '1', state: '1' })
|
||||
.expect(HttpStatus.BAD_REQUEST)
|
||||
.expect({
|
||||
status: 400,
|
||||
code: 'Bad Request',
|
||||
type: 'BAD_REQUEST',
|
||||
name: 'MISSING_OAUTH_QUERY_PARAMETER',
|
||||
message: 'Missing query parameter `provider`.',
|
||||
data: { name: 'provider' },
|
||||
});
|
||||
|
||||
t.pass();
|
||||
});
|
||||
|
||||
test('should throw if provider is invalid in callback uri', async t => {
|
||||
const { app, oauth } = t.context;
|
||||
|
||||
// @ts-expect-error mock
|
||||
Sinon.stub(oauth, 'getOAuthState').resolves({ provider: 'Invalid' });
|
||||
Sinon.stub(oauth, 'isValidState').resolves(true);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/oauth/callback')
|
||||
.send({ code: '1', state: '1' })
|
||||
.expect(HttpStatus.BAD_REQUEST)
|
||||
.expect({
|
||||
status: 400,
|
||||
code: 'Bad Request',
|
||||
type: 'INVALID_INPUT',
|
||||
name: 'UNKNOWN_OAUTH_PROVIDER',
|
||||
message: 'Unknown authentication provider Invalid.',
|
||||
data: { name: 'Invalid' },
|
||||
});
|
||||
|
||||
t.pass();
|
||||
});
|
||||
|
||||
function mockOAuthProvider(app: INestApplication, email: string) {
|
||||
const provider = app.get(GoogleOAuthProvider);
|
||||
const oauth = app.get(OAuthService);
|
||||
|
||||
Sinon.stub(oauth, 'isValidState').resolves(true);
|
||||
Sinon.stub(oauth, 'getOAuthState').resolves({
|
||||
provider: OAuthProviderName.Google,
|
||||
});
|
||||
|
||||
// @ts-expect-error mock
|
||||
Sinon.stub(provider, 'getToken').resolves({ accessToken: '1' });
|
||||
Sinon.stub(provider, 'getUser').resolves({
|
||||
id: '1',
|
||||
email,
|
||||
avatarUrl: 'avatar',
|
||||
});
|
||||
}
|
||||
|
||||
test('should be able to sign up with oauth', async t => {
|
||||
const { app, db } = t.context;
|
||||
|
||||
mockOAuthProvider(app, 'u2@affine.pro');
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(`/api/oauth/callback`)
|
||||
.send({ code: '1', state: '1' })
|
||||
.expect(HttpStatus.OK);
|
||||
|
||||
const session = await getSession(app, res);
|
||||
|
||||
t.truthy(session.user);
|
||||
t.is(session.user!.email, 'u2@affine.pro');
|
||||
|
||||
const user = await db.user.findFirst({
|
||||
select: {
|
||||
email: true,
|
||||
connectedAccounts: true,
|
||||
},
|
||||
where: {
|
||||
email: 'u2@affine.pro',
|
||||
},
|
||||
});
|
||||
|
||||
t.truthy(user);
|
||||
t.is(user!.email, 'u2@affine.pro');
|
||||
t.is(user!.connectedAccounts[0].providerAccountId, '1');
|
||||
});
|
||||
|
||||
test('should not throw if account registered', async t => {
|
||||
const { app, u1 } = t.context;
|
||||
|
||||
mockOAuthProvider(app, u1.email);
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(`/api/oauth/callback`)
|
||||
.send({ code: '1', state: '1' })
|
||||
.expect(HttpStatus.OK);
|
||||
|
||||
t.is(res.body.id, u1.id);
|
||||
});
|
||||
|
||||
test('should be able to fullfil user with oauth sign in', async t => {
|
||||
const { app, user, db } = t.context;
|
||||
|
||||
const u3 = await user.createUser({
|
||||
name: 'u3',
|
||||
email: 'u3@affine.pro',
|
||||
registered: false,
|
||||
});
|
||||
|
||||
mockOAuthProvider(app, u3.email);
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/api/oauth/callback')
|
||||
.send({ code: '1', state: '1' })
|
||||
.expect(HttpStatus.OK);
|
||||
|
||||
const session = await getSession(app, res);
|
||||
|
||||
t.truthy(session.user);
|
||||
t.is(session.user!.email, u3.email);
|
||||
|
||||
const account = await db.connectedAccount.findFirst({
|
||||
where: {
|
||||
userId: u3.id,
|
||||
},
|
||||
});
|
||||
|
||||
t.truthy(account);
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
# Snapshot report for `src/__tests__/payment/service.spec.ts`
|
||||
|
||||
The actual snapshot is saved in `service.spec.ts.snap`.
|
||||
|
||||
Generated by [AVA](https://avajs.dev).
|
||||
|
||||
## should list normal price for unauthenticated user
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
[
|
||||
'pro_monthly',
|
||||
'pro_yearly',
|
||||
'pro_lifetime',
|
||||
'ai_yearly',
|
||||
'team_monthly',
|
||||
'team_yearly',
|
||||
]
|
||||
|
||||
## should list normal prices for authenticated user
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
[
|
||||
'pro_monthly',
|
||||
'pro_yearly',
|
||||
'pro_lifetime',
|
||||
'ai_yearly',
|
||||
'team_monthly',
|
||||
'team_yearly',
|
||||
]
|
||||
|
||||
## should not show lifetime price if not enabled
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
[
|
||||
'pro_monthly',
|
||||
'pro_yearly',
|
||||
'ai_yearly',
|
||||
'team_monthly',
|
||||
'team_yearly',
|
||||
]
|
||||
|
||||
## should list early access prices for pro ea user
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
[
|
||||
'pro_monthly',
|
||||
'pro_lifetime',
|
||||
'pro_yearly_earlyaccess',
|
||||
'ai_yearly',
|
||||
'team_monthly',
|
||||
'team_yearly',
|
||||
]
|
||||
|
||||
## should list normal prices for pro ea user with old subscriptions
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
[
|
||||
'pro_monthly',
|
||||
'pro_yearly',
|
||||
'pro_lifetime',
|
||||
'ai_yearly',
|
||||
'team_monthly',
|
||||
'team_yearly',
|
||||
]
|
||||
|
||||
## should list early access prices for ai ea user
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
[
|
||||
'pro_monthly',
|
||||
'pro_yearly',
|
||||
'pro_lifetime',
|
||||
'ai_yearly_earlyaccess',
|
||||
'team_monthly',
|
||||
'team_yearly',
|
||||
]
|
||||
|
||||
## should list early access prices for pro and ai ea user
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
[
|
||||
'pro_monthly',
|
||||
'pro_lifetime',
|
||||
'pro_yearly_earlyaccess',
|
||||
'ai_yearly_earlyaccess',
|
||||
'team_monthly',
|
||||
'team_yearly',
|
||||
]
|
||||
|
||||
## should list normal prices for ai ea user with old subscriptions
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
[
|
||||
'pro_monthly',
|
||||
'pro_yearly',
|
||||
'pro_lifetime',
|
||||
'ai_yearly',
|
||||
'team_monthly',
|
||||
'team_yearly',
|
||||
]
|
||||
|
||||
## should be able to list prices for team
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
[
|
||||
'pro_monthly',
|
||||
'pro_yearly',
|
||||
'pro_lifetime',
|
||||
'ai_yearly',
|
||||
'team_monthly',
|
||||
'team_yearly',
|
||||
]
|
||||
Binary file not shown.
1729
packages/backend/server/src/__tests__/payment/service.spec.ts
Normal file
1729
packages/backend/server/src/__tests__/payment/service.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
212
packages/backend/server/src/__tests__/quota.spec.ts
Normal file
212
packages/backend/server/src/__tests__/quota.spec.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
/// <reference types="../global.d.ts" />
|
||||
|
||||
import { TestingModule } from '@nestjs/testing';
|
||||
import type { TestFn } from 'ava';
|
||||
import ava from 'ava';
|
||||
|
||||
import { AuthService } from '../core/auth';
|
||||
import {
|
||||
QuotaManagementService,
|
||||
QuotaModule,
|
||||
QuotaService,
|
||||
QuotaType,
|
||||
} from '../core/quota';
|
||||
import { OneGB, OneMB } from '../core/quota/constant';
|
||||
import { FreePlan, ProPlan } from '../core/quota/schema';
|
||||
import { StorageModule, WorkspaceBlobStorage } from '../core/storage';
|
||||
import { WorkspaceResolver } from '../core/workspaces/resolvers';
|
||||
import { createTestingModule } from './utils';
|
||||
import { WorkspaceResolverMock } from './utils/feature';
|
||||
|
||||
const test = ava as TestFn<{
|
||||
auth: AuthService;
|
||||
quota: QuotaService;
|
||||
quotaManager: QuotaManagementService;
|
||||
workspace: WorkspaceResolver;
|
||||
workspaceBlob: WorkspaceBlobStorage;
|
||||
module: TestingModule;
|
||||
}>;
|
||||
|
||||
test.beforeEach(async t => {
|
||||
const module = await createTestingModule({
|
||||
imports: [StorageModule, QuotaModule],
|
||||
providers: [WorkspaceResolver],
|
||||
tapModule: module => {
|
||||
module
|
||||
.overrideProvider(WorkspaceResolver)
|
||||
.useClass(WorkspaceResolverMock);
|
||||
},
|
||||
});
|
||||
|
||||
t.context.module = module;
|
||||
t.context.auth = module.get(AuthService);
|
||||
t.context.quota = module.get(QuotaService);
|
||||
t.context.quotaManager = module.get(QuotaManagementService);
|
||||
t.context.workspace = module.get(WorkspaceResolver);
|
||||
t.context.workspaceBlob = module.get(WorkspaceBlobStorage);
|
||||
});
|
||||
|
||||
test.afterEach.always(async t => {
|
||||
await t.context.module.close();
|
||||
});
|
||||
|
||||
test('should be able to set quota', async t => {
|
||||
const { auth, quota } = t.context;
|
||||
|
||||
const u1 = await auth.signUp('test@affine.pro', '123456');
|
||||
|
||||
const q1 = await quota.getUserQuota(u1.id);
|
||||
t.truthy(q1, 'should have quota');
|
||||
t.is(q1?.feature.name, QuotaType.FreePlanV1, 'should be free plan');
|
||||
t.is(q1?.feature.version, 4, 'should be version 4');
|
||||
|
||||
await quota.switchUserQuota(u1.id, QuotaType.ProPlanV1);
|
||||
|
||||
const q2 = await quota.getUserQuota(u1.id);
|
||||
t.is(q2?.feature.name, QuotaType.ProPlanV1, 'should be pro plan');
|
||||
|
||||
const fail = quota.switchUserQuota(u1.id, 'not_exists_plan_v1' as QuotaType);
|
||||
await t.throwsAsync(fail, { instanceOf: Error }, 'should throw error');
|
||||
});
|
||||
|
||||
test('should be able to check storage quota', async t => {
|
||||
const { auth, quota, quotaManager } = t.context;
|
||||
const u1 = await auth.signUp('test@affine.pro', '123456');
|
||||
const freePlan = FreePlan.configs;
|
||||
const proPlan = ProPlan.configs;
|
||||
|
||||
const q1 = await quotaManager.getUserQuota(u1.id);
|
||||
t.is(q1?.blobLimit, freePlan.blobLimit, 'should be free plan');
|
||||
t.is(q1?.storageQuota, freePlan.storageQuota, 'should be free plan');
|
||||
|
||||
await quota.switchUserQuota(u1.id, QuotaType.ProPlanV1);
|
||||
const q2 = await quotaManager.getUserQuota(u1.id);
|
||||
t.is(q2?.blobLimit, proPlan.blobLimit, 'should be pro plan');
|
||||
t.is(q2?.storageQuota, proPlan.storageQuota, 'should be pro plan');
|
||||
});
|
||||
|
||||
test('should be able revert quota', async t => {
|
||||
const { auth, quota, quotaManager } = t.context;
|
||||
const u1 = await auth.signUp('test@affine.pro', '123456');
|
||||
const freePlan = FreePlan.configs;
|
||||
const proPlan = ProPlan.configs;
|
||||
|
||||
const q1 = await quotaManager.getUserQuota(u1.id);
|
||||
|
||||
t.is(q1?.blobLimit, freePlan.blobLimit, 'should be free plan');
|
||||
t.is(q1?.storageQuota, freePlan.storageQuota, 'should be free plan');
|
||||
|
||||
await quota.switchUserQuota(u1.id, QuotaType.ProPlanV1);
|
||||
const q2 = await quotaManager.getUserQuota(u1.id);
|
||||
t.is(q2?.blobLimit, proPlan.blobLimit, 'should be pro plan');
|
||||
t.is(q2?.storageQuota, proPlan.storageQuota, 'should be pro plan');
|
||||
t.is(
|
||||
q2?.copilotActionLimit,
|
||||
proPlan.copilotActionLimit!,
|
||||
'should be pro plan'
|
||||
);
|
||||
|
||||
await quota.switchUserQuota(u1.id, QuotaType.FreePlanV1);
|
||||
const q3 = await quotaManager.getUserQuota(u1.id);
|
||||
t.is(q3?.blobLimit, freePlan.blobLimit, 'should be free plan');
|
||||
|
||||
const quotas = await quota.getUserQuotas(u1.id);
|
||||
t.is(quotas.length, 3, 'should have 3 quotas');
|
||||
t.is(quotas[0].feature.name, QuotaType.FreePlanV1, 'should be free plan');
|
||||
t.is(quotas[1].feature.name, QuotaType.ProPlanV1, 'should be pro plan');
|
||||
t.is(quotas[2].feature.name, QuotaType.FreePlanV1, 'should be free plan');
|
||||
t.is(quotas[0].activated, false, 'should be activated');
|
||||
t.is(quotas[1].activated, false, 'should be activated');
|
||||
t.is(quotas[2].activated, true, 'should be activated');
|
||||
});
|
||||
|
||||
test('should be able to check quota', async t => {
|
||||
const { auth, quotaManager } = t.context;
|
||||
const u1 = await auth.signUp('test@affine.pro', '123456');
|
||||
const freePlan = FreePlan.configs;
|
||||
|
||||
const q1 = await quotaManager.getUserQuota(u1.id);
|
||||
t.assert(q1, 'should have quota');
|
||||
t.is(q1.blobLimit, freePlan.blobLimit, 'should be free plan');
|
||||
t.is(q1.storageQuota, freePlan.storageQuota, 'should be free plan');
|
||||
t.is(q1.historyPeriod, freePlan.historyPeriod, 'should be free plan');
|
||||
t.is(q1.memberLimit, freePlan.memberLimit, 'should be free plan');
|
||||
t.is(
|
||||
q1.copilotActionLimit!,
|
||||
freePlan.copilotActionLimit!,
|
||||
'should be free plan'
|
||||
);
|
||||
});
|
||||
|
||||
test('should be able to override quota', async t => {
|
||||
const { auth, quotaManager, workspace } = t.context;
|
||||
|
||||
const u1 = await auth.signUp('test@affine.pro', '123456');
|
||||
const w1 = await workspace.createWorkspace(u1, null);
|
||||
|
||||
const wq1 = await quotaManager.getWorkspaceUsage(w1.id);
|
||||
t.is(wq1.blobLimit, 10 * OneMB, 'should be 10MB');
|
||||
t.is(wq1.businessBlobLimit, 100 * OneMB, 'should be 100MB');
|
||||
t.is(wq1.memberLimit, 3, 'should be 3');
|
||||
|
||||
await quotaManager.addTeamWorkspace(w1.id, 'test');
|
||||
const wq2 = await quotaManager.getWorkspaceUsage(w1.id);
|
||||
t.is(wq2.storageQuota, 120 * OneGB, 'should be override to 100GB');
|
||||
t.is(wq2.businessBlobLimit, 500 * OneMB, 'should be override to 500MB');
|
||||
t.is(wq2.memberLimit, 1, 'should be override to 1');
|
||||
|
||||
await quotaManager.updateWorkspaceConfig(w1.id, QuotaType.TeamPlanV1, {
|
||||
memberLimit: 2,
|
||||
});
|
||||
const wq3 = await quotaManager.getWorkspaceUsage(w1.id);
|
||||
t.is(wq3.storageQuota, 140 * OneGB, 'should be override to 120GB');
|
||||
t.is(wq3.memberLimit, 2, 'should be override to 1');
|
||||
});
|
||||
|
||||
test('should be able to check with workspace quota', async t => {
|
||||
const { auth, quotaManager, workspace, workspaceBlob } = t.context;
|
||||
|
||||
const u1 = await auth.signUp('test@affine.pro', '123456');
|
||||
const w1 = await workspace.createWorkspace(u1, null);
|
||||
const w2 = await workspace.createWorkspace(u1, null);
|
||||
const w3 = await workspace.createWorkspace(u1, null);
|
||||
await quotaManager.addTeamWorkspace(w3.id, 'test');
|
||||
|
||||
{
|
||||
const wq1 = await quotaManager.getWorkspaceUsage(w1.id);
|
||||
t.is(wq1.usedSize, 0, 'should be 0');
|
||||
const wq2 = await quotaManager.getWorkspaceUsage(w2.id);
|
||||
t.is(wq2.usedSize, 0, 'should be 0');
|
||||
const wq3 = await quotaManager.getWorkspaceUsage(w3.id);
|
||||
t.is(wq3.usedSize, 0, 'should be 0');
|
||||
}
|
||||
|
||||
{
|
||||
await workspaceBlob.put(w1.id, 'test', Buffer.from([0, 0]));
|
||||
await workspaceBlob.put(w2.id, 'test', Buffer.from([0, 0]));
|
||||
|
||||
// normal workspace
|
||||
const wq1 = await quotaManager.getWorkspaceUsage(w1.id);
|
||||
t.is(wq1.usedSize, 4, 'should share usage with w2');
|
||||
const wq2 = await quotaManager.getWorkspaceUsage(w2.id);
|
||||
t.is(wq2.usedSize, 4, 'should share usage with w1');
|
||||
|
||||
// workspace with quota
|
||||
const wq3 = await quotaManager.getWorkspaceUsage(w3.id);
|
||||
t.is(wq3.usedSize, 0, 'should not share usage with w1 and w2');
|
||||
}
|
||||
|
||||
{
|
||||
await workspaceBlob.put(w3.id, 'test', Buffer.from([0, 0, 0]));
|
||||
|
||||
// normal workspace
|
||||
const wq1 = await quotaManager.getWorkspaceUsage(w1.id);
|
||||
t.is(wq1.usedSize, 4, 'should not share usage with w3');
|
||||
const wq2 = await quotaManager.getWorkspaceUsage(w2.id);
|
||||
t.is(wq2.usedSize, 4, 'should not share usage with w3');
|
||||
|
||||
// workspace with quota
|
||||
const wq3 = await quotaManager.getWorkspaceUsage(w3.id);
|
||||
t.is(wq3.usedSize, 3, 'should not share usage with w1 and w2');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import test from 'ava';
|
||||
|
||||
test('should test through sync gateway', t => {
|
||||
t.pass();
|
||||
});
|
||||
726
packages/backend/server/src/__tests__/team.e2e.ts
Normal file
726
packages/backend/server/src/__tests__/team.e2e.ts
Normal file
@@ -0,0 +1,726 @@
|
||||
/// <reference types="../global.d.ts" />
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { getCurrentMailMessageCount } from '@affine-test/kit/utils/cloud';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { WorkspaceMemberStatus } from '@prisma/client';
|
||||
import type { TestFn } from 'ava';
|
||||
import ava from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import { AppModule } from '../app.module';
|
||||
import { EventEmitter } from '../base';
|
||||
import { AuthService } from '../core/auth';
|
||||
import { DocContentService } from '../core/doc-renderer';
|
||||
import { Permission, PermissionService } from '../core/permission';
|
||||
import { QuotaManagementService, QuotaService, QuotaType } from '../core/quota';
|
||||
import { WorkspaceType } from '../core/workspaces';
|
||||
import {
|
||||
acceptInviteById,
|
||||
approveMember,
|
||||
createInviteLink,
|
||||
createTestingApp,
|
||||
createWorkspace,
|
||||
getInviteInfo,
|
||||
getInviteLink,
|
||||
getWorkspace,
|
||||
grantMember,
|
||||
inviteUser,
|
||||
inviteUsers,
|
||||
leaveWorkspace,
|
||||
PermissionEnum,
|
||||
revokeInviteLink,
|
||||
revokeUser,
|
||||
signUp,
|
||||
sleep,
|
||||
UserAuthedType,
|
||||
} from './utils';
|
||||
|
||||
const test = ava as TestFn<{
|
||||
app: INestApplication;
|
||||
auth: AuthService;
|
||||
event: Sinon.SinonStubbedInstance<EventEmitter>;
|
||||
quota: QuotaService;
|
||||
quotaManager: QuotaManagementService;
|
||||
permissions: PermissionService;
|
||||
}>;
|
||||
|
||||
test.beforeEach(async t => {
|
||||
const { app } = await createTestingApp({
|
||||
imports: [AppModule],
|
||||
tapModule: module => {
|
||||
module
|
||||
.overrideProvider(EventEmitter)
|
||||
.useValue(Sinon.createStubInstance(EventEmitter));
|
||||
module.overrideProvider(DocContentService).useValue({
|
||||
getWorkspaceContent() {
|
||||
return {
|
||||
name: 'test',
|
||||
avatarKey: null,
|
||||
};
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
t.context.app = app;
|
||||
t.context.auth = app.get(AuthService);
|
||||
t.context.event = app.get(EventEmitter);
|
||||
t.context.quota = app.get(QuotaService);
|
||||
t.context.quotaManager = app.get(QuotaManagementService);
|
||||
t.context.permissions = app.get(PermissionService);
|
||||
});
|
||||
|
||||
test.afterEach.always(async t => {
|
||||
await t.context.app.close();
|
||||
});
|
||||
|
||||
const init = async (
|
||||
app: INestApplication,
|
||||
memberLimit = 10,
|
||||
prefix = randomUUID()
|
||||
) => {
|
||||
const owner = await signUp(
|
||||
app,
|
||||
'owner',
|
||||
`${prefix}owner@affine.pro`,
|
||||
'123456'
|
||||
);
|
||||
{
|
||||
const quota = app.get(QuotaService);
|
||||
await quota.switchUserQuota(owner.id, QuotaType.ProPlanV1);
|
||||
}
|
||||
|
||||
const workspace = await createWorkspace(app, owner.token.token);
|
||||
const teamWorkspace = await createWorkspace(app, owner.token.token);
|
||||
{
|
||||
const quota = app.get(QuotaManagementService);
|
||||
await quota.addTeamWorkspace(teamWorkspace.id, 'test');
|
||||
await quota.updateWorkspaceConfig(teamWorkspace.id, QuotaType.TeamPlanV1, {
|
||||
memberLimit,
|
||||
});
|
||||
}
|
||||
|
||||
const invite = async (
|
||||
email: string,
|
||||
permission: PermissionEnum = 'Write',
|
||||
shouldSendEmail: boolean = false
|
||||
) => {
|
||||
const member = await signUp(app, email.split('@')[0], email, '123456');
|
||||
|
||||
{
|
||||
// normal workspace
|
||||
const inviteId = await inviteUser(
|
||||
app,
|
||||
owner.token.token,
|
||||
workspace.id,
|
||||
member.email,
|
||||
shouldSendEmail
|
||||
);
|
||||
await acceptInviteById(app, workspace.id, inviteId, shouldSendEmail);
|
||||
}
|
||||
|
||||
{
|
||||
// team workspace
|
||||
const inviteId = await inviteUser(
|
||||
app,
|
||||
owner.token.token,
|
||||
teamWorkspace.id,
|
||||
member.email,
|
||||
shouldSendEmail
|
||||
);
|
||||
await acceptInviteById(app, teamWorkspace.id, inviteId, shouldSendEmail);
|
||||
await grantMember(
|
||||
app,
|
||||
owner.token.token,
|
||||
teamWorkspace.id,
|
||||
member.id,
|
||||
permission
|
||||
);
|
||||
}
|
||||
|
||||
return member;
|
||||
};
|
||||
|
||||
const inviteBatch = async (
|
||||
emails: string[],
|
||||
shouldSendEmail: boolean = false
|
||||
) => {
|
||||
const members = [];
|
||||
for (const email of emails) {
|
||||
const member = await signUp(app, email.split('@')[0], email, '123456');
|
||||
members.push(member);
|
||||
}
|
||||
const invites = await inviteUsers(
|
||||
app,
|
||||
owner.token.token,
|
||||
teamWorkspace.id,
|
||||
emails,
|
||||
shouldSendEmail
|
||||
);
|
||||
return [members, invites] as const;
|
||||
};
|
||||
|
||||
const getCreateInviteLinkFetcher = async (ws: WorkspaceType) => {
|
||||
const { link } = await createInviteLink(
|
||||
app,
|
||||
owner.token.token,
|
||||
ws.id,
|
||||
'OneDay'
|
||||
);
|
||||
const inviteId = link.split('/').pop()!;
|
||||
return [
|
||||
inviteId,
|
||||
async (
|
||||
email: string,
|
||||
shouldSendEmail: boolean = false
|
||||
): Promise<UserAuthedType> => {
|
||||
const member = await signUp(app, email.split('@')[0], email, '123456');
|
||||
await acceptInviteById(
|
||||
app,
|
||||
ws.id,
|
||||
inviteId,
|
||||
shouldSendEmail,
|
||||
member.token.token
|
||||
);
|
||||
return member;
|
||||
},
|
||||
async (token: string) => {
|
||||
await acceptInviteById(app, ws.id, inviteId, false, token);
|
||||
},
|
||||
] as const;
|
||||
};
|
||||
|
||||
const admin = await invite(`${prefix}admin@affine.pro`, 'Admin');
|
||||
const write = await invite(`${prefix}write@affine.pro`);
|
||||
const read = await invite(`${prefix}read@affine.pro`, 'Read');
|
||||
|
||||
return {
|
||||
invite,
|
||||
inviteBatch,
|
||||
createInviteLink: getCreateInviteLinkFetcher,
|
||||
owner,
|
||||
workspace,
|
||||
teamWorkspace,
|
||||
admin,
|
||||
write,
|
||||
read,
|
||||
};
|
||||
};
|
||||
|
||||
test('should be able to invite multiple users', async t => {
|
||||
const { app } = t.context;
|
||||
const { teamWorkspace: ws, owner, admin, write, read } = await init(app, 4);
|
||||
|
||||
{
|
||||
// no permission
|
||||
await t.throwsAsync(
|
||||
inviteUsers(app, read.token.token, ws.id, ['test@affine.pro']),
|
||||
{ instanceOf: Error },
|
||||
'should throw error if not manager'
|
||||
);
|
||||
await t.throwsAsync(
|
||||
inviteUsers(app, write.token.token, ws.id, ['test@affine.pro']),
|
||||
{ instanceOf: Error },
|
||||
'should throw error if not manager'
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
// manager
|
||||
const m1 = await signUp(app, 'm1', 'm1@affine.pro', '123456');
|
||||
const m2 = await signUp(app, 'm2', 'm2@affine.pro', '123456');
|
||||
t.is(
|
||||
(await inviteUsers(app, owner.token.token, ws.id, [m1.email])).length,
|
||||
1,
|
||||
'should be able to invite user'
|
||||
);
|
||||
t.is(
|
||||
(await inviteUsers(app, admin.token.token, ws.id, [m2.email])).length,
|
||||
1,
|
||||
'should be able to invite user'
|
||||
);
|
||||
t.is(
|
||||
(await inviteUsers(app, admin.token.token, ws.id, [m2.email])).length,
|
||||
0,
|
||||
'should not be able to invite user if already in workspace'
|
||||
);
|
||||
|
||||
await t.throwsAsync(
|
||||
inviteUsers(
|
||||
app,
|
||||
admin.token.token,
|
||||
ws.id,
|
||||
Array.from({ length: 513 }, (_, i) => `m${i}@affine.pro`)
|
||||
),
|
||||
{ message: 'Too many requests.' },
|
||||
'should throw error if exceed maximum number of invitations per request'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('should be able to check seat limit', async t => {
|
||||
const { app, permissions, quotaManager } = t.context;
|
||||
const { invite, inviteBatch, teamWorkspace: ws } = await init(app, 4);
|
||||
|
||||
{
|
||||
// invite
|
||||
await t.throwsAsync(
|
||||
invite('member3@affine.pro', 'Read'),
|
||||
{ message: 'You have exceeded your workspace member quota.' },
|
||||
'should throw error if exceed member limit'
|
||||
);
|
||||
await quotaManager.updateWorkspaceConfig(ws.id, QuotaType.TeamPlanV1, {
|
||||
memberLimit: 5,
|
||||
});
|
||||
await t.notThrowsAsync(
|
||||
invite('member4@affine.pro', 'Read'),
|
||||
'should not throw error if not exceed member limit'
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const members1 = inviteBatch(['member5@affine.pro']);
|
||||
// invite batch
|
||||
await t.notThrowsAsync(
|
||||
members1,
|
||||
'should not throw error in batch invite event reach limit'
|
||||
);
|
||||
|
||||
t.is(
|
||||
await permissions.getWorkspaceMemberStatus(
|
||||
ws.id,
|
||||
(await members1)[0][0].id
|
||||
),
|
||||
WorkspaceMemberStatus.NeedMoreSeat,
|
||||
'should be able to check member status'
|
||||
);
|
||||
|
||||
// refresh seat, fifo
|
||||
sleep(1000);
|
||||
const [[members2]] = await inviteBatch(['member6@affine.pro']);
|
||||
await permissions.refreshSeatStatus(ws.id, 6);
|
||||
|
||||
t.is(
|
||||
await permissions.getWorkspaceMemberStatus(
|
||||
ws.id,
|
||||
(await members1)[0][0].id
|
||||
),
|
||||
WorkspaceMemberStatus.Pending,
|
||||
'should become accepted after refresh'
|
||||
);
|
||||
t.is(
|
||||
await permissions.getWorkspaceMemberStatus(ws.id, members2.id),
|
||||
WorkspaceMemberStatus.NeedMoreSeat,
|
||||
'should not change status'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('should be able to grant team member permission', async t => {
|
||||
const { app, permissions } = t.context;
|
||||
const { owner, teamWorkspace: ws, admin, write, read } = await init(app);
|
||||
|
||||
await t.throwsAsync(
|
||||
grantMember(app, read.token.token, ws.id, write.id, 'Write'),
|
||||
{ instanceOf: Error },
|
||||
'should throw error if not owner'
|
||||
);
|
||||
await t.throwsAsync(
|
||||
grantMember(app, write.token.token, ws.id, read.id, 'Write'),
|
||||
{ instanceOf: Error },
|
||||
'should throw error if not owner'
|
||||
);
|
||||
await t.throwsAsync(
|
||||
grantMember(app, admin.token.token, ws.id, read.id, 'Write'),
|
||||
{ instanceOf: Error },
|
||||
'should throw error if not owner'
|
||||
);
|
||||
|
||||
{
|
||||
// owner should be able to grant permission
|
||||
t.true(
|
||||
await permissions.tryCheckWorkspaceIs(ws.id, read.id, Permission.Read),
|
||||
'should be able to check permission'
|
||||
);
|
||||
t.truthy(
|
||||
await grantMember(app, owner.token.token, ws.id, read.id, 'Admin'),
|
||||
'should be able to grant permission'
|
||||
);
|
||||
t.true(
|
||||
await permissions.tryCheckWorkspaceIs(ws.id, read.id, Permission.Admin),
|
||||
'should be able to check permission'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('should be able to leave workspace', async t => {
|
||||
const { app } = t.context;
|
||||
const { owner, teamWorkspace: ws, admin, write, read } = await init(app);
|
||||
|
||||
t.false(
|
||||
await leaveWorkspace(app, owner.token.token, ws.id),
|
||||
'owner should not be able to leave workspace'
|
||||
);
|
||||
t.true(
|
||||
await leaveWorkspace(app, admin.token.token, ws.id),
|
||||
'admin should be able to leave workspace'
|
||||
);
|
||||
t.true(
|
||||
await leaveWorkspace(app, write.token.token, ws.id),
|
||||
'write should be able to leave workspace'
|
||||
);
|
||||
t.true(
|
||||
await leaveWorkspace(app, read.token.token, ws.id),
|
||||
'read should be able to leave workspace'
|
||||
);
|
||||
});
|
||||
|
||||
test('should be able to revoke team member', async t => {
|
||||
const { app } = t.context;
|
||||
const { teamWorkspace: ws, owner, admin, write, read } = await init(app);
|
||||
|
||||
{
|
||||
// no permission
|
||||
t.throwsAsync(
|
||||
revokeUser(app, read.token.token, ws.id, read.id),
|
||||
{ instanceOf: Error },
|
||||
'should throw error if not admin'
|
||||
);
|
||||
t.throwsAsync(
|
||||
revokeUser(app, read.token.token, ws.id, write.id),
|
||||
{ instanceOf: Error },
|
||||
'should throw error if not admin'
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
// manager
|
||||
t.true(
|
||||
await revokeUser(app, admin.token.token, ws.id, read.id),
|
||||
'admin should be able to revoke member'
|
||||
);
|
||||
|
||||
t.true(
|
||||
await revokeUser(app, owner.token.token, ws.id, write.id),
|
||||
'owner should be able to revoke member'
|
||||
);
|
||||
|
||||
await t.throwsAsync(
|
||||
revokeUser(app, admin.token.token, ws.id, admin.id),
|
||||
{ instanceOf: Error },
|
||||
'should not be able to revoke themselves'
|
||||
);
|
||||
|
||||
t.false(
|
||||
await revokeUser(app, owner.token.token, ws.id, owner.id),
|
||||
'should not be able to revoke themselves'
|
||||
);
|
||||
|
||||
await revokeUser(app, owner.token.token, ws.id, admin.id);
|
||||
await t.throwsAsync(
|
||||
revokeUser(app, admin.token.token, ws.id, read.id),
|
||||
{ instanceOf: Error },
|
||||
'should not be able to revoke member not in workspace'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('should be able to manage invite link', async t => {
|
||||
const { app } = t.context;
|
||||
const {
|
||||
workspace: ws,
|
||||
teamWorkspace: tws,
|
||||
owner,
|
||||
admin,
|
||||
write,
|
||||
read,
|
||||
} = await init(app, 4);
|
||||
|
||||
for (const [workspace, managers] of [
|
||||
[ws, [owner]],
|
||||
[tws, [owner, admin]],
|
||||
] as const) {
|
||||
for (const manager of managers) {
|
||||
const { link } = await createInviteLink(
|
||||
app,
|
||||
manager.token.token,
|
||||
workspace.id,
|
||||
'OneDay'
|
||||
);
|
||||
const { link: currLink } = await getInviteLink(
|
||||
app,
|
||||
manager.token.token,
|
||||
workspace.id
|
||||
);
|
||||
t.is(link, currLink, 'should be able to get invite link');
|
||||
|
||||
t.true(
|
||||
await revokeInviteLink(app, manager.token.token, workspace.id),
|
||||
'should be able to revoke invite link'
|
||||
);
|
||||
}
|
||||
|
||||
for (const collaborator of [write, read]) {
|
||||
await t.throwsAsync(
|
||||
createInviteLink(app, collaborator.token.token, workspace.id, 'OneDay'),
|
||||
{ instanceOf: Error },
|
||||
'should throw error if not manager'
|
||||
);
|
||||
await t.throwsAsync(
|
||||
getInviteLink(app, collaborator.token.token, workspace.id),
|
||||
{ instanceOf: Error },
|
||||
'should throw error if not manager'
|
||||
);
|
||||
await t.throwsAsync(
|
||||
revokeInviteLink(app, collaborator.token.token, workspace.id),
|
||||
{ instanceOf: Error },
|
||||
'should throw error if not manager'
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('should be able to approve team member', async t => {
|
||||
const { app } = t.context;
|
||||
const { teamWorkspace: tws, owner, admin, write, read } = await init(app, 5);
|
||||
|
||||
{
|
||||
const { link } = await createInviteLink(
|
||||
app,
|
||||
owner.token.token,
|
||||
tws.id,
|
||||
'OneDay'
|
||||
);
|
||||
const inviteId = link.split('/').pop()!;
|
||||
|
||||
const member = await signUp(
|
||||
app,
|
||||
'newmember',
|
||||
'newmember@affine.pro',
|
||||
'123456'
|
||||
);
|
||||
t.true(
|
||||
await acceptInviteById(app, tws.id, inviteId, false, member.token.token),
|
||||
'should be able to accept invite'
|
||||
);
|
||||
|
||||
const { members } = await getWorkspace(app, owner.token.token, tws.id);
|
||||
const memberInvite = members.find(m => m.id === member.id)!;
|
||||
t.is(memberInvite.status, 'UnderReview', 'should be under review');
|
||||
|
||||
t.is(
|
||||
await approveMember(app, admin.token.token, tws.id, member.id),
|
||||
memberInvite.inviteId
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
await t.throwsAsync(
|
||||
approveMember(app, admin.token.token, tws.id, 'not_exists_id'),
|
||||
{ instanceOf: Error },
|
||||
'should throw error if member not exists'
|
||||
);
|
||||
await t.throwsAsync(
|
||||
approveMember(app, write.token.token, tws.id, 'not_exists_id'),
|
||||
{ instanceOf: Error },
|
||||
'should throw error if not manager'
|
||||
);
|
||||
await t.throwsAsync(
|
||||
approveMember(app, read.token.token, tws.id, 'not_exists_id'),
|
||||
{ instanceOf: Error },
|
||||
'should throw error if not manager'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('should be able to invite by link', async t => {
|
||||
const { app, permissions, quotaManager } = t.context;
|
||||
const {
|
||||
createInviteLink,
|
||||
owner,
|
||||
workspace: ws,
|
||||
teamWorkspace: tws,
|
||||
} = await init(app, 4);
|
||||
const [inviteId, invite] = await createInviteLink(ws);
|
||||
const [teamInviteId, teamInvite, acceptTeamInvite] =
|
||||
await createInviteLink(tws);
|
||||
|
||||
{
|
||||
// check invite link
|
||||
const info = await getInviteInfo(app, owner.token.token, inviteId);
|
||||
t.is(info.workspace.id, ws.id, 'should be able to get invite info');
|
||||
|
||||
// check team invite link
|
||||
const teamInfo = await getInviteInfo(app, owner.token.token, teamInviteId);
|
||||
t.is(teamInfo.workspace.id, tws.id, 'should be able to get invite info');
|
||||
}
|
||||
|
||||
{
|
||||
// invite link
|
||||
for (const [i] of Array.from({ length: 6 }).entries()) {
|
||||
const user = await invite(`test${i}@affine.pro`);
|
||||
const status = await permissions.getWorkspaceMemberStatus(ws.id, user.id);
|
||||
t.is(
|
||||
status,
|
||||
WorkspaceMemberStatus.Accepted,
|
||||
'should be able to check status'
|
||||
);
|
||||
}
|
||||
|
||||
await t.throwsAsync(
|
||||
invite('exceed@affine.pro'),
|
||||
{ message: 'You have exceeded your workspace member quota.' },
|
||||
'should throw error if exceed member limit'
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
// team invite link
|
||||
const members: UserAuthedType[] = [];
|
||||
await t.notThrowsAsync(async () => {
|
||||
members.push(await teamInvite('member3@affine.pro'));
|
||||
members.push(await teamInvite('member4@affine.pro'));
|
||||
}, 'should not throw error even exceed member limit');
|
||||
const [m3, m4] = members;
|
||||
|
||||
t.is(
|
||||
await permissions.getWorkspaceMemberStatus(tws.id, m3.id),
|
||||
WorkspaceMemberStatus.NeedMoreSeatAndReview,
|
||||
'should not change status'
|
||||
);
|
||||
t.is(
|
||||
await permissions.getWorkspaceMemberStatus(tws.id, m4.id),
|
||||
WorkspaceMemberStatus.NeedMoreSeatAndReview,
|
||||
'should not change status'
|
||||
);
|
||||
|
||||
await quotaManager.updateWorkspaceConfig(tws.id, QuotaType.TeamPlanV1, {
|
||||
memberLimit: 5,
|
||||
});
|
||||
await permissions.refreshSeatStatus(tws.id, 5);
|
||||
t.is(
|
||||
await permissions.getWorkspaceMemberStatus(tws.id, m3.id),
|
||||
WorkspaceMemberStatus.UnderReview,
|
||||
'should not change status'
|
||||
);
|
||||
t.is(
|
||||
await permissions.getWorkspaceMemberStatus(tws.id, m4.id),
|
||||
WorkspaceMemberStatus.NeedMoreSeatAndReview,
|
||||
'should not change status'
|
||||
);
|
||||
|
||||
await quotaManager.updateWorkspaceConfig(tws.id, QuotaType.TeamPlanV1, {
|
||||
memberLimit: 6,
|
||||
});
|
||||
await permissions.refreshSeatStatus(tws.id, 6);
|
||||
t.is(
|
||||
await permissions.getWorkspaceMemberStatus(tws.id, m4.id),
|
||||
WorkspaceMemberStatus.UnderReview,
|
||||
'should not change status'
|
||||
);
|
||||
|
||||
{
|
||||
const message = `You have already joined in Space ${tws.id}.`;
|
||||
await t.throwsAsync(
|
||||
acceptTeamInvite(owner.token.token),
|
||||
{ message },
|
||||
'should throw error if member already in workspace'
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('should be able to send mails', async t => {
|
||||
const { app } = t.context;
|
||||
const { inviteBatch } = await init(app, 4);
|
||||
const primitiveMailCount = await getCurrentMailMessageCount();
|
||||
|
||||
{
|
||||
await inviteBatch(['m3@affine.pro', 'm4@affine.pro'], true);
|
||||
t.is(await getCurrentMailMessageCount(), primitiveMailCount + 2);
|
||||
}
|
||||
});
|
||||
|
||||
test('should be able to emit events', async t => {
|
||||
const { app, event } = t.context;
|
||||
|
||||
{
|
||||
const { teamWorkspace: tws, inviteBatch } = await init(app, 4);
|
||||
|
||||
await inviteBatch(['m1@affine.pro', 'm2@affine.pro']);
|
||||
const [membersUpdated] = event.emit
|
||||
.getCalls()
|
||||
.map(call => call.args)
|
||||
.toReversed();
|
||||
t.deepEqual(membersUpdated, [
|
||||
'workspace.members.updated',
|
||||
{
|
||||
workspaceId: tws.id,
|
||||
count: 6,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
{
|
||||
const { teamWorkspace: tws, owner, createInviteLink } = await init(app);
|
||||
const [, invite] = await createInviteLink(tws);
|
||||
const user = await invite('m3@affine.pro');
|
||||
const { members } = await getWorkspace(app, owner.token.token, tws.id);
|
||||
const memberInvite = members.find(m => m.id === user.id)!;
|
||||
t.deepEqual(
|
||||
event.emit.lastCall.args,
|
||||
[
|
||||
'workspace.members.reviewRequested',
|
||||
{ inviteId: memberInvite.inviteId },
|
||||
],
|
||||
'should emit review requested event'
|
||||
);
|
||||
|
||||
await revokeUser(app, owner.token.token, tws.id, user.id);
|
||||
t.deepEqual(
|
||||
event.emit.lastCall.args,
|
||||
[
|
||||
'workspace.members.requestDeclined',
|
||||
{ userId: user.id, workspaceId: tws.id },
|
||||
],
|
||||
'should emit review requested event'
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const { teamWorkspace: tws, owner, read } = await init(app);
|
||||
await grantMember(app, owner.token.token, tws.id, read.id, 'Admin');
|
||||
t.deepEqual(
|
||||
event.emit.lastCall.args,
|
||||
[
|
||||
'workspace.members.roleChanged',
|
||||
{ userId: read.id, workspaceId: tws.id, permission: Permission.Admin },
|
||||
],
|
||||
'should emit role changed event'
|
||||
);
|
||||
|
||||
await grantMember(app, owner.token.token, tws.id, read.id, 'Owner');
|
||||
const [ownerTransferred, roleChanged] = event.emit
|
||||
.getCalls()
|
||||
.map(call => call.args)
|
||||
.toReversed();
|
||||
t.deepEqual(
|
||||
roleChanged,
|
||||
[
|
||||
'workspace.members.roleChanged',
|
||||
{ userId: read.id, workspaceId: tws.id, permission: Permission.Owner },
|
||||
],
|
||||
'should emit role changed event'
|
||||
);
|
||||
t.deepEqual(
|
||||
ownerTransferred,
|
||||
[
|
||||
'workspace.members.ownerTransferred',
|
||||
{ email: owner.email, workspaceId: tws.id },
|
||||
],
|
||||
'should emit owner transferred event'
|
||||
);
|
||||
}
|
||||
});
|
||||
54
packages/backend/server/src/__tests__/user.e2e.ts
Normal file
54
packages/backend/server/src/__tests__/user.e2e.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import test from 'ava';
|
||||
import request from 'supertest';
|
||||
|
||||
import { AppModule } from '../app.module';
|
||||
import { createTestingApp, currentUser, signUp } from './utils';
|
||||
|
||||
let app: INestApplication;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
const { app: testApp } = await createTestingApp({
|
||||
imports: [AppModule],
|
||||
});
|
||||
app = testApp;
|
||||
});
|
||||
|
||||
test.afterEach.always(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test('should register a user', async t => {
|
||||
const user = await signUp(app, 'u1', 'u1@affine.pro', '123456');
|
||||
t.is(typeof user.id, 'string', 'user.id is not a string');
|
||||
t.is(user.name, 'u1', 'user.name is not valid');
|
||||
t.is(user.email, 'u1@affine.pro', 'user.email is not valid');
|
||||
});
|
||||
|
||||
test('should get current user', async t => {
|
||||
const user = await signUp(app, 'u1', 'u1@affine.pro', '123456');
|
||||
const currUser = await currentUser(app, user.token.token);
|
||||
t.is(currUser.id, user.id, 'user.id is not valid');
|
||||
t.is(currUser.name, user.name, 'user.name is not valid');
|
||||
t.is(currUser.email, user.email, 'user.email is not valid');
|
||||
t.true(currUser.hasPassword, 'currUser.hasPassword is not valid');
|
||||
});
|
||||
|
||||
test('should be able to delete user', async t => {
|
||||
const user = await signUp(app, 'u1', 'u1@affine.pro', '123456');
|
||||
await request(app.getHttpServer())
|
||||
.post('/graphql')
|
||||
.auth(user.token.token, { type: 'bearer' })
|
||||
.send({
|
||||
query: `
|
||||
mutation {
|
||||
deleteAccount {
|
||||
success
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
.expect(200);
|
||||
t.is(await currentUser(app, user.token.token), null);
|
||||
t.pass();
|
||||
});
|
||||
96
packages/backend/server/src/__tests__/user/user.e2e.ts
Normal file
96
packages/backend/server/src/__tests__/user/user.e2e.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import type { TestFn } from 'ava';
|
||||
import ava from 'ava';
|
||||
import request from 'supertest';
|
||||
|
||||
import { AppModule } from '../../app.module';
|
||||
import { AuthService, CurrentUser } from '../../core/auth';
|
||||
import { createTestingApp, gql, internalSignIn } from '../utils';
|
||||
|
||||
const test = ava as TestFn<{
|
||||
app: INestApplication;
|
||||
u1: CurrentUser;
|
||||
}>;
|
||||
|
||||
test.beforeEach(async t => {
|
||||
const { app } = await createTestingApp({
|
||||
imports: [AppModule],
|
||||
});
|
||||
|
||||
t.context.u1 = await app.get(AuthService).signUp('u1@affine.pro', '1');
|
||||
t.context.app = app;
|
||||
});
|
||||
|
||||
test.afterEach.always(async t => {
|
||||
await t.context.app.close();
|
||||
});
|
||||
|
||||
async function fakeUploadAvatar(
|
||||
app: INestApplication,
|
||||
userId: string,
|
||||
avatar: Buffer
|
||||
) {
|
||||
const cookie = await internalSignIn(app, userId);
|
||||
|
||||
return gql(app)
|
||||
.set('Cookie', cookie)
|
||||
.field(
|
||||
'operations',
|
||||
JSON.stringify({
|
||||
name: 'uploadAvatar',
|
||||
query: `mutation uploadAvatar($avatar: Upload!) {
|
||||
uploadAvatar(avatar: $avatar) {
|
||||
avatarUrl
|
||||
}
|
||||
}`,
|
||||
variables: { avatar: null },
|
||||
})
|
||||
)
|
||||
.field('map', JSON.stringify({ '0': ['variables.avatar'] }))
|
||||
.attach('0', avatar, {
|
||||
filename: 'test.png',
|
||||
contentType: 'image/png',
|
||||
});
|
||||
}
|
||||
|
||||
test('should be able to upload user avatar', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const avatar = Buffer.from('test');
|
||||
const res = await fakeUploadAvatar(app, t.context.u1.id, avatar);
|
||||
|
||||
t.is(res.status, 200);
|
||||
const avatarUrl = res.body.data.uploadAvatar.avatarUrl;
|
||||
t.truthy(avatarUrl);
|
||||
|
||||
const avatarRes = await request(app.getHttpServer())
|
||||
.get(new URL(avatarUrl).pathname)
|
||||
.expect(200);
|
||||
|
||||
t.deepEqual(avatarRes.body, Buffer.from('test'));
|
||||
});
|
||||
|
||||
test('should be able to update user avatar, and invalidate old avatar url', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const avatar = Buffer.from('test');
|
||||
let res = await fakeUploadAvatar(app, t.context.u1.id, avatar);
|
||||
|
||||
const oldAvatarUrl = res.body.data.uploadAvatar.avatarUrl;
|
||||
|
||||
const newAvatar = Buffer.from('new');
|
||||
res = await fakeUploadAvatar(app, t.context.u1.id, newAvatar);
|
||||
const newAvatarUrl = res.body.data.uploadAvatar.avatarUrl;
|
||||
|
||||
t.not(oldAvatarUrl, newAvatarUrl);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get(new URL(oldAvatarUrl).pathname)
|
||||
.expect(404);
|
||||
|
||||
const avatarRes = await request(app.getHttpServer())
|
||||
.get(new URL(newAvatarUrl).pathname)
|
||||
.expect(200);
|
||||
|
||||
t.deepEqual(avatarRes.body, Buffer.from('new'));
|
||||
});
|
||||
97
packages/backend/server/src/__tests__/utils/blobs.ts
Normal file
97
packages/backend/server/src/__tests__/utils/blobs.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
|
||||
import { gql } from './common';
|
||||
|
||||
export async function listBlobs(
|
||||
app: INestApplication,
|
||||
token: string,
|
||||
workspaceId: string
|
||||
): Promise<string[]> {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(gql)
|
||||
.auth(token, { type: 'bearer' })
|
||||
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
|
||||
.send({
|
||||
query: `
|
||||
query {
|
||||
listBlobs(workspaceId: "${workspaceId}")
|
||||
}
|
||||
`,
|
||||
})
|
||||
.expect(200);
|
||||
return res.body.data.listBlobs;
|
||||
}
|
||||
|
||||
export async function getWorkspaceBlobsSize(
|
||||
app: INestApplication,
|
||||
token: string,
|
||||
workspaceId: string
|
||||
): Promise<number> {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(gql)
|
||||
.auth(token, { type: 'bearer' })
|
||||
.send({
|
||||
query: `
|
||||
query {
|
||||
workspace(id: "${workspaceId}") {
|
||||
blobsSize
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
.expect(200);
|
||||
return res.body.data.workspace.blobsSize;
|
||||
}
|
||||
|
||||
export async function collectAllBlobSizes(
|
||||
app: INestApplication,
|
||||
token: string
|
||||
): Promise<number> {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(gql)
|
||||
.auth(token, { type: 'bearer' })
|
||||
.send({
|
||||
query: `
|
||||
query {
|
||||
currentUser {
|
||||
quotaUsage {
|
||||
storageQuota
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
.expect(200);
|
||||
return res.body.data.currentUser.quotaUsage.storageQuota;
|
||||
}
|
||||
|
||||
export async function setBlob(
|
||||
app: INestApplication,
|
||||
token: string,
|
||||
workspaceId: string,
|
||||
buffer: Buffer
|
||||
): Promise<string> {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(gql)
|
||||
.auth(token, { type: 'bearer' })
|
||||
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
|
||||
.field(
|
||||
'operations',
|
||||
JSON.stringify({
|
||||
name: 'setBlob',
|
||||
query: `mutation setBlob($blob: Upload!) {
|
||||
setBlob(workspaceId: "${workspaceId}", blob: $blob)
|
||||
}`,
|
||||
variables: { blob: null },
|
||||
})
|
||||
)
|
||||
.field('map', JSON.stringify({ '0': ['variables.blob'] }))
|
||||
.attach(
|
||||
'0',
|
||||
buffer,
|
||||
`blob-${Math.random().toString(16).substring(2, 10)}.data`
|
||||
)
|
||||
.expect(200);
|
||||
return res.body.data.setBlob;
|
||||
}
|
||||
1
packages/backend/server/src/__tests__/utils/common.ts
Normal file
1
packages/backend/server/src/__tests__/utils/common.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const gql = '/graphql';
|
||||
545
packages/backend/server/src/__tests__/utils/copilot.ts
Normal file
545
packages/backend/server/src/__tests__/utils/copilot.ts
Normal file
File diff suppressed because one or more lines are too long
27
packages/backend/server/src/__tests__/utils/feature.ts
Normal file
27
packages/backend/server/src/__tests__/utils/feature.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client';
|
||||
|
||||
import { Permission } from '../../core/permission';
|
||||
import { UserType } from '../../core/user/types';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceResolverMock {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
|
||||
async createWorkspace(user: UserType, _init: null) {
|
||||
const workspace = await this.prisma.workspace.create({
|
||||
data: {
|
||||
public: false,
|
||||
permissions: {
|
||||
create: {
|
||||
type: Permission.Owner,
|
||||
userId: user.id,
|
||||
accepted: true,
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return workspace;
|
||||
}
|
||||
}
|
||||
5
packages/backend/server/src/__tests__/utils/index.ts
Normal file
5
packages/backend/server/src/__tests__/utils/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './blobs';
|
||||
export * from './invite';
|
||||
export * from './user';
|
||||
export * from './utils';
|
||||
export * from './workspace';
|
||||
281
packages/backend/server/src/__tests__/utils/invite.ts
Normal file
281
packages/backend/server/src/__tests__/utils/invite.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
|
||||
import type { InvitationType } from '../../core/workspaces';
|
||||
import { gql } from './common';
|
||||
|
||||
export async function inviteUser(
|
||||
app: INestApplication,
|
||||
token: string,
|
||||
workspaceId: string,
|
||||
email: string,
|
||||
sendInviteMail = false
|
||||
): Promise<string> {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(gql)
|
||||
.auth(token, { type: 'bearer' })
|
||||
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
|
||||
.send({
|
||||
query: `
|
||||
mutation {
|
||||
invite(workspaceId: "${workspaceId}", email: "${email}", sendInviteMail: ${sendInviteMail})
|
||||
}
|
||||
`,
|
||||
})
|
||||
.expect(200);
|
||||
if (res.body.errors) {
|
||||
throw new Error(res.body.errors[0].message);
|
||||
}
|
||||
return res.body.data.invite;
|
||||
}
|
||||
|
||||
export async function inviteUsers(
|
||||
app: INestApplication,
|
||||
token: string,
|
||||
workspaceId: string,
|
||||
emails: string[],
|
||||
sendInviteMail = false
|
||||
): Promise<Array<{ email: string; inviteId?: string; sentSuccess?: boolean }>> {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(gql)
|
||||
.auth(token, { type: 'bearer' })
|
||||
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
|
||||
.send({
|
||||
query: `
|
||||
mutation inviteBatch($workspaceId: String!, $emails: [String!]!, $sendInviteMail: Boolean) {
|
||||
inviteBatch(
|
||||
workspaceId: $workspaceId
|
||||
emails: $emails
|
||||
sendInviteMail: $sendInviteMail
|
||||
) {
|
||||
email
|
||||
inviteId
|
||||
sentSuccess
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { workspaceId, emails, sendInviteMail },
|
||||
})
|
||||
.expect(200);
|
||||
if (res.body.errors) {
|
||||
throw new Error(res.body.errors[0].message);
|
||||
}
|
||||
return res.body.data.inviteBatch;
|
||||
}
|
||||
|
||||
export async function getInviteLink(
|
||||
app: INestApplication,
|
||||
token: string,
|
||||
workspaceId: string
|
||||
): Promise<{ link: string; expireTime: string }> {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(gql)
|
||||
.auth(token, { type: 'bearer' })
|
||||
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
|
||||
.send({
|
||||
query: `
|
||||
query {
|
||||
workspace(id: "${workspaceId}") {
|
||||
inviteLink {
|
||||
link
|
||||
expireTime
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
.expect(200);
|
||||
if (res.body.errors) {
|
||||
throw new Error(res.body.errors[0].message);
|
||||
}
|
||||
return res.body.data.workspace.inviteLink;
|
||||
}
|
||||
|
||||
export async function createInviteLink(
|
||||
app: INestApplication,
|
||||
token: string,
|
||||
workspaceId: string,
|
||||
expireTime: 'OneDay' | 'ThreeDays' | 'OneWeek' | 'OneMonth'
|
||||
): Promise<{ link: string; expireTime: string }> {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(gql)
|
||||
.auth(token, { type: 'bearer' })
|
||||
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
|
||||
.send({
|
||||
query: `
|
||||
mutation {
|
||||
createInviteLink(workspaceId: "${workspaceId}", expireTime: ${expireTime}) {
|
||||
link
|
||||
expireTime
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
.expect(200);
|
||||
if (res.body.errors) {
|
||||
throw new Error(res.body.errors[0].message);
|
||||
}
|
||||
return res.body.data.createInviteLink;
|
||||
}
|
||||
|
||||
export async function revokeInviteLink(
|
||||
app: INestApplication,
|
||||
token: string,
|
||||
workspaceId: string
|
||||
): Promise<boolean> {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(gql)
|
||||
.auth(token, { type: 'bearer' })
|
||||
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
|
||||
.send({
|
||||
query: `
|
||||
mutation {
|
||||
revokeInviteLink(workspaceId: "${workspaceId}")
|
||||
}
|
||||
`,
|
||||
})
|
||||
.expect(200);
|
||||
if (res.body.errors) {
|
||||
throw new Error(res.body.errors[0].message);
|
||||
}
|
||||
return res.body.data.revokeInviteLink;
|
||||
}
|
||||
|
||||
export async function acceptInviteById(
|
||||
app: INestApplication,
|
||||
workspaceId: string,
|
||||
inviteId: string,
|
||||
sendAcceptMail = false,
|
||||
token: string = ''
|
||||
): Promise<boolean> {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(gql)
|
||||
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
|
||||
.auth(token, { type: 'bearer' })
|
||||
.send({
|
||||
query: `
|
||||
mutation {
|
||||
acceptInviteById(workspaceId: "${workspaceId}", inviteId: "${inviteId}", sendAcceptMail: ${sendAcceptMail})
|
||||
}
|
||||
`,
|
||||
})
|
||||
.expect(200);
|
||||
if (res.body.errors) {
|
||||
throw new Error(res.body.errors[0].message, {
|
||||
cause: res.body.errors[0].cause,
|
||||
});
|
||||
}
|
||||
return res.body.data.acceptInviteById;
|
||||
}
|
||||
|
||||
export async function approveMember(
|
||||
app: INestApplication,
|
||||
token: string,
|
||||
workspaceId: string,
|
||||
userId: string
|
||||
): Promise<string> {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(gql)
|
||||
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
|
||||
.auth(token, { type: 'bearer' })
|
||||
.send({
|
||||
query: `
|
||||
mutation {
|
||||
approveMember(workspaceId: "${workspaceId}", userId: "${userId}")
|
||||
}
|
||||
`,
|
||||
})
|
||||
.expect(200);
|
||||
if (res.body.errors) {
|
||||
throw new Error(res.body.errors[0].message, {
|
||||
cause: res.body.errors[0].cause,
|
||||
});
|
||||
}
|
||||
return res.body.data.approveMember;
|
||||
}
|
||||
|
||||
export async function leaveWorkspace(
|
||||
app: INestApplication,
|
||||
token: string,
|
||||
workspaceId: string,
|
||||
sendLeaveMail = false
|
||||
): Promise<boolean> {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(gql)
|
||||
.auth(token, { type: 'bearer' })
|
||||
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
|
||||
.send({
|
||||
query: `
|
||||
mutation {
|
||||
leaveWorkspace(workspaceId: "${workspaceId}", sendLeaveMail: ${sendLeaveMail})
|
||||
}
|
||||
`,
|
||||
})
|
||||
.expect(200);
|
||||
if (res.body.errors) {
|
||||
throw new Error(res.body.errors[0].message);
|
||||
}
|
||||
return res.body.data.leaveWorkspace;
|
||||
}
|
||||
|
||||
export async function revokeUser(
|
||||
app: INestApplication,
|
||||
token: string,
|
||||
workspaceId: string,
|
||||
userId: string
|
||||
): Promise<boolean> {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(gql)
|
||||
.auth(token, { type: 'bearer' })
|
||||
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
|
||||
.send({
|
||||
query: `
|
||||
mutation {
|
||||
revoke(workspaceId: "${workspaceId}", userId: "${userId}")
|
||||
}
|
||||
`,
|
||||
})
|
||||
.expect(200);
|
||||
if (res.body.errors) {
|
||||
throw new Error(res.body.errors[0].message, {
|
||||
cause: res.body.errors[0].cause,
|
||||
});
|
||||
}
|
||||
return res.body.data.revoke;
|
||||
}
|
||||
|
||||
export async function getInviteInfo(
|
||||
app: INestApplication,
|
||||
token: string,
|
||||
inviteId: string
|
||||
): Promise<InvitationType> {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(gql)
|
||||
.auth(token, { type: 'bearer' })
|
||||
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
|
||||
.send({
|
||||
query: `
|
||||
query {
|
||||
getInviteInfo(inviteId: "${inviteId}") {
|
||||
workspace {
|
||||
id
|
||||
name
|
||||
avatar
|
||||
}
|
||||
user {
|
||||
id
|
||||
name
|
||||
avatarUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
.expect(200);
|
||||
if (res.body.errors) {
|
||||
throw new Error(res.body.errors[0].message, {
|
||||
cause: res.body.errors[0].cause,
|
||||
});
|
||||
}
|
||||
return res.body.data.getInviteInfo;
|
||||
}
|
||||
200
packages/backend/server/src/__tests__/utils/user.ts
Normal file
200
packages/backend/server/src/__tests__/utils/user.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import request, { type Response } from 'supertest';
|
||||
|
||||
import {
|
||||
AuthService,
|
||||
type ClientTokenType,
|
||||
type CurrentUser,
|
||||
} from '../../core/auth';
|
||||
import { sessionUser } from '../../core/auth/service';
|
||||
import { UserService, type UserType } from '../../core/user';
|
||||
import { gql } from './common';
|
||||
|
||||
export type UserAuthedType = UserType & { token: ClientTokenType };
|
||||
|
||||
export async function internalSignIn(app: INestApplication, userId: string) {
|
||||
const auth = app.get(AuthService);
|
||||
|
||||
const session = await auth.createUserSession(userId);
|
||||
|
||||
return `${AuthService.sessionCookieName}=${session.sessionId}`;
|
||||
}
|
||||
|
||||
export function sessionCookie(headers: any): string {
|
||||
const cookie = headers['set-cookie']?.find((c: string) =>
|
||||
c.startsWith(`${AuthService.sessionCookieName}=`)
|
||||
);
|
||||
|
||||
if (!cookie) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return cookie.split(';')[0];
|
||||
}
|
||||
|
||||
export async function getSession(
|
||||
app: INestApplication,
|
||||
signInRes: Response
|
||||
): Promise<{ user?: CurrentUser }> {
|
||||
const cookie = sessionCookie(signInRes.headers);
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/auth/session')
|
||||
.set('cookie', cookie!)
|
||||
.expect(200);
|
||||
|
||||
return res.body;
|
||||
}
|
||||
|
||||
export async function signUp(
|
||||
app: INestApplication,
|
||||
name: string,
|
||||
email: string,
|
||||
password: string,
|
||||
autoVerifyEmail = true
|
||||
): Promise<UserAuthedType> {
|
||||
const user = await app.get(UserService).createUser({
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
emailVerifiedAt: autoVerifyEmail ? new Date() : null,
|
||||
});
|
||||
const { sessionId } = await app.get(AuthService).createUserSession(user.id);
|
||||
|
||||
return {
|
||||
...sessionUser(user),
|
||||
token: { token: sessionId, refresh: '' },
|
||||
};
|
||||
}
|
||||
|
||||
export async function currentUser(app: INestApplication, token: string) {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(gql)
|
||||
.auth(token, { type: 'bearer' })
|
||||
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
|
||||
.send({
|
||||
query: `
|
||||
query {
|
||||
currentUser {
|
||||
id, name, email, emailVerified, avatarUrl, hasPassword,
|
||||
token { token }
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
.expect(200);
|
||||
return res.body.data.currentUser;
|
||||
}
|
||||
|
||||
export async function sendChangeEmail(
|
||||
app: INestApplication,
|
||||
userToken: string,
|
||||
email: string,
|
||||
callbackUrl: string
|
||||
): Promise<boolean> {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(gql)
|
||||
.auth(userToken, { type: 'bearer' })
|
||||
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
|
||||
.send({
|
||||
query: `
|
||||
mutation {
|
||||
sendChangeEmail(email: "${email}", callbackUrl: "${callbackUrl}")
|
||||
}
|
||||
`,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
return res.body.data.sendChangeEmail;
|
||||
}
|
||||
|
||||
export async function sendSetPasswordEmail(
|
||||
app: INestApplication,
|
||||
userToken: string,
|
||||
email: string,
|
||||
callbackUrl: string
|
||||
): Promise<boolean> {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(gql)
|
||||
.auth(userToken, { type: 'bearer' })
|
||||
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
|
||||
.send({
|
||||
query: `
|
||||
mutation {
|
||||
sendSetPasswordEmail(email: "${email}", callbackUrl: "${callbackUrl}")
|
||||
}
|
||||
`,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
return res.body.data.sendChangeEmail;
|
||||
}
|
||||
|
||||
export async function changePassword(
|
||||
app: INestApplication,
|
||||
userId: string,
|
||||
token: string,
|
||||
password: string
|
||||
): Promise<string> {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(gql)
|
||||
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
|
||||
.send({
|
||||
query: `
|
||||
mutation changePassword($token: String!, $userId: String!, $password: String!) {
|
||||
changePassword(token: $token, userId: $userId, newPassword: $password)
|
||||
}
|
||||
`,
|
||||
variables: { token, password, userId },
|
||||
})
|
||||
.expect(200);
|
||||
return res.body.data.changePassword;
|
||||
}
|
||||
|
||||
export async function sendVerifyChangeEmail(
|
||||
app: INestApplication,
|
||||
userToken: string,
|
||||
token: string,
|
||||
email: string,
|
||||
callbackUrl: string
|
||||
): Promise<boolean> {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(gql)
|
||||
.auth(userToken, { type: 'bearer' })
|
||||
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
|
||||
.send({
|
||||
query: `
|
||||
mutation {
|
||||
sendVerifyChangeEmail(token:"${token}", email: "${email}", callbackUrl: "${callbackUrl}")
|
||||
}
|
||||
`,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
return res.body.data.sendVerifyChangeEmail;
|
||||
}
|
||||
|
||||
export async function changeEmail(
|
||||
app: INestApplication,
|
||||
userToken: string,
|
||||
token: string,
|
||||
email: string
|
||||
): Promise<UserAuthedType> {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(gql)
|
||||
.auth(userToken, { type: 'bearer' })
|
||||
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
|
||||
.send({
|
||||
query: `
|
||||
mutation {
|
||||
changeEmail(token: "${token}", email: "${email}") {
|
||||
id
|
||||
name
|
||||
avatarUrl
|
||||
email
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
.expect(200);
|
||||
return res.body.data.changeEmail;
|
||||
}
|
||||
190
packages/backend/server/src/__tests__/utils/utils.ts
Normal file
190
packages/backend/server/src/__tests__/utils/utils.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { INestApplication, ModuleMetadata } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { Query, Resolver } from '@nestjs/graphql';
|
||||
import { Test, TestingModuleBuilder } from '@nestjs/testing';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
|
||||
import type { Response } from 'supertest';
|
||||
import supertest from 'supertest';
|
||||
|
||||
import { AppModule, FunctionalityModules } from '../../app.module';
|
||||
import { GlobalExceptionFilter, Runtime } from '../../base';
|
||||
import { GqlModule } from '../../base/graphql';
|
||||
import { AuthGuard, AuthModule } from '../../core/auth';
|
||||
import { UserFeaturesInit1698652531198 } from '../../data/migrations/1698652531198-user-features-init';
|
||||
import { ModelModules } from '../../models';
|
||||
|
||||
export type PermissionEnum = 'Owner' | 'Admin' | 'Write' | 'Read';
|
||||
|
||||
async function flushDB(client: PrismaClient) {
|
||||
const result: { tablename: string }[] =
|
||||
await client.$queryRaw`SELECT tablename
|
||||
FROM pg_catalog.pg_tables
|
||||
WHERE schemaname != 'pg_catalog'
|
||||
AND schemaname != 'information_schema'`;
|
||||
|
||||
// remove all table data
|
||||
await client.$executeRawUnsafe(
|
||||
`TRUNCATE TABLE ${result
|
||||
.map(({ tablename }) => tablename)
|
||||
.filter(name => !name.includes('migrations'))
|
||||
.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
async function initFeatureConfigs(db: PrismaClient) {
|
||||
await UserFeaturesInit1698652531198.up(db);
|
||||
}
|
||||
|
||||
export async function initTestingDB(db: PrismaClient) {
|
||||
await flushDB(db);
|
||||
await initFeatureConfigs(db);
|
||||
}
|
||||
|
||||
interface TestingModuleMeatdata extends ModuleMetadata {
|
||||
tapModule?(m: TestingModuleBuilder): void;
|
||||
tapApp?(app: INestApplication): void;
|
||||
}
|
||||
|
||||
function dedupeModules(modules: NonNullable<ModuleMetadata['imports']>) {
|
||||
const map = new Map();
|
||||
|
||||
modules.forEach(m => {
|
||||
if ('module' in m) {
|
||||
map.set(m.module, m);
|
||||
} else {
|
||||
map.set(m, m);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
@Resolver(() => String)
|
||||
class MockResolver {
|
||||
@Query(() => String)
|
||||
hello() {
|
||||
return 'hello world';
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTestingModule(
|
||||
moduleDef: TestingModuleMeatdata = {},
|
||||
init = true
|
||||
) {
|
||||
// setting up
|
||||
let imports = moduleDef.imports ?? [];
|
||||
imports =
|
||||
imports[0] === AppModule
|
||||
? [AppModule]
|
||||
: dedupeModules([
|
||||
...FunctionalityModules,
|
||||
ModelModules,
|
||||
AuthModule,
|
||||
GqlModule,
|
||||
...imports,
|
||||
]);
|
||||
|
||||
const builder = Test.createTestingModule({
|
||||
imports,
|
||||
providers: [
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: AuthGuard,
|
||||
},
|
||||
MockResolver,
|
||||
...(moduleDef.providers ?? []),
|
||||
],
|
||||
controllers: moduleDef.controllers,
|
||||
});
|
||||
|
||||
if (moduleDef.tapModule) {
|
||||
moduleDef.tapModule(builder);
|
||||
}
|
||||
|
||||
const m = await builder.compile();
|
||||
|
||||
const prisma = m.get(PrismaClient);
|
||||
if (prisma instanceof PrismaClient) {
|
||||
await initTestingDB(prisma);
|
||||
}
|
||||
|
||||
if (init) {
|
||||
await m.init();
|
||||
|
||||
const runtime = m.get(Runtime);
|
||||
// by pass password min length validation
|
||||
await runtime.set('auth/password.min', 1);
|
||||
}
|
||||
|
||||
return m;
|
||||
}
|
||||
|
||||
export async function createTestingApp(moduleDef: TestingModuleMeatdata = {}) {
|
||||
const m = await createTestingModule(moduleDef, false);
|
||||
|
||||
const app = m.createNestApplication({
|
||||
cors: true,
|
||||
bodyParser: true,
|
||||
rawBody: true,
|
||||
logger: ['warn'],
|
||||
});
|
||||
|
||||
app.useGlobalFilters(new GlobalExceptionFilter(app.getHttpAdapter()));
|
||||
app.use(
|
||||
graphqlUploadExpress({
|
||||
maxFileSize: 10 * 1024 * 1024,
|
||||
maxFiles: 5,
|
||||
})
|
||||
);
|
||||
|
||||
app.use(cookieParser());
|
||||
|
||||
if (moduleDef.tapApp) {
|
||||
moduleDef.tapApp(app);
|
||||
}
|
||||
|
||||
await app.init();
|
||||
|
||||
const runtime = app.get(Runtime);
|
||||
// by pass password min length validation
|
||||
await runtime.set('auth/password.min', 1);
|
||||
|
||||
return {
|
||||
module: m,
|
||||
app,
|
||||
};
|
||||
}
|
||||
|
||||
export function handleGraphQLError(resp: Response) {
|
||||
const { errors } = resp.body;
|
||||
if (errors) {
|
||||
const cause = errors[0];
|
||||
const stacktrace = cause.extensions?.stacktrace;
|
||||
throw new Error(
|
||||
stacktrace
|
||||
? Array.isArray(stacktrace)
|
||||
? stacktrace.join('\n')
|
||||
: String(stacktrace)
|
||||
: cause.message,
|
||||
cause
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function gql(app: INestApplication, query?: string) {
|
||||
const req = supertest(app.getHttpServer())
|
||||
.post('/graphql')
|
||||
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' });
|
||||
|
||||
if (query) {
|
||||
return req.send({ query });
|
||||
}
|
||||
|
||||
return req;
|
||||
}
|
||||
|
||||
export async function sleep(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
182
packages/backend/server/src/__tests__/utils/workspace.ts
Normal file
182
packages/backend/server/src/__tests__/utils/workspace.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
|
||||
import type { WorkspaceType } from '../../core/workspaces';
|
||||
import { gql } from './common';
|
||||
import { PermissionEnum } from './utils';
|
||||
|
||||
export async function createWorkspace(
|
||||
app: INestApplication,
|
||||
token: string
|
||||
): Promise<WorkspaceType> {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(gql)
|
||||
.auth(token, { type: 'bearer' })
|
||||
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
|
||||
.field(
|
||||
'operations',
|
||||
JSON.stringify({
|
||||
name: 'createWorkspace',
|
||||
query: `mutation createWorkspace($init: Upload!) {
|
||||
createWorkspace(init: $init) {
|
||||
id
|
||||
}
|
||||
}`,
|
||||
variables: { init: null },
|
||||
})
|
||||
)
|
||||
.field('map', JSON.stringify({ '0': ['variables.init'] }))
|
||||
.attach('0', Buffer.from([0, 0]), 'init.data')
|
||||
.expect(200);
|
||||
return res.body.data.createWorkspace;
|
||||
}
|
||||
|
||||
export async function getWorkspacePublicPages(
|
||||
app: INestApplication,
|
||||
token: string,
|
||||
workspaceId: string
|
||||
) {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(gql)
|
||||
.auth(token, { type: 'bearer' })
|
||||
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
|
||||
.send({
|
||||
query: `
|
||||
query {
|
||||
workspace(id: "${workspaceId}") {
|
||||
publicPages {
|
||||
id
|
||||
mode
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
.expect(200);
|
||||
return res.body.data.workspace.publicPages;
|
||||
}
|
||||
|
||||
export async function getWorkspace(
|
||||
app: INestApplication,
|
||||
token: string,
|
||||
workspaceId: string,
|
||||
skip = 0,
|
||||
take = 8
|
||||
): Promise<WorkspaceType> {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(gql)
|
||||
.auth(token, { type: 'bearer' })
|
||||
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
|
||||
.send({
|
||||
query: `
|
||||
query {
|
||||
workspace(id: "${workspaceId}") {
|
||||
id, members(skip: ${skip}, take: ${take}) { id, name, email, permission, inviteId, status }
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
.expect(200);
|
||||
return res.body.data.workspace;
|
||||
}
|
||||
|
||||
export async function updateWorkspace(
|
||||
app: INestApplication,
|
||||
token: string,
|
||||
workspaceId: string,
|
||||
isPublic: boolean
|
||||
): Promise<boolean> {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(gql)
|
||||
.auth(token, { type: 'bearer' })
|
||||
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
|
||||
.send({
|
||||
query: `
|
||||
mutation {
|
||||
updateWorkspace(input: { id: "${workspaceId}", public: ${isPublic} }) {
|
||||
public
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
.expect(200);
|
||||
return res.body.data.updateWorkspace.public;
|
||||
}
|
||||
|
||||
export async function publishPage(
|
||||
app: INestApplication,
|
||||
token: string,
|
||||
workspaceId: string,
|
||||
pageId: string
|
||||
) {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(gql)
|
||||
.auth(token, { type: 'bearer' })
|
||||
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
|
||||
.send({
|
||||
query: `
|
||||
mutation {
|
||||
publishPage(workspaceId: "${workspaceId}", pageId: "${pageId}") {
|
||||
id
|
||||
mode
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
.expect(200);
|
||||
return res.body.errors?.[0]?.message || res.body.data?.publishPage;
|
||||
}
|
||||
|
||||
export async function revokePublicPage(
|
||||
app: INestApplication,
|
||||
token: string,
|
||||
workspaceId: string,
|
||||
pageId: string
|
||||
) {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(gql)
|
||||
.auth(token, { type: 'bearer' })
|
||||
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
|
||||
.send({
|
||||
query: `
|
||||
mutation {
|
||||
revokePublicPage(workspaceId: "${workspaceId}", pageId: "${pageId}") {
|
||||
id
|
||||
mode
|
||||
public
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
.expect(200);
|
||||
return res.body.errors?.[0]?.message || res.body.data?.revokePublicPage;
|
||||
}
|
||||
|
||||
export async function grantMember(
|
||||
app: INestApplication,
|
||||
token: string,
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
permission: PermissionEnum
|
||||
) {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post(gql)
|
||||
.auth(token, { type: 'bearer' })
|
||||
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
|
||||
.send({
|
||||
query: `
|
||||
mutation {
|
||||
grantMember(
|
||||
workspaceId: "${workspaceId}"
|
||||
userId: "${userId}"
|
||||
permission: ${permission}
|
||||
)
|
||||
}
|
||||
`,
|
||||
})
|
||||
.expect(200);
|
||||
if (res.body.errors) {
|
||||
throw new Error(res.body.errors[0].message);
|
||||
}
|
||||
return res.body.data?.grantMember;
|
||||
}
|
||||
240
packages/backend/server/src/__tests__/workspace-invite.e2e.ts
Normal file
240
packages/backend/server/src/__tests__/workspace-invite.e2e.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import {
|
||||
getCurrentMailMessageCount,
|
||||
getLatestMailMessage,
|
||||
} from '@affine-test/kit/utils/cloud';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import type { TestFn } from 'ava';
|
||||
import ava from 'ava';
|
||||
|
||||
import { AppModule } from '../app.module';
|
||||
import { MailService } from '../base/mailer';
|
||||
import { AuthService } from '../core/auth/service';
|
||||
import { UserService } from '../core/user';
|
||||
import {
|
||||
acceptInviteById,
|
||||
createTestingApp,
|
||||
createWorkspace,
|
||||
getWorkspace,
|
||||
inviteUser,
|
||||
leaveWorkspace,
|
||||
revokeUser,
|
||||
signUp,
|
||||
} from './utils';
|
||||
|
||||
const test = ava as TestFn<{
|
||||
app: INestApplication;
|
||||
client: PrismaClient;
|
||||
auth: AuthService;
|
||||
mail: MailService;
|
||||
user: UserService;
|
||||
}>;
|
||||
|
||||
test.beforeEach(async t => {
|
||||
const { app } = await createTestingApp({
|
||||
imports: [AppModule],
|
||||
});
|
||||
t.context.app = app;
|
||||
t.context.client = app.get(PrismaClient);
|
||||
t.context.auth = app.get(AuthService);
|
||||
t.context.mail = app.get(MailService);
|
||||
t.context.user = app.get(UserService);
|
||||
});
|
||||
|
||||
test.afterEach.always(async t => {
|
||||
await t.context.app.close();
|
||||
});
|
||||
|
||||
test('should invite a user', async t => {
|
||||
const { app } = t.context;
|
||||
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
|
||||
const u2 = await signUp(app, 'u2', 'u2@affine.pro', '1');
|
||||
|
||||
const workspace = await createWorkspace(app, u1.token.token);
|
||||
|
||||
const invite = await inviteUser(app, u1.token.token, workspace.id, u2.email);
|
||||
t.truthy(invite, 'failed to invite user');
|
||||
});
|
||||
|
||||
test('should leave a workspace', async t => {
|
||||
const { app } = t.context;
|
||||
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
|
||||
const u2 = await signUp(app, 'u2', 'u2@affine.pro', '1');
|
||||
|
||||
const workspace = await createWorkspace(app, u1.token.token);
|
||||
const id = await inviteUser(app, u1.token.token, workspace.id, u2.email);
|
||||
await acceptInviteById(app, workspace.id, id, false);
|
||||
|
||||
const leave = await leaveWorkspace(app, u2.token.token, workspace.id);
|
||||
|
||||
t.pass();
|
||||
t.true(leave, 'failed to leave workspace');
|
||||
});
|
||||
|
||||
test('should revoke a user', async t => {
|
||||
const { app } = t.context;
|
||||
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
|
||||
const u2 = await signUp(app, 'u2', 'u2@affine.pro', '1');
|
||||
|
||||
const workspace = await createWorkspace(app, u1.token.token);
|
||||
await inviteUser(app, u1.token.token, workspace.id, u2.email);
|
||||
|
||||
const currWorkspace = await getWorkspace(app, u1.token.token, workspace.id);
|
||||
t.is(currWorkspace.members.length, 2, 'failed to invite user');
|
||||
|
||||
const revoke = await revokeUser(app, u1.token.token, workspace.id, u2.id);
|
||||
t.true(revoke, 'failed to revoke user');
|
||||
});
|
||||
|
||||
test('should create user if not exist', async t => {
|
||||
const { app, user } = t.context;
|
||||
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
|
||||
|
||||
const workspace = await createWorkspace(app, u1.token.token);
|
||||
|
||||
await inviteUser(app, u1.token.token, workspace.id, 'u2@affine.pro');
|
||||
|
||||
const u2 = await user.findUserByEmail('u2@affine.pro');
|
||||
t.not(u2, undefined, 'failed to create user');
|
||||
t.is(u2?.name, 'u2', 'failed to create user');
|
||||
});
|
||||
|
||||
test('should invite a user by link', async t => {
|
||||
const { app } = t.context;
|
||||
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
|
||||
const u2 = await signUp(app, 'u2', 'u2@affine.pro', '1');
|
||||
|
||||
const workspace = await createWorkspace(app, u1.token.token);
|
||||
|
||||
const invite = await inviteUser(app, u1.token.token, workspace.id, u2.email);
|
||||
|
||||
const accept = await acceptInviteById(app, workspace.id, invite);
|
||||
t.true(accept, 'failed to accept invite');
|
||||
|
||||
const invite1 = await inviteUser(app, u1.token.token, workspace.id, u2.email);
|
||||
|
||||
t.is(invite, invite1, 'repeat the invitation must return same id');
|
||||
|
||||
const currWorkspace = await getWorkspace(app, u1.token.token, workspace.id);
|
||||
const currMember = currWorkspace.members.find(u => u.email === u2.email);
|
||||
t.not(currMember, undefined, 'failed to invite user');
|
||||
t.is(currMember?.inviteId, invite, 'failed to check invite id');
|
||||
});
|
||||
|
||||
test('should send email', async t => {
|
||||
const { mail, app } = t.context;
|
||||
if (mail.hasConfigured()) {
|
||||
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
|
||||
const u2 = await signUp(app, 'test', 'production@toeverything.info', '1');
|
||||
|
||||
const workspace = await createWorkspace(app, u1.token.token);
|
||||
const primitiveMailCount = await getCurrentMailMessageCount();
|
||||
|
||||
const invite = await inviteUser(
|
||||
app,
|
||||
u1.token.token,
|
||||
workspace.id,
|
||||
u2.email,
|
||||
true
|
||||
);
|
||||
|
||||
const afterInviteMailCount = await getCurrentMailMessageCount();
|
||||
t.is(
|
||||
primitiveMailCount + 1,
|
||||
afterInviteMailCount,
|
||||
'failed to send invite email'
|
||||
);
|
||||
const inviteEmailContent = await getLatestMailMessage();
|
||||
|
||||
t.not(
|
||||
inviteEmailContent.To.find((item: any) => {
|
||||
return item.Mailbox === 'production';
|
||||
}),
|
||||
undefined,
|
||||
'invite email address was incorrectly sent'
|
||||
);
|
||||
|
||||
const accept = await acceptInviteById(app, workspace.id, invite, true);
|
||||
t.true(accept, 'failed to accept invite');
|
||||
|
||||
const afterAcceptMailCount = await getCurrentMailMessageCount();
|
||||
t.is(
|
||||
afterInviteMailCount + 1,
|
||||
afterAcceptMailCount,
|
||||
'failed to send accepted email to owner'
|
||||
);
|
||||
const acceptEmailContent = await getLatestMailMessage();
|
||||
t.not(
|
||||
acceptEmailContent.To.find((item: any) => {
|
||||
return item.Mailbox === 'u1';
|
||||
}),
|
||||
undefined,
|
||||
'accept email address was incorrectly sent'
|
||||
);
|
||||
|
||||
await leaveWorkspace(app, u2.token.token, workspace.id, true);
|
||||
|
||||
const afterLeaveMailCount = await getCurrentMailMessageCount();
|
||||
t.is(
|
||||
afterAcceptMailCount + 1,
|
||||
afterLeaveMailCount,
|
||||
'failed to send leave email to owner'
|
||||
);
|
||||
const leaveEmailContent = await getLatestMailMessage();
|
||||
t.not(
|
||||
leaveEmailContent.To.find((item: any) => {
|
||||
return item.Mailbox === 'u1';
|
||||
}),
|
||||
undefined,
|
||||
'leave email address was incorrectly sent'
|
||||
);
|
||||
}
|
||||
t.pass();
|
||||
});
|
||||
|
||||
test('should support pagination for member', async t => {
|
||||
const { app } = t.context;
|
||||
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
|
||||
const u2 = await signUp(app, 'u2', 'u2@affine.pro', '1');
|
||||
const u3 = await signUp(app, 'u3', 'u3@affine.pro', '1');
|
||||
|
||||
const workspace = await createWorkspace(app, u1.token.token);
|
||||
const invite1 = await inviteUser(app, u1.token.token, workspace.id, u2.email);
|
||||
const invite2 = await inviteUser(app, u1.token.token, workspace.id, u3.email);
|
||||
|
||||
await acceptInviteById(app, workspace.id, invite1, false);
|
||||
await acceptInviteById(app, workspace.id, invite2, false);
|
||||
|
||||
const firstPageWorkspace = await getWorkspace(
|
||||
app,
|
||||
u1.token.token,
|
||||
workspace.id,
|
||||
0,
|
||||
2
|
||||
);
|
||||
t.is(firstPageWorkspace.members.length, 2, 'failed to check invite id');
|
||||
const secondPageWorkspace = await getWorkspace(
|
||||
app,
|
||||
u1.token.token,
|
||||
workspace.id,
|
||||
2,
|
||||
2
|
||||
);
|
||||
t.is(secondPageWorkspace.members.length, 1, 'failed to check invite id');
|
||||
});
|
||||
|
||||
test('should limit member count correctly', async t => {
|
||||
const { app } = t.context;
|
||||
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const workspace = await createWorkspace(app, u1.token.token);
|
||||
await Promise.allSettled(
|
||||
Array.from({ length: 10 }).map(async (_, i) =>
|
||||
inviteUser(app, u1.token.token, workspace.id, `u${i}@affine.pro`)
|
||||
)
|
||||
);
|
||||
|
||||
const ws = await getWorkspace(app, u1.token.token, workspace.id);
|
||||
t.assert(ws.members.length <= 3, 'failed to check member list');
|
||||
}
|
||||
});
|
||||
250
packages/backend/server/src/__tests__/workspace.e2e.ts
Normal file
250
packages/backend/server/src/__tests__/workspace.e2e.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import type { TestFn } from 'ava';
|
||||
import ava from 'ava';
|
||||
import request from 'supertest';
|
||||
|
||||
import { AppModule } from '../app.module';
|
||||
import {
|
||||
acceptInviteById,
|
||||
createTestingApp,
|
||||
createWorkspace,
|
||||
getWorkspacePublicPages,
|
||||
inviteUser,
|
||||
publishPage,
|
||||
revokePublicPage,
|
||||
signUp,
|
||||
updateWorkspace,
|
||||
} from './utils';
|
||||
|
||||
const test = ava as TestFn<{
|
||||
app: INestApplication;
|
||||
client: PrismaClient;
|
||||
}>;
|
||||
|
||||
test.beforeEach(async t => {
|
||||
const { app } = await createTestingApp({
|
||||
imports: [AppModule],
|
||||
});
|
||||
|
||||
t.context.client = app.get(PrismaClient);
|
||||
t.context.app = app;
|
||||
});
|
||||
|
||||
test.afterEach.always(async t => {
|
||||
await t.context.app.close();
|
||||
});
|
||||
|
||||
test('should register a user', async t => {
|
||||
const user = await signUp(t.context.app, 'u1', 'u1@affine.pro', '123456');
|
||||
t.is(typeof user.id, 'string', 'user.id is not a string');
|
||||
t.is(user.name, 'u1', 'user.name is not valid');
|
||||
t.is(user.email, 'u1@affine.pro', 'user.email is not valid');
|
||||
});
|
||||
|
||||
test('should create a workspace', async t => {
|
||||
const { app } = t.context;
|
||||
const user = await signUp(app, 'u1', 'u1@affine.pro', '1');
|
||||
|
||||
const workspace = await createWorkspace(app, user.token.token);
|
||||
t.is(typeof workspace.id, 'string', 'workspace.id is not a string');
|
||||
});
|
||||
|
||||
test('should can publish workspace', async t => {
|
||||
const { app } = t.context;
|
||||
const user = await signUp(app, 'u1', 'u1@affine.pro', '1');
|
||||
const workspace = await createWorkspace(app, user.token.token);
|
||||
|
||||
const isPublic = await updateWorkspace(
|
||||
app,
|
||||
user.token.token,
|
||||
workspace.id,
|
||||
true
|
||||
);
|
||||
t.true(isPublic, 'failed to publish workspace');
|
||||
|
||||
const isPrivate = await updateWorkspace(
|
||||
app,
|
||||
user.token.token,
|
||||
workspace.id,
|
||||
false
|
||||
);
|
||||
t.false(isPrivate, 'failed to unpublish workspace');
|
||||
});
|
||||
|
||||
test('should share a page', async t => {
|
||||
const { app } = t.context;
|
||||
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
|
||||
const u2 = await signUp(app, 'u2', 'u2@affine.pro', '1');
|
||||
|
||||
const workspace = await createWorkspace(app, u1.token.token);
|
||||
|
||||
const share = await publishPage(app, u1.token.token, workspace.id, 'page1');
|
||||
t.is(share.id, 'page1', 'failed to share page');
|
||||
const pages = await getWorkspacePublicPages(
|
||||
app,
|
||||
u1.token.token,
|
||||
workspace.id
|
||||
);
|
||||
t.is(pages.length, 1, 'failed to get shared pages');
|
||||
t.deepEqual(
|
||||
pages[0],
|
||||
{ id: 'page1', mode: 'Page' },
|
||||
'failed to get shared page: page1'
|
||||
);
|
||||
|
||||
const resp1 = await request(app.getHttpServer())
|
||||
.get(`/api/workspaces/${workspace.id}/docs/${workspace.id}`)
|
||||
.auth(u1.token.token, { type: 'bearer' });
|
||||
t.is(resp1.statusCode, 200, 'failed to get root doc with u1 token');
|
||||
const resp2 = await request(app.getHttpServer()).get(
|
||||
`/api/workspaces/${workspace.id}/docs/${workspace.id}`
|
||||
);
|
||||
t.is(resp2.statusCode, 200, 'failed to get root doc with public pages');
|
||||
|
||||
const resp3 = await request(app.getHttpServer())
|
||||
.get(`/api/workspaces/${workspace.id}/docs/page1`)
|
||||
.auth(u1.token.token, { type: 'bearer' });
|
||||
// 404 because we don't put the page doc to server
|
||||
t.is(resp3.statusCode, 404, 'failed to get shared doc with u1 token');
|
||||
const resp4 = await request(app.getHttpServer()).get(
|
||||
`/api/workspaces/${workspace.id}/docs/page1`
|
||||
);
|
||||
// 404 because we don't put the page doc to server
|
||||
t.is(resp4.statusCode, 404, 'should not get shared doc without token');
|
||||
|
||||
const msg1 = await publishPage(app, u2.token.token, 'not_exists_ws', 'page2');
|
||||
t.is(
|
||||
msg1,
|
||||
'You do not have permission to access Space not_exists_ws.',
|
||||
'unauthorized user can share page'
|
||||
);
|
||||
const msg2 = await revokePublicPage(
|
||||
app,
|
||||
u2.token.token,
|
||||
'not_exists_ws',
|
||||
'page2'
|
||||
);
|
||||
t.is(
|
||||
msg2,
|
||||
'You do not have permission to access Space not_exists_ws.',
|
||||
'unauthorized user can share page'
|
||||
);
|
||||
|
||||
await acceptInviteById(
|
||||
app,
|
||||
workspace.id,
|
||||
await inviteUser(app, u1.token.token, workspace.id, u2.email)
|
||||
);
|
||||
const invited = await publishPage(app, u2.token.token, workspace.id, 'page2');
|
||||
t.is(invited.id, 'page2', 'failed to share page');
|
||||
|
||||
const revoke = await revokePublicPage(
|
||||
app,
|
||||
u1.token.token,
|
||||
workspace.id,
|
||||
'page1'
|
||||
);
|
||||
t.false(revoke.public, 'failed to revoke page');
|
||||
const pages2 = await getWorkspacePublicPages(
|
||||
app,
|
||||
u1.token.token,
|
||||
workspace.id
|
||||
);
|
||||
t.is(pages2.length, 1, 'failed to get shared pages');
|
||||
t.is(pages2[0].id, 'page2', 'failed to get shared page: page2');
|
||||
|
||||
const msg3 = await revokePublicPage(
|
||||
app,
|
||||
u1.token.token,
|
||||
workspace.id,
|
||||
'page3'
|
||||
);
|
||||
t.is(msg3, 'Page is not public');
|
||||
|
||||
const msg4 = await revokePublicPage(
|
||||
app,
|
||||
u1.token.token,
|
||||
workspace.id,
|
||||
'page2'
|
||||
);
|
||||
t.false(msg4.public, 'failed to revoke page');
|
||||
const page3 = await getWorkspacePublicPages(
|
||||
app,
|
||||
u1.token.token,
|
||||
workspace.id
|
||||
);
|
||||
t.is(page3.length, 0, 'failed to get shared pages');
|
||||
});
|
||||
|
||||
test('should can get workspace doc', async t => {
|
||||
const { app } = t.context;
|
||||
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
|
||||
const u2 = await signUp(app, 'u2', 'u2@affine.pro', '2');
|
||||
const workspace = await createWorkspace(app, u1.token.token);
|
||||
|
||||
const res1 = await request(app.getHttpServer())
|
||||
.get(`/api/workspaces/${workspace.id}/docs/${workspace.id}`)
|
||||
.auth(u1.token.token, { type: 'bearer' })
|
||||
.expect(200)
|
||||
.type('application/octet-stream');
|
||||
|
||||
t.deepEqual(
|
||||
res1.body,
|
||||
Buffer.from([0, 0]),
|
||||
'failed to get doc with u1 token'
|
||||
);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get(`/api/workspaces/${workspace.id}/docs/${workspace.id}`)
|
||||
.expect(403);
|
||||
await request(app.getHttpServer())
|
||||
.get(`/api/workspaces/${workspace.id}/docs/${workspace.id}`)
|
||||
.auth(u2.token.token, { type: 'bearer' })
|
||||
.expect(403);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get(`/api/workspaces/${workspace.id}/docs/${workspace.id}`)
|
||||
.auth(u2.token.token, { type: 'bearer' })
|
||||
.expect(403);
|
||||
|
||||
await acceptInviteById(
|
||||
app,
|
||||
workspace.id,
|
||||
await inviteUser(app, u1.token.token, workspace.id, u2.email)
|
||||
);
|
||||
|
||||
const res2 = await request(app.getHttpServer())
|
||||
.get(`/api/workspaces/${workspace.id}/docs/${workspace.id}`)
|
||||
.auth(u2.token.token, { type: 'bearer' })
|
||||
.expect(200)
|
||||
.type('application/octet-stream');
|
||||
|
||||
t.deepEqual(
|
||||
res2.body,
|
||||
Buffer.from([0, 0]),
|
||||
'failed to get doc with u2 token'
|
||||
);
|
||||
});
|
||||
|
||||
test('should be able to get public workspace doc', async t => {
|
||||
const { app } = t.context;
|
||||
const user = await signUp(app, 'u1', 'u1@affine.pro', '1');
|
||||
const workspace = await createWorkspace(app, user.token.token);
|
||||
|
||||
const isPublic = await updateWorkspace(
|
||||
app,
|
||||
user.token.token,
|
||||
workspace.id,
|
||||
true
|
||||
);
|
||||
|
||||
t.true(isPublic, 'failed to publish workspace');
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(`/api/workspaces/${workspace.id}/docs/${workspace.id}`)
|
||||
.expect(200)
|
||||
.type('application/octet-stream');
|
||||
|
||||
t.deepEqual(res.body, Buffer.from([0, 0]), 'failed to get public doc');
|
||||
});
|
||||
165
packages/backend/server/src/__tests__/workspace/blobs.e2e.ts
Normal file
165
packages/backend/server/src/__tests__/workspace/blobs.e2e.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import test from 'ava';
|
||||
import request from 'supertest';
|
||||
|
||||
import { AppModule } from '../../app.module';
|
||||
import { FeatureManagementService, FeatureType } from '../../core/features';
|
||||
import { QuotaService, QuotaType } from '../../core/quota';
|
||||
import {
|
||||
collectAllBlobSizes,
|
||||
createTestingApp,
|
||||
createWorkspace,
|
||||
getWorkspaceBlobsSize,
|
||||
listBlobs,
|
||||
setBlob,
|
||||
signUp,
|
||||
} from '../utils';
|
||||
|
||||
const OneMB = 1024 * 1024;
|
||||
|
||||
let app: INestApplication;
|
||||
let quota: QuotaService;
|
||||
let feature: FeatureManagementService;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
const { app: testApp } = await createTestingApp({
|
||||
imports: [AppModule],
|
||||
});
|
||||
|
||||
app = testApp;
|
||||
quota = app.get(QuotaService);
|
||||
feature = app.get(FeatureManagementService);
|
||||
});
|
||||
|
||||
test.afterEach.always(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test('should set blobs', async t => {
|
||||
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
|
||||
|
||||
const workspace = await createWorkspace(app, u1.token.token);
|
||||
|
||||
const buffer1 = Buffer.from([0, 0]);
|
||||
const hash1 = await setBlob(app, u1.token.token, workspace.id, buffer1);
|
||||
const buffer2 = Buffer.from([0, 1]);
|
||||
const hash2 = await setBlob(app, u1.token.token, workspace.id, buffer2);
|
||||
|
||||
const server = app.getHttpServer();
|
||||
|
||||
const response1 = await request(server)
|
||||
.get(`/api/workspaces/${workspace.id}/blobs/${hash1}`)
|
||||
.auth(u1.token.token, { type: 'bearer' })
|
||||
.buffer();
|
||||
|
||||
t.deepEqual(response1.body, buffer1, 'failed to get blob');
|
||||
|
||||
const response2 = await request(server)
|
||||
.get(`/api/workspaces/${workspace.id}/blobs/${hash2}`)
|
||||
.auth(u1.token.token, { type: 'bearer' })
|
||||
.buffer();
|
||||
|
||||
t.deepEqual(response2.body, buffer2, 'failed to get blob');
|
||||
});
|
||||
|
||||
test('should list blobs', async t => {
|
||||
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
|
||||
|
||||
const workspace = await createWorkspace(app, u1.token.token);
|
||||
const blobs = await listBlobs(app, u1.token.token, workspace.id);
|
||||
t.is(blobs.length, 0, 'failed to list blobs');
|
||||
|
||||
const buffer1 = Buffer.from([0, 0]);
|
||||
const hash1 = await setBlob(app, u1.token.token, workspace.id, buffer1);
|
||||
const buffer2 = Buffer.from([0, 1]);
|
||||
const hash2 = await setBlob(app, u1.token.token, workspace.id, buffer2);
|
||||
|
||||
const ret = await listBlobs(app, u1.token.token, workspace.id);
|
||||
t.is(ret.length, 2, 'failed to list blobs');
|
||||
// list blob result is not ordered
|
||||
t.deepEqual(ret.sort(), [hash1, hash2].sort());
|
||||
});
|
||||
|
||||
test('should calc blobs size', async t => {
|
||||
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
|
||||
|
||||
const workspace = await createWorkspace(app, u1.token.token);
|
||||
|
||||
const buffer1 = Buffer.from([0, 0]);
|
||||
await setBlob(app, u1.token.token, workspace.id, buffer1);
|
||||
const buffer2 = Buffer.from([0, 1]);
|
||||
await setBlob(app, u1.token.token, workspace.id, buffer2);
|
||||
|
||||
const size = await getWorkspaceBlobsSize(app, u1.token.token, workspace.id);
|
||||
t.is(size, 4, 'failed to collect blob sizes');
|
||||
});
|
||||
|
||||
test('should calc all blobs size', async t => {
|
||||
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
|
||||
|
||||
const workspace1 = await createWorkspace(app, u1.token.token);
|
||||
|
||||
const buffer1 = Buffer.from([0, 0]);
|
||||
await setBlob(app, u1.token.token, workspace1.id, buffer1);
|
||||
const buffer2 = Buffer.from([0, 1]);
|
||||
await setBlob(app, u1.token.token, workspace1.id, buffer2);
|
||||
|
||||
const workspace2 = await createWorkspace(app, u1.token.token);
|
||||
|
||||
const buffer3 = Buffer.from([0, 0]);
|
||||
await setBlob(app, u1.token.token, workspace2.id, buffer3);
|
||||
const buffer4 = Buffer.from([0, 1]);
|
||||
await setBlob(app, u1.token.token, workspace2.id, buffer4);
|
||||
|
||||
const size = await collectAllBlobSizes(app, u1.token.token);
|
||||
t.is(size, 8, 'failed to collect all blob sizes');
|
||||
});
|
||||
|
||||
test('should reject blob exceeded limit', async t => {
|
||||
const u1 = await signUp(app, 'darksky', 'darksky@affine.pro', '1');
|
||||
|
||||
const workspace1 = await createWorkspace(app, u1.token.token);
|
||||
await quota.switchUserQuota(u1.id, QuotaType.RestrictedPlanV1);
|
||||
|
||||
const buffer1 = Buffer.from(Array.from({ length: OneMB + 1 }, () => 0));
|
||||
await t.throwsAsync(setBlob(app, u1.token.token, workspace1.id, buffer1));
|
||||
|
||||
await quota.switchUserQuota(u1.id, QuotaType.FreePlanV1);
|
||||
|
||||
const buffer2 = Buffer.from(Array.from({ length: OneMB + 1 }, () => 0));
|
||||
await t.notThrowsAsync(setBlob(app, u1.token.token, workspace1.id, buffer2));
|
||||
|
||||
const buffer3 = Buffer.from(Array.from({ length: 100 * OneMB + 1 }, () => 0));
|
||||
await t.throwsAsync(setBlob(app, u1.token.token, workspace1.id, buffer3));
|
||||
});
|
||||
|
||||
test('should reject blob exceeded quota', async t => {
|
||||
const u1 = await signUp(app, 'darksky', 'darksky@affine.pro', '1');
|
||||
|
||||
const workspace = await createWorkspace(app, u1.token.token);
|
||||
await quota.switchUserQuota(u1.id, QuotaType.RestrictedPlanV1);
|
||||
|
||||
const buffer = Buffer.from(Array.from({ length: OneMB }, () => 0));
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await t.notThrowsAsync(setBlob(app, u1.token.token, workspace.id, buffer));
|
||||
}
|
||||
|
||||
await t.throwsAsync(setBlob(app, u1.token.token, workspace.id, buffer));
|
||||
});
|
||||
|
||||
test('should accept blob even storage out of quota if workspace has unlimited feature', async t => {
|
||||
const u1 = await signUp(app, 'darksky', 'darksky@affine.pro', '1');
|
||||
|
||||
const workspace = await createWorkspace(app, u1.token.token);
|
||||
await quota.switchUserQuota(u1.id, QuotaType.RestrictedPlanV1);
|
||||
feature.addWorkspaceFeatures(workspace.id, FeatureType.UnlimitedWorkspace);
|
||||
|
||||
const buffer = Buffer.from(Array.from({ length: OneMB }, () => 0));
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await t.notThrowsAsync(setBlob(app, u1.token.token, workspace.id, buffer));
|
||||
}
|
||||
|
||||
await t.notThrowsAsync(setBlob(app, u1.token.token, workspace.id, buffer));
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { HttpStatus, INestApplication } from '@nestjs/common';
|
||||
import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client';
|
||||
import ava, { TestFn } from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
import request from 'supertest';
|
||||
|
||||
import { AppModule } from '../../app.module';
|
||||
import { CurrentUser } from '../../core/auth';
|
||||
import { AuthService } from '../../core/auth/service';
|
||||
import { PgWorkspaceDocStorageAdapter } from '../../core/doc';
|
||||
import { WorkspaceBlobStorage } from '../../core/storage';
|
||||
import { createTestingApp, internalSignIn } from '../utils';
|
||||
|
||||
const test = ava as TestFn<{
|
||||
u1: CurrentUser;
|
||||
db: PrismaClient;
|
||||
app: INestApplication;
|
||||
storage: Sinon.SinonStubbedInstance<WorkspaceBlobStorage>;
|
||||
workspace: Sinon.SinonStubbedInstance<PgWorkspaceDocStorageAdapter>;
|
||||
}>;
|
||||
|
||||
test.before(async t => {
|
||||
const { app } = await createTestingApp({
|
||||
imports: [AppModule],
|
||||
tapModule: m => {
|
||||
m.overrideProvider(WorkspaceBlobStorage)
|
||||
.useValue(Sinon.createStubInstance(WorkspaceBlobStorage))
|
||||
.overrideProvider(PgWorkspaceDocStorageAdapter)
|
||||
.useValue(Sinon.createStubInstance(PgWorkspaceDocStorageAdapter));
|
||||
},
|
||||
});
|
||||
|
||||
const auth = app.get(AuthService);
|
||||
t.context.u1 = await auth.signUp('u1@affine.pro', '1');
|
||||
const db = app.get(PrismaClient);
|
||||
|
||||
t.context.db = db;
|
||||
t.context.app = app;
|
||||
t.context.storage = app.get(WorkspaceBlobStorage);
|
||||
t.context.workspace = app.get(PgWorkspaceDocStorageAdapter);
|
||||
|
||||
await db.workspacePage.create({
|
||||
data: {
|
||||
workspace: {
|
||||
create: {
|
||||
id: 'public',
|
||||
public: true,
|
||||
},
|
||||
},
|
||||
pageId: 'private',
|
||||
public: false,
|
||||
},
|
||||
});
|
||||
|
||||
await db.workspacePage.create({
|
||||
data: {
|
||||
workspace: {
|
||||
create: {
|
||||
id: 'private',
|
||||
public: false,
|
||||
},
|
||||
},
|
||||
pageId: 'public',
|
||||
public: true,
|
||||
},
|
||||
});
|
||||
|
||||
await db.workspacePage.create({
|
||||
data: {
|
||||
workspace: {
|
||||
create: {
|
||||
id: 'totally-private',
|
||||
public: false,
|
||||
},
|
||||
},
|
||||
pageId: 'private',
|
||||
public: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test.after.always(async t => {
|
||||
await t.context.app.close();
|
||||
});
|
||||
|
||||
function blob() {
|
||||
function stream() {
|
||||
return Readable.from(Buffer.from('blob'));
|
||||
}
|
||||
|
||||
const init = stream();
|
||||
const ret = {
|
||||
body: init,
|
||||
metadata: {
|
||||
contentType: 'text/plain',
|
||||
lastModified: new Date(),
|
||||
contentLength: 4,
|
||||
},
|
||||
};
|
||||
|
||||
init.on('end', () => {
|
||||
ret.body = stream();
|
||||
});
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
// blob
|
||||
test('should be able to get blob from public workspace', async t => {
|
||||
const { app, u1, storage } = t.context;
|
||||
|
||||
// no authenticated user
|
||||
storage.get.resolves(blob());
|
||||
let res = await request(t.context.app.getHttpServer()).get(
|
||||
'/api/workspaces/public/blobs/test'
|
||||
);
|
||||
|
||||
t.is(res.status, HttpStatus.OK);
|
||||
t.is(res.get('content-type'), 'text/plain');
|
||||
t.is(res.text, 'blob');
|
||||
|
||||
// authenticated user
|
||||
const cookie = await internalSignIn(app, u1.id);
|
||||
res = await request(t.context.app.getHttpServer())
|
||||
.get('/api/workspaces/public/blobs/test')
|
||||
.set('Cookie', cookie);
|
||||
|
||||
t.is(res.status, HttpStatus.OK);
|
||||
t.is(res.get('content-type'), 'text/plain');
|
||||
t.is(res.text, 'blob');
|
||||
});
|
||||
|
||||
test('should be able to get private workspace with public pages', async t => {
|
||||
const { app, u1, storage } = t.context;
|
||||
|
||||
// no authenticated user
|
||||
storage.get.resolves(blob());
|
||||
let res = await request(app.getHttpServer()).get(
|
||||
'/api/workspaces/private/blobs/test'
|
||||
);
|
||||
|
||||
t.is(res.status, HttpStatus.OK);
|
||||
t.is(res.get('content-type'), 'text/plain');
|
||||
t.is(res.text, 'blob');
|
||||
|
||||
// authenticated user
|
||||
const cookie = await internalSignIn(app, u1.id);
|
||||
res = await request(app.getHttpServer())
|
||||
.get('/api/workspaces/private/blobs/test')
|
||||
.set('cookie', cookie);
|
||||
|
||||
t.is(res.status, HttpStatus.OK);
|
||||
t.is(res.get('content-type'), 'text/plain');
|
||||
t.is(res.text, 'blob');
|
||||
});
|
||||
|
||||
test('should not be able to get private workspace with no public pages', async t => {
|
||||
const { app, u1 } = t.context;
|
||||
|
||||
let res = await request(app.getHttpServer()).get(
|
||||
'/api/workspaces/totally-private/blobs/test'
|
||||
);
|
||||
|
||||
t.is(res.status, HttpStatus.FORBIDDEN);
|
||||
|
||||
res = await request(app.getHttpServer())
|
||||
.get('/api/workspaces/totally-private/blobs/test')
|
||||
.set('cookie', await internalSignIn(app, u1.id));
|
||||
|
||||
t.is(res.status, HttpStatus.FORBIDDEN);
|
||||
});
|
||||
|
||||
test('should be able to get permission granted workspace', async t => {
|
||||
const { app, u1, db, storage } = t.context;
|
||||
|
||||
const cookie = await internalSignIn(app, u1.id);
|
||||
await db.workspaceUserPermission.create({
|
||||
data: {
|
||||
workspaceId: 'totally-private',
|
||||
userId: u1.id,
|
||||
type: 1,
|
||||
accepted: true,
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
},
|
||||
});
|
||||
|
||||
storage.get.resolves(blob());
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/workspaces/totally-private/blobs/test')
|
||||
.set('Cookie', cookie);
|
||||
|
||||
t.is(res.status, HttpStatus.OK);
|
||||
t.is(res.text, 'blob');
|
||||
});
|
||||
|
||||
test('should return 404 if blob not found', async t => {
|
||||
const { app, storage } = t.context;
|
||||
|
||||
// @ts-expect-error mock
|
||||
storage.get.resolves({ body: null });
|
||||
const res = await request(app.getHttpServer()).get(
|
||||
'/api/workspaces/public/blobs/test'
|
||||
);
|
||||
|
||||
t.is(res.status, HttpStatus.NOT_FOUND);
|
||||
});
|
||||
|
||||
// doc
|
||||
// NOTE: permission checking of doc api is the same with blob api, skip except one
|
||||
test('should not be able to get private workspace with private page', async t => {
|
||||
const { app, u1 } = t.context;
|
||||
|
||||
let res = await request(app.getHttpServer()).get(
|
||||
'/api/workspaces/private/docs/private-page'
|
||||
);
|
||||
|
||||
t.is(res.status, HttpStatus.FORBIDDEN);
|
||||
|
||||
res = await request(app.getHttpServer())
|
||||
.get('/api/workspaces/private/docs/private-page')
|
||||
.set('cookie', await internalSignIn(app, u1.id));
|
||||
|
||||
t.is(res.status, HttpStatus.FORBIDDEN);
|
||||
});
|
||||
|
||||
test('should be able to get doc', async t => {
|
||||
const { app, workspace: doc } = t.context;
|
||||
|
||||
doc.getDoc.resolves({
|
||||
spaceId: '',
|
||||
docId: '',
|
||||
bin: Buffer.from([0, 0]),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const res = await request(app.getHttpServer()).get(
|
||||
'/api/workspaces/private/docs/public'
|
||||
);
|
||||
|
||||
t.is(res.status, HttpStatus.OK);
|
||||
t.is(res.get('content-type'), 'application/octet-stream');
|
||||
t.deepEqual(res.body, Buffer.from([0, 0]));
|
||||
});
|
||||
|
||||
test('should be able to change page publish mode', async t => {
|
||||
const { app, workspace: doc, db } = t.context;
|
||||
|
||||
doc.getDoc.resolves({
|
||||
spaceId: '',
|
||||
docId: '',
|
||||
bin: Buffer.from([0, 0]),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
let res = await request(app.getHttpServer()).get(
|
||||
'/api/workspaces/private/docs/public'
|
||||
);
|
||||
|
||||
t.is(res.status, HttpStatus.OK);
|
||||
t.is(res.get('publish-mode'), 'page');
|
||||
|
||||
await db.workspacePage.update({
|
||||
where: { workspaceId_pageId: { workspaceId: 'private', pageId: 'public' } },
|
||||
data: { mode: 1 },
|
||||
});
|
||||
|
||||
res = await request(app.getHttpServer()).get(
|
||||
'/api/workspaces/private/docs/public'
|
||||
);
|
||||
|
||||
t.is(res.status, HttpStatus.OK);
|
||||
t.is(res.get('publish-mode'), 'edgeless');
|
||||
});
|
||||
Reference in New Issue
Block a user