import { afterAll, describe, expect, test } from 'bun:test'; import { AccessToken } from '@nestri/core/access-token/index'; import { Box } from '@nestri/core/box/index'; import { Fixtures } from '@nestri/core/db/fixtures'; import { testDb } from '@nestri/core/db/test'; import { Game } from '@nestri/core/game/index'; import { Identifier } from '@nestri/core/id'; import { Machine } from '@nestri/core/machine/index'; import { Session } from '@nestri/core/session/index'; import { Library } from '@nestri/core/user/library'; import { LinkedAccount } from '@nestri/core/user/linked-account'; import { app } from '../app/index'; import './setup'; const sql = testDb(); const createdUserIds: string[] = []; const createdGameIds: string[] = []; async function newGame(steamAppId: number): Promise { const [row] = await Game.upsert({ id: Identifier.ascending('game'), steamAppId, slug: `session-route-${steamAppId}`, name: `Session Route ${steamAppId}` }); if (!row) throw new Error('expected a game row'); createdGameIds.push(row.id); return row.id; } /** * Everything one session needs, plus both sets of credentials that reach it. * * The person authenticates with a personal token, which is the one user * credential a test can mint without an auth service; the host authenticates * as itself with the secret registration hands back exactly once. */ async function scene(label: string, steamAppId: number) { const owner = await Fixtures.owner(label); createdUserIds.push(owner.userId); const registered = await Machine.register({ id: Identifier.ascending('machine'), ownerUserId: owner.userId, teamId: owner.teamId, label }); const box = await Box.create({ id: Identifier.ascending('box'), userId: owner.userId, machineId: registered.id, label, tier: 'sm' }); const pat = await AccessToken.create({ id: Identifier.ascending('accessToken'), ownerUserId: owner.userId, // Null on purpose: a token scoped to the user alone makes the caller a // plain user actor, which is the credential a person browsing has. teamId: null, name: label }); const gameId = await newGame(steamAppId); // A run launches as a Steam account that owns the game, so the endpoint // refuses one outside the caller's library. Every scene here is about // something else, so the game is stocked. await Library.upsert({ id: Identifier.ascending('userLibrary'), userId: owner.userId, gameId, playtime2w: null, playtimeForever: null, lastPlayed: null }); return { owner, box, machineId: registered.id, gameId, user: { authorization: `Bearer ${pat.token}`, 'content-type': 'application/json' } as Record, host: { 'x-nestri-machine-id': registered.id, 'x-nestri-machine-secret': registered.secret, 'content-type': 'application/json' } as Record }; } async function requestSession(s: Awaited>) { const res = await app.request('/session', { method: 'POST', headers: s.user, body: JSON.stringify({ boxId: s.box.id, gameId: s.gameId, linkedAccountId: s.owner.linkedAccountId }) }); const body = (await res.json()) as any; return { res, body }; } afterAll(async () => { if (createdUserIds.length > 0) { // `burn_segment` holds a session with `restrict` — deleting a run must // not erase what it cost — so the record goes before the runs do. await sql`delete from "burn_segment" where session_id in ( select s.id from "session" s join "box" b on b.id = s.box_id where b.user_id in ${sql(createdUserIds)} )`; await sql`delete from "box" where user_id in ${sql(createdUserIds)}`; await sql`delete from "user" where id in ${sql(createdUserIds)}`; createdUserIds.length = 0; } if (createdGameIds.length > 0) { await sql`delete from "game" where id in ${sql(createdGameIds)}`; createdGameIds.length = 0; } }); describe('POST /session', () => { test('a request creates the job, in the envelope both ends read', async () => { const s = await scene('route-create', 5500); const { res, body } = await requestSession(s); expect(res.status).toBe(201); // The field names are the contract. A rename on either side produces a // host that starts, reads nothing, and reports success — so the shape // is asserted whole rather than field by field. // `billing` rides alongside `data` on purpose: every surface that can // start a run has to show what it costs and what remains, and a second // call for that is a call nobody makes. expect(Object.keys(body)).toEqual(['data', 'billing']); expect(body.billing.exhausted).toBe(false); expect(body.billing.windows.map((w: { window: string }) => w.window)).toEqual([ 'fiveHour', 'sevenDay', 'thirtyDay' ]); // Nothing is live yet, so nothing is being spent — and one more run // would cost exactly one unit per second. expect(body.billing.rateMilli).toBe(0); expect(body.billing.rateMilliIfOneMore).toBe(1000); expect(body.data).toEqual({ id: body.data.id, boxId: s.box.id, gameId: s.gameId, linkedAccountId: s.owner.linkedAccountId, state: 'requested', ticket: null, timeStarted: null, timeStopped: null, errorMessage: null }); expect(body.data.id.startsWith('ses_')).toBe(true); }); test('creating a session makes no placement decision', async () => { const s = await scene('route-noplacement', 5501); const { body } = await requestSession(s); // A session inherits its machine through its box, so there is nothing // to choose here and no way for a caller to ask for a host. expect(body.data).not.toHaveProperty('machineId'); const withHost = await app.request('/session', { method: 'POST', headers: s.user, body: JSON.stringify({ boxId: s.box.id, gameId: s.gameId, linkedAccountId: s.owner.linkedAccountId, machineId: s.machineId }) }); expect(withHost.status).toBe(400); }); test('a box somebody else owns is not there to run', async () => { const mine = await scene('route-mine', 5502); const theirs = await scene('route-theirs', 5503); const res = await app.request('/session', { method: 'POST', headers: mine.user, body: JSON.stringify({ boxId: theirs.box.id, gameId: mine.gameId, linkedAccountId: mine.owner.linkedAccountId }) }); expect(res.status).toBe(404); const unknown = await app.request('/session', { method: 'POST', headers: mine.user, body: JSON.stringify({ boxId: Identifier.ascending('box'), gameId: mine.gameId, linkedAccountId: mine.owner.linkedAccountId }) }); // Owner-scoped in the query, so somebody else's box and a box that was // never created are the same answer. expect(unknown.status).toBe(404); expect(await res.json()).toEqual(await unknown.json()); }); test('a box already running refuses a second run rather than picking one', async () => { const s = await scene('route-busy', 5504); expect((await requestSession(s)).res.status).toBe(201); const second = await requestSession(s); expect(second.res.status).toBe(409); expect(second.body.type).toBe('already_exists'); }); test('two requests racing for one box still start it once', async () => { const s = await scene('route-race', 5509); const [a, b] = await Promise.all([requestSession(s), requestSession(s)]); // Which request wins is a timing detail; that exactly one does is not. // The pre-check and the unique index answer identically, so the loser // cannot tell which caught it. // // This asserts the endpoint's answer, not the invariant: two requests // in one process usually interleave such that the pre-check catches // the second, so it passes with the unique index dropped. The index is // pinned in the core tests, where both callers can be made to read // before either writes. const statuses = [a.res.status, b.res.status].sort(); expect(statuses).toEqual([201, 409]); expect([a.body, b.body].find((x) => x.type)?.type).toBe('already_exists'); expect(await Session.listByBox(s.box.id)).toHaveLength(1); // The failure this prevents: the host offered the same box twice. const jobs = await app.request('/machine/jobs', { headers: s.host }); expect(((await jobs.json()) as any).data).toHaveLength(1); }); test('you can only play as an account you have linked', async () => { const mine = await scene('route-account-mine', 5505); const theirs = await scene('route-account-theirs', 5506); const res = await app.request('/session', { method: 'POST', headers: mine.user, body: JSON.stringify({ boxId: mine.box.id, gameId: mine.gameId, linkedAccountId: theirs.owner.linkedAccountId }) }); expect(res.status).toBe(403); }); test('you can only run a game you own', async () => { const s = await scene('route-unowned', 5560); // A real game in the catalog, simply not in this person's library. const unowned = await newGame(5561); const res = await app.request('/session', { method: 'POST', headers: s.user, body: JSON.stringify({ boxId: s.box.id, gameId: unowned, linkedAccountId: s.owner.linkedAccountId }) }); // Told apart from a game that does not exist, deliberately: the catalog // is public, so there is nothing to hide, and a box that starts and // then cannot launch is a worse answer minutes later. expect(res.status).toBe(403); expect(await Session.listByBox(s.box.id)).toHaveLength(0); }); test('the library check is per person, not per account it plays as', async () => { const s = await scene('route-multilink', 5562); // A second Steam account on the same person. The unique index is on // (provider, providerAccountId) and is global rather than per user, so // nothing stops this — but a fixed id here would collide with its own // previous run, hence one shaped like a SteamID64 and unique per run. const second = await LinkedAccount.create({ id: Identifier.ascending('linkedAccount'), userId: s.owner.userId, provider: 'steam', providerAccountId: `7656119${Date.now()}`.slice(0, 17), profile: null }); const res = await app.request('/session', { method: 'POST', headers: s.user, body: JSON.stringify({ boxId: s.box.id, gameId: s.gameId, linkedAccountId: second }) }); // Accepted, and this pins a known gap rather than asserting it is // right: a library entry records the person and not the account the // games came from, so "the account playing owns this" cannot be asked. // The run will fail at launch exactly as it did before the check // existed. Closing it means recording the account on the library // entry, which changes what a library is and what the sync must send. expect(res.status).toBe(201); }); test('an unknown game is a 404 and not a foreign key crash', async () => { const s = await scene('route-nogame', 5507); const res = await app.request('/session', { method: 'POST', headers: s.user, body: JSON.stringify({ boxId: s.box.id, gameId: Identifier.ascending('game'), linkedAccountId: s.owner.linkedAccountId }) }); expect(res.status).toBe(404); }); test('a host cannot ask for a session on its owner’s behalf', async () => { const s = await scene('route-hostcreate', 5508); const res = await app.request('/session', { method: 'POST', headers: s.host, body: JSON.stringify({ boxId: s.box.id, gameId: s.gameId, linkedAccountId: s.owner.linkedAccountId }) }); // A box holds credentials but is not the person who owns it. expect(res.status).toBe(403); }); test('requesting a session requires a signed-in person', async () => { const res = await app.request('/session', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ boxId: 'box_x', gameId: 'gam_x', linkedAccountId: 'lac_x' }) }); expect(res.status).toBe(401); }); }); /** * Two attempts. The rival exists so that "the holder" is a claim a test can * actually fail, rather than a value every request in the file shares. */ const HOLDER = 'h'.repeat(32); const RIVAL = 'r'.repeat(32); describe('GET /session/:id', () => { test('the owner reads their own run, ticket and all', async () => { const s = await scene('route-read', 5510); const { body } = await requestSession(s); await app.request(`/session/${body.data.id}/state`, { method: 'POST', headers: s.host, body: JSON.stringify({ state: 'starting', claimToken: HOLDER }) }); await app.request(`/session/${body.data.id}/ticket`, { method: 'POST', headers: s.host, body: JSON.stringify({ ticket: 'nodeaaa-one', claimToken: HOLDER }) }); const res = await app.request(`/session/${body.data.id}`, { headers: s.user }); expect(res.status).toBe(200); const read = (await res.json()) as any; expect(read.data.state).toBe('starting'); // A ticket may appear while the state is still `starting`, and the // client is expected to re-read rather than cache the first one. expect(read.data.ticket).toBe('nodeaaa-one'); }); test('somebody else’s run is not visible, and neither is its absence', async () => { const mine = await scene('route-read-mine', 5511); const theirs = await scene('route-read-theirs', 5512); const { body } = await requestSession(theirs); const forbidden = await app.request(`/session/${body.data.id}`, { headers: mine.user }); const unknown = await app.request(`/session/${Identifier.ascending('session')}`, { headers: mine.user }); expect(forbidden.status).toBe(404); expect(unknown.status).toBe(404); expect(await forbidden.json()).toEqual(await unknown.json()); }); test('reading a run requires a signed-in person', async () => { const res = await app.request('/session/ses_whatever'); expect(res.status).toBe(401); }); }); describe('GET /machine/jobs', () => { test('a host is handed the work for its own boxes, with the kind on the wire', async () => { const s = await scene('route-jobs', 5520); const { body } = await requestSession(s); const res = await app.request('/machine/jobs', { headers: s.host }); expect(res.status).toBe(200); const jobs = (await res.json()) as any; expect(Object.keys(jobs)).toEqual(['data']); expect(jobs.data).toHaveLength(1); expect(jobs.data[0]).toEqual({ kind: 'session.start', sessionId: body.data.id, boxId: s.box.id, boxTier: 'sm', gameId: s.gameId, steamAppId: 5520, linkedAccountId: s.owner.linkedAccountId }); }); test('a host never sees work for a box on other hardware', async () => { const mine = await scene('route-jobs-mine', 5521); const theirs = await scene('route-jobs-theirs', 5522); await requestSession(theirs); const res = await app.request('/machine/jobs', { headers: mine.host }); expect(res.status).toBe(200); // Scoped in the query rather than by the host asking for its own work. expect(((await res.json()) as any).data).toEqual([]); }); test('bad credentials are indistinguishable from none', async () => { const s = await scene('route-jobs-auth', 5523); const wrong = await app.request('/machine/jobs', { headers: { ...s.host, 'x-nestri-machine-secret': 'msk_wrong' } }); const none = await app.request('/machine/jobs'); expect(wrong.status).toBe(403); expect(none.status).toBe(403); // Bad credentials fall through to public and are then forbidden, so // probing tells an attacker nothing. Asserting the two are identical is // the only way that stays true. expect(await wrong.json()).toEqual(await none.json()); }); test('a person cannot poll for jobs', async () => { const s = await scene('route-jobs-person', 5524); const res = await app.request('/machine/jobs', { headers: s.user }); expect(res.status).toBe(403); }); }); describe('POST /session/:id/state', () => { test('the claim moves the row, and the job stops being offered', async () => { const s = await scene('route-claim', 5530); const { body } = await requestSession(s); const res = await app.request(`/session/${body.data.id}/state`, { method: 'POST', headers: s.host, body: JSON.stringify({ state: 'starting', claimToken: HOLDER }) }); expect(res.status).toBe(200); expect(((await res.json()) as any).data.state).toBe('starting'); const jobs = await app.request('/machine/jobs', { headers: s.host }); expect(((await jobs.json()) as any).data).toEqual([]); }); test('a report without a holder is refused before it reaches the run', async () => { const s = await scene('route-claim-tokenless', 5532); const { body } = await requestSession(s); const res = await app.request(`/session/${body.data.id}/state`, { method: 'POST', headers: s.host, body: JSON.stringify({ state: 'starting' }) }); expect(res.status).toBe(400); // And the run did not move on the way to being refused. expect((await Session.fromID(body.data.id))?.state).toBe('requested'); }); test('the same state from a second attempt is 409, not the retry’s 200', async () => { const s = await scene('route-claim-rival', 5533); const { body } = await requestSession(s); const report = (claimToken: string) => app.request(`/session/${body.data.id}/state`, { method: 'POST', headers: s.host, body: JSON.stringify({ state: 'starting', claimToken }) }); expect((await report(HOLDER)).status).toBe(200); // Identical credentials, identical state, identical everything except // the holder — which is the only thing that can separate an agent // retrying a lost response from one that lost the race, and the reason // this endpoint takes a token at all. expect((await report(RIVAL)).status).toBe(409); expect((await report(HOLDER)).status).toBe(200); }); test('a run does not move for an attempt that does not hold it', async () => { const s = await scene('route-claim-rival-move', 5534); const { body } = await requestSession(s); await app.request(`/session/${body.data.id}/state`, { method: 'POST', headers: s.host, body: JSON.stringify({ state: 'starting', claimToken: HOLDER }) }); const res = await app.request(`/session/${body.data.id}/state`, { method: 'POST', headers: s.host, body: JSON.stringify({ state: 'live', claimToken: RIVAL }) }); expect(res.status).toBe(409); expect((await Session.fromID(body.data.id))?.state).toBe('starting'); }); test('the same host re-reporting a state it already reported is fine', async () => { const s = await scene('route-claim-retry', 5531); const { body } = await requestSession(s); const report = () => app.request(`/session/${body.data.id}/state`, { method: 'POST', headers: s.host, body: JSON.stringify({ state: 'starting', claimToken: HOLDER }) }); expect((await report()).status).toBe(200); // An agent retrying after a lost response must not be told it broke // something. const again = await report(); expect(again.status).toBe(200); expect(((await again.json()) as any).data.state).toBe('starting'); }); test('a different host reporting anything is refused, and learns nothing', async () => { const mine = await scene('route-claim-mine', 5532); const theirs = await scene('route-claim-theirs', 5533); const { body } = await requestSession(theirs); const other = await app.request(`/session/${body.data.id}/state`, { method: 'POST', headers: mine.host, body: JSON.stringify({ state: 'starting', claimToken: HOLDER }) }); const unknown = await app.request(`/session/${Identifier.ascending('session')}/state`, { method: 'POST', headers: mine.host, body: JSON.stringify({ state: 'starting', claimToken: HOLDER }) }); expect(other.status).toBe(403); expect(unknown.status).toBe(403); expect(await other.json()).toEqual(await unknown.json()); expect((await Session.fromID(body.data.id))?.state).toBe('requested'); }); test('a transition that is not allowed is a conflict, and the row stays put', async () => { const s = await scene('route-claim-illegal', 5534); const { body } = await requestSession(s); const skipped = await app.request(`/session/${body.data.id}/state`, { method: 'POST', headers: s.host, body: JSON.stringify({ state: 'live', claimToken: HOLDER }) }); expect(skipped.status).toBe(409); expect((await Session.fromID(body.data.id))?.state).toBe('requested'); }); test('a stopped run cannot be started again', async () => { const s = await scene('route-claim-terminal', 5535); const { body } = await requestSession(s); const report = (state: string, errorMessage?: string) => app.request(`/session/${body.data.id}/state`, { method: 'POST', headers: s.host, body: JSON.stringify({ state, claimToken: HOLDER, errorMessage }) }); expect((await report('starting')).status).toBe(200); expect((await report('failed', 'the guest never came up')).status).toBe(200); expect((await report('starting')).status).toBe(409); const failed = await Session.fromID(body.data.id); expect(failed?.state).toBe('failed'); expect(failed?.errorMessage).toBe('the guest never came up'); }); test('a duplicate live report does not extend a run somebody is billed for', async () => { const s = await scene('route-claim-billing', 5536); const { body } = await requestSession(s); const report = (state: string) => app.request(`/session/${body.data.id}/state`, { method: 'POST', headers: s.host, body: JSON.stringify({ state, claimToken: HOLDER }) }); await report('starting'); const live = (await (await report('live')).json()) as any; expect(live.data.timeStarted).not.toBeNull(); const again = (await (await report('live')).json()) as any; expect(again.data.timeStarted).toBe(live.data.timeStarted); }); test('a state nobody defined is a validation error, not a conflict', async () => { const s = await scene('route-claim-bogus', 5537); const { body } = await requestSession(s); const res = await app.request(`/session/${body.data.id}/state`, { method: 'POST', headers: s.host, body: JSON.stringify({ state: 'exploded', claimToken: HOLDER }) }); expect(res.status).toBe(400); }); test('a person cannot report a state on their own session', async () => { const s = await scene('route-claim-person', 5538); const { body } = await requestSession(s); const res = await app.request(`/session/${body.data.id}/state`, { method: 'POST', headers: s.user, body: JSON.stringify({ state: 'starting', claimToken: HOLDER }) }); // Terminal states are written by the agent alone; a person closing the // app is not the same fact as a run that stopped. expect(res.status).toBe(403); }); }); describe('POST /session/:id/ticket', () => { test('a later ticket replaces the first, because it is a better address', async () => { const s = await scene('route-ticket', 5540); const { body } = await requestSession(s); await app.request(`/session/${body.data.id}/state`, { method: 'POST', headers: s.host, body: JSON.stringify({ state: 'starting', claimToken: HOLDER }) }); const publish = (ticket: string) => app.request(`/session/${body.data.id}/ticket`, { method: 'POST', headers: s.host, body: JSON.stringify({ ticket, claimToken: HOLDER }) }); const first = await publish('nodeaaa-one'); expect(first.status).toBe(200); expect(((await first.json()) as any).data.ticket).toBe('nodeaaa-one'); const second = await publish('nodeaaa-two'); expect(((await second.json()) as any).data.ticket).toBe('nodeaaa-two'); expect(await Session.listByBox(s.box.id)).toHaveLength(1); }); test('a second attempt cannot publish an address over the first’s', async () => { const s = await scene('route-ticket-rival', 5547); const { body } = await requestSession(s); await app.request(`/session/${body.data.id}/state`, { method: 'POST', headers: s.host, body: JSON.stringify({ state: 'starting', claimToken: HOLDER }) }); const publish = (ticket: string, claimToken: string) => app.request(`/session/${body.data.id}/ticket`, { method: 'POST', headers: s.host, body: JSON.stringify({ ticket, claimToken }) }); expect((await publish('nodeaaa-winner', HOLDER)).status).toBe(200); // The failure this prevents is quieter than a box started twice. The // client re-reads the address rather than keeping the first one, so a // ticket written here by the wrong attempt produces a client that // connects, successfully, to a machine running nothing. expect((await publish('nodeaaa-rival', RIVAL)).status).toBe(409); const read = await app.request(`/session/${body.data.id}`, { headers: s.user }); expect(((await read.json()) as any).data.ticket).toBe('nodeaaa-winner'); }); test('the holder is not in what the person reading their run gets back', async () => { const s = await scene('route-ticket-noleak', 5548); const { body } = await requestSession(s); await app.request(`/session/${body.data.id}/state`, { method: 'POST', headers: s.host, body: JSON.stringify({ state: 'starting', claimToken: HOLDER }) }); // Holding it permits writing to this run, and the owner is not the // holder. This asserts the whole shape rather than the one field, so a // column added later has to be added here too before it goes out. const read = await app.request(`/session/${body.data.id}`, { headers: s.user }); const data = ((await read.json()) as any).data; expect(Object.keys(data).sort()).toEqual([ 'boxId', 'errorMessage', 'gameId', 'id', 'linkedAccountId', 'state', 'ticket', 'timeStarted', 'timeStopped' ]); }); test('a run nobody has claimed has no address to publish', async () => { const s = await scene('route-ticket-early', 5546); const { body } = await requestSession(s); const early = await app.request(`/session/${body.data.id}/ticket`, { method: 'POST', headers: s.host, body: JSON.stringify({ ticket: 'nodeaaa-too-soon', claimToken: HOLDER }) }); // Publishing before reporting `starting` means the agent skipped the // claim, which is the only mutual exclusion in the design. expect(early.status).toBe(409); expect((await Session.fromID(body.data.id))?.ticket).toBeNull(); await app.request(`/session/${body.data.id}/state`, { method: 'POST', headers: s.host, body: JSON.stringify({ state: 'starting', claimToken: HOLDER }) }); const now = await app.request(`/session/${body.data.id}/ticket`, { method: 'POST', headers: s.host, body: JSON.stringify({ ticket: 'nodeaaa-in-time', claimToken: HOLDER }) }); expect(now.status).toBe(200); }); test('a different host cannot publish an address for someone else’s run', async () => { const mine = await scene('route-ticket-mine', 5541); const theirs = await scene('route-ticket-theirs', 5542); const { body } = await requestSession(theirs); const res = await app.request(`/session/${body.data.id}/ticket`, { method: 'POST', headers: mine.host, body: JSON.stringify({ ticket: 'nodeaaa-stolen', claimToken: HOLDER }) }); expect(res.status).toBe(403); expect((await Session.fromID(body.data.id))?.ticket).toBeNull(); }); test('a stopped run has no address to publish', async () => { const s = await scene('route-ticket-dead', 5543); const { body } = await requestSession(s); const report = (state: string) => app.request(`/session/${body.data.id}/state`, { method: 'POST', headers: s.host, body: JSON.stringify({ state, claimToken: HOLDER }) }); await report('starting'); await report('live'); await report('ended'); const res = await app.request(`/session/${body.data.id}/ticket`, { method: 'POST', headers: s.host, body: JSON.stringify({ ticket: 'nodeaaa-late', claimToken: HOLDER }) }); expect(res.status).toBe(409); expect((await Session.fromID(body.data.id))?.ticket).toBeNull(); }); test('a run that stops loses the address it published', async () => { const s = await scene('route-ticket-cleared', 5545); const { body } = await requestSession(s); const report = (state: string) => app.request(`/session/${body.data.id}/state`, { method: 'POST', headers: s.host, body: JSON.stringify({ state, claimToken: HOLDER }) }); await report('starting'); await report('live'); const published = await app.request(`/session/${body.data.id}/ticket`, { method: 'POST', headers: s.host, body: JSON.stringify({ ticket: 'nodeaaa-live', claimToken: HOLDER }) }); expect(((await published.json()) as any).data.ticket).toBe('nodeaaa-live'); await report('ended'); // The polling client is the reason. It reads this endpoint until it has // an address, and an address left behind by a run that stopped is one // it would dial — while publishing a replacement is already refused. const read = await app.request(`/session/${body.data.id}`, { headers: s.user }); const after = (await read.json()) as any; expect(after.data.state).toBe('ended'); expect(after.data.ticket).toBeNull(); }); test('a ticket has to say something', async () => { const s = await scene('route-ticket-empty', 5544); const { body } = await requestSession(s); const res = await app.request(`/session/${body.data.id}/ticket`, { method: 'POST', headers: s.host, body: JSON.stringify({ ticket: '', claimToken: HOLDER }) }); expect(res.status).toBe(400); }); }); describe('The box a run happens on', () => { test('the endpoints move the box, not just the run', async () => { const s = await scene('route-box-state', 5550); const { body } = await requestSession(s); const report = (state: string, errorMessage?: string) => app.request(`/session/${body.data.id}/state`, { method: 'POST', headers: s.host, body: JSON.stringify({ state, claimToken: HOLDER, errorMessage }) }); expect((await Box.fromID(s.box.id))?.state).toBe('created'); await report('starting'); await report('live'); // The screens that tell a person what their hardware is doing read the // box, so a live run has to be visible there and not only on the run. expect((await Box.fromID(s.box.id))?.state).toBe('running'); await report('failed', 'the guest never came up'); const stopped = await Box.fromID(s.box.id); expect(stopped?.state).toBe('stopped'); expect(stopped?.stopClean).toBe(false); expect(stopped?.stopReason).toBe('the guest never came up'); }); }); describe('Session routes in the spec', () => { test('every path a caller needs is documented', async () => { const res = await app.request('/doc'); const paths = Object.keys(((await res.json()) as any).paths); expect(paths).toContain('/session'); expect(paths).toContain('/session/{id}'); expect(paths).toContain('/session/{id}/state'); expect(paths).toContain('/session/{id}/ticket'); expect(paths).toContain('/machine/jobs'); }); });