import { createServer, type Server } from 'node:http'; import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { setTimeout as sleep } from 'node:timers/promises'; import { INestApplication } from '@nestjs/common'; import { BACKUP_COMMAND_CHANNEL, RESTORE_STATUS_FILE, archiveFileName, dumpFileName, type BackupCommand, type BackupSetsView, type BackupSettingsView, type RestoreStatus, type SystemBackupView, } from '@dorfteich/shared'; import { PrismaClient } from '@prisma/client'; import { Client } from 'pg'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { createTestApp, sessionCookieOf } from '../testing/test-app'; import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { UsersService } from '../users/users.service'; /** * A fake Nextcloud endpoint covering the WebDAV subset the admin endpoints * use (PROPFIND/MKCOL) plus a remote bundle listing — the connection test * and the restore picker run against real HTTP. */ function createDavServer(): { server: Server; start(): Promise; stop(): Promise; setAuthOk(ok: boolean): void; } { let authOk = true; const files = ['dorfteich-backup-20260710-030000.tar.gz']; const server = createServer((req, res) => { if (!authOk) { res.statusCode = 401; return res.end(); } if (req.method === 'PROPFIND') { const depth = req.headers.depth; res.statusCode = 207; res.setHeader('Content-Type', 'application/xml'); const children = depth === '1' ? files .map( (name) => `${req.url}/${name} 2048 `, ) .join('') : ''; return res.end( ` ${req.url}/ ${children}`, ); } if (req.method === 'MKCOL') { res.statusCode = 201; return res.end(); } res.statusCode = 405; res.end(); }); return { server, setAuthOk: (ok) => { authOk = ok; }, start: () => new Promise((resolve) => { server.listen(0, '127.0.0.1', () => { resolve(`http://127.0.0.1:${(server.address() as { port: number }).port}`); }); }), stop: () => new Promise((resolve) => server.close(() => resolve())), }; } /** * Backup administration end to end (issue #103): settings roundtrip with * the app password landing in the secret store (never the database), the * live connection test, the restore picker's set listing, command NOTIFYs * for run/restore, the public restore-status endpoint, and the maintenance * gate's 503 semantics including staleness. */ describe.skipIf(!hasTestDb)('backup admin (e2e, issue #103)', () => { let app: INestApplication; let prisma: PrismaClient; let backupsDir: string; let secretsFile: string; let davUrl: string; const dav = createDavServer(); const suffix = uniqueSuffix(); const password = 'backupadmin ist vorsichtig 1'; const ids: Record = {}; const cookies: Record = {}; const baseSecretsFile = process.env.SECRETS_FILE; const commands: BackupCommand[] = []; let listenClient: Client; const api = () => request(app.getHttpServer()); async function makeUser(handle: string, siteAdmin: boolean): Promise { const users = app.get(UsersService); const username = `bak-${handle}-${suffix}`; const user = await users.createUser({ username, email: `${username}@example.org`, displayName: `Bak ${handle}`, password, locale: 'en', }); await users.markEmailVerified(user.id); if (siteAdmin) await prisma.user.update({ where: { id: user.id }, data: { isSiteAdmin: true } }); ids[handle] = user.id; cookies[handle] = sessionCookieOf( await api() .post('/api/v1/auth/login') .send({ usernameOrEmail: username, password }) .expect(200), ); } function settingsInput(overrides: Record = {}): Record { return { localRetentionDays: 14, remoteRetentionDays: 60, nextcloud: { enabled: true, baseUrl: davUrl, username: 'clouduser', folder: `dorfteich-e2e-${suffix}`, uploadSchedule: 'weekly', password: 'app-password-123', ...((overrides.nextcloud as object) ?? {}), }, ...Object.fromEntries(Object.entries(overrides).filter(([k]) => k !== 'nextcloud')), }; } beforeAll(async () => { backupsDir = mkdtempSync(join(tmpdir(), 'dorfteich-backup-admin-')); secretsFile = join(mkdtempSync(join(tmpdir(), 'dorfteich-backup-secrets-')), 'secrets.env'); process.env.BACKUPS_DIR = backupsDir; process.env.SECRETS_FILE = secretsFile; // The in-test WebDAV server must be allowlisted (issue #192) — the // policy paths themselves are covered by backup-allowlist*.e2e.db.test.ts. process.env.BACKUP_ALLOWED_TARGETS = '127.0.0.1'; davUrl = await dav.start(); prisma = createTestPrisma(); await prisma.rateLimit.deleteMany({}); // Clean leftovers of earlier runs — the settings keys are singletons. await prisma.instanceSetting.deleteMany({ where: { key: { startsWith: 'backup.' } } }); app = await createTestApp(); await makeUser('admin', true); await makeUser('user', false); listenClient = new Client({ connectionString: process.env.TEST_DATABASE_URL }); await listenClient.connect(); listenClient.on('notification', (message) => { if (message.channel === BACKUP_COMMAND_CHANNEL && message.payload) { commands.push(JSON.parse(message.payload) as BackupCommand); } }); await listenClient.query(`LISTEN ${BACKUP_COMMAND_CHANNEL}`); }); afterAll(async () => { delete process.env.BACKUPS_DIR; if (baseSecretsFile === undefined) delete process.env.SECRETS_FILE; else process.env.SECRETS_FILE = baseSecretsFile; await dav.stop(); await listenClient.end().catch(() => undefined); const all = Object.values(ids); await prisma.instanceSetting.deleteMany({ where: { key: { startsWith: 'backup.' } } }); await prisma.auditEntry.deleteMany({ where: { actorId: { in: all } } }); await prisma.session.deleteMany({ where: { userId: { in: all } } }); await prisma.userIdentity.deleteMany({ where: { userId: { in: all } } }); await prisma.user.deleteMany({ where: { id: { in: all } } }); await prisma.$disconnect(); await app.close(); }); it('rejects non-admins on every backup admin route', async () => { for (const [method, path] of [ ['get', '/api/v1/admin/system/backup/settings'], ['put', '/api/v1/admin/system/backup/settings'], ['post', '/api/v1/admin/system/backup/nextcloud/test'], ['get', '/api/v1/admin/system/backup/sets'], ['post', '/api/v1/admin/system/backup/run'], ['post', '/api/v1/admin/system/backup/restore'], ] as const) { await api()[method](path).set('Cookie', cookies.user!).expect(403); } }); it('tests the connection against a live WebDAV endpoint', async () => { const ok = await api() .post('/api/v1/admin/system/backup/nextcloud/test') .set('Cookie', cookies.admin!) .send({ baseUrl: davUrl, username: 'clouduser', folder: 'dorfteich-e2e', password: 'app-password-123', }) .expect(200); expect(ok.body).toEqual({ ok: true }); dav.setAuthOk(false); const bad = await api() .post('/api/v1/admin/system/backup/nextcloud/test') .set('Cookie', cookies.admin!) .send({ baseUrl: davUrl, username: 'clouduser', folder: 'dorfteich-e2e', password: 'wrong', }) .expect(200); expect(bad.body.ok).toBe(false); expect(String(bad.body.error)).toContain('authentication failed'); dav.setAuthOk(true); }); it('refuses to save an enabled target that fails the connection test', async () => { dav.setAuthOk(false); const res = await api() .put('/api/v1/admin/system/backup/settings') .set('Cookie', cookies.admin!) .send(settingsInput()) .expect(400); expect(res.body.code).toBe('backup_connection_failed'); dav.setAuthOk(true); // Nothing was persisted. const view = await api() .get('/api/v1/admin/system/backup/settings') .set('Cookie', cookies.admin!) .expect(200); expect((view.body as BackupSettingsView).nextcloud.enabled).toBe(false); }); it('saves settings, keeping the app password out of the database', async () => { const res = await api() .put('/api/v1/admin/system/backup/settings') .set('Cookie', cookies.admin!) .send(settingsInput()) .expect(200); const view = res.body as BackupSettingsView; expect(view).toMatchObject({ localRetentionDays: 14, remoteRetentionDays: 60, nextcloud: { enabled: true, baseUrl: davUrl, username: 'clouduser', uploadSchedule: 'weekly', passwordSet: true, }, }); // Secret store holds the password; instance_settings never does. expect(readFileSync(secretsFile, 'utf8')).toMatch( /BACKUP_NEXTCLOUD_PASSWORD="?app-password-123"?/, ); const rows = await prisma.instanceSetting.findMany({ where: { key: { startsWith: 'backup.' } }, }); expect(JSON.stringify(rows.map((row) => row.value))).not.toContain('app-password-123'); // Re-saving without a password keeps the stored one (write-only field). await api() .put('/api/v1/admin/system/backup/settings') .set('Cookie', cookies.admin!) .send(settingsInput({ nextcloud: { password: undefined } })) .expect(200); expect(readFileSync(secretsFile, 'utf8')).toMatch( /BACKUP_NEXTCLOUD_PASSWORD="?app-password-123"?/, ); }); it('lists local and remote restore sets, and reflects the target on the card', async () => { writeFileSync(join(backupsDir, dumpFileName('20260712-030000')), 'dump'); writeFileSync(join(backupsDir, archiveFileName('20260712-030000')), 'archive'); // An incomplete set never shows up as restorable. writeFileSync(join(backupsDir, dumpFileName('20260712-040000')), 'dump-only'); const res = await api() .get('/api/v1/admin/system/backup/sets') .set('Cookie', cookies.admin!) .expect(200); const sets = res.body as BackupSetsView; expect(sets.remoteConfigured).toBe(true); expect(sets.local.map((s) => s.backupId)).toEqual(['20260712-030000']); expect(sets.remote.map((s) => s.backupId)).toEqual(['20260710-030000']); expect(sets.remote[0]!.sizeBytes).toBe(2048); const card = await api() .get('/api/v1/admin/system/backup') .set('Cookie', cookies.admin!) .expect(200); expect((card.body as SystemBackupView).remoteConfigured).toBe(true); }); it('sends run and restore commands over NOTIFY, audit-logged', async () => { commands.length = 0; await api().post('/api/v1/admin/system/backup/run').set('Cookie', cookies.admin!).expect(202); await api() .post('/api/v1/admin/system/backup/restore') .set('Cookie', cookies.admin!) .send({ source: 'local', backupId: '20260712-030000', confirm: '20260712-030000' }) .expect(202); await sleep(300); expect(commands).toEqual([ { kind: 'run', requestedBy: `bak-admin-${suffix}` }, { kind: 'restore', source: 'local', backupId: '20260712-030000', requestedBy: `bak-admin-${suffix}`, }, ]); const audit = await prisma.auditEntry.findMany({ where: { actorId: ids.admin!, action: { startsWith: 'backup.' } }, }); const actions = audit.map((entry) => entry.action); expect(actions).toContain('backup.run_triggered'); expect(actions).toContain('backup.restore_requested'); }); it('guards the restore trigger: confirm mismatch, unknown sets, bad sources', async () => { await api() .post('/api/v1/admin/system/backup/restore') .set('Cookie', cookies.admin!) .send({ source: 'local', backupId: '20260712-030000', confirm: 'nope' }) .expect(400); await api() .post('/api/v1/admin/system/backup/restore') .set('Cookie', cookies.admin!) .send({ source: 'local', backupId: '20260712-040000', confirm: '20260712-040000' }) .expect(404); await api() .post('/api/v1/admin/system/backup/restore') .set('Cookie', cookies.admin!) .send({ source: 'remote', backupId: '20260712-050000', confirm: '20260712-050000' }) .expect(404); }); it('serves the public restore status and gates the api during a restore', async () => { // No restore yet → idle, and the api serves normally. const idle = await api().get('/api/v1/backup/restore-status').expect(200); expect(idle.body).toEqual({ state: 'idle' }); const running: RestoreStatus = { schemaVersion: 1, state: 'running', backupId: '20260712-030000', source: 'local', requestedBy: 'admin', startedAt: new Date().toISOString(), finishedAt: null, }; writeFileSync(join(backupsDir, RESTORE_STATUS_FILE), JSON.stringify(running)); await sleep(1600); // maintenance state cache TTL // Anonymous status endpoint keeps answering; everything else 503s. const status = await api().get('/api/v1/backup/restore-status').expect(200); expect((status.body as RestoreStatus).state).toBe('running'); const gated = await api().get('/api/v1/ponds').set('Cookie', cookies.admin!).expect(503); expect(gated.body.code).toBe('maintenance_mode'); await api().get('/api/v1/healthz').expect(200); // A crashed restore (stale running state) must not brick the instance. writeFileSync( join(backupsDir, RESTORE_STATUS_FILE), JSON.stringify({ ...running, startedAt: new Date(Date.now() - 31 * 60_000).toISOString() }), ); await sleep(1600); await api().get('/api/v1/ponds').set('Cookie', cookies.admin!).expect(200); // A finished restore leaves the gate open and reports its result. writeFileSync( join(backupsDir, RESTORE_STATUS_FILE), JSON.stringify({ ...running, state: 'succeeded', finishedAt: new Date().toISOString() }), ); await sleep(1600); const done = await api().get('/api/v1/backup/restore-status').expect(200); expect((done.body as RestoreStatus).state).toBe('succeeded'); await api().get('/api/v1/ponds').set('Cookie', cookies.admin!).expect(200); }); });