Files
netris-nestri/apps/api/test/session.test.ts
Wanjohi b4776fad2f feat(core,api): record burn as rate segments, and refuse the next run when spent
The counters this fills are the ones the windows already knew how to read.
What was missing was anything that put a number in them.

Burn is recorded as segments: one stretch of one run at one unchanging rate,
opened when the rate becomes true and closed when it stops being. Not a row per
session, because a session's rate does not survive its own lifetime — a second
run changes what the account spends per second while the first is still going,
and a rate that applied from that moment must not be backdated over the time
before it. Not a row per event either, because burn accrues against an envelope
that is held rather than per thing consumed.

Closing a segment is what moves burn into the counters, so a long run lands
incrementally instead of all at the end. Burn that only arrives when a session
stops is burn that cannot refuse the next one, and a bar that does not move
while something is running is a bar nobody believes.

The counters are written with the staleness rule as a single statement: add to
the total if its stamp is still inside the window, otherwise start again from
this amount. Reading and then deciding would be two statements with a gap, and
the gap is where a concurrent tick doubles or vanishes. The first tick for a
team and the thousandth are the same call, for the same reason.

The gate sits at the one moment it is allowed to speak — before a run starts,
never again. A limit refuses the next run and never interrupts one already
going; someone losing a session mid-game to a meter does not come back. Every
window is checked rather than the shortest, because they protect different
things over different spans.

The answer comes back with the created run rather than being thrown away: the
response carries where each window stands, what the account spends per second
now, and what one more run would cost. Every surface that can start a run has
to show that before the click, and a second call for it is a call nobody makes.
Asking twice would also let the number shown and the number billed disagree.

Accrual is wired to the run's own state transition, in the same transaction
that moves it. A session that went live without its meter starting is free
hardware; one that ended without its meter stopping bills forever. Both are
silent, so neither may be a second write that might not happen.
2026-09-19 00:19:39 +03:00

873 lines
30 KiB
TypeScript
Raw Permalink 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) {
// `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 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');
});
});