From adab646d1e0ac793219a468bd7ec914fdae9776d Mon Sep 17 00:00:00 2001 From: Hampus Date: Sat, 12 Sep 2026 00:19:44 +0200 Subject: [PATCH] fix(schema): preserve WebAuthn payloads and align fixtures (#2710) --- fluxer_admin/tests/parity.rs | 26 ++++ .../parity/fixtures/api/lookup_guild.json | 7 +- .../parity/fixtures/api/search_guilds.json | 6 +- fluxer_api/src/api/Validator.ts | 17 +- .../api/auth/tests/WebAuthnValidation.test.ts | 145 ++++++++++++++++++ packages/openapi/vitest.config.ts | 1 + packages/schema/src/SchemaMetadata.ts | 1 + .../src/domains/auth/WebAuthnSchemas.ts | 5 +- 8 files changed, 193 insertions(+), 15 deletions(-) create mode 100644 fluxer_api/src/api/auth/tests/WebAuthnValidation.test.ts diff --git a/fluxer_admin/tests/parity.rs b/fluxer_admin/tests/parity.rs index 3b84fa91f..242a11aef 100644 --- a/fluxer_admin/tests/parity.rs +++ b/fluxer_admin/tests/parity.rs @@ -3,6 +3,7 @@ #[path = "parity/mod.rs"] mod parity_support; +use fluxer_admin::api::generated::types::{LookupGuildResponse, SearchGuildsResponse}; use parity_support::{ TEST_ACCESS_TOKEN, TEST_ADMIN_SECRET, TEST_ADMIN_USER_ID, api_fixtures, capture, html_normalizer, rust_server, @@ -48,6 +49,31 @@ fn html_normalizer_allows_intentional_rust_markup_fixes() { ); } +#[test] +fn guild_search_fixture_matches_the_generated_response_contract() { + let response: SearchGuildsResponse = + serde_json::from_str(include_str!("parity/fixtures/api/search_guilds.json")) + .expect("guild search fixture must match the generated response contract"); + assert_eq!(response.guilds.len(), 1); + let guild = &response.guilds[0]; + assert_eq!(guild.name, "Parity Guild"); + assert_eq!(guild.content_warning_level.as_deref(), Some(&0)); +} + +#[test] +fn guild_lookup_fixture_matches_the_generated_response_contract() { + let response: LookupGuildResponse = + serde_json::from_str(include_str!("parity/fixtures/api/lookup_guild.json")) + .expect("guild lookup fixture must match the generated response contract"); + let guild = response + .guild + .expect("guild lookup fixture must contain a guild"); + assert_eq!(String::from(guild.name), "Parity Guild"); + assert_eq!(guild.content_warning_level.as_deref(), Some(&0)); + assert_eq!(guild.channels.len(), 1); + assert_eq!(guild.channels[0].content_warning_level.as_deref(), Some(&0)); +} + #[tokio::test(flavor = "multi_thread")] async fn rust_admin_fixture_routes_cover_default_protected_routes() -> Result<(), Box> { let api_server = api_fixtures::ApiFixtureServer::start_default() diff --git a/fluxer_admin/tests/parity/fixtures/api/lookup_guild.json b/fluxer_admin/tests/parity/fixtures/api/lookup_guild.json index 4ca8edef1..53f8cfab6 100644 --- a/fluxer_admin/tests/parity/fixtures/api/lookup_guild.json +++ b/fluxer_admin/tests/parity/fixtures/api/lookup_guild.json @@ -16,7 +16,7 @@ "mfa_level": 0, "nsfw_level": 0, "nsfw": false, - "content_warning_level": null, + "content_warning_level": 0, "content_warning_text": null, "explicit_content_filter": 2, "default_message_notifications": 1, @@ -36,7 +36,7 @@ "parent_id": null, "nsfw": false, "nsfw_override": null, - "content_warning_level": null, + "content_warning_level": 0, "content_warning_text": null, "url": null } @@ -51,7 +51,6 @@ "hoist": false, "mentionable": false } - ], - "description": "Guild used by admin parity fixtures." + ] } } diff --git a/fluxer_admin/tests/parity/fixtures/api/search_guilds.json b/fluxer_admin/tests/parity/fixtures/api/search_guilds.json index e86782013..4e280cc4f 100644 --- a/fluxer_admin/tests/parity/fixtures/api/search_guilds.json +++ b/fluxer_admin/tests/parity/fixtures/api/search_guilds.json @@ -13,10 +13,8 @@ "features": ["COMMUNITY", "DISCOVERABLE"], "nsfw_level": 0, "nsfw": false, - "content_warning_level": null, - "content_warning_text": null, - "description": "Guild used by admin parity fixtures.", - "vanity_url_code": "parity" + "content_warning_level": 0, + "content_warning_text": null } ], "total": 1 diff --git a/fluxer_api/src/api/Validator.ts b/fluxer_api/src/api/Validator.ts index e08c45461..c80916add 100644 --- a/fluxer_api/src/api/Validator.ts +++ b/fluxer_api/src/api/Validator.ts @@ -7,9 +7,10 @@ import { type LocalizedValidationError, } from '@fluxer/errors/src/domains/core/InputValidationError'; import type {ValidationError} from '@fluxer/errors/src/domains/core/ValidationError'; +import {schemaMetadata} from '@fluxer/schema/src/SchemaMetadata'; import type {Context, Env, Input, MiddlewareHandler, TypedResponse, ValidationTargets} from 'hono'; import {getCookie} from 'hono/cookie'; -import type {core, input, output, ZodSafeParseResult, ZodType} from 'zod'; +import {type core, type input, type output, ZodObject, ZodOptional, type ZodSafeParseResult, type ZodType} from 'zod'; import {requireRequestJsonBody} from './utils/RequestJsonBody'; import {initializeFluxerErrorMap} from './ZodErrorMap'; @@ -44,13 +45,19 @@ function extractVariablesFromIssue(issue: core.$ZodIssue): Record convertEmptyValuesToNull(item, false)); + if (Array.isArray(obj)) return obj.map((item) => convertEmptyValuesToNull(item, undefined, false)); if (obj !== null && typeof obj === 'object') { if (isEmptyObject(obj) && !isRoot) return null; + const shape = schema instanceof ZodObject ? schema.shape : undefined; const processed = Object.fromEntries( - Object.entries(obj).map(([key, value]) => [key, convertEmptyValuesToNull(value, false)]), + Object.entries(obj).map(([key, value]) => [ + key, + convertEmptyValuesToNull(value, shape && Object.hasOwn(shape, key) ? shape[key] : undefined, false), + ]), ); if (!isRoot && Object.values(processed).every((value) => value === null)) return null; return processed; @@ -192,7 +199,7 @@ export const Validator = < if (options.pre) { value = await options.pre(value, c, target); } - const transformedValue = convertEmptyValuesToNull(value); + const transformedValue = convertEmptyValuesToNull(value, schema); const result = await schema.safeParseAsync(transformedValue); if (options.post) { const hookResult = await options.post({...result, target}, c); diff --git a/fluxer_api/src/api/auth/tests/WebAuthnValidation.test.ts b/fluxer_api/src/api/auth/tests/WebAuthnValidation.test.ts new file mode 100644 index 000000000..d24a0d8b5 --- /dev/null +++ b/fluxer_api/src/api/auth/tests/WebAuthnValidation.test.ts @@ -0,0 +1,145 @@ +import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError'; +import { + WebAuthnAuthenticateRequest, + WebAuthnMfaRequest, + WebAuthnRegisterRequest, +} from '@fluxer/schema/src/domains/auth/AuthSchemas'; +import { + UserSettingsUpdateRequest, + UserUpdateWithVerificationRequest, +} from '@fluxer/schema/src/domains/user/UserRequestSchemas'; +import {Hono} from 'hono'; +import {describe, expect, it} from 'vitest'; +import type {ZodType} from 'zod'; +import {Validator} from '../../Validator'; + +const credential = { + id: 'credential-id', + rawId: 'credential-id', + type: 'public-key', + clientExtensionResults: {}, +}; +const registration = { + ...credential, + response: {clientDataJSON: 'client-data', attestationObject: 'attestation'}, +}; +const authentication = { + ...credential, + response: {clientDataJSON: '', authenticatorData: '', signature: '', userHandle: ''}, +}; + +async function validateRequest(schema: ZodType, body: unknown): Promise { + const app = new Hono(); + app.onError((error, ctx) => { + if (error instanceof InputValidationError) { + return ctx.json({errors: error.getLocalizedErrors()}, 400); + } + throw error; + }); + app.post('/validate', Validator('json', schema), (ctx) => Response.json(ctx.req.valid('json'))); + return app.request('/validate', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(body), + }); +} + +describe.each([ + { + name: 'registration', + schema: WebAuthnRegisterRequest, + field: 'response', + response: registration, + body: {challenge: 'challenge', name: 'Passkey'}, + challenge: 'challenge', + }, + { + name: 'authentication', + schema: WebAuthnAuthenticateRequest, + field: 'response', + response: authentication, + body: {challenge: 'challenge'}, + challenge: 'challenge', + }, + { + name: 'MFA login', + schema: WebAuthnMfaRequest, + field: 'response', + response: authentication, + body: {challenge: 'challenge', ticket: 'ticket'}, + challenge: 'challenge', + }, + { + name: 'sudo account update', + schema: UserUpdateWithVerificationRequest, + field: 'webauthn_response', + response: authentication, + body: {mfa_method: 'webauthn', webauthn_challenge: 'challenge'}, + challenge: 'webauthn_challenge', + }, +])('$name validation', ({schema, field, response, body, challenge}) => { + it.each([ + {name: 'empty results', value: {}}, + { + name: 'nested empty values and unknown extensions', + value: {credProps: {}, appid: false, futureExtension: {empty: {}, text: '', entries: [{}, '', null, 0]}}, + }, + ])('preserves $name', async ({value: clientExtensionResults}) => { + const input = {...body, [field]: {...response, clientExtensionResults}}; + const result = await validateRequest(schema, input); + expect(result.status).toBe(200); + expect(await result.json()).toEqual(input); + }); + + it.each([ + {name: 'missing results', value: undefined, path: ''}, + {name: 'null results', value: null, path: ''}, + {name: 'array results', value: [], path: ''}, + {name: 'non-boolean appid', value: {appid: 'false'}, path: '.appid'}, + {name: 'null credential properties', value: {credProps: null}, path: '.credProps'}, + ])('rejects $name', async ({value, path}) => { + const result = await validateRequest(schema, {...body, [field]: {...response, clientExtensionResults: value}}); + expect(result.status).toBe(400); + expect(await result.json()).toMatchObject({errors: [{path: `${field}.clientExtensionResults${path}`}]}); + }); + + it.each([ + {key: 'id', value: undefined}, + {key: 'rawId', value: undefined}, + {key: 'type', value: 'invalid'}, + {key: 'response', value: null}, + ])('rejects malformed credential $key', async ({key, value}) => { + const result = await validateRequest(schema, {...body, [field]: {...response, [key]: value}}); + expect(result.status).toBe(400); + expect(await result.json()).toMatchObject({errors: [{path: `${field}.${key}`}]}); + }); + + it('still validates the challenge outside the credential', async () => { + const result = await validateRequest(schema, {...body, [field]: response, [challenge]: ''}); + expect(result.status).toBe(400); + expect(await result.json()).toMatchObject({errors: [{path: challenge}]}); + }); +}); + +it('retains profile clearing beside a WebAuthn sudo response', async () => { + const result = await validateRequest(UserUpdateWithVerificationRequest, { + webauthn_response: authentication, + avatar: '', + banner: '', + bio: '', + }); + expect(result.status).toBe(200); + expect(await result.json()).toEqual({webauthn_response: authentication, avatar: null, banner: null, bio: null}); +}); + +it('retains empty-object clearing outside WebAuthn credentials', async () => { + const result = await validateRequest(UserSettingsUpdateRequest, {custom_status: {}}); + expect(result.status).toBe(200); + expect(await result.json()).toEqual({custom_status: null}); +}); + +it.each(['__proto__', 'constructor', 'toString'])('does not treat unknown key %s as a schema field', async (field) => { + const result = await validateRequest(UserUpdateWithVerificationRequest, {[field]: {}, avatar: ''}); + expect(result.status).toBe(200); + expect(await result.json()).toEqual({avatar: null}); +}); diff --git a/packages/openapi/vitest.config.ts b/packages/openapi/vitest.config.ts index dc8021799..742ea2985 100644 --- a/packages/openapi/vitest.config.ts +++ b/packages/openapi/vitest.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ include: ['**/*.{test,spec}.{ts,tsx}'], exclude: ['node_modules', 'dist'], testTimeout: 60000, + hookTimeout: 60000, coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], diff --git a/packages/schema/src/SchemaMetadata.ts b/packages/schema/src/SchemaMetadata.ts index 643f10440..26be3e867 100644 --- a/packages/schema/src/SchemaMetadata.ts +++ b/packages/schema/src/SchemaMetadata.ts @@ -18,6 +18,7 @@ interface SchemaMetadata { openEnum?: boolean; bitflagValues?: Array; format?: string; + preserveEmptyValues?: boolean; } export const schemaMetadata = z.registry(); diff --git a/packages/schema/src/domains/auth/WebAuthnSchemas.ts b/packages/schema/src/domains/auth/WebAuthnSchemas.ts index 80348f49f..35fcbcff4 100644 --- a/packages/schema/src/domains/auth/WebAuthnSchemas.ts +++ b/packages/schema/src/domains/auth/WebAuthnSchemas.ts @@ -1,3 +1,4 @@ +import {schemaMetadata} from '@fluxer/schema/src/SchemaMetadata'; import type { AuthenticationResponseJSON, PublicKeyCredentialRequestOptionsJSON, @@ -26,7 +27,7 @@ export const WebAuthnAuthenticationResponse = WebAuthnCredentialBase.extend({ signature: z.string(), userHandle: z.string().optional(), }), -}) satisfies z.ZodType; +}).register(schemaMetadata, {preserveEmptyValues: true}) satisfies z.ZodType; export const WebAuthnRegistrationResponse = WebAuthnCredentialBase.extend({ response: z.looseObject({ @@ -37,7 +38,7 @@ export const WebAuthnRegistrationResponse = WebAuthnCredentialBase.extend({ publicKeyAlgorithm: z.number().int().optional(), publicKey: z.string().optional(), }), -}) satisfies z.ZodType; +}).register(schemaMetadata, {preserveEmptyValues: true}) satisfies z.ZodType; export const WebAuthnAuthenticationOptions = z.looseObject({ challenge: z.string(),