diff --git a/fluxer_api/src/api/database/CassandraParamGuard.test.ts b/fluxer_api/src/api/database/CassandraParamGuard.test.ts new file mode 100644 index 000000000..714af0f3a --- /dev/null +++ b/fluxer_api/src/api/database/CassandraParamGuard.test.ts @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {describe, expect, it} from 'vitest'; +import {assertNoUndefinedParams} from './CassandraTypes'; + +function messageFor(path: string): string { + return `Undefined value at "${path}". This project forbids undefined in Cassandra params; use null explicitly or omit the column via PATCH.`; +} + +function guardError(params: Record): string { + try { + assertNoUndefinedParams(params); + } catch (error) { + return (error as Error).message; + } + throw new Error('Expected assertNoUndefinedParams to throw'); +} + +describe('assertNoUndefinedParams', () => { + it('accepts a realistic message row without throwing', () => { + expect(() => + assertNoUndefinedParams({ + message_id: 123n, + channel_id: 456n, + content: 'hello', + edited_at: null, + pinned: false, + created_at: new Date(0), + blob: Buffer.from('x'), + mention_users: [1n, 2n, 3n], + nsfw_emojis: new Set(['a', 'b']), + reactions: new Map([['a', 1]]), + embeds: {title: 'a', fields: [{name: 'n', value: 'v'}], footer: {text: null}}, + }), + ).not.toThrow(); + }); + + it('reports the dotted path of a top level undefined param', () => { + expect(guardError({user_id: undefined})).toBe(messageFor(':user_id')); + }); + + it('reports the dotted path of an undefined array element', () => { + expect(guardError({mention_users: [1n, undefined, 3n]})).toBe(messageFor(':mention_users[1]')); + }); + + it('reports the dotted path of an undefined set member', () => { + expect(guardError({nsfw_emojis: new Set(['a', undefined])})).toBe(messageFor(':nsfw_emojis{set:1}')); + }); + + it('reports the dotted path of an undefined map key', () => { + expect(guardError({reactions: new Map([['a', 1] as const, [undefined, 2] as const])})).toBe( + messageFor(':reactions{mapKey:1}'), + ); + }); + + it('reports the dotted path of an undefined map value', () => { + expect( + guardError({ + reactions: new Map([ + ['a', 1], + ['b', undefined], + ]), + }), + ).toBe(messageFor(':reactions{mapVal:1}')); + }); + + it('reports the dotted path of an undefined nested object property', () => { + expect(guardError({embeds: {footer: {icon_url: undefined}}})).toBe(messageFor(':embeds.footer.icon_url')); + }); + + it('reports the dotted path of an undefined value nested through mixed containers', () => { + expect(guardError({embeds: [{fields: new Set([new Map([['inline', undefined]])])}]})).toBe( + messageFor(':embeds[0].fields{set:0}{mapVal:0}'), + ); + }); + + it('reports the first undefined in parameter order', () => { + expect(guardError({a: 1, b: [undefined], c: undefined})).toBe(messageFor(':b[0]')); + }); +}); diff --git a/fluxer_api/src/api/database/CassandraTypes.ts b/fluxer_api/src/api/database/CassandraTypes.ts index 5ccc35b80..3ee5e3fdd 100644 --- a/fluxer_api/src/api/database/CassandraTypes.ts +++ b/fluxer_api/src/api/database/CassandraTypes.ts @@ -254,6 +254,41 @@ export function isUnsafePreparedStatement(query: string): boolean { return tokens.length >= 2 && tokens[0].toLowerCase() === 'select' && tokens[1] === '*'; } +function hasUndefinedDeep(value: unknown): boolean { + if (value === undefined) return true; + if (value === null) return false; + const t = typeof value; + if (t === 'string' || t === 'number' || t === 'bigint' || t === 'boolean') return false; + if (value instanceof Date) return false; + if (value instanceof Buffer) return false; + if (t === 'object' && value.constructor?.name === 'LocalDate') return false; + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + if (hasUndefinedDeep(value[i])) return true; + } + return false; + } + if (value instanceof Set) { + for (const v of value.values()) { + if (hasUndefinedDeep(v)) return true; + } + return false; + } + if (value instanceof Map) { + for (const [k, v] of value.entries()) { + if (hasUndefinedDeep(k) || hasUndefinedDeep(v)) return true; + } + return false; + } + if (t === 'object') { + const keys = Object.keys(value as Record); + for (let i = 0; i < keys.length; i++) { + if (hasUndefinedDeep((value as Record)[keys[i]!])) return true; + } + } + return false; +} + function assertNoUndefinedDeep(value: unknown, path: string): void { if (value === undefined) { throw new Error( @@ -297,8 +332,13 @@ function assertNoUndefinedDeep(value: unknown, path: string): void { } export function assertNoUndefinedParams(params: Record): void { - for (const [k, v] of Object.entries(params)) { - assertNoUndefinedDeep(v, `:${k}`); + const keys = Object.keys(params); + for (let i = 0; i < keys.length; i++) { + const k = keys[i]!; + const v = params[k]; + if (hasUndefinedDeep(v)) { + assertNoUndefinedDeep(v, `:${k}`); + } } }