mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
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");
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user