adds tests
Some checks failed
Build / test (push) Successful in 11m12s
Build / build (push) Failing after 2m33s

This commit is contained in:
2026-03-27 23:51:03 -04:00
parent 2f75b8980d
commit 505315c8bd
8 changed files with 2651 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(npm test:*)"
]
}
}

View File

@@ -8,8 +8,28 @@ env:
IMAGE: ${{ vars.REGISTRY_HOST }}/${{ gitea.repository_owner }}/catalyst IMAGE: ${{ vars.REGISTRY_HOST }}/${{ gitea.repository_owner }}/catalyst
jobs: jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: lts/*
cache: npm
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: test
steps: steps:
- name: Checkout - name: Checkout

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
node_modules/

2241
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

12
package.json Normal file
View File

@@ -0,0 +1,12 @@
{
"type": "module",
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"vitest": "^2.0.0",
"sql.js": "^1.10.2",
"jsdom": "^25.0.0"
}
}

250
tests/db.test.js Normal file
View File

@@ -0,0 +1,250 @@
import { describe, it, expect, beforeEach } from 'vitest'
import initSqlJs from 'sql.js'
// ── Schema (mirrors db.js) ────────────────────────────────────────────────────
const SCHEMA = `
CREATE TABLE instances (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
state TEXT DEFAULT 'deployed',
stack TEXT DEFAULT '',
vmid INTEGER UNIQUE NOT NULL,
atlas INTEGER DEFAULT 0,
argus INTEGER DEFAULT 0,
semaphore INTEGER DEFAULT 0,
patchmon INTEGER DEFAULT 0,
tailscale INTEGER DEFAULT 0,
andromeda INTEGER DEFAULT 0,
tailscale_ip TEXT DEFAULT '',
hardware_acceleration INTEGER DEFAULT 0,
createdAt TEXT DEFAULT (datetime('now')),
updatedAt TEXT DEFAULT (datetime('now'))
)
`
// ── Helpers ───────────────────────────────────────────────────────────────────
let db
beforeEach(async () => {
const SQL = await initSqlJs()
db = new SQL.Database()
db.run(SCHEMA)
})
function rows(res) {
if (!res.length) return []
const cols = res[0].columns
return res[0].values.map(row => Object.fromEntries(cols.map((c, i) => [c, row[i]])))
}
function insert(overrides = {}) {
const defaults = {
name: 'test-instance', state: 'deployed', stack: 'production', vmid: 100,
atlas: 0, argus: 0, semaphore: 0, patchmon: 0,
tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0,
}
const d = { ...defaults, ...overrides }
db.run(
`INSERT INTO instances
(name, state, stack, vmid, atlas, argus, semaphore, patchmon, tailscale, andromeda, tailscale_ip, hardware_acceleration)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[d.name, d.state, d.stack, d.vmid, d.atlas, d.argus, d.semaphore,
d.patchmon, d.tailscale, d.andromeda, d.tailscale_ip, d.hardware_acceleration]
)
return d
}
function getInstances(filters = {}) {
let sql = 'SELECT * FROM instances WHERE 1=1'
const params = []
if (filters.search) {
sql += ' AND (name LIKE ? OR CAST(vmid AS TEXT) LIKE ? OR stack LIKE ?)'
const s = `%${filters.search}%`
params.push(s, s, s)
}
if (filters.state) { sql += ' AND state = ?'; params.push(filters.state) }
if (filters.stack) { sql += ' AND stack = ?'; params.push(filters.stack) }
sql += ' ORDER BY name ASC'
return rows(db.exec(sql, params))
}
function getInstance(vmid) {
const res = rows(db.exec('SELECT * FROM instances WHERE vmid = ?', [vmid]))
return res[0] ?? null
}
function getDistinctStacks() {
const res = db.exec(`SELECT DISTINCT stack FROM instances WHERE stack != '' ORDER BY stack`)
if (!res.length) return []
return res[0].values.map(r => r[0])
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('getInstances', () => {
it('returns empty array when no instances exist', () => {
expect(getInstances()).toEqual([])
})
it('returns all instances sorted by name', () => {
insert({ name: 'zebra', vmid: 1 })
insert({ name: 'alpha', vmid: 2 })
const result = getInstances()
expect(result).toHaveLength(2)
expect(result[0].name).toBe('alpha')
expect(result[1].name).toBe('zebra')
})
it('filters by state', () => {
insert({ name: 'a', vmid: 1, state: 'deployed' })
insert({ name: 'b', vmid: 2, state: 'degraded' })
insert({ name: 'c', vmid: 3, state: 'testing' })
expect(getInstances({ state: 'deployed' })).toHaveLength(1)
expect(getInstances({ state: 'degraded' })).toHaveLength(1)
expect(getInstances({ state: 'testing' })).toHaveLength(1)
})
it('filters by stack', () => {
insert({ name: 'a', vmid: 1, stack: 'production' })
insert({ name: 'b', vmid: 2, stack: 'development' })
expect(getInstances({ stack: 'production' })).toHaveLength(1)
expect(getInstances({ stack: 'development' })).toHaveLength(1)
})
it('searches by name', () => {
insert({ name: 'plex', vmid: 1 })
insert({ name: 'gitea', vmid: 2 })
expect(getInstances({ search: 'ple' })).toHaveLength(1)
expect(getInstances({ search: 'ple' })[0].name).toBe('plex')
})
it('searches by vmid', () => {
insert({ name: 'a', vmid: 137 })
insert({ name: 'b', vmid: 200 })
expect(getInstances({ search: '137' })).toHaveLength(1)
})
it('searches by stack', () => {
insert({ name: 'a', vmid: 1, stack: 'production' })
insert({ name: 'b', vmid: 2, stack: 'development' })
expect(getInstances({ search: 'prod' })).toHaveLength(1)
})
it('combines search and state filters', () => {
insert({ name: 'plex', vmid: 1, state: 'deployed' })
insert({ name: 'plex2', vmid: 2, state: 'degraded' })
expect(getInstances({ search: 'plex', state: 'deployed' })).toHaveLength(1)
})
it('returns empty array when no results match', () => {
insert({ name: 'plex', vmid: 1 })
expect(getInstances({ search: 'zzz' })).toEqual([])
})
})
describe('getInstance', () => {
it('returns the instance with the given vmid', () => {
insert({ name: 'plex', vmid: 117 })
const inst = getInstance(117)
expect(inst).not.toBeNull()
expect(inst.name).toBe('plex')
expect(inst.vmid).toBe(117)
})
it('returns null for an unknown vmid', () => {
expect(getInstance(999)).toBeNull()
})
})
describe('getDistinctStacks', () => {
it('returns empty array when no instances exist', () => {
expect(getDistinctStacks()).toEqual([])
})
it('returns unique stacks sorted alphabetically', () => {
insert({ vmid: 1, stack: 'production' })
insert({ vmid: 2, stack: 'development' })
insert({ vmid: 3, stack: 'production' })
expect(getDistinctStacks()).toEqual(['development', 'production'])
})
it('excludes blank stack values', () => {
insert({ vmid: 1, stack: '' })
insert({ vmid: 2, stack: 'production' })
expect(getDistinctStacks()).toEqual(['production'])
})
})
describe('createInstance', () => {
it('inserts a new instance', () => {
insert({ name: 'traefik', vmid: 100, stack: 'production', state: 'deployed' })
const inst = getInstance(100)
expect(inst.name).toBe('traefik')
expect(inst.stack).toBe('production')
expect(inst.state).toBe('deployed')
})
it('stores service flags correctly', () => {
insert({ vmid: 1, atlas: 1, argus: 0, tailscale: 1, hardware_acceleration: 1 })
const inst = getInstance(1)
expect(inst.atlas).toBe(1)
expect(inst.argus).toBe(0)
expect(inst.tailscale).toBe(1)
expect(inst.hardware_acceleration).toBe(1)
})
it('rejects duplicate vmid', () => {
insert({ vmid: 100 })
expect(() => insert({ name: 'other', vmid: 100 })).toThrow()
})
it('sets createdAt and updatedAt on insert', () => {
insert({ vmid: 1 })
const inst = getInstance(1)
expect(inst.createdAt).not.toBeNull()
expect(inst.updatedAt).not.toBeNull()
})
})
describe('updateInstance', () => {
it('updates fields on an existing instance', () => {
insert({ name: 'old-name', vmid: 100, state: 'testing', stack: 'development' })
const before = getInstance(100)
db.run(
`UPDATE instances SET name=?, state=?, stack=?, updatedAt=datetime('now') WHERE id=?`,
['new-name', 'deployed', 'production', before.id]
)
const after = getInstance(100)
expect(after.name).toBe('new-name')
expect(after.state).toBe('deployed')
expect(after.stack).toBe('production')
})
it('updates updatedAt on write', () => {
insert({ vmid: 1 })
const before = getInstance(1)
db.run(`UPDATE instances SET name=?, updatedAt=datetime('now') WHERE id=?`, ['updated', before.id])
const after = getInstance(1)
expect(after.updatedAt).not.toBeNull()
})
})
describe('deleteInstance', () => {
it('removes the instance', () => {
insert({ vmid: 1 })
const inst = getInstance(1)
db.run('DELETE FROM instances WHERE id = ?', [inst.id])
expect(getInstance(1)).toBeNull()
})
it('only removes the targeted instance', () => {
insert({ name: 'a', vmid: 1 })
insert({ name: 'b', vmid: 2 })
const inst = getInstance(1)
db.run('DELETE FROM instances WHERE id = ?', [inst.id])
expect(getInstance(1)).toBeNull()
expect(getInstance(2)).not.toBeNull()
})
})

113
tests/helpers.test.js Normal file
View File

@@ -0,0 +1,113 @@
import { describe, it, expect } from 'vitest'
// ── 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('&lt;script&gt;')
expect(esc('</script>')).toBe('&lt;/script&gt;')
})
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('&lt;img')
expect(result).toContain('&gt;')
})
it('escapes ampersands', () => {
expect(esc('a & b')).toBe('a &amp; 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('—')
})
})

7
vitest.config.js Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'jsdom',
},
})