Files
netris-nestri/apps/api/test/heartbeat.test.ts
Wanjohi 40b4270161 refactor(api)!: remove the shared operator secret, and let hosts sync their own
A single secret that turned any request into an operator was the only
credential several routes accepted, and it had no caller left: the device
pairing it existed for is on hold, and nothing in this tree or any client
sent it. What remained was a key that bypassed authentication entirely,
required to boot, and checked by nobody.

Every route behind it had a better answer available:

- Library and game sync move to host credentials. Both took a `userId` in
  the body, which meant one secret could write into anybody's library. A
  host now says which of its enrolled users a batch is for, and that claim
  is checked against the Steam sign-ins it actually holds — one box carries
  several people's accounts, so the pair is the unit.
- Download-state reporting narrows to hosts alone, and the body that could
  name a different host is gone. Which host is reporting comes from its own
  credentials, and a body that still names one is refused rather than
  ignored.
- Linking a Steam account is always for the caller.
- Creating a game by hand is deleted; syncing already upserts the catalogue.
- Reading the waitlist is deleted. Every address on it belongs to someone
  who has not agreed to anything, and answering it over HTTP made that list
  something a leaked key could drain.
- The pairing-code routes are deleted with the flow they served. The domain
  module and its table stay, so returning to it is a route file rather than
  a migration.

Nothing in the API now accepts a credential that stands for more than one
caller: every request resolves to a specific user or a specific host, which
is what lets a route say "the caller's own library" and mean it.

BREAKING CHANGE: the `x-nestri-admin-token` header is no longer accepted and
`ADMIN_SHARED_SECRET` is no longer read. `POST /games`, `GET /waitlist` and
the `/pairing-code` routes are gone; `POST /games/sync` and `POST /library/sync`
now require host credentials and take `userId` in the body; `POST /steam/link`
no longer accepts `userId`; `POST /games/download-state` no longer accepts
`hostId`.
2026-09-18 22:59:06 +03:00

216 lines
7.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterAll, describe, expect, test } from 'bun:test';
import { AccessToken } from '@nestri/core/access-token/index';
import { Fixtures } from '@nestri/core/db/fixtures';
import { testDb } from '@nestri/core/db/test';
import { Identifier } from '@nestri/core/id';
import { Machine } from '@nestri/core/machine/index';
import { app } from '../app/index';
import './setup';
const sql = testDb();
const createdUserIds: string[] = [];
/**
* A signed-in person, as a personal access token.
*
* The tests below use it to prove `machineOnly` refuses a human: it needs a
* caller who is authenticated and is not a host, and a user session is the
* only kind there is.
*/
async function signedInHeaders(label: string): Promise<Record<string, string>> {
const owner = await Fixtures.owner(label);
createdUserIds.push(owner.userId);
const pat = await AccessToken.create({
id: Identifier.ascending('accessToken'),
ownerUserId: owner.userId,
teamId: null,
name: label
});
return { authorization: `Bearer ${pat.token}` };
}
/**
* A registered host, with the secret kept — which registration returns exactly
* once, so a test that needs to authenticate as a machine has to hold onto it
* here rather than reading it back later.
*/
async function registeredHost(label: string) {
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
});
return {
id: registered.id,
headers: {
'x-nestri-machine-id': registered.id,
'x-nestri-machine-secret': registered.secret
}
};
}
afterAll(async () => {
if (createdUserIds.length > 0) {
await sql`delete from "user" where id in ${sql(createdUserIds)}`;
createdUserIds.length = 0;
}
});
describe('POST /machine/heartbeat', () => {
test('a host beats and is told how often to beat again', async () => {
const host = await registeredHost('beat-ok');
const res = await app.request('/machine/heartbeat', {
method: 'POST',
headers: host.headers
});
expect(res.status).toBe(200);
const body = (await res.json()) as any;
// These two field names are what a host agent reads out of the reply. A
// rename on either side produces a host that beats, parses nothing, and
// reports success — so the names are the contract and this is the test
// that holds them.
expect(typeof body.data.lastSeen).toBe('string');
expect(body.data.intervalSeconds).toBe(Machine.HEARTBEAT_SECONDS);
expect(new Date(body.data.lastSeen).getTime()).not.toBeNaN();
});
test('the beat is what makes the host look online', async () => {
const host = await registeredHost('beat-online');
// Before any beat there is nothing to be online on the strength of.
expect(Machine.isOnline((await Machine.fromID(host.id))?.lastSeen ?? null)).toBe(false);
await app.request('/machine/heartbeat', { method: 'POST', headers: host.headers });
expect(Machine.isOnline((await Machine.fromID(host.id))?.lastSeen ?? null)).toBe(true);
});
test('wrong credentials are indistinguishable from none', async () => {
const host = await registeredHost('beat-wrongsecret');
const none = await app.request('/machine/heartbeat', { method: 'POST' });
const wrong = await app.request('/machine/heartbeat', {
method: 'POST',
headers: { ...host.headers, 'x-nestri-machine-secret': 'msk_wrong' }
});
// The middleware falls through to `public` on bad credentials rather
// than erroring, precisely so probing cannot tell an attacker which
// machine ids exist. Both therefore fail the same way, and asserting
// they are *identical* is the only way that property stays true.
expect(wrong.status).toBe(403);
expect(none.status).toBe(403);
expect(await wrong.json()).toEqual(await none.json());
// And the failed attempt left no trace of having been alive.
expect((await Machine.fromID(host.id))?.lastSeen).toBeNull();
});
test('a host says where it is on the beat it already sends', async () => {
const host = await registeredHost('beat-endpoint');
const endpointId = 'd'.repeat(64);
// A beat carrying no body is what every agent shipped before this field
// sends, and it must still be a beat.
const bare = await app.request('/machine/heartbeat', {
method: 'POST',
headers: host.headers
});
expect(bare.status).toBe(200);
expect((await Machine.fromID(host.id))?.endpointId).toBeNull();
const res = await app.request('/machine/heartbeat', {
method: 'POST',
headers: { ...host.headers, 'content-type': 'application/json' },
body: JSON.stringify({ endpointId })
});
expect(res.status).toBe(200);
expect((await Machine.fromID(host.id))?.endpointId).toBe(endpointId);
// And a later beat that says nothing does not take the host off the map.
await app.request('/machine/heartbeat', { method: 'POST', headers: host.headers });
expect((await Machine.fromID(host.id))?.endpointId).toBe(endpointId);
});
test('a host cannot report where somebody else is', async () => {
// The report is authenticated as the machine it is about, and there is
// no field naming a different one. This is the assertion that keeps it
// that way: a body that tries anyway changes nothing.
const host = await registeredHost('beat-endpoint-other');
const victim = await registeredHost('beat-endpoint-victim');
const endpointId = 'e'.repeat(64);
const res = await app.request('/machine/heartbeat', {
method: 'POST',
headers: { ...host.headers, 'content-type': 'application/json' },
body: JSON.stringify({ endpointId, machineId: victim.id, id: victim.id })
});
expect(res.status).toBe(200);
expect((await Machine.fromID(host.id))?.endpointId).toBe(endpointId);
expect((await Machine.fromID(victim.id))?.endpointId).toBeNull();
});
test('an endpoint id that cannot be one is refused', async () => {
const host = await registeredHost('beat-endpoint-shape');
const res = await app.request('/machine/heartbeat', {
method: 'POST',
headers: { ...host.headers, 'content-type': 'application/json' },
body: JSON.stringify({ endpointId: 'not-an-endpoint-id' })
});
expect(res.status).toBe(400);
expect((await Machine.fromID(host.id))?.endpointId).toBeNull();
// Liveness is still recorded, and that is not a half-applied write:
// authenticating as this machine is itself proof it is alive, and the
// middleware records it before any route runs. What the refusal keeps
// out is the value that failed the check.
expect((await Machine.fromID(host.id))?.lastSeen).not.toBeNull();
});
test('claiming another hosts endpoint id is a conflict, not a fault', async () => {
const first = await registeredHost('beat-endpoint-taken-a');
const second = await registeredHost('beat-endpoint-taken-b');
const endpointId = 'f'.repeat(64);
await app.request('/machine/heartbeat', {
method: 'POST',
headers: { ...first.headers, 'content-type': 'application/json' },
body: JSON.stringify({ endpointId })
});
const res = await app.request('/machine/heartbeat', {
method: 'POST',
headers: { ...second.headers, 'content-type': 'application/json' },
body: JSON.stringify({ endpointId })
});
// The unique index is the invariant, so the database refusing is the
// expected way to find out — and an expected refusal reaching a host as
// a 500 tells it the server broke rather than that the id is taken.
expect(res.status).toBe(409);
expect((await res.json()) as any).toMatchObject({ type: 'already_exists' });
expect((await Machine.fromID(second.id))?.endpointId).toBeNull();
});
test('a user session cannot beat on a hosts behalf', async () => {
// A box holds credentials but is not its owner, and the reverse holds
// too: `machineOnly` exists so a route written for a host cannot be
// driven by whoever owns it.
const res = await app.request('/machine/heartbeat', {
method: 'POST',
headers: await signedInHeaders('beat-nomachine')
});
expect(res.status).toBe(403);
});
});