diff --git a/apps/api/test/heartbeat.test.ts b/apps/api/test/heartbeat.test.ts new file mode 100644 index 00000000..fe7eb3e6 --- /dev/null +++ b/apps/api/test/heartbeat.test.ts @@ -0,0 +1,107 @@ +import { afterAll, describe, expect, test } from 'bun:test'; + +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 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 `neslet`'s plane.rs 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 user session cannot beat on a host’s 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: { 'x-nestri-admin-token': 'test-admin-secret-42' } + }); + expect(res.status).toBe(403); + }); +});