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,87 @@
|
||||
import {Request, Response} from 'express';
|
||||
|
||||
jest.mock('../../src/models/calendar/users/users.service', () => ({
|
||||
checkSession: jest.fn()
|
||||
}));
|
||||
|
||||
import * as UserService from '../../src/models/calendar/users/users.service';
|
||||
import {requireAdminAuth, sessionHeaderAuthenticator} from '../../src/models/feedback/feedback.auth';
|
||||
|
||||
const mockCheckSession = UserService.checkSession as jest.Mock;
|
||||
|
||||
const makeReq = (headers: Record<string, string>): Request => {
|
||||
return {
|
||||
header: (name: string) => headers[name],
|
||||
ip: '203.0.113.42'
|
||||
} as unknown as Request;
|
||||
};
|
||||
|
||||
const makeRes = (): Response => {
|
||||
const res: any = {};
|
||||
res.status = jest.fn().mockReturnValue(res);
|
||||
res.send = jest.fn().mockReturnValue(res);
|
||||
res.locals = {};
|
||||
return res as Response;
|
||||
};
|
||||
|
||||
describe('sessionHeaderAuthenticator', () => {
|
||||
beforeEach(() => mockCheckSession.mockReset());
|
||||
|
||||
it('returns null when headers are missing', async () => {
|
||||
const identity = await sessionHeaderAuthenticator(makeReq({}));
|
||||
expect(identity).toBeNull();
|
||||
expect(mockCheckSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns null when checkSession finds no user', async () => {
|
||||
mockCheckSession.mockResolvedValue(null);
|
||||
const identity = await sessionHeaderAuthenticator(makeReq({'X-Session-Id': '1', 'X-Session-Key': 'k'}));
|
||||
expect(identity).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a valid session on an inactive account', async () => {
|
||||
mockCheckSession.mockResolvedValue({userId: 1, email: 'a@nachklang.art', fullName: 'A', isActive: false});
|
||||
const identity = await sessionHeaderAuthenticator(makeReq({'X-Session-Id': '1', 'X-Session-Key': 'k'}));
|
||||
expect(identity).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the identity for a valid session on an active account', async () => {
|
||||
mockCheckSession.mockResolvedValue({userId: 1, email: 'a@nachklang.art', fullName: 'Anna Admin', isActive: true});
|
||||
const identity = await sessionHeaderAuthenticator(makeReq({'X-Session-Id': '1', 'X-Session-Key': 'k'}));
|
||||
expect(identity).toEqual({id: '1', email: 'a@nachklang.art', displayName: 'Anna Admin'});
|
||||
});
|
||||
|
||||
it('passes the session id and key from headers through to checkSession, never from query params', async () => {
|
||||
mockCheckSession.mockResolvedValue({userId: 1, email: 'a@nachklang.art', fullName: 'A', isActive: true});
|
||||
await sessionHeaderAuthenticator(makeReq({'X-Session-Id': '42', 'X-Session-Key': 'sekret'}));
|
||||
expect(mockCheckSession).toHaveBeenCalledWith('42', 'sekret', '203.0.113.42');
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireAdminAuth', () => {
|
||||
beforeEach(() => mockCheckSession.mockReset());
|
||||
|
||||
it('responds 401 and does not call next() when unauthenticated', async () => {
|
||||
mockCheckSession.mockResolvedValue(null);
|
||||
const req = makeReq({});
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
|
||||
await requireAdminAuth(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sets res.locals.admin and calls next() when authenticated', async () => {
|
||||
mockCheckSession.mockResolvedValue({userId: 1, email: 'a@nachklang.art', fullName: 'Anna Admin', isActive: true});
|
||||
const req = makeReq({'X-Session-Id': '1', 'X-Session-Key': 'k'});
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
|
||||
await requireAdminAuth(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.locals.admin).toEqual({id: '1', email: 'a@nachklang.art', displayName: 'Anna Admin'});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user