fix(api): resolve missing user rows for webhooks and sessions (#2487)

This commit is contained in:
Hampus
2026-09-06 15:23:03 +02:00
committed by GitHub
parent d5bfa5a73d
commit a036d9a2e1
10 changed files with 258 additions and 25 deletions
@@ -152,14 +152,7 @@ export class AdminReportService {
publicComment: string;
}): Promise<void> {
const {users: userRepository} = this.deps.apiContext.services;
const systemUser = await userRepository.findUnique(SYSTEM_USER_ID);
if (!systemUser) {
Logger.warn(
{reportId: reportId.toString(), reporterId: reporter.id.toString()},
'Skipping report review system DM because system user does not exist',
);
return;
}
const systemUser = await userRepository.findUniqueAssert(SYSTEM_USER_ID);
const template = getEmailTemplate('report_resolved', reporter.locale, {
username: reporter.username,
reportId: reportId.toString(),
@@ -98,10 +98,14 @@ export const UserMiddleware = createMiddleware<HonoEnv>(async (ctx, next) => {
const authSession = await AuthSession.getAuthSessionByToken(apiContext, token);
if (authSession) {
void AuthSession.updateAuthSessionLastUsed(apiContext, authSession.sessionIdHash);
const user = await apiContext.services.users.findUniqueAssert(authSession.userId);
ctx.set('authSession', authSession);
ctx.set('authTokenType', 'session');
setUserInContext(ctx, user, true);
const user = await apiContext.services.users.findUnique(authSession.userId);
if (user) {
ctx.set('authSession', authSession);
ctx.set('authTokenType', 'session');
setUserInContext(ctx, user, true);
} else {
recordAbuseSignal(resolvedClientIp, 'auth_failure:session', {tokenHash});
}
} else {
recordAbuseSignal(resolvedClientIp, 'auth_failure:session', {tokenHash});
}
@@ -0,0 +1,51 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHash} from 'node:crypto';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
import {createAuthHarness} from '../../auth/tests/AuthTestUtils';
import {createUserID} from '../../BrandedTypes';
import type {ApiTestHarness} from '../../test/ApiTestHarness';
import {HTTP_STATUS} from '../../test/TestConstants';
import {createBuilder} from '../../test/TestRequestBuilder';
import {UserRepository} from '../../user/repositories/UserRepository';
const MISSING_USER_ID = createUserID(999999999999999998n);
const SESSION_TOKEN = 'flx_Zmlzc2lvbmVkc2Vzc2lvbnRva2VuMDAwMDAx';
interface UnauthorizedErrorResponse {
code?: string;
message?: string;
}
describe('Session token whose account row is gone', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('answers 401 on an authenticated route instead of 500', async () => {
const now = new Date();
await new UserRepository().createAuthSession({
user_id: MISSING_USER_ID,
session_id_hash: Buffer.from(createHash('sha256').update(SESSION_TOKEN).digest()),
created_at: now,
approx_last_used_at: now,
client_ip: '127.0.0.1',
client_user_agent: null,
client_os: null,
client_country: null,
version: 1,
});
const result = await createBuilder<UnauthorizedErrorResponse>(harness, SESSION_TOKEN)
.get('/users/@me')
.executeRaw();
expect(result.response.status).toBe(HTTP_STATUS.UNAUTHORIZED);
expect(result.json.code).toBe(APIErrorCodes.UNAUTHORIZED);
});
});
@@ -2,12 +2,19 @@
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
import {createUserID} from '../../BrandedTypes';
import {Config} from '../../Config';
import type {IGuildRepositoryAggregate} from '../../guild/repositories/IGuildRepositoryAggregate';
import type {GuildService} from '../../guild/services/GuildService';
import {createGuild, createRole, getMember} from '../../guild/tests/GuildTestUtils';
import type {IGatewayService} from '../../infrastructure/IGatewayService';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {createBuilder} from '../../test/TestRequestBuilder';
import {UserRepository} from '../../user/repositories/UserRepository';
import {StripePremiumService} from '../services/StripePremiumService';
describe('StripePremiumService', () => {
let harness: ApiTestHarness;
@@ -63,6 +70,23 @@ describe('StripePremiumService', () => {
await createBuilder(harness, 'invalid-token').post('/premium/visionary/rejoin').expect(401).execute();
});
});
describe('POST /premium/grace/end', () => {
test('rejects with UNKNOWN_USER when the account record is gone', async () => {
const premiumService = new StripePremiumService(
new UserRepository(),
{} as IGatewayService,
{} as IGuildRepositoryAggregate,
{} as GuildService,
);
const error = await premiumService.endGracePeriod(createUserID(999999999999999997n)).then(
() => null,
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(UnknownUserError);
expect((error as UnknownUserError).status).toBe(404);
expect((error as UnknownUserError).code).toBe(APIErrorCodes.UNKNOWN_USER);
});
});
describe('premium duration and stacking', () => {
test('sets premium_since on first grant', async () => {
const account = await createTestAccount(harness);
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {DELETED_USER_ID, UserFlags} from '@fluxer/constants/src/UserConstants';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import {BACKGROUND_READ_TIMEOUT_MS} from '@pkgs/cassandra/src/Client';
import {createUserID, type UserID} from '../../../../BrandedTypes';
import {fetchMany, fetchOne, fetchPage, upsertOne} from '../../../../database/CassandraQueryExecution';
@@ -16,7 +17,6 @@ import {User} from '../../../../models/User';
import {Users} from '../../../../Tables';
const FLUXER_BOT_USER_ID = 0n;
const DELETED_USER_ID = 1n;
const FETCH_USERS_BY_IDS_CQL = Users.selectCql({
where: Users.where.in('user_id', 'user_ids'),
});
@@ -73,7 +73,11 @@ export class UserDataRepository {
}
async findUniqueAssert(userId: UserID): Promise<User> {
return (await this.findUnique(userId))!;
const user = await this.findUnique(userId);
if (!user) {
throw new UnknownUserError();
}
return user;
}
async listAllUsersPaginated(limit: number, lastUserId?: UserID): Promise<Array<User>> {
@@ -0,0 +1,46 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
import {createUserID} from '../../BrandedTypes';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {UserRepository} from '../repositories/UserRepository';
const MISSING_USER_ID = createUserID(999999999999999999n);
const SYSTEM_USER_ID = createUserID(0n);
describe('UserRepository.findUniqueAssert', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
afterAll(async () => {
await harness.shutdown();
});
beforeEach(async () => {
await harness.resetData();
});
test('returns the user when the row exists', async () => {
const account = await createTestAccount(harness);
const user = await new UserRepository().findUniqueAssert(createUserID(BigInt(account.userId)));
expect(user.id.toString()).toBe(account.userId);
});
test('resolves the system account without a stored row', async () => {
const user = await new UserRepository().findUniqueAssert(SYSTEM_USER_ID);
expect(user.id.toString()).toBe('0');
expect(user.isSystem).toBe(true);
expect(user.username).toBe('Fluxer');
});
test('throws UnknownUserError when the row is gone', async () => {
const repository = new UserRepository();
const error = await repository.findUniqueAssert(MISSING_USER_ID).then(
() => null,
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(UnknownUserError);
expect((error as UnknownUserError).status).toBe(404);
expect((error as UnknownUserError).code).toBe(APIErrorCodes.UNKNOWN_USER);
});
});
@@ -1,7 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {DELETED_USER_ID} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import {
ChannelIdParam,
GuildIdParam,
@@ -26,7 +28,14 @@ import {
import {WebhookResponse, WebhookTokenResponse} from '@fluxer/schema/src/domains/webhook/WebhookSchemas';
import type {Context} from 'hono';
import {z} from 'zod';
import {createChannelID, createGuildID, createMessageID, createWebhookID, createWebhookToken} from '../BrandedTypes';
import {
createChannelID,
createGuildID,
createMessageID,
createUserID,
createWebhookID,
createWebhookToken,
} from '../BrandedTypes';
import type {MessageRequest} from '../channel/MessageTypes';
import {normalizeMessageRequestPayload} from '../channel/services/message/MessageRequestCompatibility';
import {parseMultipartMessageData} from '../channel/services/message/MessageRequestParser';
@@ -76,12 +85,12 @@ async function parseWebhookMultipartMessageData(
webhookId: createWebhookID(webhookId),
token: createWebhookToken(token),
});
if (!webhook.creatorId) {
throw InputValidationError.fromCode('message_data', ValidationErrorCodes.INVALID_MESSAGE_DATA);
}
const creator = await ctx.get('userRepository').findUnique(webhook.creatorId);
const userRepository = ctx.get('userRepository');
const creator =
(webhook.creatorId ? await userRepository.findUnique(webhook.creatorId) : null) ??
(await userRepository.findUnique(createUserID(DELETED_USER_ID)));
if (!creator) {
throw InputValidationError.fromCode('message_data', ValidationErrorCodes.INVALID_MESSAGE_DATA);
throw new UnknownUserError();
}
let parsedPayload: unknown = null;
const messageData: MessageRequest = await parseMultipartMessageData(
+3 -4
View File
@@ -1,7 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {DELETED_USER_ID} from '@fluxer/constants/src/UserConstants';
import type {WebhookResponse, WebhookTokenResponse} from '@fluxer/schema/src/domains/webhook/WebhookSchemas';
import type {z} from 'zod';
import {createUserID} from '../BrandedTypes';
import type {UserCacheService} from '../infrastructure/UserCacheService';
import type {RequestCache} from '../middleware/RequestCacheMiddleware';
import type {Webhook} from '../models/Webhook';
@@ -28,13 +30,10 @@ export async function mapWebhookToResponseWithCache({
requestCache: RequestCache;
}): Promise<z.infer<typeof WebhookResponse>> {
const creatorPartial = await getCachedUserPartialResponse({
userId: webhook.creatorId!,
userId: webhook.creatorId ?? createUserID(DELETED_USER_ID),
userCacheService,
requestCache,
});
if (!creatorPartial) {
throw new Error(`Creator user ${webhook.creatorId} not found for webhook`);
}
return {
...mapWebhookToTokenResponse(webhook),
user: creatorPartial,
@@ -0,0 +1,102 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {DELETED_USER_ID, DELETED_USER_USERNAME} from '@fluxer/constants/src/UserConstants';
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
import {createUserID, createWebhookID} from '../../BrandedTypes';
import {createGuild} from '../../guild/tests/GuildTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {HTTP_STATUS} from '../../test/TestConstants';
import {WebhookRepository} from '../WebhookRepository';
import {createWebhook, executeWebhook, executeWebhookWithAttachments, getChannelWebhooks} from './WebhookTestUtils';
const VANISHED_CREATOR_ID = createUserID(999999999999999997n);
function parseErrorCode(text: string): string | undefined {
return (JSON.parse(text) as {code?: string}).code;
}
describe('Webhook whose creating account cannot be resolved', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
afterEach(async () => {
await harness?.shutdown();
});
it('lists a webhook with no creator id as the deleted user', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Null creator webhook guild');
const channelId = guild.system_channel_id!;
const webhook = await createWebhook(harness, channelId, owner.token, 'Null Creator Webhook');
await new WebhookRepository().update(createWebhookID(BigInt(webhook.id)), {creatorId: null});
const webhooks = await getChannelWebhooks(harness, channelId, owner.token);
const listed = webhooks.find((entry) => entry.id === webhook.id);
expect(listed).toBeDefined();
expect(listed?.user.id).toBe(DELETED_USER_ID.toString());
expect(listed?.user.username).toBe(DELETED_USER_USERNAME);
});
it('lists a webhook whose creator row is gone as the deleted user', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Vanished creator webhook guild');
const channelId = guild.system_channel_id!;
const webhook = await createWebhook(harness, channelId, owner.token, 'Vanished Creator Webhook');
await new WebhookRepository().update(createWebhookID(BigInt(webhook.id)), {creatorId: VANISHED_CREATOR_ID});
const webhooks = await getChannelWebhooks(harness, channelId, owner.token);
const listed = webhooks.find((entry) => entry.id === webhook.id);
expect(listed).toBeDefined();
expect(listed?.user.id).toBe(VANISHED_CREATOR_ID.toString());
expect(listed?.user.username).toBe(DELETED_USER_USERNAME);
});
it('answers a multipart execution for a webhook with no creator id with an access decision', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Null creator multipart guild');
const channelId = guild.system_channel_id!;
const webhook = await createWebhook(harness, channelId, owner.token, 'Null Creator Multipart Webhook');
await new WebhookRepository().update(createWebhookID(BigInt(webhook.id)), {creatorId: null});
const {response, text} = await executeWebhookWithAttachments(harness, {
webhookId: webhook.id,
webhookToken: webhook.token,
payload: {
attachments: [{id: 0, filename: 'orphaned.txt'}],
},
files: [{index: 0, filename: 'orphaned.txt', data: Buffer.from('uploaded by an orphaned webhook')}],
});
expect(response.status).toBe(HTTP_STATUS.FORBIDDEN);
expect(parseErrorCode(text)).toBe(APIErrorCodes.ACCESS_DENIED);
});
it('answers a multipart execution for a webhook whose creator row is gone with an access decision', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Vanished creator multipart guild');
const channelId = guild.system_channel_id!;
const webhook = await createWebhook(harness, channelId, owner.token, 'Vanished Creator Multipart Webhook');
await new WebhookRepository().update(createWebhookID(BigInt(webhook.id)), {creatorId: VANISHED_CREATOR_ID});
const {response, text} = await executeWebhookWithAttachments(harness, {
webhookId: webhook.id,
webhookToken: webhook.token,
payload: {
attachments: [{id: 0, filename: 'vanished.txt'}],
},
files: [{index: 0, filename: 'vanished.txt', data: Buffer.from('uploaded by a vanished creator')}],
});
expect(response.status).toBe(HTTP_STATUS.FORBIDDEN);
expect(parseErrorCode(text)).toBe(APIErrorCodes.ACCESS_DENIED);
});
it('executes a json payload for a webhook with no creator id', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Null creator json guild');
const channelId = guild.system_channel_id!;
const webhook = await createWebhook(harness, channelId, owner.token, 'Null Creator Json Webhook');
await new WebhookRepository().update(createWebhookID(BigInt(webhook.id)), {creatorId: null});
const {response, json} = await executeWebhook(
harness,
webhook.id,
webhook.token,
{content: 'sent by an orphaned webhook', wait: true},
200,
);
expect(response.status).toBe(HTTP_STATUS.OK);
expect(json?.content).toBe('sent by an orphaned webhook');
});
});
+1
View File
@@ -152,6 +152,7 @@ export const PUBLIC_USER_FLAGS =
export const DELETED_USER_USERNAME = 'DeletedUser';
export const DELETED_USER_GLOBAL_NAME = 'Deleted User';
export const DELETED_USER_DISCRIMINATOR = 0;
export const DELETED_USER_ID = 1n;
export const PublicUserFlags = {
STAFF: Number(UserFlags.STAFF),
PARTNER: Number(UserFlags.PARTNER),