Add clear host deletion controls with workload safeguards
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
|
||||
let fixture: Fixture;
|
||||
const inventory = { hostname: 'games-1', protocolVersion: 1, dockerRunning: true, directoriesWritable: true, storageAvailable: 100 * 1024 ** 3 };
|
||||
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
|
||||
afterEach(async () => { vi.unstubAllEnvs(); await fixture.cleanup(); });
|
||||
|
||||
async function enrolled() {
|
||||
const hosts = await import('../../src/lib/server/hosts/index.js');
|
||||
const host = hosts.createHost({ name: 'Game host' });
|
||||
const credentials = hosts.enrollHost(hosts.createEnrollment(host.id).token, inventory);
|
||||
return { ...hosts, host, credentials };
|
||||
}
|
||||
|
||||
async function instance(hostId: string, retired = false) {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
db().prepare(`INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at,retired_at)
|
||||
VALUES('instance','t5_zombie_theater_1',?,'library','plutainer:test',28960,?,?)`)
|
||||
.run(hostId, new Date().toISOString(), retired ? new Date().toISOString() : null);
|
||||
}
|
||||
|
||||
describe('host deletion', () => {
|
||||
it('adds the deletion marker to existing host schemas without losing hosts', async () => {
|
||||
const { ensureHostSchema } = await import('../../src/lib/server/hosts/schema.js');
|
||||
const legacy = new Database(':memory:');
|
||||
try {
|
||||
legacy.exec("CREATE TABLE ec_hosts(id TEXT PRIMARY KEY, name TEXT NOT NULL); INSERT INTO ec_hosts VALUES('old','Existing host')");
|
||||
ensureHostSchema(legacy); ensureHostSchema(legacy);
|
||||
expect(legacy.prepare('SELECT * FROM ec_hosts').get()).toEqual({ id: 'old', name: 'Existing host', deleted_at: null });
|
||||
} finally { legacy.close(); }
|
||||
});
|
||||
|
||||
it('deletes an unenrolled host and invalidates its unused enrollment', async () => {
|
||||
const hosts = await import('../../src/lib/server/hosts/index.js');
|
||||
const host = hosts.createHost({ name: 'Not installed' });
|
||||
const enrollment = hosts.createEnrollment(host.id);
|
||||
expect(hosts.hostDeletionStatus(host.id)).toEqual({ allowed: true, blockers: [] });
|
||||
hosts.deleteHost(host.id);
|
||||
expect(hosts.getHost(host.id)).toBeNull(); expect(hosts.listHosts()).toEqual([]);
|
||||
expect(() => hosts.enrollHost(enrollment.token, inventory)).toThrow('invalid or expired');
|
||||
expect(() => hosts.createEnrollment(host.id)).toThrow('not found');
|
||||
expect(() => hosts.deleteHost(host.id)).toThrow(expect.objectContaining({ status: 404 }));
|
||||
});
|
||||
|
||||
it('deletes an enrolled empty host, revokes credentials, and preserves files and history', async () => {
|
||||
const hosts = await enrolled();
|
||||
const other = hosts.createHost({ name: 'Keep' });
|
||||
const otherCredentials = hosts.enrollHost(hosts.createEnrollment(other.id).token, inventory);
|
||||
const enrollment = hosts.createEnrollment(hosts.host.id);
|
||||
const completed = hosts.queueHostOperation(hosts.host.id, 'host.inspect', {});
|
||||
hosts.reportHostOperation(hosts.host.id, completed.id, { state: 'completed', result: { hostname: 'games-1' } });
|
||||
await instance(hosts.host.id, true);
|
||||
const file = join(fixture.root, 'game-files-sentinel');
|
||||
await writeFile(file, 'Game files remain');
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const { ensureGameFileSchema } = await import('../../src/lib/server/game-files/schema.js');
|
||||
ensureGameFileSchema(db());
|
||||
db().prepare("INSERT INTO game_file_libraries(id,name,game,description,created_at) VALUES('library','T5','t5','',?)").run(new Date().toISOString());
|
||||
db().prepare(`INSERT INTO game_file_versions(id,library_id,version,description,dlc,state,created_at,storage_location,source_host_id,source_volume)
|
||||
VALUES('version','library','1','','[]','ready',?,?,?,'t5_game_files')`).run(new Date().toISOString(), file, hosts.host.id);
|
||||
db().prepare("INSERT INTO host_library_caches(host_id,version_id,status,updated_at) VALUES(?,'version','ready',?)").run(hosts.host.id, new Date().toISOString());
|
||||
|
||||
hosts.deleteHost(hosts.host.id);
|
||||
|
||||
expect(hosts.authenticateCredential(hosts.credentials.credential)).toBeNull();
|
||||
expect(hosts.authenticateCredential(otherCredentials.credential)?.id).toBe(other.id);
|
||||
expect(() => hosts.enrollHost(enrollment.token, inventory)).toThrow('invalid or expired');
|
||||
expect(hosts.listHosts().map(host => host.id)).toEqual([other.id]);
|
||||
expect(hosts.getHostOperation(completed.id)).toMatchObject({ state: 'completed', result: { hostname: 'games-1' } });
|
||||
expect(hosts.listHostOperations(hosts.host.id)).toHaveLength(1);
|
||||
expect(db().prepare('SELECT mode,deleted_at FROM ec_hosts WHERE id=?').get(hosts.host.id)).toEqual({ mode: 'disabled', deleted_at: expect.any(String) });
|
||||
expect(db().prepare('SELECT revoked_at FROM ec_host_credentials WHERE host_id=?').get(hosts.host.id)).toEqual({ revoked_at: expect.any(String) });
|
||||
expect(db().prepare('SELECT COUNT(*) AS count FROM native_instances').get()).toEqual({ count: 1 });
|
||||
expect(db().prepare('SELECT COUNT(*) AS count FROM host_library_caches').get()).toEqual({ count: 1 });
|
||||
expect(db().prepare("SELECT state,source_host_id FROM game_file_versions WHERE id='version'").get()).toEqual({ state: 'ready', source_host_id: hosts.host.id });
|
||||
expect(await readFile(file, 'utf8')).toBe('Game files remain');
|
||||
expect(() => hosts.heartbeat(hosts.host.id, inventory)).toThrow('not found');
|
||||
expect(() => hosts.rotateCredential(hosts.host.id)).toThrow('not found');
|
||||
expect(() => hosts.queueHostOperation(hosts.host.id, 'runtime.pull', {})).toThrow('not found');
|
||||
expect(() => hosts.reportHostOperation(hosts.host.id, completed.id, { state: 'running' })).toThrow('not found');
|
||||
expect(() => hosts.updateHost(hosts.host.id, { mode: 'active' })).toThrow('not found');
|
||||
});
|
||||
|
||||
it('cancels only undelivered queued operations and retains their journal', async () => {
|
||||
const hosts = await enrolled();
|
||||
const operations = ['runtime.pull', 'library.download', 'credentials.rotate'].map(kind => hosts.queueHostOperation(hosts.host.id, kind, {}));
|
||||
expect(hosts.hostDeletionStatus(hosts.host.id).allowed).toBe(true);
|
||||
hosts.deleteHost(hosts.host.id);
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
for (const operation of operations) {
|
||||
expect(hosts.getHostOperation(operation.id)).toMatchObject({ state: 'failed', error: expect.stringContaining('before this operation was delivered') });
|
||||
expect(db().prepare('SELECT state FROM ec_host_operation_events WHERE operation_id=? ORDER BY id').all(operation.id)).toEqual([{ state: 'pending' }, { state: 'failed' }]);
|
||||
}
|
||||
});
|
||||
|
||||
it('blocks live instances even when no server record or port reservation exists', async () => {
|
||||
const hosts = await enrolled();
|
||||
await instance(hosts.host.id);
|
||||
const status = hosts.hostDeletionStatus(hosts.host.id);
|
||||
expect(status.allowed).toBe(false); expect(status.blockers.join(' ')).toContain('t5_zombie_theater_1');
|
||||
expect(() => hosts.deleteHost(hosts.host.id)).toThrow(expect.objectContaining({ status: 409 }));
|
||||
expect(hosts.authenticateCredential(hosts.credentials.credential)?.id).toBe(hosts.host.id);
|
||||
});
|
||||
|
||||
it('blocks residual port reservations independently of live instances', async () => {
|
||||
const hosts = await enrolled();
|
||||
hosts.reserveHostPort(hosts.host.id, 'unresolved-instance');
|
||||
expect(hosts.hostDeletionStatus(hosts.host.id)).toMatchObject({ allowed: false, blockers: [expect.stringContaining('reserved game port')] });
|
||||
expect(() => hosts.deleteHost(hosts.host.id)).toThrow(expect.objectContaining({ status: 409 }));
|
||||
hosts.releaseHostPorts('unresolved-instance');
|
||||
hosts.deleteHost(hosts.host.id);
|
||||
expect(hosts.getHost(hosts.host.id)).toBeNull();
|
||||
});
|
||||
|
||||
it.each(['pending', 'accepted', 'running', 'unknown'] as const)('blocks unresolved %s work after delivery without revoking access or cancelling other work', async state => {
|
||||
const hosts = await enrolled();
|
||||
const operation = hosts.queueHostOperation(hosts.host.id, 'runtime.pull', {});
|
||||
hosts.pendingHostOperations(hosts.host.id);
|
||||
if (state !== 'pending') hosts.reportHostOperation(hosts.host.id, operation.id, { state });
|
||||
const queued = hosts.queueHostOperation(hosts.host.id, 'library.download', {});
|
||||
hosts.updateHost(hosts.host.id, { mode: 'disabled' });
|
||||
expect(hosts.hostDeletionStatus(hosts.host.id)).toMatchObject({ allowed: false, blockers: [expect.stringContaining('delivered operation')] });
|
||||
expect(() => hosts.deleteHost(hosts.host.id)).toThrow(expect.objectContaining({ status: 409 }));
|
||||
expect(hosts.getHostOperation(queued.id)?.state).toBe('pending');
|
||||
expect(hosts.authenticateCredential(hosts.credentials.credential)?.id).toBe(hosts.host.id);
|
||||
hosts.reportHostOperation(hosts.host.id, operation.id, { state: 'completed' });
|
||||
hosts.deleteHost(hosts.host.id);
|
||||
expect(hosts.getHostOperation(queued.id)?.state).toBe('failed');
|
||||
});
|
||||
|
||||
it('rechecks blockers on DELETE even after a successful eligibility check', async () => {
|
||||
const hosts = await enrolled();
|
||||
const route = await import('../../src/routes/api/hosts/[id]/+server.js');
|
||||
const url = `https://ec.test/api/hosts/${hosts.host.id}`;
|
||||
const params = { id: hosts.host.id };
|
||||
expect(await route.GET({ request: new Request(url), params }).json()).toMatchObject({ deletion: { allowed: true, blockers: [] } });
|
||||
const operation = hosts.queueHostOperation(hosts.host.id, 'runtime.pull', {});
|
||||
hosts.pendingHostOperations(hosts.host.id);
|
||||
const blocked = route.DELETE({ request: new Request(url, { method: 'DELETE' }), params });
|
||||
expect(blocked.status).toBe(409);
|
||||
expect(await blocked.json()).toMatchObject({ error: expect.any(String), blockers: [expect.stringContaining('delivered operation')] });
|
||||
hosts.reportHostOperation(hosts.host.id, operation.id, { state: 'completed' });
|
||||
const removed = route.DELETE({ request: new Request(url, { method: 'DELETE' }), params });
|
||||
expect(removed.status).toBe(200); expect(await removed.json()).toEqual({ deleted: true });
|
||||
expect(route.DELETE({ request: new Request(url, { method: 'DELETE' }), params }).status).toBe(404);
|
||||
});
|
||||
|
||||
it('keeps DELETE restricted to admin sessions and same-origin management requests', async () => {
|
||||
const hosts = await enrolled();
|
||||
const { routePermission, roleAllows } = await import('../../src/lib/server/auth/access.js');
|
||||
const permission = routePermission('/api/hosts/[id]', 'DELETE');
|
||||
expect(roleAllows('admin', permission)).toBe(true);
|
||||
expect(roleAllows('operator', permission)).toBe(false); expect(roleAllows('viewer', permission)).toBe(false);
|
||||
const { DELETE } = await import('../../src/routes/api/hosts/[id]/+server.js');
|
||||
for (const headers of [{ origin: 'https://attacker.test' }, { authorization: `Bearer ${hosts.credentials.credential}` }]) {
|
||||
expect(() => DELETE({ request: new Request('https://ec.test/api/hosts/id', { method: 'DELETE', headers }), params: { id: hosts.host.id } })).toThrow(expect.objectContaining({ status: 403 }));
|
||||
}
|
||||
expect(hosts.getHost(hosts.host.id)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user