fix(api): raise coded errors for prerequisites and bounds (#2502)

This commit is contained in:
Hampus
2026-09-06 15:29:36 +02:00
committed by GitHub
parent f06d65db54
commit cc110b9f5a
7 changed files with 109 additions and 28 deletions
+42
View File
@@ -0,0 +1,42 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {beforeAll, describe, expect, it} from 'vitest';
import {z} from 'zod';
import {initializeFluxerErrorMap} from './ZodErrorMap';
function firstIssueMessage(schema: z.ZodType, value: unknown): string | undefined {
const result = schema.safeParse(value);
return result.success ? undefined : result.error.issues[0]?.message;
}
describe('ZodErrorMap', () => {
beforeAll(() => {
initializeFluxerErrorMap();
});
it('maps a date below its minimum to INVALID_FORMAT', () => {
expect(
firstIssueMessage(z.date().min(new Date('2000-01-01T00:00:00.000Z')), new Date('1999-12-31T00:00:00.000Z')),
).toBe(ValidationErrorCodes.INVALID_FORMAT);
});
it('maps a date above its maximum to INVALID_FORMAT', () => {
expect(
firstIssueMessage(z.date().max(new Date('2000-01-01T00:00:00.000Z')), new Date('2000-01-02T00:00:00.000Z')),
).toBe(ValidationErrorCodes.INVALID_FORMAT);
});
it('maps both numeric bounds to INVALID_FORMAT', () => {
expect(firstIssueMessage(z.number().min(1), 0)).toBe(ValidationErrorCodes.INVALID_FORMAT);
expect(firstIssueMessage(z.number().max(1), 2)).toBe(ValidationErrorCodes.INVALID_FORMAT);
});
it('maps a string longer than its maximum to CONTENT_EXCEEDS_MAX_LENGTH', () => {
expect(firstIssueMessage(z.string().max(1), 'ab')).toBe(ValidationErrorCodes.CONTENT_EXCEEDS_MAX_LENGTH);
});
it('maps a string shorter than its minimum to INVALID_FORMAT', () => {
expect(firstIssueMessage(z.string().min(2), 'a')).toBe(ValidationErrorCodes.INVALID_FORMAT);
});
});
+1 -6
View File
@@ -54,12 +54,7 @@ function fluxerZodErrorMap(issue: FluxerZodErrorMapIssue): FluxerZodErrorMapResu
break;
}
case 'too_small': {
const origin = 'origin' in issue ? String(issue.origin) : undefined;
if (origin === 'date') {
errorCode = ValidationErrorCodes.INVALID_DATE_OF_BIRTH_FORMAT;
} else {
errorCode = ValidationErrorCodes.INVALID_FORMAT;
}
errorCode = ValidationErrorCodes.INVALID_FORMAT;
break;
}
case 'too_big': {
+8 -6
View File
@@ -16,6 +16,7 @@ import {PhoneVerificationRequiredError} from '@fluxer/errors/src/domains/auth/Ph
import {SmsVerificationUnavailableError} from '@fluxer/errors/src/domains/auth/SmsVerificationUnavailableError';
import {CaptchaVerificationRequiredError} from '@fluxer/errors/src/domains/core/CaptchaVerificationRequiredError';
import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {FluxerError} from '@fluxer/errors/src/FluxerError';
import {PHONE_E164_REGEX} from '@fluxer/schema/src/primitives/UserValidators';
import type {RateLimitResult, RateLimitScope} from '@pkgs/rate_limit/src/IRateLimitService';
@@ -74,15 +75,16 @@ function reuseStoreFor(ctx: ApiContext): PhoneVerificationReuseStore {
export async function startInboundPhoneChallenge(ctx: ApiContext, userId: UserID): Promise<IssuedChallenge> {
const {inboundSmsChallenge, users, config} = ctx.services;
if (!inboundSmsChallenge) {
throw new Error('Inbound SMS challenge flow is not configured on this instance');
}
const ourNumber = config.sms.inboundChallengeNumber;
if (!ourNumber) {
throw new Error('Config.sms.inboundChallengeNumber is required for the inbound SMS challenge flow');
if (!inboundSmsChallenge || !ourNumber) {
Logger.warn(
{userId: String(userId)},
'Inbound SMS challenge requested but FLUXER_SMS_INBOUND_CHALLENGE_NUMBER is unset',
);
throw new SmsVerificationUnavailableError();
}
const user = await users.findUnique(userId);
if (!user) throw new Error('User not found');
if (!user) throw new UnknownUserError();
assertNonBotUser(user);
return inboundSmsChallenge.issueChallenge({userId, ourNumber});
}
@@ -0,0 +1,55 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
import {getConfig} from '../../Config';
import type {ApiTestHarness} from '../../test/ApiTestHarness';
import {createBuilder} from '../../test/TestRequestBuilder';
import {createAuthHarness, createTestAccount} from './AuthTestUtils';
describe('Inbound SMS challenge without a receiving number', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('rejects the challenge with SMS_VERIFICATION_UNAVAILABLE when no number is configured', async () => {
const account = await createTestAccount(harness);
const config = getConfig();
const previousInboundNumber = config.sms.inboundChallengeNumber;
config.sms.inboundChallengeNumber = undefined;
try {
const {response, json} = await createBuilder(harness, account.token)
.post('/users/@me/phone/inbound-challenge')
.executeRaw();
expect(response.status).toBe(400);
expect(json).toMatchObject({code: 'SMS_VERIFICATION_UNAVAILABLE'});
} finally {
config.sms.inboundChallengeNumber = previousInboundNumber;
}
});
it('issues the challenge once a receiving number is configured', async () => {
const account = await createTestAccount(harness);
const config = getConfig();
const previousInboundNumber = config.sms.inboundChallengeNumber;
config.sms.inboundChallengeNumber = '+15551234567';
try {
const response = await createBuilder<{
challenge_code: string;
our_number: string;
expires_at: string;
}>(harness, account.token)
.post('/users/@me/phone/inbound-challenge')
.expect(200)
.execute();
expect(response.our_number).toBe('+15551234567');
expect(response.challenge_code).toMatch(/^\d{6}$/);
} finally {
config.sms.inboundChallengeNumber = previousInboundNumber;
}
});
});
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
import {SudoVerificationSchema} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import {
ChannelUpdateRequest,
@@ -131,7 +132,7 @@ export function ChannelController(app: HonoApp) {
pre: async (raw: unknown, ctx: Context<HonoEnv>) => {
const channelType = ctx.get('channelUpdateType');
if (channelType === undefined) {
throw new Error('Missing channel type for update validation');
throw new UnknownChannelError();
}
const body = isPlainObject(raw) ? raw : {};
return {...body, type: channelType};
@@ -33,12 +33,6 @@ function base64UrlEncode(buf: Buffer): string {
return buf.toString('base64url');
}
function assertRelaySecretConfigured(relayConfig: UploadRelayConfig): void {
if (relayConfig.relaySecretBase64.length === 0) {
throw new Error('FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 is required for relayed uploads');
}
}
function decodeRelaySecret(relaySecretBase64: string): Buffer {
const decoded = Buffer.from(relaySecretBase64, 'base64');
if (decoded.length < 32) {
@@ -100,7 +94,6 @@ function buildRelayUrl({bucket, key, uploadId, partNumber, contentType, maxBytes
export async function resolveUploadRelayDecision(clientIp: string | undefined | null): Promise<UploadRelayDecision> {
const relayConfig = Config.mediaProxy.uploadRelay;
if (!clientIp) {
assertRelaySecretConfigured(relayConfig);
return relayConfig;
}
let countryCode: string | null = null;
@@ -109,14 +102,12 @@ export async function resolveUploadRelayDecision(clientIp: string | undefined |
countryCode = geo.countryCode;
} catch (error) {
logger.warn({clientIp, error}, 'geoip lookup failed for upload relay decision; using upload relay');
assertRelaySecretConfigured(relayConfig);
return relayConfig;
}
const keepDirectCountries = new Set(relayConfig.keepDirectCountries.map((code) => code.toUpperCase()));
if (countryCode && keepDirectCountries.has(countryCode.toUpperCase())) {
return null;
}
assertRelaySecretConfigured(relayConfig);
return relayConfig;
}
@@ -8,7 +8,6 @@ import type {Hono} from 'hono';
import {Config} from '../Config';
import type {GifService} from '../gif/GifService';
import type {IGifProvider} from '../gif/IGifProvider';
import type {LimitConfigService} from '../limits/LimitConfigService';
import {RateLimitMiddleware} from '../middleware/RateLimitMiddleware';
import {OpenAPI} from '../middleware/ResponseTypeMiddleware';
import {RateLimitConfigs} from '../RateLimitConfig';
@@ -96,8 +95,7 @@ export function InstanceController(app: Hono<HonoEnv>) {
async (ctx) => {
ctx.header('Access-Control-Allow-Origin', '*');
const gifService = ctx.get('gifService') as GifService | undefined;
const limitConfigService = ctx.get('limitConfigService') as LimitConfigService | undefined;
const limits = limitConfigService?.getConfigWireFormat();
const limits = ctx.get('limitConfigService').getConfigWireFormat();
const sso = await ctx.get('ssoService').getPublicStatus();
const instanceConfigRepository = ctx.get('instanceConfigRepository');
const [registration, community, services, appPublicConfig, captcha, email] = await Promise.all([
@@ -108,9 +106,6 @@ export function InstanceController(app: Hono<HonoEnv>) {
instanceConfigRepository.getEffectiveCaptchaConfig(),
instanceConfigRepository.getEffectiveEmailConfig(),
]);
if (!limits) {
throw new Error('limit_config_service is not bound');
}
const response = buildDiscoveryResponse(
buildDiscoveryStaticInput(
gifService,