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>
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
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'
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user