fix: tighten edge cases across services (#3158)

This commit is contained in:
Hampus
2026-10-03 13:04:29 +02:00
committed by GitHub
parent a9f7a23c0d
commit 4e6b837ccc
170 changed files with 2028 additions and 421 deletions
@@ -692,7 +692,7 @@ describe('ConfigLoader', () => {
}
});
test('rejects a cache purge endpoint that carries credentials', async () => {
test('rejects a cache purge endpoint that contains credentials', async () => {
stubMinimalEnv({
FLUXER_CACHE_PURGE_ADAPTER: 'http',
FLUXER_CACHE_PURGE_HTTP_ENDPOINT: 'https://purger:[email protected]/purge',
@@ -869,7 +869,7 @@ describe('ConfigLoader', () => {
expect(config.integrations.voice.url).toBe('http://localhost:8088/livekit');
});
test('inserts the public port into every other public url the config carries', async () => {
test('inserts the public port into every other public url the config contains', async () => {
stubMinimalEnv({
FLUXER_S3_PUBLIC_ENDPOINT: 'http://localhost/s3',
FLUXER_EMAIL_APP_BASE_URL: 'http://localhost',
@@ -207,7 +207,7 @@ describe('the shipped compose stack expanded on a non-default port', () => {
expect(starved).toEqual([]);
});
test('every public URL the stack hands a browser carries the port', () => {
test('every public URL the stack hands a browser includes the port', () => {
const entries = serviceNames
.filter((service) => !SERVICES_WITHOUT_ENDPOINT_REPAIR.has(service))
.flatMap((service) => repairedPublicUrls(service, PORT_ONLY_ENV));
@@ -226,7 +226,7 @@ describe('the shipped compose stack expanded on a non-default port', () => {
});
});
describe('a public origin carrying a port while FLUXER_PUBLIC_PORT stays standard', () => {
describe('a public origin with a port while FLUXER_PUBLIC_PORT stays standard', () => {
beforeEach(() => {
resetConfig();
});
@@ -236,7 +236,7 @@ describe('a public origin carrying a port while FLUXER_PUBLIC_PORT stays standar
vi.unstubAllEnvs();
});
test('the compose overrides all carry the origin port', () => {
test('the compose overrides all include the origin port', () => {
const environment = expandedEnvironment('api', ORIGIN_ONLY_ENV);
const entries = publicUrlNames(environment).map((name): [string, string] => [name, environment[name]]);
expect(entries.length).toBeGreaterThan(0);
@@ -89,6 +89,17 @@ describe('AppErrorHandler logging', () => {
expect(details.status).toBe(400);
});
it('logs the matched route pattern instead of the request path', async () => {
const app = createApp();
app.get('/reset/:token', () => {
throw new ServiceUnavailableError({message: 'unavailable'});
});
const response = await app.request('/reset/abc123');
expect(response.status).toBe(503);
expect(logCalls.error).toHaveLength(1);
expect(logCalls.error[0]![0].path).toBe('/reset/:token');
});
it('still reports unexpected errors as unhandled', async () => {
const app = createApp();
app.get('/thing', () => {
@@ -188,7 +188,7 @@ describe('PremiumPurchaseBlockedError', () => {
});
});
it('carries the blocking provider when one is given', () => {
it('includes the blocking provider when one is given', () => {
const error = new PremiumPurchaseBlockedError('existing_subscription', {provider: 'app_store'});
expect(error.toJSON()).toEqual({
@@ -15,6 +15,7 @@ import {
resolveMessageVariables,
} from '@fluxer/errors/src/error_handling/ErrorIntrospection';
import {createJsonErrorResponse} from '@fluxer/errors/src/error_handling/ErrorResponse';
import {resolveRoutePattern} from '@fluxer/errors/src/error_handling/RoutePattern';
import {FluxerError} from '@fluxer/errors/src/FluxerError';
import {ErrorCodeToI18nKey} from '@fluxer/errors/src/i18n/ErrorCodeMappings';
import {getErrorMessageUnsafe} from '@fluxer/errors/src/i18n/ErrorI18n';
@@ -281,7 +282,7 @@ function logErrorResponse<E extends BaseHonoEnv>(err: Error, resolved: ResolvedE
err,
status,
method: ctx.req.method,
path: ctx.req.path,
path: resolveRoutePattern(ctx),
requestId: ctx.get('requestId'),
};
if (resolved.unexpected) {
@@ -0,0 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {Context} from 'hono';
import {matchedRoutes} from 'hono/route';
import {METHOD_NAME_ALL} from 'hono/router';
export function resolveRoutePattern(ctx: Context): string {
const routes = matchedRoutes(ctx);
const endpoint = routes.findLast((route) => route.method !== METHOD_NAME_ALL);
return (endpoint ?? routes.at(-1))?.path ?? '*';
}
+2 -1
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createErrorHandler as createFluxerErrorHandler} from '@fluxer/errors/src/ErrorHandler';
import {resolveRoutePattern} from '@fluxer/errors/src/error_handling/RoutePattern';
import type {Context, ErrorHandler} from 'hono';
export interface ErrorHandlerOptions {
@@ -19,7 +20,7 @@ export function createErrorHandler(options: ErrorHandlerOptions = {}): ErrorHand
}
if (captureException) {
captureException(error, {
path: context.req.path,
path: resolveRoutePattern(context),
method: context.req.method,
status: context.res?.status,
});
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {resolveRoutePattern} from '@fluxer/errors/src/error_handling/RoutePattern';
import {matchesAnyPathPattern} from '@fluxer/hono/src/middleware/utils/PathMatchers';
import type {MiddlewareHandler} from 'hono';
@@ -38,8 +39,7 @@ export function createInfoRequestLogger(logger: RequestInfoLogger): LogFunction
export function requestLogger(options: RequestLoggerOptions): MiddlewareHandler {
const {log, skip = []} = options;
return async (c, next) => {
const path = c.req.path;
if (matchesAnyPathPattern(path, skip)) {
if (matchesAnyPathPattern(c.req.path, skip)) {
return next();
}
const startTime = Date.now();
@@ -47,6 +47,6 @@ export function requestLogger(options: RequestLoggerOptions): MiddlewareHandler
await next();
const durationMs = Date.now() - startTime;
const status = c.res.status;
log({method, path, status, durationMs});
log({method, path: resolveRoutePattern(c), status, durationMs});
};
}
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {RequestLogData} from '@fluxer/hono/src/middleware/RequestLogger';
import {requestLogger} from '@fluxer/hono/src/middleware/RequestLogger';
import {Hono} from 'hono';
import {describe, expect, test} from 'vitest';
function createApp() {
const entries: Array<RequestLogData> = [];
const routes = new Hono();
routes.use(requestLogger({log: (data) => entries.push(data), skip: ['/_health']}));
routes.get('/_health', (c) => c.text('OK'));
routes.get('/auth/reset/:token', (c) => c.json({ok: true}));
routes.post('/webhooks/:webhook_id/:token', (c) => c.body(null, 204));
routes.get('/blocked/:code', (c) => c.text('unreachable'));
routes.use('/limited/*', async (c) => c.text('limited', 429));
routes.get('/limited/:code', (c) => c.text('unreachable'));
const app = new Hono();
app.route('/v1', routes);
app.route('/', routes);
return {app, entries};
}
describe('RequestLogger Middleware', () => {
test('logs the matched route pattern instead of the request path', async () => {
const {app, entries} = createApp();
await app.request('/v1/auth/reset/abc123');
await app.request('/webhooks/111/def456', {method: 'POST'});
expect(entries).toEqual([
expect.objectContaining({method: 'GET', path: '/v1/auth/reset/:token', status: 200}),
expect.objectContaining({method: 'POST', path: '/webhooks/:webhook_id/:token', status: 204}),
]);
});
test('logs the route pattern when middleware ends the request early', async () => {
const {app, entries} = createApp();
await app.request('/v1/limited/ghi789');
expect(entries).toEqual([expect.objectContaining({path: '/v1/limited/:code', status: 429})]);
});
test('does not log raw segments for unmatched routes', async () => {
const {app, entries} = createApp();
await app.request('/v1/unknown/jkl012');
expect(entries.length).toBeGreaterThan(0);
for (const entry of entries) {
expect(entry.status).toBe(404);
expect(entry.path).not.toContain('jkl012');
}
});
test('skips configured paths', async () => {
const {app, entries} = createApp();
await app.request('/_health');
expect(entries).toEqual([]);
});
});
@@ -61,7 +61,7 @@ export type AdminBlocklistEntryListQuery = z.infer<typeof AdminBlocklistEntryLis
const AdminBlocklistTypeResponse = z.object({
list_type: AdminBlocklistListType,
description: z.string().describe('What the blocklist matches and how matching is performed'),
value_field: z.string().describe('The request body field that carries the entry value when adding to this blocklist'),
value_field: z.string().describe('The request body field that holds the entry value when adding to this blocklist'),
fields: z.array(z.string()).max(8).describe('Fields entries of this blocklist accept beyond the value itself'),
scoped: z.boolean().describe('Whether entries are scoped to a profile field and a scope must be supplied'),
supports_bulk_create: z.boolean().describe('Whether PUT on the entry collection is accepted'),
@@ -55,7 +55,7 @@ export const AdminMessageSearchQuery = z.object({
'Return the single message with this ID together with its surrounding context; ignores every other filter',
),
attachment_id: SnowflakeType.optional().describe(
'Return the single message carrying this attachment together with its surrounding context; requires filename',
'Return the single message with this attachment together with its surrounding context; requires filename',
),
filename: FilenameType.optional().describe('The filename of the attachment named by attachment_id'),
context_limit: createQueryIntegerType({defaultValue: 50, minValue: 1, maxValue: 100}).describe(
@@ -1,8 +1,18 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createStringType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {
CHANNEL_RATE_LIMIT_PER_USER_MAX,
CHANNEL_RATE_LIMIT_PER_USER_MIN,
CHANNEL_TOPIC_MAX_LENGTH,
VOICE_CHANNEL_CONNECTION_LIMIT_MAX,
VOICE_CHANNEL_CONNECTION_LIMIT_MIN,
VOICE_CHANNEL_USER_LIMIT_MIN,
} from '@fluxer/constants/src/LimitConstants';
import {ColorType, createStringType, Int32Type} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {z} from 'zod';
const TEMPLATE_NAME_MAX_LENGTH = 100;
const TemplateEntityId = z
.union([
z.number().int().nonnegative(),
@@ -36,17 +46,35 @@ export const TemplateChannel = z.object({
type: z.number().describe('The channel type (0 = text, 2 = voice, 4 = category)'),
name: z
.string()
.max(TEMPLATE_NAME_MAX_LENGTH)
.nullish()
.transform((value) => value ?? '')
.describe('The name of the channel'),
topic: z.string().nullish().describe('The channel topic'),
position: z.number().describe('The position of the channel'),
topic: z.string().max(CHANNEL_TOPIC_MAX_LENGTH).nullish().describe('The channel topic'),
position: Int32Type.describe('The position of the channel'),
parent_id: TemplateEntityId.nullish().describe('The template-local ID of the parent category'),
bitrate: z.number().nullish().describe('The bitrate for voice channels'),
user_limit: z.number().nullish().describe('The user limit for voice channels'),
voice_connection_limit: z.number().nullish().describe('The per-user voice connection limit for voice channels'),
bitrate: z.number().int().nonnegative().nullish().describe('The bitrate for voice channels'),
user_limit: z
.number()
.int()
.min(VOICE_CHANNEL_USER_LIMIT_MIN)
.nullish()
.describe('The user limit for voice channels'),
voice_connection_limit: z
.number()
.int()
.min(VOICE_CHANNEL_CONNECTION_LIMIT_MIN)
.max(VOICE_CHANNEL_CONNECTION_LIMIT_MAX)
.nullish()
.describe('The per-user voice connection limit for voice channels'),
nsfw: z.boolean().optional().describe('Whether the channel is NSFW'),
rate_limit_per_user: z.number().optional().describe('Slowmode rate limit in seconds'),
rate_limit_per_user: z
.number()
.int()
.min(CHANNEL_RATE_LIMIT_PER_USER_MIN)
.max(CHANNEL_RATE_LIMIT_PER_USER_MAX)
.optional()
.describe('Slowmode rate limit in seconds'),
permission_overwrites: z
.array(TemplatePermissionOverwrite)
.optional()
@@ -59,12 +87,13 @@ export const TemplateRole = z.object({
id: TemplateEntityId.describe('The template-local role ID'),
name: z
.string()
.max(TEMPLATE_NAME_MAX_LENGTH)
.nullish()
.transform((value) => value ?? '')
.describe('The name of the role'),
permissions: TemplatePermissionBitfield.optional().describe('The permissions bitfield as a string (legacy)'),
permissions_new: TemplatePermissionBitfield.optional().describe('The permissions bitfield as a string (preferred)'),
color: z.number().optional().describe('The colour of the role as an integer'),
color: ColorType.optional().describe('The colour of the role as an integer'),
hoist: z.boolean().optional().describe('Whether the role is hoisted'),
mentionable: z.boolean().optional().describe('Whether the role is mentionable'),
unicode_emoji: z.string().nullish().describe('The unicode emoji for the role icon'),
@@ -69,7 +69,7 @@ export const RefreshedAttachmentUrl = z.object({
original: z.string().describe('The requested URL, echoed back unchanged'),
refreshed: z
.string()
.describe('The same URL carrying a fresh signature, or the original when it is not an attachment URL of ours'),
.describe('The same URL with a fresh signature, or the original when it is not an attachment URL of ours'),
});
export type RefreshedAttachmentUrl = z.infer<typeof RefreshedAttachmentUrl>;
@@ -166,7 +166,7 @@ export interface MessageResponse extends MessageBaseResponse {
export const MessageResponseSchema = MessageBaseResponseSchema.extend({
referenced_message: MessageBaseResponseSchema.nullish().describe(
'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.',
'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.',
),
});
const ChannelPinMessageResponse = MessageResponseSchema.omit({
@@ -98,7 +98,7 @@ describe('webhook responses', () => {
expect(WebhookCreateResponse.safeParse({...withoutToken, user: creator}).success).toBe(false);
});
it('omits the token and carries the source on follower webhooks', () => {
it('omits the token and includes the source on follower webhooks', () => {
const {token: _token, ...withoutToken} = incomingWebhook;
const follower = {
...withoutToken,
@@ -168,6 +168,21 @@ export const UserPrivateResponse = UserPartialResponse.extend({
export type UserPrivateResponse = z.infer<typeof UserPrivateResponse>;
export const UserUpdateResponse = UserPrivateResponse.extend({
token: z
.string()
.optional()
.describe('Authentication token for the replacement session, present when the password was changed'),
auth_session_id_hash: z
.string()
.optional()
.describe(
'Base64url-encoded hash of the replacement authentication session, present when the password was changed',
),
});
export type UserUpdateResponse = z.infer<typeof UserUpdateResponse>;
export const EmailChangeStartResponse = z.object({
ticket: z.string().describe('Ticket returned for email change actions'),
require_original: z.boolean().describe('Whether verification of the original email is required'),
@@ -16,8 +16,8 @@ export function isVoiceEngineV2FrameReceivedEvent(event: VoiceEngineV2Event): ev
export function canCoalesceVoiceEngineV2Events(tailEvent: VoiceEngineV2Event, nextEvent: VoiceEngineV2Event): boolean {
if (!isVoiceEngineV2FrameReceivedEvent(nextEvent)) return false;
if (!isVoiceEngineV2FrameReceivedEvent(tailEvent)) return false;
assert.equal(typeof tailEvent.frame.trackSid, 'string', 'tail frame event must carry a string trackSid');
assert.equal(typeof nextEvent.frame.trackSid, 'string', 'next frame event must carry a string trackSid');
assert.equal(typeof tailEvent.frame.trackSid, 'string', 'tail frame event must have a string trackSid');
assert.equal(typeof nextEvent.frame.trackSid, 'string', 'next frame event must have a string trackSid');
return tailEvent.frame.trackSid === nextEvent.frame.trackSid;
}