mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
fix(api)!: always send Retry-After and reclassify two limits (#2489)
This commit is contained in:
1 parent
bec34ea147
commit
bff29d8f07
9 files changed
+202
-18
No files matched your search
@@ -77,7 +77,7 @@ describe('Auth IP Authorization Resend', () => {
|
||||
.execute();
|
||||
expect(errorResp.code).toBe(APIErrorCodes.IP_AUTHORIZATION_RESEND_COOLDOWN);
|
||||
});
|
||||
it('returns resend_available_in when rate limited', async () => {
|
||||
it('returns resend_available_in and a matching Retry-After header when rate limited', async () => {
|
||||
const email = createUniqueEmail('ip-resend-cooldown');
|
||||
const password = 'a-strong-password';
|
||||
await registerUser(harness, {
|
||||
@@ -90,15 +90,17 @@ describe('Auth IP Authorization Resend', () => {
|
||||
});
|
||||
const newIP = '10.230.240.250';
|
||||
const ipAuthResp = await triggerIpAuthorization(email, password, newIP);
|
||||
const errorResp = await createBuilderWithoutAuth<ResendErrorResponse>(harness)
|
||||
const {response, json: errorResp} = await createBuilderWithoutAuth<ResendErrorResponse>(harness)
|
||||
.post('/auth/ip-authorization/resend')
|
||||
.body({ticket: ipAuthResp.ticket})
|
||||
.header('x-forwarded-for', newIP)
|
||||
.expect(HTTP_TOO_MANY_REQUESTS)
|
||||
.execute();
|
||||
.executeWithResponse();
|
||||
expect(errorResp.code).toBe(APIErrorCodes.IP_AUTHORIZATION_RESEND_COOLDOWN);
|
||||
expect(typeof errorResp.resend_available_in).toBe('number');
|
||||
expect(errorResp.resend_available_in).toBeGreaterThan(0);
|
||||
expect(response.headers.get('Retry-After')).toBe(String(errorResp.resend_available_in));
|
||||
expect(errorResp.retry_after).toBe(errorResp.resend_available_in);
|
||||
});
|
||||
});
|
||||
describe('Multiple resend attempts handling', () => {
|
||||
@@ -126,7 +128,7 @@ describe('Auth IP Authorization Resend', () => {
|
||||
});
|
||||
});
|
||||
describe('Resend with already-used ticket', () => {
|
||||
it('returns 429 when resend flag is already set on the ticket', async () => {
|
||||
it('returns 400 when resend flag is already set on the ticket', async () => {
|
||||
const email = createUniqueEmail('ip-resend-used');
|
||||
const password = 'a-strong-password';
|
||||
const reg = await registerUser(harness, {
|
||||
@@ -155,14 +157,15 @@ describe('Auth IP Authorization Resend', () => {
|
||||
ttl_seconds: 900,
|
||||
})
|
||||
.execute();
|
||||
const errorResp = await createBuilderWithoutAuth<ResendErrorResponse>(harness)
|
||||
const {response, json: errorResp} = await createBuilderWithoutAuth<ResendErrorResponse>(harness)
|
||||
.post('/auth/ip-authorization/resend')
|
||||
.body({ticket})
|
||||
.expect(HTTP_TOO_MANY_REQUESTS)
|
||||
.execute();
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.executeWithResponse();
|
||||
expect(errorResp.code).toBe(APIErrorCodes.IP_AUTHORIZATION_RESEND_LIMIT_EXCEEDED);
|
||||
expect(response.headers.get('Retry-After')).toBeNull();
|
||||
});
|
||||
it('returns 429 after IP has already been authorized', async () => {
|
||||
it('returns 400 after IP has already been authorized', async () => {
|
||||
const email = createUniqueEmail('ip-resend-after-auth');
|
||||
const password = 'a-strong-password';
|
||||
await registerUser(harness, {
|
||||
@@ -304,7 +307,7 @@ describe('Auth IP Authorization Resend', () => {
|
||||
const errorResp = await createBuilderWithoutAuth<ResendErrorResponse>(harness)
|
||||
.post('/auth/ip-authorization/resend')
|
||||
.body({ticket})
|
||||
.expect(HTTP_TOO_MANY_REQUESTS)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
expect(errorResp.code).toBe(APIErrorCodes.IP_AUTHORIZATION_RESEND_LIMIT_EXCEEDED);
|
||||
expect(typeof errorResp.message).toBe('string');
|
||||
|
||||
@@ -230,7 +230,7 @@ export class MessageEditService {
|
||||
if (!lockToken) {
|
||||
throw new ThrottledError({
|
||||
code: APIErrorCodes.RESOURCE_LOCKED,
|
||||
headers: {'Retry-After': '1'},
|
||||
retryAfterSeconds: 1,
|
||||
data: {retry_after: 1},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {MAX_CONNECTIONS_PER_USER} from '@fluxer/constants/src/ConnectionConstants';
|
||||
import {ThrottledError} from '@fluxer/errors/src/domains/core/ThrottledError';
|
||||
import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError';
|
||||
|
||||
export class ConnectionLimitReachedError extends ThrottledError {
|
||||
export class ConnectionLimitReachedError extends BadRequestError {
|
||||
constructor(limit: number = MAX_CONNECTIONS_PER_USER) {
|
||||
super({
|
||||
code: APIErrorCodes.CONNECTION_LIMIT_REACHED,
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {
|
||||
ConnectionTypes,
|
||||
ConnectionVisibilityFlags,
|
||||
MAX_CONNECTIONS_PER_USER,
|
||||
} from '@fluxer/constants/src/ConnectionConstants';
|
||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
||||
import {createUserID, type UserID} from '../../BrandedTypes';
|
||||
import type {IBlueskyOAuthService} from '../../bluesky/IBlueskyOAuthService';
|
||||
import {setCassandraQueryExecutorForTesting} from '../../database/CassandraQueryExecution';
|
||||
import type {UserConnectionRow} from '../../database/types/ConnectionTypes';
|
||||
import type {IGatewayService} from '../../infrastructure/IGatewayService';
|
||||
import {InMemoryCassandraQueryExecutor} from '../../test/InMemoryCassandraQueryExecutor';
|
||||
import {ConnectionRepository} from '../ConnectionRepository';
|
||||
import {ConnectionService} from '../ConnectionService';
|
||||
import {ConnectionLimitReachedError} from '../errors/ConnectionLimitReachedError';
|
||||
|
||||
function connectionRow(userId: UserID, connectionId: string, sortOrder: number): UserConnectionRow {
|
||||
return {
|
||||
user_id: userId,
|
||||
connection_id: connectionId,
|
||||
connection_type: ConnectionTypes.DOMAIN,
|
||||
identifier: `${connectionId}.example`,
|
||||
name: connectionId,
|
||||
verified: true,
|
||||
visibility_flags: ConnectionVisibilityFlags.EVERYONE,
|
||||
sort_order: sortOrder,
|
||||
verification_token: 'token',
|
||||
verified_at: null,
|
||||
last_verified_at: null,
|
||||
created_at: new Date(0),
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ConnectionService connection limit', () => {
|
||||
let executor: InMemoryCassandraQueryExecutor;
|
||||
|
||||
beforeEach(() => {
|
||||
executor = new InMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
executor.reset();
|
||||
setCassandraQueryExecutorForTesting(null);
|
||||
});
|
||||
|
||||
it('rejects the connection past the ceiling with 400 and no retry guidance', async () => {
|
||||
const userId = createUserID(1n);
|
||||
const repository = new ConnectionRepository();
|
||||
for (let index = 0; index < MAX_CONNECTIONS_PER_USER; index++) {
|
||||
await repository.create(connectionRow(userId, `connection-${index}`, index));
|
||||
}
|
||||
const service = new ConnectionService(
|
||||
repository,
|
||||
{} as unknown as IGatewayService,
|
||||
{} as unknown as IBlueskyOAuthService,
|
||||
);
|
||||
|
||||
const error = await service.initiateConnection(userId, ConnectionTypes.DOMAIN, 'over-the-limit.example').then(
|
||||
() => null,
|
||||
(caught: unknown) => caught,
|
||||
);
|
||||
|
||||
expect(error).toBeInstanceOf(ConnectionLimitReachedError);
|
||||
const response = (error as ConnectionLimitReachedError).getResponse();
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.headers.get('Retry-After')).toBeNull();
|
||||
expect(await response.json()).toMatchObject({code: APIErrorCodes.CONNECTION_LIMIT_REACHED});
|
||||
});
|
||||
|
||||
it('allows a connection while a slot is free', async () => {
|
||||
const userId = createUserID(2n);
|
||||
const repository = new ConnectionRepository();
|
||||
for (let index = 0; index < MAX_CONNECTIONS_PER_USER - 1; index++) {
|
||||
await repository.create(connectionRow(userId, `connection-${index}`, index));
|
||||
}
|
||||
const service = new ConnectionService(
|
||||
repository,
|
||||
{} as unknown as IGatewayService,
|
||||
{} as unknown as IBlueskyOAuthService,
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.initiateConnection(userId, ConnectionTypes.DOMAIN, 'under-the-limit.example'),
|
||||
).resolves.toEqual({});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user