summaryrefslogtreecommitdiffhomepage
path: root/packages/core/tests
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-01 09:50:30 +0900
committerAdam Malczewski <[email protected]>2026-06-01 09:50:30 +0900
commit1870d0b86e797077b2a86c39d93fd34004b39e40 (patch)
treee1da8ed73fbec1483a5f5a2e263fbdae6a9f5f9f /packages/core/tests
parent29bdd00f946d75671bea7c4b534d32197e5b4b55 (diff)
downloaddispatch-1870d0b86e797077b2a86c39d93fd34004b39e40.tar.gz
dispatch-1870d0b86e797077b2a86c39d93fd34004b39e40.zip
fix(notifications): address Gemini review — tighten validation, sanitize Click, support Basic auth, non-optimistic UI clear
Acted on 4 of 6 findings from the gemini-3-flash-preview second-opinion review (the other 2 were verified-wrong or judged not worth the complexity — see HANDOFF.md). core/src/notifications/ntfy.ts: - validateTopicUrl now enforces ntfy's actual topic-name constraints: exactly one path segment, 1–64 chars, charset [A-Za-z0-9_-]. Prevents users from saving topic URLs that look fine but silently 404 at publish time (cf. binwiederhier/ntfy#1451 for the 64-char limit and binwiederhier/ntfy's topic-name regex for the charset). - Click header now passes through sanitizeHeader, closing the same CRLF-injection vector that Title/Tags already had. - Authorization header construction now factors through a small buildAuthHeaderValue helper: a value that already starts with a scheme token ("Bearer xyz", "Basic dXNlcjpwYXNz") is used verbatim, so users of private ntfy servers that want Basic auth can paste the full header value. Bare tokens still get the "Bearer " prefix automatically. frontend/SettingsPanel.svelte: - clearNtfyAuthToken() was optimistic: it flipped hasAuthToken=false locally before awaiting the network call. If the request failed the UI lied about server state, and worse — a subsequent Save() with authToken:undefined would silently re-arm the original token. Now awaits the response, surfaces failures via the existing ntfySaveError banner, and only mutates local state on success. Adds a ntfyClearingToken loading flag so the button disables + spins during the request. Tests: +6 in ntfy.test.ts (multi-segment rejection, charset rejection, length boundary, 64-char acceptance, Basic auth pass-through, Click sanitization). All 442 tests pass; biome clean; svelte-check clean; manual ntfy.sh end-to-end re-verified.
Diffstat (limited to 'packages/core/tests')
-rw-r--r--packages/core/tests/notifications/ntfy.test.ts45
1 files changed, 44 insertions, 1 deletions
diff --git a/packages/core/tests/notifications/ntfy.test.ts b/packages/core/tests/notifications/ntfy.test.ts
index 3fb1d51..c183847 100644
--- a/packages/core/tests/notifications/ntfy.test.ts
+++ b/packages/core/tests/notifications/ntfy.test.ts
@@ -61,6 +61,27 @@ describe("validateTopicUrl", () => {
expect(validateTopicUrl("https://ntfy.sh")).toMatch(/topic/);
expect(validateTopicUrl("https://ntfy.sh/")).toMatch(/topic/);
});
+
+ it("rejects multi-segment paths (topic must be a single segment)", () => {
+ expect(validateTopicUrl("https://ntfy.sh/team/sub")).toMatch(/single topic/);
+ });
+
+ it("rejects topic names with disallowed characters", () => {
+ expect(validateTopicUrl("https://ntfy.sh/has.dot")).toMatch(/letters\/numbers/);
+ expect(validateTopicUrl("https://ntfy.sh/has space")).toMatch(/letters\/numbers/);
+ expect(validateTopicUrl("https://ntfy.sh/has%20enc")).toMatch(/letters\/numbers/);
+ });
+
+ it("rejects topic names longer than 64 chars", () => {
+ const long = "a".repeat(65);
+ expect(validateTopicUrl(`https://ntfy.sh/${long}`)).toMatch(/64/);
+ });
+
+ it("accepts 64-char topic names and underscore/hyphen mixes", () => {
+ const maxlen = "a".repeat(64);
+ expect(validateTopicUrl(`https://ntfy.sh/${maxlen}`)).toBeNull();
+ expect(validateTopicUrl("https://ntfy.sh/My_Topic-123")).toBeNull();
+ });
});
describe("sendNtfy", () => {
@@ -91,13 +112,23 @@ describe("sendNtfy", () => {
expect(init.headers.Tags).toBe("rotating_light");
});
- it("attaches Authorization header when authToken is set", async () => {
+ it("attaches Authorization header with Bearer prefix when authToken is a bare token", async () => {
const fetchImpl = makeFetch();
await sendNtfy(makeConfig({ authToken: "tk_secret " }), makeEvent(), fetchImpl);
const init = fetchImpl.mock.calls[0][1];
expect(init.headers.Authorization).toBe("Bearer tk_secret");
});
+ it("passes a pre-prefixed Authorization value (Basic, custom schemes) through verbatim", async () => {
+ const fetchImpl = makeFetch();
+ await sendNtfy(makeConfig({ authToken: "Basic dXNlcjpwYXNz" }), makeEvent(), fetchImpl);
+ expect(fetchImpl.mock.calls[0][1].headers.Authorization).toBe("Basic dXNlcjpwYXNz");
+
+ const fetchImpl2 = makeFetch();
+ await sendNtfy(makeConfig({ authToken: "Bearer already_prefixed" }), makeEvent(), fetchImpl2);
+ expect(fetchImpl2.mock.calls[0][1].headers.Authorization).toBe("Bearer already_prefixed");
+ });
+
it("omits Authorization when authToken is blank", async () => {
const fetchImpl = makeFetch();
await sendNtfy(makeConfig({ authToken: " " }), makeEvent(), fetchImpl);
@@ -112,6 +143,18 @@ describe("sendNtfy", () => {
expect(init.headers.Click).toBe("https://example.com/tab/abc");
});
+ it("sanitizes Click header (CR/LF injection guard)", async () => {
+ const fetchImpl = makeFetch();
+ await sendNtfy(
+ makeConfig(),
+ makeEvent({ clickUrl: "https://example.com/\r\nInjected: yes" }),
+ fetchImpl,
+ );
+ const v = fetchImpl.mock.calls[0][1].headers.Click;
+ expect(v).not.toContain("\n");
+ expect(v).not.toContain("\r");
+ });
+
it("appends short tab tag when tabId is set", async () => {
const fetchImpl = makeFetch();
await sendNtfy(