import Database from 'better-sqlite3'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { listBackups } from '../src/backups/backup'; import { makeApp, setUpAdmin, type Client, type TestApp } from './helpers'; import { discoverCsv } from './synthetic'; // The milestone 3 drill, automated: back up, change things, restore, and get // everything back exactly. All data here is made up. let t: TestApp; let admin: Client; const snapshot = async () => { const page = (await admin.get('/api/v1/transactions?limit=500')).json; return { total: page.total, sums: page.sums, accounts: ((await admin.get('/api/v1/accounts')).json as { name: string; balanceCents: number }[]).map((a) => [a.name, a.balanceCents]), budgets: (await admin.get('/api/v1/budgets?year=2026')).json, subscriptions: ((await admin.get('/api/v1/subscriptions')).json as { name: string; status: string }[]).map((s) => [s.name, s.status]), }; }; beforeEach(async () => { t = await makeApp(); admin = await setUpAdmin(t); const card = (await admin.post('/api/v1/accounts', { name: 'Discover', kind: 'credit_card', institution: 'Discover' })).json.id; const preview = await admin.upload('/api/v1/imports/files', 'Discover-2026.csv', discoverCsv()); await admin.post('/api/v1/imports', { stagingId: preview.json.stagingId, accountId: card }); await admin.post('/api/v1/budgets/fill', { month: '2026-10', source: 'average3' }); }); afterEach(() => t.cleanup()); describe('restoring a backup', () => { it('brings everything back, after saving a copy of how things were', async () => { const before = await snapshot(); const backup = (await admin.post('/api/v1/backups')).json.name as string; // Things go wrong: the import is undone and a budget is wiped. const imports = (await admin.get('/api/v1/imports')).json as { id: number }[]; await admin.del(`/api/v1/imports/${imports[0]!.id}`); await admin.put('/api/v1/budgets', { categoryId: before.budgets.items[0].categoryId, year: 2026, month: 1, amountCents: 0, scope: 'year' }); expect((await snapshot()).total).toBe(0); const check = await admin.get(`/api/v1/backups/${backup}/check`); expect(check.json.counts).toMatchObject({ transactions: before.total, accounts: 1, people: 1 }); const restored = await admin.post(`/api/v1/backups/${backup}/restore`); expect(restored.status).toBe(200); expect(restored.json.safetyCopy).toMatch(/^spendlight-pre-restore-/); expect(await snapshot()).toEqual(before); // The app keeps working on the restored data, and the restore can be undone. expect((await admin.post('/api/v1/subscriptions/scan')).status).toBe(200); const undo = await admin.post(`/api/v1/backups/${restored.json.safetyCopy}/restore`); expect(undo.status).toBe(200); expect((await snapshot()).total).toBe(0); expect(listBackups(t.ctx.config.backupDir).filter((b) => b.kind === 'pre-restore')).toHaveLength(2); }); it('keeps whoever restored signed in, and leaves only whole files in the backup folder', async () => { const backup = (await admin.post('/api/v1/backups')).json.name as string; // A sign-in newer than the backup. const { Client } = await import('./helpers'); const fresh = new Client(t); await fresh.post('/api/v1/auth/login', { username: 'Chris', password: 'correct-horse' }); await fresh.get(`/api/v1/backups/${backup}/check`); expect((await fresh.post(`/api/v1/backups/${backup}/restore`)).status).toBe(200); expect((await fresh.get('/api/v1/me')).status).toBe(200); const names = (await import('node:fs')).readdirSync(t.ctx.config.backupDir); expect(names.every((n) => /^spendlight-[a-z-]+-\d{8}-\d{6}\.db$/.test(n))).toBe(true); }); it('accepts an uploaded backup, such as one from another machine', async () => { const before = await snapshot(); const backup = (await admin.post('/api/v1/backups')).json.name as string; const imports = (await admin.get('/api/v1/imports')).json as { id: number }[]; await admin.del(`/api/v1/imports/${imports[0]!.id}`); const form = new FormData(); const bytes = (await import('node:fs')).readFileSync(join(t.ctx.config.backupDir, backup)); form.append('file', new File([bytes], backup)); const res = await t.app.request('http://localhost/api/v1/backups/restore-upload', { method: 'POST', body: form, headers: { Cookie: `sl_session=${admin.cookie('sl_session')}` } }); expect(res.status).toBe(200); expect(await snapshot()).toEqual(before); }); it('refuses files that aren’t a Spendlight backup', async () => { const dir = t.ctx.config.backupDir; const upload = async (name: string, data: Buffer | string) => { const form = new FormData(); form.append('file', new File([data], name)); const res = await t.app.request('http://localhost/api/v1/backups/restore-upload', { method: 'POST', body: form, headers: { Cookie: `sl_session=${admin.cookie('sl_session')}` } }); return { status: res.status, json: (await res.json()) as { error: { code: string } } }; }; expect((await upload('notes.db', 'just some text')).json.error.code).toBe('not_sqlite'); // A SQLite file from some other app. const other = join(dir, 'other.db'); const db = new Database(other); db.exec('CREATE TABLE things (id INTEGER)'); db.close(); expect((await upload('other.db', (await import('node:fs')).readFileSync(other))).json.error.code).toBe('not_spendlight'); // A Spendlight backup from a newer version, with a database upgrade this one doesn't know. const backup = (await admin.post('/api/v1/backups')).json.name as string; const newer = new Database(join(dir, backup)); newer.prepare('INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?)').run('future', Date.now()); newer.close(); const tooNew = await admin.post(`/api/v1/backups/${backup}/restore`); expect(tooNew.json.error.code).toBe('too_new'); // Nothing changed, and no safety copies were needed. expect(listBackups(dir).filter((b) => b.kind === 'pre-restore')).toHaveLength(0); expect((await admin.get('/api/v1/transactions?limit=1')).json.total).toBeGreaterThan(0); }); it('is for admins only, and only for names in the backup folder', async () => { await admin.post('/api/v1/people', { username: 'helper', displayName: 'Helper', access: 'full', isAdmin: false, password: 'helper-password-1' }); const { Client } = await import('./helpers'); const helper = new Client(t); await helper.post('/api/v1/auth/login', { username: 'helper', password: 'helper-password-1' }); const backup = (await admin.post('/api/v1/backups')).json.name as string; expect((await helper.post(`/api/v1/backups/${backup}/restore`)).status).toBe(403); expect((await admin.post('/api/v1/backups/..%2Fdata%2Fspendlight.db/restore')).status).toBe(404); }); });