mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
fix: tighten edge cases across services (#3158)
This commit is contained in:
@@ -80,7 +80,7 @@ describe('reconstructOriginalUrl', () => {
|
||||
).toBe('https://static.klipy.com/ii/c8/28/HkAKKCzZ.webp?v=query_param&goes=here');
|
||||
});
|
||||
|
||||
it('does not double the question mark when the query segment carries one', () => {
|
||||
it('does not double the question mark when the query segment has one', () => {
|
||||
const decoded = reconstructOriginalUrl('%3Fa%3D1/https/example.com/x.png');
|
||||
expect(decoded).toBe('https://example.com/x.png?a=1');
|
||||
expect(decoded).not.toContain('??');
|
||||
|
||||
@@ -43,13 +43,13 @@ describe('buildAPIServerOptions', () => {
|
||||
expect(server.requestTimeout).toBe(120_000);
|
||||
});
|
||||
|
||||
test('carries the operator header timeout from the environment into the server', async () => {
|
||||
test('passes the operator header timeout from the environment into the server', async () => {
|
||||
const server = await listenWithEnv({FLUXER_API_HEADERS_TIMEOUT_MS: '45000'});
|
||||
expect(server.headersTimeout).toBe(45_000);
|
||||
expect(server.requestTimeout).toBe(120_000);
|
||||
});
|
||||
|
||||
test('carries the operator request timeout from the environment into the server', async () => {
|
||||
test('passes the operator request timeout from the environment into the server', async () => {
|
||||
const server = await listenWithEnv({FLUXER_API_REQUEST_TIMEOUT_MS: '600000'});
|
||||
expect(server.headersTimeout).toBe(30_000);
|
||||
expect(server.requestTimeout).toBe(600_000);
|
||||
@@ -134,7 +134,7 @@ describe('buildAPIConfigFromMaster stripe legacy prices', () => {
|
||||
master = await loadConfig();
|
||||
});
|
||||
|
||||
it('carries the retired stripe price map from master config onto the api config', () => {
|
||||
it('copies the retired stripe price map from master config onto the api config', () => {
|
||||
const legacyPrices = {
|
||||
monthly_brl: ['price_retired_monthly_brl'],
|
||||
yearly_brl: ['price_retired_yearly_brl_a', 'price_retired_yearly_brl_b'],
|
||||
@@ -145,7 +145,7 @@ describe('buildAPIConfigFromMaster stripe legacy prices', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('carries the retired price map even when no live prices are configured', () => {
|
||||
it('copies the retired price map even when no live prices are configured', () => {
|
||||
const withoutPrices: MasterConfig = {
|
||||
...master,
|
||||
integrations: {
|
||||
|
||||
@@ -146,7 +146,7 @@ export function AdminApiKeyAdminController(app: HonoApp) {
|
||||
security: ['adminApiKey'],
|
||||
tags: ['Admin'],
|
||||
description:
|
||||
'Renames an API key or replaces the access control lists (ACLs) it carries. The key may only carry permissions the acting admin already holds. Omitted fields are left unchanged and the key material is never rotated or returned.',
|
||||
'Renames an API key or replaces the access control lists (ACLs) it has. The key may only hold permissions the acting admin already holds. Omitted fields are left unchanged and the key material is never rotated or returned.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminApiKeyService = ctx.get('adminApiKeyService');
|
||||
|
||||
@@ -269,7 +269,7 @@ export function BanAdminController(app: HonoApp) {
|
||||
security: ['adminApiKey'],
|
||||
tags: ['Admin'],
|
||||
description:
|
||||
'List every blocklist this instance maintains, the request field that carries an entry value, the extra fields its entries accept, and which of the bulk and update operations it supports.',
|
||||
'List every blocklist this instance maintains, the request field that holds an entry value, the extra fields its entries accept, and which of the bulk and update operations it supports.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
await recordAdminRead(ctx, {
|
||||
@@ -524,7 +524,7 @@ export function BanAdminController(app: HonoApp) {
|
||||
tags: ['Admin'],
|
||||
requestSchema: AdminBlocklistEntryUpdateRequest,
|
||||
description:
|
||||
'Rewrite the stored fields of a blocklist entry without removing and re-adding it. The stored metadata is replaced by the supplied fields, so fields left out fall back to their defaults. Only blocklists whose entries carry fields accept this operation, reported as supports_update by GET /admin/blocklists.',
|
||||
'Rewrite the stored fields of a blocklist entry without removing and re-adding it. The stored metadata is replaced by the supplied fields, so fields left out fall back to their defaults. Only blocklists whose entries have fields accept this operation, reported as supports_update by GET /admin/blocklists.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
|
||||
@@ -49,7 +49,7 @@ export function MessageAdminController(app: HonoApp) {
|
||||
operationId: 'search_admin_messages',
|
||||
summary: 'Search messages',
|
||||
description:
|
||||
'Searches the messages of a channel by content, or resolves a single message by its ID or by one of its attachments. Passing message_id returns that message with the messages surrounding it; passing attachment_id together with filename returns the message carrying that attachment with its surrounding context. Requires MESSAGE_LOOKUP permission.',
|
||||
'Searches the messages of a channel by content, or resolves a single message by its ID or by one of its attachments. Passing message_id returns that message with the messages surrounding it; passing attachment_id together with filename returns the message with that attachment with its surrounding context. Requires MESSAGE_LOOKUP permission.',
|
||||
responseSchema: AdminMessageSearchResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
|
||||
@@ -159,7 +159,7 @@ describe('VoiceAdminController', () => {
|
||||
expect(deletedRegion.success).toBe(true);
|
||||
expect(await voiceRepository.getRegion(fixture.regionId)).toBeNull();
|
||||
});
|
||||
test('rejects voice server creation when no region carries the identifier', async () => {
|
||||
test('rejects voice server creation when no region has the identifier', async () => {
|
||||
const admin = await createAdminWithAcls(harness, [AdminACLs.VOICE_SERVER_CREATE]);
|
||||
const regionId = 'voice-region-missing-for-server-create';
|
||||
const serverId = 'voice-server-missing-region';
|
||||
|
||||
@@ -280,7 +280,7 @@ export async function resetPassword(
|
||||
await ctx.services.botMfaMirror.syncAuthenticatorTypesForOwner(updatedUser);
|
||||
}
|
||||
await AuthSession.terminateAllUserSessions(ctx, user.id);
|
||||
await users.deletePasswordResetToken(data.token);
|
||||
await users.deleteAllPasswordResetTokens(user.id);
|
||||
if (hasMfa) {
|
||||
return await createMfaTicketResponse(ctx, updatedUser, webauthnIsSecondFactor);
|
||||
}
|
||||
|
||||
@@ -44,7 +44,6 @@ interface DispatchAuthSessionChangeParams {
|
||||
userId: UserID;
|
||||
oldAuthSessionIdHash: string;
|
||||
newAuthSessionIdHash: string;
|
||||
newToken: string;
|
||||
}
|
||||
|
||||
interface ReplaceCurrentAuthSessionParams {
|
||||
@@ -192,13 +191,12 @@ export async function replaceCurrentAuthSession(
|
||||
await deleteAndTerminateAuthSessions(ctx, user.id, otherAuthSessions);
|
||||
const [newToken, newAuthSession] = await createAuthSession(ctx, {user, origin: resolveSessionOrigin(ctx, request)});
|
||||
const newAuthSessionIdHash = encodeSessionIdHash(newAuthSession.sessionIdHash);
|
||||
await deleteAndTerminateAuthSessions(ctx, user.id, [currentAuthSession]);
|
||||
await dispatchAuthSessionChange(ctx, {
|
||||
userId: user.id,
|
||||
oldAuthSessionIdHash,
|
||||
newAuthSessionIdHash,
|
||||
newToken,
|
||||
});
|
||||
await deleteAndTerminateAuthSessions(ctx, user.id, [currentAuthSession]);
|
||||
return {
|
||||
token: newToken,
|
||||
authSession: newAuthSession,
|
||||
@@ -231,14 +229,13 @@ function encodeSessionIdHash(sessionIdHash: Uint8Array): string {
|
||||
|
||||
async function dispatchAuthSessionChange(ctx: ApiContext, params: DispatchAuthSessionChangeParams): Promise<void> {
|
||||
const {gateway} = ctx.services;
|
||||
const {userId, oldAuthSessionIdHash, newAuthSessionIdHash, newToken} = params;
|
||||
const {userId, oldAuthSessionIdHash, newAuthSessionIdHash} = params;
|
||||
await gateway.dispatchPresence({
|
||||
userId,
|
||||
event: 'AUTH_SESSION_CHANGE',
|
||||
data: {
|
||||
old_auth_session_id_hash: oldAuthSessionIdHash,
|
||||
new_auth_session_id_hash: newAuthSessionIdHash,
|
||||
new_token: newToken,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
clearTestEmails,
|
||||
createAuthHarness,
|
||||
createTestAccount,
|
||||
findLastTestEmail,
|
||||
listTestEmails,
|
||||
loginAccount,
|
||||
type TestAccount,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
|
||||
import {generateUniquePassword, HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
interface AuthSessionRow {
|
||||
id_hash: string;
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
interface ReplacementResponse {
|
||||
token?: string;
|
||||
auth_session_id_hash?: string;
|
||||
}
|
||||
|
||||
type GatewayCall = {kind: 'terminate'; hashes: Array<string>} | {kind: 'session_change'; data: Record<string, unknown>};
|
||||
|
||||
async function getCurrentAuthSessionHash(harness: ApiTestHarness, token: string): Promise<string> {
|
||||
const sessions = await createBuilder<Array<AuthSessionRow>>(harness, token).get('/auth/sessions').execute();
|
||||
const current = sessions.find((session) => session.current);
|
||||
if (!current) {
|
||||
throw new Error('Current auth session not found');
|
||||
}
|
||||
return current.id_hash;
|
||||
}
|
||||
|
||||
async function completePasswordChange(
|
||||
harness: ApiTestHarness,
|
||||
account: TestAccount,
|
||||
newPassword: string,
|
||||
): Promise<ReplacementResponse> {
|
||||
const start = await createBuilder<{ticket: string}>(harness, account.token)
|
||||
.post('/users/@me/password-change/start')
|
||||
.body({})
|
||||
.execute();
|
||||
const emails = await listTestEmails(harness, {recipient: account.email});
|
||||
const record = findLastTestEmail(emails, 'password_change_verification');
|
||||
if (!record) {
|
||||
throw new Error('Password change verification email not found');
|
||||
}
|
||||
const verify = await createBuilder<{verification_proof: string}>(harness, account.token)
|
||||
.post('/users/@me/password-change/verify')
|
||||
.body({ticket: start.ticket, code: record.metadata.code})
|
||||
.execute();
|
||||
return createBuilder<ReplacementResponse>(harness, account.token)
|
||||
.post('/users/@me/password-change/complete')
|
||||
.body({ticket: start.ticket, verification_proof: verify.verification_proof, new_password: newPassword})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
|
||||
describe('Auth session replacement on password change', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let calls: Array<GatewayCall>;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
await clearTestEmails(harness);
|
||||
calls = [];
|
||||
vi.spyOn(NoopGatewayService.prototype, 'terminateSession').mockImplementation(async (params) => {
|
||||
calls.push({kind: 'terminate', hashes: [...params.sessionIdHashes]});
|
||||
});
|
||||
vi.spyOn(NoopGatewayService.prototype, 'dispatchPresence').mockImplementation(async (params) => {
|
||||
if (params.event === 'AUTH_SESSION_CHANGE') {
|
||||
calls.push({kind: 'session_change', data: params.data as Record<string, unknown>});
|
||||
}
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
function expectReplacedSessionClosedBeforeEvent(oldHash: string, newHash: string | undefined): void {
|
||||
const eventIndex = calls.findIndex((call) => call.kind === 'session_change');
|
||||
const terminateIndex = calls.findIndex((call) => call.kind === 'terminate' && call.hashes.includes(oldHash));
|
||||
expect(terminateIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(eventIndex).toBeGreaterThan(terminateIndex);
|
||||
const event = calls[eventIndex] as Extract<GatewayCall, {kind: 'session_change'}>;
|
||||
expect(event.data).toEqual({old_auth_session_id_hash: oldHash, new_auth_session_id_hash: newHash});
|
||||
}
|
||||
|
||||
it('returns the replacement token from PATCH /users/@me', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const otherSession = await loginAccount(harness, account);
|
||||
const oldHash = await getCurrentAuthSessionHash(harness, account.token);
|
||||
calls = [];
|
||||
const response = await createBuilder<ReplacementResponse>(harness, account.token)
|
||||
.patch('/users/@me')
|
||||
.body({password: account.password, new_password: generateUniquePassword()})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(typeof response.token).toBe('string');
|
||||
expect(typeof response.auth_session_id_hash).toBe('string');
|
||||
expectReplacedSessionClosedBeforeEvent(oldHash, response.auth_session_id_hash);
|
||||
await createBuilder(harness, account.token).get('/users/@me').expect(HTTP_STATUS.UNAUTHORIZED).execute();
|
||||
await createBuilder(harness, otherSession.token).get('/users/@me').expect(HTTP_STATUS.UNAUTHORIZED).execute();
|
||||
await createBuilder(harness, response.token!).get('/users/@me').expect(HTTP_STATUS.OK).execute();
|
||||
expect(await getCurrentAuthSessionHash(harness, response.token!)).toBe(response.auth_session_id_hash);
|
||||
});
|
||||
|
||||
it('leaves the PATCH response without a token when the password is unchanged', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const response = await createBuilder<ReplacementResponse>(harness, account.token)
|
||||
.patch('/users/@me')
|
||||
.body({global_name: 'Renamed'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(response.token).toBeUndefined();
|
||||
expect(response.auth_session_id_hash).toBeUndefined();
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('closes the replaced session before announcing the change on password-change/complete', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const oldHash = await getCurrentAuthSessionHash(harness, account.token);
|
||||
calls = [];
|
||||
const response = await completePasswordChange(harness, account, generateUniquePassword());
|
||||
expectReplacedSessionClosedBeforeEvent(oldHash, response.auth_session_id_hash);
|
||||
await createBuilder(harness, response.token!).get('/users/@me').expect(HTTP_STATUS.OK).execute();
|
||||
});
|
||||
});
|
||||
@@ -65,6 +65,45 @@ describe('Password reset flow', () => {
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
it('invalidates every outstanding reset token once a reset completes', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/forgot')
|
||||
.body({email: account.email})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
}
|
||||
const emails = await listTestEmails(harness, {recipient: account.email});
|
||||
const tokens = [
|
||||
...new Set(
|
||||
emails
|
||||
.filter((email) => email.type === 'password_reset')
|
||||
.map((email) => email.metadata?.token)
|
||||
.filter((token): token is string => typeof token === 'string'),
|
||||
),
|
||||
];
|
||||
expect(tokens).toHaveLength(2);
|
||||
const [earlierToken, laterToken] = tokens;
|
||||
const newPassword = generateUniquePassword();
|
||||
const resetResp = await createBuilderWithoutAuth<LoginSuccessResponse>(harness)
|
||||
.post('/auth/reset')
|
||||
.body({token: laterToken, password: newPassword})
|
||||
.execute();
|
||||
expect(resetResp.token.length).toBeGreaterThan(0);
|
||||
const check = await createBuilderWithoutAuth<{valid: boolean}>(harness)
|
||||
.get(`/auth/reset/${earlierToken}`)
|
||||
.execute();
|
||||
expect(check.valid).toBe(false);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/reset')
|
||||
.body({token: earlierToken, password: generateUniquePassword()})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
await createBuilder(harness, resetResp.token).get('/users/@me').expect(HTTP_STATUS.OK).execute();
|
||||
const login = await loginUser(harness, {email: account.email, password: newPassword});
|
||||
expect('token' in login && login.token.length > 0).toBe(true);
|
||||
});
|
||||
it('rejects invalid reset token', async () => {
|
||||
await createTestAccount(harness);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
|
||||
@@ -22,7 +22,7 @@ describe('WebAuthn registration user handle', () => {
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('ensures registration options carry the stable user identifier', async () => {
|
||||
it('ensures registration options include the stable user identifier', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const secret = createTotpSecret();
|
||||
await createBuilder(harness, account.token)
|
||||
|
||||
@@ -500,7 +500,7 @@ describe('mapStripeRefundToRow', () => {
|
||||
expect(result.byPaymentIntent).not.toBeNull();
|
||||
expect(result.byInvoice).toBeNull();
|
||||
});
|
||||
it('payment_intent is an expanded object; hints carry through', () => {
|
||||
it('payment_intent is an expanded object; hints pass through', () => {
|
||||
const r = stripeFixture<Stripe.Refund>({
|
||||
id: 're_2',
|
||||
charge: null,
|
||||
|
||||
@@ -172,6 +172,8 @@ export class ChannelService {
|
||||
snowflakeService,
|
||||
this.messages.persistence,
|
||||
limitConfigService,
|
||||
voiceRoomStore,
|
||||
liveKitService,
|
||||
);
|
||||
this.calls = new CallService(
|
||||
channelRepository,
|
||||
|
||||
@@ -72,7 +72,7 @@ describe('StreamService.uploadPreview', () => {
|
||||
expect(uploaded).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects a thumbnail carrying no base64 digits', async () => {
|
||||
it('rejects a thumbnail with no base64 digits', async () => {
|
||||
await expect(upload('====')).rejects.toBeInstanceOf(InvalidStreamThumbnailPayloadError);
|
||||
expect(uploaded).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService';
|
||||
import {mapGuildToGuildResponse} from '@app/api/guild/GuildModel';
|
||||
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
|
||||
import {ChannelHelpers} from '@app/api/guild/services/channel/ChannelHelpers';
|
||||
import {createGuildMfaEnforcer} from '@app/api/guild/services/GuildMfaEnforcement';
|
||||
import {contentModerationService} from '@app/api/infrastructure/ContentModerationService';
|
||||
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
|
||||
import type {ILiveKitService} from '@app/api/infrastructure/ILiveKitService';
|
||||
@@ -629,6 +630,27 @@ export class ChannelOperationsService {
|
||||
}
|
||||
}
|
||||
|
||||
private async checkOverwritePermission(params: {
|
||||
guildId: GuildID;
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
}): Promise<void> {
|
||||
const canManageRoles = await this.gatewayService.checkPermission({
|
||||
guildId: params.guildId,
|
||||
userId: params.userId,
|
||||
channelId: params.channelId,
|
||||
permission: Permissions.MANAGE_ROLES,
|
||||
});
|
||||
if (!canManageRoles) throw new MissingPermissionsError();
|
||||
const guildData = await this.gatewayService.getGuildData({guildId: params.guildId, userId: params.userId});
|
||||
const enforceGuildMfa = await createGuildMfaEnforcer({
|
||||
userRepository: this.userRepository,
|
||||
guildData,
|
||||
userId: params.userId,
|
||||
});
|
||||
enforceGuildMfa(Permissions.MANAGE_ROLES);
|
||||
}
|
||||
|
||||
async setChannelPermissionOverwrite(params: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
@@ -644,13 +666,7 @@ export class ChannelOperationsService {
|
||||
}): Promise<void> {
|
||||
const channel = await this.channelRepository.channelData.findUnique(params.channelId);
|
||||
if (!channel?.guildId) throw new UnknownChannelError();
|
||||
const canManageRoles = await this.gatewayService.checkPermission({
|
||||
guildId: channel.guildId,
|
||||
userId: params.userId,
|
||||
channelId: channel.id,
|
||||
permission: Permissions.MANAGE_ROLES,
|
||||
});
|
||||
if (!canManageRoles) throw new MissingPermissionsError();
|
||||
await this.checkOverwritePermission({guildId: channel.guildId, userId: params.userId, channelId: channel.id});
|
||||
const userPermissions = await this.gatewayService.getUserPermissions({
|
||||
guildId: channel.guildId,
|
||||
userId: params.userId,
|
||||
@@ -716,13 +732,7 @@ export class ChannelOperationsService {
|
||||
}): Promise<void> {
|
||||
const channel = await this.channelRepository.channelData.findUnique(params.channelId);
|
||||
if (!channel?.guildId) throw new UnknownChannelError();
|
||||
const canManageRoles = await this.gatewayService.checkPermission({
|
||||
guildId: channel.guildId,
|
||||
userId: params.userId,
|
||||
channelId: channel.id,
|
||||
permission: Permissions.MANAGE_ROLES,
|
||||
});
|
||||
if (!canManageRoles) throw new MissingPermissionsError();
|
||||
await this.checkOverwritePermission({guildId: channel.guildId, userId: params.userId, channelId: channel.id});
|
||||
const previousPermissionOverwrites = channel.permissionOverwrites;
|
||||
const overwrites = new Map(channel.permissionOverwrites ?? []);
|
||||
const removedRole = overwrites.get(createRoleID(params.overwriteId));
|
||||
|
||||
@@ -10,8 +10,11 @@ import {dispatchMessageCreateBroadcast} from '@app/api/channel/services/message/
|
||||
import type {MessagePersistenceService} from '@app/api/channel/services/message/MessagePersistenceService';
|
||||
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
|
||||
import type {ILiveKitService} from '@app/api/infrastructure/ILiveKitService';
|
||||
import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService';
|
||||
import type {IVoiceRoomStore} from '@app/api/infrastructure/IVoiceRoomStore';
|
||||
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import type {LimitConfigService} from '@app/api/limits/LimitConfigService';
|
||||
import {resolveLimitSafe} from '@app/api/limits/LimitConfigUtils';
|
||||
import {createLimitMatchContext} from '@app/api/limits/LimitMatchContextBuilder';
|
||||
@@ -47,6 +50,8 @@ export class GroupDmOperationsService {
|
||||
private snowflakeService: ISnowflakeService,
|
||||
private messagePersistenceService: MessagePersistenceService,
|
||||
private readonly limitConfigService: LimitConfigService,
|
||||
private readonly voiceRoomStore: IVoiceRoomStore,
|
||||
private readonly liveKitService: ILiveKitService,
|
||||
) {
|
||||
this.userPermissionUtils = new UserPermissionUtils(userRepository, guildRepository);
|
||||
}
|
||||
@@ -258,6 +263,7 @@ export class GroupDmOperationsService {
|
||||
await deleteChannelMessageSearchDocuments(channelId, {context: {source: 'group_dm_delete'}});
|
||||
await this.channelRepository.channelData.delete(channelId);
|
||||
await this.userRepository.closeDmForUser(recipientId, channelId);
|
||||
await this.disconnectRemovedRecipientFromCall(channelId, recipientId);
|
||||
await dispatchChannelDelete({
|
||||
channel,
|
||||
requestCache,
|
||||
@@ -275,6 +281,7 @@ export class GroupDmOperationsService {
|
||||
nicks: updatedNicknames.size > 0 ? updatedNicknames : null,
|
||||
});
|
||||
await this.userRepository.closeDmForUser(recipientId, channelId);
|
||||
await this.disconnectRemovedRecipientFromCall(channelId, recipientId);
|
||||
const recipientUserResponse = await this.userCacheService.getUserPartialResponse(recipientId, requestCache);
|
||||
for (const recId of updatedRecipientIds) {
|
||||
await this.gatewayService.dispatchPresence({
|
||||
@@ -319,6 +326,31 @@ export class GroupDmOperationsService {
|
||||
);
|
||||
}
|
||||
|
||||
private async disconnectRemovedRecipientFromCall(channelId: ChannelID, recipientId: UserID): Promise<void> {
|
||||
try {
|
||||
const {voiceStates} = await this.gatewayService.getVoiceStatesForChannel({channelId});
|
||||
await this.gatewayService.disconnectVoiceUserIfInChannel({channelId, userId: recipientId});
|
||||
const recipientVoiceStates = voiceStates.filter((voiceState) => voiceState.userId === recipientId.toString());
|
||||
if (recipientVoiceStates.length === 0) return;
|
||||
const pinnedServer = await this.voiceRoomStore.getPinnedRoomServer(undefined, channelId);
|
||||
if (!pinnedServer) return;
|
||||
for (const voiceState of recipientVoiceStates) {
|
||||
await this.liveKitService.disconnectParticipant({
|
||||
userId: recipientId,
|
||||
channelId,
|
||||
connectionId: voiceState.connectionId,
|
||||
regionId: pinnedServer.regionId,
|
||||
serverId: pinnedServer.serverId,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{error, channelId: channelId.toString(), userId: recipientId.toString()},
|
||||
'Failed to disconnect removed group DM recipient from call',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async syncGroupDmRecipientsForUser(userId: UserID): Promise<void> {
|
||||
const channels = await this.userRepository.listPrivateChannels(userId);
|
||||
const groupDmChannels = channels.filter((ch) => ch.type === ChannelTypes.GROUP_DM);
|
||||
|
||||
@@ -100,7 +100,7 @@ describe('Attachment Upload Validation', () => {
|
||||
status: HTTP_STATUS.SERVICE_UNAVAILABLE,
|
||||
}),
|
||||
method: 'POST',
|
||||
path: `/channels/${channelId}/messages`,
|
||||
path: '/channels/:channel_id/messages',
|
||||
requestId: expect.any(String),
|
||||
status: HTTP_STATUS.SERVICE_UNAVAILABLE,
|
||||
},
|
||||
|
||||
@@ -471,7 +471,7 @@ describe('Crosspost moderation', () => {
|
||||
});
|
||||
}
|
||||
|
||||
test('forwarding a published source carries none of the server bits', async () => {
|
||||
test('forwarding a published source keeps none of the server bits', async () => {
|
||||
const source = await sendChannelMessage(harness, world.b.owner.token, world.a.ann.id, 'forward me');
|
||||
await publish(harness, world.b.owner.token, world.a.ann.id, source.id);
|
||||
const forwarded = await forward(world.b.owner.token, world.b.t2.id, world.a.ann.id, world.a.guild.id, source.id)
|
||||
@@ -480,7 +480,7 @@ describe('Crosspost moderation', () => {
|
||||
expect(forwarded.message_snapshots?.[0]?.flags ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
test('forwarding a copy carries none of the server bits', async () => {
|
||||
test('forwarding a copy keeps none of the server bits', async () => {
|
||||
const source = await sendChannelMessage(harness, world.a.member.token, world.a.ann.id, 'update');
|
||||
const {copyId} = await fabricateCopy({
|
||||
harness,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createFriendship,
|
||||
createGroupDmChannel,
|
||||
getChannel,
|
||||
removeRecipientFromGroupDm,
|
||||
} from '@app/api/channel/tests/ChannelTestUtils';
|
||||
import {DisabledLiveKitService} from '@app/api/infrastructure/DisabledLiveKitService';
|
||||
import {InMemoryVoiceRoomStore} from '@app/api/infrastructure/InMemoryVoiceRoomStore';
|
||||
import {ensureSessionStarted} from '@app/api/message/tests/MessageTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
describe('Group DM recipient removal call teardown', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
async function setupGroupDm() {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
const other = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, owner.token);
|
||||
await ensureSessionStarted(harness, member.token);
|
||||
await ensureSessionStarted(harness, other.token);
|
||||
await createFriendship(harness, owner, member);
|
||||
await createFriendship(harness, owner, other);
|
||||
const groupDm = await createGroupDmChannel(harness, owner.token, [member.userId, other.userId]);
|
||||
return {owner, member, other, groupDm};
|
||||
}
|
||||
|
||||
it('disconnects the removed recipient from the call and the voice room', async () => {
|
||||
const {owner, member, other, groupDm} = await setupGroupDm();
|
||||
vi.spyOn(NoopGatewayService.prototype, 'getVoiceStatesForChannel').mockResolvedValue({
|
||||
voiceStates: [
|
||||
{connectionId: 'member-conn', userId: member.userId, channelId: groupDm.id},
|
||||
{connectionId: 'other-conn', userId: other.userId, channelId: groupDm.id},
|
||||
],
|
||||
});
|
||||
vi.spyOn(InMemoryVoiceRoomStore.prototype, 'getPinnedRoomServer').mockResolvedValue({
|
||||
regionId: 'region-a',
|
||||
serverId: 'server-a',
|
||||
endpoint: 'wss://voice.invalid',
|
||||
});
|
||||
const disconnectFromCall = vi.spyOn(NoopGatewayService.prototype, 'disconnectVoiceUserIfInChannel');
|
||||
const disconnectParticipant = vi.spyOn(DisabledLiveKitService.prototype, 'disconnectParticipant');
|
||||
|
||||
await removeRecipientFromGroupDm(harness, owner.token, groupDm.id, member.userId);
|
||||
|
||||
expect(disconnectFromCall).toHaveBeenCalledTimes(1);
|
||||
const callParams = disconnectFromCall.mock.calls[0]![0];
|
||||
expect(callParams.guildId).toBeUndefined();
|
||||
expect(callParams.channelId.toString()).toBe(groupDm.id);
|
||||
expect(callParams.userId.toString()).toBe(member.userId);
|
||||
expect(disconnectParticipant).toHaveBeenCalledTimes(1);
|
||||
const participantParams = disconnectParticipant.mock.calls[0]![0];
|
||||
expect(participantParams.userId.toString()).toBe(member.userId);
|
||||
expect(participantParams.channelId.toString()).toBe(groupDm.id);
|
||||
expect(participantParams.connectionId).toBe('member-conn');
|
||||
expect(participantParams.regionId).toBe('region-a');
|
||||
expect(participantParams.serverId).toBe('server-a');
|
||||
});
|
||||
|
||||
it('disconnects a recipient who leaves the group DM themselves', async () => {
|
||||
const {member, groupDm} = await setupGroupDm();
|
||||
const disconnectFromCall = vi.spyOn(NoopGatewayService.prototype, 'disconnectVoiceUserIfInChannel');
|
||||
|
||||
await removeRecipientFromGroupDm(harness, member.token, groupDm.id, member.userId);
|
||||
|
||||
expect(disconnectFromCall).toHaveBeenCalledTimes(1);
|
||||
expect(disconnectFromCall.mock.calls[0]![0].userId.toString()).toBe(member.userId);
|
||||
});
|
||||
|
||||
it('still removes the recipient when the call teardown fails', async () => {
|
||||
const {owner, member, groupDm} = await setupGroupDm();
|
||||
vi.spyOn(NoopGatewayService.prototype, 'disconnectVoiceUserIfInChannel').mockRejectedValue(
|
||||
new Error('gateway unavailable'),
|
||||
);
|
||||
|
||||
await removeRecipientFromGroupDm(harness, owner.token, groupDm.id, member.userId);
|
||||
|
||||
const channel = await getChannel(harness, owner.token, groupDm.id);
|
||||
expect(channel.recipients?.map((recipient) => recipient.id)).not.toContain(member.userId);
|
||||
});
|
||||
});
|
||||
@@ -249,7 +249,7 @@ describe('Crosspost fan-out', () => {
|
||||
expect(after?.mentionedRoleIds.size).toBe(0);
|
||||
});
|
||||
|
||||
test('sendable flags carry over to the copy', async () => {
|
||||
test('the copy keeps the sendable flags', async () => {
|
||||
await followInto(harness, world, world.b.t1.id);
|
||||
const message = await sendMessage(harness, world.a.owner.token, world.a.ann.id, {content: 'quiet'});
|
||||
const sendable = MessageFlags.SUPPRESS_EMBEDS | MessageFlags.SUPPRESS_NOTIFICATIONS | MessageFlags.VOICE_MESSAGE;
|
||||
@@ -259,7 +259,7 @@ describe('Crosspost fan-out', () => {
|
||||
expect(copy!.flags).toBe(MessageFlags.IS_CROSSPOST | sendable);
|
||||
});
|
||||
|
||||
test('copies carry the source attachments and resolve to the source channel', async () => {
|
||||
test('copies have the source attachments and resolve to the source channel', async () => {
|
||||
await followInto(harness, world, world.b.t1.id);
|
||||
const message = await sendWithImage(harness, world.a.owner.token, world.a.ann.id, {content: 'files'}, [
|
||||
'first.png',
|
||||
|
||||
@@ -42,7 +42,7 @@ describe('Reaction users pagination', () => {
|
||||
return {token: owner.token, channelId: systemChannel.id, messageId: message.id};
|
||||
}
|
||||
|
||||
it('carries the pagination signal of the page in headers', async () => {
|
||||
it('sends the pagination signal of the page in headers', async () => {
|
||||
const {token, channelId, messageId} = await setupReactedMessage();
|
||||
|
||||
const legacy = await createBuilder<Array<{id: string}>>(harness, token)
|
||||
|
||||
@@ -67,7 +67,7 @@ function collectErrorChain(error: unknown): Array<ErrorNode> {
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function carriesPostgresClient(node: ErrorNode): boolean {
|
||||
function hasPostgresClient(node: ErrorNode): boolean {
|
||||
const client = node['client'];
|
||||
return typeof client === 'object' && client !== null;
|
||||
}
|
||||
@@ -92,7 +92,7 @@ export function isTransientDatabaseError(error: unknown): boolean {
|
||||
if (nodes.some(hasTransientSqlState)) {
|
||||
return true;
|
||||
}
|
||||
if (nodes.some(carriesPostgresClient) && nodes.some(hasTransientSocketCode)) {
|
||||
if (nodes.some(hasPostgresClient) && nodes.some(hasTransientSocketCode)) {
|
||||
return true;
|
||||
}
|
||||
return nodes.some(hasTransientDriverMessage);
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
MAX_GUILD_ROLES,
|
||||
VOICE_CHANNEL_BITRATE_DEFAULT,
|
||||
VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT,
|
||||
VOICE_CHANNEL_USER_LIMIT_MAX,
|
||||
} from '@fluxer/constants/src/LimitConstants';
|
||||
import {DEFAULT_GUILD_FOLDER_ICON} from '@fluxer/constants/src/UserConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
@@ -1065,7 +1066,7 @@ export class GuildOperationsService {
|
||||
content_warning_text: null,
|
||||
rate_limit_per_user: channel.rate_limit_per_user ?? 0,
|
||||
bitrate: isVoice ? resolveVoiceChannelBitrate(channel.bitrate, null) : null,
|
||||
user_limit: isVoice ? (channel.user_limit ?? 0) : null,
|
||||
user_limit: isVoice ? Math.min(channel.user_limit ?? 0, VOICE_CHANNEL_USER_LIMIT_MAX) : null,
|
||||
voice_connection_limit: isVoice
|
||||
? (channel.voice_connection_limit ?? VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT)
|
||||
: null,
|
||||
|
||||
@@ -117,7 +117,7 @@ describe('Guild expression clone opt-in', () => {
|
||||
expect(cloned.name).toBe(source.sticker.name);
|
||||
}
|
||||
|
||||
test('rejects both emoji and sticker cloning when the source guild carries no clone features', async () => {
|
||||
test('rejects both emoji and sticker cloning when the source guild has no clone features', async () => {
|
||||
const source = await createSource(harness, 'No Clone Features Source');
|
||||
expect(source.guild.features).not.toContain(GuildFeatures.CLONE_EMOJI_ENABLED);
|
||||
expect(source.guild.features).not.toContain(GuildFeatures.CLONE_STICKER_ENABLED);
|
||||
@@ -154,7 +154,7 @@ describe('Guild expression clone opt-in', () => {
|
||||
await expectStickerCloneAllowed(source, 'Deprecated Plus Enabled');
|
||||
});
|
||||
|
||||
test('rejects cloning when the source guild carries only the deprecated disabled features', async () => {
|
||||
test('rejects cloning when the source guild has only the deprecated disabled features', async () => {
|
||||
const source = await createSource(harness, 'Deprecated Only Source');
|
||||
await addDeprecatedFeatures(harness, source, [
|
||||
GuildFeatures.CLONE_EMOJI_DISABLED,
|
||||
@@ -186,7 +186,7 @@ describe('Guild expression clone opt-in', () => {
|
||||
expect(stickerAfter.allow_cloning).toBe(true);
|
||||
});
|
||||
|
||||
test('reports allow_cloning false for a guild carrying only the deprecated disabled features', async () => {
|
||||
test('reports allow_cloning false for a guild with only the deprecated disabled features', async () => {
|
||||
const source = await createSource(harness, 'Metadata Deprecated Source');
|
||||
await addDeprecatedFeatures(harness, source, [
|
||||
GuildFeatures.CLONE_EMOJI_DISABLED,
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount, type TestAccount, totpCodeNow} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createGuild, setupTestGuildWithMembers} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {
|
||||
addMemberRole,
|
||||
createGuild,
|
||||
createRole,
|
||||
getChannel,
|
||||
setupTestGuildWithMembers,
|
||||
} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildMFALevel} from '@fluxer/constants/src/GuildConstants';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
@@ -116,6 +123,43 @@ describe('Guild MFA level', () => {
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
it('requires 2FA for channel permission overwrite edits in an elevated guild', async () => {
|
||||
const {owner, members, guild, channels} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0]!;
|
||||
const channel = channels[0]!;
|
||||
const managerRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Managers',
|
||||
permissions: (Permissions.MANAGE_ROLES | Permissions.VIEW_CHANNEL | Permissions.SEND_MESSAGES).toString(),
|
||||
});
|
||||
const targetRole = await createRole(harness, owner.token, guild.id, {name: 'Target'});
|
||||
await addMemberRole(harness, owner.token, guild.id, member.userId, managerRole.id);
|
||||
await enableTotp(harness, owner);
|
||||
const loggedInOwner = await loginWithTotp(harness, owner);
|
||||
await createBuilder<GuildResponse>(harness, loggedInOwner.token)
|
||||
.patch(`/guilds/${guild.id}`)
|
||||
.body({mfa_level: GuildMFALevel.ELEVATED, mfa_method: 'totp', mfa_code: totpCodeNow(TOTP_SECRET)})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const overwrite = {type: 0, allow: Permissions.SEND_MESSAGES.toString(), deny: '0'};
|
||||
await createBuilder(harness, member.token)
|
||||
.put(`/channels/${channel.id}/permissions/${targetRole.id}`)
|
||||
.body(overwrite)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'TWO_FACTOR_REQUIRED')
|
||||
.execute();
|
||||
await createBuilder(harness, loggedInOwner.token)
|
||||
.put(`/channels/${channel.id}/permissions/${targetRole.id}`)
|
||||
.body(overwrite)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
await createBuilder(harness, member.token)
|
||||
.delete(`/channels/${channel.id}/permissions/${targetRole.id}`)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'TWO_FACTOR_REQUIRED')
|
||||
.execute();
|
||||
const stored = await getChannel(harness, loggedInOwner.token, channel.id);
|
||||
expect(stored.permission_overwrites?.find((entry) => entry.id === targetRole.id)?.allow).toBe(
|
||||
Permissions.SEND_MESSAGES.toString(),
|
||||
);
|
||||
});
|
||||
it('does not require sudo mode for non-mfa_level guild updates', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'MFA Test Guild');
|
||||
|
||||
@@ -6,6 +6,7 @@ import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHa
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {SystemChannelFlags} from '@fluxer/constants/src/GuildConstants';
|
||||
import {VOICE_CHANNEL_USER_LIMIT_MAX} from '@fluxer/constants/src/LimitConstants';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
@@ -209,6 +210,84 @@ describe('Guild Template Import', () => {
|
||||
expect(roles.some((role) => role.name === '')).toBe(true);
|
||||
expect(channels.some((channel) => channel.name === '')).toBe(true);
|
||||
});
|
||||
test.each([
|
||||
['a negative slowmode', {rate_limit_per_user: -1}],
|
||||
['a slowmode above the channel maximum', {rate_limit_per_user: 1_000_000_000}],
|
||||
['a fractional position', {position: 0.5}],
|
||||
['a negative position', {position: -3}],
|
||||
['a topic above the channel maximum', {topic: 'x'.repeat(1025)}],
|
||||
['a name above the channel maximum', {name: 'x'.repeat(101)}],
|
||||
['a negative user limit', {type: ChannelTypes.GUILD_VOICE, user_limit: -1}],
|
||||
['a voice connection limit above the maximum', {type: ChannelTypes.GUILD_VOICE, voice_connection_limit: 100_000}],
|
||||
['a negative voice connection limit', {type: ChannelTypes.GUILD_VOICE, voice_connection_limit: -5}],
|
||||
])('rejects a template channel with %s', async (_label, overrides) => {
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/guilds')
|
||||
.body({
|
||||
name: 'Bounded Guild',
|
||||
template: buildMinimalTemplate({
|
||||
channels: [{id: 6001, type: ChannelTypes.GUILD_TEXT, name: 'general', position: 0, ...overrides}],
|
||||
}),
|
||||
})
|
||||
.expect(400, 'INVALID_FORM_BODY')
|
||||
.execute();
|
||||
});
|
||||
test.each([
|
||||
['a negative colour', {color: -1}],
|
||||
['a colour above 0xffffff', {color: 0x1000000}],
|
||||
['a name above the role maximum', {name: 'x'.repeat(101)}],
|
||||
])('rejects a template role with %s', async (_label, overrides) => {
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/guilds')
|
||||
.body({
|
||||
name: 'Bounded Guild',
|
||||
template: buildMinimalTemplate({
|
||||
roles: [
|
||||
{id: 0, name: '@everyone', permissions: DEFAULT_EVERYONE_PERMISSIONS},
|
||||
{id: 6100, name: 'Role', permissions: '0', ...overrides},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.expect(400, 'INVALID_FORM_BODY')
|
||||
.execute();
|
||||
});
|
||||
test('clamps imported voice user limits to the channel maximum and keeps channels readable', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createBuilder<GuildResponse>(harness, account.token)
|
||||
.post('/guilds')
|
||||
.body({
|
||||
name: 'Stage Guild',
|
||||
template: buildMinimalTemplate({
|
||||
channels: [
|
||||
{id: 6001, type: ChannelTypes.GUILD_TEXT, name: 'general', position: 0, rate_limit_per_user: 30},
|
||||
{id: 6002, type: 13, name: 'town-hall', position: 1, user_limit: 10_000},
|
||||
{id: 6003, type: ChannelTypes.GUILD_VOICE, name: 'lounge', position: 2, voice_connection_limit: 100},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.execute();
|
||||
const channels = await getGuildChannels(harness, account.token, guild.id);
|
||||
expect(channels.find((channel) => channel.name === 'general')?.rate_limit_per_user).toBe(30);
|
||||
expect(channels.find((channel) => channel.name === 'town-hall')?.user_limit).toBe(VOICE_CHANNEL_USER_LIMIT_MAX);
|
||||
expect(channels.find((channel) => channel.name === 'lounge')?.voice_connection_limit).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
const DEFAULT_EVERYONE_PERMISSIONS = Permissions.VIEW_CHANNEL.toString();
|
||||
|
||||
function buildMinimalTemplate(overrides: {channels?: Array<object>; roles?: Array<object>}) {
|
||||
return {
|
||||
name: 'Template Source',
|
||||
description: null,
|
||||
verification_level: 0,
|
||||
default_message_notifications: 0,
|
||||
explicit_content_filter: 0,
|
||||
system_channel_id: 6001,
|
||||
afk_timeout: 300,
|
||||
system_channel_flags: 0,
|
||||
roles: overrides.roles ?? [{id: 0, name: '@everyone', permissions: DEFAULT_EVERYONE_PERMISSIONS}],
|
||||
channels: overrides.channels ?? [{id: 6001, type: ChannelTypes.GUILD_TEXT, name: 'general', position: 0}],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ describe('AvatarService emoji and sticker size ceilings', () => {
|
||||
{path: 'image', code: ValidationErrorCodes.IMAGE_SIZE_EXCEEDS_LIMIT, variables: {maxSize: 1024}},
|
||||
]);
|
||||
});
|
||||
it('applies a guild-feature-filtered emoji_max_size rule only to a guild that carries the feature', async () => {
|
||||
it('applies a guild-feature-filtered emoji_max_size rule only to a guild that has the feature', async () => {
|
||||
const rules: Array<LimitRule> = [
|
||||
{id: 'big-emoji', filters: {guildFeatures: ['BIG_EMOJI']}, limits: {emoji_max_size: EMOJI_MAX_SIZE * 2}},
|
||||
];
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('canonicalizePurgeUrl', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a base path when the media endpoint carries one', () => {
|
||||
it('keeps a base path when the media endpoint has one', () => {
|
||||
Config.endpoints.media = `${MEDIA}/media`;
|
||||
expect(canonicalizePurgeUrl(`${MEDIA}/media/avatars/1/b35cc3d3`)).toEqual([
|
||||
'media.test/media/avatars/1/b35cc3d3',
|
||||
|
||||
@@ -24,5 +24,7 @@ export abstract class IInviteRepository {
|
||||
|
||||
abstract updateInviteUses(code: InviteCode, uses: number, invite: Invite): Promise<void>;
|
||||
|
||||
abstract compareAndSetInviteUses(invite: Invite, uses: number): Promise<boolean>;
|
||||
|
||||
abstract delete(code: InviteCode): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
|
||||
import type {ChannelID, GuildID, InviteCode, UserID} from '@app/api/BrandedTypes';
|
||||
import {createInviteCode} from '@app/api/BrandedTypes';
|
||||
import {BatchBuilder, fetchMany, fetchOne, upsertOne} from '@app/api/database/CassandraQueryExecution';
|
||||
import {
|
||||
BatchBuilder,
|
||||
executeConditional,
|
||||
fetchMany,
|
||||
fetchOne,
|
||||
upsertOne,
|
||||
} from '@app/api/database/CassandraQueryExecution';
|
||||
import {Db} from '@app/api/database/CassandraTypes';
|
||||
import type {InviteRow} from '@app/api/database/types/ChannelTypes';
|
||||
import {IInviteRepository} from '@app/api/invite/IInviteRepository';
|
||||
@@ -168,17 +174,13 @@ export class InviteRepository extends IInviteRepository {
|
||||
|
||||
async updateInviteUses(code: InviteCode, uses: number, invite: Invite): Promise<void> {
|
||||
if (invite.maxAge > 0) {
|
||||
const remainingTtl = Math.max(
|
||||
Math.floor((invite.createdAt.getTime() + invite.maxAge * 1000 - Date.now()) / 1000),
|
||||
1,
|
||||
);
|
||||
await upsertOne(
|
||||
Invites.patchByPkWithTtl(
|
||||
{code},
|
||||
{
|
||||
uses: Db.set(uses),
|
||||
},
|
||||
remainingTtl,
|
||||
this.remainingTtl(invite),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
@@ -193,6 +195,21 @@ export class InviteRepository extends IInviteRepository {
|
||||
}
|
||||
}
|
||||
|
||||
async compareAndSetInviteUses(invite: Invite, uses: number): Promise<boolean> {
|
||||
const patch = {uses: Db.set(uses)};
|
||||
const expected = {uses: invite.uses};
|
||||
if (invite.maxAge > 0) {
|
||||
return executeConditional(
|
||||
Invites.conditionalPatchByPkWithTtl({code: invite.code}, patch, expected, this.remainingTtl(invite)),
|
||||
);
|
||||
}
|
||||
return executeConditional(Invites.conditionalPatchByPk({code: invite.code}, patch, expected));
|
||||
}
|
||||
|
||||
private remainingTtl(invite: Invite): number {
|
||||
return Math.max(Math.floor((invite.createdAt.getTime() + invite.maxAge * 1000 - Date.now()) / 1000), 1);
|
||||
}
|
||||
|
||||
async delete(code: InviteCode): Promise<void> {
|
||||
const invite = await this.findUnique(code);
|
||||
if (!invite) {
|
||||
|
||||
@@ -33,6 +33,8 @@ import type {
|
||||
GuildInviteMetadataResponse,
|
||||
} from '@fluxer/schema/src/domains/invite/InviteSchemas';
|
||||
|
||||
const INVITE_USE_RESERVATION_EXTRA_ATTEMPTS = 8;
|
||||
|
||||
interface GetChannelInvitesParams {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
@@ -262,13 +264,17 @@ export class InviteService {
|
||||
return invite;
|
||||
}
|
||||
if (user) assertAccountNotLimited(user);
|
||||
await this.channelService.groupDms.addRecipientViaInvite({
|
||||
channelId: invite.channelId,
|
||||
recipientId: userId,
|
||||
inviterId: invite.inviterId,
|
||||
requestCache,
|
||||
});
|
||||
return this.incrementInviteUses(invite, {deleteWhenExhausted: true});
|
||||
const channelId = invite.channelId;
|
||||
const reservedInvite = await this.reserveInviteUse(invite);
|
||||
await this.withReservedInviteUse(reservedInvite, () =>
|
||||
this.channelService.groupDms.addRecipientViaInvite({
|
||||
channelId,
|
||||
recipientId: userId,
|
||||
inviterId: invite.inviterId,
|
||||
requestCache,
|
||||
}),
|
||||
);
|
||||
return this.completeInviteUse(reservedInvite, {deleteWhenExhausted: true});
|
||||
}
|
||||
if (!invite.guildId) throw new UnknownInviteError();
|
||||
const guild = await this.guildService.data.getGuildSystem(invite.guildId);
|
||||
@@ -294,20 +300,24 @@ export class InviteService {
|
||||
}
|
||||
const vanityCode = guild.vanityUrlCode ? vanityCodeToInviteCode(guild.vanityUrlCode) : null;
|
||||
const isVanityInvite = invite.code === vanityCode;
|
||||
await this.guildService.members.addUserToGuild({
|
||||
userId,
|
||||
guildId: invite.guildId,
|
||||
sendJoinMessage: true,
|
||||
requestCache,
|
||||
isTemporary: invite.temporary,
|
||||
joinSourceType: isVanityInvite ? JoinSourceTypes.VANITY_URL : JoinSourceTypes.INSTANT_INVITE,
|
||||
sourceInviteCode: isVanityInvite ? undefined : invite.code,
|
||||
inviterId: isVanityInvite ? undefined : (invite.inviterId ?? undefined),
|
||||
});
|
||||
const guildId = invite.guildId;
|
||||
const reservedInvite = await this.reserveInviteUse(invite);
|
||||
await this.withReservedInviteUse(reservedInvite, () =>
|
||||
this.guildService.members.addUserToGuild({
|
||||
userId,
|
||||
guildId,
|
||||
sendJoinMessage: true,
|
||||
requestCache,
|
||||
isTemporary: invite.temporary,
|
||||
joinSourceType: isVanityInvite ? JoinSourceTypes.VANITY_URL : JoinSourceTypes.INSTANT_INVITE,
|
||||
sourceInviteCode: isVanityInvite ? undefined : invite.code,
|
||||
inviterId: isVanityInvite ? undefined : (invite.inviterId ?? undefined),
|
||||
}),
|
||||
);
|
||||
if (invite.temporary) {
|
||||
await this.apiContext.services.gateway.addTemporaryGuild({userId, guildId: invite.guildId});
|
||||
await this.apiContext.services.gateway.addTemporaryGuild({userId, guildId});
|
||||
}
|
||||
return this.incrementInviteUses(invite, {deleteWhenExhausted: !isVanityInvite});
|
||||
return this.completeInviteUse(reservedInvite, {deleteWhenExhausted: !isVanityInvite});
|
||||
}
|
||||
|
||||
private createRandomInviteCode(): InviteCode {
|
||||
@@ -326,13 +336,53 @@ export class InviteService {
|
||||
});
|
||||
}
|
||||
|
||||
private async incrementInviteUses(invite: Invite, params: {deleteWhenExhausted: boolean}): Promise<Invite> {
|
||||
const newUses = invite.uses + 1;
|
||||
await this.inviteRepository.updateInviteUses(invite.code, newUses, invite);
|
||||
if (params.deleteWhenExhausted && invite.maxUses > 0 && newUses >= invite.maxUses) {
|
||||
private async reserveInviteUse(invite: Invite): Promise<Invite> {
|
||||
if (invite.maxUses <= 0) return invite;
|
||||
let current: Invite | null = invite;
|
||||
for (let attempt = 0; attempt <= invite.maxUses + INVITE_USE_RESERVATION_EXTRA_ATTEMPTS; attempt++) {
|
||||
if (!current || current.uses >= current.maxUses) break;
|
||||
const reservedUses = current.uses + 1;
|
||||
if (await this.inviteRepository.compareAndSetInviteUses(current, reservedUses)) {
|
||||
return this.cloneInviteWithUses(current, reservedUses);
|
||||
}
|
||||
current = await this.inviteRepository.findUnique(invite.code);
|
||||
}
|
||||
throw new UnknownInviteError();
|
||||
}
|
||||
|
||||
private async withReservedInviteUse(reservedInvite: Invite, join: () => Promise<unknown>): Promise<void> {
|
||||
try {
|
||||
await join();
|
||||
} catch (error) {
|
||||
await this.releaseInviteUse(reservedInvite);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async releaseInviteUse(reservedInvite: Invite): Promise<void> {
|
||||
if (reservedInvite.maxUses <= 0) return;
|
||||
try {
|
||||
let current = await this.inviteRepository.findUnique(reservedInvite.code);
|
||||
for (let attempt = 0; attempt <= reservedInvite.maxUses + INVITE_USE_RESERVATION_EXTRA_ATTEMPTS; attempt++) {
|
||||
if (!current || current.uses <= 0) return;
|
||||
if (await this.inviteRepository.compareAndSetInviteUses(current, current.uses - 1)) return;
|
||||
current = await this.inviteRepository.findUnique(reservedInvite.code);
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error({error, inviteCode: reservedInvite.code}, 'Failed to release reserved invite use');
|
||||
}
|
||||
}
|
||||
|
||||
private async completeInviteUse(invite: Invite, params: {deleteWhenExhausted: boolean}): Promise<Invite> {
|
||||
if (invite.maxUses <= 0) {
|
||||
const newUses = invite.uses + 1;
|
||||
await this.inviteRepository.updateInviteUses(invite.code, newUses, invite);
|
||||
return this.cloneInviteWithUses(invite, newUses);
|
||||
}
|
||||
if (params.deleteWhenExhausted && invite.uses >= invite.maxUses) {
|
||||
await this.inviteRepository.delete(invite.code);
|
||||
}
|
||||
return this.cloneInviteWithUses(invite, newUses);
|
||||
return invite;
|
||||
}
|
||||
|
||||
private async findInviteWithLowercaseFallback(inviteCode: InviteCode): Promise<Invite | null> {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createGuild} from '@app/api/channel/tests/ChannelTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {MAX_GUILD_MEMBERS} from '@fluxer/constants/src/LimitConstants';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
interface InviteResponse {
|
||||
code: string;
|
||||
uses?: number;
|
||||
}
|
||||
|
||||
async function setupInvite(harness: ApiTestHarness, maxUses: number, joinerCount: number) {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Max uses guild');
|
||||
if (!guild.system_channel_id) {
|
||||
throw new Error('Guild system channel is missing');
|
||||
}
|
||||
const invite = await createBuilder<InviteResponse>(harness, owner.token)
|
||||
.post(`/channels/${guild.system_channel_id}/invites`)
|
||||
.body({max_uses: maxUses, unique: true})
|
||||
.execute();
|
||||
const joiners: Array<TestAccount> = [];
|
||||
for (let i = 0; i < joinerCount; i++) {
|
||||
joiners.push(await createTestAccount(harness));
|
||||
}
|
||||
return {owner, guild, invite, joiners};
|
||||
}
|
||||
|
||||
async function countMembers(harness: ApiTestHarness, accounts: Array<TestAccount>, guildId: string): Promise<number> {
|
||||
let count = 0;
|
||||
for (const account of accounts) {
|
||||
const guilds = await createBuilder<Array<{id: string}>>(harness, account.token).get('/users/@me/guilds').execute();
|
||||
if (guilds.some((guild) => guild.id === guildId)) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
async function findGuildInvite(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
guildId: string,
|
||||
code: string,
|
||||
): Promise<InviteResponse | null> {
|
||||
const invites = await createBuilder<Array<InviteResponse>>(harness, token)
|
||||
.get(`/guilds/${guildId}/invites`)
|
||||
.execute();
|
||||
return invites.find((invite) => invite.code === code) ?? null;
|
||||
}
|
||||
|
||||
describe('Invite max uses', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
it.each([
|
||||
[1, 8],
|
||||
[3, 10],
|
||||
])('admits at most max_uses=%i of %i simultaneous joiners', async (maxUses, joinerCount) => {
|
||||
const {owner, guild, invite, joiners} = await setupInvite(harness, maxUses, joinerCount);
|
||||
const responses = await Promise.all(
|
||||
joiners.map((joiner) =>
|
||||
createBuilder(harness, joiner.token).post(`/invites/${invite.code}`).body(null).executeRaw(),
|
||||
),
|
||||
);
|
||||
const statuses = responses.map((result) => result.response.status);
|
||||
expect(statuses.filter((status) => status === HTTP_STATUS.OK)).toHaveLength(maxUses);
|
||||
expect(
|
||||
statuses.filter((status) => status !== HTTP_STATUS.OK).every((status) => status === HTTP_STATUS.NOT_FOUND),
|
||||
).toBe(true);
|
||||
expect(await countMembers(harness, joiners, guild.id)).toBe(maxUses);
|
||||
expect(await findGuildInvite(harness, owner.token, guild.id, invite.code)).toBeNull();
|
||||
});
|
||||
it('counts every use when joiners arrive together', async () => {
|
||||
const {owner, guild, invite, joiners} = await setupInvite(harness, 10, 4);
|
||||
await Promise.all(
|
||||
joiners.map((joiner) =>
|
||||
createBuilder(harness, joiner.token)
|
||||
.post(`/invites/${invite.code}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute(),
|
||||
),
|
||||
);
|
||||
const after = await findGuildInvite(harness, owner.token, guild.id, invite.code);
|
||||
expect(after?.uses).toBe(4);
|
||||
});
|
||||
it('returns the use when the join fails', async () => {
|
||||
const {owner, guild, invite, joiners} = await setupInvite(harness, 1, 2);
|
||||
const [first, second] = joiners;
|
||||
await createBuilder(harness, '')
|
||||
.post(`/test/guilds/${guild.id}/member-count`)
|
||||
.body({member_count: MAX_GUILD_MEMBERS})
|
||||
.execute();
|
||||
await createBuilder(harness, first!.token)
|
||||
.post(`/invites/${invite.code}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.MAX_GUILD_MEMBERS)
|
||||
.execute();
|
||||
const afterFailure = await findGuildInvite(harness, owner.token, guild.id, invite.code);
|
||||
expect(afterFailure?.uses).toBe(0);
|
||||
await createBuilder(harness, '').post(`/test/guilds/${guild.id}/member-count`).body({member_count: 1}).execute();
|
||||
await createBuilder(harness, second!.token)
|
||||
.post(`/invites/${invite.code}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(await countMembers(harness, [second!], guild.id)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {Config} from '@app/api/Config';
|
||||
import type {HonoEnv} from '@app/api/types/HonoEnv';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {InternalServerError} from '@fluxer/errors/src/domains/core/InternalServerError';
|
||||
import {resolveRoutePattern} from '@fluxer/errors/src/error_handling/RoutePattern';
|
||||
import {createLogger} from '@fluxer/logger/src/Logger';
|
||||
import type {Context, MiddlewareHandler} from 'hono';
|
||||
import type {ZodType} from 'zod';
|
||||
@@ -53,7 +54,7 @@ async function validateAndRewriteResponse(ctx: Context<HonoEnv>, schema: ZodType
|
||||
}));
|
||||
const errorContext = {
|
||||
method: ctx.req.method,
|
||||
path: ctx.req.path,
|
||||
path: resolveRoutePattern(ctx),
|
||||
status: response.status,
|
||||
validationErrors,
|
||||
body,
|
||||
|
||||
@@ -102,7 +102,7 @@ describe('client ip resolution across the request pipeline', () => {
|
||||
expect(pipeline.resolutions[0]?.ip).toBe('203.0.113.10');
|
||||
expect(pipeline.resolutions[1]?.ip).toBe('203.0.113.10');
|
||||
});
|
||||
it('rejects an invalid trusted header even when the configured header carries a valid address', async () => {
|
||||
it('rejects an invalid trusted header even when the configured header contains a valid address', async () => {
|
||||
const pipeline = createPipeline('x-real-ip');
|
||||
const response = await pipeline.request({'x-forwarded-for': '203.0.113.10', 'x-real-ip': 'not-an-ip'});
|
||||
expect(response.status).toBe(403);
|
||||
|
||||
@@ -304,7 +304,10 @@ export class OAuth2Service {
|
||||
if (authCode.userId && !(await this.findActiveUser(authCode.userId))) {
|
||||
throw new InvalidGrantError();
|
||||
}
|
||||
await this.tokens.deleteAuthorizationCode(code);
|
||||
if (!(await this.tokens.consumeAuthorizationCode(code, authCode.applicationId))) {
|
||||
Logger.debug({code_len: code.length}, 'OAuth2 tokenExchange: authorization code already redeemed');
|
||||
throw new InvalidGrantError();
|
||||
}
|
||||
const res = await this.issueTokens({
|
||||
application,
|
||||
userId: authCode.userId,
|
||||
@@ -328,7 +331,9 @@ export class OAuth2Service {
|
||||
if (!(await this.findActiveUser(refresh.userId))) {
|
||||
throw new InvalidGrantError();
|
||||
}
|
||||
await this.tokens.deleteRefreshToken(params.refreshToken!, refresh.applicationId, refresh.userId);
|
||||
if (!(await this.tokens.consumeRefreshToken(params.refreshToken!, refresh.applicationId, refresh.userId))) {
|
||||
throw new InvalidGrantError();
|
||||
}
|
||||
const res = await this.issueTokens({
|
||||
application,
|
||||
userId: refresh.userId,
|
||||
|
||||
@@ -14,13 +14,14 @@ export interface IOAuth2TokenRepository {
|
||||
createAuthorizationCode(data: OAuth2AuthorizationCodeRow): Promise<OAuth2AuthorizationCode>;
|
||||
getAuthorizationCode(code: string): Promise<OAuth2AuthorizationCode | null>;
|
||||
deleteAuthorizationCode(code: string): Promise<void>;
|
||||
consumeAuthorizationCode(code: string, applicationId: ApplicationID): Promise<boolean>;
|
||||
createAccessToken(data: OAuth2AccessTokenRow): Promise<OAuth2AccessToken>;
|
||||
getAccessToken(token: string): Promise<OAuth2AccessToken | null>;
|
||||
deleteAccessToken(token: string, applicationId: ApplicationID, userId: UserID | null): Promise<void>;
|
||||
deleteAllAccessTokensForUser(userId: UserID): Promise<void>;
|
||||
createRefreshToken(data: OAuth2RefreshTokenRow): Promise<OAuth2RefreshToken>;
|
||||
getRefreshToken(token: string): Promise<OAuth2RefreshToken | null>;
|
||||
deleteRefreshToken(token: string, applicationId: ApplicationID, userId: UserID): Promise<void>;
|
||||
consumeRefreshToken(token: string, applicationId: ApplicationID, userId: UserID): Promise<boolean>;
|
||||
deleteAllRefreshTokensForUser(userId: UserID): Promise<void>;
|
||||
listRefreshTokensForUser(userId: UserID): Promise<Array<OAuth2RefreshToken>>;
|
||||
deleteAllTokensForUserAndApplication(userId: UserID, applicationId: ApplicationID): Promise<void>;
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ApplicationID, UserID} from '@app/api/BrandedTypes';
|
||||
import {BatchBuilder, deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '@app/api/database/CassandraQueryExecution';
|
||||
import {
|
||||
BatchBuilder,
|
||||
deleteOneOrMany,
|
||||
executeConditional,
|
||||
fetchMany,
|
||||
fetchOne,
|
||||
upsertOne,
|
||||
} from '@app/api/database/CassandraQueryExecution';
|
||||
import type {
|
||||
OAuth2AccessTokenByUserRow,
|
||||
OAuth2AccessTokenRow,
|
||||
@@ -71,6 +78,10 @@ export class OAuth2TokenRepository implements IOAuth2TokenRepository {
|
||||
await deleteOneOrMany(OAuth2AuthorizationCodes.deleteByPk({code}));
|
||||
}
|
||||
|
||||
async consumeAuthorizationCode(code: string, applicationId: ApplicationID): Promise<boolean> {
|
||||
return executeConditional(OAuth2AuthorizationCodes.conditionalDeleteByPk({code}, {application_id: applicationId}));
|
||||
}
|
||||
|
||||
async createAccessToken(data: OAuth2AccessTokenRow): Promise<OAuth2AccessToken> {
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(OAuth2AccessTokens.insertWithTtl(data, ACCESS_TOKEN_TTL_SECONDS));
|
||||
@@ -142,11 +153,14 @@ export class OAuth2TokenRepository implements IOAuth2TokenRepository {
|
||||
return row ? new OAuth2RefreshToken(row) : null;
|
||||
}
|
||||
|
||||
async deleteRefreshToken(token: string, _applicationId: ApplicationID, userId: UserID): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(OAuth2RefreshTokens.deleteByPk({token_: token}));
|
||||
batch.addPrepared(OAuth2RefreshTokensByUser.deleteByPk({user_id: userId, token_: token}));
|
||||
await batch.execute();
|
||||
async consumeRefreshToken(token: string, applicationId: ApplicationID, userId: UserID): Promise<boolean> {
|
||||
const consumed = await executeConditional(
|
||||
OAuth2RefreshTokens.conditionalDeleteByPk({token_: token}, {application_id: applicationId, user_id: userId}),
|
||||
);
|
||||
if (consumed) {
|
||||
await deleteOneOrMany(OAuth2RefreshTokensByUser.deleteByPk({user_id: userId, token_: token}));
|
||||
}
|
||||
return consumed;
|
||||
}
|
||||
|
||||
async deleteAllRefreshTokensForUser(userId: UserID): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
authorizeOAuth2,
|
||||
createOAuth2TestSetup,
|
||||
exchangeOAuth2AuthorizationCode,
|
||||
} from '@app/api/oauth/tests/OAuthTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
|
||||
|
||||
const CONCURRENT_REQUESTS = 8;
|
||||
|
||||
interface TokenResult {
|
||||
status: number;
|
||||
accessToken: string | null;
|
||||
}
|
||||
|
||||
function addQueryLatency(): void {
|
||||
const executeQuery = InMemoryCassandraQueryExecutor.prototype.executeQuery;
|
||||
vi.spyOn(InMemoryCassandraQueryExecutor.prototype, 'executeQuery').mockImplementation(async function (
|
||||
this: InMemoryCassandraQueryExecutor,
|
||||
...args: Parameters<typeof executeQuery>
|
||||
) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
return executeQuery.apply(this, args);
|
||||
} as typeof executeQuery);
|
||||
}
|
||||
|
||||
async function postToken(
|
||||
harness: ApiTestHarness,
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
form: Record<string, string>,
|
||||
): Promise<TokenResult> {
|
||||
const response = await harness.app.request('/oauth2/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`,
|
||||
'x-forwarded-for': '127.0.0.1',
|
||||
},
|
||||
body: new URLSearchParams(form).toString(),
|
||||
});
|
||||
const body = (await response.json().catch(() => null)) as {access_token?: string} | null;
|
||||
return {status: response.status, accessToken: body?.access_token ?? null};
|
||||
}
|
||||
|
||||
async function postConcurrently(
|
||||
harness: ApiTestHarness,
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
form: Record<string, string>,
|
||||
): Promise<Array<TokenResult>> {
|
||||
addQueryLatency();
|
||||
try {
|
||||
return await Promise.all(
|
||||
Array.from({length: CONCURRENT_REQUESTS}, () => postToken(harness, clientId, clientSecret, form)),
|
||||
);
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
}
|
||||
|
||||
function expectSingleSuccess(results: Array<TokenResult>): void {
|
||||
const succeeded = results.filter((result) => result.status === HTTP_STATUS.OK);
|
||||
expect(succeeded).toHaveLength(1);
|
||||
expect(succeeded[0]!.accessToken).toBeTruthy();
|
||||
expect(results.filter((result) => result.status === HTTP_STATUS.BAD_REQUEST)).toHaveLength(CONCURRENT_REQUESTS - 1);
|
||||
}
|
||||
|
||||
describe('OAuth2 concurrent grant redemption', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
test('redeems an authorization code once when requests overlap', async () => {
|
||||
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
|
||||
const {code} = await authorizeOAuth2(harness, endUser.token, {
|
||||
client_id: application.id,
|
||||
redirect_uri: redirectURI,
|
||||
scope: 'identify',
|
||||
});
|
||||
const results = await postConcurrently(harness, application.id, application.client_secret, {
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: redirectURI,
|
||||
client_id: application.id,
|
||||
});
|
||||
expectSingleSuccess(results);
|
||||
});
|
||||
|
||||
test('rotates a refresh token once when requests overlap', async () => {
|
||||
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
|
||||
const {code} = await authorizeOAuth2(harness, endUser.token, {
|
||||
client_id: application.id,
|
||||
redirect_uri: redirectURI,
|
||||
scope: 'identify',
|
||||
});
|
||||
const initial = await exchangeOAuth2AuthorizationCode(harness, {
|
||||
client_id: application.id,
|
||||
client_secret: application.client_secret,
|
||||
code,
|
||||
redirect_uri: redirectURI,
|
||||
});
|
||||
const results = await postConcurrently(harness, application.id, application.client_secret, {
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: initial.refresh_token!,
|
||||
client_id: application.id,
|
||||
});
|
||||
expectSingleSuccess(results);
|
||||
});
|
||||
});
|
||||
@@ -13912,7 +13912,7 @@
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserPrivateResponse"}}}
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserUpdateResponse"}}}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
@@ -13953,7 +13953,7 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Updates the authenticated user's profile information such as username, avatar, and bio. Requires sudo mode verification for security-sensitive changes. Only default users can modify their own profile.",
|
||||
"description": "Updates the authenticated user's profile information such as username, avatar, and bio. Requires sudo mode verification for security-sensitive changes. Only default users can modify their own profile. A password change invalidates all existing sessions and returns the replacement session token.",
|
||||
"security": [{"sessionToken": []}],
|
||||
"requestBody": {
|
||||
"required": false,
|
||||
@@ -21543,7 +21543,7 @@
|
||||
]
|
||||
},
|
||||
"referenced_message": {
|
||||
"description": "The reply target. Present and populated when the target resolved, present and null when the target is gone, absent when this message carries no default reference. Clients must tell null apart from absent by key presence.",
|
||||
"description": "The reply target. Present and populated when the target resolved, present and null when the target is gone, absent when this message has no default reference. Clients must tell null apart from absent by key presence.",
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
@@ -25300,6 +25300,250 @@
|
||||
"webauthn_challenge": {"description": "WebAuthn challenge string", "type": "string"}
|
||||
}
|
||||
},
|
||||
"UserUpdateResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"description": "The unique identifier (snowflake) for this user",
|
||||
"$ref": "#/components/schemas/SnowflakeStringType"
|
||||
},
|
||||
"username": {"type": "string", "description": "The username of the user, not unique across the platform"},
|
||||
"discriminator": {"type": "string", "description": "The four-digit discriminator tag of the user"},
|
||||
"global_name": {"description": "The display name of the user, if set", "type": ["string", "null"]},
|
||||
"avatar": {"description": "The hash of the user avatar image", "type": ["string", "null"]},
|
||||
"avatar_color": {
|
||||
"anyOf": [{"$ref": "#/components/schemas/Int32Type"}, {"type": "null"}],
|
||||
"description": "The dominant avatar color of the user as an integer"
|
||||
},
|
||||
"bot": {"description": "Whether the user is a bot account", "type": "boolean"},
|
||||
"system": {"description": "Whether the user is an official system user", "type": "boolean"},
|
||||
"flags": {"$ref": "#/components/schemas/PublicUserFlags"},
|
||||
"mention_flags": {
|
||||
"description": "The user's account-wide reply mention preference. Omitted when the user has no preference set (treated as NO_PREFERENCE).",
|
||||
"$ref": "#/components/schemas/MentionReplyPreferences"
|
||||
},
|
||||
"is_staff": {"type": "boolean", "description": "Whether the user has staff permissions"},
|
||||
"acls": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Access control list entries for the user"
|
||||
},
|
||||
"traits": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Special traits assigned to the user account"
|
||||
},
|
||||
"email": {"description": "The email address associated with the account", "type": ["string", "null"]},
|
||||
"email_bounced": {
|
||||
"description": "Whether the current email address is marked as bounced by the mail provider",
|
||||
"type": "boolean"
|
||||
},
|
||||
"has_verified_phone": {"type": "boolean", "description": "Deprecated. Always false."},
|
||||
"bio": {"description": "The user biography text", "type": ["string", "null"]},
|
||||
"pronouns": {"description": "The preferred pronouns of the user", "type": ["string", "null"]},
|
||||
"accent_color": {
|
||||
"anyOf": [{"$ref": "#/components/schemas/Int32Type"}, {"type": "null"}],
|
||||
"description": "The user-selected accent color as an integer"
|
||||
},
|
||||
"timezone": {"description": "The IANA timezone identifier saved by the user", "type": ["string", "null"]},
|
||||
"timezone_privacy_flags": {"$ref": "#/components/schemas/ProfileFieldPrivacyFlags"},
|
||||
"banner": {"description": "The hash of the user profile banner image", "type": ["string", "null"]},
|
||||
"banner_color": {
|
||||
"anyOf": [{"$ref": "#/components/schemas/Int32Type"}, {"type": "null"}],
|
||||
"description": "The default banner color if no custom banner is set"
|
||||
},
|
||||
"mfa_enabled": {"type": "boolean", "description": "Whether multi-factor authentication is enabled"},
|
||||
"authenticator_types": {
|
||||
"description": "The types of authenticators configured for MFA",
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/UserAuthenticatorTypes"}
|
||||
},
|
||||
"verified": {"type": "boolean", "description": "Whether the email address has been verified"},
|
||||
"account_limited": {"description": "Whether the account is limited", "type": "boolean"},
|
||||
"premium_type": {
|
||||
"anyOf": [
|
||||
{"$ref": "#/components/schemas/UserPremiumTypes", "description": "The type of premium subscription"},
|
||||
{"type": "null"}
|
||||
]
|
||||
},
|
||||
"premium_since": {
|
||||
"description": "ISO8601 timestamp of when premium was first activated",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"premium_until": {
|
||||
"description": "ISO8601 timestamp of when premium access ends, including stacked gift time",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"premium_will_cancel": {
|
||||
"type": "boolean",
|
||||
"description": "Whether premium is set to cancel at the end of the billing period"
|
||||
},
|
||||
"premium_billing_cycle": {
|
||||
"description": "The billing cycle for the premium subscription",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"premium_lifetime_sequence": {
|
||||
"anyOf": [{"$ref": "#/components/schemas/Int32Type"}, {"type": "null"}],
|
||||
"description": "The sequence number for lifetime premium subscribers"
|
||||
},
|
||||
"premium_grace_ends_at": {
|
||||
"description": "ISO8601 timestamp at which grace access ends after premium_until passes: after a failed renewal payment (7 days from the renewal for monthly plans, 14 for yearly), after a subscription ends (3 days), or during an App Store or Google Play grace period. Perks stay active and the original premium_since is kept on resubscribe until this timestamp passes. Null when no grace is recorded, in which case access lasts 3 days after premium_until.",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"premium_discriminator": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the user selected a premium-only discriminator that will be rerolled when non-lifetime premium access ends"
|
||||
},
|
||||
"premium_badge_hidden": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the premium badge is hidden on the profile"
|
||||
},
|
||||
"premium_badge_masked": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the premium badge shows a masked appearance"
|
||||
},
|
||||
"premium_badge_timestamp_hidden": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the premium start timestamp is hidden"
|
||||
},
|
||||
"premium_badge_sequence_hidden": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the lifetime sequence number is hidden"
|
||||
},
|
||||
"premium_purchase_disabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether premium purchases are disabled for this account"
|
||||
},
|
||||
"premium_enabled_override": {
|
||||
"type": "boolean",
|
||||
"description": "Whether premium features are enabled via override"
|
||||
},
|
||||
"premium_perks_disabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether premium perks are temporarily disabled for this account"
|
||||
},
|
||||
"password_last_changed_at": {
|
||||
"description": "ISO8601 timestamp of the last password change",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"last_voice_activity_sharing_change_at": {
|
||||
"description": "ISO8601 timestamp of the last bulk voice-activity-sharing change. Drives the 24-hour cooldown for re-toggling the Active Now sharing default.",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"required_actions": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Deprecated. Always empty."
|
||||
},
|
||||
"nsfw_allowed": {"type": "boolean", "description": "Whether the user is allowed to view NSFW content"},
|
||||
"has_dismissed_premium_onboarding": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the user has dismissed the premium onboarding flow"
|
||||
},
|
||||
"has_ever_purchased": {"type": "boolean", "description": "Whether the user has ever made a purchase"},
|
||||
"has_unread_gift_inventory": {
|
||||
"type": "boolean",
|
||||
"description": "Whether there are unread items in the gift inventory"
|
||||
},
|
||||
"unread_gift_inventory_count": {
|
||||
"description": "The number of unread gift inventory items",
|
||||
"$ref": "#/components/schemas/Int32Type"
|
||||
},
|
||||
"pending_bulk_message_deletion": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"scheduled_at": {
|
||||
"type": "string",
|
||||
"description": "ISO8601 timestamp of when the deletion was scheduled"
|
||||
},
|
||||
"channel_count": {
|
||||
"description": "The number of channels with messages to delete",
|
||||
"$ref": "#/components/schemas/Int32Type"
|
||||
},
|
||||
"message_count": {
|
||||
"description": "The total number of messages to delete",
|
||||
"$ref": "#/components/schemas/Int32Type"
|
||||
}
|
||||
},
|
||||
"required": ["scheduled_at", "channel_count", "message_count"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{"type": "null"}
|
||||
],
|
||||
"description": "Information about a pending bulk message deletion request. Only populated when the legacy delayed-deletion flow is in progress; the new immediate-deletion flow does not surface a pending state here."
|
||||
},
|
||||
"age_verified_adult": {
|
||||
"description": "Whether the user has verified their age as an adult via credit card verification",
|
||||
"type": "boolean"
|
||||
},
|
||||
"terms_agreed_at": {
|
||||
"description": "ISO8601 timestamp of when the user last agreed to the terms of service",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"privacy_agreed_at": {
|
||||
"description": "ISO8601 timestamp of when the user last agreed to the privacy policy",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"token": {
|
||||
"description": "Authentication token for the replacement session, present when the password was changed",
|
||||
"type": "string"
|
||||
},
|
||||
"auth_session_id_hash": {
|
||||
"description": "Base64url-encoded hash of the replacement authentication session, present when the password was changed",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"username",
|
||||
"discriminator",
|
||||
"global_name",
|
||||
"avatar",
|
||||
"avatar_color",
|
||||
"flags",
|
||||
"is_staff",
|
||||
"acls",
|
||||
"traits",
|
||||
"email",
|
||||
"has_verified_phone",
|
||||
"bio",
|
||||
"pronouns",
|
||||
"accent_color",
|
||||
"banner",
|
||||
"banner_color",
|
||||
"mfa_enabled",
|
||||
"verified",
|
||||
"premium_type",
|
||||
"premium_since",
|
||||
"premium_until",
|
||||
"premium_will_cancel",
|
||||
"premium_billing_cycle",
|
||||
"premium_lifetime_sequence",
|
||||
"premium_grace_ends_at",
|
||||
"premium_discriminator",
|
||||
"premium_badge_hidden",
|
||||
"premium_badge_masked",
|
||||
"premium_badge_timestamp_hidden",
|
||||
"premium_badge_sequence_hidden",
|
||||
"premium_purchase_disabled",
|
||||
"premium_enabled_override",
|
||||
"premium_perks_disabled",
|
||||
"password_last_changed_at",
|
||||
"last_voice_activity_sharing_change_at",
|
||||
"required_actions",
|
||||
"nsfw_allowed",
|
||||
"has_dismissed_premium_onboarding",
|
||||
"has_ever_purchased",
|
||||
"has_unread_gift_inventory",
|
||||
"unread_gift_inventory_count",
|
||||
"pending_bulk_message_deletion",
|
||||
"terms_agreed_at",
|
||||
"privacy_agreed_at"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"UnfurlRequest": {
|
||||
"type": "object",
|
||||
"properties": {"url": {"description": "The URL to unfurl", "type": "string"}},
|
||||
@@ -30548,7 +30792,7 @@
|
||||
"original": {"type": "string", "description": "The requested URL, echoed back unchanged"},
|
||||
"refreshed": {
|
||||
"type": "string",
|
||||
"description": "The same URL carrying a fresh signature, or the original when it is not an attachment URL of ours"
|
||||
"description": "The same URL with a fresh signature, or the original when it is not an attachment URL of ours"
|
||||
}
|
||||
},
|
||||
"required": ["original", "refreshed"],
|
||||
@@ -32300,9 +32544,15 @@
|
||||
"description": "The template-local channel ID"
|
||||
},
|
||||
"type": {"type": "number", "description": "The channel type (0 = text, 2 = voice, 4 = category)"},
|
||||
"name": {"description": "The name of the channel", "type": ["string", "null"]},
|
||||
"topic": {"description": "The channel topic", "type": ["string", "null"]},
|
||||
"position": {"type": "number", "description": "The position of the channel"},
|
||||
"name": {
|
||||
"description": "The name of the channel",
|
||||
"anyOf": [{"type": "string", "maxLength": 100}, {"type": "null"}]
|
||||
},
|
||||
"topic": {
|
||||
"description": "The channel topic",
|
||||
"anyOf": [{"type": "string", "maxLength": 1024}, {"type": "null"}]
|
||||
},
|
||||
"position": {"description": "The position of the channel", "$ref": "#/components/schemas/Int32Type"},
|
||||
"parent_id": {
|
||||
"description": "The template-local ID of the parent category",
|
||||
"anyOf": [
|
||||
@@ -32313,14 +32563,25 @@
|
||||
{"type": "null"}
|
||||
]
|
||||
},
|
||||
"bitrate": {"description": "The bitrate for voice channels", "type": ["number", "null"]},
|
||||
"user_limit": {"description": "The user limit for voice channels", "type": ["number", "null"]},
|
||||
"bitrate": {
|
||||
"description": "The bitrate for voice channels",
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 9007199254740991}, {"type": "null"}]
|
||||
},
|
||||
"user_limit": {
|
||||
"description": "The user limit for voice channels",
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 9007199254740991}, {"type": "null"}]
|
||||
},
|
||||
"voice_connection_limit": {
|
||||
"description": "The per-user voice connection limit for voice channels",
|
||||
"type": ["number", "null"]
|
||||
"anyOf": [{"type": "integer", "minimum": 1, "maximum": 100}, {"type": "null"}]
|
||||
},
|
||||
"nsfw": {"description": "Whether the channel is NSFW", "type": "boolean"},
|
||||
"rate_limit_per_user": {"description": "Slowmode rate limit in seconds", "type": "number"},
|
||||
"rate_limit_per_user": {
|
||||
"description": "Slowmode rate limit in seconds",
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 21600
|
||||
},
|
||||
"permission_overwrites": {
|
||||
"description": "Permission overwrites for this channel",
|
||||
"type": "array",
|
||||
@@ -32357,7 +32618,10 @@
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 9007199254740991}, {"type": "string"}],
|
||||
"description": "The template-local role ID"
|
||||
},
|
||||
"name": {"description": "The name of the role", "type": ["string", "null"]},
|
||||
"name": {
|
||||
"description": "The name of the role",
|
||||
"anyOf": [{"type": "string", "maxLength": 100}, {"type": "null"}]
|
||||
},
|
||||
"permissions": {
|
||||
"description": "The permissions bitfield as a string (legacy)",
|
||||
"anyOf": [{"type": "string"}, {"type": "integer", "minimum": 0, "maximum": 9007199254740991}]
|
||||
@@ -32366,7 +32630,7 @@
|
||||
"description": "The permissions bitfield as a string (preferred)",
|
||||
"anyOf": [{"type": "string"}, {"type": "integer", "minimum": 0, "maximum": 9007199254740991}]
|
||||
},
|
||||
"color": {"description": "The colour of the role as an integer", "type": "number"},
|
||||
"color": {"description": "The colour of the role as an integer", "$ref": "#/components/schemas/ColorType"},
|
||||
"hoist": {"description": "Whether the role is hoisted", "type": "boolean"},
|
||||
"mentionable": {"description": "Whether the role is mentionable", "type": "boolean"},
|
||||
"unicode_emoji": {"description": "The unicode emoji for the role icon", "type": ["string", "null"]}
|
||||
@@ -35032,6 +35296,14 @@
|
||||
"required": ["src", "proxy_src", "width", "height"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"UserAuthenticatorTypes": {
|
||||
"description": "Authenticator type",
|
||||
"type": "integer",
|
||||
"enum": [0, 2],
|
||||
"format": "int32",
|
||||
"x-enumNames": ["TOTP", "WEBAUTHN"],
|
||||
"x-enumDescriptions": ["Time-based one-time password authenticator", "WebAuthn authenticator"]
|
||||
},
|
||||
"ProfileFieldPrivacyFlags": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
@@ -35283,14 +35555,6 @@
|
||||
"required": ["id", "rawId", "type", "clientExtensionResults", "response"],
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"UserAuthenticatorTypes": {
|
||||
"description": "Authenticator type",
|
||||
"type": "integer",
|
||||
"enum": [0, 2],
|
||||
"format": "int32",
|
||||
"x-enumNames": ["TOTP", "WEBAUTHN"],
|
||||
"x-enumDescriptions": ["Time-based one-time password authenticator", "WebAuthn authenticator"]
|
||||
},
|
||||
"HexString32Type": {"type": "string", "pattern": "^[a-f0-9]{32}$"},
|
||||
"CompletedPasskeyBridgeSudoRedeemResponse": {
|
||||
"type": "object",
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('POST /test/rpc-session-init harness access', () => {
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('rejects a session init carrying the wrong harness token', async () => {
|
||||
test('rejects a session init with the wrong harness token', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
Config.dev.testHarnessToken = HARNESS_TOKEN;
|
||||
await createBuilder(harness, '')
|
||||
@@ -62,7 +62,7 @@ describe('POST /test/rpc-session-init harness access', () => {
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('accepts a session init carrying the harness token', async () => {
|
||||
test('accepts a session init with the harness token', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
Config.dev.testHarnessToken = HARNESS_TOKEN;
|
||||
const response = await createBuilder<RpcSessionResponse>(harness, '')
|
||||
|
||||
@@ -569,7 +569,7 @@ describe('Message Search Filters', () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
test('has: snapshot combined with has: image finds forwards whose snapshot carries an image', async () => {
|
||||
test('has: snapshot combined with has: image finds forwards whose snapshot has an image', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Forward Image Guild');
|
||||
const sourceChannelId = guild.system_channel_id!;
|
||||
|
||||
@@ -370,7 +370,7 @@ describe('App Store JWS verification with a test chain', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a leaf that carries the identifier only as a policy', async () => {
|
||||
it('rejects a leaf that has the identifier only as a policy', async () => {
|
||||
const policyOnly = createAppleTestPki({
|
||||
leaf: {appleExtension: false, extraExtensions: [certificatePoliciesExtension(APPLE_RECEIPT_SIGNING_OID)]},
|
||||
});
|
||||
|
||||
@@ -262,7 +262,7 @@ describe('App Store purchases', () => {
|
||||
};
|
||||
}
|
||||
|
||||
it('grants premium for a claim that carries the account token and answers repeats the same way', async () => {
|
||||
it('grants premium for a claim that includes the account token and answers repeats the same way', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const token = await accountToken(account);
|
||||
const expiresDate = Date.now() + ms('30 days');
|
||||
|
||||
@@ -23,7 +23,7 @@ export async function useCheapCaptcha(): Promise<void> {
|
||||
|
||||
export async function solveCaptchaChallenge(body: CaptchaErrorBody): Promise<string> {
|
||||
const challenge = body.altcha_challenge;
|
||||
if (!challenge) throw new Error('The response carried no ALTCHA challenge');
|
||||
if (!challenge) throw new Error('The response had no ALTCHA challenge');
|
||||
const solution = await solveChallenge({challenge, deriveKey, timeout: 0});
|
||||
if (!solution) throw new Error('The ALTCHA challenge was not solved');
|
||||
const payload = {challenge: {parameters: challenge.parameters, signature: challenge.signature}, solution};
|
||||
|
||||
@@ -78,6 +78,7 @@ import {
|
||||
UserProfileFullResponse,
|
||||
UserSettingsResponse,
|
||||
UserTagCheckResponse,
|
||||
UserUpdateResponse,
|
||||
} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
import {uint8ArrayToBase64} from 'uint8array-extras';
|
||||
|
||||
@@ -118,12 +119,12 @@ export function UserAccountController(app: HonoApp) {
|
||||
OpenAPI({
|
||||
operationId: 'update_current_user',
|
||||
summary: 'Update current user profile',
|
||||
responseSchema: UserPrivateResponse,
|
||||
responseSchema: UserUpdateResponse,
|
||||
statusCode: 200,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
description:
|
||||
"Updates the authenticated user's profile information such as username, avatar, and bio. Requires sudo mode verification for security-sensitive changes. Only default users can modify their own profile.",
|
||||
"Updates the authenticated user's profile information such as username, avatar, and bio. Requires sudo mode verification for security-sensitive changes. Only default users can modify their own profile. A password change invalidates all existing sessions and returns the replacement session token.",
|
||||
}),
|
||||
async (ctx) => {
|
||||
const userAccountRequestService = ctx.get('userAccountRequestService');
|
||||
|
||||
@@ -35,7 +35,11 @@ import type {
|
||||
EmailChangeApplyRequest,
|
||||
UserUpdateWithVerificationRequest,
|
||||
} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
|
||||
import type {UserPrivateResponse, UserProfileFullResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
import type {
|
||||
UserPrivateResponse,
|
||||
UserProfileFullResponse,
|
||||
UserUpdateResponse,
|
||||
} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
import type {Context} from 'hono';
|
||||
|
||||
type UserUpdatePayload = Omit<
|
||||
@@ -127,7 +131,7 @@ export class UserAccountRequestService {
|
||||
user: User;
|
||||
body: UserUpdateWithVerificationRequest;
|
||||
authSession: AuthSession;
|
||||
}): Promise<UserPrivateResponse> {
|
||||
}): Promise<UserUpdateResponse> {
|
||||
const {ctx, body, authSession} = params;
|
||||
const {user} = params;
|
||||
const oldEmail = user.email;
|
||||
@@ -180,7 +184,7 @@ export class UserAccountRequestService {
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
|
||||
}
|
||||
}
|
||||
const updatedUser = await this.userAccountService.update({
|
||||
const {user: updatedUser, authSessionReplacement} = await this.userAccountService.update({
|
||||
user,
|
||||
oldAuthSession: authSession,
|
||||
data: userUpdateData,
|
||||
@@ -224,7 +228,15 @@ export class UserAccountRequestService {
|
||||
Logger.warn({error, userId: updatedUser.id}, 'Failed to issue email revert token');
|
||||
}
|
||||
}
|
||||
return mapUserToPrivateResponse(updatedUser);
|
||||
const response = mapUserToPrivateResponse(updatedUser);
|
||||
if (!authSessionReplacement) {
|
||||
return response;
|
||||
}
|
||||
return {
|
||||
...response,
|
||||
token: authSessionReplacement.token,
|
||||
auth_session_id_hash: authSessionReplacement.authSessionIdHash,
|
||||
};
|
||||
}
|
||||
|
||||
async applyEmailChange(params: {
|
||||
|
||||
@@ -41,6 +41,11 @@ interface UserAccountSecurityServiceDeps {
|
||||
limitConfigService: LimitConfigService;
|
||||
}
|
||||
|
||||
export interface AuthSessionReplacement {
|
||||
token: string;
|
||||
authSessionIdHash: string;
|
||||
}
|
||||
|
||||
export class UserAccountSecurityService {
|
||||
constructor(private readonly deps: UserAccountSecurityServiceDeps) {}
|
||||
|
||||
@@ -158,12 +163,13 @@ export class UserAccountSecurityService {
|
||||
user: User;
|
||||
oldAuthSession: AuthSessionModel;
|
||||
request: Request;
|
||||
}): Promise<void> {
|
||||
await AuthSession.replaceCurrentAuthSession(this.deps.apiContext, {
|
||||
}): Promise<AuthSessionReplacement> {
|
||||
const replacement = await AuthSession.replaceCurrentAuthSession(this.deps.apiContext, {
|
||||
user,
|
||||
currentAuthSession: oldAuthSession,
|
||||
request,
|
||||
});
|
||||
return {token: replacement.token, authSessionIdHash: replacement.newAuthSessionIdHash};
|
||||
}
|
||||
|
||||
private async createSudoModeRequiredError(user: User): Promise<SudoModeRequiredError> {
|
||||
|
||||
@@ -22,7 +22,10 @@ import {UserAccountLifecycleService} from '@app/api/user/services/UserAccountLif
|
||||
import {UserAccountLookupService} from '@app/api/user/services/UserAccountLookupService';
|
||||
import {UserAccountNotesService} from '@app/api/user/services/UserAccountNotesService';
|
||||
import {UserAccountProfileService} from '@app/api/user/services/UserAccountProfileService';
|
||||
import {UserAccountSecurityService} from '@app/api/user/services/UserAccountSecurityService';
|
||||
import {
|
||||
type AuthSessionReplacement,
|
||||
UserAccountSecurityService,
|
||||
} from '@app/api/user/services/UserAccountSecurityService';
|
||||
import {UserAccountSettingsService} from '@app/api/user/services/UserAccountSettingsService';
|
||||
import {UserAccountUpdatePropagator} from '@app/api/user/services/UserAccountUpdatePropagator';
|
||||
import type {UserContactChangeLogService} from '@app/api/user/services/UserContactChangeLogService';
|
||||
@@ -41,6 +44,11 @@ interface UpdateUserParams {
|
||||
emailVerifiedViaToken?: boolean;
|
||||
}
|
||||
|
||||
interface UpdateUserResult {
|
||||
user: User;
|
||||
authSessionReplacement: AuthSessionReplacement | null;
|
||||
}
|
||||
|
||||
interface UserAccountRepository
|
||||
extends IUserAccountRepository,
|
||||
IUserSettingsRepository,
|
||||
@@ -137,7 +145,7 @@ export class UserAccountService {
|
||||
});
|
||||
}
|
||||
|
||||
async update(params: UpdateUserParams): Promise<User> {
|
||||
async update(params: UpdateUserParams): Promise<UpdateUserResult> {
|
||||
const {user, oldAuthSession, data, request, sudoContext, emailVerifiedViaToken = false} = params;
|
||||
const profileResult = await this.profileService.processProfileUpdates({user, data});
|
||||
const securityResult = await this.securityService.processSecurityUpdates({user, data, sudoContext});
|
||||
@@ -195,14 +203,21 @@ export class UserAccountService {
|
||||
}
|
||||
},
|
||||
];
|
||||
let authSessionReplacement: AuthSessionReplacement | null = null;
|
||||
if (securityResult.metadata.invalidateAuthSessions) {
|
||||
finalizationSteps.push(
|
||||
() => this.securityService.invalidateAndRecreateSessions({user, oldAuthSession, request}),
|
||||
async () => {
|
||||
authSessionReplacement = await this.securityService.invalidateAndRecreateSessions({
|
||||
user,
|
||||
oldAuthSession,
|
||||
request,
|
||||
});
|
||||
},
|
||||
() => this.userAccountRepository.deleteAllPasswordResetTokens(user.id),
|
||||
);
|
||||
}
|
||||
await runAllInOrder(finalizationSteps, 'Failed to finalize user update');
|
||||
return updatedUser;
|
||||
return {user: updatedUser, authSessionReplacement};
|
||||
}
|
||||
|
||||
private async reindexGuildMembersForUser(updatedUser: User): Promise<void> {
|
||||
|
||||
@@ -132,7 +132,7 @@ describe('Favorite Meme Operations', () => {
|
||||
expect(sent.attachments[0].filename).toBe(filename);
|
||||
expect(sent.attachments[0].flags & MessageAttachmentFlags.IS_ANIMATED).toBe(MessageAttachmentFlags.IS_ANIMATED);
|
||||
});
|
||||
test('should carry the saved placeholder onto the sent attachment', async () => {
|
||||
test('should copy the saved placeholder onto the sent attachment', async () => {
|
||||
const account = await createTestAccountForAttachmentTests(harness);
|
||||
const {channel} = await setupTestGuildAndChannel(harness, account);
|
||||
const message = await createMessageWithImageAttachment(harness, account.token, channel.id);
|
||||
|
||||
@@ -87,7 +87,7 @@ describe('User profile text validation', () => {
|
||||
await createBuilder(harness, account.token)
|
||||
.put(`/users/@me/notes/${target.userId}`)
|
||||
.header('content-type', 'text/plain')
|
||||
.body({note: 'note carrying a blockedphrase value'})
|
||||
.body({note: 'note with a blockedphrase value'})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.CONTENT_BLOCKED)
|
||||
.execute();
|
||||
await createBuilder(harness, account.token)
|
||||
|
||||
@@ -66,7 +66,7 @@ describe('resolveSessionClientInfo', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a narrow Linux window as a desktop because the product token cannot carry form factor', () => {
|
||||
it('treats a narrow Linux window as a desktop because the product token cannot include form factor', () => {
|
||||
expect(resolve('Fluxer Linux/1.4.2 (stable)', 'linux')).toEqual({
|
||||
platform: 'Fluxer Lite Linux',
|
||||
os: 'Linux',
|
||||
|
||||
@@ -96,7 +96,7 @@ describe('Webhook Instatus integration', () => {
|
||||
expect(await countWebhookMessages(harness, owner.token, channelId, webhook.id)).toBe(1);
|
||||
await deleteWebhook(harness, webhook.id, owner.token);
|
||||
});
|
||||
it('processes a callback carrying no identifier every time', async () => {
|
||||
it('processes a callback with no identifier every time', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Instatus Unidentified Guild');
|
||||
const channelId = guild.system_channel_id!;
|
||||
|
||||
@@ -84,7 +84,7 @@ describe('Bulk delete messages for users', () => {
|
||||
return messages.filter((message) => message.author.id === userId).length;
|
||||
}
|
||||
|
||||
it('carries the admin reason and the message count on the per-user audit row', async () => {
|
||||
it('records the admin reason and the message count on the per-user audit row', async () => {
|
||||
const {owner, members, systemChannel} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0]!;
|
||||
await sendChannelMessage(harness, member.token, systemChannel.id, 'first spam');
|
||||
|
||||
@@ -76,7 +76,7 @@ describe('bulkUpdateUserFlags task', () => {
|
||||
clearWorkerDependencies();
|
||||
});
|
||||
|
||||
test('writes a per-user audit row carrying the admin reason and records failed items', async () => {
|
||||
test('writes a per-user audit row with the admin reason and records failed items', async () => {
|
||||
const first = await createTestAccount(harness);
|
||||
const second = await createTestAccount(harness);
|
||||
const result = (await bulkUpdateUserFlags(
|
||||
|
||||
@@ -96,7 +96,7 @@ describe('Retired worker task types', () => {
|
||||
expect(msg.ack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dead-letters a legacy job that carries no ledger id', async () => {
|
||||
it('dead-letters a legacy job that has no ledger id', async () => {
|
||||
const runner = createRunner();
|
||||
const msg = createJobMessage(RETIRED_TASK_TYPE, {userId: '1', scheduledMessageId: '2'});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user