mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
Gotify's POST /message types priority as an integer, so the generic body, which sends the word, was refused with a 400 before the token was ever the question. The webhook card now offers a payload format: generic, which stays byte for byte what it was, and gotify, which sends title, message, an integer priority and extras. Priorities follow the Gotify Android client's channel thresholds: 5 for routine events (audible), 8 for medication reminders (the lowest heads-up value), 10 for urgent events. The extras declare plain text and carry the event name, masked to reminder in discreet mode as the generic body does. The choice lives in the encrypted channel config, so no migration: an absent format is generic, only gotify is written, and a save that omits the field keeps the stored choice. GET returns the resolved value. The sender comment no longer suggests an Authorization Bearer header for Gotify, which takes X-Gotify-Key or a token query parameter. Refs #947
459 lines
15 KiB
TypeScript
459 lines
15 KiB
TypeScript
/**
|
|
* Generic-webhook sender unit tests (v1.17.1).
|
|
*
|
|
* Covers: success, hard-reject classification (404/410/401/403), SSRF block
|
|
* (safeFetch throws `private_host`), cooldown-relevant transient classification
|
|
* (5xx), the push_attempts ledger write per outcome, and (#947) the
|
|
* NOTIFICATION_PRIVATE_ORIGINS grant: a listed origin rides the
|
|
* operator-approved pin, an unlisted one keeps the public pin, and a listed
|
|
* origin admits nothing beside itself.
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const safeFetchMock = vi.fn();
|
|
const recordPushAttemptMock = vi.fn();
|
|
|
|
vi.mock("@/lib/safe-fetch", () => ({
|
|
safeFetch: (...args: unknown[]) => safeFetchMock(...args),
|
|
SafeFetchError: class SafeFetchError extends Error {
|
|
kind: string;
|
|
constructor(message: string, kind: string) {
|
|
super(message);
|
|
this.kind = kind;
|
|
}
|
|
},
|
|
}));
|
|
|
|
const annotateMock = vi.fn();
|
|
vi.mock("@/lib/logging/context", () => ({
|
|
getEvent: () => ({
|
|
addWarning: vi.fn(),
|
|
addExternalCall: vi.fn(),
|
|
hasAction: () => false,
|
|
}),
|
|
annotate: (...args: unknown[]) => annotateMock(...args),
|
|
}));
|
|
|
|
vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({
|
|
recordPushAttempt: (...args: unknown[]) => recordPushAttemptMock(...args),
|
|
recordPushAttemptForPayload: (
|
|
_payload: unknown,
|
|
_recipientUserId: string,
|
|
attempt: unknown,
|
|
) => recordPushAttemptMock(attempt),
|
|
}));
|
|
|
|
import { sendViaWebhook } from "../webhook";
|
|
import { SafeFetchError } from "@/lib/safe-fetch";
|
|
|
|
const config = {
|
|
url: "https://gotify.example.com/message",
|
|
headerName: "Authorization",
|
|
headerValue: "Bearer secret",
|
|
};
|
|
|
|
function payload(over?: Record<string, unknown>) {
|
|
return {
|
|
eventType: "SYSTEM_ALERT" as const,
|
|
userId: "user-1",
|
|
title: "Title",
|
|
message: "Body",
|
|
...over,
|
|
};
|
|
}
|
|
|
|
const ORIGINAL_PRIVATE_ORIGINS = process.env.NOTIFICATION_PRIVATE_ORIGINS;
|
|
|
|
beforeEach(() => {
|
|
safeFetchMock.mockReset();
|
|
recordPushAttemptMock.mockReset();
|
|
annotateMock.mockReset();
|
|
delete process.env.NOTIFICATION_PRIVATE_ORIGINS;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
if (ORIGINAL_PRIVATE_ORIGINS === undefined) {
|
|
delete process.env.NOTIFICATION_PRIVATE_ORIGINS;
|
|
} else {
|
|
process.env.NOTIFICATION_PRIVATE_ORIGINS = ORIGINAL_PRIVATE_ORIGINS;
|
|
}
|
|
});
|
|
|
|
type EgressOpts = {
|
|
requirePublicHost?: boolean;
|
|
operatorApprovedPrivateOrigin?: string;
|
|
};
|
|
|
|
describe("sendViaWebhook", () => {
|
|
it("POSTs through safeFetch with requirePublicHost and returns ok on 200", async () => {
|
|
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
|
|
|
|
const result = await sendViaWebhook(config, payload());
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(result.statusCode).toBe(200);
|
|
// SSRF floor + DNS-rebinding pin: requirePublicHost must be set.
|
|
const [, init, opts] = safeFetchMock.mock.calls[0];
|
|
expect((opts as { requirePublicHost?: boolean }).requirePublicHost).toBe(
|
|
true,
|
|
);
|
|
expect((init as { method?: string }).method).toBe("POST");
|
|
// Custom header is attached.
|
|
expect(
|
|
(init as { headers?: Record<string, string> }).headers?.Authorization,
|
|
).toBe("Bearer secret");
|
|
expect(recordPushAttemptMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({ channel: "WEBHOOK", result: "ok" }),
|
|
);
|
|
});
|
|
|
|
it("hard-rejects on 410 (endpoint gone)", async () => {
|
|
safeFetchMock.mockResolvedValue({ ok: false, status: 410 });
|
|
|
|
const result = await sendViaWebhook(config, payload());
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.hardReject).toBe(true);
|
|
expect(result.reason).toBe("webhook_410_gone");
|
|
expect(recordPushAttemptMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({ channel: "WEBHOOK", result: "error" }),
|
|
);
|
|
});
|
|
|
|
it("hard-rejects on 401/403 (shared secret wrong)", async () => {
|
|
safeFetchMock.mockResolvedValue({ ok: false, status: 403 });
|
|
const result = await sendViaWebhook(config, payload());
|
|
expect(result.hardReject).toBe(true);
|
|
expect(result.reason).toBe("webhook_auth_rejected");
|
|
});
|
|
|
|
it("soft-fails on 503 (transient — eligible for backoff)", async () => {
|
|
safeFetchMock.mockResolvedValue({ ok: false, status: 503 });
|
|
|
|
const result = await sendViaWebhook(config, payload());
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.hardReject).toBe(false);
|
|
expect(result.reason).toBe("webhook_503");
|
|
});
|
|
|
|
it("reports a soft, named refusal when safeFetch blocks a private host (SSRF)", async () => {
|
|
// Before #947 this collapsed into `webhook_network_error` and the test
|
|
// button answered a bare 500. The refusal stays soft (delivery resumes
|
|
// the moment the operator lists the origin) but names itself.
|
|
safeFetchMock.mockRejectedValue(
|
|
new SafeFetchError("refused private host", "private_host"),
|
|
);
|
|
|
|
const result = await sendViaWebhook(config, payload());
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.hardReject).toBe(false);
|
|
expect(result.reason).toBe("webhook_private_origin_refused");
|
|
expect(result.errorCode).toBe("private_origin_not_approved");
|
|
expect(recordPushAttemptMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
channel: "WEBHOOK",
|
|
result: "error",
|
|
reason: "webhook_private_origin_refused",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("keeps a genuine network fault distinct from a policy refusal", async () => {
|
|
safeFetchMock.mockRejectedValue(
|
|
new SafeFetchError("socket hang up", "network"),
|
|
);
|
|
|
|
const result = await sendViaWebhook(config, payload());
|
|
|
|
expect(result.reason).toBe("webhook_network_error");
|
|
expect(result.errorCode).toBeUndefined();
|
|
});
|
|
|
|
it("omits the custom header when not configured", async () => {
|
|
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
|
|
await sendViaWebhook({ url: "https://example.com/hook" }, payload());
|
|
const [, init] = safeFetchMock.mock.calls[0];
|
|
expect(
|
|
(init as { headers?: Record<string, string> }).headers?.Authorization,
|
|
).toBeUndefined();
|
|
});
|
|
|
|
describe("NOTIFICATION_PRIVATE_ORIGINS (#947)", () => {
|
|
it("dials a listed origin through the operator-approved pin and marks the event", async () => {
|
|
process.env.NOTIFICATION_PRIVATE_ORIGINS =
|
|
"https://gotify.example.com, http://ntfy.lan:8080";
|
|
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
|
|
|
|
const result = await sendViaWebhook(config, payload());
|
|
|
|
expect(result.ok).toBe(true);
|
|
const [url, , opts] = safeFetchMock.mock.calls[0] as [
|
|
string,
|
|
unknown,
|
|
EgressOpts,
|
|
];
|
|
expect(url).toBe("https://gotify.example.com/message");
|
|
expect(opts.requirePublicHost).toBe(false);
|
|
expect(opts.operatorApprovedPrivateOrigin).toBe(
|
|
"https://gotify.example.com",
|
|
);
|
|
expect(annotateMock).toHaveBeenCalledWith({
|
|
action: { name: "notification.egress.private_origin" },
|
|
});
|
|
expect(annotateMock).toHaveBeenCalledWith({
|
|
meta: { channel: "webhook", origin: "https://gotify.example.com" },
|
|
});
|
|
});
|
|
|
|
it("keeps the public pin for a target the operator did not list", async () => {
|
|
process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://ntfy.lan:8080";
|
|
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
|
|
|
|
await sendViaWebhook(config, payload());
|
|
|
|
const [, , opts] = safeFetchMock.mock.calls[0] as [
|
|
string,
|
|
unknown,
|
|
EgressOpts,
|
|
];
|
|
expect(opts.requirePublicHost).toBe(true);
|
|
expect(opts.operatorApprovedPrivateOrigin).toBeUndefined();
|
|
expect(annotateMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
["sibling port", "https://gotify.example.com:8443/message"],
|
|
["other scheme", "http://gotify.example.com/message"],
|
|
["sub-host", "https://push.gotify.example.com/message"],
|
|
["unlisted private literal", "http://10.0.0.9/message"],
|
|
])(
|
|
"refuses a %s without dialling: a listed origin admits only itself",
|
|
async (_label, url) => {
|
|
process.env.NOTIFICATION_PRIVATE_ORIGINS = "https://gotify.example.com";
|
|
|
|
const result = await sendViaWebhook({ url }, payload());
|
|
|
|
expect(safeFetchMock).not.toHaveBeenCalled();
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
hardReject: false,
|
|
reason: "webhook_private_origin_refused",
|
|
errorCode: "private_origin_not_approved",
|
|
});
|
|
expect(annotateMock).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it("approves a listed literal private address the input floor alone would refuse", async () => {
|
|
process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://10.0.0.9:8080";
|
|
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
|
|
|
|
const result = await sendViaWebhook(
|
|
{ url: "http://10.0.0.9:8080/message" },
|
|
payload(),
|
|
);
|
|
|
|
expect(result.ok).toBe(true);
|
|
const [, , opts] = safeFetchMock.mock.calls[0] as [
|
|
string,
|
|
unknown,
|
|
EgressOpts,
|
|
];
|
|
expect(opts.operatorApprovedPrivateOrigin).toBe("http://10.0.0.9:8080");
|
|
});
|
|
});
|
|
|
|
it("reports the not-grantable code for a metadata target, without dialling (M1)", async () => {
|
|
const result = await sendViaWebhook(
|
|
{ url: "http://169.254.169.254/latest" },
|
|
payload(),
|
|
);
|
|
|
|
expect(safeFetchMock).not.toHaveBeenCalled();
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
hardReject: false,
|
|
reason: "webhook_private_origin_refused",
|
|
errorCode: "private_origin_not_grantable",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("sendViaWebhook — what the relay said (#947)", () => {
|
|
it("carries a short error body on a non-2xx", async () => {
|
|
safeFetchMock.mockResolvedValue(
|
|
new Response('{"error":"Bad Request","errorCode":400}', { status: 400 }),
|
|
);
|
|
|
|
const result = await sendViaWebhook(config, payload());
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
statusCode: 400,
|
|
reason: "webhook_400",
|
|
upstreamBody: '{"error":"Bad Request","errorCode":400}',
|
|
});
|
|
// The ledger row keeps the reason only, never the body.
|
|
expect(recordPushAttemptMock).toHaveBeenCalledWith(
|
|
expect.not.objectContaining({ upstreamBody: expect.anything() }),
|
|
);
|
|
});
|
|
|
|
it("drops a body that echoes the header token or a query token", async () => {
|
|
safeFetchMock.mockResolvedValueOnce(
|
|
new Response("invalid token secret", { status: 401 }),
|
|
);
|
|
const viaHeader = await sendViaWebhook(config, payload());
|
|
expect(viaHeader.upstreamBody).toBeUndefined();
|
|
|
|
safeFetchMock.mockResolvedValueOnce(
|
|
new Response("unknown token Q1w2E3r4", { status: 401 }),
|
|
);
|
|
const viaQuery = await sendViaWebhook(
|
|
{ url: "https://relay.example.com/message?token=Q1w2E3r4" },
|
|
payload(),
|
|
);
|
|
expect(viaQuery.upstreamBody).toBeUndefined();
|
|
});
|
|
|
|
it("names a timeout and a refused connection, and leaves an unknown fault uncoded", async () => {
|
|
safeFetchMock.mockRejectedValueOnce(
|
|
new SafeFetchError("timed out", "timeout"),
|
|
);
|
|
expect((await sendViaWebhook(config, payload())).failureCode).toBe(
|
|
"timeout",
|
|
);
|
|
|
|
safeFetchMock.mockRejectedValueOnce(
|
|
new SafeFetchError("ECONNREFUSED", "network"),
|
|
);
|
|
expect((await sendViaWebhook(config, payload())).failureCode).toBe(
|
|
"connection_failed",
|
|
);
|
|
|
|
safeFetchMock.mockRejectedValueOnce(new TypeError("boom"));
|
|
expect(
|
|
(await sendViaWebhook(config, payload())).failureCode,
|
|
).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
function sentBody(): string {
|
|
return (safeFetchMock.mock.calls[0][1] as { body: string }).body;
|
|
}
|
|
|
|
describe("sendViaWebhook — generic body stays byte for byte", () => {
|
|
// Literal strings, not re-parsed objects: a Home Assistant or n8n rule
|
|
// that matches on the raw body must keep matching after the format choice
|
|
// was added.
|
|
it.each([
|
|
[
|
|
"a routine event",
|
|
{},
|
|
'{"title":"Title","message":"Body","eventType":"SYSTEM_ALERT","priority":"default"}',
|
|
],
|
|
[
|
|
"a medication reminder",
|
|
{ eventType: "MEDICATION_REMINDER" },
|
|
'{"title":"Title","message":"Body","eventType":"MEDICATION_REMINDER","priority":"high"}',
|
|
],
|
|
[
|
|
"an urgent event",
|
|
{ urgent: true },
|
|
'{"title":"Title","message":"Body","eventType":"SYSTEM_ALERT","priority":"urgent"}',
|
|
],
|
|
[
|
|
"a discreet cycle event",
|
|
{ eventType: "CYCLE_PERIOD_SOON", discreet: true },
|
|
'{"title":"Title","message":"Body","eventType":"reminder","priority":"default"}',
|
|
],
|
|
])("%s", async (_label, over, expected) => {
|
|
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
|
|
|
|
await sendViaWebhook(
|
|
{ url: "https://relay.example.com/hook" },
|
|
payload(over),
|
|
);
|
|
|
|
expect(sentBody()).toBe(expected);
|
|
});
|
|
});
|
|
|
|
describe("sendViaWebhook — Gotify format (#947)", () => {
|
|
const gotify = {
|
|
url: "https://gotify.example.com/message",
|
|
headerName: "X-Gotify-Key",
|
|
headerValue: "AbCdEfGh123",
|
|
format: "gotify" as const,
|
|
};
|
|
|
|
it("sends the body Gotify binds, with an integer priority and no eventType field", async () => {
|
|
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
|
|
|
|
await sendViaWebhook(gotify, payload({ message: "<b>Body</b>" }));
|
|
|
|
expect(JSON.parse(sentBody())).toEqual({
|
|
title: "Title",
|
|
message: "Body",
|
|
priority: 5,
|
|
extras: {
|
|
"client::display": { contentType: "text/plain" },
|
|
"healthlog::event": { type: "SYSTEM_ALERT" },
|
|
},
|
|
});
|
|
const [url, init] = safeFetchMock.mock.calls[0] as [
|
|
string,
|
|
{ headers: Record<string, string> },
|
|
];
|
|
expect(url).toBe("https://gotify.example.com/message");
|
|
expect(init.headers["X-Gotify-Key"]).toBe("AbCdEfGh123");
|
|
});
|
|
|
|
it.each([
|
|
["a routine event", 5, {}],
|
|
["a medication reminder", 8, { eventType: "MEDICATION_REMINDER" }],
|
|
["an urgent event", 10, { urgent: true }],
|
|
[
|
|
"an urgent medication reminder",
|
|
10,
|
|
{ eventType: "MEDICATION_REMINDER", urgent: true },
|
|
],
|
|
])("maps %s to priority %i", async (_label, priority, over) => {
|
|
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
|
|
|
|
await sendViaWebhook(gotify, payload(over));
|
|
|
|
const body = JSON.parse(sentBody());
|
|
expect(body.priority).toBe(priority);
|
|
expect(Number.isInteger(body.priority)).toBe(true);
|
|
});
|
|
|
|
it("keeps the cycle event name out of a discreet message", async () => {
|
|
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
|
|
|
|
await sendViaWebhook(
|
|
gotify,
|
|
payload({ eventType: "CYCLE_PERIOD_SOON", discreet: true }),
|
|
);
|
|
|
|
expect(sentBody()).not.toContain("CYCLE");
|
|
expect(JSON.parse(sentBody()).extras["healthlog::event"]).toEqual({
|
|
type: "reminder",
|
|
});
|
|
});
|
|
|
|
it("treats an unknown stored format as generic", async () => {
|
|
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
|
|
|
|
await sendViaWebhook(
|
|
{ url: "https://relay.example.com/hook", format: "bogus" as never },
|
|
payload(),
|
|
);
|
|
|
|
expect(JSON.parse(sentBody()).priority).toBe("default");
|
|
});
|
|
});
|