Files
redefined-designs/backend/tests/integration/adminSettings.integration.test.ts
T
bermudalambandClaude Opus 5 346e9eae4c feat(intake): act on a signed link from the notification (#224)
Mounted publicly, deliberately not behind requireAdminGate. These are clicked from an inbox by someone who is not signed in, which is the whole point; the signature is what protects them.

GET confirms and changes nothing, POST acts. Mail scanners and corporate link-rewriting gateways issue a GET against every URL in a message before a human sees it, so a GET that discarded a draft would fire itself on delivery — carrying a valid signature, looking entirely legitimate in the log, and nobody would know to go and recover it. That is the case the split exists for and it has its own test.

Forged, replayed, upgraded and expired links are each refused with the same 403. Distinguishing them would tell somebody probing which of those they had achieved. There is no signable publish, and asking for one finds no handler.

The two registry guard tests are updated rather than worked around: they assert the full set of settings and template keys, so adding either is exactly what should trip them.

Backend now 367 unit and 329 integration, all passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 15:32:07 -05:00

126 lines
4.8 KiB
TypeScript

import request from 'supertest';
import app from '../../src/app';
import { pool } from '../../src/db';
import { resetDb, closeDb } from './setup/testDb';
import { getSettings } from '../../src/adminSettings';
// resetDb only clears the stored email templates out of admin_settings — it
// matches `email\_%`. The settings themselves survive it, so without this a
// value written by one test is the baseline the next one reads. Cleared after
// the file too, so nothing leaks into the suites that run behind it.
beforeEach(async () => {
await resetDb();
await pool.query(`DELETE FROM admin_settings`);
});
afterAll(async () => {
await pool.query(`DELETE FROM admin_settings`);
await pool.end();
await closeDb();
});
describe('GET /api/admin/settings', () => {
it('answers with every setting, falling back for the ones never written', async () => {
const res = await request(app).get('/api/admin/settings');
expect(res.status).toBe(200);
expect(res.body).toEqual({
cartExpiryHours: 24,
verifyTokenHours: 24,
passwordResetHours: 1,
greetingFormat: 'Hi {{firstName}},',
greetingFallback: 'Hi,',
draftingModel: 'claude-sonnet-5',
// Empty by default: nowhere to send the intake notification is a working
// configuration, and means simply do not send one (#224).
intakeNotifyEmail: ''
});
});
});
describe('PUT /api/admin/settings', () => {
it('stores the lifetimes and reads them back', async () => {
const res = await request(app)
.put('/api/admin/settings')
.send({ verifyTokenHours: 2, passwordResetHours: 0.5 });
expect(res.status).toBe(200);
expect(res.body.verifyTokenHours).toBe(2);
expect(res.body.passwordResetHours).toBe(0.5);
// Read back through the accessor the senders use, not just the response.
expect((await getSettings()).verifyTokenHours).toBe(2);
});
// Partial rather than whole-object: a caller updating one field should not
// have to echo the others back to avoid clobbering them.
it('leaves settings it was not sent alone', async () => {
await request(app).put('/api/admin/settings').send({ cartExpiryHours: 6 });
await request(app).put('/api/admin/settings').send({ verifyTokenHours: 3 });
const { cartExpiryHours, verifyTokenHours } = await getSettings();
expect(cartExpiryHours).toBe(6);
expect(verifyTokenHours).toBe(3);
});
it('stores the greeting format and its fallback', async () => {
const res = await request(app)
.put('/api/admin/settings')
.send({ greetingFormat: 'Dear {{firstName}} {{lastName}}:', greetingFallback: 'Hello there,' });
expect(res.status).toBe(200);
expect(res.body.greetingFormat).toBe('Dear {{firstName}} {{lastName}}:');
expect(res.body.greetingFallback).toBe('Hello there,');
});
it.each([
['cartExpiryHours', 0],
['verifyTokenHours', -1],
['passwordResetHours', 'soon']
])('refuses %s of %p, naming the field', async (name, value) => {
const res = await request(app).put('/api/admin/settings').send({ [name]: value });
expect(res.status).toBe(400);
expect(res.body.error).toContain(name);
});
// An empty format would render every greeting as nothing at all, which reads
// as a broken email rather than a setting someone cleared.
it.each(['greetingFormat', 'greetingFallback'])('refuses an empty %s', async (name) => {
const res = await request(app).put('/api/admin/settings').send({ [name]: ' ' });
expect(res.status).toBe(400);
expect(res.body.error).toContain(name);
});
// Every field is validated before any is written, so a request that is part
// nonsense does not half-apply.
it('does not write anything when one field in the request is invalid', async () => {
await request(app).put('/api/admin/settings').send({ cartExpiryHours: 6 });
const res = await request(app)
.put('/api/admin/settings')
.send({ cartExpiryHours: 8, verifyTokenHours: -3 });
expect(res.status).toBe(400);
expect((await getSettings()).cartExpiryHours).toBe(6);
});
// #223. A model name outside the offered set is refused rather than stored.
// Stored, it would be accepted here and then fail on every submission,
// surfacing only as drafts quietly not appearing.
it('refuses a drafting model it does not offer', async () => {
const res = await request(app)
.put('/api/admin/settings')
.send({ draftingModel: 'claude-sonnet-5-typo' });
expect(res.status).toBe(400);
expect(res.body.error).toContain('draftingModel');
});
it('accepts a drafting model it does offer', async () => {
const res = await request(app).put('/api/admin/settings').send({ draftingModel: 'claude-opus-5' });
expect(res.status).toBe(200);
expect((await getSettings()).draftingModel).toBe('claude-opus-5');
});
});