diff --git a/src/app/api/nightscout/connect/__tests__/route.test.ts b/src/app/api/nightscout/connect/__tests__/route.test.ts index da7d9c03c..f1cde5a7f 100644 --- a/src/app/api/nightscout/connect/__tests__/route.test.ts +++ b/src/app/api/nightscout/connect/__tests__/route.test.ts @@ -43,7 +43,16 @@ vi.mock("@/lib/rate-limit", () => ({ vi.mock("@/lib/api-response", () => ({ apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), - apiError: (error: string, status: number) => ({ data: null, error, status }), + apiError: ( + error: string, + status: number, + options?: { errorCode?: string }, + ) => ({ + data: null, + error, + status, + meta: options?.errorCode ? { errorCode: options.errorCode } : undefined, + }), safeJson: async (req: NextRequest) => { try { return { data: await req.json(), error: null }; @@ -67,11 +76,17 @@ function req(body: unknown): NextRequest { }); } -type RouteResult = { data: unknown; error: string | null; status: number }; +type RouteResult = { + data: unknown; + error: string | null; + status: number; + meta?: { errorCode?: string }; +}; const post = POST as unknown as (r: NextRequest) => Promise; beforeEach(() => { vi.clearAllMocks(); + delete process.env.NIGHTSCOUT_PRIVATE_ORIGINS; rateLimitMock.mockResolvedValue({ allowed: true, remaining: 9, resetAt: 0 }); fetchSgvEntriesMock.mockResolvedValue([{ id: "x", sgv: 100, date: 1 }]); bossSend.mockResolvedValue("job-1"); @@ -108,16 +123,51 @@ describe("POST /api/nightscout/connect", () => { expect(data.nightscoutTokenEncrypted).toBeNull(); }); - it("passes the private-host opt-in through to the test fetch", async () => { - await post( + it("rejects an ordinary user's private-host boolean before the probe", async () => { + const res = await post( req({ url: "http://192.168.1.5:1337", token: "tok", allowPrivateHost: true, }), ); - const fetchArg = fetchSgvEntriesMock.mock.calls[0]![0]; - expect(fetchArg.allowPrivateHost).toBe(true); + + expect(res.status).toBe(422); + expect(res.meta?.errorCode).toBe("private_origin_not_approved"); + expect(fetchSgvEntriesMock).not.toHaveBeenCalled(); + expect(userUpdate).not.toHaveBeenCalled(); + }); + + it("connects an exact operator-approved private origin without a user bypass", async () => { + process.env.NIGHTSCOUT_PRIVATE_ORIGINS = "http://192.168.1.5:1337"; + + const res = await post( + req({ + url: "http://192.168.1.5:1337", + token: "tok", + }), + ); + + expect(res.status).toBe(200); + expect(fetchSgvEntriesMock).toHaveBeenCalledTimes(1); + expect(userUpdate).toHaveBeenCalledTimes(1); + }); + + it("rejects an approved private host on a different port", async () => { + process.env.NIGHTSCOUT_PRIVATE_ORIGINS = "http://192.168.1.5:1337"; + + const res = await post( + req({ + url: "http://192.168.1.5:8080", + token: "tok", + allowPrivateHost: true, + }), + ); + + expect(res.status).toBe(422); + expect(res.meta?.errorCode).toBe("private_origin_not_approved"); + expect(fetchSgvEntriesMock).not.toHaveBeenCalled(); + expect(userUpdate).not.toHaveBeenCalled(); }); it("refuses a private host when the opt-in is off (clear 422, no store)", async () => { @@ -183,14 +233,13 @@ describe("POST /api/nightscout/connect", () => { expect(userUpdate).not.toHaveBeenCalled(); }); - it("still lets the explicit self-hoster opt-in through to the probe", async () => { - // The floor is gated, not absolute: a LAN instance stays reachable for - // the operator who asked for it. Without this the opt-in is dead code. + it("lets an exact operator-approved self-hosted origin through to the probe", async () => { + process.env.NIGHTSCOUT_PRIVATE_ORIGINS = "http://192.168.1.5:1337"; + const res = await post( req({ url: "http://192.168.1.5:1337", token: "tok", - allowPrivateHost: true, }), ); diff --git a/src/app/api/nightscout/sync/__tests__/route.test.ts b/src/app/api/nightscout/sync/__tests__/route.test.ts index 15585a50b..f3461d5f9 100644 --- a/src/app/api/nightscout/sync/__tests__/route.test.ts +++ b/src/app/api/nightscout/sync/__tests__/route.test.ts @@ -160,4 +160,27 @@ describe("POST /api/nightscout/sync", () => { expect(serialised).not.toContain("cgm.example.org"); expect(serialised).not.toContain("token="); }); + + it("returns a stable operator-action reason for an unapproved legacy private connection", async () => { + vi.mocked(syncUserNightscout).mockRejectedValue( + Object.assign( + new Error( + "GET http://10.0.0.4:1337/api/v1/entries.json?token=hunter2secret refused", + ), + { reasonCode: "private_origin_not_approved" }, + ), + ); + + const response = await post(request()); + const body = await envelope(response); + const serialised = JSON.stringify(body); + + expect(response.status).toBe(422); + expect(body.error).toBe( + "Private Nightscout access requires server operator approval", + ); + expect(body.meta?.errorCode).toBe("private_origin_not_approved"); + expect(serialised).not.toContain("10.0.0.4"); + expect(serialised).not.toContain("hunter2secret"); + }); }); diff --git a/src/lib/nightscout/__tests__/client.test.ts b/src/lib/nightscout/__tests__/client.test.ts index ec2d4e1b2..56a48d959 100644 --- a/src/lib/nightscout/__tests__/client.test.ts +++ b/src/lib/nightscout/__tests__/client.test.ts @@ -42,12 +42,20 @@ const SAMPLE: NightscoutSgvEntry[] = [ }, ]; +const ORIGINAL_PRIVATE_ORIGINS = process.env.NIGHTSCOUT_PRIVATE_ORIGINS; + beforeEach(() => { safeFetchMock.mockReset(); + delete process.env.NIGHTSCOUT_PRIVATE_ORIGINS; }); afterEach(() => { vi.restoreAllMocks(); + if (ORIGINAL_PRIVATE_ORIGINS === undefined) { + delete process.env.NIGHTSCOUT_PRIVATE_ORIGINS; + } else { + process.env.NIGHTSCOUT_PRIVATE_ORIGINS = ORIGINAL_PRIVATE_ORIGINS; + } }); describe("parseSgvEntries", () => { @@ -143,16 +151,79 @@ describe("fetchSgvEntries — SSRF gating", () => { expect(opts.requirePublicHost).toBe(true); }); - it("relaxes the public-host pin only when the private-host flag is set", async () => { + it("keeps an exact operator-approved private origin functional", async () => { + process.env.NIGHTSCOUT_PRIVATE_ORIGINS = "http://192.168.1.50:1337"; safeFetchMock.mockResolvedValue(jsonResponse(200, SAMPLE)); - await fetchSgvEntries({ + const entries = await fetchSgvEntries({ baseUrl: "http://192.168.1.50:1337", token: "", count: 10, + // Compatibility metadata is deliberately false: operator configuration, + // not this user-controlled field, grants the private connection. + allowPrivateHost: false, + }); + + expect(entries).toHaveLength(2); + expect(safeFetchMock).toHaveBeenCalledTimes(1); + }); + + it("does not let the legacy user boolean authorize private egress", async () => { + delete process.env.NIGHTSCOUT_PRIVATE_ORIGINS; + + await expect( + fetchSgvEntries({ + baseUrl: "http://192.168.1.50:1337", + token: "", + count: 10, + allowPrivateHost: true, + }), + ).rejects.toMatchObject({ + reasonCode: "private_origin_not_approved", + }); + + expect(safeFetchMock).not.toHaveBeenCalled(); + }); + + it("rejects the same private host on a different port before any network call", async () => { + process.env.NIGHTSCOUT_PRIVATE_ORIGINS = "http://192.168.1.50:1337"; + + await expect( + fetchSgvEntries({ + baseUrl: "http://192.168.1.50:8080", + token: "", + count: 10, + allowPrivateHost: true, + }), + ).rejects.toMatchObject({ + reasonCode: "private_origin_not_approved", + }); + + expect(safeFetchMock).not.toHaveBeenCalled(); + }); + + it("maps a denied DNS answer or connect-time drift to one redacted policy reason", async () => { + process.env.NIGHTSCOUT_PRIVATE_ORIGINS = "https://cgm.example.test"; + safeFetchMock.mockRejectedValue( + new SafeFetchError( + "safeFetch refused private resolved address for cgm.example.test/api/v1/entries.json?token=ns-secret", + "private_host", + ), + ); + + const rejected = fetchSgvEntries({ + baseUrl: "https://cgm.example.test", + token: "ns-secret", + count: 10, allowPrivateHost: true, }); - const opts = safeFetchMock.mock.calls[0]![2]; - expect(opts.requirePublicHost).toBe(false); + + await expect(rejected).rejects.toMatchObject({ + message: "private_origin_not_approved", + reasonCode: "private_origin_not_approved", + }); + await expect(rejected).rejects.not.toMatchObject({ + message: expect.stringContaining("ns-secret"), + }); }); it("pins redirect:manual + a timeout on every call", async () => { diff --git a/src/lib/nightscout/__tests__/sync.test.ts b/src/lib/nightscout/__tests__/sync.test.ts index 2d903a3ec..e43560469 100644 --- a/src/lib/nightscout/__tests__/sync.test.ts +++ b/src/lib/nightscout/__tests__/sync.test.ts @@ -141,6 +141,51 @@ describe("syncUserNightscout", () => { expect(recordSuccessMock).toHaveBeenCalledWith("u1", "nightscout"); }); + it("does not forward a legacy stored boolean as private-egress authority", async () => { + getCredsMock.mockResolvedValue({ + baseUrl: "http://10.0.0.4:1337", + token: "tok", + allowPrivateHost: true, + }); + fetchSgvEntriesMock.mockResolvedValue([]); + + await syncUserNightscout("u1"); + + expect(fetchSgvEntriesMock).toHaveBeenCalledTimes(1); + expect(fetchSgvEntriesMock.mock.calls[0]![0]).not.toHaveProperty( + "allowPrivateHost", + ); + }); + + it("records a stable operator-action reason when a legacy private row is no longer approved", async () => { + getCredsMock.mockResolvedValue({ + baseUrl: "http://10.0.0.4:1337", + token: "tok", + allowPrivateHost: true, + }); + fetchSgvEntriesMock.mockRejectedValue( + Object.assign( + new Error( + "GET http://10.0.0.4:1337/api/v1/entries.json?token=tok refused", + ), + { reasonCode: "private_origin_not_approved" }, + ), + ); + + await expect(syncUserNightscout("u1")).rejects.toThrow(); + + expect(recordFailureMock).toHaveBeenCalledWith( + expect.objectContaining({ + integration: "nightscout", + kind: "persistent", + message: "private_origin_not_approved", + }), + ); + const recorded = JSON.stringify(recordFailureMock.mock.calls[0]![0]); + expect(recorded).not.toContain("10.0.0.4"); + expect(recorded).not.toContain("tok"); + }); + it("N-1 — counts only fresh inserts and emits ONLY the newly-inserted rows", async () => { fetchSgvEntriesMock.mockResolvedValue([ENTRY_A, ENTRY_B]); // ENTRY_A already exists (a re-confirm); ENTRY_B is genuinely new.