mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
fix(api): raise coded errors for prerequisites and bounds (#2502)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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': {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user