Initial commit: Infrastructure host tracking app
build-and-push / build-and-push (push) Successful in 1m26s

Fastify + node:sqlite single-process app with vanilla JS UI for
looking up hosts by hardware ID, hostname, or asset ID. Includes
per-host network interface tracking, sites/rooms/server-types CRUD,
Docker packaging, and a Gitea Actions workflow that runs tests then
builds and pushes to gitea.thewrightserver.net/josh/infrastructure.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-19 17:05:50 -04:00
commit f500db971b
26 changed files with 4057 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
import { buildApp } from '../src/server.js';
export async function newApp() {
return buildApp({ dbPath: ':memory:', seed: false });
}
export async function seedFixtures(app) {
const site = JSON.parse((await app.inject({
method: 'POST', url: '/api/sites', payload: { name: 'HQ' },
})).body);
const room = JSON.parse((await app.inject({
method: 'POST', url: '/api/rooms', payload: { site_id: site.id, name: 'Main' },
})).body);
const type = JSON.parse((await app.inject({
method: 'POST', url: '/api/server-types', payload: { name: 'Web' },
})).body);
return { site, room, type };
}
export function newHostPayload({ room_id, server_type_id }, suffix = '') {
return {
hardware_id: `HW-${suffix || '1'}`,
hostname: `host-${suffix || '1'}`,
asset_id: `AST-${suffix || '1'}`,
room_id,
position: 'R1-U1',
server_type_id,
};
}
export async function seedHost(app, fx, suffix = '') {
const res = await app.inject({
method: 'POST', url: '/api/hosts',
payload: newHostPayload({ room_id: fx.room.id, server_type_id: fx.type.id }, suffix),
});
return JSON.parse(res.body);
}
+128
View File
@@ -0,0 +1,128 @@
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import { newApp, seedFixtures, newHostPayload } from './helpers.js';
test('hosts: create, get, search, update, delete', async (t) => {
const app = await newApp();
after(() => app.close());
const fx = await seedFixtures(app);
// Create
const create = await app.inject({
method: 'POST', url: '/api/hosts',
payload: newHostPayload({ room_id: fx.room.id, server_type_id: fx.type.id }, 'A'),
});
assert.equal(create.statusCode, 201);
const created = JSON.parse(create.body);
assert.equal(created.hostname, 'host-A');
assert.equal(created.site_name, 'HQ');
assert.equal(created.room_name, 'Main');
assert.equal(created.server_type, 'Web');
// Get one
const get = await app.inject({ method: 'GET', url: `/api/hosts/${created.id}` });
assert.equal(get.statusCode, 200);
assert.equal(JSON.parse(get.body).hostname, 'host-A');
// 404 on missing
const missing = await app.inject({ method: 'GET', url: '/api/hosts/9999' });
assert.equal(missing.statusCode, 404);
// Lookup by hardware_id
const byHwid = await app.inject({ method: 'GET', url: '/api/hosts/by-hardware-id/HW-A' });
assert.equal(byHwid.statusCode, 200);
assert.equal(JSON.parse(byHwid.body).id, created.id);
// 404 by hardware_id
const missingHwid = await app.inject({ method: 'GET', url: '/api/hosts/by-hardware-id/nope' });
assert.equal(missingHwid.statusCode, 404);
// Search by hostname / hardware_id / asset_id
for (const q of ['host', 'HW-A', 'AST-A']) {
const r = await app.inject({ method: 'GET', url: `/api/hosts?q=${encodeURIComponent(q)}` });
assert.equal(r.statusCode, 200);
const rows = JSON.parse(r.body);
assert.equal(rows.length, 1);
assert.equal(rows[0].hostname, 'host-A');
}
// Search is case-insensitive
const ci = await app.inject({ method: 'GET', url: '/api/hosts?q=HOST-a' });
assert.equal(JSON.parse(ci.body).length, 1);
// Update
const upd = await app.inject({
method: 'PUT', url: `/api/hosts/${created.id}`,
payload: { ...newHostPayload({ room_id: fx.room.id, server_type_id: fx.type.id }, 'A'), position: 'R5-U10' },
});
assert.equal(upd.statusCode, 200);
assert.equal(JSON.parse(upd.body).position, 'R5-U10');
// Delete
const del = await app.inject({ method: 'DELETE', url: `/api/hosts/${created.id}` });
assert.equal(del.statusCode, 204);
const afterDel = await app.inject({ method: 'GET', url: `/api/hosts/${created.id}` });
assert.equal(afterDel.statusCode, 404);
});
test('hosts: duplicate hardware_id returns 409', async (t) => {
const app = await newApp();
after(() => app.close());
const fx = await seedFixtures(app);
const a = await app.inject({
method: 'POST', url: '/api/hosts',
payload: newHostPayload({ room_id: fx.room.id, server_type_id: fx.type.id }, 'X'),
});
assert.equal(a.statusCode, 201);
const dup = await app.inject({
method: 'POST', url: '/api/hosts',
payload: { ...newHostPayload({ room_id: fx.room.id, server_type_id: fx.type.id }, 'Y'), hardware_id: 'HW-X' },
});
assert.equal(dup.statusCode, 409);
assert.match(JSON.parse(dup.body).error, /hardware_id/);
});
test('hosts: invalid body returns 400 with details', async (t) => {
const app = await newApp();
after(() => app.close());
const r = await app.inject({
method: 'POST', url: '/api/hosts',
payload: { hostname: '' },
});
assert.equal(r.statusCode, 400);
const body = JSON.parse(r.body);
assert.equal(body.error, 'validation failed');
assert.ok(Array.isArray(body.details) && body.details.length > 0);
});
test('hosts: missing FK returns 409', async (t) => {
const app = await newApp();
after(() => app.close());
const r = await app.inject({
method: 'POST', url: '/api/hosts',
payload: {
hardware_id: 'HW-1', hostname: 'h', asset_id: 'A',
room_id: 999, position: '', server_type_id: 999,
},
});
assert.equal(r.statusCode, 409);
});
test('hosts: list caps at 200', async (t) => {
const app = await newApp();
after(() => app.close());
const fx = await seedFixtures(app);
for (let i = 0; i < 205; i++) {
await app.inject({
method: 'POST', url: '/api/hosts',
payload: newHostPayload({ room_id: fx.room.id, server_type_id: fx.type.id }, String(i).padStart(4, '0')),
});
}
const r = await app.inject({ method: 'GET', url: '/api/hosts' });
assert.equal(JSON.parse(r.body).length, 200);
});
+176
View File
@@ -0,0 +1,176 @@
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import { newApp, seedFixtures, seedHost } from './helpers.js';
const validIface = (host_id, name = 'eth0') => ({
host_id,
name,
mac_address: 'aa:bb:cc:dd:ee:ff',
ip_address: '10.0.0.5',
subnet: '10.0.0.0/24',
link_speed: '1000/full',
});
test('interfaces: create, list, get, update, delete', async () => {
const app = await newApp();
after(() => app.close());
const fx = await seedFixtures(app);
const host = await seedHost(app, fx, 'A');
const create = await app.inject({
method: 'POST', url: '/api/interfaces',
payload: validIface(host.id, 'eth0'),
});
assert.equal(create.statusCode, 201);
const iface = JSON.parse(create.body);
assert.equal(iface.name, 'eth0');
assert.equal(iface.host_id, host.id);
assert.equal(iface.ip_address, '10.0.0.5');
const list = await app.inject({
method: 'GET', url: `/api/interfaces?host_id=${host.id}`,
});
assert.equal(list.statusCode, 200);
assert.equal(JSON.parse(list.body).length, 1);
const get = await app.inject({ method: 'GET', url: `/api/interfaces/${iface.id}` });
assert.equal(get.statusCode, 200);
assert.equal(JSON.parse(get.body).name, 'eth0');
const upd = await app.inject({
method: 'PUT', url: `/api/interfaces/${iface.id}`,
payload: { ...validIface(host.id, 'eth0'), ip_address: '10.0.0.99' },
});
assert.equal(upd.statusCode, 200);
assert.equal(JSON.parse(upd.body).ip_address, '10.0.0.99');
const del = await app.inject({ method: 'DELETE', url: `/api/interfaces/${iface.id}` });
assert.equal(del.statusCode, 204);
const after404 = await app.inject({ method: 'GET', url: `/api/interfaces/${iface.id}` });
assert.equal(after404.statusCode, 404);
});
test('interfaces: optional fields may be empty strings', async () => {
const app = await newApp();
after(() => app.close());
const fx = await seedFixtures(app);
const host = await seedHost(app, fx, 'B');
const r = await app.inject({
method: 'POST', url: '/api/interfaces',
payload: { host_id: host.id, name: 'eth0' },
});
assert.equal(r.statusCode, 201);
const iface = JSON.parse(r.body);
assert.equal(iface.mac_address, '');
assert.equal(iface.ip_address, '');
assert.equal(iface.subnet, '');
assert.equal(iface.link_speed, '');
});
test('interfaces: duplicate (host_id, name) returns 409', async () => {
const app = await newApp();
after(() => app.close());
const fx = await seedFixtures(app);
const host = await seedHost(app, fx, 'C');
const a = await app.inject({
method: 'POST', url: '/api/interfaces',
payload: { host_id: host.id, name: 'eth0' },
});
assert.equal(a.statusCode, 201);
const dup = await app.inject({
method: 'POST', url: '/api/interfaces',
payload: { host_id: host.id, name: 'eth0' },
});
assert.equal(dup.statusCode, 409);
assert.match(JSON.parse(dup.body).error, /already exists/);
});
test('interfaces: same name allowed on different hosts', async () => {
const app = await newApp();
after(() => app.close());
const fx = await seedFixtures(app);
const h1 = await seedHost(app, fx, 'D1');
const h2 = await seedHost(app, fx, 'D2');
const a = await app.inject({
method: 'POST', url: '/api/interfaces',
payload: { host_id: h1.id, name: 'eth0' },
});
const b = await app.inject({
method: 'POST', url: '/api/interfaces',
payload: { host_id: h2.id, name: 'eth0' },
});
assert.equal(a.statusCode, 201);
assert.equal(b.statusCode, 201);
});
test('interfaces: missing host FK returns 409', async () => {
const app = await newApp();
after(() => app.close());
const r = await app.inject({
method: 'POST', url: '/api/interfaces',
payload: { host_id: 9999, name: 'eth0' },
});
assert.equal(r.statusCode, 409);
});
test('interfaces: invalid formats return 400 with details', async () => {
const app = await newApp();
after(() => app.close());
const fx = await seedFixtures(app);
const host = await seedHost(app, fx, 'E');
const cases = [
{ mac_address: 'zz:zz:zz:zz:zz:zz' },
{ ip_address: '999.0.0.1' },
{ subnet: '10.0.0.0/99' },
{ link_speed: '1000/weird' },
];
for (const extra of cases) {
const r = await app.inject({
method: 'POST', url: '/api/interfaces',
payload: { host_id: host.id, name: 'eth0', ...extra },
});
assert.equal(r.statusCode, 400, `expected 400 for ${JSON.stringify(extra)}`);
const body = JSON.parse(r.body);
assert.equal(body.error, 'validation failed');
assert.ok(Array.isArray(body.details) && body.details.length > 0);
}
});
test('interfaces: deleting the host cascades', async () => {
const app = await newApp();
after(() => app.close());
const fx = await seedFixtures(app);
const host = await seedHost(app, fx, 'F');
await app.inject({
method: 'POST', url: '/api/interfaces',
payload: { host_id: host.id, name: 'eth0' },
});
await app.inject({
method: 'POST', url: '/api/interfaces',
payload: { host_id: host.id, name: 'eth1' },
});
const del = await app.inject({ method: 'DELETE', url: `/api/hosts/${host.id}` });
assert.equal(del.statusCode, 204);
const list = await app.inject({
method: 'GET', url: `/api/interfaces?host_id=${host.id}`,
});
assert.equal(list.statusCode, 200);
assert.equal(JSON.parse(list.body).length, 0);
});
test('interfaces: list requires host_id', async () => {
const app = await newApp();
after(() => app.close());
const r = await app.inject({ method: 'GET', url: '/api/interfaces' });
assert.equal(r.statusCode, 400);
});
+57
View File
@@ -0,0 +1,57 @@
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import { newApp } from './helpers.js';
test('rooms: CRUD with site filter', async (t) => {
const app = await newApp();
after(() => app.close());
const s1 = JSON.parse((await app.inject({ method: 'POST', url: '/api/sites', payload: { name: 'S1' } })).body);
const s2 = JSON.parse((await app.inject({ method: 'POST', url: '/api/sites', payload: { name: 'S2' } })).body);
await app.inject({ method: 'POST', url: '/api/rooms', payload: { site_id: s1.id, name: 'A' } });
await app.inject({ method: 'POST', url: '/api/rooms', payload: { site_id: s1.id, name: 'B' } });
await app.inject({ method: 'POST', url: '/api/rooms', payload: { site_id: s2.id, name: 'C' } });
const all = JSON.parse((await app.inject({ method: 'GET', url: '/api/rooms' })).body);
assert.equal(all.length, 3);
const onlyS1 = JSON.parse((await app.inject({ method: 'GET', url: `/api/rooms?site_id=${s1.id}` })).body);
assert.equal(onlyS1.length, 2);
assert.ok(onlyS1.every((r) => r.site_id === s1.id));
});
test('rooms: same name allowed in different sites, blocked in same site', async (t) => {
const app = await newApp();
after(() => app.close());
const s1 = JSON.parse((await app.inject({ method: 'POST', url: '/api/sites', payload: { name: 'S1' } })).body);
const s2 = JSON.parse((await app.inject({ method: 'POST', url: '/api/sites', payload: { name: 'S2' } })).body);
const a = await app.inject({ method: 'POST', url: '/api/rooms', payload: { site_id: s1.id, name: 'X' } });
assert.equal(a.statusCode, 201);
const b = await app.inject({ method: 'POST', url: '/api/rooms', payload: { site_id: s2.id, name: 'X' } });
assert.equal(b.statusCode, 201);
const dup = await app.inject({ method: 'POST', url: '/api/rooms', payload: { site_id: s1.id, name: 'X' } });
assert.equal(dup.statusCode, 409);
});
test('rooms: room with hosts cannot be deleted', async (t) => {
const app = await newApp();
after(() => app.close());
const site = JSON.parse((await app.inject({ method: 'POST', url: '/api/sites', payload: { name: 'S' } })).body);
const room = JSON.parse((await app.inject({ method: 'POST', url: '/api/rooms', payload: { site_id: site.id, name: 'R' } })).body);
const type = JSON.parse((await app.inject({ method: 'POST', url: '/api/server-types', payload: { name: 'T' } })).body);
await app.inject({
method: 'POST', url: '/api/hosts',
payload: {
hardware_id: 'HW', hostname: 'h', asset_id: 'A',
room_id: room.id, position: '', server_type_id: type.id,
},
});
const r = await app.inject({ method: 'DELETE', url: `/api/rooms/${room.id}` });
assert.equal(r.statusCode, 409);
});
+47
View File
@@ -0,0 +1,47 @@
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import { newApp } from './helpers.js';
test('server-types: full CRUD', async (t) => {
const app = await newApp();
after(() => app.close());
const created = await app.inject({
method: 'POST', url: '/api/server-types', payload: { name: 'Database' },
});
assert.equal(created.statusCode, 201);
const t1 = JSON.parse(created.body);
const dup = await app.inject({
method: 'POST', url: '/api/server-types', payload: { name: 'Database' },
});
assert.equal(dup.statusCode, 409);
const upd = await app.inject({
method: 'PUT', url: `/api/server-types/${t1.id}`, payload: { name: 'DB' },
});
assert.equal(JSON.parse(upd.body).name, 'DB');
const del = await app.inject({ method: 'DELETE', url: `/api/server-types/${t1.id}` });
assert.equal(del.statusCode, 204);
});
test('server-types: cannot delete one referenced by a host (409)', async (t) => {
const app = await newApp();
after(() => app.close());
const site = JSON.parse((await app.inject({ method: 'POST', url: '/api/sites', payload: { name: 'S' } })).body);
const room = JSON.parse((await app.inject({ method: 'POST', url: '/api/rooms', payload: { site_id: site.id, name: 'R' } })).body);
const type = JSON.parse((await app.inject({ method: 'POST', url: '/api/server-types', payload: { name: 'Web' } })).body);
await app.inject({
method: 'POST', url: '/api/hosts',
payload: {
hardware_id: 'HW', hostname: 'h', asset_id: 'A',
room_id: room.id, position: '', server_type_id: type.id,
},
});
const r = await app.inject({ method: 'DELETE', url: `/api/server-types/${type.id}` });
assert.equal(r.statusCode, 409);
});
+54
View File
@@ -0,0 +1,54 @@
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import { newApp } from './helpers.js';
test('sites: full CRUD', async (t) => {
const app = await newApp();
after(() => app.close());
const empty = await app.inject({ method: 'GET', url: '/api/sites' });
assert.deepEqual(JSON.parse(empty.body), []);
const created = await app.inject({
method: 'POST', url: '/api/sites', payload: { name: 'DC1' },
});
assert.equal(created.statusCode, 201);
const site = JSON.parse(created.body);
assert.equal(site.name, 'DC1');
const list = await app.inject({ method: 'GET', url: '/api/sites' });
assert.equal(JSON.parse(list.body).length, 1);
const upd = await app.inject({
method: 'PUT', url: `/api/sites/${site.id}`, payload: { name: 'DC2' },
});
assert.equal(upd.statusCode, 200);
assert.equal(JSON.parse(upd.body).name, 'DC2');
const del = await app.inject({ method: 'DELETE', url: `/api/sites/${site.id}` });
assert.equal(del.statusCode, 204);
});
test('sites: duplicate name returns 409', async (t) => {
const app = await newApp();
after(() => app.close());
await app.inject({ method: 'POST', url: '/api/sites', payload: { name: 'A' } });
const dup = await app.inject({ method: 'POST', url: '/api/sites', payload: { name: 'A' } });
assert.equal(dup.statusCode, 409);
});
test('sites: cannot delete site that has rooms (409)', async (t) => {
const app = await newApp();
after(() => app.close());
const site = JSON.parse((await app.inject({
method: 'POST', url: '/api/sites', payload: { name: 'S' },
})).body);
await app.inject({
method: 'POST', url: '/api/rooms',
payload: { site_id: site.id, name: 'R' },
});
const r = await app.inject({ method: 'DELETE', url: `/api/sites/${site.id}` });
assert.equal(r.statusCode, 409);
});