test(01-04): pin Nightscout egress enforcement

- Deny user-authorized and mismatched private origins before network access
- Preserve exact operator-private and public positive flows
- Require stable redacted policy reasons through sync boundaries
This commit is contained in:
Marc-André Bombeck committed 2026-07-29 13:39:06 +02:00
1 parent 9f6cd86c0d
commit 14f451093e
4 files changed
+202 -14

No files matched your search

@@ -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<RouteResult>;
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,
}),
);
@@ -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");
});
});
+75 -4
View File
@@ -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 () => {
+45
View File
@@ -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.