Jobs with run_on_create=true in their config fire automatically after a new instance is created. Runs fire-and-forget so they don't delay the 201 response. Option exposed as a checkbox in each job's detail panel. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
662 lines
28 KiB
JavaScript
662 lines
28 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 3', async () => {
|
|
const res = await request(app).get('/api/export')
|
|
expect(res.body.version).toBe(3)
|
|
})
|
|
|
|
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)
|
|
})
|
|
|
|
it('includes jobs and job_runs arrays', async () => {
|
|
createJob(testJob)
|
|
const res = await request(app).get('/api/export')
|
|
expect(res.body.jobs).toBeInstanceOf(Array)
|
|
expect(res.body.jobs).toHaveLength(1)
|
|
expect(res.body.jobs[0].key).toBe('tailscale_sync')
|
|
expect(res.body.job_runs).toBeInstanceOf(Array)
|
|
})
|
|
|
|
it('exports raw job config without masking', async () => {
|
|
createJob(testJob)
|
|
const res = await request(app).get('/api/export')
|
|
expect(res.body.jobs[0].config).toContain('tskey-test')
|
|
})
|
|
})
|
|
|
|
// ── 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)
|
|
})
|
|
|
|
it('imports jobs and job_runs and returns imported_jobs count', async () => {
|
|
const exp = await request(app).get('/api/export')
|
|
createJob(testJob)
|
|
const fullExport = await request(app).get('/api/export')
|
|
const res = await request(app).post('/api/import').send({
|
|
instances: fullExport.body.instances,
|
|
history: fullExport.body.history,
|
|
jobs: fullExport.body.jobs,
|
|
job_runs: fullExport.body.job_runs,
|
|
})
|
|
expect(res.status).toBe(200)
|
|
expect(res.body.imported_jobs).toBe(1)
|
|
expect((await request(app).get('/api/jobs')).body).toHaveLength(1)
|
|
})
|
|
|
|
it('leaves jobs untouched when no jobs key in payload', async () => {
|
|
createJob(testJob)
|
|
await request(app).post('/api/import')
|
|
.send({ instances: [{ ...base, vmid: 1, name: 'x' }] })
|
|
expect((await request(app).get('/api/jobs')).body).toHaveLength(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)
|
|
})
|
|
|
|
it('run_on_create: triggers matching jobs when an instance is created', async () => {
|
|
createJob({ ...testJob, config: JSON.stringify({ api_key: 'k', tailnet: 't', run_on_create: true }) })
|
|
const id = (await request(app).get('/api/jobs')).body[0].id
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ devices: [] }) }))
|
|
await request(app).post('/api/instances').send(base)
|
|
await new Promise(r => setImmediate(r))
|
|
const detail = await request(app).get(`/api/jobs/${id}`)
|
|
expect(detail.body.runs).toHaveLength(1)
|
|
expect(detail.body.runs[0].status).toBe('success')
|
|
})
|
|
|
|
it('run_on_create: does not trigger jobs without the flag', async () => {
|
|
createJob(testJob)
|
|
const id = (await request(app).get('/api/jobs')).body[0].id
|
|
await request(app).post('/api/instances').send(base)
|
|
await new Promise(r => setImmediate(r))
|
|
expect((await request(app).get(`/api/jobs/${id}`)).body.runs).toHaveLength(0)
|
|
})
|
|
|
|
it('semaphore_sync: parses ansible inventory and updates instances', async () => {
|
|
const semaphoreJob = {
|
|
key: 'semaphore_sync', name: 'Semaphore Sync', description: 'test',
|
|
enabled: 0, schedule: 60,
|
|
config: JSON.stringify({ api_url: 'http://semaphore:3000/api/project/1/inventory/1', api_token: 'bearer-token' }),
|
|
}
|
|
createJob(semaphoreJob)
|
|
const id = (await request(app).get('/api/jobs')).body[0].id
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({ inventory: '[production]\nplex\nhomeassistant\n' }),
|
|
}))
|
|
const res = await request(app).post(`/api/jobs/${id}/run`)
|
|
expect(res.status).toBe(200)
|
|
expect(res.body.summary).toMatch(/updated of/)
|
|
})
|
|
})
|