mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
fix(api): resolve missing user rows for webhooks and sessions (#2487)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user