Files
proxy-manager/api/npm-client.test.ts
T
pastilhasandClaude Opus 5 3380a482ed the clever type cost twelve errors; the untested rule was the load-bearing one
four fixes from a review, and the first one was leaking out of the plugin.

TYPING. `respond` derived its context from `Parameters<typeof router.get>[1]`.
hono's .get is overloaded, `Parameters<>` resolves the FIRST overload — `[path:
string]` — so index 1 was `undefined`, ctx was `never`, and all nine handlers
errored. twelve errors from one line, in a tree where `bunx tsgo` is shared with
the platform and expected clean. imports hono's `Context` instead. tsgo now
exits 0 tree-wide, which it has not done since this plugin landed.

READONLYWRITES. `/_config/test` was declared as a read-only write so a member
would not meet a 403 on the test button. they always would: authorize.ts asks
isOwnerOnlyRoute before isRequestAllowedAtLevel, and ownerOnly is prefix
matched, so `/_config` already covered it and the entry was inert. removed, and
the reason it SHOULD be inert written down — that request carries the admin
secret in its body, so a member who could call it would hold an oracle pointed
at somebody else's password.

STATUS WROTE ON EVERY POLL. recordProbe ran unconditionally on a GET whose own
comment invites polling, so a read endpoint issued an UPDATE per poll per open
panel. the probe is cached 30s and the row is written only when the verdict or
the version changes — a flapping NPM still records every transition, which is
the case the column exists for. the cache is dropped when the credential does.

DEAD TERNARY. `likelyAuth ? 502 : 502`.

and the tests. 22 over a stubbed fetch, 99% of npm-client.ts. the two that
matter are the 404-means-credential rule and the mint-once-and-retry: both are
silent when broken, and a refactor of either turns a working panel into one
that says "not found" forever. every test uses its own credential because the
token cache is module state — sharing one would let an earlier test's token
satisfy the retry test, which would then pass for the wrong reason.

still not installed, still no settings form, still no db:push. README says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 15:26:18 +00:00

286 lines
12 KiB
TypeScript

import { describe, expect, test, beforeEach, afterEach } from 'bun:test';
import { NpmError, forgetToken, normaliseUrl, npmRequest, probe } from './npm-client';
/**
* The client, against a stubbed `fetch`.
*
* Two behaviours here are worth a test more than anything else in the plugin, because both are invisible
* when they break: **a 404 means the credential, not a missing route**, and **a stale token is re-minted
* once before anything is concluded**. Neither throws, neither logs, and a plausible refactor of either
* turns a working panel into one that says "not found" forever.
*
* The token cache is module state keyed on `url + identity`, so every test uses its own credential and
* clears it afterwards. Sharing one would let an earlier test's cached token satisfy a later one, and the
* test that then passes for the wrong reason is the retry test.
*/
type StubbedCall = { url: string; init: RequestInit | undefined };
const realFetch = globalThis.fetch;
let calls: StubbedCall[] = [];
/** Queue one Response per expected call, in order. A call past the end of the queue fails loudly. */
const stubFetch = (responses: (() => Response | Promise<Response>)[]): void => {
let index = 0;
calls = [];
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
calls.push({ url: String(input), init });
const next = responses[index++];
if (!next) throw new Error(`unexpected fetch #${index} to ${String(input)}`);
return next();
}) as typeof fetch;
};
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
const token = (value: string, expires?: string) => () => json({ token: value, expires });
const npmNotFound = (path: string) => () => json({ error: { code: 404, message: `Not Found - ${path}` } }, 404);
let counter = 0;
const freshCredentials = () => ({ url: 'http://npm.test', identity: `admin${counter++}@test`, secret: 'pw' });
let used: ReturnType<typeof freshCredentials>[] = [];
const credentials = () => {
const c = freshCredentials();
used.push(c);
return c;
};
beforeEach(() => {
used = [];
});
afterEach(() => {
globalThis.fetch = realFetch;
for (const c of used) forgetToken(c);
});
describe('normaliseUrl', () => {
test('strips trailing slashes and surrounding space', () => {
expect(normaliseUrl(' http://npm.test/ ')).toBe('http://npm.test');
expect(normaliseUrl('http://npm.test///')).toBe('http://npm.test');
expect(normaliseUrl('http://npm.test')).toBe('http://npm.test');
});
// The base is concatenated with `/api${path}`, so a surviving slash produces `//api/...`, which NPM
// answers with — of course — a 404, and then the 404 rule blames the credential.
test('a normalised url produces exactly one slash before /api', async () => {
const c = { ...credentials(), url: 'http://npm.test/' };
stubFetch([token('t'), () => json([])]);
await npmRequest(c, { path: '/nginx/proxy-hosts' });
expect(calls[1]?.url).toBe('http://npm.test/api/nginx/proxy-hosts');
});
});
describe('tokens', () => {
test('mints once and reuses the cached token', async () => {
const c = credentials();
stubFetch([token('t1'), () => json([]), () => json([])]);
await npmRequest(c, { path: '/nginx/proxy-hosts' });
await npmRequest(c, { path: '/nginx/certificates' });
expect(calls).toHaveLength(3);
expect(calls[0]?.url).toBe('http://npm.test/api/tokens');
expect(calls[1]?.init?.headers).toMatchObject({ authorization: 'Bearer t1' });
expect(calls[2]?.init?.headers).toMatchObject({ authorization: 'Bearer t1' });
});
test('the cache is keyed on identity, so a changed credential cannot reuse a token', async () => {
const first = credentials();
const second = { ...credentials(), url: first.url };
stubFetch([token('t1'), () => json([]), token('t2'), () => json([])]);
await npmRequest(first, { path: '/nginx/proxy-hosts' });
await npmRequest(second, { path: '/nginx/proxy-hosts' });
expect(calls[1]?.init?.headers).toMatchObject({ authorization: 'Bearer t1' });
expect(calls[3]?.init?.headers).toMatchObject({ authorization: 'Bearer t2' });
});
test('forgetToken forces a fresh mint', async () => {
const c = credentials();
stubFetch([token('t1'), () => json([]), token('t2'), () => json([])]);
await npmRequest(c, { path: '/nginx/proxy-hosts' });
forgetToken(c);
await npmRequest(c, { path: '/nginx/proxy-hosts' });
expect(calls[3]?.init?.headers).toMatchObject({ authorization: 'Bearer t2' });
});
// A token cached right up to its stated expiry fails mid-flight as a 404, which reads as a missing
// route. The client keeps a minute of headroom; an already-expired one must never be served.
test('an expiry inside the headroom window is not cached', async () => {
const c = credentials();
const soon = new Date(Date.now() + 30_000).toISOString();
stubFetch([token('t1', soon), () => json([]), token('t2', soon), () => json([])]);
await npmRequest(c, { path: '/nginx/proxy-hosts' });
await npmRequest(c, { path: '/nginx/proxy-hosts' });
expect(calls[2]?.url).toBe('http://npm.test/api/tokens');
expect(calls[3]?.init?.headers).toMatchObject({ authorization: 'Bearer t2' });
});
test('a bad credential is reported as auth, not as a 400', async () => {
const c = credentials();
stubFetch([() => json({ error: { message: 'Invalid email or password' } }, 400)]);
const error = (await npmRequest(c, { path: '/nginx/proxy-hosts' }).catch((e) => e)) as NpmError;
expect(error).toBeInstanceOf(NpmError);
expect(error.status).toBe(400);
expect(error.likelyAuth).toBe(true);
});
test('a token response with no token is an error rather than a bearer of undefined', async () => {
const c = credentials();
stubFetch([() => json({ expires: '2030-01-01T00:00:00Z' })]);
const error = (await npmRequest(c, { path: '/nginx/proxy-hosts' }).catch((e) => e)) as NpmError;
expect(error).toBeInstanceOf(NpmError);
expect(error.message).toMatch(/no token/i);
});
});
describe('the 404 quirk', () => {
// The whole reason this client exists in the shape it does. NPM registers a resource only once the
// request carries a token, so unauthenticated and non-existent are the same response.
test('a stale token is re-minted once and the request retried', async () => {
const c = credentials();
stubFetch([token('stale'), npmNotFound('/nginx/proxy-hosts'), token('fresh'), () => json([{ id: 1 }])]);
const hosts = await npmRequest<{ id: number }[]>(c, { path: '/nginx/proxy-hosts' });
expect(hosts).toEqual([{ id: 1 }]);
expect(calls).toHaveLength(4);
expect(calls[1]?.init?.headers).toMatchObject({ authorization: 'Bearer stale' });
expect(calls[3]?.init?.headers).toMatchObject({ authorization: 'Bearer fresh' });
});
test('401 gets the same retry as 404', async () => {
const c = credentials();
stubFetch([token('stale'), () => json({ error: { message: 'nope' } }, 401), token('fresh'), () => json([])]);
await npmRequest(c, { path: '/nginx/proxy-hosts' });
expect(calls[3]?.init?.headers).toMatchObject({ authorization: 'Bearer fresh' });
});
test('a 404 that survives the retry blames the credential, not the route', async () => {
const c = credentials();
stubFetch([token('t1'), npmNotFound('/nginx/proxy-hosts'), token('t2'), npmNotFound('/nginx/proxy-hosts')]);
const error = (await npmRequest(c, { path: '/nginx/proxy-hosts' }).catch((e) => e)) as NpmError;
expect(error.status).toBe(404);
expect(error.likelyAuth).toBe(true);
expect(error.message).toMatch(/credential/i);
});
test('retries exactly once — a second 404 does not mint a third token', async () => {
const c = credentials();
stubFetch([token('t1'), npmNotFound('/x'), token('t2'), npmNotFound('/x')]);
await npmRequest(c, { path: '/x' }).catch(() => undefined);
expect(calls.filter((call) => call.url.endsWith('/api/tokens'))).toHaveLength(2);
});
});
describe('requests', () => {
test('a body is sent as JSON with a content-type; a bodyless request has neither', async () => {
const c = credentials();
stubFetch([token('t'), () => json({ ok: true }), () => json({ ok: true })]);
await npmRequest(c, { path: '/x', method: 'POST', body: { a: 1 } });
await npmRequest(c, { path: '/y' });
expect(calls[1]?.init?.method).toBe('POST');
expect(calls[1]?.init?.headers).toMatchObject({ 'content-type': 'application/json' });
expect(calls[1]?.init?.body).toBe('{"a":1}');
expect(calls[2]?.init?.body).toBeUndefined();
expect(calls[2]?.init?.headers).not.toMatchObject({ 'content-type': 'application/json' });
});
// NPM answers 204 to some writes. `response.json()` on an empty body throws, and that throw would
// surface as an unshaped 500 rather than a success.
test('204 resolves to undefined instead of failing to parse', async () => {
const c = credentials();
stubFetch([token('t'), () => new Response(null, { status: 204 })]);
expect(await npmRequest(c, { path: '/x', method: 'POST' })).toBeUndefined();
});
test('an unreachable server is status 0, not a raw fetch error', async () => {
const c = credentials();
stubFetch([
() => {
throw new Error('connect ECONNREFUSED');
},
]);
const error = (await npmRequest(c, { path: '/x' }).catch((e) => e)) as NpmError;
expect(error).toBeInstanceOf(NpmError);
expect(error.status).toBe(0);
expect(error.message).toMatch(/Cannot reach/);
});
test("an error body's message is preferred over the status text", async () => {
const c = credentials();
stubFetch([token('t'), () => json({ error: { message: 'Access list not found' } }, 400)]);
const error = (await npmRequest(c, { path: '/x' }).catch((e) => e)) as NpmError;
expect(error.message).toBe('Access list not found');
expect(error.likelyAuth).toBe(false);
});
test('a non-JSON error body falls back to the status rather than throwing', async () => {
const c = credentials();
stubFetch([token('t'), () => new Response('<html>502</html>', { status: 502 })]);
const error = (await npmRequest(c, { path: '/x' }).catch((e) => e)) as NpmError;
expect(error).toBeInstanceOf(NpmError);
expect(error.status).toBe(502);
});
});
describe('probe', () => {
test('reads the version from GET /api/ and sends no token', async () => {
stubFetch([() => json({ status: 'OK', setup: true, version: { major: 2, minor: 15, revision: 1 } })]);
expect(await probe('http://npm.test/')).toEqual({ version: '2.15.1', setup: true });
expect(calls[0]?.url).toBe('http://npm.test/api/');
expect(calls[0]?.init?.headers).toBeUndefined();
});
test('setup defaults to false when NPM omits it', async () => {
stubFetch([() => json({ version: { major: 2, minor: 15, revision: 1 } })]);
expect((await probe('http://npm.test')).setup).toBe(false);
});
test('something that answers but is not an NPM is rejected', async () => {
stubFetch([() => json({ hello: 'world' })]);
const error = (await probe('http://npm.test').catch((e) => e)) as NpmError;
expect(error).toBeInstanceOf(NpmError);
expect(error.message).toMatch(/not like an Nginx Proxy Manager/);
});
test('a non-200 names the url rather than reporting a parse failure', async () => {
stubFetch([() => new Response('nope', { status: 500 })]);
const error = (await probe('http://npm.test').catch((e) => e)) as NpmError;
expect(error.status).toBe(500);
expect(error.message).toMatch(/is that an Nginx Proxy Manager/);
});
test('unreachable is status 0', async () => {
stubFetch([
() => {
throw new Error('ETIMEDOUT');
},
]);
const error = (await probe('http://npm.test').catch((e) => e)) as NpmError;
expect(error.status).toBe(0);
});
});