Files
API/test/feedback/submissions.service.test.ts
Paddy 17ca6399e0 Add Feedback domain module: public submission flow, admin CRUD, reporting
New /feedback API domain backed by its own FEEDBACK_DB, mirroring the
Calendar domain's router -> service -> DB pool layering:

- Public endpoints (no auth): eligible-events listing, event config,
  submission with honeypot + rate limiting (in-memory + DB backstop).
- Admin endpoints (session-header auth, reusing Calendar's users/sessions
  via a swappable feedback.auth.ts boundary): events/songs/questions CRUD,
  bulk reorder/assignment, aggregated reporting, CSV export.
- Schema in sql/feedback/001_init.sql (8 tables), applied and verified
  against the real FEEDBACK_DB.
- 64 Jest tests covering validation, auth, rate limiting, CSV escaping,
  and report aggregation (pure functions, no DB needed).

Includes fixes from a security review: path traversal defense doesn't
apply here (that's the frontend proxy, separate repo), but the
rate-limiter cluster does - recordSubmission now counts every processed
request (not just successful ones), the in-memory Map evicts empty
entries instead of growing unbounded, FEEDBACK_IP_SALT is required at
boot instead of silently degrading to unsalted hashing, and submission
answer/rating arrays are capped and de-duplicated to bound insert
amplification.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 23:32:22 +02:00

152 lines
6.2 KiB
TypeScript

import {MAX_ANSWER_ROWS, validateAnswers, validateGuestBook, validateNewsletter} from '../../src/models/feedback/public/submissions.service';
type QuestionLookup = Map<number, {eventQuestionId: number; questionId: number; type: 'SONG_PICK' | 'SONG_RATING' | 'FREE_TEXT'; label: string; position: number}>;
const songTitleById = new Map<number, string>([
[1, 'Abendlied'],
[2, 'Morgenlied']
]);
describe('validateAnswers', () => {
const questionsById: QuestionLookup = new Map([
[10, {eventQuestionId: 10, questionId: 100, type: 'SONG_PICK', label: 'Lieblingsstück?', position: 0}],
[11, {eventQuestionId: 11, questionId: 101, type: 'SONG_RATING', label: 'Bewertung', position: 1}],
[12, {eventQuestionId: 12, questionId: 102, type: 'FREE_TEXT', label: 'Sonstiges', position: 2}]
]);
it('produces a row for a valid SONG_PICK answer', () => {
const rows = validateAnswers([{eventQuestionId: 10, songId: 1}], questionsById, songTitleById);
expect(rows).toEqual([
{eventQuestionId: 10, questionId: 100, label: 'Lieblingsstück?', type: 'SONG_PICK', position: 0, songId: 1, songTitle: 'Abendlied', rating: null, text: null}
]);
});
it('ignores a SONG_PICK answer with an unknown songId', () => {
const rows = validateAnswers([{eventQuestionId: 10, songId: 999}], questionsById, songTitleById);
expect(rows).toHaveLength(0);
});
it('ignores an answer for an unknown eventQuestionId', () => {
const rows = validateAnswers([{eventQuestionId: 999, songId: 1}], questionsById, songTitleById);
expect(rows).toHaveLength(0);
});
it('produces one row per rated song for SONG_RATING, ignoring unknown songs', () => {
const rows = validateAnswers([
{eventQuestionId: 11, ratings: [{songId: 1, rating: 5}, {songId: 2, rating: 3}, {songId: 999, rating: 4}]}
], questionsById, songTitleById);
expect(rows).toHaveLength(2);
expect(rows.map(r => r.songId)).toEqual([1, 2]);
});
it('clamps ratings to the 1..5 range', () => {
const rows = validateAnswers([
{eventQuestionId: 11, ratings: [{songId: 1, rating: 9}, {songId: 2, rating: -3}]}
], questionsById, songTitleById);
expect(rows.find(r => r.songId === 1)?.rating).toBe(5);
expect(rows.find(r => r.songId === 2)?.rating).toBe(1);
});
it('an unrated song in a SONG_RATING block produces no row', () => {
const rows = validateAnswers([{eventQuestionId: 11, ratings: []}], questionsById, songTitleById);
expect(rows).toHaveLength(0);
});
it('trims FREE_TEXT and drops it if empty after trimming', () => {
const withText = validateAnswers([{eventQuestionId: 12, text: ' Danke für den Abend! '}], questionsById, songTitleById);
expect(withText[0].text).toBe('Danke für den Abend!');
const blank = validateAnswers([{eventQuestionId: 12, text: ' '}], questionsById, songTitleById);
expect(blank).toHaveLength(0);
});
it('caps FREE_TEXT at 5000 characters', () => {
const long = 'a'.repeat(6000);
const rows = validateAnswers([{eventQuestionId: 12, text: long}], questionsById, songTitleById);
expect(rows[0].text).toHaveLength(5000);
});
it('a fully empty answer set produces no rows (skipped questions produce no rows)', () => {
const rows = validateAnswers([], questionsById, songTitleById);
expect(rows).toHaveLength(0);
});
it('de-duplicates repeated ratings for the same song, keeping the last value', () => {
const rows = validateAnswers([
{eventQuestionId: 11, ratings: [{songId: 1, rating: 2}, {songId: 1, rating: 5}, {songId: 1, rating: 3}]}
], questionsById, songTitleById);
expect(rows).toHaveLength(1);
expect(rows[0].rating).toBe(3);
});
it('caps total generated rows at MAX_ANSWER_ROWS regardless of how many ratings are submitted', () => {
const massRatings = Array.from({length: MAX_ANSWER_ROWS + 500}, (_, i) => ({
songId: 1,
rating: (i % 5) + 1
}));
// Force distinct songIds so de-duplication alone can't be the thing capping the count.
const distinctSongTitleById = new Map<number, string>(
Array.from({length: MAX_ANSWER_ROWS + 500}, (_, i) => [i, `Song ${i}`])
);
const distinctRatings = massRatings.map((r, i) => ({songId: i, rating: r.rating}));
const rows = validateAnswers(
[{eventQuestionId: 11, ratings: distinctRatings}],
questionsById,
distinctSongTitleById
);
expect(rows.length).toBe(MAX_ANSWER_ROWS);
});
it('stops adding rows across multiple answers once the cap is reached', () => {
const distinctSongTitleById = new Map<number, string>(
Array.from({length: MAX_ANSWER_ROWS + 10}, (_, i) => [i, `Song ${i}`])
);
const answers = Array.from({length: MAX_ANSWER_ROWS + 10}, (_, i) => ({
eventQuestionId: 10,
songId: i
}));
// SONG_PICK only ever produces 0 or 1 row per answer entry, so this
// exercises the cap across many separate answers, not one big array.
const rows = validateAnswers(answers, questionsById, distinctSongTitleById);
expect(rows.length).toBe(MAX_ANSWER_ROWS);
});
});
describe('validateGuestBook', () => {
it('returns null when nothing was filled in', () => {
expect(validateGuestBook(undefined)).toBeNull();
expect(validateGuestBook({displayName: ' ', message: ' '})).toBeNull();
});
it('keeps a valid entry with only a display name', () => {
expect(validateGuestBook({displayName: 'Familie Müller'})).toEqual({displayName: 'Familie Müller', message: null});
});
it('caps the message at 2000 characters', () => {
const long = 'x'.repeat(3000);
const result = validateGuestBook({message: long});
expect(result?.message).toHaveLength(2000);
});
});
describe('validateNewsletter', () => {
it('returns null when the object is missing', () => {
expect(validateNewsletter(undefined)).toBeNull();
});
it('drops the signup silently when the email is invalid', () => {
expect(validateNewsletter({firstName: 'Anna', lastName: 'Beispiel', email: 'not-an-email'})).toBeNull();
});
it('drops the signup when first or last name is missing', () => {
expect(validateNewsletter({firstName: '', lastName: 'Beispiel', email: 'a@b.de'})).toBeNull();
expect(validateNewsletter({firstName: 'Anna', lastName: '', email: 'a@b.de'})).toBeNull();
});
it('accepts a fully valid signup', () => {
expect(validateNewsletter({firstName: 'Anna', lastName: 'Beispiel', email: 'anna@beispiel.de'})).toEqual({
firstName: 'Anna', lastName: 'Beispiel', email: 'anna@beispiel.de'
});
});
});