mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-29 00:06:09 +08:00
chore: drop old client support (#14369)
This commit is contained in:
@@ -32,6 +32,16 @@ Generated by [AVA](https://avajs.dev).
|
||||
|
||||
> Snapshot 4
|
||||
|
||||
{
|
||||
code: 'Bad Request',
|
||||
message: 'Invalid header',
|
||||
name: 'BAD_REQUEST',
|
||||
status: 400,
|
||||
type: 'BAD_REQUEST',
|
||||
}
|
||||
|
||||
> Snapshot 5
|
||||
|
||||
Buffer @Uint8Array [
|
||||
66616b65 20696d61 6765
|
||||
]
|
||||
@@ -56,7 +66,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
|
||||
{
|
||||
code: 'Bad Request',
|
||||
message: 'Invalid URL',
|
||||
message: 'Invalid header',
|
||||
name: 'BAD_REQUEST',
|
||||
status: 400,
|
||||
type: 'BAD_REQUEST',
|
||||
@@ -64,6 +74,16 @@ Generated by [AVA](https://avajs.dev).
|
||||
|
||||
> Snapshot 4
|
||||
|
||||
{
|
||||
code: 'Bad Request',
|
||||
message: 'Invalid URL',
|
||||
name: 'BAD_REQUEST',
|
||||
status: 400,
|
||||
type: 'BAD_REQUEST',
|
||||
}
|
||||
|
||||
> Snapshot 5
|
||||
|
||||
{
|
||||
description: 'Test Description',
|
||||
favicons: [
|
||||
@@ -77,7 +97,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
videos: [],
|
||||
}
|
||||
|
||||
> Snapshot 5
|
||||
> Snapshot 6
|
||||
|
||||
{
|
||||
charset: 'gbk',
|
||||
@@ -90,7 +110,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
videos: [],
|
||||
}
|
||||
|
||||
> Snapshot 6
|
||||
> Snapshot 7
|
||||
|
||||
{
|
||||
charset: 'shift_jis',
|
||||
@@ -103,7 +123,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
videos: [],
|
||||
}
|
||||
|
||||
> Snapshot 7
|
||||
> Snapshot 8
|
||||
|
||||
{
|
||||
charset: 'big5',
|
||||
@@ -116,7 +136,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
videos: [],
|
||||
}
|
||||
|
||||
> Snapshot 8
|
||||
> Snapshot 9
|
||||
|
||||
{
|
||||
charset: 'euc-kr',
|
||||
|
||||
Binary file not shown.
@@ -33,7 +33,7 @@ test('change email', async t => {
|
||||
const u2Email = 'u2@affine.pro';
|
||||
|
||||
const user = await app.signupV1(u1Email);
|
||||
await sendChangeEmail(app, u1Email, 'affine.pro');
|
||||
await sendChangeEmail(app, u1Email, '/email-change');
|
||||
|
||||
const changeMail = app.mails.last('ChangeEmail');
|
||||
|
||||
@@ -53,7 +53,7 @@ test('change email', async t => {
|
||||
app,
|
||||
changeEmailToken as string,
|
||||
u2Email,
|
||||
'affine.pro'
|
||||
'/email-change-verify'
|
||||
);
|
||||
|
||||
const verifyMail = app.mails.last('VerifyChangeEmail');
|
||||
@@ -94,7 +94,7 @@ test('set and change password', async t => {
|
||||
const u1Email = 'u1@affine.pro';
|
||||
|
||||
const u1 = await app.signupV1(u1Email);
|
||||
await sendSetPasswordEmail(app, u1Email, 'affine.pro');
|
||||
await sendSetPasswordEmail(app, u1Email, '/password-change');
|
||||
|
||||
const setPasswordMail = app.mails.last('ChangePassword');
|
||||
const link = new URL(setPasswordMail.props.url);
|
||||
@@ -131,3 +131,29 @@ test('set and change password', async t => {
|
||||
t.not(user, null, 'failed to get current user');
|
||||
t.is(user?.email, u1Email, 'failed to get current user');
|
||||
});
|
||||
|
||||
test('should forbid graphql callbackUrl to external origin', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const u1Email = 'u1@affine.pro';
|
||||
await app.signupV1(u1Email);
|
||||
|
||||
const res = await app
|
||||
.POST('/graphql')
|
||||
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
|
||||
.send({
|
||||
query: `
|
||||
mutation($email: String!, $callbackUrl: String!) {
|
||||
sendChangeEmail(email: $email, callbackUrl: $callbackUrl)
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
email: u1Email,
|
||||
callbackUrl: 'https://evil.example',
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
t.truthy(res.body.errors?.length);
|
||||
t.is(res.body.errors[0].extensions?.name, 'ACTION_FORBIDDEN');
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { HttpStatus } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import ava, { TestFn } from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
import supertest from 'supertest';
|
||||
|
||||
import { parseCookies as safeParseCookies } from '../../base/utils/request';
|
||||
import { AuthService } from '../../core/auth/service';
|
||||
@@ -126,6 +127,36 @@ test('should not be able to sign in if forbidden', async t => {
|
||||
t.pass();
|
||||
});
|
||||
|
||||
test('should forbid magic link with external callbackUrl', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const u1 = await app.createUser('u1@affine.pro');
|
||||
|
||||
await app
|
||||
.POST('/api/auth/sign-in')
|
||||
.send({
|
||||
email: u1.email,
|
||||
callbackUrl: 'https://evil.example/magic-link',
|
||||
})
|
||||
.expect(HttpStatus.FORBIDDEN);
|
||||
t.pass();
|
||||
});
|
||||
|
||||
test('should forbid magic link with untrusted redirect_uri in callbackUrl', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const u1 = await app.createUser('u1@affine.pro');
|
||||
|
||||
await app
|
||||
.POST('/api/auth/sign-in')
|
||||
.send({
|
||||
email: u1.email,
|
||||
callbackUrl: '/magic-link?redirect_uri=https://evil.example',
|
||||
})
|
||||
.expect(HttpStatus.FORBIDDEN);
|
||||
t.pass();
|
||||
});
|
||||
|
||||
test('should be able to sign out', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
@@ -136,13 +167,82 @@ test('should be able to sign out', async t => {
|
||||
.send({ email: u1.email, password: u1.password })
|
||||
.expect(200);
|
||||
|
||||
await app.GET('/api/auth/sign-out').expect(200);
|
||||
await app.POST('/api/auth/sign-out').expect(200);
|
||||
|
||||
const session = await currentUser(app);
|
||||
|
||||
t.falsy(session);
|
||||
});
|
||||
|
||||
test('should reject sign out when csrf token mismatched', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const u1 = await app.createUser('u1@affine.pro');
|
||||
|
||||
await app
|
||||
.POST('/api/auth/sign-in')
|
||||
.send({ email: u1.email, password: u1.password })
|
||||
.expect(200);
|
||||
|
||||
await app
|
||||
.POST('/api/auth/sign-out')
|
||||
.set('x-affine-csrf-token', 'invalid')
|
||||
.expect(HttpStatus.FORBIDDEN);
|
||||
|
||||
const session = await currentUser(app);
|
||||
t.is(session?.id, u1.id);
|
||||
});
|
||||
|
||||
test('should sign in desktop app via one-time open-app code', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const u1 = await app.createUser('u1@affine.pro');
|
||||
|
||||
await app
|
||||
.POST('/api/auth/sign-in')
|
||||
.send({ email: u1.email, password: u1.password })
|
||||
.expect(200);
|
||||
|
||||
const codeRes = await app.POST('/api/auth/open-app/sign-in-code').expect(201);
|
||||
|
||||
const code = codeRes.body.code as string;
|
||||
t.truthy(code);
|
||||
|
||||
const exchangeRes = await supertest(app.getHttpServer())
|
||||
.post('/api/auth/open-app/sign-in')
|
||||
.send({ code })
|
||||
.expect(201);
|
||||
|
||||
const exchangedCookies = exchangeRes.get('Set-Cookie') ?? [];
|
||||
t.true(
|
||||
exchangedCookies.some(c =>
|
||||
c.startsWith(`${AuthService.sessionCookieName}=`)
|
||||
)
|
||||
);
|
||||
|
||||
const cookieHeader = exchangedCookies.map(c => c.split(';')[0]).join('; ');
|
||||
const sessionRes = await supertest(app.getHttpServer())
|
||||
.get('/api/auth/session')
|
||||
.set('Cookie', cookieHeader)
|
||||
.expect(200);
|
||||
|
||||
t.is(sessionRes.body.user?.id, u1.id);
|
||||
|
||||
// one-time use
|
||||
await supertest(app.getHttpServer())
|
||||
.post('/api/auth/open-app/sign-in')
|
||||
.send({ code })
|
||||
.expect(400)
|
||||
.expect({
|
||||
status: 400,
|
||||
code: 'Bad Request',
|
||||
type: 'BAD_REQUEST',
|
||||
name: 'INVALID_AUTH_STATE',
|
||||
message:
|
||||
'Invalid auth state. You might start the auth progress from another device.',
|
||||
});
|
||||
});
|
||||
|
||||
test('should be able to correct user id cookie', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
@@ -228,7 +328,7 @@ test('should be able to sign out multiple accounts in one session', async t => {
|
||||
const u2 = await app.signupV1('u2@affine.pro');
|
||||
|
||||
// sign out u2
|
||||
await app.GET(`/api/auth/sign-out?user_id=${u2.id}`).expect(200);
|
||||
await app.POST(`/api/auth/sign-out?user_id=${u2.id}`).expect(200);
|
||||
|
||||
// list [u1]
|
||||
let session = await app.GET('/api/auth/session').expect(200);
|
||||
@@ -241,7 +341,7 @@ test('should be able to sign out multiple accounts in one session', async t => {
|
||||
.expect(200);
|
||||
|
||||
// sign out all account in session
|
||||
await app.GET('/api/auth/sign-out').expect(200);
|
||||
await app.POST('/api/auth/sign-out').expect(200);
|
||||
|
||||
session = await app.GET('/api/auth/session').expect(200);
|
||||
t.falsy(session.body.user);
|
||||
@@ -337,3 +437,56 @@ test('should not be able to sign in if token is invalid', async t => {
|
||||
|
||||
t.is(res.body.message, 'An invalid email token provided.');
|
||||
});
|
||||
|
||||
test('should not allow magic link OTP replay', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const u1 = await app.createUser('u1@affine.pro');
|
||||
|
||||
await app.POST('/api/auth/sign-in').send({ email: u1.email }).expect(200);
|
||||
const signInMail = app.mails.last('SignIn');
|
||||
const url = new URL(signInMail.props.url);
|
||||
const email = url.searchParams.get('email');
|
||||
const token = url.searchParams.get('token');
|
||||
|
||||
await app.POST('/api/auth/magic-link').send({ email, token }).expect(201);
|
||||
|
||||
await app
|
||||
.POST('/api/auth/magic-link')
|
||||
.send({ email, token })
|
||||
.expect(400)
|
||||
.expect({
|
||||
status: 400,
|
||||
code: 'Bad Request',
|
||||
type: 'INVALID_INPUT',
|
||||
name: 'INVALID_EMAIL_TOKEN',
|
||||
message: 'An invalid email token provided.',
|
||||
});
|
||||
t.pass();
|
||||
});
|
||||
|
||||
test('should lock magic link OTP after too many attempts', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const u1 = await app.createUser('u1@affine.pro');
|
||||
|
||||
await app.POST('/api/auth/sign-in').send({ email: u1.email }).expect(200);
|
||||
const signInMail = app.mails.last('SignIn');
|
||||
const url = new URL(signInMail.props.url);
|
||||
const email = url.searchParams.get('email');
|
||||
const token = url.searchParams.get('token') as string;
|
||||
|
||||
const wrongOtp = token === '000000' ? '000001' : '000000';
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await app
|
||||
.POST('/api/auth/magic-link')
|
||||
.send({ email, token: wrongOtp })
|
||||
.expect(400);
|
||||
}
|
||||
|
||||
await app.POST('/api/auth/magic-link').send({ email, token }).expect(400);
|
||||
|
||||
const session = await currentUser(app);
|
||||
t.falsy(session);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { TestingModule } from '@nestjs/testing';
|
||||
import test from 'ava';
|
||||
|
||||
@@ -7,6 +9,8 @@ import { createTestingModule } from './utils';
|
||||
|
||||
let cache: Cache;
|
||||
let module: TestingModule;
|
||||
const keyPrefix = `test:${randomUUID()}:`;
|
||||
const key = (name: string) => `${keyPrefix}${name}`;
|
||||
test.before(async () => {
|
||||
module = await createTestingModule({
|
||||
imports: FunctionalityModules,
|
||||
@@ -19,78 +23,78 @@ test.after.always(async () => {
|
||||
});
|
||||
|
||||
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.set(key('test'), 1));
|
||||
t.is(await cache.get<number>(key('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.has(key('test')));
|
||||
t.true(await cache.delete(key('test')));
|
||||
t.is(await cache.get(key('test')), undefined);
|
||||
|
||||
t.true(await cache.set('test', { a: 1 }));
|
||||
t.deepEqual(await cache.get('test'), { a: 1 });
|
||||
t.true(await cache.set(key('test'), { a: 1 }));
|
||||
t.deepEqual(await cache.get(key('test')), { a: 1 });
|
||||
});
|
||||
|
||||
test('should be able to set cache with non-exiting flag', async t => {
|
||||
t.true(await cache.setnx('test-nx', 1));
|
||||
t.false(await cache.setnx('test-nx', 2));
|
||||
t.is(await cache.get('test-nx'), 1);
|
||||
t.true(await cache.setnx(key('test-nx'), 1));
|
||||
t.false(await cache.setnx(key('test-nx'), 2));
|
||||
t.is(await cache.get(key('test-nx')), 1);
|
||||
});
|
||||
|
||||
test('should be able to set cache with ttl', async t => {
|
||||
t.true(await cache.set('test-ttl', 1));
|
||||
t.is(await cache.get('test-ttl'), 1);
|
||||
t.true(await cache.set(key('test-ttl'), 1));
|
||||
t.is(await cache.get(key('test-ttl')), 1);
|
||||
|
||||
t.true(await cache.expire('test-ttl', 1 * 1000));
|
||||
const ttl = await cache.ttl('test-ttl');
|
||||
t.true(await cache.expire(key('test-ttl'), 1 * 1000));
|
||||
const ttl = await cache.ttl(key('test-ttl'));
|
||||
t.true(ttl <= 1 * 1000);
|
||||
t.true(ttl > 0);
|
||||
});
|
||||
|
||||
test('should be able to incr/decr number cache', async t => {
|
||||
t.true(await cache.set('test-incr', 1));
|
||||
t.is(await cache.increase('test-incr'), 2);
|
||||
t.is(await cache.increase('test-incr'), 3);
|
||||
t.is(await cache.decrease('test-incr'), 2);
|
||||
t.is(await cache.decrease('test-incr'), 1);
|
||||
t.true(await cache.set(key('test-incr'), 1));
|
||||
t.is(await cache.increase(key('test-incr')), 2);
|
||||
t.is(await cache.increase(key('test-incr')), 3);
|
||||
t.is(await cache.decrease(key('test-incr')), 2);
|
||||
t.is(await cache.decrease(key('test-incr')), 1);
|
||||
|
||||
// increase an nonexists number
|
||||
t.is(await cache.increase('test-incr2'), 1);
|
||||
t.is(await cache.increase('test-incr2'), 2);
|
||||
t.is(await cache.increase(key('test-incr2')), 1);
|
||||
t.is(await cache.increase(key('test-incr2')), 2);
|
||||
});
|
||||
|
||||
test('should be able to manipulate list cache', async t => {
|
||||
t.is(await cache.pushBack('test-list', 1), 1);
|
||||
t.is(await cache.pushBack('test-list', 2, 3, 4), 4);
|
||||
t.is(await cache.len('test-list'), 4);
|
||||
t.is(await cache.pushBack(key('test-list'), 1), 1);
|
||||
t.is(await cache.pushBack(key('test-list'), 2, 3, 4), 4);
|
||||
t.is(await cache.len(key('test-list')), 4);
|
||||
|
||||
t.deepEqual(await cache.list('test-list', 1, -1), [2, 3, 4]);
|
||||
t.deepEqual(await cache.list(key('test-list'), 1, -1), [2, 3, 4]);
|
||||
|
||||
t.deepEqual(await cache.popFront('test-list', 2), [1, 2]);
|
||||
t.deepEqual(await cache.popBack('test-list', 1), [4]);
|
||||
t.deepEqual(await cache.popFront(key('test-list'), 2), [1, 2]);
|
||||
t.deepEqual(await cache.popBack(key('test-list'), 1), [4]);
|
||||
|
||||
t.is(await cache.pushBack('test-list2', { a: 1 }), 1);
|
||||
t.deepEqual(await cache.popFront('test-list2', 1), [{ a: 1 }]);
|
||||
t.is(await cache.pushBack(key('test-list2'), { a: 1 }), 1);
|
||||
t.deepEqual(await cache.popFront(key('test-list2'), 1), [{ a: 1 }]);
|
||||
});
|
||||
|
||||
test('should be able to manipulate map cache', async t => {
|
||||
t.is(await cache.mapSet('test-map', 'a', 1), true);
|
||||
t.is(await cache.mapSet('test-map', 'b', 2), true);
|
||||
t.is(await cache.mapLen('test-map'), 2);
|
||||
t.is(await cache.mapSet(key('test-map'), 'a', 1), true);
|
||||
t.is(await cache.mapSet(key('test-map'), 'b', 2), true);
|
||||
t.is(await cache.mapLen(key('test-map')), 2);
|
||||
|
||||
t.is(await cache.mapGet('test-map', 'a'), 1);
|
||||
t.is(await cache.mapGet('test-map', 'b'), 2);
|
||||
t.is(await cache.mapGet(key('test-map'), 'a'), 1);
|
||||
t.is(await cache.mapGet(key('test-map'), 'b'), 2);
|
||||
|
||||
t.is(await cache.mapIncrease('test-map', 'a'), 2);
|
||||
t.is(await cache.mapIncrease('test-map', 'a'), 3);
|
||||
t.is(await cache.mapDecrease('test-map', 'b', 3), -1);
|
||||
t.is(await cache.mapIncrease(key('test-map'), 'a'), 2);
|
||||
t.is(await cache.mapIncrease(key('test-map'), 'a'), 3);
|
||||
t.is(await cache.mapDecrease(key('test-map'), 'b', 3), -1);
|
||||
|
||||
const keys = await cache.mapKeys('test-map');
|
||||
const keys = await cache.mapKeys(key('test-map'));
|
||||
t.deepEqual(keys, ['a', 'b']);
|
||||
|
||||
const randomKey = await cache.mapRandomKey('test-map');
|
||||
const randomKey = await cache.mapRandomKey(key('test-map'));
|
||||
t.truthy(randomKey);
|
||||
t.true(keys.includes(randomKey!));
|
||||
|
||||
t.is(await cache.mapDelete('test-map', 'a'), true);
|
||||
t.is(await cache.mapGet('test-map', 'a'), undefined);
|
||||
t.is(await cache.mapDelete(key('test-map'), 'a'), true);
|
||||
t.is(await cache.mapGet(key('test-map'), 'a'), undefined);
|
||||
});
|
||||
|
||||
@@ -922,7 +922,6 @@ test('should be able to manage context', async t => {
|
||||
const { id: fileId } = await addContextFile(
|
||||
app,
|
||||
contextId,
|
||||
'fileId1',
|
||||
'sample.pdf',
|
||||
buffer
|
||||
);
|
||||
|
||||
@@ -41,6 +41,7 @@ interface TestingAppMetadata {
|
||||
export class TestingApp extends NestApplication {
|
||||
private sessionCookie: string | null = null;
|
||||
private currentUserCookie: string | null = null;
|
||||
private csrfCookie: string | null = null;
|
||||
private readonly userCookies: Set<string> = new Set();
|
||||
|
||||
create = createFactory(this.get(PrismaClient, { strict: false }));
|
||||
@@ -65,12 +66,23 @@ export class TestingApp extends NestApplication {
|
||||
method: 'options' | 'get' | 'post' | 'put' | 'delete' | 'patch',
|
||||
path: string
|
||||
): supertest.Test {
|
||||
return supertest(this.getHttpServer())
|
||||
const cookies = [
|
||||
`${AuthService.sessionCookieName}=${this.sessionCookie ?? ''}`,
|
||||
`${AuthService.userCookieName}=${this.currentUserCookie ?? ''}`,
|
||||
];
|
||||
if (this.csrfCookie) {
|
||||
cookies.push(`${AuthService.csrfCookieName}=${this.csrfCookie}`);
|
||||
}
|
||||
|
||||
const req = supertest(this.getHttpServer())
|
||||
[method](path)
|
||||
.set('Cookie', [
|
||||
`${AuthService.sessionCookieName}=${this.sessionCookie ?? ''}`,
|
||||
`${AuthService.userCookieName}=${this.currentUserCookie ?? ''}`,
|
||||
]);
|
||||
.set('Cookie', cookies);
|
||||
|
||||
if (this.csrfCookie) {
|
||||
req.set('x-affine-csrf-token', this.csrfCookie);
|
||||
}
|
||||
|
||||
return req;
|
||||
}
|
||||
|
||||
gql = gqlFetcherFactory('', async (_input, init) => {
|
||||
@@ -123,6 +135,9 @@ export class TestingApp extends NestApplication {
|
||||
|
||||
this.sessionCookie = cookies[AuthService.sessionCookieName];
|
||||
this.currentUserCookie = cookies[AuthService.userCookieName];
|
||||
if (AuthService.csrfCookieName in cookies) {
|
||||
this.csrfCookie = cookies[AuthService.csrfCookieName] || null;
|
||||
}
|
||||
if (this.currentUserCookie) {
|
||||
this.userCookies.add(this.currentUserCookie);
|
||||
}
|
||||
@@ -180,13 +195,17 @@ export class TestingApp extends NestApplication {
|
||||
}
|
||||
|
||||
async logout(userId?: string) {
|
||||
const res = await this.GET(
|
||||
const res = await this.POST(
|
||||
'/api/auth/sign-out' + (userId ? `?user_id=${userId}` : '')
|
||||
).expect(200);
|
||||
const cookies = parseCookies(res);
|
||||
this.sessionCookie = cookies[AuthService.sessionCookieName];
|
||||
if (AuthService.csrfCookieName in cookies) {
|
||||
this.csrfCookie = cookies[AuthService.csrfCookieName] || null;
|
||||
}
|
||||
if (!this.sessionCookie) {
|
||||
this.currentUserCookie = null;
|
||||
this.csrfCookie = null;
|
||||
this.userCookies.clear();
|
||||
} else {
|
||||
this.currentUserCookie = cookies[AuthService.userCookieName];
|
||||
|
||||
@@ -16,9 +16,13 @@ e2e('should get doc markdown success', async t => {
|
||||
user: owner,
|
||||
});
|
||||
|
||||
const path = `/rpc/workspaces/${workspace.id}/docs/${docSnapshot.id}/markdown`;
|
||||
const res = await app
|
||||
.GET(`/rpc/workspaces/${workspace.id}/docs/${docSnapshot.id}/markdown`)
|
||||
.set('x-access-token', crypto.sign(docSnapshot.id))
|
||||
.GET(path)
|
||||
.set(
|
||||
'x-access-token',
|
||||
crypto.signInternalAccessToken({ method: 'GET', path })
|
||||
)
|
||||
.expect(200)
|
||||
.expect('Content-Type', 'application/json; charset=utf-8');
|
||||
|
||||
@@ -32,9 +36,13 @@ e2e('should get doc markdown return null when doc not exists', async t => {
|
||||
});
|
||||
|
||||
const docId = randomUUID();
|
||||
const path = `/rpc/workspaces/${workspace.id}/docs/${docId}/markdown`;
|
||||
const res = await app
|
||||
.GET(`/rpc/workspaces/${workspace.id}/docs/${docId}/markdown`)
|
||||
.set('x-access-token', crypto.sign(docId))
|
||||
.GET(path)
|
||||
.set(
|
||||
'x-access-token',
|
||||
crypto.signInternalAccessToken({ method: 'GET', path })
|
||||
)
|
||||
.expect(404)
|
||||
.expect('Content-Type', 'application/json; charset=utf-8');
|
||||
|
||||
|
||||
@@ -39,31 +39,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
},
|
||||
}
|
||||
|
||||
## should not return apple oauth provider when client version is not specified
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
{
|
||||
serverConfig: {
|
||||
oauthProviders: [
|
||||
'Google',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
## should not return apple oauth provider in version < 0.22.0
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
{
|
||||
serverConfig: {
|
||||
oauthProviders: [
|
||||
'Google',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
## should not return apple oauth provider when client version format is not correct
|
||||
## should return apple oauth provider when client version is not specified
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
@@ -71,6 +47,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
serverConfig: {
|
||||
oauthProviders: [
|
||||
'Google',
|
||||
'Apple',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -71,7 +71,7 @@ e2e('should return apple oauth provider in version >= 0.22.0', async t => {
|
||||
});
|
||||
|
||||
e2e(
|
||||
'should not return apple oauth provider when client version is not specified',
|
||||
'should return apple oauth provider when client version is not specified',
|
||||
async t => {
|
||||
const res = await app.gql({
|
||||
query: oauthProvidersQuery,
|
||||
@@ -80,32 +80,3 @@ e2e(
|
||||
t.snapshot(res);
|
||||
}
|
||||
);
|
||||
|
||||
e2e('should not return apple oauth provider in version < 0.22.0', async t => {
|
||||
const res = await app.gql({
|
||||
query: oauthProvidersQuery,
|
||||
context: {
|
||||
headers: {
|
||||
'x-affine-version': '0.21.0',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
t.snapshot(res);
|
||||
});
|
||||
|
||||
e2e(
|
||||
'should not return apple oauth provider when client version format is not correct',
|
||||
async t => {
|
||||
const res = await app.gql({
|
||||
query: oauthProvidersQuery,
|
||||
context: {
|
||||
headers: {
|
||||
'x-affine-version': 'mock-invalid-version',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
t.snapshot(res);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -228,11 +228,13 @@ async function getBlobUploadPartUrl(
|
||||
) {
|
||||
const data = await gql(
|
||||
`
|
||||
mutation getBlobUploadPartUrl($workspaceId: String!, $key: String!, $uploadId: String!, $partNumber: Int!) {
|
||||
getBlobUploadPartUrl(workspaceId: $workspaceId, key: $key, uploadId: $uploadId, partNumber: $partNumber) {
|
||||
uploadUrl
|
||||
headers
|
||||
expiresAt
|
||||
query getBlobUploadPartUrl($workspaceId: String!, $key: String!, $uploadId: String!, $partNumber: Int!) {
|
||||
workspace(id: $workspaceId) {
|
||||
blobUploadPartUrl(key: $key, uploadId: $uploadId, partNumber: $partNumber) {
|
||||
uploadUrl
|
||||
headers
|
||||
expiresAt
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
@@ -240,7 +242,7 @@ async function getBlobUploadPartUrl(
|
||||
'getBlobUploadPartUrl'
|
||||
);
|
||||
|
||||
return data.getBlobUploadPartUrl;
|
||||
return data.workspace.blobUploadPartUrl;
|
||||
}
|
||||
|
||||
async function setupWorkspace() {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { getUserQuery } from '@affine/graphql';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import { ThrottlerStorage } from '../../../base/throttler';
|
||||
import { app, e2e, Mockers } from '../test';
|
||||
|
||||
e2e('user(email) should return null without auth', async t => {
|
||||
const user = await app.create(Mockers.User);
|
||||
|
||||
await app.logout();
|
||||
|
||||
const res = await app.gql({
|
||||
query: getUserQuery,
|
||||
variables: { email: user.email },
|
||||
});
|
||||
|
||||
t.is(res.user, null);
|
||||
});
|
||||
|
||||
e2e('user(email) should return null outside workspace scope', async t => {
|
||||
await app.logout();
|
||||
const me = await app.signup();
|
||||
const other = await app.create(Mockers.User);
|
||||
|
||||
const res = await app.gql({
|
||||
query: getUserQuery,
|
||||
variables: { email: other.email },
|
||||
});
|
||||
|
||||
t.is(res.user, null);
|
||||
|
||||
// sanity: querying self is always allowed
|
||||
const self = await app.gql({
|
||||
query: getUserQuery,
|
||||
variables: { email: me.email },
|
||||
});
|
||||
t.truthy(self.user);
|
||||
if (!self.user) return;
|
||||
t.is(self.user.__typename, 'UserType');
|
||||
if (self.user.__typename === 'UserType') {
|
||||
t.is(self.user.id, me.id);
|
||||
}
|
||||
});
|
||||
|
||||
e2e('user(email) should return user within workspace scope', async t => {
|
||||
await app.logout();
|
||||
const me = await app.signup();
|
||||
const other = await app.create(Mockers.User);
|
||||
const ws = await app.create(Mockers.Workspace, { owner: me });
|
||||
|
||||
await app.create(Mockers.WorkspaceUser, {
|
||||
workspaceId: ws.id,
|
||||
userId: other.id,
|
||||
});
|
||||
|
||||
const res = await app.gql({
|
||||
query: getUserQuery,
|
||||
variables: { email: other.email },
|
||||
});
|
||||
|
||||
t.truthy(res.user);
|
||||
if (!res.user) return;
|
||||
t.is(res.user.__typename, 'UserType');
|
||||
if (res.user.__typename === 'UserType') {
|
||||
t.is(res.user.id, other.id);
|
||||
}
|
||||
});
|
||||
|
||||
e2e('user(email) should be rate limited', async t => {
|
||||
await app.logout();
|
||||
const me = await app.signup();
|
||||
|
||||
const stub = Sinon.stub(app.get(ThrottlerStorage), 'increment').resolves({
|
||||
timeToExpire: 10,
|
||||
totalHits: 21,
|
||||
isBlocked: true,
|
||||
timeToBlockExpire: 10,
|
||||
});
|
||||
|
||||
await t.throwsAsync(
|
||||
app.gql({
|
||||
query: getUserQuery,
|
||||
variables: { email: me.email },
|
||||
}),
|
||||
{ message: /too many requests/i }
|
||||
);
|
||||
|
||||
stub.restore();
|
||||
});
|
||||
@@ -17,17 +17,3 @@ Generated by [AVA](https://avajs.dev).
|
||||
name: 'Free',
|
||||
storageQuota: 10737418240,
|
||||
}
|
||||
|
||||
## should get feature if extra fields exist in feature config
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
{
|
||||
blobLimit: 10485760,
|
||||
businessBlobLimit: 104857600,
|
||||
copilotActionLimit: 10,
|
||||
historyPeriod: 604800000,
|
||||
memberLimit: 3,
|
||||
name: 'Free',
|
||||
storageQuota: 10737418240,
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -68,7 +68,7 @@ test("should be able to redirect to oauth provider's login page", async t => {
|
||||
|
||||
const res = await app
|
||||
.POST('/api/oauth/preflight')
|
||||
.send({ provider: 'Google' })
|
||||
.send({ provider: 'Google', client_nonce: 'test-nonce' })
|
||||
.expect(HttpStatus.OK);
|
||||
|
||||
const { url } = res.body;
|
||||
@@ -100,7 +100,7 @@ test('should be able to redirect to oauth provider with multiple hosts', async t
|
||||
const res = await app
|
||||
.POST('/api/oauth/preflight')
|
||||
.set('host', 'test.affine.dev')
|
||||
.send({ provider: 'Google' })
|
||||
.send({ provider: 'Google', client_nonce: 'test-nonce' })
|
||||
.expect(HttpStatus.OK);
|
||||
|
||||
const { url } = res.body;
|
||||
@@ -156,12 +156,45 @@ test('should be able to redirect to oauth provider with client_nonce', async t =
|
||||
t.truthy(state.state);
|
||||
});
|
||||
|
||||
test('should forbid preflight with untrusted redirect_uri', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
await app
|
||||
.POST('/api/oauth/preflight')
|
||||
.send({
|
||||
provider: 'Google',
|
||||
redirect_uri: 'https://evil.example',
|
||||
client_nonce: 'test-nonce',
|
||||
})
|
||||
.expect(HttpStatus.FORBIDDEN);
|
||||
t.pass();
|
||||
});
|
||||
|
||||
test('should throw if client_nonce is missing in preflight', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
await app
|
||||
.POST('/api/oauth/preflight')
|
||||
.send({ provider: 'Google' })
|
||||
.expect(HttpStatus.BAD_REQUEST)
|
||||
.expect({
|
||||
status: 400,
|
||||
code: 'Bad Request',
|
||||
type: 'BAD_REQUEST',
|
||||
name: 'MISSING_OAUTH_QUERY_PARAMETER',
|
||||
message: 'Missing query parameter `client_nonce`.',
|
||||
data: { name: 'client_nonce' },
|
||||
});
|
||||
|
||||
t.pass();
|
||||
});
|
||||
|
||||
test('should throw if provider is invalid', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
await app
|
||||
.POST('/api/oauth/preflight')
|
||||
.send({ provider: 'Invalid' })
|
||||
.send({ provider: 'Invalid', client_nonce: 'test-nonce' })
|
||||
.expect(HttpStatus.BAD_REQUEST)
|
||||
.expect({
|
||||
status: 400,
|
||||
@@ -320,7 +353,7 @@ test('should throw if provider is invalid in callback uri', async t => {
|
||||
function mockOAuthProvider(
|
||||
app: TestingApp,
|
||||
email: string,
|
||||
clientNonce?: string
|
||||
clientNonce: string = randomUUID()
|
||||
) {
|
||||
const provider = app.get(GoogleOAuthProvider);
|
||||
const oauth = app.get(OAuthService);
|
||||
@@ -337,16 +370,18 @@ function mockOAuthProvider(
|
||||
email,
|
||||
avatarUrl: 'avatar',
|
||||
});
|
||||
|
||||
return clientNonce;
|
||||
}
|
||||
|
||||
test('should be able to sign up with oauth', async t => {
|
||||
const { app, db } = t.context;
|
||||
|
||||
mockOAuthProvider(app, 'u2@affine.pro');
|
||||
const clientNonce = mockOAuthProvider(app, 'u2@affine.pro');
|
||||
|
||||
await app
|
||||
.POST('/api/oauth/callback')
|
||||
.send({ code: '1', state: '1' })
|
||||
.send({ code: '1', state: '1', client_nonce: clientNonce })
|
||||
.expect(HttpStatus.OK);
|
||||
|
||||
const sessionUser = await currentUser(app);
|
||||
@@ -427,11 +462,11 @@ test('should throw if client_nonce is invalid', async t => {
|
||||
test('should not throw if account registered', async t => {
|
||||
const { app, u1 } = t.context;
|
||||
|
||||
mockOAuthProvider(app, u1.email);
|
||||
const clientNonce = mockOAuthProvider(app, u1.email);
|
||||
|
||||
const res = await app
|
||||
.POST('/api/oauth/callback')
|
||||
.send({ code: '1', state: '1' })
|
||||
.send({ code: '1', state: '1', client_nonce: clientNonce })
|
||||
.expect(HttpStatus.OK);
|
||||
|
||||
t.is(res.body.id, u1.id);
|
||||
@@ -442,9 +477,11 @@ test('should be able to fullfil user with oauth sign in', async t => {
|
||||
|
||||
const u3 = await app.createUser('u3@affine.pro');
|
||||
|
||||
mockOAuthProvider(app, u3.email);
|
||||
const clientNonce = mockOAuthProvider(app, u3.email);
|
||||
|
||||
await app.POST('/api/oauth/callback').send({ code: '1', state: '1' });
|
||||
await app
|
||||
.POST('/api/oauth/callback')
|
||||
.send({ code: '1', state: '1', client_nonce: clientNonce });
|
||||
|
||||
const sessionUser = await currentUser(app);
|
||||
|
||||
|
||||
@@ -1,5 +1,339 @@
|
||||
import test from 'ava';
|
||||
import test, { type ExecutionContext } from 'ava';
|
||||
import { io, type Socket as SocketIOClient } from 'socket.io-client';
|
||||
import { Doc, encodeStateAsUpdate } from 'yjs';
|
||||
|
||||
test('should test through sync gateway', t => {
|
||||
t.pass();
|
||||
import { createTestingApp, TestingApp } from '../utils';
|
||||
|
||||
type WebsocketResponse<T> =
|
||||
| { error: { name: string; message: string } }
|
||||
| { data: T };
|
||||
|
||||
const WS_TIMEOUT_MS = 5_000;
|
||||
|
||||
function unwrapResponse<T>(t: ExecutionContext, res: WebsocketResponse<T>): T {
|
||||
if ('data' in res) {
|
||||
return res.data;
|
||||
}
|
||||
|
||||
t.log(res);
|
||||
throw new Error(`Websocket error: ${res.error.name}: ${res.error.message}`);
|
||||
}
|
||||
|
||||
async function withTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
label: string
|
||||
) {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(new Error(`Timeout (${timeoutMs}ms): ${label}`));
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([promise, timeout]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function createClient(url: string, cookie: string): SocketIOClient {
|
||||
return io(url, {
|
||||
transports: ['websocket'],
|
||||
reconnection: false,
|
||||
forceNew: true,
|
||||
extraHeaders: {
|
||||
cookie,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function waitForConnect(socket: SocketIOClient) {
|
||||
if (socket.connected) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return withTimeout(
|
||||
new Promise<void>((resolve, reject) => {
|
||||
socket.once('connect', resolve);
|
||||
socket.once('connect_error', reject);
|
||||
}),
|
||||
WS_TIMEOUT_MS,
|
||||
'socket connect'
|
||||
);
|
||||
}
|
||||
|
||||
function waitForDisconnect(socket: SocketIOClient) {
|
||||
if (socket.disconnected) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return withTimeout(
|
||||
new Promise<void>(resolve => {
|
||||
socket.once('disconnect', () => resolve());
|
||||
}),
|
||||
WS_TIMEOUT_MS,
|
||||
'socket disconnect'
|
||||
);
|
||||
}
|
||||
|
||||
function emitWithAck<T>(socket: SocketIOClient, event: string, data: unknown) {
|
||||
return withTimeout(
|
||||
new Promise<WebsocketResponse<T>>(resolve => {
|
||||
socket.emit(event, data, (res: WebsocketResponse<T>) => resolve(res));
|
||||
}),
|
||||
WS_TIMEOUT_MS,
|
||||
`ack ${event}`
|
||||
);
|
||||
}
|
||||
|
||||
function waitForEvent<T>(socket: SocketIOClient, event: string) {
|
||||
return withTimeout(
|
||||
new Promise<T>(resolve => {
|
||||
socket.once(event, (payload: T) => resolve(payload));
|
||||
}),
|
||||
WS_TIMEOUT_MS,
|
||||
`event ${event}`
|
||||
);
|
||||
}
|
||||
|
||||
function expectNoEvent(
|
||||
socket: SocketIOClient,
|
||||
event: string,
|
||||
durationMs = 200
|
||||
) {
|
||||
return withTimeout(
|
||||
new Promise<void>((resolve, reject) => {
|
||||
let timer: NodeJS.Timeout;
|
||||
const onEvent = () => {
|
||||
clearTimeout(timer);
|
||||
socket.off(event, onEvent);
|
||||
reject(new Error(`Unexpected event received: ${event}`));
|
||||
};
|
||||
|
||||
timer = setTimeout(() => {
|
||||
socket.off(event, onEvent);
|
||||
resolve();
|
||||
}, durationMs);
|
||||
|
||||
socket.on(event, onEvent);
|
||||
}),
|
||||
WS_TIMEOUT_MS,
|
||||
`expect no event ${event}`
|
||||
);
|
||||
}
|
||||
|
||||
async function login(app: TestingApp) {
|
||||
const user = await app.createUser('u1@affine.pro');
|
||||
const res = await app
|
||||
.POST('/api/auth/sign-in')
|
||||
.send({ email: user.email, password: user.password })
|
||||
.expect(200);
|
||||
|
||||
const cookies = res.get('Set-Cookie') ?? [];
|
||||
const cookieHeader = cookies.map(c => c.split(';')[0]).join('; ');
|
||||
return { user, cookieHeader };
|
||||
}
|
||||
|
||||
function createYjsUpdateBase64() {
|
||||
const doc = new Doc();
|
||||
doc.getMap('m').set('k', 'v');
|
||||
const update = encodeStateAsUpdate(doc);
|
||||
return Buffer.from(update).toString('base64');
|
||||
}
|
||||
|
||||
let app: TestingApp;
|
||||
let url: string;
|
||||
|
||||
test.before(async () => {
|
||||
app = await createTestingApp();
|
||||
url = app.url();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await app.initTestingDB();
|
||||
});
|
||||
|
||||
test.after.always(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test('clientVersion=0.25.0 should only receive space:broadcast-doc-update', async t => {
|
||||
const { user, cookieHeader } = await login(app);
|
||||
const spaceId = user.id;
|
||||
const update = createYjsUpdateBase64();
|
||||
|
||||
const sender = createClient(url, cookieHeader);
|
||||
const receiver = createClient(url, cookieHeader);
|
||||
|
||||
try {
|
||||
await Promise.all([waitForConnect(sender), waitForConnect(receiver)]);
|
||||
|
||||
const receiverJoin = unwrapResponse(
|
||||
t,
|
||||
await emitWithAck<{ clientId: string; success: boolean }>(
|
||||
receiver,
|
||||
'space:join',
|
||||
{ spaceType: 'userspace', spaceId, clientVersion: '0.25.0' }
|
||||
)
|
||||
);
|
||||
t.true(receiverJoin.success);
|
||||
|
||||
const senderJoin = unwrapResponse(
|
||||
t,
|
||||
await emitWithAck<{ clientId: string; success: boolean }>(
|
||||
sender,
|
||||
'space:join',
|
||||
{ spaceType: 'userspace', spaceId, clientVersion: '0.26.0' }
|
||||
)
|
||||
);
|
||||
t.true(senderJoin.success);
|
||||
|
||||
const onUpdate = waitForEvent<{
|
||||
spaceType: string;
|
||||
spaceId: string;
|
||||
docId: string;
|
||||
update: string;
|
||||
}>(receiver, 'space:broadcast-doc-update');
|
||||
const noUpdates = expectNoEvent(receiver, 'space:broadcast-doc-updates');
|
||||
|
||||
const pushRes = await emitWithAck<{ accepted: true; timestamp?: number }>(
|
||||
sender,
|
||||
'space:push-doc-update',
|
||||
{
|
||||
spaceType: 'userspace',
|
||||
spaceId,
|
||||
docId: 'doc-1',
|
||||
update,
|
||||
}
|
||||
);
|
||||
unwrapResponse(t, pushRes);
|
||||
|
||||
const message = await onUpdate;
|
||||
t.is(message.spaceType, 'userspace');
|
||||
t.is(message.spaceId, spaceId);
|
||||
t.is(message.docId, 'doc-1');
|
||||
t.is(message.update, update);
|
||||
|
||||
await noUpdates;
|
||||
} finally {
|
||||
sender.disconnect();
|
||||
receiver.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('clientVersion>=0.26.0 should only receive space:broadcast-doc-updates', async t => {
|
||||
const { user, cookieHeader } = await login(app);
|
||||
const spaceId = user.id;
|
||||
const update = createYjsUpdateBase64();
|
||||
|
||||
const sender = createClient(url, cookieHeader);
|
||||
const receiver = createClient(url, cookieHeader);
|
||||
|
||||
try {
|
||||
await Promise.all([waitForConnect(sender), waitForConnect(receiver)]);
|
||||
|
||||
const receiverJoin = unwrapResponse(
|
||||
t,
|
||||
await emitWithAck<{ clientId: string; success: boolean }>(
|
||||
receiver,
|
||||
'space:join',
|
||||
{ spaceType: 'userspace', spaceId, clientVersion: '0.26.0' }
|
||||
)
|
||||
);
|
||||
t.true(receiverJoin.success);
|
||||
|
||||
const senderJoin = unwrapResponse(
|
||||
t,
|
||||
await emitWithAck<{ clientId: string; success: boolean }>(
|
||||
sender,
|
||||
'space:join',
|
||||
{ spaceType: 'userspace', spaceId, clientVersion: '0.25.0' }
|
||||
)
|
||||
);
|
||||
t.true(senderJoin.success);
|
||||
|
||||
const onUpdates = waitForEvent<{
|
||||
spaceType: string;
|
||||
spaceId: string;
|
||||
docId: string;
|
||||
updates: string[];
|
||||
}>(receiver, 'space:broadcast-doc-updates');
|
||||
const noUpdate = expectNoEvent(receiver, 'space:broadcast-doc-update');
|
||||
|
||||
const pushRes = await emitWithAck<{ accepted: true; timestamp?: number }>(
|
||||
sender,
|
||||
'space:push-doc-update',
|
||||
{
|
||||
spaceType: 'userspace',
|
||||
spaceId,
|
||||
docId: 'doc-2',
|
||||
update,
|
||||
}
|
||||
);
|
||||
unwrapResponse(t, pushRes);
|
||||
|
||||
const message = await onUpdates;
|
||||
t.is(message.spaceType, 'userspace');
|
||||
t.is(message.spaceId, spaceId);
|
||||
t.is(message.docId, 'doc-2');
|
||||
t.deepEqual(message.updates, [update]);
|
||||
|
||||
await noUpdate;
|
||||
} finally {
|
||||
sender.disconnect();
|
||||
receiver.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('clientVersion<0.25.0 should be rejected and disconnected', async t => {
|
||||
const { user, cookieHeader } = await login(app);
|
||||
const spaceId = user.id;
|
||||
|
||||
const socket = createClient(url, cookieHeader);
|
||||
try {
|
||||
await waitForConnect(socket);
|
||||
|
||||
const res = unwrapResponse(
|
||||
t,
|
||||
await emitWithAck<{ clientId: string; success: boolean }>(
|
||||
socket,
|
||||
'space:join',
|
||||
{ spaceType: 'userspace', spaceId, clientVersion: '0.24.4' }
|
||||
)
|
||||
);
|
||||
t.false(res.success);
|
||||
|
||||
await waitForDisconnect(socket);
|
||||
} finally {
|
||||
socket.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('space:join-awareness should reject clientVersion<0.25.0', async t => {
|
||||
const { user, cookieHeader } = await login(app);
|
||||
const spaceId = user.id;
|
||||
|
||||
const socket = createClient(url, cookieHeader);
|
||||
try {
|
||||
await waitForConnect(socket);
|
||||
|
||||
const res = unwrapResponse(
|
||||
t,
|
||||
await emitWithAck<{ clientId: string; success: boolean }>(
|
||||
socket,
|
||||
'space:join-awareness',
|
||||
{
|
||||
spaceType: 'userspace',
|
||||
spaceId,
|
||||
docId: 'doc-awareness',
|
||||
clientVersion: '0.24.4',
|
||||
}
|
||||
)
|
||||
);
|
||||
t.false(res.success);
|
||||
|
||||
await waitForDisconnect(socket);
|
||||
} finally {
|
||||
socket.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -152,9 +152,13 @@ export async function getBlobUploadPartUrl(
|
||||
) {
|
||||
const res = await app.gql(
|
||||
`
|
||||
mutation getBlobUploadPartUrl($workspaceId: String!, $key: String!, $uploadId: String!, $partNumber: Int!) {
|
||||
getBlobUploadPartUrl(workspaceId: $workspaceId, key: $key, uploadId: $uploadId, partNumber: $partNumber) {
|
||||
uploadUrl
|
||||
query getBlobUploadPartUrl($workspaceId: String!, $key: String!, $uploadId: String!, $partNumber: Int!) {
|
||||
workspace(id: $workspaceId) {
|
||||
blobUploadPartUrl(key: $key, uploadId: $uploadId, partNumber: $partNumber) {
|
||||
uploadUrl
|
||||
headers
|
||||
expiresAt
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
@@ -165,5 +169,5 @@ export async function getBlobUploadPartUrl(
|
||||
partNumber,
|
||||
}
|
||||
);
|
||||
return res.getBlobUploadPartUrl;
|
||||
return res.workspace.blobUploadPartUrl;
|
||||
}
|
||||
|
||||
@@ -250,7 +250,6 @@ export async function listContext(
|
||||
export async function addContextFile(
|
||||
app: TestingApp,
|
||||
contextId: string,
|
||||
blobId: string,
|
||||
fileName: string,
|
||||
content: Buffer
|
||||
): Promise<{ id: string }> {
|
||||
@@ -269,7 +268,7 @@ export async function addContextFile(
|
||||
`,
|
||||
variables: {
|
||||
content: null,
|
||||
options: { contextId, blobId },
|
||||
options: { contextId },
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
@@ -139,11 +139,11 @@ export async function revokeUser(
|
||||
): Promise<boolean> {
|
||||
const res = await app.gql(`
|
||||
mutation {
|
||||
revoke(workspaceId: "${workspaceId}", userId: "${userId}")
|
||||
revokeMember(workspaceId: "${workspaceId}", userId: "${userId}")
|
||||
}
|
||||
`);
|
||||
|
||||
return res.revoke;
|
||||
return res.revokeMember;
|
||||
}
|
||||
|
||||
export async function getInviteInfo(
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
GlobalExceptionFilter,
|
||||
JobQueue,
|
||||
} from '../../base';
|
||||
import { SocketIoAdapter } from '../../base/websocket';
|
||||
import { AuthService } from '../../core/auth';
|
||||
import { Mailer } from '../../core/mail';
|
||||
import { UserModel } from '../../models';
|
||||
@@ -61,6 +62,7 @@ export async function createTestingApp(
|
||||
);
|
||||
|
||||
app.use(cookieParser());
|
||||
app.useWebSocketAdapter(new SocketIoAdapter(app));
|
||||
|
||||
if (moduleDef.tapApp) {
|
||||
moduleDef.tapApp(app);
|
||||
@@ -89,6 +91,7 @@ export function parseCookies(res: supertest.Response) {
|
||||
export class TestingApp extends ApplyType<INestApplication>() {
|
||||
private sessionCookie: string | null = null;
|
||||
private currentUserCookie: string | null = null;
|
||||
private csrfCookie: string | null = null;
|
||||
private readonly userCookies: Set<string> = new Set();
|
||||
|
||||
readonly create!: ReturnType<typeof createFactory>;
|
||||
@@ -103,6 +106,7 @@ export class TestingApp extends ApplyType<INestApplication>() {
|
||||
await initTestingDB(this);
|
||||
this.sessionCookie = null;
|
||||
this.currentUserCookie = null;
|
||||
this.csrfCookie = null;
|
||||
this.userCookies.clear();
|
||||
}
|
||||
|
||||
@@ -118,12 +122,23 @@ export class TestingApp extends ApplyType<INestApplication>() {
|
||||
method: 'options' | 'get' | 'post' | 'put' | 'delete' | 'patch',
|
||||
path: string
|
||||
): supertest.Test {
|
||||
return supertest(this.getHttpServer())
|
||||
const cookies = [
|
||||
`${AuthService.sessionCookieName}=${this.sessionCookie ?? ''}`,
|
||||
`${AuthService.userCookieName}=${this.currentUserCookie ?? ''}`,
|
||||
];
|
||||
if (this.csrfCookie) {
|
||||
cookies.push(`${AuthService.csrfCookieName}=${this.csrfCookie}`);
|
||||
}
|
||||
|
||||
const req = supertest(this.getHttpServer())
|
||||
[method](path)
|
||||
.set('Cookie', [
|
||||
`${AuthService.sessionCookieName}=${this.sessionCookie ?? ''}`,
|
||||
`${AuthService.userCookieName}=${this.currentUserCookie ?? ''}`,
|
||||
]);
|
||||
.set('Cookie', cookies);
|
||||
|
||||
if (this.csrfCookie) {
|
||||
req.set('x-affine-csrf-token', this.csrfCookie);
|
||||
}
|
||||
|
||||
return req;
|
||||
}
|
||||
|
||||
OPTIONS(path: string): supertest.Test {
|
||||
@@ -147,6 +162,9 @@ export class TestingApp extends ApplyType<INestApplication>() {
|
||||
|
||||
this.sessionCookie = cookies[AuthService.sessionCookieName];
|
||||
this.currentUserCookie = cookies[AuthService.userCookieName];
|
||||
if (AuthService.csrfCookieName in cookies) {
|
||||
this.csrfCookie = cookies[AuthService.csrfCookieName] || null;
|
||||
}
|
||||
if (this.currentUserCookie) {
|
||||
this.userCookies.add(this.currentUserCookie);
|
||||
}
|
||||
@@ -270,13 +288,17 @@ export class TestingApp extends ApplyType<INestApplication>() {
|
||||
}
|
||||
|
||||
async logout(userId?: string) {
|
||||
const res = await this.GET(
|
||||
const res = await this.POST(
|
||||
'/api/auth/sign-out' + (userId ? `?user_id=${userId}` : '')
|
||||
).expect(200);
|
||||
const cookies = parseCookies(res);
|
||||
this.sessionCookie = cookies[AuthService.sessionCookieName];
|
||||
if (AuthService.csrfCookieName in cookies) {
|
||||
this.csrfCookie = cookies[AuthService.csrfCookieName] || null;
|
||||
}
|
||||
if (!this.sessionCookie) {
|
||||
this.currentUserCookie = null;
|
||||
this.csrfCookie = null;
|
||||
this.userCookies.clear();
|
||||
} else {
|
||||
this.currentUserCookie = cookies[AuthService.userCookieName];
|
||||
|
||||
@@ -188,10 +188,10 @@ export async function revokeMember(
|
||||
const res = await app.gql(
|
||||
`
|
||||
mutation {
|
||||
revoke(workspaceId: "${workspaceId}", userId: "${userId}")
|
||||
revokeMember(workspaceId: "${workspaceId}", userId: "${userId}")
|
||||
}
|
||||
`
|
||||
);
|
||||
|
||||
return res.revoke;
|
||||
return res.revokeMember;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ function checkVersion(enabled = true) {
|
||||
client: {
|
||||
versionControl: {
|
||||
enabled,
|
||||
requiredVersion: '>=0.20.0',
|
||||
requiredVersion: '>=0.25.0',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -88,23 +88,23 @@ test('should passthrough is version range is invalid', async t => {
|
||||
});
|
||||
|
||||
test('should pass if client version is allowed', async t => {
|
||||
let res = await app.GET('/guarded/test').set('x-affine-version', '0.20.0');
|
||||
let res = await app.GET('/guarded/test').set('x-affine-version', '0.25.0');
|
||||
|
||||
t.is(res.status, 200);
|
||||
|
||||
res = await app.GET('/guarded/test').set('x-affine-version', '0.21.0');
|
||||
res = await app.GET('/guarded/test').set('x-affine-version', '0.26.0');
|
||||
|
||||
t.is(res.status, 200);
|
||||
|
||||
config.override({
|
||||
client: {
|
||||
versionControl: {
|
||||
requiredVersion: '>=0.19.0',
|
||||
requiredVersion: '>=0.25.0',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
res = await app.GET('/guarded/test').set('x-affine-version', '0.19.0');
|
||||
res = await app.GET('/guarded/test').set('x-affine-version', '0.25.0');
|
||||
|
||||
t.is(res.status, 200);
|
||||
});
|
||||
@@ -115,7 +115,7 @@ test('should fail if client version is not set or invalid', async t => {
|
||||
t.is(res.status, 403);
|
||||
t.is(
|
||||
res.body.message,
|
||||
'Unsupported client with version [unset_or_invalid], required version is [>=0.20.0].'
|
||||
'Unsupported client with version [unset_or_invalid], required version is [>=0.25.0].'
|
||||
);
|
||||
|
||||
res = await app.GET('/guarded/test').set('x-affine-version', 'invalid');
|
||||
@@ -123,7 +123,7 @@ test('should fail if client version is not set or invalid', async t => {
|
||||
t.is(res.status, 403);
|
||||
t.is(
|
||||
res.body.message,
|
||||
'Unsupported client with version [invalid], required version is [>=0.20.0].'
|
||||
'Unsupported client with version [invalid], required version is [>=0.25.0].'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -131,17 +131,17 @@ test('should tell upgrade if client version is lower than allowed', async t => {
|
||||
config.override({
|
||||
client: {
|
||||
versionControl: {
|
||||
requiredVersion: '>=0.21.0 <=0.22.0',
|
||||
requiredVersion: '>=0.26.0 <=0.27.0',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let res = await app.GET('/guarded/test').set('x-affine-version', '0.20.0');
|
||||
let res = await app.GET('/guarded/test').set('x-affine-version', '0.25.0');
|
||||
|
||||
t.is(res.status, 403);
|
||||
t.is(
|
||||
res.body.message,
|
||||
'Unsupported client with version [0.20.0], required version is [>=0.21.0 <=0.22.0].'
|
||||
'Unsupported client with version [0.25.0], required version is [>=0.26.0 <=0.27.0].'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -149,17 +149,17 @@ test('should tell downgrade if client version is higher than allowed', async t =
|
||||
config.override({
|
||||
client: {
|
||||
versionControl: {
|
||||
requiredVersion: '>=0.20.0 <=0.22.0',
|
||||
requiredVersion: '>=0.25.0 <=0.26.0',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let res = await app.GET('/guarded/test').set('x-affine-version', '0.23.0');
|
||||
let res = await app.GET('/guarded/test').set('x-affine-version', '0.27.0');
|
||||
|
||||
t.is(res.status, 403);
|
||||
t.is(
|
||||
res.body.message,
|
||||
'Unsupported client with version [0.23.0], required version is [>=0.20.0 <=0.22.0].'
|
||||
'Unsupported client with version [0.27.0], required version is [>=0.25.0 <=0.26.0].'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -167,25 +167,25 @@ test('should test prerelease version', async t => {
|
||||
config.override({
|
||||
client: {
|
||||
versionControl: {
|
||||
requiredVersion: '>=0.19.0',
|
||||
requiredVersion: '>=0.25.0',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let res = await app
|
||||
.GET('/guarded/test')
|
||||
.set('x-affine-version', '0.19.0-canary.1');
|
||||
.set('x-affine-version', '0.25.0-canary.1');
|
||||
|
||||
// 0.19.0-canary.1 is lower than 0.19.0 obviously
|
||||
// 0.25.0-canary.1 is lower than 0.25.0 obviously
|
||||
t.is(res.status, 403);
|
||||
|
||||
res = await app
|
||||
.GET('/guarded/test')
|
||||
.set('x-affine-version', '0.20.0-canary.1');
|
||||
.set('x-affine-version', '0.26.0-canary.1');
|
||||
|
||||
t.is(res.status, 200);
|
||||
|
||||
res = await app.GET('/guarded/test').set('x-affine-version', '0.20.0-beta.2');
|
||||
res = await app.GET('/guarded/test').set('x-affine-version', '0.26.0-beta.2');
|
||||
|
||||
t.is(res.status, 200);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import type { ExecutionContext, TestFn } from 'ava';
|
||||
import ava from 'ava';
|
||||
import { LookupAddress } from 'dns';
|
||||
import Sinon from 'sinon';
|
||||
import type { Response } from 'supertest';
|
||||
|
||||
import {
|
||||
__resetDnsLookupForTests,
|
||||
__setDnsLookupForTests,
|
||||
type DnsLookup,
|
||||
} from '../base/utils/ssrf';
|
||||
import { createTestingApp, TestingApp } from './utils';
|
||||
|
||||
type TestContext = {
|
||||
@@ -11,15 +17,30 @@ type TestContext = {
|
||||
|
||||
const test = ava as TestFn<TestContext>;
|
||||
|
||||
const LookupAddressStub = (async (_hostname, options) => {
|
||||
const result = [{ address: '76.76.21.21', family: 4 }] as LookupAddress[];
|
||||
const isOptions = options && typeof options === 'object';
|
||||
if (isOptions && 'all' in options && options.all) {
|
||||
return result;
|
||||
}
|
||||
return result[0];
|
||||
}) as DnsLookup;
|
||||
|
||||
test.before(async t => {
|
||||
// @ts-expect-error test
|
||||
env.DEPLOYMENT_TYPE = 'selfhosted';
|
||||
|
||||
// Avoid relying on real DNS during tests. SSRF protection uses dns.lookup().
|
||||
__setDnsLookupForTests(LookupAddressStub);
|
||||
|
||||
const app = await createTestingApp();
|
||||
|
||||
t.context.app = app;
|
||||
});
|
||||
|
||||
test.after.always(async t => {
|
||||
Sinon.restore();
|
||||
__resetDnsLookupForTests();
|
||||
await t.context.app.close();
|
||||
});
|
||||
|
||||
@@ -29,7 +50,8 @@ const assertAndSnapshotRaw = async (
|
||||
message: string,
|
||||
options?: {
|
||||
status?: number;
|
||||
origin?: string;
|
||||
origin?: string | null;
|
||||
referer?: string | null;
|
||||
method?: 'GET' | 'OPTIONS' | 'POST';
|
||||
body?: any;
|
||||
checker?: (res: Response) => any;
|
||||
@@ -37,16 +59,21 @@ const assertAndSnapshotRaw = async (
|
||||
) => {
|
||||
const {
|
||||
status = 200,
|
||||
origin = 'http://localhost',
|
||||
origin = 'http://localhost:3010',
|
||||
referer,
|
||||
method = 'GET',
|
||||
checker = () => {},
|
||||
} = options || {};
|
||||
const { app } = t.context;
|
||||
const res = app[method](route)
|
||||
.set('Origin', origin)
|
||||
.send(options?.body)
|
||||
.expect(status)
|
||||
.expect(checker);
|
||||
const req = app[method](route);
|
||||
if (origin) {
|
||||
req.set('Origin', origin);
|
||||
}
|
||||
if (referer) {
|
||||
req.set('Referer', referer);
|
||||
}
|
||||
|
||||
const res = req.send(options?.body).expect(status).expect(checker);
|
||||
await t.notThrowsAsync(res, message);
|
||||
t.snapshot((await res).body);
|
||||
};
|
||||
@@ -76,6 +103,14 @@ test('should proxy image', async t => {
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
await assertAndSnapshot(
|
||||
'/api/worker/image-proxy?url=http://example.com/image.png',
|
||||
'should return 400 if origin and referer are missing',
|
||||
{ status: 400, origin: null, referer: null }
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
await assertAndSnapshot(
|
||||
'/api/worker/image-proxy?url=http://example.com/image.png',
|
||||
@@ -86,17 +121,13 @@ test('should proxy image', async t => {
|
||||
|
||||
{
|
||||
const fakeBuffer = Buffer.from('fake image');
|
||||
const fakeResponse = {
|
||||
ok: true,
|
||||
const fakeResponse = new Response(fakeBuffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
get: (header: string) => {
|
||||
if (header.toLowerCase() === 'content-type') return 'image/png';
|
||||
if (header.toLowerCase() === 'content-disposition') return 'inline';
|
||||
return null;
|
||||
},
|
||||
'content-type': 'image/png',
|
||||
'content-disposition': 'inline',
|
||||
},
|
||||
arrayBuffer: async () => fakeBuffer,
|
||||
} as any;
|
||||
});
|
||||
|
||||
const fetchSpy = Sinon.stub(global, 'fetch').resolves(fakeResponse);
|
||||
|
||||
@@ -132,6 +163,18 @@ test('should preview link', async t => {
|
||||
{ status: 400, method: 'POST' }
|
||||
);
|
||||
|
||||
await assertAndSnapshot(
|
||||
'/api/worker/link-preview',
|
||||
'should return 400 if origin and referer are missing',
|
||||
{
|
||||
status: 400,
|
||||
method: 'POST',
|
||||
origin: null,
|
||||
referer: null,
|
||||
body: { url: 'http://external.com/page' },
|
||||
}
|
||||
);
|
||||
|
||||
await assertAndSnapshot(
|
||||
'/api/worker/link-preview',
|
||||
'should return 400 if provided URL is from the same origin',
|
||||
|
||||
Reference in New Issue
Block a user