Files
Catalyst/tests/api.test.js
josh 15ed329743
All checks were successful
CI / test (pull_request) Successful in 9m32s
fix: db volume ownership and explicit error handling for write failures
Root cause of the 500 on create/update/delete: the non-root app user in
the Docker container lacked write permission to the volume mount point.
Docker volume mounts are owned by root by default; the app user (added
in a previous commit) could read the database but not write to it.

Fixes:

1. Dockerfile — RUN mkdir -p /app/data before chown so the directory
   exists in the image with correct ownership. Docker uses this as a
   seed when initialising a new named volume, ensuring the app user
   owns the mount point from the start.

   NOTE: existing volumes from before the non-root user was introduced
   will still be root-owned. Fix with:
     docker run --rm -v catalyst-data:/data alpine chown -R 1000:1000 /data

2. server/routes.js — replace bare `throw e` in POST/PUT catch blocks
   with console.error (route context + error) + explicit 500 response.
   Add try-catch to DELETE handler which previously had none. Unexpected
   DB errors now log the route they came from and return a clean JSON
   body instead of relying on the generic Express error handler.

3. server/db.js — wrap the boot init() call in try-catch. Fatal startup
   errors (e.g. data directory not writable) now print a clear message
   pointing to the cause before exiting, instead of a raw stack trace.

TDD: tests written first (RED), then fixed (GREEN). Six new tests in
tests/api.test.js verify that unexpected DB errors on POST, PUT, and
DELETE return 500 with { error: 'internal server error' } and call
console.error with the route context string.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 11:11:00 -04:00

352 lines
15 KiB
JavaScript

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)
})
})
// ── 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('<base href="/">')
})
})
// ── 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)
)
})
})