test(server): isHoneypotTrap helper + publicResults strip-text contract

§3.F (subset) of docs/plans/architecture-improvements.md.

Honeypot:

- Extract the `body.company && body.company.length > 0` check that was
  inlined in /submit and /posts into isHoneypotTrap(body) in feedback-pure.
  Same rule, two callers — locks the trap behaviour in one place. 5
  cases: missing / empty-string / null / non-empty / single-space all
  classified as expected.

publicResults:

- Extend results.test.ts: 3 cases proving short_text + long_text answers
  are stripped from publicResults output while counts are preserved and
  scale/numeric questions pass through untouched. The participant page's
  "live results after submit" path leans on this — without the strip,
  free-text answers (which can carry PII or contributor identity) would
  leak to anonymous participants.

- Also asserts publicResults does not mutate the input (JSON-stringify
  round-trip).

54 tests pass across 5 files. svelte-check + bun run build clean.
This commit is contained in:
mAi
2026-05-07 19:50:16 +02:00
parent 993fc84e19
commit 6888ca5eab
6 changed files with 116 additions and 5 deletions

View File

@@ -1,5 +1,11 @@
import { describe, test, expect } from 'bun:test';
import { generateSlug, parseFormDefinition, clampUserAgent, lookupPlan } from './feedback-pure';
import {
generateSlug,
parseFormDefinition,
clampUserAgent,
lookupPlan,
isHoneypotTrap,
} from './feedback-pure';
describe('generateSlug', () => {
test('returns 32 characters', () => {
@@ -121,3 +127,28 @@ describe('lookupPlan', () => {
]);
});
});
describe('isHoneypotTrap', () => {
// The honeypot field `company` is hidden from real users via .fb-honeypot
// (off-screen + tabindex="-1" + aria-hidden). Spam bots fill every field
// they find. Both /submit and /posts call this and reply with a fake-ok
// response when triggered, so the bot doesn't learn the trap is rigged.
test('missing company → not a trap', () => {
expect(isHoneypotTrap({})).toBe(false);
});
test('empty-string company → not a trap', () => {
expect(isHoneypotTrap({ company: '' })).toBe(false);
});
test('null company → not a trap', () => {
expect(isHoneypotTrap({ company: null })).toBe(false);
});
test('any non-empty company → trap', () => {
expect(isHoneypotTrap({ company: 'Acme Corp' })).toBe(true);
expect(isHoneypotTrap({ company: ' ' })).toBe(true);
expect(isHoneypotTrap({ company: 'a' })).toBe(true);
});
});

View File

@@ -90,3 +90,15 @@ export function lookupPlan(by: LookupKeys): LookupStrategy[] {
}
return plan;
}
/**
* Honeypot trap test — the participant form + chat-compose include a hidden
* `company` field that real users never see. Anonymous spam bots fill every
* field they find; if `company` is non-empty, drop the request silently with
* a fake-success response.
*
* Used by both /api/public/feedback/[slug]/submit and /posts.
*/
export function isHoneypotTrap(body: { company?: string | null }): boolean {
return typeof body.company === 'string' && body.company.length > 0;
}

View File

@@ -15,6 +15,7 @@ export {
parseFormDefinition,
clampUserAgent,
lookupPlan,
isHoneypotTrap,
} from './feedback-pure';
export type {

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test';
import { aggregateResults, type SubmissionRow } from './results';
import { aggregateResults, publicResults, type SubmissionRow } from './results';
import type { FeedbackFormDefinition } from '../schemas';
const baseForm: FeedbackFormDefinition = {
@@ -104,3 +104,69 @@ describe('aggregateResults — date_ranked_choice', () => {
}
});
});
describe('publicResults', () => {
// Free-text answers may contain PII or contributor identity. The participant
// page can show "live results" after submitting, but text answers must never
// leak through that endpoint. Lock the contract here.
const formWithText: FeedbackFormDefinition = {
questions: [
{ id: 'short', label: 'Short', type: 'short_text' },
{ id: 'long', label: 'Long', type: 'long_text' },
{ id: 'rate', label: 'Rate', type: 'scale', min: 1, max: 5 },
],
};
function textSub(answers: Record<string, unknown>): SubmissionRow {
return { answers, form_snapshot: formWithText, created_at: '2026-05-06T10:00:00Z' };
}
test('strips short_text and long_text answers, keeps counts', () => {
const full = aggregateResults(formWithText, [
textSub({ short: 'secret name', long: 'long secret', rate: 4 }),
textSub({ short: 'another', long: 'more text', rate: 5 }),
]);
// Sanity: full results contain the text values
const fullShort = full.questions.find((q) => q.id === 'short')!.stats;
if (fullShort.type !== 'short_text') throw new Error('narrow');
expect(fullShort.count).toBe(2);
expect(fullShort.answers).toHaveLength(2);
const pub = publicResults(full);
const pubShort = pub.questions.find((q) => q.id === 'short')!.stats;
if (pubShort.type !== 'short_text') throw new Error('narrow');
expect(pubShort.count).toBe(2); // count preserved
expect(pubShort.answers).toEqual([]); // text stripped
const pubLong = pub.questions.find((q) => q.id === 'long')!.stats;
if (pubLong.type !== 'long_text') throw new Error('narrow');
expect(pubLong.count).toBe(2);
expect(pubLong.answers).toEqual([]);
// Non-text question untouched
const pubRate = pub.questions.find((q) => q.id === 'rate')!.stats;
if (pubRate.type !== 'scale') throw new Error('narrow');
expect(pubRate.count).toBe(2);
expect(pubRate.mean).toBe(4.5);
});
test('preserves total_submissions', () => {
const full = aggregateResults(formWithText, [
textSub({ short: 'a' }),
textSub({ short: 'b' }),
textSub({ short: 'c' }),
]);
const pub = publicResults(full);
expect(pub.total_submissions).toBe(3);
});
test('does not mutate the input', () => {
const full = aggregateResults(formWithText, [textSub({ short: 'kept' })]);
const before = JSON.stringify(full);
publicResults(full);
expect(JSON.stringify(full)).toBe(before);
});
});

View File

@@ -6,7 +6,7 @@ import type { RequestHandler } from './$types';
import { json } from '$lib/server/response';
import { parseBody, handleApiError, badRequest, notFound } from '$lib/server/errors';
import { FeedbackPostSchema } from '$lib/schemas';
import { getInstanceBySlug, RATE_LIMIT, clampUserAgent } from '$lib/server/feedback';
import { getInstanceBySlug, RATE_LIMIT, clampUserAgent, isHoneypotTrap } from '$lib/server/feedback';
import { checkRate } from '$lib/server/rate-limit';
import { fdb } from '$lib/server/fdb';
@@ -69,7 +69,7 @@ export const POST: RequestHandler = async ({ params, request, getClientAddress }
const body = await parseBody(request, FeedbackPostSchema);
if (body.company && body.company.length > 0) return json({ ok: true });
if (isHoneypotTrap(body)) return json({ ok: true });
const ip = getClientAddress();
const key = `fb:post:${ip}:${params.slug}`;

View File

@@ -10,6 +10,7 @@ import {
RATE_LIMIT,
clampUserAgent,
findExistingSubmission,
isHoneypotTrap,
} from '$lib/server/feedback';
import { checkRate } from '$lib/server/rate-limit';
import { fdb } from '$lib/server/fdb';
@@ -23,7 +24,7 @@ export const POST: RequestHandler = async ({ params, request, getClientAddress }
const body = await parseBody(request, FeedbackSubmissionSchema);
if (body.company && body.company.length > 0) return json({ ok: true });
if (isHoneypotTrap(body)) return json({ ok: true });
const ip = getClientAddress();
const key = `fb:submit:${ip}:${params.slug}`;