mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 09:15:19 +03:00
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.
853 lines
29 KiB
TypeScript
853 lines
29 KiB
TypeScript
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 owner’s behalf', async () => {
|
||
const s = await scene('route-hostcreate', 5508);
|
||
const res = await app.request('/session', {
|
||
method: 'POST',
|
||
headers: s.host,
|
||
body: JSON.stringify({
|
||
boxId: s.box.id,
|
||
gameId: s.gameId,
|
||
linkedAccountId: s.owner.linkedAccountId
|
||
})
|
||
});
|
||
// A box holds credentials but is not the person who owns it.
|
||
expect(res.status).toBe(403);
|
||
});
|
||
|
||
test('requesting a session requires a signed-in person', async () => {
|
||
const res = await app.request('/session', {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify({ boxId: 'box_x', gameId: 'gam_x', linkedAccountId: 'lac_x' })
|
||
});
|
||
expect(res.status).toBe(401);
|
||
});
|
||
});
|
||
|
||
/**
|
||
* Two attempts. The rival exists so that "the holder" is a claim a test can
|
||
* actually fail, rather than a value every request in the file shares.
|
||
*/
|
||
const HOLDER = 'h'.repeat(32);
|
||
const RIVAL = 'r'.repeat(32);
|
||
|
||
describe('GET /session/:id', () => {
|
||
test('the owner reads their own run, ticket and all', async () => {
|
||
const s = await scene('route-read', 5510);
|
||
const { body } = await requestSession(s);
|
||
|
||
await app.request(`/session/${body.data.id}/state`, {
|
||
method: 'POST',
|
||
headers: s.host,
|
||
body: JSON.stringify({ state: 'starting', claimToken: HOLDER })
|
||
});
|
||
await app.request(`/session/${body.data.id}/ticket`, {
|
||
method: 'POST',
|
||
headers: s.host,
|
||
body: JSON.stringify({ ticket: 'nodeaaa-one', claimToken: HOLDER })
|
||
});
|
||
|
||
const res = await app.request(`/session/${body.data.id}`, { headers: s.user });
|
||
expect(res.status).toBe(200);
|
||
const read = (await res.json()) as any;
|
||
expect(read.data.state).toBe('starting');
|
||
// A ticket may appear while the state is still `starting`, and the
|
||
// client is expected to re-read rather than cache the first one.
|
||
expect(read.data.ticket).toBe('nodeaaa-one');
|
||
});
|
||
|
||
test('somebody else’s run is not visible, and neither is its absence', async () => {
|
||
const mine = await scene('route-read-mine', 5511);
|
||
const theirs = await scene('route-read-theirs', 5512);
|
||
const { body } = await requestSession(theirs);
|
||
|
||
const forbidden = await app.request(`/session/${body.data.id}`, { headers: mine.user });
|
||
const unknown = await app.request(`/session/${Identifier.ascending('session')}`, {
|
||
headers: mine.user
|
||
});
|
||
expect(forbidden.status).toBe(404);
|
||
expect(unknown.status).toBe(404);
|
||
expect(await forbidden.json()).toEqual(await unknown.json());
|
||
});
|
||
|
||
test('reading a run requires a signed-in person', async () => {
|
||
const res = await app.request('/session/ses_whatever');
|
||
expect(res.status).toBe(401);
|
||
});
|
||
});
|
||
|
||
describe('GET /machine/jobs', () => {
|
||
test('a host is handed the work for its own boxes, with the kind on the wire', async () => {
|
||
const s = await scene('route-jobs', 5520);
|
||
const { body } = await requestSession(s);
|
||
|
||
const res = await app.request('/machine/jobs', { headers: s.host });
|
||
expect(res.status).toBe(200);
|
||
const jobs = (await res.json()) as any;
|
||
expect(Object.keys(jobs)).toEqual(['data']);
|
||
expect(jobs.data).toHaveLength(1);
|
||
expect(jobs.data[0]).toEqual({
|
||
kind: 'session.start',
|
||
sessionId: body.data.id,
|
||
boxId: s.box.id,
|
||
boxTier: 'sm',
|
||
gameId: s.gameId,
|
||
steamAppId: 5520,
|
||
linkedAccountId: s.owner.linkedAccountId
|
||
});
|
||
});
|
||
|
||
test('a host never sees work for a box on other hardware', async () => {
|
||
const mine = await scene('route-jobs-mine', 5521);
|
||
const theirs = await scene('route-jobs-theirs', 5522);
|
||
await requestSession(theirs);
|
||
|
||
const res = await app.request('/machine/jobs', { headers: mine.host });
|
||
expect(res.status).toBe(200);
|
||
// Scoped in the query rather than by the host asking for its own work.
|
||
expect(((await res.json()) as any).data).toEqual([]);
|
||
});
|
||
|
||
test('bad credentials are indistinguishable from none', async () => {
|
||
const s = await scene('route-jobs-auth', 5523);
|
||
const wrong = await app.request('/machine/jobs', {
|
||
headers: { ...s.host, 'x-nestri-machine-secret': 'msk_wrong' }
|
||
});
|
||
const none = await app.request('/machine/jobs');
|
||
expect(wrong.status).toBe(403);
|
||
expect(none.status).toBe(403);
|
||
// Bad credentials fall through to public and are then forbidden, so
|
||
// probing tells an attacker nothing. Asserting the two are identical is
|
||
// the only way that stays true.
|
||
expect(await wrong.json()).toEqual(await none.json());
|
||
});
|
||
|
||
test('a person cannot poll for jobs', async () => {
|
||
const s = await scene('route-jobs-person', 5524);
|
||
const res = await app.request('/machine/jobs', { headers: s.user });
|
||
expect(res.status).toBe(403);
|
||
});
|
||
});
|
||
|
||
describe('POST /session/:id/state', () => {
|
||
test('the claim moves the row, and the job stops being offered', async () => {
|
||
const s = await scene('route-claim', 5530);
|
||
const { body } = await requestSession(s);
|
||
|
||
const res = await app.request(`/session/${body.data.id}/state`, {
|
||
method: 'POST',
|
||
headers: s.host,
|
||
body: JSON.stringify({ state: 'starting', claimToken: HOLDER })
|
||
});
|
||
expect(res.status).toBe(200);
|
||
expect(((await res.json()) as any).data.state).toBe('starting');
|
||
|
||
const jobs = await app.request('/machine/jobs', { headers: s.host });
|
||
expect(((await jobs.json()) as any).data).toEqual([]);
|
||
});
|
||
|
||
test('a report without a holder is refused before it reaches the run', async () => {
|
||
const s = await scene('route-claim-tokenless', 5532);
|
||
const { body } = await requestSession(s);
|
||
|
||
const res = await app.request(`/session/${body.data.id}/state`, {
|
||
method: 'POST',
|
||
headers: s.host,
|
||
body: JSON.stringify({ state: 'starting' })
|
||
});
|
||
expect(res.status).toBe(400);
|
||
// And the run did not move on the way to being refused.
|
||
expect((await Session.fromID(body.data.id))?.state).toBe('requested');
|
||
});
|
||
|
||
test('the same state from a second attempt is 409, not the retry’s 200', async () => {
|
||
const s = await scene('route-claim-rival', 5533);
|
||
const { body } = await requestSession(s);
|
||
|
||
const report = (claimToken: string) =>
|
||
app.request(`/session/${body.data.id}/state`, {
|
||
method: 'POST',
|
||
headers: s.host,
|
||
body: JSON.stringify({ state: 'starting', claimToken })
|
||
});
|
||
|
||
expect((await report(HOLDER)).status).toBe(200);
|
||
// Identical credentials, identical state, identical everything except
|
||
// the holder — which is the only thing that can separate an agent
|
||
// retrying a lost response from one that lost the race, and the reason
|
||
// this endpoint takes a token at all.
|
||
expect((await report(RIVAL)).status).toBe(409);
|
||
expect((await report(HOLDER)).status).toBe(200);
|
||
});
|
||
|
||
test('a run does not move for an attempt that does not hold it', async () => {
|
||
const s = await scene('route-claim-rival-move', 5534);
|
||
const { body } = await requestSession(s);
|
||
await app.request(`/session/${body.data.id}/state`, {
|
||
method: 'POST',
|
||
headers: s.host,
|
||
body: JSON.stringify({ state: 'starting', claimToken: HOLDER })
|
||
});
|
||
|
||
const res = await app.request(`/session/${body.data.id}/state`, {
|
||
method: 'POST',
|
||
headers: s.host,
|
||
body: JSON.stringify({ state: 'live', claimToken: RIVAL })
|
||
});
|
||
expect(res.status).toBe(409);
|
||
expect((await Session.fromID(body.data.id))?.state).toBe('starting');
|
||
});
|
||
|
||
test('the same host re-reporting a state it already reported is fine', async () => {
|
||
const s = await scene('route-claim-retry', 5531);
|
||
const { body } = await requestSession(s);
|
||
|
||
const report = () =>
|
||
app.request(`/session/${body.data.id}/state`, {
|
||
method: 'POST',
|
||
headers: s.host,
|
||
body: JSON.stringify({ state: 'starting', claimToken: HOLDER })
|
||
});
|
||
|
||
expect((await report()).status).toBe(200);
|
||
// An agent retrying after a lost response must not be told it broke
|
||
// something.
|
||
const again = await report();
|
||
expect(again.status).toBe(200);
|
||
expect(((await again.json()) as any).data.state).toBe('starting');
|
||
});
|
||
|
||
test('a different host reporting anything is refused, and learns nothing', async () => {
|
||
const mine = await scene('route-claim-mine', 5532);
|
||
const theirs = await scene('route-claim-theirs', 5533);
|
||
const { body } = await requestSession(theirs);
|
||
|
||
const other = await app.request(`/session/${body.data.id}/state`, {
|
||
method: 'POST',
|
||
headers: mine.host,
|
||
body: JSON.stringify({ state: 'starting', claimToken: HOLDER })
|
||
});
|
||
const unknown = await app.request(`/session/${Identifier.ascending('session')}/state`, {
|
||
method: 'POST',
|
||
headers: mine.host,
|
||
body: JSON.stringify({ state: 'starting', claimToken: HOLDER })
|
||
});
|
||
|
||
expect(other.status).toBe(403);
|
||
expect(unknown.status).toBe(403);
|
||
expect(await other.json()).toEqual(await unknown.json());
|
||
expect((await Session.fromID(body.data.id))?.state).toBe('requested');
|
||
});
|
||
|
||
test('a transition that is not allowed is a conflict, and the row stays put', async () => {
|
||
const s = await scene('route-claim-illegal', 5534);
|
||
const { body } = await requestSession(s);
|
||
|
||
const skipped = await app.request(`/session/${body.data.id}/state`, {
|
||
method: 'POST',
|
||
headers: s.host,
|
||
body: JSON.stringify({ state: 'live', claimToken: HOLDER })
|
||
});
|
||
expect(skipped.status).toBe(409);
|
||
expect((await Session.fromID(body.data.id))?.state).toBe('requested');
|
||
});
|
||
|
||
test('a stopped run cannot be started again', async () => {
|
||
const s = await scene('route-claim-terminal', 5535);
|
||
const { body } = await requestSession(s);
|
||
const report = (state: string, errorMessage?: string) =>
|
||
app.request(`/session/${body.data.id}/state`, {
|
||
method: 'POST',
|
||
headers: s.host,
|
||
body: JSON.stringify({ state, claimToken: HOLDER, errorMessage })
|
||
});
|
||
|
||
expect((await report('starting')).status).toBe(200);
|
||
expect((await report('failed', 'the guest never came up')).status).toBe(200);
|
||
expect((await report('starting')).status).toBe(409);
|
||
|
||
const failed = await Session.fromID(body.data.id);
|
||
expect(failed?.state).toBe('failed');
|
||
expect(failed?.errorMessage).toBe('the guest never came up');
|
||
});
|
||
|
||
test('a duplicate live report does not extend a run somebody is billed for', async () => {
|
||
const s = await scene('route-claim-billing', 5536);
|
||
const { body } = await requestSession(s);
|
||
const report = (state: string) =>
|
||
app.request(`/session/${body.data.id}/state`, {
|
||
method: 'POST',
|
||
headers: s.host,
|
||
body: JSON.stringify({ state, claimToken: HOLDER })
|
||
});
|
||
|
||
await report('starting');
|
||
const live = (await (await report('live')).json()) as any;
|
||
expect(live.data.timeStarted).not.toBeNull();
|
||
|
||
const again = (await (await report('live')).json()) as any;
|
||
expect(again.data.timeStarted).toBe(live.data.timeStarted);
|
||
});
|
||
|
||
test('a state nobody defined is a validation error, not a conflict', async () => {
|
||
const s = await scene('route-claim-bogus', 5537);
|
||
const { body } = await requestSession(s);
|
||
const res = await app.request(`/session/${body.data.id}/state`, {
|
||
method: 'POST',
|
||
headers: s.host,
|
||
body: JSON.stringify({ state: 'exploded', claimToken: HOLDER })
|
||
});
|
||
expect(res.status).toBe(400);
|
||
});
|
||
|
||
test('a person cannot report a state on their own session', async () => {
|
||
const s = await scene('route-claim-person', 5538);
|
||
const { body } = await requestSession(s);
|
||
const res = await app.request(`/session/${body.data.id}/state`, {
|
||
method: 'POST',
|
||
headers: s.user,
|
||
body: JSON.stringify({ state: 'starting', claimToken: HOLDER })
|
||
});
|
||
// Terminal states are written by the agent alone; a person closing the
|
||
// app is not the same fact as a run that stopped.
|
||
expect(res.status).toBe(403);
|
||
});
|
||
});
|
||
|
||
describe('POST /session/:id/ticket', () => {
|
||
test('a later ticket replaces the first, because it is a better address', async () => {
|
||
const s = await scene('route-ticket', 5540);
|
||
const { body } = await requestSession(s);
|
||
await app.request(`/session/${body.data.id}/state`, {
|
||
method: 'POST',
|
||
headers: s.host,
|
||
body: JSON.stringify({ state: 'starting', claimToken: HOLDER })
|
||
});
|
||
|
||
const publish = (ticket: string) =>
|
||
app.request(`/session/${body.data.id}/ticket`, {
|
||
method: 'POST',
|
||
headers: s.host,
|
||
body: JSON.stringify({ ticket, claimToken: HOLDER })
|
||
});
|
||
|
||
const first = await publish('nodeaaa-one');
|
||
expect(first.status).toBe(200);
|
||
expect(((await first.json()) as any).data.ticket).toBe('nodeaaa-one');
|
||
|
||
const second = await publish('nodeaaa-two');
|
||
expect(((await second.json()) as any).data.ticket).toBe('nodeaaa-two');
|
||
expect(await Session.listByBox(s.box.id)).toHaveLength(1);
|
||
});
|
||
|
||
test('a second attempt cannot publish an address over the first’s', async () => {
|
||
const s = await scene('route-ticket-rival', 5547);
|
||
const { body } = await requestSession(s);
|
||
await app.request(`/session/${body.data.id}/state`, {
|
||
method: 'POST',
|
||
headers: s.host,
|
||
body: JSON.stringify({ state: 'starting', claimToken: HOLDER })
|
||
});
|
||
|
||
const publish = (ticket: string, claimToken: string) =>
|
||
app.request(`/session/${body.data.id}/ticket`, {
|
||
method: 'POST',
|
||
headers: s.host,
|
||
body: JSON.stringify({ ticket, claimToken })
|
||
});
|
||
|
||
expect((await publish('nodeaaa-winner', HOLDER)).status).toBe(200);
|
||
// The failure this prevents is quieter than a box started twice. The
|
||
// client re-reads the address rather than keeping the first one, so a
|
||
// ticket written here by the wrong attempt produces a client that
|
||
// connects, successfully, to a machine running nothing.
|
||
expect((await publish('nodeaaa-rival', RIVAL)).status).toBe(409);
|
||
|
||
const read = await app.request(`/session/${body.data.id}`, { headers: s.user });
|
||
expect(((await read.json()) as any).data.ticket).toBe('nodeaaa-winner');
|
||
});
|
||
|
||
test('the holder is not in what the person reading their run gets back', async () => {
|
||
const s = await scene('route-ticket-noleak', 5548);
|
||
const { body } = await requestSession(s);
|
||
await app.request(`/session/${body.data.id}/state`, {
|
||
method: 'POST',
|
||
headers: s.host,
|
||
body: JSON.stringify({ state: 'starting', claimToken: HOLDER })
|
||
});
|
||
|
||
// Holding it permits writing to this run, and the owner is not the
|
||
// holder. This asserts the whole shape rather than the one field, so a
|
||
// column added later has to be added here too before it goes out.
|
||
const read = await app.request(`/session/${body.data.id}`, { headers: s.user });
|
||
const data = ((await read.json()) as any).data;
|
||
expect(Object.keys(data).sort()).toEqual([
|
||
'boxId',
|
||
'errorMessage',
|
||
'gameId',
|
||
'id',
|
||
'linkedAccountId',
|
||
'state',
|
||
'ticket',
|
||
'timeStarted',
|
||
'timeStopped'
|
||
]);
|
||
});
|
||
|
||
test('a run nobody has claimed has no address to publish', async () => {
|
||
const s = await scene('route-ticket-early', 5546);
|
||
const { body } = await requestSession(s);
|
||
|
||
const early = await app.request(`/session/${body.data.id}/ticket`, {
|
||
method: 'POST',
|
||
headers: s.host,
|
||
body: JSON.stringify({ ticket: 'nodeaaa-too-soon', claimToken: HOLDER })
|
||
});
|
||
// Publishing before reporting `starting` means the agent skipped the
|
||
// claim, which is the only mutual exclusion in the design.
|
||
expect(early.status).toBe(409);
|
||
expect((await Session.fromID(body.data.id))?.ticket).toBeNull();
|
||
|
||
await app.request(`/session/${body.data.id}/state`, {
|
||
method: 'POST',
|
||
headers: s.host,
|
||
body: JSON.stringify({ state: 'starting', claimToken: HOLDER })
|
||
});
|
||
const now = await app.request(`/session/${body.data.id}/ticket`, {
|
||
method: 'POST',
|
||
headers: s.host,
|
||
body: JSON.stringify({ ticket: 'nodeaaa-in-time', claimToken: HOLDER })
|
||
});
|
||
expect(now.status).toBe(200);
|
||
});
|
||
|
||
test('a different host cannot publish an address for someone else’s run', async () => {
|
||
const mine = await scene('route-ticket-mine', 5541);
|
||
const theirs = await scene('route-ticket-theirs', 5542);
|
||
const { body } = await requestSession(theirs);
|
||
|
||
const res = await app.request(`/session/${body.data.id}/ticket`, {
|
||
method: 'POST',
|
||
headers: mine.host,
|
||
body: JSON.stringify({ ticket: 'nodeaaa-stolen', claimToken: HOLDER })
|
||
});
|
||
expect(res.status).toBe(403);
|
||
expect((await Session.fromID(body.data.id))?.ticket).toBeNull();
|
||
});
|
||
|
||
test('a stopped run has no address to publish', async () => {
|
||
const s = await scene('route-ticket-dead', 5543);
|
||
const { body } = await requestSession(s);
|
||
const report = (state: string) =>
|
||
app.request(`/session/${body.data.id}/state`, {
|
||
method: 'POST',
|
||
headers: s.host,
|
||
body: JSON.stringify({ state, claimToken: HOLDER })
|
||
});
|
||
await report('starting');
|
||
await report('live');
|
||
await report('ended');
|
||
|
||
const res = await app.request(`/session/${body.data.id}/ticket`, {
|
||
method: 'POST',
|
||
headers: s.host,
|
||
body: JSON.stringify({ ticket: 'nodeaaa-late', claimToken: HOLDER })
|
||
});
|
||
expect(res.status).toBe(409);
|
||
expect((await Session.fromID(body.data.id))?.ticket).toBeNull();
|
||
});
|
||
|
||
test('a run that stops loses the address it published', async () => {
|
||
const s = await scene('route-ticket-cleared', 5545);
|
||
const { body } = await requestSession(s);
|
||
const report = (state: string) =>
|
||
app.request(`/session/${body.data.id}/state`, {
|
||
method: 'POST',
|
||
headers: s.host,
|
||
body: JSON.stringify({ state, claimToken: HOLDER })
|
||
});
|
||
await report('starting');
|
||
await report('live');
|
||
const published = await app.request(`/session/${body.data.id}/ticket`, {
|
||
method: 'POST',
|
||
headers: s.host,
|
||
body: JSON.stringify({ ticket: 'nodeaaa-live', claimToken: HOLDER })
|
||
});
|
||
expect(((await published.json()) as any).data.ticket).toBe('nodeaaa-live');
|
||
|
||
await report('ended');
|
||
|
||
// The polling client is the reason. It reads this endpoint until it has
|
||
// an address, and an address left behind by a run that stopped is one
|
||
// it would dial — while publishing a replacement is already refused.
|
||
const read = await app.request(`/session/${body.data.id}`, { headers: s.user });
|
||
const after = (await read.json()) as any;
|
||
expect(after.data.state).toBe('ended');
|
||
expect(after.data.ticket).toBeNull();
|
||
});
|
||
|
||
test('a ticket has to say something', async () => {
|
||
const s = await scene('route-ticket-empty', 5544);
|
||
const { body } = await requestSession(s);
|
||
const res = await app.request(`/session/${body.data.id}/ticket`, {
|
||
method: 'POST',
|
||
headers: s.host,
|
||
body: JSON.stringify({ ticket: '', claimToken: HOLDER })
|
||
});
|
||
expect(res.status).toBe(400);
|
||
});
|
||
});
|
||
|
||
describe('The box a run happens on', () => {
|
||
test('the endpoints move the box, not just the run', async () => {
|
||
const s = await scene('route-box-state', 5550);
|
||
const { body } = await requestSession(s);
|
||
const report = (state: string, errorMessage?: string) =>
|
||
app.request(`/session/${body.data.id}/state`, {
|
||
method: 'POST',
|
||
headers: s.host,
|
||
body: JSON.stringify({ state, claimToken: HOLDER, errorMessage })
|
||
});
|
||
|
||
expect((await Box.fromID(s.box.id))?.state).toBe('created');
|
||
|
||
await report('starting');
|
||
await report('live');
|
||
// The screens that tell a person what their hardware is doing read the
|
||
// box, so a live run has to be visible there and not only on the run.
|
||
expect((await Box.fromID(s.box.id))?.state).toBe('running');
|
||
|
||
await report('failed', 'the guest never came up');
|
||
const stopped = await Box.fromID(s.box.id);
|
||
expect(stopped?.state).toBe('stopped');
|
||
expect(stopped?.stopClean).toBe(false);
|
||
expect(stopped?.stopReason).toBe('the guest never came up');
|
||
});
|
||
});
|
||
|
||
describe('Session routes in the spec', () => {
|
||
test('every path a caller needs is documented', async () => {
|
||
const res = await app.request('/doc');
|
||
const paths = Object.keys(((await res.json()) as any).paths);
|
||
expect(paths).toContain('/session');
|
||
expect(paths).toContain('/session/{id}');
|
||
expect(paths).toContain('/session/{id}/state');
|
||
expect(paths).toContain('/session/{id}/ticket');
|
||
expect(paths).toContain('/machine/jobs');
|
||
});
|
||
});
|