Files
netris-nestri/apps/api/test/session.test.ts
Wanjohi 54d5c81edb feat(api): hold a run to the attempt that claimed it
The agent side sends a claim token on every write; this side rejected the
field outright, so every state report and every ticket publish answered
400. Both bodies now take it.

Underneath that, nothing compared a holder. A run was reachable by any
caller on the right machine, and a box names exactly one machine — so two
attempts polling the same job presented identical credentials and were
told apart only by which one's select landed first. That is timing, not a
rule, and no caller could be told which case it was in.

The row now remembers which attempt holds it. Taking a claim requires
there to be no holder; every write after it requires the caller to be the
holder. The same state reported by a different attempt is a lost race and
not a retry, and is refused whatever the state is - which is the only
thing that separates the two 200s from the 409s.

The ticket is held to the claim too, for a worse reason than a double
start: the client re-reads the address rather than keeping the first, so
a ticket written by a losing attempt produces a client that connects,
successfully, to a machine running nothing.

The holder is never cleared, including on a terminal state, so a settled
claim cannot be replayed and a finished run still records which attempt
ran it. It is not in what goes out - holding one permits writing to a
run, and the owner reading their own session is not the holder.
2026-09-05 13:02:57 +03:00

853 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string> {
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<string, string>,
host: {
'x-nestri-machine-id': registered.id,
'x-nestri-machine-secret': registered.secret,
'content-type': 'application/json'
} as Record<string, string>
};
}
async function requestSession(s: Awaited<ReturnType<typeof scene>>) {
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) {
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.
expect(Object.keys(body)).toEqual(['data']);
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 owners 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 elses 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 retrys 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 firsts', 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 elses 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');
});
});