Files
electric-cherry/tests/unit/runtime-image-policy.test.ts
T
2026-10-09 17:15:15 -04:00

234 lines
10 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import { prepareNativeHost } from '../helpers/native-host.js';
vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn(async () => {}) }));
let fixture: Fixture;
let prepared: Awaited<ReturnType<typeof prepareNativeHost>>;
const custom = 'registry.example/untrusted:latest';
const launch = {
game: 't5' as const,
gameMode: 'zombie' as const,
mapSlug: 'theater',
plutoKey: 'saved-key'
};
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture([]);
prepared = await prepareNativeHost();
});
afterEach(async () => {
await fixture.cleanup();
});
async function reportImages(
preparedImages: string[] | undefined,
cachedImages = preparedImages ?? []
) {
const hosts = await import('../../src/lib/server/hosts/index.js');
const host = hosts.getHost(prepared.host.id)!;
hosts.heartbeat(host.id, {
...host.inventory,
runtimeImages: cachedImages,
preparedRuntimeImages: preparedImages
});
}
describe('runtime image launch policy', () => {
it.each([
{ automatic: false, cached: false, legacy: false },
{ automatic: true, cached: false, legacy: false },
{ automatic: false, cached: true, legacy: false },
{ automatic: true, cached: true, legacy: false },
{ automatic: false, cached: true, legacy: true },
{ automatic: true, cached: true, legacy: true }
])(
'rejects an unprepared custom image at POST /api/servers ($automatic, cached: $cached, legacy: $legacy)',
async ({ automatic, cached, legacy }) => {
await reportImages(legacy ? undefined : [], cached ? [custom] : []);
const { POST } = await import('../../src/routes/api/servers/+server.js');
const response = await POST({
request: new Request('https://controller.test/api/servers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...launch,
image: custom,
...(automatic ? {} : { hostId: prepared.host.id })
})
})
});
expect(response.status).toBe(400);
expect(await response.json()).toMatchObject({
error: expect.stringContaining('prepared by an admin')
});
const { db } = await import('../../src/lib/server/db/index.js');
for (const table of ['native_creation_intents', 'server_deployments', 'server_keys']) {
expect(db().prepare(`SELECT * FROM ${table}`).all()).toEqual([]);
}
}
);
it('allows the configured default before it is cached', async () => {
await reportImages([]);
const { queueNativeCreation } = await import('../../src/lib/server/provisioning/native.js');
expect(queueNativeCreation(launch).ok).toBe(true);
});
it('keeps the cached configured default eligible with a legacy agent', async () => {
const { config } = await import('../../src/lib/server/config.js');
await reportImages(undefined, [config.defaultRuntimeImage, custom]);
const { nativeSelection } = await import('../../src/lib/server/provisioning/native.js');
expect(nativeSelection({}, 't5').hostId).toBe(prepared.host.id);
const { getHost } = await import('../../src/lib/server/hosts/index.js');
expect(getHost(prepared.host.id)!.inventory.preparedRuntimeImages).toEqual([
config.defaultRuntimeImage
]);
});
it('ignores malformed preparation fields already stored by an older controller', async () => {
const { config } = await import('../../src/lib/server/config.js');
const { db } = await import('../../src/lib/server/db/index.js');
const { getHost } = await import('../../src/lib/server/hosts/index.js');
const inventory = getHost(prepared.host.id)!.inventory;
for (const reported of [{}, 42, custom]) {
db()
.prepare('UPDATE ec_hosts SET inventory=? WHERE id=?')
.run(
JSON.stringify({
...inventory,
runtimeImages: [config.defaultRuntimeImage, custom],
preparedRuntimeImages: reported
}),
prepared.host.id
);
expect(getHost(prepared.host.id)).toMatchObject({
readiness: 'ready',
inventory: { preparedRuntimeImages: [config.defaultRuntimeImage] }
});
}
});
it('hides unrelated cached images from host list, detail, and page responses', async () => {
const { config } = await import('../../src/lib/server/config.js');
await reportImages(undefined, [config.defaultRuntimeImage, custom]);
const list = await import('../../src/routes/api/hosts/+server.js');
const detail = await import('../../src/routes/api/hosts/[id]/+server.js');
const { load } = await import('../../src/routes/hosts/+page.server.js');
const request = new Request('https://controller.test/api/hosts');
const bodies = [
await list.GET({ request }).json(),
await detail.GET({ request, params: { id: prepared.host.id } }).json(),
load()
];
for (const body of bodies) {
const host = 'hosts' in body ? body.hosts[0] : body.host;
expect(host.inventory.runtimeImages).toBeUndefined();
expect(host.inventory.preparedRuntimeImages).toEqual([config.defaultRuntimeImage]);
expect(JSON.stringify(body)).not.toContain(custom);
}
});
it('does not place an accepted runtime on another host that only caches it', async () => {
await reportImages([custom]);
const other = await prepareNativeHost();
const hosts = await import('../../src/lib/server/hosts/index.js');
hosts.heartbeat(other.host.id, {
...hosts.getHost(other.host.id)!.inventory,
runtimeImages: [custom]
});
expect(() => hosts.requireReadyHost(other.host.id, { image: custom })).toThrow(
/runtime image is not prepared/
);
const { choosePlacement } = await import('../../src/lib/server/hosts/capacity.js');
expect(() =>
choosePlacement({
hostId: other.host.id,
libraryId: other.versions.get('t5')!,
image: custom
})
).toThrow(/runtime image is not prepared/);
});
it('accepts prepared images only on hosts that have them', async () => {
await reportImages([custom]);
const hosts = await import('../../src/lib/server/hosts/index.js');
const other = hosts.createHost({ name: 'Unprepared host' });
const { nativeLaunchSelection, nativeSelection } =
await import('../../src/lib/server/provisioning/native.js');
expect(nativeLaunchSelection({ hostId: prepared.host.id, image: custom }, 't5').image).toBe(
custom
);
expect(nativeSelection({ image: custom }, 't5').hostId).toBe(prepared.host.id);
expect(() => nativeLaunchSelection({ hostId: other.id, image: custom }, 't5')).toThrow(
/prepared by an admin/
);
hosts.updateHost(prepared.host.id, { mode: 'disabled' });
expect(() => nativeLaunchSelection({ image: custom }, 't5')).toThrow(
/prepared by an admin/
);
});
it('requires exact prepared references and does not treat a queued admin pull as preparation', async () => {
const hosts = await import('../../src/lib/server/hosts/index.js');
hosts.queueHostOperation(prepared.host.id, 'runtime.pull', { image: custom });
const { nativeLaunchSelection } =
await import('../../src/lib/server/provisioning/native.js');
expect(() => nativeLaunchSelection({ image: custom }, 't5')).toThrow(
/prepared by an admin/
);
await reportImages([custom + '-other']);
expect(() => nativeLaunchSelection({ image: custom }, 't5')).toThrow(
/prepared by an admin/
);
});
it('rechecks preparation when submitting a saved preview even if the image is still cached', async () => {
await reportImages([custom]);
const { previewNativeCreation, queueNativeCreation } =
await import('../../src/lib/server/provisioning/native.js');
const preview = previewNativeCreation({ ...launch, image: custom });
await reportImages([], [custom]);
expect(queueNativeCreation({ ...launch, previewId: preview.id })).toMatchObject({
ok: false,
message: expect.stringContaining('prepared by an admin')
});
});
it('rejects unprepared collection images and rechecks saved previews before writing any jobs', async () => {
const { saveBlueprint } = await import('../../src/lib/server/fleet/launch-recipes.js');
const { previewCollection, queueCollection } =
await import('../../src/lib/server/fleet/collections.js');
const blueprint = saveBlueprint({
name: 'Image policy',
entries: [{ game: 't5', mapSlug: 'theater', count: 2 }]
});
await reportImages([], [custom]);
expect(() => previewCollection(blueprint.id, 'fill', { image: custom })).toThrow(
/prepared by an admin/
);
await reportImages([custom]);
const preview = previewCollection(blueprint.id, 'fill', { image: custom });
const keys = Object.fromEntries(
preview.servers.map((server) => [server.name!, 'key-' + server.name])
);
await reportImages([], [custom]);
expect(() => queueCollection({ previewId: preview.id, keys })).toThrow(
/prepared by an admin/
);
const { db } = await import('../../src/lib/server/db/index.js');
for (const table of [
'server_launch_collections',
'native_creation_intents',
'server_deployments',
'server_keys'
]) {
expect(db().prepare(`SELECT * FROM ${table}`).all()).toEqual([]);
}
await reportImages([custom]);
expect(queueCollection({ previewId: preview.id, keys }).ok).toBe(true);
});
});