Files
TREK/server/tests/e2e/settings.e2e.test.ts
T
jubnl e0530680cb feat(server): adopt zod DTOs for the settings body contracts
SettingUpsertDto/SettingsBulkDto wrap the existing @trek/shared schemas; both
SettingsController entries drop off the body-contract allow-list (boot
ratchet). Per the todo precedent the dead bespoke 400 guards go: missing/empty
key and a non-record settings body now answer with the pipe envelope
({ error: 'key: …' } / { error: 'settings: …' }) instead of 'Key is required' /
'Settings object is required', and arrays for settings are rejected. The local
masked-value literal now imports MASKED_SETTING_VALUE from @trek/shared.
The settings e2e registers ZodValidationPipe and pins the DTO 400s plus the
bulk masked-sentinel skip.
2026-07-26 22:24:14 +02:00

123 lines
5.6 KiB
TypeScript

/**
* Settings e2e — exercises the migrated /api/settings endpoints through the real
* JwtAuthGuard against a temp SQLite db. DI-native: SettingsService runs its
* real SQL over the temp db (no service mock); this covers auth, the DTO 400s
* (ZodValidationPipe), the masked-sentinel no-op, status codes and the actual
* persisted rows.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { DatabaseModule } from '../../src/nest/database/database.module';
import { Test } from '@nestjs/testing';
import { seedUser, sessionCookie } from './harness';
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const tmp = new Database(':memory:');
tmp.exec('PRAGMA journal_mode = WAL');
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
tmp.exec(`CREATE TABLE settings (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL,
key TEXT NOT NULL, value TEXT, UNIQUE(user_id, key));`);
tmp.exec('CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);');
return { db: tmp };
});
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
import { SettingsModule } from '../../src/nest/settings/settings.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
describe('Settings e2e (real auth guard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, SettingsModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
nest.useGlobalPipes(new ZodValidationPipe());
await nest.init();
return nest;
}
beforeAll(async () => {
seedUser(db as never, { id: 1 });
app = await build();
server = app.getHttpServer();
});
beforeEach(() => {
db.exec('DELETE FROM settings');
db.exec('DELETE FROM app_settings');
});
afterAll(async () => {
await app.close();
});
it('401 without a session cookie', async () => {
expect((await request(server).get('/api/settings')).status).toBe(401);
});
it('200 list with a session', async () => {
db.prepare("INSERT INTO settings (user_id, key, value) VALUES (1, 'theme', 'dark')").run();
const res = await request(server).get('/api/settings').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ settings: { theme: 'dark' } });
});
it('PUT 400 without a key (pipe envelope via SettingUpsertDto)', async () => {
const res = await request(server).put('/api/settings').set('Cookie', sessionCookie(1)).send({ value: 'x' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/^key: /);
expect(db.prepare('SELECT COUNT(*) AS n FROM settings').get()).toEqual({ n: 0 });
});
it('POST /bulk 400 without a settings object (pipe envelope via SettingsBulkDto)', async () => {
const res = await request(server).post('/api/settings/bulk').set('Cookie', sessionCookie(1)).send({ settings: null });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/^settings: /);
});
it('PUT persists the setting', async () => {
const res = await request(server).put('/api/settings').set('Cookie', sessionCookie(1)).send({ key: 'language', value: 'fr' });
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true, key: 'language', value: 'fr' });
const row = db.prepare("SELECT value FROM settings WHERE user_id = 1 AND key = 'language'").get() as { value: string };
expect(row.value).toBe('fr');
});
it('PUT no-ops on the masked sentinel', async () => {
const res = await request(server).put('/api/settings').set('Cookie', sessionCookie(1)).send({ key: 'secret', value: '••••••••' });
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true, key: 'secret', unchanged: true });
expect(db.prepare("SELECT COUNT(*) AS n FROM settings WHERE user_id = 1 AND key = 'secret'").get()).toEqual({ n: 0 });
});
it('POST /bulk 200', async () => {
const res = await request(server).post('/api/settings/bulk').set('Cookie', sessionCookie(1)).send({ settings: { a: 1, b: 2 } });
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true, updated: 2 });
const rows = db.prepare('SELECT key, value FROM settings WHERE user_id = 1 ORDER BY key').all();
expect(rows).toEqual([
{ key: 'a', value: '1' },
{ key: 'b', value: '2' },
]);
});
it('POST /bulk skips masked-sentinel values (a stored secret survives)', async () => {
db.prepare("INSERT INTO settings (user_id, key, value) VALUES (1, 'ntfy_token', 'tok-real')").run();
const res = await request(server).post('/api/settings/bulk').set('Cookie', sessionCookie(1))
.send({ settings: { ntfy_topic: 'trek', ntfy_token: '••••••••' } });
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true, updated: 1 });
expect(db.prepare("SELECT value FROM settings WHERE user_id = 1 AND key = 'ntfy_token'").get()).toEqual({ value: 'tok-real' });
});
});