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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export {
|
||||
parseFormDefinition,
|
||||
clampUserAgent,
|
||||
lookupPlan,
|
||||
isHoneypotTrap,
|
||||
} from './feedback-pure';
|
||||
|
||||
export type {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
Reference in New Issue
Block a user