- Add fmtHistVal and stateClass helper tests (7 new, 106 total) - Add import regression test: missing name field returns 400 not 500 - Fix normalise() crash on missing name: body.name.trim() → (body.name ?? '').trim() - Extract duplicate DB error handler into handleDbError() helper - Rewrite README from scratch with audit log, export/import, full API docs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
225 lines
8.0 KiB
JavaScript
225 lines
8.0 KiB
JavaScript
// @vitest-environment jsdom
|
|
import { describe, it, expect } from 'vitest'
|
|
import { readFileSync } from 'fs'
|
|
import { join } from 'path'
|
|
|
|
// ── esc() ─────────────────────────────────────────────────────────────────────
|
|
// Mirrors the implementation in ui.js exactly (DOM-based).
|
|
// Tests the XSS contract — if the implementation changes, these define
|
|
// what it must still guarantee.
|
|
|
|
function esc(str) {
|
|
const d = document.createElement('div')
|
|
d.textContent = (str == null) ? '' : String(str)
|
|
return d.innerHTML
|
|
}
|
|
|
|
describe('esc', () => {
|
|
it('passes through plain strings unchanged', () => {
|
|
expect(esc('plex')).toBe('plex')
|
|
expect(esc('postgres-primary')).toBe('postgres-primary')
|
|
})
|
|
|
|
it('escapes < and >', () => {
|
|
expect(esc('<script>')).toBe('<script>')
|
|
expect(esc('</script>')).toBe('</script>')
|
|
})
|
|
|
|
it('neutralises a script injection payload', () => {
|
|
const payload = '<script>alert(1)</script>'
|
|
expect(esc(payload)).not.toContain('<script>')
|
|
})
|
|
|
|
it('neutralises an img onerror payload', () => {
|
|
const result = esc('<img src=x onerror=alert(1)>')
|
|
expect(result).not.toContain('<img')
|
|
expect(result).toContain('<img')
|
|
expect(result).toContain('>')
|
|
})
|
|
|
|
it('escapes ampersands', () => {
|
|
expect(esc('a & b')).toBe('a & b')
|
|
})
|
|
|
|
it('handles null without throwing', () => {
|
|
expect(() => esc(null)).not.toThrow()
|
|
expect(esc(null)).toBe('')
|
|
})
|
|
|
|
it('handles undefined without throwing', () => {
|
|
expect(() => esc(undefined)).not.toThrow()
|
|
expect(esc(undefined)).toBe('')
|
|
})
|
|
|
|
it('coerces numbers to string', () => {
|
|
expect(esc(137)).toBe('137')
|
|
})
|
|
})
|
|
|
|
// ── fmtDate() ─────────────────────────────────────────────────────────────────
|
|
|
|
function fmtDate(d) {
|
|
if (!d) return '—'
|
|
try {
|
|
return new Date(d).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
|
|
} catch (e) { return d }
|
|
}
|
|
|
|
describe('fmtDate', () => {
|
|
it('formats a valid ISO date string', () => {
|
|
const result = fmtDate('2024-03-15T00:00:00')
|
|
expect(result).toMatch(/Mar/)
|
|
expect(result).toMatch(/15/)
|
|
expect(result).toMatch(/2024/)
|
|
})
|
|
|
|
it('returns — for null', () => {
|
|
expect(fmtDate(null)).toBe('—')
|
|
})
|
|
|
|
it('returns — for empty string', () => {
|
|
expect(fmtDate('')).toBe('—')
|
|
})
|
|
|
|
it('returns — for undefined', () => {
|
|
expect(fmtDate(undefined)).toBe('—')
|
|
})
|
|
})
|
|
|
|
// ── fmtDateFull() ─────────────────────────────────────────────────────────────
|
|
|
|
function fmtDateFull(d) {
|
|
if (!d) return '—'
|
|
try {
|
|
return new Date(d).toLocaleString('en-US', {
|
|
year: 'numeric', month: 'short', day: 'numeric',
|
|
hour: '2-digit', minute: '2-digit',
|
|
})
|
|
} catch (e) { return d }
|
|
}
|
|
|
|
describe('fmtDateFull', () => {
|
|
it('includes date and time components', () => {
|
|
const result = fmtDateFull('2024-03-15T14:30:00')
|
|
expect(result).toMatch(/Mar/)
|
|
expect(result).toMatch(/2024/)
|
|
expect(result).toMatch(/\d{1,2}:\d{2}/)
|
|
})
|
|
|
|
it('returns — for null', () => {
|
|
expect(fmtDateFull(null)).toBe('—')
|
|
})
|
|
|
|
it('returns — for empty string', () => {
|
|
expect(fmtDateFull('')).toBe('—')
|
|
})
|
|
})
|
|
|
|
// ── versionLabel() ───────────────────────────────────────────────────────────
|
|
// Mirrors the logic in app.js — semver strings get a v prefix, dev strings don't.
|
|
|
|
function versionLabel(v) {
|
|
return /^\d/.test(v) ? `v${v}` : v
|
|
}
|
|
|
|
describe('version label formatting', () => {
|
|
it('prepends v for semver strings', () => {
|
|
expect(versionLabel('1.1.2')).toBe('v1.1.2')
|
|
expect(versionLabel('2.0.0')).toBe('v2.0.0')
|
|
})
|
|
|
|
it('does not prepend v for dev build strings', () => {
|
|
expect(versionLabel('dev-abc1234')).toBe('dev-abc1234')
|
|
})
|
|
})
|
|
|
|
// ── fmtHistVal() ─────────────────────────────────────────────────────────────
|
|
// Mirrors the logic in ui.js — formats history field values for display.
|
|
|
|
const BOOL_FIELDS = ['atlas','argus','semaphore','patchmon','tailscale','andromeda','hardware_acceleration']
|
|
|
|
function fmtHistVal(field, val) {
|
|
if (val == null || val === '') return '—'
|
|
if (BOOL_FIELDS.includes(field)) return val === '1' ? 'on' : 'off'
|
|
return val
|
|
}
|
|
|
|
describe('fmtHistVal', () => {
|
|
it('returns — for null', () => {
|
|
expect(fmtHistVal('state', null)).toBe('—')
|
|
})
|
|
|
|
it('returns — for empty string', () => {
|
|
expect(fmtHistVal('state', '')).toBe('—')
|
|
})
|
|
|
|
it('returns on/off for boolean service fields', () => {
|
|
expect(fmtHistVal('atlas', '1')).toBe('on')
|
|
expect(fmtHistVal('atlas', '0')).toBe('off')
|
|
expect(fmtHistVal('hardware_acceleration', '1')).toBe('on')
|
|
})
|
|
|
|
it('returns the value as-is for non-boolean fields', () => {
|
|
expect(fmtHistVal('state', 'deployed')).toBe('deployed')
|
|
expect(fmtHistVal('name', 'plex')).toBe('plex')
|
|
expect(fmtHistVal('tailscale_ip', '100.64.0.1')).toBe('100.64.0.1')
|
|
})
|
|
})
|
|
|
|
// ── stateClass() ─────────────────────────────────────────────────────────────
|
|
// Mirrors the logic in ui.js — maps state values to timeline CSS classes.
|
|
|
|
function stateClass(field, val) {
|
|
if (field !== 'state') return ''
|
|
return { deployed: 'tl-deployed', testing: 'tl-testing', degraded: 'tl-degraded' }[val] ?? ''
|
|
}
|
|
|
|
describe('stateClass', () => {
|
|
it('returns empty string for non-state fields', () => {
|
|
expect(stateClass('name', 'plex')).toBe('')
|
|
expect(stateClass('stack', 'production')).toBe('')
|
|
})
|
|
|
|
it('returns the correct colour class for each state value', () => {
|
|
expect(stateClass('state', 'deployed')).toBe('tl-deployed')
|
|
expect(stateClass('state', 'testing')).toBe('tl-testing')
|
|
expect(stateClass('state', 'degraded')).toBe('tl-degraded')
|
|
})
|
|
|
|
it('returns empty string for unknown state values', () => {
|
|
expect(stateClass('state', 'unknown')).toBe('')
|
|
})
|
|
})
|
|
|
|
// ── CSS regressions ───────────────────────────────────────────────────────────
|
|
|
|
const css = readFileSync(join(__dirname, '../css/app.css'), 'utf8')
|
|
|
|
describe('CSS regressions', () => {
|
|
it('.badge has text-align: center so state labels are not left-skewed on cards', () => {
|
|
// Regression: badges rendered left-aligned inside the card's flex-end column.
|
|
// Without text-align: center, short labels (e.g. "deployed") appear
|
|
// left-justified inside their pill rather than centred.
|
|
expect(css).toMatch(/\.badge\s*\{[^}]*text-align\s*:\s*center/s)
|
|
})
|
|
})
|
|
|
|
// ── CI workflow regressions ───────────────────────────────────────────────────
|
|
|
|
const ciYml = readFileSync(join(__dirname, '../.gitea/workflows/ci.yml'), 'utf8')
|
|
|
|
describe('CI workflow regressions', () => {
|
|
it('build-dev job passes BUILD_VERSION build arg', () => {
|
|
// Regression: dev image showed semver instead of dev-<sha> because
|
|
// BUILD_VERSION was never passed to docker build.
|
|
expect(ciYml).toContain('BUILD_VERSION')
|
|
})
|
|
|
|
it('short SHA is computed with git rev-parse, not $GITEA_SHA (which is empty)', () => {
|
|
// Regression: ${GITEA_SHA::7} expands to "" on Gitea runners — nav showed "dev-".
|
|
// git rev-parse --short HEAD works regardless of which env vars the runner sets.
|
|
expect(ciYml).toContain('git rev-parse --short HEAD')
|
|
expect(ciYml).not.toContain('GITEA_SHA')
|
|
})
|
|
})
|