mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
fix(schema): preserve WebAuthn payloads and align fixtures (#2710)
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
#[path = "parity/mod.rs"]
|
#[path = "parity/mod.rs"]
|
||||||
mod parity_support;
|
mod parity_support;
|
||||||
|
|
||||||
|
use fluxer_admin::api::generated::types::{LookupGuildResponse, SearchGuildsResponse};
|
||||||
use parity_support::{
|
use parity_support::{
|
||||||
TEST_ACCESS_TOKEN, TEST_ADMIN_SECRET, TEST_ADMIN_USER_ID, api_fixtures, capture,
|
TEST_ACCESS_TOKEN, TEST_ADMIN_SECRET, TEST_ADMIN_USER_ID, api_fixtures, capture,
|
||||||
html_normalizer, rust_server,
|
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")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn rust_admin_fixture_routes_cover_default_protected_routes() -> Result<(), Box<dyn Error>> {
|
async fn rust_admin_fixture_routes_cover_default_protected_routes() -> Result<(), Box<dyn Error>> {
|
||||||
let api_server = api_fixtures::ApiFixtureServer::start_default()
|
let api_server = api_fixtures::ApiFixtureServer::start_default()
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"mfa_level": 0,
|
"mfa_level": 0,
|
||||||
"nsfw_level": 0,
|
"nsfw_level": 0,
|
||||||
"nsfw": false,
|
"nsfw": false,
|
||||||
"content_warning_level": null,
|
"content_warning_level": 0,
|
||||||
"content_warning_text": null,
|
"content_warning_text": null,
|
||||||
"explicit_content_filter": 2,
|
"explicit_content_filter": 2,
|
||||||
"default_message_notifications": 1,
|
"default_message_notifications": 1,
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
"parent_id": null,
|
"parent_id": null,
|
||||||
"nsfw": false,
|
"nsfw": false,
|
||||||
"nsfw_override": null,
|
"nsfw_override": null,
|
||||||
"content_warning_level": null,
|
"content_warning_level": 0,
|
||||||
"content_warning_text": null,
|
"content_warning_text": null,
|
||||||
"url": null
|
"url": null
|
||||||
}
|
}
|
||||||
@@ -51,7 +51,6 @@
|
|||||||
"hoist": false,
|
"hoist": false,
|
||||||
"mentionable": false
|
"mentionable": false
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"description": "Guild used by admin parity fixtures."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,8 @@
|
|||||||
"features": ["COMMUNITY", "DISCOVERABLE"],
|
"features": ["COMMUNITY", "DISCOVERABLE"],
|
||||||
"nsfw_level": 0,
|
"nsfw_level": 0,
|
||||||
"nsfw": false,
|
"nsfw": false,
|
||||||
"content_warning_level": null,
|
"content_warning_level": 0,
|
||||||
"content_warning_text": null,
|
"content_warning_text": null
|
||||||
"description": "Guild used by admin parity fixtures.",
|
|
||||||
"vanity_url_code": "parity"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"total": 1
|
"total": 1
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import {
|
|||||||
type LocalizedValidationError,
|
type LocalizedValidationError,
|
||||||
} from '@fluxer/errors/src/domains/core/InputValidationError';
|
} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||||
import type {ValidationError} from '@fluxer/errors/src/domains/core/ValidationError';
|
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 type {Context, Env, Input, MiddlewareHandler, TypedResponse, ValidationTargets} from 'hono';
|
||||||
import {getCookie} from 'hono/cookie';
|
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 {requireRequestJsonBody} from './utils/RequestJsonBody';
|
||||||
import {initializeFluxerErrorMap} from './ZodErrorMap';
|
import {initializeFluxerErrorMap} from './ZodErrorMap';
|
||||||
|
|
||||||
@@ -44,13 +45,19 @@ function extractVariablesFromIssue(issue: core.$ZodIssue): Record<string, unknow
|
|||||||
return {name: fieldName};
|
return {name: fieldName};
|
||||||
}
|
}
|
||||||
|
|
||||||
function convertEmptyValuesToNull(obj: unknown, isRoot = true): unknown {
|
function convertEmptyValuesToNull(obj: unknown, schema?: core.$ZodType, isRoot = true): unknown {
|
||||||
|
while (schema instanceof ZodOptional) schema = schema.unwrap();
|
||||||
|
if (schema && schemaMetadata.get(schema)?.preserveEmptyValues) return obj;
|
||||||
if (typeof obj === 'string' && obj === '') return null;
|
if (typeof obj === 'string' && obj === '') return null;
|
||||||
if (Array.isArray(obj)) return obj.map((item) => convertEmptyValuesToNull(item, false));
|
if (Array.isArray(obj)) return obj.map((item) => convertEmptyValuesToNull(item, undefined, false));
|
||||||
if (obj !== null && typeof obj === 'object') {
|
if (obj !== null && typeof obj === 'object') {
|
||||||
if (isEmptyObject(obj) && !isRoot) return null;
|
if (isEmptyObject(obj) && !isRoot) return null;
|
||||||
|
const shape = schema instanceof ZodObject ? schema.shape : undefined;
|
||||||
const processed = Object.fromEntries(
|
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;
|
if (!isRoot && Object.values(processed).every((value) => value === null)) return null;
|
||||||
return processed;
|
return processed;
|
||||||
@@ -192,7 +199,7 @@ export const Validator = <
|
|||||||
if (options.pre) {
|
if (options.pre) {
|
||||||
value = await options.pre(value, c, target);
|
value = await options.pre(value, c, target);
|
||||||
}
|
}
|
||||||
const transformedValue = convertEmptyValuesToNull(value);
|
const transformedValue = convertEmptyValuesToNull(value, schema);
|
||||||
const result = await schema.safeParseAsync(transformedValue);
|
const result = await schema.safeParseAsync(transformedValue);
|
||||||
if (options.post) {
|
if (options.post) {
|
||||||
const hookResult = await options.post({...result, target}, c);
|
const hookResult = await options.post({...result, target}, c);
|
||||||
|
|||||||
@@ -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<Response> {
|
||||||
|
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});
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ export default defineConfig({
|
|||||||
include: ['**/*.{test,spec}.{ts,tsx}'],
|
include: ['**/*.{test,spec}.{ts,tsx}'],
|
||||||
exclude: ['node_modules', 'dist'],
|
exclude: ['node_modules', 'dist'],
|
||||||
testTimeout: 60000,
|
testTimeout: 60000,
|
||||||
|
hookTimeout: 60000,
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: 'v8',
|
provider: 'v8',
|
||||||
reporter: ['text', 'json', 'html'],
|
reporter: ['text', 'json', 'html'],
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface SchemaMetadata {
|
|||||||
openEnum?: boolean;
|
openEnum?: boolean;
|
||||||
bitflagValues?: Array<BitflagEntry>;
|
bitflagValues?: Array<BitflagEntry>;
|
||||||
format?: string;
|
format?: string;
|
||||||
|
preserveEmptyValues?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const schemaMetadata = z.registry<SchemaMetadata>();
|
export const schemaMetadata = z.registry<SchemaMetadata>();
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import {schemaMetadata} from '@fluxer/schema/src/SchemaMetadata';
|
||||||
import type {
|
import type {
|
||||||
AuthenticationResponseJSON,
|
AuthenticationResponseJSON,
|
||||||
PublicKeyCredentialRequestOptionsJSON,
|
PublicKeyCredentialRequestOptionsJSON,
|
||||||
@@ -26,7 +27,7 @@ export const WebAuthnAuthenticationResponse = WebAuthnCredentialBase.extend({
|
|||||||
signature: z.string(),
|
signature: z.string(),
|
||||||
userHandle: z.string().optional(),
|
userHandle: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
}) satisfies z.ZodType<AuthenticationResponseJSON>;
|
}).register(schemaMetadata, {preserveEmptyValues: true}) satisfies z.ZodType<AuthenticationResponseJSON>;
|
||||||
|
|
||||||
export const WebAuthnRegistrationResponse = WebAuthnCredentialBase.extend({
|
export const WebAuthnRegistrationResponse = WebAuthnCredentialBase.extend({
|
||||||
response: z.looseObject({
|
response: z.looseObject({
|
||||||
@@ -37,7 +38,7 @@ export const WebAuthnRegistrationResponse = WebAuthnCredentialBase.extend({
|
|||||||
publicKeyAlgorithm: z.number().int().optional(),
|
publicKeyAlgorithm: z.number().int().optional(),
|
||||||
publicKey: z.string().optional(),
|
publicKey: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
}) satisfies z.ZodType<RegistrationResponseJSON>;
|
}).register(schemaMetadata, {preserveEmptyValues: true}) satisfies z.ZodType<RegistrationResponseJSON>;
|
||||||
|
|
||||||
export const WebAuthnAuthenticationOptions = z.looseObject({
|
export const WebAuthnAuthenticationOptions = z.looseObject({
|
||||||
challenge: z.string(),
|
challenge: z.string(),
|
||||||
|
|||||||
Reference in New Issue
Block a user