mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-20 01:35:19 +03:00
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`.
This commit is contained in:
@@ -1,18 +1,37 @@
|
||||
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 { TEST_ADMIN_SECRET } from './setup';
|
||||
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 Steam ID is 17 digits; these are distinct and obviously not real. */
|
||||
function steamId(n: number) {
|
||||
return `765611980000${String(n).padStart(5, '0')}`;
|
||||
@@ -185,7 +204,10 @@ describe('POST /machine/enrolment', () => {
|
||||
test('machine credentials are required', async () => {
|
||||
const res = await app.request('/machine/enrolment', {
|
||||
method: 'POST',
|
||||
headers: { 'x-nestri-admin-token': TEST_ADMIN_SECRET, 'content-type': 'application/json' },
|
||||
headers: {
|
||||
...(await signedInHeaders('enrol-nomachine')),
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ userId: Identifier.ascending('user'), steamId: steamId(7) })
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
@@ -240,7 +262,10 @@ describe('POST /machine/enrolment/stale', () => {
|
||||
test('machine credentials are required', async () => {
|
||||
const res = await app.request('/machine/enrolment/stale', {
|
||||
method: 'POST',
|
||||
headers: { 'x-nestri-admin-token': TEST_ADMIN_SECRET, 'content-type': 'application/json' },
|
||||
headers: {
|
||||
...(await signedInHeaders('stale-nomachine')),
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ userId: Identifier.ascending('user') })
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
@@ -272,7 +297,7 @@ describe('GET /machine/enrolment', () => {
|
||||
|
||||
test('machine credentials are required', async () => {
|
||||
const res = await app.request('/machine/enrolment', {
|
||||
headers: { 'x-nestri-admin-token': TEST_ADMIN_SECRET }
|
||||
headers: await signedInHeaders('list-nomachine')
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
@@ -12,6 +13,25 @@ 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
|
||||
@@ -188,7 +208,7 @@ describe('POST /machine/heartbeat', () => {
|
||||
// driven by whoever owns it.
|
||||
const res = await app.request('/machine/heartbeat', {
|
||||
method: 'POST',
|
||||
headers: { 'x-nestri-admin-token': 'test-admin-secret-42' }
|
||||
headers: await signedInHeaders('beat-nomachine')
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,58 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { AccessToken } from '@nestri/core/access-token/index';
|
||||
import { Fixtures } from '@nestri/core/db/fixtures';
|
||||
import { Identifier } from '@nestri/core/id';
|
||||
import { Machine } from '@nestri/core/machine/index';
|
||||
|
||||
import { app } from '../app/index';
|
||||
import { TEST_ADMIN_SECRET } from './setup';
|
||||
import './setup';
|
||||
|
||||
function adminHeaders(): Record<string, string> {
|
||||
return { 'x-nestri-admin-token': TEST_ADMIN_SECRET };
|
||||
/**
|
||||
* A signed-in person and a registered host.
|
||||
*
|
||||
* Between them they are every credential the API accepts, so the validation
|
||||
* tests below have to pick one. There is no longer a credential that stands
|
||||
* for "some authenticated caller" in general — reaching a handler means being
|
||||
* a specific someone, which is the property these fixtures preserve.
|
||||
*
|
||||
* Built once, lazily, because the settings they need are installed by a
|
||||
* `beforeEach` that has not run when a `beforeAll` would.
|
||||
*/
|
||||
let built: Promise<{ user: Record<string, string>; host: Record<string, string> }> | undefined;
|
||||
|
||||
function credentials() {
|
||||
built ??= (async () => {
|
||||
const owner = await Fixtures.owner('routes');
|
||||
const pat = await AccessToken.create({
|
||||
id: Identifier.ascending('accessToken'),
|
||||
ownerUserId: owner.userId,
|
||||
teamId: null,
|
||||
name: 'routes'
|
||||
});
|
||||
const registered = await Machine.register({
|
||||
id: Identifier.ascending('machine'),
|
||||
ownerUserId: owner.userId,
|
||||
teamId: owner.teamId,
|
||||
label: 'routes'
|
||||
});
|
||||
return {
|
||||
user: { authorization: `Bearer ${pat.token}` },
|
||||
host: {
|
||||
'x-nestri-machine-id': registered.id,
|
||||
'x-nestri-machine-secret': registered.secret
|
||||
}
|
||||
};
|
||||
})();
|
||||
return built;
|
||||
}
|
||||
|
||||
async function userHeaders(): Promise<Record<string, string>> {
|
||||
return (await credentials()).user;
|
||||
}
|
||||
|
||||
async function hostHeaders(): Promise<Record<string, string>> {
|
||||
return (await credentials()).host;
|
||||
}
|
||||
|
||||
describe('Index', () => {
|
||||
@@ -32,22 +79,6 @@ describe('Auth middleware', () => {
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
test('admin token gains access to protected routes', async () => {
|
||||
const res = await app.request('/waitlist', {
|
||||
headers: adminHeaders()
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
test('wrong admin token is treated as public → 401', async () => {
|
||||
const res = await app.request('/library', {
|
||||
headers: { 'x-nestri-admin-token': 'wrong-secret' }
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
const body = (await res.json()) as any;
|
||||
expect(body.type).toBe('authentication');
|
||||
});
|
||||
|
||||
test('a bearer token that cannot be verified is unauthenticated, not a server error', async () => {
|
||||
// A token nobody can verify makes the *caller* unauthenticated; it does
|
||||
// not make the request a server fault. `verify` reports a malformed or
|
||||
@@ -61,13 +92,12 @@ describe('Auth middleware', () => {
|
||||
expect(body.type).toBe('authentication');
|
||||
});
|
||||
|
||||
test('missing authorization on admin-only route returns 401', async () => {
|
||||
test('missing authorization on a protected route returns 401', async () => {
|
||||
const res = await app.request('/games/sync', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
// notPublic runs before adminOnly → 401
|
||||
expect(res.status).toBe(401);
|
||||
const body = (await res.json()) as any;
|
||||
expect(body.code).toBe('unauthorized');
|
||||
@@ -79,7 +109,7 @@ describe('Validation', () => {
|
||||
const res = await app.request('/games/sync', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...adminHeaders(),
|
||||
...(await hostHeaders()),
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: '{not-json'
|
||||
@@ -93,7 +123,7 @@ describe('Validation', () => {
|
||||
const res = await app.request('/games/download-state', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...adminHeaders(),
|
||||
...(await hostHeaders()),
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ status: 'downloading' })
|
||||
@@ -107,11 +137,10 @@ describe('Validation', () => {
|
||||
const res = await app.request('/games/download-state', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...adminHeaders(),
|
||||
...(await hostHeaders()),
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
hostId: 'hst_test',
|
||||
steamAppId: 440,
|
||||
status: 'bogus_status'
|
||||
})
|
||||
@@ -123,7 +152,7 @@ describe('Validation', () => {
|
||||
|
||||
test('non-existent game returns 404', async () => {
|
||||
const res = await app.request('/games/gam_nonexistent', {
|
||||
headers: adminHeaders()
|
||||
headers: await userHeaders()
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
const body = (await res.json()) as any;
|
||||
@@ -133,7 +162,7 @@ describe('Validation', () => {
|
||||
test('missing content-type header returns 400', async () => {
|
||||
const res = await app.request('/games/sync', {
|
||||
method: 'POST',
|
||||
headers: adminHeaders(),
|
||||
headers: await hostHeaders(),
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
@@ -143,7 +172,7 @@ describe('Validation', () => {
|
||||
describe('Error response shape', () => {
|
||||
test('404 on unknown game has standard error shape', async () => {
|
||||
const res = await app.request('/games/gam_nonexistent', {
|
||||
headers: adminHeaders()
|
||||
headers: await userHeaders()
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
const body = (await res.json()) as any;
|
||||
@@ -154,7 +183,7 @@ describe('Error response shape', () => {
|
||||
|
||||
test('429 error responses have standard shape', async () => {
|
||||
const res = await app.request('/games/gam_nonexistent', {
|
||||
headers: adminHeaders()
|
||||
headers: await userHeaders()
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
const body = (await res.json()) as any;
|
||||
@@ -189,7 +218,6 @@ describe('OpenAPI doc', () => {
|
||||
expect(paths).toContain('/user');
|
||||
expect(paths).toContain('/user/email');
|
||||
expect(paths).toContain('/user/devices');
|
||||
expect(paths).toContain('/pairing-code');
|
||||
expect(paths).toContain('/waitlist');
|
||||
});
|
||||
|
||||
@@ -223,15 +251,14 @@ describe('CORS', () => {
|
||||
});
|
||||
|
||||
describe('Download state route', () => {
|
||||
test('POST /games/download-state requires hostId and steamAppId', async () => {
|
||||
test('POST /games/download-state requires steamAppId', async () => {
|
||||
const res = await app.request('/games/download-state', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...adminHeaders(),
|
||||
...(await hostHeaders()),
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
hostId: 'hst_test',
|
||||
status: 'downloading'
|
||||
// missing steamAppId
|
||||
})
|
||||
@@ -239,6 +266,20 @@ describe('Download state route', () => {
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('a host cannot name the host it is reporting for', async () => {
|
||||
// Which host this is comes from the credentials. The body once carried
|
||||
// it, so a caller could write download state under any box's id.
|
||||
const res = await app.request('/games/download-state', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(await hostHeaders()),
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ hostId: 'mch_someoneelse', steamAppId: 440, status: 'ready' })
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('POST /games/download-state validates status enum', async () => {
|
||||
const valid = ['pending', 'verifying', 'downloading', 'ready', 'failed'] as const;
|
||||
for (const status of valid) {
|
||||
@@ -246,11 +287,10 @@ describe('Download state route', () => {
|
||||
const res = await app.request('/games/download-state', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...adminHeaders(),
|
||||
...(await hostHeaders()),
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
hostId: 'hst_test',
|
||||
steamAppId: 440,
|
||||
status
|
||||
})
|
||||
@@ -264,26 +304,11 @@ describe('Download state route', () => {
|
||||
const res = await app.request('/games/download-state', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ hostId: 'mch_test', steamAppId: 440, status: 'ready' })
|
||||
});
|
||||
// The route group's `notPublic` runs first, so this is 401 rather than
|
||||
// the 403 `machineOrAdmin` would give an authenticated non-host.
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test('an admin caller must say which host it is reporting for', async () => {
|
||||
// hostId is optional in the schema now because a machine supplies it
|
||||
// from its own identity. Admin has no identity to take it from, so
|
||||
// leaving it out has to fail rather than write under an empty host.
|
||||
const res = await app.request('/games/download-state', {
|
||||
method: 'POST',
|
||||
headers: { ...adminHeaders(), 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ steamAppId: 440, status: 'ready' })
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as any;
|
||||
expect(body.code).toBe('missing_required_field');
|
||||
expect(body.param).toBe('hostId');
|
||||
// The route group's `notPublic` runs first, so this is 401 rather than
|
||||
// the 403 `machineOnly` would give an authenticated non-host.
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -297,24 +322,10 @@ describe('Access tokens', () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test('the admin token cannot mint a token for anyone', async () => {
|
||||
// This is the boundary that makes admin safe to hand out for tooling:
|
||||
// it reads and writes API data but cannot *become* a user. Minting a
|
||||
// PAT on someone's behalf would erase exactly that.
|
||||
const res = await app.request('/access-token', {
|
||||
method: 'POST',
|
||||
headers: { ...adminHeaders(), 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'living-room-box' })
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
const body = (await res.json()) as any;
|
||||
expect(body.message).toContain('user session');
|
||||
});
|
||||
|
||||
test('a token needs a name', async () => {
|
||||
const res = await app.request('/access-token', {
|
||||
method: 'POST',
|
||||
headers: { ...adminHeaders(), 'content-type': 'application/json' },
|
||||
headers: { ...(await userHeaders()), 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ name: '' })
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
@@ -323,7 +334,7 @@ describe('Access tokens', () => {
|
||||
test('expiry is capped at a year', async () => {
|
||||
const res = await app.request('/access-token', {
|
||||
method: 'POST',
|
||||
headers: { ...adminHeaders(), 'content-type': 'application/json' },
|
||||
headers: { ...(await userHeaders()), 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'box', expiresInDays: 4000 })
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
@@ -337,12 +348,10 @@ describe('Access tokens', () => {
|
||||
// omitting the field means "take the default", which is not the same.
|
||||
const res = await app.request('/access-token', {
|
||||
method: 'POST',
|
||||
headers: { ...adminHeaders(), 'content-type': 'application/json' },
|
||||
headers: { ...(await userHeaders()), 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'box', teamId: null })
|
||||
});
|
||||
// Admin is refused at the handler, but only after validation — so a
|
||||
// 403 here proves null passed the schema rather than being rejected.
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
test('revoking someone else’s token requires authentication', async () => {
|
||||
@@ -372,17 +381,17 @@ describe('Box access', () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test('the admin token cannot rescope a machine', async () => {
|
||||
// Rescoping is an owner action and the query is scoped to a user id;
|
||||
// admin has none, so it must be refused rather than 500 later.
|
||||
test('rescoping onto a team you do not belong to is refused', async () => {
|
||||
// Naming a team is how hardware would otherwise be parked in somebody
|
||||
// else's, so membership is checked rather than taken from the body.
|
||||
const res = await app.request('/machine/mch_whatever', {
|
||||
method: 'PATCH',
|
||||
headers: { ...adminHeaders(), 'content-type': 'application/json' },
|
||||
headers: { ...(await userHeaders()), 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ teamId: 'tem_whatever' })
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
const body = (await res.json()) as any;
|
||||
expect(body.message).toContain('user session');
|
||||
expect(body.message).toContain('not a member');
|
||||
});
|
||||
|
||||
test('teamId is required on the body, and null is no longer a value', async () => {
|
||||
@@ -392,32 +401,31 @@ describe('Box access', () => {
|
||||
// error rather than a meaning.
|
||||
const missing = await app.request('/machine/mch_whatever', {
|
||||
method: 'PATCH',
|
||||
headers: { ...adminHeaders(), 'content-type': 'application/json' },
|
||||
headers: { ...(await userHeaders()), 'content-type': 'application/json' },
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
expect(missing.status).toBe(400);
|
||||
|
||||
const explicitNull = await app.request('/machine/mch_whatever', {
|
||||
method: 'PATCH',
|
||||
headers: { ...adminHeaders(), 'content-type': 'application/json' },
|
||||
headers: { ...(await userHeaders()), 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ teamId: null })
|
||||
});
|
||||
expect(explicitNull.status).toBe(400);
|
||||
|
||||
const named = await app.request('/machine/mch_whatever', {
|
||||
method: 'PATCH',
|
||||
headers: { ...adminHeaders(), 'content-type': 'application/json' },
|
||||
headers: { ...(await userHeaders()), 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ teamId: 'tem_whatever' })
|
||||
});
|
||||
// Past validation, refused at the handler for being admin.
|
||||
expect(named.status).toBe(403);
|
||||
expect([403, 404]).toContain(named.status);
|
||||
});
|
||||
|
||||
test('entitlement requires machine credentials, not a user session', async () => {
|
||||
// The machine is taken from its credentials, never the query, so a box
|
||||
// cannot ask about another box.
|
||||
const res = await app.request('/machine/entitlement?userId=usr_x', {
|
||||
headers: adminHeaders()
|
||||
headers: await userHeaders()
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
const body = (await res.json()) as any;
|
||||
@@ -441,24 +449,10 @@ describe('Machine registration', () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test('the admin token cannot register a machine', async () => {
|
||||
// Registering is an act of ownership and the resulting row references a
|
||||
// user. Admin is authenticated but owns nothing, so it must be refused
|
||||
// here rather than fail later on a null owner.
|
||||
const res = await app.request('/machine/register', {
|
||||
method: 'POST',
|
||||
headers: { ...adminHeaders(), 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ label: 'living-room-box' })
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
const body = (await res.json()) as any;
|
||||
expect(body.message).toContain('user session');
|
||||
});
|
||||
|
||||
test('registering a machine requires a label', async () => {
|
||||
const res = await app.request('/machine/register', {
|
||||
method: 'POST',
|
||||
headers: { ...adminHeaders(), 'content-type': 'application/json' },
|
||||
headers: { ...(await userHeaders()), 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ label: '' })
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
@@ -467,7 +461,7 @@ describe('Machine registration', () => {
|
||||
});
|
||||
|
||||
test('describing yourself requires machine credentials', async () => {
|
||||
const res = await app.request('/machine/me', { headers: adminHeaders() });
|
||||
const res = await app.request('/machine/me', { headers: await userHeaders() });
|
||||
expect(res.status).toBe(403);
|
||||
const body = (await res.json()) as any;
|
||||
expect(body.message).toContain('Machine credentials');
|
||||
@@ -484,7 +478,7 @@ describe('Steam routes', () => {
|
||||
const res = await app.request('/steam/link', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...adminHeaders(),
|
||||
...(await userHeaders()),
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({})
|
||||
@@ -507,67 +501,6 @@ describe('Library routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pairing code routes', () => {
|
||||
test('POST /pairing-code requires auth', async () => {
|
||||
// Generating a code says "this key is also me", so it can only be done
|
||||
// from a session that already is that user.
|
||||
const res = await app.request('/pairing-code', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test('POST /pairing-code/claim rejects an unauthenticated caller', async () => {
|
||||
// Claiming is done for a device with no identity yet, so it carries the
|
||||
// admin token rather than a user session. Without it, no.
|
||||
const res = await app.request('/pairing-code/claim', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ code: 'NESSH-7F2Q', fingerprint: 'aa:bb' })
|
||||
});
|
||||
expect([401, 403]).toContain(res.status);
|
||||
});
|
||||
|
||||
test('POST /pairing-code/claim requires both a code and a fingerprint', async () => {
|
||||
for (const body of [{}, { code: 'NESSH-7F2Q' }, { fingerprint: 'aa:bb' }]) {
|
||||
// eslint-disable-next-line
|
||||
const res = await app.request('/pairing-code/claim', {
|
||||
method: 'POST',
|
||||
headers: { ...adminHeaders(), 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
}
|
||||
});
|
||||
|
||||
test('POST /pairing-code/claim rejects an empty code', async () => {
|
||||
// An empty string must not be treated as "any code".
|
||||
const res = await app.request('/pairing-code/claim', {
|
||||
method: 'POST',
|
||||
headers: { ...adminHeaders(), 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ code: '', fingerprint: 'aa:bb' })
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('POST /pairing-code caps how long a code stays valid', async () => {
|
||||
// Short-lived by design; a long-lived code is a shared password.
|
||||
const res = await app.request('/pairing-code', {
|
||||
method: 'POST',
|
||||
headers: { ...adminHeaders(), 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ ttlMinutes: 60 * 24 })
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('GET /pairing-code requires auth', async () => {
|
||||
const res = await app.request('/pairing-code');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Email routes', () => {
|
||||
test('POST /user/email requires auth', async () => {
|
||||
const res = await app.request('/user/email', {
|
||||
@@ -581,7 +514,7 @@ describe('Email routes', () => {
|
||||
test('POST /user/email rejects a malformed address', async () => {
|
||||
const res = await app.request('/user/email', {
|
||||
method: 'POST',
|
||||
headers: { ...adminHeaders(), 'content-type': 'application/json' },
|
||||
headers: { ...(await userHeaders()), 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ email: 'not-an-email' })
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
@@ -606,7 +539,7 @@ describe('Email routes', () => {
|
||||
test('POST /user/email/verify requires a 6-digit code', async () => {
|
||||
const res = await app.request('/user/email/verify', {
|
||||
method: 'POST',
|
||||
headers: { ...adminHeaders(), 'content-type': 'application/json' },
|
||||
headers: { ...(await userHeaders()), 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ code: '12' })
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
@@ -662,9 +595,4 @@ describe('Waitlist routes', () => {
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('GET /waitlist is admin-only', async () => {
|
||||
const res = await app.request('/waitlist');
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,15 +2,13 @@ import { beforeEach } from 'bun:test';
|
||||
|
||||
import { Env } from '@nestri/core/env';
|
||||
|
||||
const TEST_ADMIN_SECRET = 'test-admin-secret-42';
|
||||
const TEST_FRONTEND_URL = 'http://localhost:5173';
|
||||
|
||||
beforeEach(() => {
|
||||
Env.init({
|
||||
NODE_ENV: 'test',
|
||||
ADMIN_SHARED_SECRET: TEST_ADMIN_SECRET,
|
||||
FRONTEND_URL: TEST_FRONTEND_URL
|
||||
});
|
||||
});
|
||||
|
||||
export { TEST_ADMIN_SECRET, TEST_FRONTEND_URL };
|
||||
export { TEST_FRONTEND_URL };
|
||||
|
||||
Reference in New Issue
Block a user