mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-20 01:35:19 +03:00
The machine table said who owns a host, which team it belongs to and when it was last seen, and nothing about how to reach it. Anything standing in front of a host and authenticating browsers on its behalf could therefore authorise a request perfectly and then have nowhere to send it. Reported, never assigned. A host holds the secret half of this identity and is the only thing that can know the public half first, so it rides on the beat it already sends as itself. Omitting the field leaves the stored value alone -- an agent that does not mention where it is has not moved, and an absent field must never read as "nowhere", which would take every host shipped before this field off the map on its next beat. Nullable, because "has never reported one" is a real state that every host registered before today is in. Unique, because an endpoint id belongs to one host: two rows claiming the same one would send a request addressed to one machine to another machine's agent, which is the one mistake here that the authorisation in front of it cannot catch.
171 lines
6.3 KiB
TypeScript
171 lines
6.3 KiB
TypeScript
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 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('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);
|
||
});
|
||
});
|