chore(server): move server tests folder (#9614)

This commit is contained in:
forehalo
2025-01-10 02:38:10 +00:00
parent 8e8058a44c
commit 1b6f0e78c4
54 changed files with 166 additions and 186 deletions

View 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');
});

View 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');
});

View 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'));
});

View 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');
});

View 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();
});

View 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}=;`))
);
});

View 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);
});

View 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);
});

View 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',
})
);
});

View 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);
});

View 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();
});

View 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);
});
}
);
}

View 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');
});

File diff suppressed because it is too large Load Diff

View 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());
});

View 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]));
});

View 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');

View File

@@ -0,0 +1,5 @@
import test from 'ava';
test('should test through userspace', t => {
t.pass();
});

View 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);
}
});

View 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');
});

View 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}`);
}
});

View 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();
});

View 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`)
);
});

View 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'
);
});

View File

@@ -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'));
});

View 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');
});

View 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);
});

View File

@@ -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',
]

File diff suppressed because it is too large Load Diff

View 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');
}
});

View File

@@ -0,0 +1,5 @@
import test from 'ava';
test('should test through sync gateway', t => {
t.pass();
});

View 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'
);
}
});

View 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();
});

View 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'));
});

View 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;
}

View File

@@ -0,0 +1 @@
export const gql = '/graphql';

File diff suppressed because one or more lines are too long

View 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;
}
}

View File

@@ -0,0 +1,5 @@
export * from './blobs';
export * from './invite';
export * from './user';
export * from './utils';
export * from './workspace';

View 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;
}

View 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;
}

View 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));
}

View 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;
}

View 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');
}
});

View 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');
});

View 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));
});

View File

@@ -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');
});