import { describe, it, expect, beforeEach, vi } from 'vitest'; const { getCurrentUserMock } = vi.hoisted(() => ({ getCurrentUserMock: vi.fn(), })); vi.mock('@/lib/auth', async (orig) => { const actual = (await orig()) as Record; return { ...actual, getCurrentUser: getCurrentUserMock }; }); import { NextRequest } from 'next/server'; import { GET as exportDb } from '@/app/api/settings/export-db/route'; import { POST as importDb } from '@/app/api/settings/import-db/route'; // The whole-instance DB export/import operate on every user's data (hashes, // plaintext AI keys) and can replace the entire DB. They MUST be admin-only — // see EVALUATION.md P0. These tests lock that gate so it can't silently regress. // // The admin "happy path" cases assert a real downstream status (export 200 / // import 400), not merely "not 401/403" — so they can't pass vacuously if the // route errors before reaching the gate. The export reads the live test DB, // which setup-actions.ts has already created at DATABASE_URL. const regularUser = { id: 'u1', email: 'user@example.com', isAdmin: false }; const adminUser = { id: 'a1', email: 'admin@example.com', isAdmin: true }; // POST with no body — formData() throws downstream; only the gate matters here. function emptyImportReq(): NextRequest { return new NextRequest('http://x/api/settings/import-db', { method: 'POST', } as ConstructorParameters[1]); } // POST with a structurally-present but non-SQLite file: passes the gate, then // fails the magic-byte check with a clean 400 — proving the gate was cleared. function badFileImportReq(): NextRequest { const form = new FormData(); form.append('database', new File([Buffer.from('not a sqlite db')], 'x.db')); return new NextRequest('http://x/api/settings/import-db', { method: 'POST', body: form, } as ConstructorParameters[1]); } beforeEach(() => { getCurrentUserMock.mockReset(); }); describe('GET /api/settings/export-db (whole-instance DB)', () => { it('returns 401 when unauthenticated', async () => { getCurrentUserMock.mockResolvedValue(null); expect((await exportDb()).status).toBe(401); }); it('returns 403 for a non-admin user', async () => { getCurrentUserMock.mockResolvedValue(regularUser); expect((await exportDb()).status).toBe(403); }); it('returns the DB file (200) for an admin', async () => { getCurrentUserMock.mockResolvedValue(adminUser); const res = await exportDb(); expect(res.status).toBe(200); expect(res.headers.get('content-type')).toBe('application/x-sqlite3'); }); }); describe('POST /api/settings/import-db (whole-instance DB replace)', () => { it('returns 401 when unauthenticated', async () => { getCurrentUserMock.mockResolvedValue(null); expect((await importDb(emptyImportReq())).status).toBe(401); }); it('returns 403 for a non-admin user', async () => { getCurrentUserMock.mockResolvedValue(regularUser); expect((await importDb(emptyImportReq())).status).toBe(403); }); it('lets an admin past the gate (400 at the magic-byte check, not 401/403)', async () => { getCurrentUserMock.mockResolvedValue(adminUser); // A non-SQLite file clears the admin gate and is rejected at the magic-byte // check with 400 — unambiguously "past the gate", not a vacuous pass. expect((await importDb(badFileImportReq())).status).toBe(400); }); });