mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 09:15:19 +03:00
fix(api): a run drives the box under it, and needs a game and a claim
Four things the session endpoints did not do, or did wrongly. The box had three states and nothing wrote them. A box read `created` while a run on it was `live`, so every screen showing a person what their hardware is doing was reading a column no code had ever moved. A run reaching `live` now makes its box `running`, and a terminal run stops it: `ended` cleanly, `failed` not, carrying the reason the agent gave. Not every run state maps — a box has no `starting` on purpose, because that transition is synchronous from the agent's side and a state nobody sets is a state that lies. Both writes are one transaction, since "this run is live" and "the box under it is running" are one fact in two tables, and a box stuck `running` with nothing on it has nothing to correct it. `POST /session` accepted any game in the catalog. A run launches as a Steam account that has to own the game, so one outside the caller's library is a box that starts, tries to launch and fails minutes later with nothing to point at; it is now refused up front. Told apart from a game that does not exist rather than hidden, because the catalog is public and "you do not own this" is a sentence a person can act on. The library is a synced copy, so this refuses a game bought since the last sync — that is a staleness bug in the sync, not a reason to start runs that cannot work. Publishing a ticket only refused terminal runs, so a host could publish an address for a run it had never claimed. A ticket is the address of something being brought up, so only `starting` and `live` accept one, and the state is in the write rather than only in the check above it. The two refusals stay separate answers because they are different mistakes: one agent skipped a step, the other has nothing left to reach. The migration that adds the one-active-run index stopped older duplicate runs without clearing the ticket they had published, which is the invariant that same migration exists to establish. It clears it now, verified against a box carrying two unstopped runs. Nine tests, each checked against the unfixed code first.
This commit is contained in:
@@ -5,6 +5,7 @@ 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 { Library } from '@nestri/core/user/library';
|
||||
import { LinkedAccount } from '@nestri/core/user/linked-account';
|
||||
import { Hono } from 'hono';
|
||||
import { describeRoute } from 'hono-openapi';
|
||||
@@ -136,6 +137,27 @@ export namespace SessionApi {
|
||||
);
|
||||
}
|
||||
|
||||
// A run launches as a Steam account that has to own the game, so
|
||||
// a game outside the caller's library is a box that starts, tries
|
||||
// to launch, and fails minutes later with nothing to point at.
|
||||
// Refusing here is the same answer sooner.
|
||||
//
|
||||
// Told apart from a game that does not exist rather than hidden:
|
||||
// the catalog is public, so there is nothing to hide, and "you do
|
||||
// not own this" is the sentence a person can act on.
|
||||
//
|
||||
// The library is a synced copy, so this refuses a game bought
|
||||
// since the last sync. That is a staleness bug in the sync and
|
||||
// not a reason to launch runs that cannot work.
|
||||
const owned = await Library.findByUserAndGame({ userId, gameId: game.id });
|
||||
if (!owned) {
|
||||
throw new VisibleError(
|
||||
'forbidden',
|
||||
ErrorCodes.Permission.FORBIDDEN,
|
||||
'That game is not in your library'
|
||||
);
|
||||
}
|
||||
|
||||
const actor = Actor.use();
|
||||
const linkedAccountId =
|
||||
body.linkedAccountId ||
|
||||
@@ -274,7 +296,7 @@ export namespace SessionApi {
|
||||
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.',
|
||||
'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. Only a run being brought up has an address: claim it by reporting `starting` first, and expect 409 both before that and once it has stopped.',
|
||||
responses: {
|
||||
200: {
|
||||
content: { 'application/json': { schema: Result(Session.Info) } },
|
||||
@@ -307,6 +329,8 @@ export namespace SessionApi {
|
||||
switch (result.outcome) {
|
||||
case 'forbidden':
|
||||
notYours();
|
||||
case 'unclaimed':
|
||||
conflict('Claim this run by reporting `starting` before publishing an address');
|
||||
case 'closed':
|
||||
conflict('That run has stopped, so it has no address to publish');
|
||||
default:
|
||||
|
||||
@@ -8,6 +8,7 @@ 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 { app } from '../app/index';
|
||||
import './setup';
|
||||
@@ -64,11 +65,24 @@ async function scene(label: string, steamAppId: number) {
|
||||
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: await newGame(steamAppId),
|
||||
gameId,
|
||||
user: {
|
||||
authorization: `Bearer ${pat.token}`,
|
||||
'content-type': 'application/json'
|
||||
@@ -230,6 +244,27 @@ describe('POST /session', () => {
|
||||
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('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', {
|
||||
@@ -526,6 +561,33 @@ describe('POST /session/:id/ticket', () => {
|
||||
expect(await Session.listByBox(s.box.id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
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' })
|
||||
});
|
||||
// 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' })
|
||||
});
|
||||
const now = await app.request(`/session/${body.data.id}/ticket`, {
|
||||
method: 'POST',
|
||||
headers: s.host,
|
||||
body: JSON.stringify({ ticket: 'nodeaaa-in-time' })
|
||||
});
|
||||
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);
|
||||
@@ -603,6 +665,33 @@ describe('POST /session/:id/ticket', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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, 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');
|
||||
|
||||
Reference in New Issue
Block a user