Files
Catalyst/tests/api.test.js
josh 0b350f3b28
All checks were successful
CI / test (pull_request) Successful in 14s
CI / build-dev (pull_request) Has been skipped
feat: add Patchmon Sync job
Syncs patchmon field on instances by querying the Patchmon hosts API
and matching hostnames. API token masked as REDACTED in responses.
seedJobs now uses INSERT OR IGNORE so new jobs are seeded on existing
installs without re-running the full seed.

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

589 lines
24 KiB
JavaScript

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import request from 'supertest'
import { app } from '../server/server.js'
import { _resetForTest, createJob } 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('<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)
)
})
})
const testJob = {
key: 'tailscale_sync', name: 'Tailscale Sync', description: 'Test job',
enabled: 0, schedule: 15,
config: JSON.stringify({ api_key: 'tskey-test', tailnet: 'example.com' }),
}
const patchmonJob = {
key: 'patchmon_sync', name: 'Patchmon Sync', description: 'Test patchmon job',
enabled: 0, schedule: 60,
config: JSON.stringify({ api_url: 'http://patchmon:3000/api/v1/api/hosts', api_token: 'secret-token' }),
}
// ── GET /api/jobs ─────────────────────────────────────────────────────────────
describe('GET /api/jobs', () => {
it('returns empty array when no jobs', async () => {
const res = await request(app).get('/api/jobs')
expect(res.status).toBe(200)
expect(res.body).toEqual([])
})
it('returns jobs with masked api key', async () => {
createJob(testJob)
const res = await request(app).get('/api/jobs')
expect(res.body).toHaveLength(1)
expect(res.body[0].config.api_key).toBe('**REDACTED**')
})
it('returns jobs with masked api_token', async () => {
createJob(patchmonJob)
const res = await request(app).get('/api/jobs')
expect(res.body[0].config.api_token).toBe('**REDACTED**')
})
})
// ── GET /api/jobs/:id ─────────────────────────────────────────────────────────
describe('GET /api/jobs/:id', () => {
it('returns job with runs array', async () => {
createJob(testJob)
const id = (await request(app).get('/api/jobs')).body[0].id
const res = await request(app).get(`/api/jobs/${id}`)
expect(res.status).toBe(200)
expect(res.body.runs).toBeInstanceOf(Array)
})
it('returns 404 for unknown id', async () => {
expect((await request(app).get('/api/jobs/999')).status).toBe(404)
})
it('returns 400 for non-numeric id', async () => {
expect((await request(app).get('/api/jobs/abc')).status).toBe(400)
})
})
// ── PUT /api/jobs/:id ─────────────────────────────────────────────────────────
describe('PUT /api/jobs/:id', () => {
it('updates enabled and schedule', async () => {
createJob(testJob)
const id = (await request(app).get('/api/jobs')).body[0].id
const res = await request(app).put(`/api/jobs/${id}`).send({ enabled: true, schedule: 30 })
expect(res.status).toBe(200)
expect(res.body.enabled).toBe(1)
expect(res.body.schedule).toBe(30)
})
it('does not overwrite api_key when **REDACTED** is sent', async () => {
createJob(testJob)
const id = (await request(app).get('/api/jobs')).body[0].id
await request(app).put(`/api/jobs/${id}`).send({ config: { api_key: '**REDACTED**' } })
expect(dbModule.getJob(id).config).toContain('tskey-test')
})
it('returns 404 for unknown id', async () => {
expect((await request(app).put('/api/jobs/999').send({})).status).toBe(404)
})
})
// ── POST /api/jobs/:id/run ────────────────────────────────────────────────────
describe('POST /api/jobs/:id/run', () => {
afterEach(() => vi.unstubAllGlobals())
it('returns 404 for unknown id', async () => {
expect((await request(app).post('/api/jobs/999/run')).status).toBe(404)
})
it('runs job, returns summary, and logs the run', async () => {
createJob(testJob)
const id = (await request(app).get('/api/jobs')).body[0].id
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({ devices: [] }),
}))
const res = await request(app).post(`/api/jobs/${id}/run`)
expect(res.status).toBe(200)
expect(res.body.summary).toBeDefined()
const detail = await request(app).get(`/api/jobs/${id}`)
expect(detail.body.runs).toHaveLength(1)
expect(detail.body.runs[0].status).toBe('success')
})
it('logs error run on failure', async () => {
createJob(testJob)
const id = (await request(app).get('/api/jobs')).body[0].id
vi.stubGlobal('fetch', vi.fn().mockRejectedValueOnce(new Error('network error')))
const res = await request(app).post(`/api/jobs/${id}/run`)
expect(res.status).toBe(500)
const detail = await request(app).get(`/api/jobs/${id}`)
expect(detail.body.runs[0].status).toBe('error')
})
it('patchmon_sync: marks instances present in host list as patchmon=1', async () => {
createJob(patchmonJob)
const id = (await request(app).get('/api/jobs')).body[0].id
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => [{ name: 'plex' }, { name: 'traefik' }],
}))
const res = await request(app).post(`/api/jobs/${id}/run`)
expect(res.status).toBe(200)
expect(res.body.summary).toMatch(/updated of/)
})
it('patchmon_sync: returns 500 when API token is missing', async () => {
createJob({ ...patchmonJob, config: JSON.stringify({ api_url: 'http://patchmon:3000/api/v1/api/hosts', api_token: '' }) })
const id = (await request(app).get('/api/jobs')).body[0].id
const res = await request(app).post(`/api/jobs/${id}/run`)
expect(res.status).toBe(500)
})
})