mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
feat(api): the session endpoint, and a claim that only one caller can win
A run of a box had core support and no HTTP surface. This adds both halves of it: a person asks for a run and reads it back, and the host agent the box is placed on is handed the work and reports what happened. The access rule is the point. An agent may only see or touch a run whose box is placed on its own hardware, and that is a `where` clause on every one of the three agent endpoints rather than a check next to them — host credentials are long-lived secrets sitting on hardware in somebody's home, so what one leaking can reach has to be decided by the query. "No such run" and "not your run" are the same refusal, so ids cannot be discovered by reporting states at them. `Session.setState` updated on the id alone, which means two agents polling the same work both succeed and both start the same box. There is one host today, which is exactly why that would have been built wrong and stayed wrong. The state a run is moving out of is now part of the `where` clause, so the database picks the winner; the loser gets a conflict rather than a silent no-op. Three cases that look alike are kept apart: re-reporting a state you already reported changes nothing and is not an error, a transition that does not exist is refused with the run left where it was, and another host reporting anything is forbidden. Asking for a run makes no decision about where it happens — a box already names its hardware, so the run inherits it by join. Placement therefore gets an interface at box creation, where the decision actually is, with the single-host case as its implementation and a deliberate refusal when there is more than one candidate and no policy to choose with. Tests cover the wire shape from both sides, the query scoping, the claim, and the timestamp idempotence a run's billing rests on.
This commit is contained in:
@@ -16,6 +16,7 @@ import { IndexApi } from './routes/index.js';
|
||||
import { LibraryApi } from './routes/library.js';
|
||||
import { MachineApi } from './routes/machine.js';
|
||||
import { PairingCodeApi } from './routes/pairing-code.js';
|
||||
import { SessionApi } from './routes/session.js';
|
||||
import { SteamApi } from './routes/steam.js';
|
||||
import { UserApi } from './routes/user.js';
|
||||
import { WaitlistApi } from './routes/waitlist.js';
|
||||
@@ -44,6 +45,8 @@ const routes = app
|
||||
.route('/games', GameApi.route)
|
||||
.route('/pairing-code', PairingCodeApi.route)
|
||||
.route('/machine', MachineApi.route)
|
||||
.route('/machine', SessionApi.machineRoute)
|
||||
.route('/session', SessionApi.route)
|
||||
.route('/access-token', AccessTokenApi.route)
|
||||
.route('/waitlist', WaitlistApi.route)
|
||||
.onError((error, c) => {
|
||||
|
||||
337
apps/api/app/routes/session.ts
Normal file
337
apps/api/app/routes/session.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
import { Actor } from '@nestri/core/actor';
|
||||
import { Box } from '@nestri/core/box/index';
|
||||
import { ErrorCodes, VisibleError } from '@nestri/core/error';
|
||||
import { Examples } from '@nestri/core/examples';
|
||||
import { Game } from '@nestri/core/game/index';
|
||||
import { Identifier } from '@nestri/core/id';
|
||||
import { Session } from '@nestri/core/session/index';
|
||||
import { LinkedAccount } from '@nestri/core/user/linked-account';
|
||||
import { Hono } from 'hono';
|
||||
import { describeRoute } from 'hono-openapi';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ErrorResponses, machineOnly, notPublic, Result, validator } from '../utils';
|
||||
|
||||
/**
|
||||
* Requesting a run, and carrying one out.
|
||||
*
|
||||
* Two very different callers meet on one resource here. A person asks for a
|
||||
* run and then watches it; the host agent is handed the work and reports what
|
||||
* happened. The rule that keeps them apart is that an agent may only see or
|
||||
* touch a run whose box is placed on its own hardware, and it is enforced in
|
||||
* the query rather than by the agent asking for its own work — a host
|
||||
* credential is a long-lived secret on hardware in somebody's home, and what
|
||||
* one leaking can reach is decided here.
|
||||
*/
|
||||
export namespace SessionApi {
|
||||
/**
|
||||
* One answer for "no such run" and "not your run".
|
||||
*
|
||||
* Both are the same refusal on purpose: an agent that could tell the
|
||||
* difference could discover which ids exist by reporting states at them.
|
||||
*/
|
||||
function notYours(): never {
|
||||
throw new VisibleError(
|
||||
'forbidden',
|
||||
ErrorCodes.Permission.FORBIDDEN,
|
||||
'No such session, or it is not on this machine'
|
||||
);
|
||||
}
|
||||
|
||||
function conflict(message: string): never {
|
||||
throw new VisibleError('already_exists', ErrorCodes.Validation.INVALID_STATE, message);
|
||||
}
|
||||
|
||||
/** The person a run belongs to, refusing a host acting as its owner. */
|
||||
function actingPerson(): string {
|
||||
const actor = Actor.use();
|
||||
if (actor.type !== 'user' && actor.type !== 'member') {
|
||||
throw new VisibleError(
|
||||
'forbidden',
|
||||
ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS,
|
||||
'Requesting or reading a session requires a user session'
|
||||
);
|
||||
}
|
||||
return actor.properties.userID;
|
||||
}
|
||||
|
||||
const StateReport = z
|
||||
.object({
|
||||
state: Session.ReportableState.meta({
|
||||
description: 'Where the run has got to',
|
||||
example: 'starting'
|
||||
}),
|
||||
errorMessage: z.string().max(1024).nullable().optional().meta({
|
||||
description: 'Why it failed. Kept only for a run that did',
|
||||
example: Examples.Session.errorMessage
|
||||
})
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const route = new Hono()
|
||||
.post(
|
||||
'/',
|
||||
notPublic,
|
||||
describeRoute({
|
||||
tags: ['Session'],
|
||||
summary: 'Ask for a run of a box',
|
||||
description:
|
||||
'Creates the run in state `requested`, which is the work order the box’s host picks up. This makes no decision about where the run happens: a box already names the hardware it is placed on, so the run inherits it. Poll the run to watch it start, and re-read its ticket rather than keeping the first one.',
|
||||
responses: {
|
||||
201: {
|
||||
content: { 'application/json': { schema: Result(Session.Info) } },
|
||||
description: 'The run has been requested'
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
401: ErrorResponses[401],
|
||||
403: ErrorResponses[403],
|
||||
404: ErrorResponses[404],
|
||||
409: ErrorResponses[409]
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
'json',
|
||||
z
|
||||
.object({
|
||||
boxId: z.string().min(1).meta({
|
||||
description: 'The box to run',
|
||||
example: Examples.Session.boxId
|
||||
}),
|
||||
gameId: z.string().min(1).meta({
|
||||
description: 'The game to launch',
|
||||
example: Examples.Session.gameId
|
||||
}),
|
||||
linkedAccountId: z.string().min(1).optional().meta({
|
||||
description:
|
||||
'Which linked account is playing. Defaults to the one the caller signed in with',
|
||||
example: Examples.Session.linkedAccountId
|
||||
})
|
||||
})
|
||||
// Strict, so that naming hardware is a validation error rather
|
||||
// than a field quietly ignored. There is nothing to choose:
|
||||
// asking for a run is not where a box is placed.
|
||||
.strict()
|
||||
),
|
||||
async (c) => {
|
||||
const body = c.req.valid('json');
|
||||
const userId = actingPerson();
|
||||
|
||||
const box = await Box.fromID(body.boxId);
|
||||
if (!box || box.userId !== userId) {
|
||||
// Somebody else's box and a box that was never created are the
|
||||
// same answer, so ids cannot be probed for.
|
||||
throw new VisibleError(
|
||||
'not_found',
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
'No such box, or it is not yours'
|
||||
);
|
||||
}
|
||||
|
||||
const game = await Game.fromID(body.gameId);
|
||||
if (!game) {
|
||||
throw new VisibleError(
|
||||
'not_found',
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
'No such game'
|
||||
);
|
||||
}
|
||||
|
||||
const actor = Actor.use();
|
||||
const linkedAccountId =
|
||||
body.linkedAccountId ||
|
||||
(actor.type === 'user' ? actor.properties.linkedAccountID : '') ||
|
||||
'';
|
||||
if (!linkedAccountId) {
|
||||
// Which account is playing is the question the "who's playing?"
|
||||
// screen asks, and some credentials carry no answer to it. Then
|
||||
// the caller has to say.
|
||||
throw new VisibleError(
|
||||
'validation',
|
||||
ErrorCodes.Validation.MISSING_REQUIRED_FIELD,
|
||||
'Say which linked account is playing',
|
||||
'linkedAccountId'
|
||||
);
|
||||
}
|
||||
const linked = await LinkedAccount.fromID(linkedAccountId);
|
||||
if (!linked || linked.userId !== userId) {
|
||||
throw new VisibleError(
|
||||
'forbidden',
|
||||
ErrorCodes.Permission.FORBIDDEN,
|
||||
'That account is not linked to you'
|
||||
);
|
||||
}
|
||||
|
||||
// A box runs one thing at a time. Refusing is the honest answer;
|
||||
// starting a second run would leave two rows that both think they
|
||||
// own the same hardware.
|
||||
const active = await Session.activeForBox(box.id);
|
||||
if (active) {
|
||||
conflict('That box already has a run that has not stopped');
|
||||
}
|
||||
|
||||
const session = await Session.create({
|
||||
id: Identifier.ascending('session'),
|
||||
boxId: box.id,
|
||||
gameId: game.id,
|
||||
linkedAccountId
|
||||
});
|
||||
return c.json({ data: session }, 201);
|
||||
}
|
||||
)
|
||||
.get(
|
||||
'/:id',
|
||||
notPublic,
|
||||
describeRoute({
|
||||
tags: ['Session'],
|
||||
summary: 'Read a run you asked for',
|
||||
description:
|
||||
'Poll this while a run starts. The ticket appears part-way through and is republished as addresses are discovered, so re-read it rather than keeping the first one — a client that treats the first ticket as final works on a local network and fails from anywhere else.',
|
||||
responses: {
|
||||
200: {
|
||||
content: { 'application/json': { schema: Result(Session.Info) } },
|
||||
description: 'The run as it stands'
|
||||
},
|
||||
401: ErrorResponses[401],
|
||||
403: ErrorResponses[403],
|
||||
404: ErrorResponses[404]
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
'param',
|
||||
z.object({
|
||||
id: z.string().meta({ description: 'The run to read', example: Examples.Session.id })
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const session = await Session.forOwner({
|
||||
id: c.req.valid('param').id,
|
||||
userId: actingPerson()
|
||||
});
|
||||
if (!session) {
|
||||
// Owner-scoped in the query, so somebody else's run and one that
|
||||
// never existed answer the same way.
|
||||
throw new VisibleError(
|
||||
'not_found',
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
'No such session, or it is not yours'
|
||||
);
|
||||
}
|
||||
return c.json({ data: session });
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/:id/state',
|
||||
machineOnly,
|
||||
describeRoute({
|
||||
tags: ['Session'],
|
||||
summary: 'Report where a run has got to',
|
||||
description:
|
||||
'For the host the run’s box is placed on, and no other. Moving a run out of `requested` is the claim, and it is a compare-and-set: exactly one caller can take a given run, and one that loses gets 409. Re-reporting a state already reported is fine and changes nothing, including the timestamps a run is billed on. A transition that does not exist is 409 and the run does not move.',
|
||||
responses: {
|
||||
200: {
|
||||
content: { 'application/json': { schema: Result(Session.Info) } },
|
||||
description: 'The run as it stands after the report'
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
403: ErrorResponses[403],
|
||||
409: ErrorResponses[409]
|
||||
}
|
||||
}),
|
||||
validator('param', z.object({ id: z.string() })),
|
||||
validator('json', StateReport),
|
||||
async (c) => {
|
||||
const body = c.req.valid('json');
|
||||
const result = await Session.transition({
|
||||
id: c.req.valid('param').id,
|
||||
machineId: Actor.machineID,
|
||||
state: body.state,
|
||||
errorMessage: body.errorMessage ?? null
|
||||
});
|
||||
|
||||
switch (result.outcome) {
|
||||
case 'forbidden':
|
||||
notYours();
|
||||
case 'illegal':
|
||||
conflict(`A run in state ${result.session?.state} cannot become ${body.state}`);
|
||||
case 'lost':
|
||||
conflict('Another caller moved this run first');
|
||||
default:
|
||||
// `moved` and `unchanged` are both success. An agent retrying
|
||||
// after a lost response must not be told it broke something.
|
||||
return c.json({ data: result.session });
|
||||
}
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/:id/ticket',
|
||||
machineOnly,
|
||||
describeRoute({
|
||||
tags: ['Session'],
|
||||
summary: 'Publish the address a client should connect to',
|
||||
description:
|
||||
'For the host the run’s box is placed on, and no other. Republish freely: a later ticket is a better address for the same run, not a second run, and the address changes as more of them are discovered. A run that has stopped has no address, so that is 409.',
|
||||
responses: {
|
||||
200: {
|
||||
content: { 'application/json': { schema: Result(Session.Info) } },
|
||||
description: 'The ticket is published'
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
403: ErrorResponses[403],
|
||||
409: ErrorResponses[409]
|
||||
}
|
||||
}),
|
||||
validator('param', z.object({ id: z.string() })),
|
||||
validator(
|
||||
'json',
|
||||
z
|
||||
.object({
|
||||
ticket: z.string().min(1).meta({
|
||||
description: 'The current connect ticket',
|
||||
example: Examples.Session.ticket
|
||||
})
|
||||
})
|
||||
.strict()
|
||||
),
|
||||
async (c) => {
|
||||
const result = await Session.publishTicket({
|
||||
id: c.req.valid('param').id,
|
||||
machineId: Actor.machineID,
|
||||
ticket: c.req.valid('json').ticket
|
||||
});
|
||||
|
||||
switch (result.outcome) {
|
||||
case 'forbidden':
|
||||
notYours();
|
||||
case 'closed':
|
||||
conflict('That run has stopped, so it has no address to publish');
|
||||
default:
|
||||
return c.json({ data: result.session });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* The host agent's side of the same resource, mounted where a host looks
|
||||
* for it: everything a box asks about itself lives under one prefix.
|
||||
*/
|
||||
export const machineRoute = new Hono().get(
|
||||
'/jobs',
|
||||
machineOnly,
|
||||
describeRoute({
|
||||
tags: ['Session'],
|
||||
summary: 'Ask for work',
|
||||
description:
|
||||
'Returns the runs waiting to be started on the calling host, and only those — the host comes from its own credentials and the scope is the query, so a box cannot see work for another. Poll at the cadence the heartbeat hands down. Each job carries its kind, so a second kind of work is an addition rather than a change of shape.',
|
||||
responses: {
|
||||
200: {
|
||||
content: { 'application/json': { schema: Result(z.array(Session.Job)) } },
|
||||
description: 'Work waiting for this host, oldest first'
|
||||
},
|
||||
403: ErrorResponses[403]
|
||||
}
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json({ data: await Session.listJobsForMachine(Actor.machineID) });
|
||||
}
|
||||
);
|
||||
}
|
||||
564
apps/api/test/session.test.ts
Normal file
564
apps/api/test/session.test.ts
Normal file
@@ -0,0 +1,564 @@
|
||||
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 { 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
|
||||
});
|
||||
|
||||
return {
|
||||
owner,
|
||||
box,
|
||||
machineId: registered.id,
|
||||
gameId: await newGame(steamAppId),
|
||||
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('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('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);
|
||||
});
|
||||
});
|
||||
|
||||
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' })
|
||||
});
|
||||
await app.request(`/session/${body.data.id}/ticket`, {
|
||||
method: 'POST',
|
||||
headers: s.host,
|
||||
body: JSON.stringify({ ticket: 'nodeaaa-one' })
|
||||
});
|
||||
|
||||
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' })
|
||||
});
|
||||
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('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' })
|
||||
});
|
||||
|
||||
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' })
|
||||
});
|
||||
const unknown = await app.request(`/session/${Identifier.ascending('session')}/state`, {
|
||||
method: 'POST',
|
||||
headers: mine.host,
|
||||
body: JSON.stringify({ state: 'starting' })
|
||||
});
|
||||
|
||||
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' })
|
||||
});
|
||||
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, 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 })
|
||||
});
|
||||
|
||||
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' })
|
||||
});
|
||||
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' })
|
||||
});
|
||||
// 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' })
|
||||
});
|
||||
|
||||
const publish = (ticket: string) =>
|
||||
app.request(`/session/${body.data.id}/ticket`, {
|
||||
method: 'POST',
|
||||
headers: s.host,
|
||||
body: JSON.stringify({ ticket })
|
||||
});
|
||||
|
||||
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 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' })
|
||||
});
|
||||
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 })
|
||||
});
|
||||
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' })
|
||||
});
|
||||
expect(res.status).toBe(409);
|
||||
expect((await Session.fromID(body.data.id))?.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: '' })
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user