diff --git a/fluxer_api/src/api/admin/services/AdminReportService.ts b/fluxer_api/src/api/admin/services/AdminReportService.ts index 378560b58..ba3b69eb0 100644 --- a/fluxer_api/src/api/admin/services/AdminReportService.ts +++ b/fluxer_api/src/api/admin/services/AdminReportService.ts @@ -152,14 +152,7 @@ export class AdminReportService { publicComment: string; }): Promise { 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(), diff --git a/fluxer_api/src/api/middleware/UserMiddleware.ts b/fluxer_api/src/api/middleware/UserMiddleware.ts index 23d21947a..2c70b6591 100644 --- a/fluxer_api/src/api/middleware/UserMiddleware.ts +++ b/fluxer_api/src/api/middleware/UserMiddleware.ts @@ -98,10 +98,14 @@ export const UserMiddleware = createMiddleware(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}); } diff --git a/fluxer_api/src/api/middleware/tests/SessionWithMissingUserRow.test.ts b/fluxer_api/src/api/middleware/tests/SessionWithMissingUserRow.test.ts new file mode 100644 index 000000000..274b945f8 --- /dev/null +++ b/fluxer_api/src/api/middleware/tests/SessionWithMissingUserRow.test.ts @@ -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(harness, SESSION_TOKEN) + .get('/users/@me') + .executeRaw(); + expect(result.response.status).toBe(HTTP_STATUS.UNAUTHORIZED); + expect(result.json.code).toBe(APIErrorCodes.UNAUTHORIZED); + }); +}); diff --git a/fluxer_api/src/api/stripe/tests/StripePremiumService.test.ts b/fluxer_api/src/api/stripe/tests/StripePremiumService.test.ts index 994e5a9c3..4a5cf8340 100644 --- a/fluxer_api/src/api/stripe/tests/StripePremiumService.test.ts +++ b/fluxer_api/src/api/stripe/tests/StripePremiumService.test.ts @@ -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); diff --git a/fluxer_api/src/api/user/repositories/account/crud/UserDataRepository.ts b/fluxer_api/src/api/user/repositories/account/crud/UserDataRepository.ts index 1b53fe1b6..a70b3a6e6 100644 --- a/fluxer_api/src/api/user/repositories/account/crud/UserDataRepository.ts +++ b/fluxer_api/src/api/user/repositories/account/crud/UserDataRepository.ts @@ -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 { - 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> { diff --git a/fluxer_api/src/api/user/tests/UserFindUniqueAssert.test.ts b/fluxer_api/src/api/user/tests/UserFindUniqueAssert.test.ts new file mode 100644 index 000000000..2815ef00c --- /dev/null +++ b/fluxer_api/src/api/user/tests/UserFindUniqueAssert.test.ts @@ -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); + }); +}); diff --git a/fluxer_api/src/api/webhook/WebhookController.ts b/fluxer_api/src/api/webhook/WebhookController.ts index d92b203ee..584b338d7 100644 --- a/fluxer_api/src/api/webhook/WebhookController.ts +++ b/fluxer_api/src/api/webhook/WebhookController.ts @@ -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( diff --git a/fluxer_api/src/api/webhook/WebhookModel.ts b/fluxer_api/src/api/webhook/WebhookModel.ts index 1824184e2..a7a83d36c 100644 --- a/fluxer_api/src/api/webhook/WebhookModel.ts +++ b/fluxer_api/src/api/webhook/WebhookModel.ts @@ -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> { 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, diff --git a/fluxer_api/src/api/webhook/tests/WebhookOrphanedCreator.test.ts b/fluxer_api/src/api/webhook/tests/WebhookOrphanedCreator.test.ts new file mode 100644 index 000000000..01ac755ff --- /dev/null +++ b/fluxer_api/src/api/webhook/tests/WebhookOrphanedCreator.test.ts @@ -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'); + }); +}); diff --git a/packages/constants/src/UserConstants.ts b/packages/constants/src/UserConstants.ts index f819c1d07..d257b8855 100644 --- a/packages/constants/src/UserConstants.ts +++ b/packages/constants/src/UserConstants.ts @@ -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),