mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-29 16:19:43 +08:00
feat: drop outdated session (#14373)
#### PR Dependency Tree * **PR #14373** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added client version tracking and validation to ensure application compatibility across authentication flows and sessions. * Enhanced OAuth authentication with improved version handling during sign-in and refresh operations. * **Bug Fixes** * Improved payment callback URL handling with safer defaults for redirect links. * **Tests** * Expanded test coverage for client version enforcement and session management. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -53,6 +53,34 @@ test('should be able to sign in with credential', async t => {
|
||||
t.is(session?.id, u1.id);
|
||||
});
|
||||
|
||||
test('should record sign in client version when header is provided', async t => {
|
||||
const { app, db } = t.context;
|
||||
|
||||
const u1 = await app.createUser('u1@affine.pro');
|
||||
|
||||
await app
|
||||
.POST('/api/auth/sign-in')
|
||||
.set('x-affine-version', '0.25.1')
|
||||
.send({ email: u1.email, password: u1.password })
|
||||
.expect(200);
|
||||
|
||||
const userSession1 = await db.userSession.findFirst({
|
||||
where: { userId: u1.id },
|
||||
});
|
||||
t.is(userSession1?.signInClientVersion, '0.25.1');
|
||||
|
||||
// should not overwrite existing value with null/undefined
|
||||
await app
|
||||
.POST('/api/auth/sign-in')
|
||||
.send({ email: u1.email, password: u1.password })
|
||||
.expect(200);
|
||||
|
||||
const userSession2 = await db.userSession.findFirst({
|
||||
where: { userId: u1.id },
|
||||
});
|
||||
t.is(userSession2?.signInClientVersion, '0.25.1');
|
||||
});
|
||||
|
||||
test('should be able to sign in with email', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Controller, Get, HttpStatus, INestApplication } from '@nestjs/common';
|
||||
import { Controller, Get, HttpStatus } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import ava, { TestFn } from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
import request from 'supertest';
|
||||
|
||||
import { ConfigFactory } from '../../base';
|
||||
import { AuthModule, CurrentUser, Public, Session } from '../../core/auth';
|
||||
import { AuthService } from '../../core/auth/service';
|
||||
import { Models } from '../../models';
|
||||
import { createTestingApp } from '../utils';
|
||||
import { createTestingApp, TestingApp } from '../utils';
|
||||
|
||||
@Controller('/')
|
||||
class TestController {
|
||||
@@ -29,31 +30,46 @@ class TestController {
|
||||
}
|
||||
|
||||
const test = ava as TestFn<{
|
||||
app: INestApplication;
|
||||
app: TestingApp;
|
||||
server: any;
|
||||
auth: AuthService;
|
||||
models: Models;
|
||||
db: PrismaClient;
|
||||
config: ConfigFactory;
|
||||
u1: CurrentUser;
|
||||
sessionId: string;
|
||||
}>;
|
||||
|
||||
let server!: any;
|
||||
let auth!: AuthService;
|
||||
let u1!: CurrentUser;
|
||||
|
||||
let sessionId = '';
|
||||
|
||||
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 models = app.get(Models);
|
||||
const session = await models.session.createSession();
|
||||
sessionId = session.id;
|
||||
await auth.createUserSession(u1.id, sessionId);
|
||||
|
||||
server = app.getHttpServer();
|
||||
t.context.app = app;
|
||||
t.context.server = app.getHttpServer();
|
||||
t.context.auth = app.get(AuthService);
|
||||
t.context.models = app.get(Models);
|
||||
t.context.db = app.get(PrismaClient);
|
||||
t.context.config = app.get(ConfigFactory);
|
||||
});
|
||||
|
||||
test.beforeEach(async t => {
|
||||
Sinon.restore();
|
||||
await t.context.app.initTestingDB();
|
||||
t.context.config.override({
|
||||
client: {
|
||||
versionControl: {
|
||||
enabled: false,
|
||||
requiredVersion: '>=0.25.0',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
t.context.u1 = await t.context.auth.signUp('u1@affine.pro', '1');
|
||||
const session = await t.context.models.session.createSession();
|
||||
t.context.sessionId = session.id;
|
||||
await t.context.auth.createUserSession(t.context.u1.id, t.context.sessionId);
|
||||
});
|
||||
|
||||
test.after.always(async t => {
|
||||
@@ -61,92 +77,95 @@ test.after.always(async t => {
|
||||
});
|
||||
|
||||
test('should be able to visit public api if not signed in', async t => {
|
||||
const res = await request(server).get('/public').expect(200);
|
||||
const res = await request(t.context.server).get('/public').expect(200);
|
||||
|
||||
t.is(res.body.user, undefined);
|
||||
});
|
||||
|
||||
test('should be able to visit public api if signed in', async t => {
|
||||
const res = await request(server)
|
||||
const res = await request(t.context.server)
|
||||
.get('/public')
|
||||
.set('Cookie', `${AuthService.sessionCookieName}=${sessionId}`)
|
||||
.set('Cookie', `${AuthService.sessionCookieName}=${t.context.sessionId}`)
|
||||
.expect(HttpStatus.OK);
|
||||
|
||||
t.is(res.body.user.id, u1.id);
|
||||
t.is(res.body.user.id, t.context.u1.id);
|
||||
});
|
||||
|
||||
test('should not be able to visit private api if not signed in', async t => {
|
||||
await request(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.',
|
||||
});
|
||||
await request(t.context.server)
|
||||
.get('/private')
|
||||
.expect(HttpStatus.UNAUTHORIZED)
|
||||
.expect({
|
||||
status: 401,
|
||||
code: 'Unauthorized',
|
||||
type: 'AUTHENTICATION_REQUIRED',
|
||||
name: 'AUTHENTICATION_REQUIRED',
|
||||
message: 'You must sign in first to access this resource.',
|
||||
});
|
||||
|
||||
t.assert(true);
|
||||
});
|
||||
|
||||
test('should be able to visit private api if signed in', async t => {
|
||||
const res = await request(server)
|
||||
const res = await request(t.context.server)
|
||||
.get('/private')
|
||||
.set('Cookie', `${AuthService.sessionCookieName}=${sessionId}`)
|
||||
.set('Cookie', `${AuthService.sessionCookieName}=${t.context.sessionId}`)
|
||||
.expect(HttpStatus.OK);
|
||||
|
||||
t.is(res.body.user.id, u1.id);
|
||||
t.is(res.body.user.id, t.context.u1.id);
|
||||
});
|
||||
|
||||
test('should be able to visit private api with access token', async t => {
|
||||
const models = t.context.app.get(Models);
|
||||
const token = await models.accessToken.create({
|
||||
userId: u1.id,
|
||||
userId: t.context.u1.id,
|
||||
name: 'test',
|
||||
});
|
||||
|
||||
const res = await request(server)
|
||||
const res = await request(t.context.server)
|
||||
.get('/private')
|
||||
.set('Authorization', `Bearer ${token.token}`)
|
||||
.expect(HttpStatus.OK);
|
||||
|
||||
t.is(res.body.user.id, u1.id);
|
||||
t.is(res.body.user.id, t.context.u1.id);
|
||||
});
|
||||
|
||||
test('should be able to parse session cookie', async t => {
|
||||
const spy = Sinon.spy(auth, 'getUserSession');
|
||||
await request(server)
|
||||
const spy = Sinon.spy(t.context.auth, 'getUserSession');
|
||||
await request(t.context.server)
|
||||
.get('/public')
|
||||
.set('cookie', `${AuthService.sessionCookieName}=${sessionId}`)
|
||||
.set('cookie', `${AuthService.sessionCookieName}=${t.context.sessionId}`)
|
||||
.expect(200);
|
||||
|
||||
t.deepEqual(spy.firstCall.args, [sessionId, undefined]);
|
||||
t.deepEqual(spy.firstCall.args, [t.context.sessionId, undefined]);
|
||||
spy.restore();
|
||||
});
|
||||
|
||||
test('should be able to parse bearer token', async t => {
|
||||
const spy = Sinon.spy(auth, 'getUserSession');
|
||||
const spy = Sinon.spy(t.context.auth, 'getUserSession');
|
||||
|
||||
await request(server)
|
||||
await request(t.context.server)
|
||||
.get('/public')
|
||||
.auth(sessionId, { type: 'bearer' })
|
||||
.auth(t.context.sessionId, { type: 'bearer' })
|
||||
.expect(200);
|
||||
|
||||
t.deepEqual(spy.firstCall.args, [sessionId, undefined]);
|
||||
t.deepEqual(spy.firstCall.args, [t.context.sessionId, undefined]);
|
||||
spy.restore();
|
||||
});
|
||||
|
||||
test('should be able to refresh session if needed', async t => {
|
||||
await t.context.app.get(PrismaClient).userSession.updateMany({
|
||||
where: {
|
||||
sessionId,
|
||||
sessionId: t.context.sessionId,
|
||||
},
|
||||
data: {
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 60 /* expires in 1 hour */),
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(server)
|
||||
const res = await request(t.context.server)
|
||||
.get('/session')
|
||||
.set('cookie', `${AuthService.sessionCookieName}=${sessionId}`)
|
||||
.set('cookie', `${AuthService.sessionCookieName}=${t.context.sessionId}`)
|
||||
.expect(200);
|
||||
|
||||
const cookie = res
|
||||
@@ -155,3 +174,101 @@ test('should be able to refresh session if needed', async t => {
|
||||
|
||||
t.truthy(cookie);
|
||||
});
|
||||
|
||||
test('should record refresh client version when refreshed', async t => {
|
||||
await t.context.db.userSession.updateMany({
|
||||
where: { sessionId: t.context.sessionId },
|
||||
data: {
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 60 /* expires in 1 hour */),
|
||||
},
|
||||
});
|
||||
|
||||
await request(t.context.server)
|
||||
.get('/session')
|
||||
.set('cookie', `${AuthService.sessionCookieName}=${t.context.sessionId}`)
|
||||
.set('x-affine-version', '0.25.2')
|
||||
.expect(200);
|
||||
|
||||
const userSession = await t.context.db.userSession.findFirst({
|
||||
where: { sessionId: t.context.sessionId, userId: t.context.u1.id },
|
||||
});
|
||||
t.is(userSession?.refreshClientVersion, '0.25.2');
|
||||
});
|
||||
|
||||
test('should allow auth when header is missing but stored version is valid', async t => {
|
||||
t.context.config.override({
|
||||
client: {
|
||||
versionControl: {
|
||||
enabled: true,
|
||||
requiredVersion: '>=0.25.0',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await t.context.db.userSession.updateMany({
|
||||
where: { sessionId: t.context.sessionId },
|
||||
data: { signInClientVersion: '0.25.0' },
|
||||
});
|
||||
|
||||
const res = await request(t.context.server)
|
||||
.get('/private')
|
||||
.set('Cookie', `${AuthService.sessionCookieName}=${t.context.sessionId}`)
|
||||
.expect(200);
|
||||
|
||||
t.is(res.body.user.id, t.context.u1.id);
|
||||
});
|
||||
|
||||
test('should kick out unsupported client version on non-public handler', async t => {
|
||||
t.context.config.override({
|
||||
client: {
|
||||
versionControl: {
|
||||
enabled: true,
|
||||
requiredVersion: '>=0.25.0',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(t.context.server)
|
||||
.get('/private')
|
||||
.set('Cookie', `${AuthService.sessionCookieName}=${t.context.sessionId}`)
|
||||
.set('x-affine-version', '0.24.0')
|
||||
.expect(403);
|
||||
|
||||
const setCookies = res.get('Set-Cookie') ?? [];
|
||||
t.true(
|
||||
setCookies.some(c => c.startsWith(`${AuthService.sessionCookieName}=`))
|
||||
);
|
||||
t.true(setCookies.some(c => c.startsWith(`${AuthService.userCookieName}=`)));
|
||||
t.true(setCookies.some(c => c.startsWith(`${AuthService.csrfCookieName}=`)));
|
||||
|
||||
const session = await t.context.db.session.findFirst({
|
||||
where: { id: t.context.sessionId },
|
||||
});
|
||||
t.is(session, null);
|
||||
});
|
||||
|
||||
test('should not block public handler when client version is unsupported', async t => {
|
||||
t.context.config.override({
|
||||
client: {
|
||||
versionControl: {
|
||||
enabled: true,
|
||||
requiredVersion: '>=0.25.0',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(t.context.server)
|
||||
.get('/public')
|
||||
.set('Cookie', `${AuthService.sessionCookieName}=${t.context.sessionId}`)
|
||||
.set('x-affine-version', '0.24.0')
|
||||
.expect(200);
|
||||
|
||||
t.is(res.body.user, undefined);
|
||||
|
||||
const setCookies = res.get('Set-Cookie') ?? [];
|
||||
t.true(
|
||||
setCookies.some(c => c.startsWith(`${AuthService.sessionCookieName}=`))
|
||||
);
|
||||
t.true(setCookies.some(c => c.startsWith(`${AuthService.userCookieName}=`)));
|
||||
t.true(setCookies.some(c => c.startsWith(`${AuthService.csrfCookieName}=`)));
|
||||
});
|
||||
|
||||
@@ -122,6 +122,64 @@ test('should refresh exists userSession', async t => {
|
||||
);
|
||||
});
|
||||
|
||||
test('should record sign-in client version on create and update', async t => {
|
||||
const user = await t.context.user.create({
|
||||
email: 'test@affine.pro',
|
||||
});
|
||||
const session = await t.context.session.createSession();
|
||||
|
||||
const userSession1 = await t.context.session.createOrRefreshUserSession(
|
||||
user.id,
|
||||
session.id,
|
||||
undefined,
|
||||
'0.25.0'
|
||||
);
|
||||
t.is(userSession1.signInClientVersion, '0.25.0');
|
||||
|
||||
const userSession2 = await t.context.session.createOrRefreshUserSession(
|
||||
user.id,
|
||||
session.id
|
||||
);
|
||||
t.is(userSession2.signInClientVersion, '0.25.0');
|
||||
|
||||
const userSession3 = await t.context.session.createOrRefreshUserSession(
|
||||
user.id,
|
||||
session.id,
|
||||
undefined,
|
||||
'0.26.0'
|
||||
);
|
||||
t.is(userSession3.signInClientVersion, '0.26.0');
|
||||
});
|
||||
|
||||
test('should record refresh client version only when refreshed', async t => {
|
||||
const user = await t.context.user.create({
|
||||
email: 'test@affine.pro',
|
||||
});
|
||||
const session = await t.context.session.createSession();
|
||||
const userSession = await t.context.session.createOrRefreshUserSession(
|
||||
user.id,
|
||||
session.id
|
||||
);
|
||||
|
||||
// force refresh
|
||||
userSession.expiresAt = new Date(
|
||||
userSession.expiresAt!.getTime() -
|
||||
t.context.config.auth.session.ttr * 2 * 1000
|
||||
);
|
||||
|
||||
const newExpiresAt = await t.context.session.refreshUserSessionIfNeeded(
|
||||
userSession,
|
||||
undefined,
|
||||
'0.25.0'
|
||||
);
|
||||
t.truthy(newExpiresAt);
|
||||
|
||||
const refreshed = await t.context.db.userSession.findFirst({
|
||||
where: { id: userSession.id },
|
||||
});
|
||||
t.is(refreshed?.refreshClientVersion, '0.25.0');
|
||||
});
|
||||
|
||||
test('should not refresh userSession when expires time not hit ttr', async t => {
|
||||
const user = await t.context.user.create({
|
||||
email: 'test@affine.pro',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Controller, Get, HttpStatus, UseGuards } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import ava, { TestFn } from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
import { type Response } from 'supertest';
|
||||
@@ -144,6 +145,72 @@ test('should be able to prevent requests if limit is reached', async t => {
|
||||
stub.restore();
|
||||
});
|
||||
|
||||
test('should use session id as tracker when available', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const user = await app.signupV1('u1@affine.pro');
|
||||
const userSession = await app.get(PrismaClient).userSession.findFirst({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
t.truthy(userSession);
|
||||
|
||||
const stub = Sinon.stub(app.get(ThrottlerStorage), 'increment').resolves({
|
||||
timeToExpire: 10,
|
||||
totalHits: 1,
|
||||
isBlocked: false,
|
||||
timeToBlockExpire: 0,
|
||||
});
|
||||
|
||||
await app.GET('/throttled/default').expect(200);
|
||||
|
||||
const key = stub.firstCall.args[0] as string;
|
||||
t.true(key.startsWith(`throttler:${userSession!.sessionId};default`));
|
||||
|
||||
stub.restore();
|
||||
});
|
||||
|
||||
test('should use CF-Connecting-IP as tracker when present', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const stub = Sinon.stub(app.get(ThrottlerStorage), 'increment').resolves({
|
||||
timeToExpire: 10,
|
||||
totalHits: 1,
|
||||
isBlocked: false,
|
||||
timeToBlockExpire: 0,
|
||||
});
|
||||
|
||||
await app
|
||||
.GET('/nonthrottled/default')
|
||||
.set('CF-Connecting-IP', '1.2.3.4')
|
||||
.expect(200);
|
||||
|
||||
const key = stub.firstCall.args[0] as string;
|
||||
t.true(key.startsWith('throttler:1.2.3.4;default'));
|
||||
|
||||
stub.restore();
|
||||
});
|
||||
|
||||
test('should use X-Forwarded-For as tracker when present', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const stub = Sinon.stub(app.get(ThrottlerStorage), 'increment').resolves({
|
||||
timeToExpire: 10,
|
||||
totalHits: 1,
|
||||
isBlocked: false,
|
||||
timeToBlockExpire: 0,
|
||||
});
|
||||
|
||||
await app
|
||||
.GET('/nonthrottled/default')
|
||||
.set('X-Forwarded-For', '5.6.7.8, 9.9.9.9')
|
||||
.expect(200);
|
||||
|
||||
const key = stub.firstCall.args[0] as string;
|
||||
t.true(key.startsWith('throttler:5.6.7.8;default'));
|
||||
|
||||
stub.restore();
|
||||
});
|
||||
|
||||
// ====== unauthenticated user visits ======
|
||||
test('should use default throttler for unauthenticated user when not specified', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
@@ -6,7 +6,7 @@ import ava, { TestFn } from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import { AppModule } from '../../app.module';
|
||||
import { URLHelper } from '../../base';
|
||||
import { ConfigFactory, URLHelper } from '../../base';
|
||||
import { ConfigModule } from '../../base/config';
|
||||
import { CurrentUser } from '../../core/auth';
|
||||
import { AuthService } from '../../core/auth/service';
|
||||
@@ -56,6 +56,14 @@ test.before(async t => {
|
||||
test.beforeEach(async t => {
|
||||
Sinon.restore();
|
||||
await t.context.app.initTestingDB();
|
||||
t.context.app.get(ConfigFactory).override({
|
||||
client: {
|
||||
versionControl: {
|
||||
enabled: false,
|
||||
requiredVersion: '>=0.25.0',
|
||||
},
|
||||
},
|
||||
});
|
||||
t.context.u1 = await t.context.auth.signUp('u1@affine.pro', '1');
|
||||
});
|
||||
|
||||
@@ -156,6 +164,56 @@ test('should be able to redirect to oauth provider with client_nonce', async t =
|
||||
t.truthy(state.state);
|
||||
});
|
||||
|
||||
test('should record sign in client version from oauth preflight state', async t => {
|
||||
const { app, db } = t.context;
|
||||
|
||||
const config = app.get(ConfigFactory);
|
||||
config.override({
|
||||
client: {
|
||||
versionControl: {
|
||||
enabled: true,
|
||||
requiredVersion: '>=0.25.0',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const preflightRes = await app
|
||||
.POST('/api/oauth/preflight')
|
||||
.set('x-affine-version', '0.25.3')
|
||||
.send({ provider: 'Google', client_nonce: 'test-nonce' })
|
||||
.expect(HttpStatus.OK);
|
||||
|
||||
const redirect = new URL(preflightRes.body.url as string);
|
||||
const stateParam = redirect.searchParams.get('state');
|
||||
t.truthy(stateParam);
|
||||
|
||||
// state should be a json string
|
||||
const rawState = JSON.parse(stateParam!);
|
||||
|
||||
const provider = app.get(GoogleOAuthProvider);
|
||||
Sinon.stub(provider, 'getToken').resolves({ accessToken: '1' });
|
||||
Sinon.stub(provider, 'getUser').resolves({
|
||||
id: '1',
|
||||
email: 'oauth-version@affine.pro',
|
||||
avatarUrl: 'avatar',
|
||||
});
|
||||
|
||||
const callbackRes = await app
|
||||
.POST('/api/oauth/callback')
|
||||
.send({ code: '1', state: stateParam, client_nonce: 'test-nonce' })
|
||||
.expect(HttpStatus.OK);
|
||||
|
||||
const userId = callbackRes.body.id as string;
|
||||
t.truthy(userId);
|
||||
|
||||
const userSession = await db.userSession.findFirst({
|
||||
where: { userId },
|
||||
});
|
||||
t.is(userSession?.signInClientVersion, '0.25.3');
|
||||
t.is(userSession?.refreshClientVersion, null);
|
||||
t.truthy(rawState.state);
|
||||
});
|
||||
|
||||
test('should forbid preflight with untrusted redirect_uri', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ test('should passthrough if version check is not enabled', async t => {
|
||||
spy.restore();
|
||||
});
|
||||
|
||||
test('should passthrough is version range is invalid', async t => {
|
||||
test('should enforce hard required version when version range is invalid', async t => {
|
||||
config.override({
|
||||
client: {
|
||||
versionControl: {
|
||||
@@ -82,9 +82,17 @@ test('should passthrough is version range is invalid', async t => {
|
||||
},
|
||||
});
|
||||
|
||||
let res = await app.GET('/guarded/test').set('x-affine-version', 'invalid');
|
||||
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', 'invalid');
|
||||
|
||||
t.is(res.status, 403);
|
||||
t.is(
|
||||
res.body.message,
|
||||
'Unsupported client with version [invalid], required version is [>=0.25.0].'
|
||||
);
|
||||
});
|
||||
|
||||
test('should pass if client version is allowed', async t => {
|
||||
|
||||
Reference in New Issue
Block a user