import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import request from 'supertest' import { app } from '../server/server.js' import { _resetForTest } from '../server/db.js' import * as dbModule from '../server/db.js' beforeEach(() => _resetForTest()) const base = { name: 'traefik', vmid: 100, state: 'deployed', stack: 'production', atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0, } // ── GET /api/instances ──────────────────────────────────────────────────────── describe('GET /api/instances', () => { it('returns empty array when no instances exist', async () => { const res = await request(app).get('/api/instances') expect(res.status).toBe(200) expect(res.body).toEqual([]) }) it('returns all instances sorted by name', async () => { await request(app).post('/api/instances').send({ ...base, vmid: 1, name: 'zebra' }) await request(app).post('/api/instances').send({ ...base, vmid: 2, name: 'alpha' }) const res = await request(app).get('/api/instances') expect(res.status).toBe(200) expect(res.body).toHaveLength(2) expect(res.body[0].name).toBe('alpha') expect(res.body[1].name).toBe('zebra') }) it('filters by state', async () => { await request(app).post('/api/instances').send({ ...base, vmid: 1, name: 'a', state: 'deployed' }) await request(app).post('/api/instances').send({ ...base, vmid: 2, name: 'b', state: 'degraded' }) const res = await request(app).get('/api/instances?state=deployed') expect(res.body).toHaveLength(1) expect(res.body[0].name).toBe('a') }) it('filters by stack', async () => { await request(app).post('/api/instances').send({ ...base, vmid: 1, name: 'a', stack: 'production' }) await request(app).post('/api/instances').send({ ...base, vmid: 2, name: 'b', stack: 'development', state: 'testing' }) const res = await request(app).get('/api/instances?stack=development') expect(res.body).toHaveLength(1) expect(res.body[0].name).toBe('b') }) it('searches by name substring', async () => { await request(app).post('/api/instances').send({ ...base, vmid: 1, name: 'plex' }) await request(app).post('/api/instances').send({ ...base, vmid: 2, name: 'gitea' }) const res = await request(app).get('/api/instances?search=ple') expect(res.body).toHaveLength(1) expect(res.body[0].name).toBe('plex') }) it('searches by vmid', async () => { await request(app).post('/api/instances').send({ ...base, vmid: 137, name: 'a' }) await request(app).post('/api/instances').send({ ...base, vmid: 200, name: 'b' }) const res = await request(app).get('/api/instances?search=137') expect(res.body).toHaveLength(1) expect(res.body[0].vmid).toBe(137) }) it('combines search and state filters', async () => { await request(app).post('/api/instances').send({ ...base, vmid: 1, name: 'plex', state: 'deployed' }) await request(app).post('/api/instances').send({ ...base, vmid: 2, name: 'plex2', state: 'degraded' }) const res = await request(app).get('/api/instances?search=plex&state=deployed') expect(res.body).toHaveLength(1) expect(res.body[0].name).toBe('plex') }) }) // ── GET /api/instances/stacks ───────────────────────────────────────────────── describe('GET /api/instances/stacks', () => { it('returns empty array when no instances exist', async () => { const res = await request(app).get('/api/instances/stacks') expect(res.status).toBe(200) expect(res.body).toEqual([]) }) it('returns unique stacks sorted alphabetically', async () => { await request(app).post('/api/instances').send({ ...base, vmid: 1, name: 'a', stack: 'production' }) await request(app).post('/api/instances').send({ ...base, vmid: 2, name: 'b', stack: 'development', state: 'testing' }) await request(app).post('/api/instances').send({ ...base, vmid: 3, name: 'c', stack: 'production' }) const res = await request(app).get('/api/instances/stacks') expect(res.body).toEqual(['development', 'production']) }) }) // ── GET /api/instances/:vmid ────────────────────────────────────────────────── describe('GET /api/instances/:vmid', () => { it('returns the instance for a known vmid', async () => { await request(app).post('/api/instances').send({ ...base, vmid: 117, name: 'plex' }) const res = await request(app).get('/api/instances/117') expect(res.status).toBe(200) expect(res.body.name).toBe('plex') expect(res.body.vmid).toBe(117) }) it('returns 404 for unknown vmid', async () => { const res = await request(app).get('/api/instances/999') expect(res.status).toBe(404) expect(res.body.error).toBeDefined() }) it('returns 400 for non-numeric vmid', async () => { const res = await request(app).get('/api/instances/abc') expect(res.status).toBe(400) }) }) // ── POST /api/instances ─────────────────────────────────────────────────────── describe('POST /api/instances', () => { it('creates an instance and returns 201 with the created record', async () => { const res = await request(app).post('/api/instances').send(base) expect(res.status).toBe(201) expect(res.body.name).toBe('traefik') expect(res.body.vmid).toBe(100) expect(res.body.created_at).not.toBeNull() expect(res.body.updated_at).not.toBeNull() }) it('stores service flags correctly', async () => { const res = await request(app).post('/api/instances').send({ ...base, atlas: 1, tailscale: 1, hardware_acceleration: 1 }) expect(res.body.atlas).toBe(1) expect(res.body.tailscale).toBe(1) expect(res.body.hardware_acceleration).toBe(1) expect(res.body.argus).toBe(0) }) it('returns 409 for duplicate vmid', async () => { await request(app).post('/api/instances').send(base) const res = await request(app).post('/api/instances').send({ ...base, name: 'other' }) expect(res.status).toBe(409) expect(res.body.error).toMatch(/vmid/) }) it('returns 400 when name is missing', async () => { const res = await request(app).post('/api/instances').send({ ...base, name: '' }) expect(res.status).toBe(400) expect(res.body.errors).toBeInstanceOf(Array) }) it('returns 400 for vmid less than 1', async () => { const res = await request(app).post('/api/instances').send({ ...base, vmid: 0 }) expect(res.status).toBe(400) expect(res.body.errors).toBeInstanceOf(Array) }) it('returns 400 for invalid state', async () => { const res = await request(app).post('/api/instances').send({ ...base, state: 'invalid' }) expect(res.status).toBe(400) }) it('returns 400 for invalid stack', async () => { const res = await request(app).post('/api/instances').send({ ...base, stack: 'invalid' }) expect(res.status).toBe(400) }) it('trims whitespace from name', async () => { const res = await request(app).post('/api/instances').send({ ...base, name: ' plex ' }) expect(res.status).toBe(201) expect(res.body.name).toBe('plex') }) }) // ── PUT /api/instances/:vmid ────────────────────────────────────────────────── describe('PUT /api/instances/:vmid', () => { it('updates fields and returns the updated record', async () => { await request(app).post('/api/instances').send(base) const res = await request(app).put('/api/instances/100').send({ ...base, name: 'updated', state: 'degraded' }) expect(res.status).toBe(200) expect(res.body.name).toBe('updated') expect(res.body.state).toBe('degraded') }) it('can change the vmid', async () => { await request(app).post('/api/instances').send(base) await request(app).put('/api/instances/100').send({ ...base, vmid: 200 }) expect((await request(app).get('/api/instances/100')).status).toBe(404) expect((await request(app).get('/api/instances/200')).status).toBe(200) }) it('returns 404 for unknown vmid', async () => { const res = await request(app).put('/api/instances/999').send(base) expect(res.status).toBe(404) }) it('returns 400 for validation errors', async () => { await request(app).post('/api/instances').send(base) const res = await request(app).put('/api/instances/100').send({ ...base, name: '' }) expect(res.status).toBe(400) expect(res.body.errors).toBeInstanceOf(Array) }) it('returns 409 when new vmid conflicts with an existing instance', async () => { await request(app).post('/api/instances').send({ ...base, vmid: 100, name: 'a' }) await request(app).post('/api/instances').send({ ...base, vmid: 200, name: 'b' }) const res = await request(app).put('/api/instances/100').send({ ...base, vmid: 200 }) expect(res.status).toBe(409) }) }) // ── DELETE /api/instances/:vmid ─────────────────────────────────────────────── describe('DELETE /api/instances/:vmid', () => { it('deletes a development instance and returns 204', async () => { await request(app).post('/api/instances').send({ ...base, stack: 'development', state: 'testing' }) const res = await request(app).delete('/api/instances/100') expect(res.status).toBe(204) expect((await request(app).get('/api/instances/100')).status).toBe(404) }) it('returns 422 when attempting to delete a production instance', async () => { await request(app).post('/api/instances').send({ ...base, stack: 'production' }) const res = await request(app).delete('/api/instances/100') expect(res.status).toBe(422) expect(res.body.error).toMatch(/development/) }) it('returns 404 for unknown vmid', async () => { const res = await request(app).delete('/api/instances/999') expect(res.status).toBe(404) }) it('returns 400 for non-numeric vmid', async () => { const res = await request(app).delete('/api/instances/abc') expect(res.status).toBe(400) }) }) // ── GET /api/instances/:vmid/history ───────────────────────────────────────── describe('GET /api/instances/:vmid/history', () => { it('returns history events for a known vmid', async () => { await request(app).post('/api/instances').send(base) const res = await request(app).get('/api/instances/100/history') expect(res.status).toBe(200) expect(res.body).toBeInstanceOf(Array) expect(res.body[0].field).toBe('created') }) it('returns 404 for unknown vmid', async () => { expect((await request(app).get('/api/instances/999/history')).status).toBe(404) }) it('returns 400 for non-numeric vmid', async () => { expect((await request(app).get('/api/instances/abc/history')).status).toBe(400) }) }) // ── GET /api/export ─────────────────────────────────────────────────────────── describe('GET /api/export', () => { it('returns 200 with instances array and attachment header', async () => { await request(app).post('/api/instances').send(base) const res = await request(app).get('/api/export') expect(res.status).toBe(200) expect(res.headers['content-disposition']).toMatch(/attachment/) expect(res.body.instances).toHaveLength(1) expect(res.body.instances[0].name).toBe('traefik') }) it('returns empty instances array when no data', async () => { const res = await request(app).get('/api/export') expect(res.body.instances).toEqual([]) }) it('returns version 2', async () => { const res = await request(app).get('/api/export') expect(res.body.version).toBe(2) }) it('includes a history array', async () => { await request(app).post('/api/instances').send(base) const res = await request(app).get('/api/export') expect(res.body.history).toBeInstanceOf(Array) expect(res.body.history.some(e => e.field === 'created')).toBe(true) }) }) // ── POST /api/import ────────────────────────────────────────────────────────── describe('POST /api/import', () => { it('replaces all instances and returns imported count', async () => { await request(app).post('/api/instances').send(base) const res = await request(app).post('/api/import') .send({ instances: [{ ...base, vmid: 999, name: 'imported' }] }) expect(res.status).toBe(200) expect(res.body.imported).toBe(1) expect((await request(app).get('/api/instances')).body[0].name).toBe('imported') }) it('returns 400 if instances is not an array', async () => { expect((await request(app).post('/api/import').send({ instances: 'bad' })).status).toBe(400) }) it('returns 400 with per-row errors for invalid rows', async () => { const res = await request(app).post('/api/import') .send({ instances: [{ ...base, name: '', vmid: 1 }] }) expect(res.status).toBe(400) expect(res.body.errors[0].index).toBe(0) }) it('returns 400 if body has no instances key', async () => { expect((await request(app).post('/api/import').send({})).status).toBe(400) }) it('returns 400 (not 500) when a row is missing name', async () => { const res = await request(app).post('/api/import') .send({ instances: [{ ...base, name: undefined, vmid: 1 }] }) expect(res.status).toBe(400) }) it('restores history when history array is provided', async () => { await request(app).post('/api/instances').send(base) const exp = await request(app).get('/api/export') await request(app).post('/api/instances').send({ ...base, vmid: 999, name: 'other' }) const res = await request(app).post('/api/import').send({ instances: exp.body.instances, history: exp.body.history, }) expect(res.status).toBe(200) const hist = await request(app).get('/api/instances/100/history') expect(hist.body.some(e => e.field === 'created')).toBe(true) }) it('succeeds with a v1 backup that has no history key', async () => { const res = await request(app).post('/api/import') .send({ instances: [{ ...base, vmid: 1, name: 'legacy' }] }) expect(res.status).toBe(200) expect(res.body.imported).toBe(1) }) }) // ── Static assets & SPA routing ─────────────────────────────────────────────── describe('static assets and SPA routing', () => { it('serves index.html at root', async () => { const res = await request(app).get('/') expect(res.status).toBe(200) expect(res.headers['content-type']).toMatch(/html/) }) it('serves index.html for deep SPA routes (e.g. /instance/117)', async () => { const res = await request(app).get('/instance/117') expect(res.status).toBe(200) expect(res.headers['content-type']).toMatch(/html/) }) it('serves CSS with correct content-type (not sniffed as HTML)', async () => { const res = await request(app).get('/css/app.css') expect(res.status).toBe(200) expect(res.headers['content-type']).toMatch(/text\/css/) }) it('does not set upgrade-insecure-requests in CSP (HTTP deployments must work)', async () => { const res = await request(app).get('/') const csp = res.headers['content-security-policy'] ?? '' expect(csp).not.toContain('upgrade-insecure-requests') }) it('allows inline event handlers in CSP (onclick attributes)', async () => { const res = await request(app).get('/') const csp = res.headers['content-security-policy'] ?? '' // script-src-attr must not be 'none' — that blocks onclick handlers expect(csp).not.toContain("script-src-attr 'none'") }) it('index.html contains base href / for correct asset resolution on deep routes', async () => { const res = await request(app).get('/') expect(res.text).toContain('') }) }) // ── Error handling — unexpected DB failures ─────────────────────────────────── const dbError = () => Object.assign( new Error('attempt to write a readonly database'), { code: 'ERR_SQLITE_ERROR', errcode: 8 } ) describe('error handling — unexpected DB failures', () => { let consoleSpy beforeEach(() => { consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) }) afterEach(() => { vi.restoreAllMocks() }) it('POST returns 500 with friendly message when DB throws unexpectedly', async () => { vi.spyOn(dbModule, 'createInstance').mockImplementationOnce(() => { throw dbError() }) const res = await request(app).post('/api/instances').send(base) expect(res.status).toBe(500) expect(res.body).toEqual({ error: 'internal server error' }) }) it('POST logs the error with route context when DB throws unexpectedly', async () => { vi.spyOn(dbModule, 'createInstance').mockImplementationOnce(() => { throw dbError() }) await request(app).post('/api/instances').send(base) expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('POST /api/instances'), expect.any(Error) ) }) it('PUT returns 500 with friendly message when DB throws unexpectedly', async () => { await request(app).post('/api/instances').send(base) vi.spyOn(dbModule, 'updateInstance').mockImplementationOnce(() => { throw dbError() }) const res = await request(app).put('/api/instances/100').send(base) expect(res.status).toBe(500) expect(res.body).toEqual({ error: 'internal server error' }) }) it('PUT logs the error with route context when DB throws unexpectedly', async () => { await request(app).post('/api/instances').send(base) vi.spyOn(dbModule, 'updateInstance').mockImplementationOnce(() => { throw dbError() }) await request(app).put('/api/instances/100').send(base) expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('PUT /api/instances/:vmid'), expect.any(Error) ) }) it('DELETE returns 500 with friendly message when DB throws unexpectedly', async () => { await request(app).post('/api/instances').send({ ...base, stack: 'development', state: 'testing' }) vi.spyOn(dbModule, 'deleteInstance').mockImplementationOnce(() => { throw dbError() }) const res = await request(app).delete('/api/instances/100') expect(res.status).toBe(500) expect(res.body).toEqual({ error: 'internal server error' }) }) it('DELETE logs the error with route context when DB throws unexpectedly', async () => { await request(app).post('/api/instances').send({ ...base, stack: 'development', state: 'testing' }) vi.spyOn(dbModule, 'deleteInstance').mockImplementationOnce(() => { throw dbError() }) await request(app).delete('/api/instances/100') expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('DELETE /api/instances/:vmid'), expect.any(Error) ) }) }) // ── GET /api/config ─────────────────────────────────────────────────────────── describe('GET /api/config', () => { it('returns 200 with all config keys', async () => { const res = await request(app).get('/api/config') expect(res.status).toBe(200) expect(res.body).toHaveProperty('tailscale_enabled') expect(res.body).toHaveProperty('tailscale_api_key') expect(res.body).toHaveProperty('tailscale_poll_minutes') }) it('returns empty string for api key when not set', async () => { expect((await request(app).get('/api/config')).body.tailscale_api_key).toBe('') }) it('masks api key as **REDACTED** when set', async () => { await request(app).put('/api/config').send({ tailscale_api_key: 'tskey-secret' }) expect((await request(app).get('/api/config')).body.tailscale_api_key).toBe('**REDACTED**') }) }) // ── PUT /api/config ─────────────────────────────────────────────────────────── describe('PUT /api/config', () => { it('saves config and returns ok', async () => { const res = await request(app).put('/api/config').send({ tailscale_tailnet: 'example.com' }) expect(res.status).toBe(200) expect(res.body.ok).toBe(true) }) it('does not overwrite api key when **REDACTED** is sent', async () => { await request(app).put('/api/config').send({ tailscale_api_key: 'real-key' }) await request(app).put('/api/config').send({ tailscale_api_key: '**REDACTED**' }) expect(dbModule.getConfig('tailscale_api_key')).toBe('real-key') }) }) // ── POST /api/jobs/tailscale/run ────────────────────────────────────────────── describe('POST /api/jobs/tailscale/run', () => { afterEach(() => vi.unstubAllGlobals()) it('returns 400 when not configured', async () => { const res = await request(app).post('/api/jobs/tailscale/run') expect(res.status).toBe(400) expect(res.body.error).toMatch(/not configured/i) }) it('updates matching instance and returns count', async () => { await request(app).put('/api/config').send({ tailscale_api_key: 'tskey-test', tailscale_tailnet: 'example.com', }) await request(app).post('/api/instances').send({ ...base, name: 'traefik', vmid: 100 }) vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ ok: true, json: async () => ({ devices: [{ hostname: 'traefik', addresses: ['100.64.0.2'] }] }), })) const res = await request(app).post('/api/jobs/tailscale/run') expect(res.status).toBe(200) expect(res.body.updated).toBe(1) const inst = await request(app).get('/api/instances/100') expect(inst.body.tailscale).toBe(1) expect(inst.body.tailscale_ip).toBe('100.64.0.2') }) })