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>; 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); }); });