Files
netris-nestri/apps/api/test/enrolment.test.ts
T
Wanjohi 24ad9f6b78 fix(api): a Steam sign-in on any host links the account
A sign-in on a person's own host recorded the enrolment but never
linked the Steam account, so their first launch asked them to link it
again with nothing in the app to do it from. The QR sign-in is the same
proof on their host as on ours.
2026-10-11 17:33:18 +03:00

458 lines
16 KiB
TypeScript
Raw 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 { Env } from '@nestri/core/env';
import { Machine } from '@nestri/core/machine/index';
import { app } from '../app/index';
import { TEST_FRONTEND_URL } from './setup';
const sql = testDb();
const createdUserIds: string[] = [];
const createdOrgIds: 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 Steam ID is 17 digits; these are distinct and obviously not real. */
function steamId(n: number) {
return `765611980000${String(n).padStart(5, '0')}`;
}
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,
userId: owner.userId,
headers: {
'x-nestri-machine-id': registered.id,
'x-nestri-machine-secret': registered.secret,
'content-type': 'application/json'
}
};
}
function enrol(host: { headers: Record<string, string> }, body: unknown) {
return app.request('/machine/enrolment', {
method: 'POST',
headers: host.headers,
body: JSON.stringify(body)
});
}
function markStale(host: { headers: Record<string, string> }, body: unknown) {
return app.request('/machine/enrolment/stale', {
method: 'POST',
headers: host.headers,
body: JSON.stringify(body)
});
}
function list(host: { headers: Record<string, string> }) {
return app.request('/machine/enrolment', { headers: host.headers });
}
afterAll(async () => {
if (createdOrgIds.length > 0) {
await sql`delete from machine where organisation_id = any(${createdOrgIds})`;
await sql`delete from organisation where id = any(${createdOrgIds})`;
}
if (createdUserIds.length > 0) {
await sql`delete from "user" where id in ${sql(createdUserIds)}`;
createdUserIds.length = 0;
}
});
describe('POST /machine/enrolment', () => {
test('the outcome is recorded and `data` is the enrolment itself', async () => {
const host = await registeredHost('enrol-shape');
const res = await enrol(host, { userId: host.userId, steamId: steamId(1) });
expect(res.status).toBe(200);
const body = (await res.json()) as any;
// `data` is the object, not `{"data": {"enrolment": …}}`. A host written
// against the wrapped form parses nothing, and finds out on first
// contact rather than in review.
expect(body.data).toMatchObject({
machineId: host.id,
userId: host.userId,
steamId: steamId(1),
state: 'enrolled'
});
expect(Object.keys(body.data).sort()).toEqual(
['enrolledAt', 'lastOkAt', 'machineId', 'revokedAt', 'state', 'steamId', 'userId'].sort()
);
expect(body.data.enrolment).toBeUndefined();
// camelCase on the wire, always. The snake-to-camel seam is where a
// host and a control plane silently stop understanding each other.
for (const key of Object.keys(body.data)) {
expect(key).not.toContain('_');
}
expect(typeof body.data.enrolledAt).toBe('string');
expect(body.data.lastOkAt).toBeNull();
expect(body.data.revokedAt).toBeNull();
});
test('the machine is taken from the credentials, never the body', async () => {
const host = await registeredHost('enrol-self');
const other = await registeredHost('enrol-other');
// A host naming another host would be a host enrolling somebody else's
// hardware. There is no field for it, so this is a validation error.
const res = await enrol(host, {
userId: host.userId,
steamId: steamId(2),
machineId: other.id
});
expect(res.status).toBe(400);
const still = await list(other);
expect(((await still.json()) as any).data).toEqual([]);
});
test('re-enrolling keeps the first `enrolledAt` and adopts the new Steam account', async () => {
const host = await registeredHost('enrol-again');
const first = (await (
await enrol(host, { userId: host.userId, steamId: steamId(3) })
).json()) as any;
const second = (await (
await enrol(host, { userId: host.userId, steamId: steamId(4) })
).json()) as any;
expect(second.data.enrolledAt).toBe(first.data.enrolledAt);
expect(second.data.steamId).toBe(steamId(4));
expect(second.data.state).toBe('enrolled');
});
test('one Steam account on two hosts is two enrolments', async () => {
// Two hosts, two tokens, two rows — the whole reason the Steam id is
// not unique across machines. A unique index there would read as
// hygiene and would refuse the second host.
const first = await registeredHost('enrol-two-a');
const second = await registeredHost('enrol-two-b');
const shared = steamId(5);
expect((await enrol(first, { userId: first.userId, steamId: shared })).status).toBe(200);
expect((await enrol(second, { userId: second.userId, steamId: shared })).status).toBe(200);
const a = ((await (await list(first)).json()) as any).data;
const b = ((await (await list(second)).json()) as any).data;
expect(a).toHaveLength(1);
expect(b).toHaveLength(1);
expect(a[0].machineId).toBe(first.id);
expect(b[0].machineId).toBe(second.id);
});
test('a user nobody has heard of is refused rather than crashing', async () => {
const host = await registeredHost('enrol-ghost');
// Well-formed and simply absent, which is the case the foreign key
// catches. A malformed one never reaches the database at all.
const res = await enrol(host, {
userId: Identifier.ascending('user'),
steamId: steamId(6)
});
expect(res.status).toBe(404);
const body = (await res.json()) as any;
expect(body.type).toBe('not_found');
});
test('a userId of the wrong shape is bad input, not a server fault', async () => {
// Ids live in a fixed-width column, so an overlong one is refused by
// the database rather than merely not found — and that refusal used to
// reach the host as a 500, which tells it to retry something that can
// never succeed. The width is checked where the input arrives.
const host = await registeredHost('enrol-misshapen');
const malformed = [`usr_${'a'.repeat(40)}`, 'usr_short', `mch_${'a'.repeat(26)}`, 'nonsense'];
for (const userId of malformed) {
// eslint-disable-next-line no-await-in-loop
const res = await enrol(host, { userId, steamId: steamId(15) });
expect(res.status).toBe(400);
// eslint-disable-next-line no-await-in-loop
expect(((await res.json()) as any).type).toBe('validation');
}
});
test('a Steam id has to look like one', async () => {
const host = await registeredHost('enrol-badsteam');
const res = await enrol(host, { userId: host.userId, steamId: 'not-a-steam-id' });
expect(res.status).toBe(400);
});
test('machine credentials are required', async () => {
const res = await app.request('/machine/enrolment', {
method: 'POST',
headers: {
...(await signedInHeaders('enrol-nomachine')),
'content-type': 'application/json'
},
body: JSON.stringify({ userId: Identifier.ascending('user'), steamId: steamId(7) })
});
expect(res.status).toBe(403);
expect(((await res.json()) as any).message).toContain('Machine credentials');
});
});
describe('POST /machine/enrolment/stale', () => {
test('a refused token moves the enrolment to stale', async () => {
const host = await registeredHost('stale-happy');
await enrol(host, { userId: host.userId, steamId: steamId(8) });
const res = await markStale(host, { userId: host.userId });
expect(res.status).toBe(200);
const body = (await res.json()) as any;
expect(body.data.state).toBe('stale');
expect(body.data.userId).toBe(host.userId);
});
test('re-enrolling after a refusal returns the row to enrolled', async () => {
const host = await registeredHost('stale-recover');
await enrol(host, { userId: host.userId, steamId: steamId(9) });
await markStale(host, { userId: host.userId });
const again = (await (
await enrol(host, { userId: host.userId, steamId: steamId(9) })
).json()) as any;
expect(again.data.state).toBe('enrolled');
});
test('an enrolment this host does not have is a 404', async () => {
const host = await registeredHost('stale-missing');
const res = await markStale(host, { userId: host.userId });
expect(res.status).toBe(404);
expect(((await res.json()) as any).type).toBe('not_found');
});
test('a host cannot mark another host’s enrolment stale', async () => {
const owner = await registeredHost('stale-owner');
const stranger = await registeredHost('stale-stranger');
await enrol(owner, { userId: owner.userId, steamId: steamId(10) });
// Scoped to the calling machine, so somebody else's row is simply not
// there — a miss, not a permission check that could be forgotten.
const res = await markStale(stranger, { userId: owner.userId });
expect(res.status).toBe(404);
const untouched = ((await (await list(owner)).json()) as any).data;
expect(untouched[0].state).toBe('enrolled');
});
test('machine credentials are required', async () => {
const res = await app.request('/machine/enrolment/stale', {
method: 'POST',
headers: {
...(await signedInHeaders('stale-nomachine')),
'content-type': 'application/json'
},
body: JSON.stringify({ userId: Identifier.ascending('user') })
});
expect(res.status).toBe(403);
});
});
describe('GET /machine/enrolment', () => {
test('a host with no enrolments gets an empty list, not a 404', async () => {
const host = await registeredHost('list-empty');
const res = await list(host);
expect(res.status).toBe(200);
expect(((await res.json()) as any).data).toEqual([]);
});
test('every enrolment this host is expected to hold, and no other host’s', async () => {
const host = await registeredHost('list-mine');
const other = await registeredHost('list-theirs');
await enrol(host, { userId: host.userId, steamId: steamId(11) });
await enrol(other, { userId: other.userId, steamId: steamId(12) });
const res = await list(host);
expect(res.status).toBe(200);
const body = (await res.json()) as any;
// `data` is the list itself.
expect(Array.isArray(body.data)).toBe(true);
expect(body.data).toHaveLength(1);
expect(body.data[0].machineId).toBe(host.id);
});
test('machine credentials are required', async () => {
const res = await app.request('/machine/enrolment', {
headers: await signedInHeaders('list-nomachine')
});
expect(res.status).toBe(403);
});
});
describe('The enrolment surface refuses a token', () => {
// The token lives on the host and nowhere else. There is no endpoint that
// accepts a refresh token, a challenge URL or a client id, and the way that
// stays true is a test that fails the moment somebody adds one.
const forbidden = [
{ refreshToken: 'eyJ.not.a.real.one' },
{ token: 'anything' },
{ accessToken: 'anything' },
{ challengeUrl: 'https://s.team/q/1/2' },
{ clientId: '1234567890' }
];
test('POST /machine/enrolment rejects every credential-shaped field', async () => {
const host = await registeredHost('refuse-token-enrol');
for (const extra of forbidden) {
// eslint-disable-next-line no-await-in-loop
const res = await enrol(host, {
userId: host.userId,
steamId: steamId(13),
...extra
});
expect(res.status).toBe(400);
// eslint-disable-next-line no-await-in-loop
expect(((await res.json()) as any).type).toBe('validation');
}
});
test('POST /machine/enrolment/stale rejects every credential-shaped field', async () => {
const host = await registeredHost('refuse-token-stale');
await enrol(host, { userId: host.userId, steamId: steamId(14) });
for (const extra of forbidden) {
// eslint-disable-next-line no-await-in-loop
const res = await markStale(host, { userId: host.userId, ...extra });
expect(res.status).toBe(400);
}
});
test('the published surface has exactly three enrolment routes and no field for a credential', async () => {
const res = await app.request('/doc');
const doc = (await res.json()) as any;
function resolve(schema: any): any {
if (schema?.$ref) {
const name = String(schema.$ref).split('/').pop()!;
return resolve(doc.components?.schemas?.[name]);
}
return schema;
}
function propertyNames(schema: any): string[] {
const s = resolve(schema);
if (!s) return [];
const own = Object.keys(s.properties ?? {});
const composed = [...(s.allOf ?? []), ...(s.anyOf ?? []), ...(s.oneOf ?? [])].flatMap(
propertyNames
);
return [...own, ...composed];
}
const paths = Object.keys(doc.paths).filter((p) => p.startsWith('/machine/enrolment'));
expect(paths.sort()).toEqual(['/machine/enrolment', '/machine/enrolment/stale']);
const accepted = new Set<string>();
for (const path of paths) {
for (const operation of Object.values<any>(doc.paths[path])) {
for (const parameter of operation.parameters ?? []) {
accepted.add(parameter.name);
}
const schema = operation.requestBody?.content?.['application/json']?.schema;
if (schema) {
for (const name of propertyNames(schema)) {
accepted.add(name);
}
}
}
}
// Not "contains no token" — an exact set. Anything new on this surface
// has to be argued for here, which is the point.
expect([...accepted].sort()).toEqual(['steamId', 'userId']);
});
});
describe('A sign-in on a host links the Steam account', () => {
/** A fleet host the API knows is the fleet, and a person to enrol on it. */
async function fleetHost(label: string) {
const operator = await Fixtures.owner(`${label}-operator`);
createdUserIds.push(operator.userId);
const f = await Fixtures.fleet(operator);
createdOrgIds.push(f.organisationId);
Env.init({
NODE_ENV: 'test',
FRONTEND_URL: TEST_FRONTEND_URL,
FLEET_ORGANISATION_ID: f.organisationId
});
const person = await Fixtures.owner(label);
createdUserIds.push(person.userId);
const [machine] = f.machines;
return {
person,
headers: {
'x-nestri-machine-id': machine!.id,
'x-nestri-machine-secret': machine!.secret,
'content-type': 'application/json'
}
};
}
const linked = (steam: string) =>
sql<{ user_id: string }[]>`
select user_id from linked_account
where provider = 'steam' and provider_account_id = ${steam} and time_deleted is null`;
test('a fleet host reporting a sign-in links that account to that person', async () => {
const host = await fleetHost('fleet-link');
const res = await enrol(host, { userId: host.person.userId, steamId: steamId(9001) });
expect(res.status).toBe(200);
expect((await linked(steamId(9001))).map((r) => r.user_id)).toEqual([host.person.userId]);
// Restated, as a host does after a restart: still one link.
expect((await enrol(host, { userId: host.person.userId, steamId: steamId(9001) })).status).toBe(
200
);
expect(await linked(steamId(9001))).toHaveLength(1);
});
test('a host somebody brought links it too', async () => {
const host = await registeredHost('own-host-link');
const res = await enrol(host, { userId: host.userId, steamId: steamId(9002) });
expect(res.status).toBe(200);
expect((await linked(steamId(9002))).map((r) => r.user_id)).toEqual([host.userId]);
});
test('an account already linked elsewhere stays there, and the report still lands', async () => {
const host = await fleetHost('fleet-link-taken');
const first = await Fixtures.owner('first-holder');
createdUserIds.push(first.userId);
await sql`insert into linked_account (id, user_id, provider, provider_account_id)
values (${Identifier.ascending('linkedAccount')}, ${first.userId}, 'steam', ${steamId(9003)})`;
const res = await enrol(host, { userId: host.person.userId, steamId: steamId(9003) });
expect(res.status).toBe(200);
expect((await linked(steamId(9003))).map((r) => r.user_id)).toEqual([first.userId]);
});
});