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)[]): 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[] = []; 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('502', { 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); }); });