Files
netris-nestri/apps/api/test/session.test.ts
T
Wanjohi b50dce2cef feat(session): a finished session says why it ended (#412)
Until now every finished run said only `ended` or `failed`. A user stop,
a trial window running out, an idle timeout, a host restart and a game
quitting were indistinguishable afterwards.

**Schema.** Migration `0026_session_end_reason` adds two nullable
columns to `session`:
- `end_reason`: one of `user`, `window`, `hours`, `idle`, `left_queue`,
`game_exited`, `launch_failed`, `host_stopped`, `host_restart`.
- `time_claimed`: when a host took the run, set on `requested →
starting`.

**API.**
- `POST /session/:id/state` takes an optional `endReason`. With `ended`
or `failed` it is stored. With `starting` or `live` it is a 400 and the
run does not move.
- Leaving it out of a terminal report stores null, so host agents that
predate this keep working.
- `endReason` and `timeClaimed` are returned on every session response.
- The API writes the reasons it decides itself: `user` when a run nobody
has claimed is stopped, and `left_queue` when a waiting run is abandoned
(the `Left the queue` message is kept).

**New transition, `starting → ended`.** A run stopped while its game is
still downloading has failed at nothing, and never reached `live`, which
is where billing starts. Until now the only way out of `starting` was
`failed`.

**Release order: this goes first.** The host agent that sends
`endReason` is a separate change. This route's body schema is strict, so
without this deployed the current API answers that agent with a 400.

**Tests.**
- API: 185 pass, 5 of them new.
- Core: 41 session tests pass, 3 of them new.
- The new tests fail when the implementation is broken on purpose.
- No new type errors.
2026-10-16 00:20:49 +03:00

1102 lines
38 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) {
// `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',
tier: null,
ticket: null,
timeClaimed: null,
timeStarted: null,
timeStopped: null,
timeStopRequested: null,
trial: false,
errorMessage: null,
endReason: 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,
userId: s.owner.userId
});
});
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);
const claimed = ((await res.json()) as any).data;
expect(claimed.state).toBe('starting');
// The time it was taken is what bring-up is measured from.
expect(body.data.timeClaimed).toBeNull();
expect(claimed.timeClaimed).not.toBeNull();
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('the report that ends a run says why, and the owner reads it', async () => {
const s = await scene('route-reason-ended', 5570);
const { body } = await requestSession(s);
const report = (state: string, endReason?: string) =>
app.request(`/session/${body.data.id}/state`, {
method: 'POST',
headers: s.host,
body: JSON.stringify({ state, claimToken: HOLDER, endReason })
});
await report('starting');
await report('live');
const ended = await report('ended', 'game_exited');
expect(ended.status).toBe(200);
expect(((await ended.json()) as any).data.endReason).toBe('game_exited');
const read = await app.request(`/session/${body.data.id}`, { headers: s.user });
expect(((await read.json()) as any).data.endReason).toBe('game_exited');
});
test('a run that failed says why it is over, beside what went wrong', async () => {
const s = await scene('route-reason-failed', 5571);
const { body } = await requestSession(s);
const report = (payload: Record<string, unknown>) =>
app.request(`/session/${body.data.id}/state`, {
method: 'POST',
headers: s.host,
body: JSON.stringify({ claimToken: HOLDER, ...payload })
});
await report({ state: 'starting' });
const failed = await report({
state: 'failed',
errorMessage: 'the download could not finish',
endReason: 'launch_failed'
});
expect(failed.status).toBe(200);
expect(((await failed.json()) as any).data).toMatchObject({
state: 'failed',
errorMessage: 'the download could not finish',
endReason: 'launch_failed'
});
});
test('a reason with a run that is not over is refused, and the run stays put', async () => {
const s = await scene('route-reason-early', 5572);
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: HOLDER, endReason: 'idle' })
});
expect(res.status).toBe(400);
expect(((await res.json()) as any).param).toBe('endReason');
const run = await Session.fromID(body.data.id);
expect(run?.state).toBe('starting');
expect(run?.endReason).toBeNull();
});
test('a reason nobody defined is a validation error', async () => {
const s = await scene('route-reason-bogus', 5573);
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: 'failed', claimToken: HOLDER, endReason: 'bored' })
});
expect(res.status).toBe(400);
expect((await Session.fromID(body.data.id))?.state).toBe('starting');
});
test('a host agent that sends no reason still ends the run, with none recorded', async () => {
const s = await scene('route-reason-absent', 5574);
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');
// The report every host agent already in the field sends.
const ended = await report('ended');
expect(ended.status).toBe(200);
expect(((await ended.json()) as any).data).toMatchObject({ state: 'ended', endReason: null });
});
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',
'endReason',
'errorMessage',
'gameId',
'id',
'linkedAccountId',
'queue',
'state',
'ticket',
'tier',
'timeClaimed',
'timeStarted',
'timeStopRequested',
'timeStopped',
'trial'
]);
});
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');
});
});
describe('POST /session/:id/stop', () => {
const stop = (s: Awaited<ReturnType<typeof scene>>, id: string) =>
app.request(`/session/${id}/stop`, { method: 'POST', headers: s.user });
const report = (s: Awaited<ReturnType<typeof scene>>, id: string, state: string) =>
app.request(`/session/${id}/state`, {
method: 'POST',
headers: s.host,
body: JSON.stringify({ state, claimToken: HOLDER })
});
const stops = async (s: Awaited<ReturnType<typeof scene>>) => {
const res = await app.request('/machine/jobs', { headers: s.host });
return (((await res.json()) as any).data as any[]).filter((j) => j.kind === 'session.stop');
};
test('a run no host has picked up ends at once, and is never handed out', async () => {
const s = await scene('stop-unclaimed', 5601);
const { body } = await requestSession(s);
const res = await stop(s, body.data.id);
expect(res.status).toBe(200);
const out = (await res.json()) as any;
expect(out.data.state).toBe('ended');
expect(out.data.timeStopped).not.toBeNull();
// Ended here rather than by a host, so the reason is written here too.
expect(out.data.endReason).toBe('user');
expect(out.data.timeClaimed).toBeNull();
const res2 = await app.request('/machine/jobs', { headers: s.host });
expect(((await res2.json()) as any).data).toEqual([]);
});
test('a live run stays live until its host has stopped it', async () => {
const s = await scene('stop-live', 5602);
const { body } = await requestSession(s);
const id = body.data.id;
await report(s, id, 'starting');
await report(s, id, 'live');
const res = await stop(s, id);
expect(res.status).toBe(200);
const out = (await res.json()) as any;
expect(out.data.state).toBe('live');
expect(out.data.timeStopRequested).not.toBeNull();
expect(await stops(s)).toEqual([
{ kind: 'session.stop', sessionId: id, boxId: s.box.id, reason: 'user' }
]);
// Asking twice is the same request: the time asked is the first one.
const again = (await (await stop(s, id)).json()) as any;
expect(again.data.timeStopRequested).toBe(out.data.timeStopRequested);
await report(s, id, 'ended');
expect(await stops(s)).toEqual([]);
const over = (await (await stop(s, id)).json()) as any;
expect(over.data.state).toBe('ended');
});
test('somebody else’s run is not there to stop', async () => {
const s = await scene('stop-owner', 5603);
const other = await scene('stop-other', 5604);
const { body } = await requestSession(s);
const res = await stop(other, body.data.id);
expect(res.status).toBe(404);
expect(
((await (await app.request(`/session/${body.data.id}`, { headers: s.user })).json()) as any)
.data.state
).toBe('requested');
});
test('a host cannot stop a run as its owner', async () => {
const s = await scene('stop-host', 5605);
const { body } = await requestSession(s);
const res = await app.request(`/session/${body.data.id}/stop`, {
method: 'POST',
headers: s.host
});
expect(res.status).toBe(403);
});
});
describe('GET /machine/:id/downloads', () => {
test('what a host says it is downloading is what its owner reads', async () => {
const s = await scene('downloads-owner', 5701);
const said = await app.request('/games/download-state', {
method: 'POST',
headers: s.host,
body: JSON.stringify({
steamAppId: 5701,
status: 'downloading',
progressBytes: 40,
totalBytes: 100
})
});
expect(said.status).toBe(200);
const res = await app.request(`/machine/${s.machineId}/downloads`, { headers: s.user });
expect(res.status).toBe(200);
const data = ((await res.json()) as any).data;
expect(data).toHaveLength(1);
expect(data[0]).toMatchObject({
status: 'downloading',
progressBytes: 40,
totalBytes: 100,
game: { steamAppId: 5701, name: 'Session Route 5701' }
});
});
test('somebody else’s machine is not there to read', async () => {
const s = await scene('downloads-mine', 5702);
const other = await scene('downloads-theirs', 5703);
const res = await app.request(`/machine/${s.machineId}/downloads`, { headers: other.user });
expect(res.status).toBe(404);
});
});