/** * Route tests for the Private Network setup wizard + status — DC-152 * (specs/PRIVATE-NETWORK-SPEC.md §9). * * Hermetic: supertest against an in-process express app with fully mocked * deps (registry, providers, peopleStore, credentialStore, technitium, * caSetup). No network, no real filesystem state (tmp state file per app). * * Coverage required by the ticket: * - mode validation for BOTH providers, incl. credential failure → * step does NOT advance * - apiKey expiry warning present for apiKey, absent for oauth * - domain default + custom-TLD warning (accepted, never blocked) * - connect runs the first sync (people + devices) * - dns capability gating in both modes * - ca locked-in warning + skip-when-dep-absent * - admin bootstrap (first person = admin) * - complete gate refuses when checks are missing * - status aggregation shape */ 'use strict'; const express = require('express'); const request = require('supertest'); const fs = require('fs'); const path = require('path'); const os = require('os'); const wizardRoutes = require('../routes/network-wizard'); const { createPeopleStore, ADMIN_GROUP } = require('../src/security/network-people-store'); const { ProviderError } = require('../src/network/errors'); /* ── fixtures ─────────────────────────────────────────────────────── */ function makeCredentialStore() { const map = new Map(); return { map, async store(key, value) { map.set(key, value); return true; }, async retrieve(key) { return map.get(key) || null; }, async delete(key) { map.delete(key); return true; }, }; } function makeProvider(overrides = {}) { const base = { id: () => 'mock', capabilities: () => ({ invites: true, splitDnsApi: true, perPeerFirewall: false, selfHosted: false }), getStatus: async () => ({ connected: true, installed: true }), listDevices: async () => [ { keyId: 'dev-1', name: 'laptop', ip: '100.64.0.2', online: true }, { keyId: 'dev-2', name: 'phone', ip: '100.64.0.3', online: false }, ], listPeople: async () => [ { providerUserId: 'user-1', displayName: 'Sami' }, ], }; return Object.assign(base, overrides); } function makeRegistry({ activeId = null } = {}) { const reg = { _activeId: activeId, setActiveProvider: jest.fn(function (id) { this._activeId = id; }), getActiveProvider: jest.fn(function () { return this._activeId; }), }; return reg; } let _seq = 0; function createApp(overrides = {}) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc152-wizard-')); const stateFile = path.join(dir, 'network-wizard-state.json'); const credentialStore = overrides.credentialStore || makeCredentialStore(); const peopleStore = overrides.peopleStore || createPeopleStore({ dataDir: dir, log: { info() {}, warn() {}, error() {} } }); const tailscale = overrides.tailscale || makeProvider({ id: () => 'tailscale' }); const wireguard = overrides.wireguard || makeProvider({ id: () => 'wireguard', capabilities: () => ({ invites: false, splitDnsApi: false, perPeerFirewall: true, selfHosted: true }), }); const registry = overrides.registry || makeRegistry(); const router = wizardRoutes({ registry, providers: { tailscale, wireguard }, peopleStore, technitium: overrides.technitium !== undefined ? overrides.technitium : null, caSetup: overrides.caSetup !== undefined ? overrides.caSetup : null, credentialStore, stateFile, connectionLogReader: overrides.connectionLogReader || null, aclStateReader: overrides.aclStateReader || null, firewallStateReader: overrides.firewallStateReader || null, asyncHandler: (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next), log: { info() {}, warn() {}, error() {} }, }); const app = express(); app.use(express.json()); app.use('/network/wizard', router); app.use((err, _req, res, _next) => { const status = err.statusCode || 500; res.status(status).json({ success: false, error: err.message, code: err.code || null }); }); _seq += 1; return { app, dir, stateFile, credentialStore, peopleStore, registry, tailscale, wireguard, seq: _seq }; } function cleanup(dir) { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ok */ } } /* ── state ────────────────────────────────────────────────────────── */ describe('GET /network/wizard/state', () => { let ctx; afterEach(() => cleanup(ctx.dir)); test('initial state: step mode, no checks passing', async () => { ctx = createApp(); const res = await request(ctx.app).get('/network/wizard/state'); expect(res.status).toBe(200); expect(res.body.success).toBe(true); expect(res.body.step).toBe('mode'); expect(res.body.mode).toBeNull(); expect(res.body.startedAt).toBeTruthy(); expect(res.body.completedAt).toBeNull(); expect(res.body.checks).toMatchObject({ modeSelected: false, domainConfigured: false, providerConnected: false, dnsConfigured: false, caConfigured: false, adminCreated: false, }); }); test('state persists to network-wizard-state.json', async () => { ctx = createApp(); await request(ctx.app).post('/network/wizard/mode').send({ mode: 'tailscale', credentials: { oauthClientSecret: 'tskey-client-xyz' }, }); const raw = JSON.parse(fs.readFileSync(ctx.stateFile, 'utf8')); expect(raw.mode).toBe('tailscale'); expect(raw.step).toBe('domain'); }); }); /* ── step 1: mode ─────────────────────────────────────────────────── */ describe('POST /network/wizard/mode', () => { let ctx; afterEach(() => cleanup(ctx.dir)); test('tailscale with oauthClientSecret: accepted, no expiry warning', async () => { ctx = createApp(); const res = await request(ctx.app).post('/network/wizard/mode').send({ mode: 'tailscale', credentials: { oauthClientSecret: 'tskey-client-abc123' }, }); expect(res.status).toBe(200); expect(res.body.mode).toBe('tailscale'); expect(res.body.apiKeyExpiryWarning).toBeUndefined(); expect(res.body.step).toBe('domain'); expect(ctx.registry.setActiveProvider).toHaveBeenCalledWith('tailscale'); }); test('tailscale with apiKey: accepted WITH expiry warning + oauth recommendation', async () => { ctx = createApp(); const res = await request(ctx.app).post('/network/wizard/mode').send({ mode: 'tailscale', credentials: { apiKey: 'tskey-api-k1234567890abcdef' }, }); expect(res.status).toBe(200); expect(res.body.apiKeyExpiryWarning).toBe(true); expect(res.body.warning).toMatch(/90 days/); expect(res.body.warning).toMatch(/tskey-client-/); expect(res.body.step).toBe('domain'); }); test('tailscale: credential stored under REUSED keys (tailscale.coord.apiToken / oauth)', async () => { ctx = createApp(); await request(ctx.app).post('/network/wizard/mode').send({ mode: 'tailscale', credentials: { apiKey: 'tskey-api-k1234567890abcdef' }, }); expect(ctx.credentialStore.map.get('tailscale.coord.apiToken')).toBe('tskey-api-k1234567890abcdef'); await request(ctx.app).post('/network/wizard/mode').send({ mode: 'tailscale', credentials: { oauthClientSecret: 'tskey-client-aaa' }, }); expect(ctx.credentialStore.map.get('tailscale.oauth.client_secret')).toBe('tskey-client-aaa'); }); test('tailscale credential failure (unauthorized): 4xx, step does NOT advance, credential rolled back', async () => { const tailscale = makeProvider({ id: () => 'tailscale', listDevices: async () => { throw new ProviderError('token rejected', { code: 'unauthorized' }); }, }); ctx = createApp({ tailscale }); const res = await request(ctx.app).post('/network/wizard/mode').send({ mode: 'tailscale', credentials: { apiKey: 'tskey-api-k1234567890abcdef' }, }); expect(res.status).toBeGreaterThanOrEqual(400); expect(res.status).toBeLessThan(500); expect(res.body.success).toBe(false); const state = await request(ctx.app).get('/network/wizard/state'); expect(state.body.step).toBe('mode'); // NOT advanced expect(state.body.checks.modeSelected).toBe(false); expect(ctx.credentialStore.map.get('tailscale.coord.apiToken')).toBeUndefined(); }); test('tailscale without any credentials: 400', async () => { ctx = createApp(); const res = await request(ctx.app).post('/network/wizard/mode').send({ mode: 'tailscale' }); expect(res.status).toBe(400); expect(res.body.success).toBe(false); }); test('wireguard happy path: endpoint validated via getStatus, step advances', async () => { ctx = createApp(); const res = await request(ctx.app).post('/network/wizard/mode').send({ mode: 'wireguard', credentials: { endpoint: 'vpn.example.com', listenPort: 51820, externalInterface: 'eth0' }, }); expect(res.status).toBe(200); expect(res.body.mode).toBe('wireguard'); expect(res.body.step).toBe('domain'); expect(ctx.registry.setActiveProvider).toHaveBeenCalledWith('wireguard'); }); test('wireguard dependency_missing: 400 with the missing binary list, step does NOT advance', async () => { const wireguard = makeProvider({ id: () => 'wireguard', getStatus: async () => { throw new ProviderError('wireguard: `wg` binary not installed; wireguard: `wg-quick` binary not installed', { code: 'dependency_missing', }); }, }); ctx = createApp({ wireguard }); const res = await request(ctx.app).post('/network/wizard/mode').send({ mode: 'wireguard', credentials: { endpoint: 'vpn.example.com' }, }); expect(res.status).toBe(400); expect(res.body.success).toBe(false); expect(res.body.details.missingBinaries).toEqual(expect.arrayContaining(['wg', 'wg-quick'])); const state = await request(ctx.app).get('/network/wizard/state'); expect(state.body.step).toBe('mode'); }); test('wireguard without endpoint: 400', async () => { ctx = createApp(); const res = await request(ctx.app).post('/network/wizard/mode').send({ mode: 'wireguard' }); expect(res.status).toBe(400); }); test('unknown mode: 400', async () => { ctx = createApp(); const res = await request(ctx.app).post('/network/wizard/mode').send({ mode: 'openvpn' }); expect(res.status).toBe(400); expect(res.body.code).toBe('bad_mode'); }); }); /* ── step 2: domain ───────────────────────────────────────────────── */ describe('POST /network/wizard/domain', () => { let ctx; afterEach(() => cleanup(ctx.dir)); beforeEach(async () => { ctx = createApp(); await request(ctx.app).post('/network/wizard/mode').send({ mode: 'tailscale', credentials: { oauthClientSecret: 'tskey-client-x' }, }); }); test('default .internal: accepted silently, no warning', async () => { const res = await request(ctx.app).post('/network/wizard/domain').send({ tld: '.internal' }); expect(res.status).toBe(200); expect(res.body.tld).toBe('.internal'); expect(res.body.customTldWarning).toBeUndefined(); expect(res.body.step).toBe('connect'); }); test('home.arpa: accepted silently', async () => { const res = await request(ctx.app).post('/network/wizard/domain').send({ tld: 'home.arpa' }); expect(res.status).toBe(200); expect(res.body.tld).toBe('.home.arpa'); expect(res.body.customTldWarning).toBeUndefined(); }); test('empty body → default .internal', async () => { const res = await request(ctx.app).post('/network/wizard/domain').send({}); expect(res.status).toBe(200); expect(res.body.tld).toBe('.internal'); expect(res.body.customTldWarning).toBeUndefined(); }); test('custom TLD: accepted WITH "could become real later" warning (never blocked)', async () => { const res = await request(ctx.app).post('/network/wizard/domain').send({ tld: '.mynet' }); expect(res.status).toBe(200); // accepted, not blocked expect(res.body.tld).toBe('.mynet'); expect(res.body.customTldWarning).toBe(true); expect(res.body.warning).toMatch(/could become real later/); }); test('garbage tld: 400', async () => { const res = await request(ctx.app).post('/network/wizard/domain').send({ tld: '.not valid!' }); expect(res.status).toBe(400); }); test('domain before mode: 409, step does not advance', async () => { const fresh = createApp(); const res = await request(fresh.app).post('/network/wizard/domain').send({ tld: '.internal' }); expect(res.status).toBe(409); cleanup(fresh.dir); }); }); /* ── step 3: connect ─────────────────────────────────────────────── */ describe('POST /network/wizard/connect', () => { let ctx; afterEach(() => cleanup(ctx.dir)); async function ready(mode = 'tailscale') { ctx = createApp(); const creds = mode === 'tailscale' ? { mode, credentials: { oauthClientSecret: 'tskey-client-x' } } : { mode, credentials: { endpoint: 'vpn.example.com' } }; await request(ctx.app).post('/network/wizard/mode').send(creds); await request(ctx.app).post('/network/wizard/domain').send({ tld: '.internal' }); } test('runs health check + first sync (people into store, devices listed)', async () => { await ready('tailscale'); const res = await request(ctx.app).post('/network/wizard/connect'); expect(res.status).toBe(200); expect(res.body.connected).toBe(true); expect(res.body.peopleSynced).toBe(1); expect(Array.isArray(res.body.devices)).toBe(true); expect(res.body.devices).toHaveLength(2); expect(res.body.step).toBe('dns'); const people = await ctx.peopleStore.listPeople(); expect(people).toHaveLength(1); expect(people[0].displayName).toBe('Sami'); }); test('connect is idempotent: re-running does not duplicate people', async () => { await ready('tailscale'); await request(ctx.app).post('/network/wizard/connect'); const res = await request(ctx.app).post('/network/wizard/connect'); expect(res.status).toBe(200); const people = await ctx.peopleStore.listPeople(); expect(people).toHaveLength(1); }); test('health check failure → 502, providerConnected stays false', async () => { await ready('wireguard'); ctx.wireguard.getStatus = async () => ({ connected: false, installed: true }); const res = await request(ctx.app).post('/network/wizard/connect'); expect(res.status).toBe(502); const state = await request(ctx.app).get('/network/wizard/state'); expect(state.body.checks.providerConnected).toBe(false); }); test('connect before mode: 409', async () => { ctx = createApp(); const res = await request(ctx.app).post('/network/wizard/connect'); expect(res.status).toBe(409); }); }); /* ── step 4: dns ─────────────────────────────────────────────────── */ describe('POST /network/wizard/dns (capability gating)', () => { let ctx; afterEach(() => cleanup(ctx.dir)); function makeTechnitium() { return { checkSetup: jest.fn(async () => ({ ok: true, installed: true, configured: true, server: '127.0.0.1:5353' })), configureSplitDns: jest.fn(async ({ zone, server }) => ({ ok: true, zone, server })), }; } async function readyToDns(mode, technitium) { ctx = createApp({ technitium }); const creds = mode === 'tailscale' ? { mode, credentials: { oauthClientSecret: 'tskey-client-x' } } : { mode, credentials: { endpoint: 'vpn.example.com' } }; await request(ctx.app).post('/network/wizard/mode').send(creds); await request(ctx.app).post('/network/wizard/domain').send({ tld: '.internal' }); await request(ctx.app).post('/network/wizard/connect'); } test('tailscale mode: split-DNS delegated via provider-capable path, per-check results', async () => { const technitium = makeTechnitium(); await readyToDns('tailscale', technitium); const res = await request(ctx.app).post('/network/wizard/dns'); expect(res.status).toBe(200); expect(res.body.configured).toBe(true); expect(res.body.checks).toHaveLength(2); expect(technitium.checkSetup).toHaveBeenCalledTimes(1); expect(technitium.configureSplitDns).toHaveBeenCalledTimes(1); expect(technitium.configureSplitDns.mock.calls[0][0].zone).toBe('internal'); expect(technitium.configureSplitDns.mock.calls[0][0].viaProviderApi).toBe(true); // splitDnsApi capability expect(res.body.step).toBe('ca'); }); test('wireguard mode: splitDnsApi capability false → direct Technitium write path', async () => { const technitium = makeTechnitium(); await readyToDns('wireguard', technitium); const res = await request(ctx.app).post('/network/wizard/dns'); expect(res.status).toBe(200); expect(res.body.configured).toBe(true); expect(technitium.configureSplitDns.mock.calls[0][0].viaProviderApi).toBe(false); }); test('technitium dep absent: skipped honestly, dns check counts as done', async () => { await readyToDns('tailscale', null); const res = await request(ctx.app).post('/network/wizard/dns'); expect(res.status).toBe(200); expect(res.body.skipped).toBe(true); expect(res.body.reason).toBeTruthy(); const state = await request(ctx.app).get('/network/wizard/state'); expect(state.body.checks.dnsConfigured).toBe(true); // skipped counts expect(state.body.step).toBe('ca'); }); test('technitium setup not ok: configured=false, per-check fail surfaced', async () => { const technitium = { checkSetup: async () => ({ ok: false, installed: false }), configureSplitDns: jest.fn(async () => ({ ok: true })), }; await readyToDns('tailscale', technitium); const res = await request(ctx.app).post('/network/wizard/dns'); expect(res.status).toBe(200); expect(res.body.configured).toBe(false); expect(res.body.checks[0].status).toBe('fail'); expect(technitium.configureSplitDns).not.toHaveBeenCalled(); const state = await request(ctx.app).get('/network/wizard/state'); expect(state.body.checks.dnsConfigured).toBe(false); }); }); /* ── step 5: ca ──────────────────────────────────────────────────── */ describe('POST /network/wizard/ca', () => { let ctx; afterEach(() => cleanup(ctx.dir)); async function readyToCa(caSetup) { ctx = createApp({ caSetup }); await request(ctx.app).post('/network/wizard/mode').send({ mode: 'tailscale', credentials: { oauthClientSecret: 'tskey-client-x' }, }); await request(ctx.app).post('/network/wizard/domain').send({ tld: '.internal' }); await request(ctx.app).post('/network/wizard/connect'); await request(ctx.app).post('/network/wizard/dns'); } test('dep present: setupNamedRootCA called with commonName + privateTld, locked-in warning returned', async () => { const caSetup = { setupNamedRootCA: jest.fn(async ({ commonName, privateTld }) => ({ ok: true, commonName, privateTld, fingerprint: 'AA:BB', })) }; await readyToCa(caSetup); const res = await request(ctx.app).post('/network/wizard/ca').send({ commonName: 'DashCaddy Home CA' }); expect(res.status).toBe(200); expect(res.body.configured).toBe(true); expect(res.body.lockedInWarning).toMatch(/new root/i); expect(res.body.lockedInWarning).toMatch(/re-trust/i); expect(caSetup.setupNamedRootCA).toHaveBeenCalledWith({ commonName: 'DashCaddy Home CA', privateTld: '.internal', }); expect(res.body.step).toBe('admin'); }); test('dep absent: skipped with reason (wizard not wedged), still carries warning', async () => { await readyToCa(null); const res = await request(ctx.app).post('/network/wizard/ca').send({ commonName: 'X' }); expect(res.status).toBe(200); expect(res.body.skipped).toBe(true); expect(res.body.reason).toBeTruthy(); expect(res.body.lockedInWarning).toBeTruthy(); expect(res.body.step).toBe('admin'); }); test('missing commonName with dep present: 400', async () => { await readyToCa({ setupNamedRootCA: async () => ({ ok: true }) }); const res = await request(ctx.app).post('/network/wizard/ca').send({}); expect(res.status).toBe(400); expect(res.body.code).toBe('bad_common_name'); }); }); /* ── step 6: admin ───────────────────────────────────────────────── */ describe('POST /network/wizard/admin (bootstrap admin)', () => { let ctx; afterEach(() => cleanup(ctx.dir)); async function readyToAdmin() { // No provider people: the store must still be EMPTY at /admin time so // the bootstrap-admin rule (first person ⇒ Admin group) applies to the // wizard-created person, not to a connect-synced one. const tailscale = makeProvider({ id: () => 'tailscale', listPeople: async () => [] }); ctx = createApp({ tailscale }); await request(ctx.app).post('/network/wizard/mode').send({ mode: 'tailscale', credentials: { oauthClientSecret: 'tskey-client-x' }, }); await request(ctx.app).post('/network/wizard/domain').send({ tld: '.internal' }); await request(ctx.app).post('/network/wizard/connect'); await request(ctx.app).post('/network/wizard/dns'); await request(ctx.app).post('/network/wizard/ca').send({ commonName: 'CA' }); } test('creates first person via peopleStore with bootstrap admin rule', async () => { await readyToAdmin(); const res = await request(ctx.app).post('/network/wizard/admin').send({ displayName: 'Sami', providerUserId: 'ts-user-1', }); expect(res.status).toBe(201); expect(res.body.adminCreated).toBe(true); expect(res.body.person.displayName).toBe('Sami'); expect(res.body.person.groups).toContain(ADMIN_GROUP); // bootstrap rule expect(res.body.person.protectedAt).toBeTruthy(); expect(res.body.step).toBe('complete'); }); test('invalid displayName: 400', async () => { await readyToAdmin(); const res = await request(ctx.app).post('/network/wizard/admin').send({ displayName: '' }); expect(res.status).toBe(400); }); }); /* ── completion gate ─────────────────────────────────────────────── */ describe('POST /network/wizard/complete', () => { let ctx; afterEach(() => cleanup(ctx.dir)); test('refuses (409) when checks missing — names them', async () => { ctx = createApp(); await request(ctx.app).post('/network/wizard/mode').send({ mode: 'tailscale', credentials: { oauthClientSecret: 'tskey-client-x' }, }); const res = await request(ctx.app).post('/network/wizard/complete'); expect(res.status).toBe(409); expect(res.body.code).toBe('incomplete'); expect(res.body.details.missing).toEqual(expect.arrayContaining([ 'providerConnected', 'dnsConfigured', 'caConfigured', 'adminCreated', ])); const state = await request(ctx.app).get('/network/wizard/state'); expect(state.body.completedAt).toBeNull(); }); test('all green → completedAt set', async () => { ctx = createApp(); await request(ctx.app).post('/network/wizard/mode').send({ mode: 'tailscale', credentials: { oauthClientSecret: 'tskey-client-x' }, }); await request(ctx.app).post('/network/wizard/domain').send({ tld: '.internal' }); await request(ctx.app).post('/network/wizard/connect'); await request(ctx.app).post('/network/wizard/dns'); // skipped path (no technitium) await request(ctx.app).post('/network/wizard/ca').send({ commonName: 'CA' }); // skipped path await request(ctx.app).post('/network/wizard/admin').send({ displayName: 'Sami' }); const res = await request(ctx.app).post('/network/wizard/complete'); expect(res.status).toBe(200); expect(res.body.completed).toBe(true); expect(res.body.completedAt).toBeTruthy(); const state = await request(ctx.app).get('/network/wizard/state'); expect(state.body.completedAt).toBeTruthy(); }); }); /* ── status aggregation ──────────────────────────────────────────── */ describe('GET /network/wizard/status', () => { let ctx; afterEach(() => cleanup(ctx.dir)); test('aggregation shape: wizard, online, recentConnections, sync, driftBanner', async () => { ctx = createApp({ connectionLogReader: async () => ({ events: [{ at: 't', ip: '100.64.0.2' }], note: 'event-store' }), aclStateReader: async () => ({ hash: 'abc123', lastPushAt: 't', drifted: false }), firewallStateReader: async () => ({ applied: true, lastApplyAt: 't', drifted: false }), }); await request(ctx.app).post('/network/wizard/mode').send({ mode: 'tailscale', credentials: { oauthClientSecret: 'tskey-client-x' }, }); const res = await request(ctx.app).get('/network/wizard/status'); expect(res.status).toBe(200); expect(res.body.wizard).toMatchObject({ mode: 'tailscale', privateTld: null }); expect(res.body.online).toMatchObject({ provider: 'tailscale', devicesTotal: 2, devicesOnline: 1 }); expect(Array.isArray(res.body.online.devices)).toBe(true); expect(res.body.recentConnections.events).toHaveLength(1); expect(res.body.sync.acl.hash).toBe('abc123'); expect(res.body.sync.firewall.applied).toBe(true); expect(res.body.driftBanner).toBe(false); }); test('no connectionLogReader → empty list with a note (not an error)', async () => { ctx = createApp(); const res = await request(ctx.app).get('/network/wizard/status'); expect(res.status).toBe(200); expect(res.body.recentConnections.events).toEqual([]); expect(res.body.recentConnections.note).toBeTruthy(); expect(res.body.sync).toEqual({ acl: null, firewall: null }); }); test('drift in either reader flips driftBanner', async () => { ctx = createApp({ aclStateReader: async () => ({ hash: 'x', drifted: true }), }); await request(ctx.app).post('/network/wizard/mode').send({ mode: 'tailscale', credentials: { oauthClientSecret: 'tskey-client-x' }, }); const res = await request(ctx.app).get('/network/wizard/status'); expect(res.status).toBe(200); expect(res.body.driftBanner).toBe(true); }); test('provider status failure surfaces as online.error, still 200', async () => { // listDevices works (mode validation passes); getStatus fails → the // status aggregation surfaces the error instead of 500ing. const tailscale = makeProvider({ id: () => 'tailscale', getStatus: async () => { throw new Error('daemon down'); }, }); ctx = createApp({ tailscale }); await request(ctx.app).post('/network/wizard/mode').send({ mode: 'tailscale', credentials: { oauthClientSecret: 'tskey-client-x' }, }); const res = await request(ctx.app).get('/network/wizard/status'); expect(res.status).toBe(200); expect(res.body.online.error).toBe('daemon down'); }); test('before any mode: honest empty shape', async () => { ctx = createApp(); const res = await request(ctx.app).get('/network/wizard/status'); expect(res.status).toBe(200); expect(res.body.wizard.mode).toBeNull(); expect(res.body.online.devices).toEqual([]); expect(res.body.driftBanner).toBe(false); }); });