adds tests
This commit is contained in:
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npm test:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
node_modules/
|
||||||
2241
package-lock.json
generated
Normal file
2241
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
12
package.json
Normal file
12
package.json
Normal 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
250
tests/db.test.js
Normal 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
113
tests/helpers.test.js
Normal 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('<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('—')
|
||||||
|
})
|
||||||
|
})
|
||||||
7
vitest.config.js
Normal file
7
vitest.config.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user