diff --git a/web/src/app/AccountApi.test.js b/web/src/app/AccountApi.test.js new file mode 100644 index 00000000..d8cdf4c3 --- /dev/null +++ b/web/src/app/AccountApi.test.js @@ -0,0 +1,207 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import i18n from "i18next"; +import session from "./Session"; +import prefs from "./Prefs"; +import subscriptionManager from "./SubscriptionManager"; +import accountApi from "./AccountApi"; + +// AccountApi.js leans on several singletons; mock them so we can drive token() and assert the +// side effects of sync(), and so importing it doesn't drag in the component/Dexie/i18n trees. +// (vi.mock is hoisted above the imports by vitest, so those imports receive the mocks.) +vi.mock("./Session", () => ({ + default: { token: vi.fn(() => "test-token"), setLastExtendedAtAsync: vi.fn(), resetAndRedirect: vi.fn() }, +})); +vi.mock("./SubscriptionManager", () => ({ default: { syncFromRemote: vi.fn() } })); +vi.mock("./Prefs", () => ({ + default: { setSound: vi.fn(), setDeleteAfter: vi.fn(), setMinPriority: vi.fn() }, + THEME: { DARK: "dark", LIGHT: "light", SYSTEM: "system" }, +})); +vi.mock("i18next", () => ({ default: { changeLanguage: vi.fn() } })); +vi.mock("../components/routes", () => ({ default: { login: "/login" } })); + +let fetchMock; + +// fetchOrThrow treats anything other than HTTP 200 as an error, so a fake response just needs a +// status and a json() method. +const ok = (body = {}) => ({ status: 200, json: async () => body }); + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => {}); + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + session.token.mockReturnValue("test-token"); + accountApi.tiers = null; // reset the billing-tiers cache between tests + accountApi.listener = null; +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe("AccountApi.login", () => { + it("POSTs basic auth to the token URL and returns the token", async () => { + fetchMock.mockResolvedValue(ok({ token: "tk_returned" })); + const token = await accountApi.login({ username: "phil", password: "secret" }); + + expect(token).toBe("tk_returned"); + const [url, options] = fetchMock.mock.calls[0]; + expect(url).toBe("https://ntfy.sh/v1/account/token"); + expect(options.method).toBe("POST"); + expect(options.headers.Authorization).toBe(`Basic ${btoa("phil:secret")}`); + }); + + it("throws when the server response has no token", async () => { + fetchMock.mockResolvedValue(ok({})); + await expect(accountApi.login({ username: "phil", password: "secret" })).rejects.toThrow("Cannot find token"); + }); +}); + +describe("AccountApi.create", () => { + it("POSTs username/password and defaults email to an empty string", async () => { + fetchMock.mockResolvedValue(ok()); + await accountApi.create("phil", "pw"); + expect(fetchMock).toHaveBeenCalledWith( + "https://ntfy.sh/v1/account", + expect.objectContaining({ method: "POST", body: JSON.stringify({ username: "phil", password: "pw", email: "" }) }) + ); + }); +}); + +describe("AccountApi.get", () => { + it("returns the parsed account and notifies the listener", async () => { + fetchMock.mockResolvedValue(ok({ username: "phil" })); + const listener = vi.fn(); + accountApi.registerListener(listener); + + const account = await accountApi.get(); + + expect(account).toEqual({ username: "phil" }); + expect(listener).toHaveBeenCalledWith({ username: "phil" }); + const [url, options] = fetchMock.mock.calls[0]; + expect(url).toBe("https://ntfy.sh/v1/account"); + expect(options.headers.Authorization).toBe("Bearer test-token"); + }); +}); + +describe("AccountApi.changePassword", () => { + it("POSTs the current and new passwords", async () => { + fetchMock.mockResolvedValue(ok()); + await accountApi.changePassword("old", "new"); + const [, options] = fetchMock.mock.calls[0]; + expect(options.body).toBe(JSON.stringify({ password: "old", new_password: "new" })); + }); +}); + +describe("AccountApi.createToken", () => { + it("converts a positive expiry into an absolute unix timestamp", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); // 1767225600 seconds + fetchMock.mockResolvedValue(ok()); + + await accountApi.createToken("my label", 3600); + + const [, options] = fetchMock.mock.calls[0]; + expect(JSON.parse(options.body)).toEqual({ label: "my label", expires: 1767225600 + 3600 }); + }); + + it("sends expires=0 for a non-expiring token", async () => { + fetchMock.mockResolvedValue(ok()); + await accountApi.createToken("forever", 0); + const [, options] = fetchMock.mock.calls[0]; + expect(JSON.parse(options.body).expires).toBe(0); + }); +}); + +describe("AccountApi.deleteToken", () => { + it("passes the target token in the X-Token header alongside the bearer token", async () => { + fetchMock.mockResolvedValue(ok()); + await accountApi.deleteToken("tk_target"); + const [, options] = fetchMock.mock.calls[0]; + expect(options.method).toBe("DELETE"); + expect(options.headers["X-Token"]).toBe("tk_target"); + expect(options.headers.Authorization).toBe("Bearer test-token"); + }); +}); + +describe("AccountApi subscriptions", () => { + it("addSubscription POSTs base_url/topic and returns the parsed subscription", async () => { + fetchMock.mockResolvedValue(ok({ id: "sub_1" })); + const subscription = await accountApi.addSubscription("https://ntfy.sh", "mytopic"); + + expect(subscription).toEqual({ id: "sub_1" }); + const [url, options] = fetchMock.mock.calls[0]; + expect(url).toBe("https://ntfy.sh/v1/account/subscription"); + expect(options.method).toBe("POST"); + expect(JSON.parse(options.body)).toEqual({ base_url: "https://ntfy.sh", topic: "mytopic" }); + }); + + it("deleteSubscription passes baseUrl/topic via headers", async () => { + fetchMock.mockResolvedValue(ok()); + await accountApi.deleteSubscription("https://ntfy.sh", "mytopic"); + const [, options] = fetchMock.mock.calls[0]; + expect(options.method).toBe("DELETE"); + expect(options.headers["X-BaseURL"]).toBe("https://ntfy.sh"); + expect(options.headers["X-Topic"]).toBe("mytopic"); + }); +}); + +describe("AccountApi.billingTiers", () => { + it("caches the tiers and only fetches once", async () => { + fetchMock.mockResolvedValue(ok([{ code: "pro" }])); + const first = await accountApi.billingTiers(); + const second = await accountApi.billingTiers(); + + expect(first).toEqual([{ code: "pro" }]); + expect(second).toBe(first); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); + +describe("AccountApi.requestPasswordReset", () => { + it("POSTs the identifier without any auth header", async () => { + fetchMock.mockResolvedValue(ok()); + await accountApi.requestPasswordReset("phil"); + const [url, options] = fetchMock.mock.calls[0]; + expect(url).toBe("https://ntfy.sh/v1/account/password/reset/request"); + expect(options.body).toBe(JSON.stringify({ identifier: "phil" })); + expect(options.headers).toBeUndefined(); + }); +}); + +describe("AccountApi.sync", () => { + it("returns null and makes no request when there is no token", async () => { + session.token.mockReturnValue(null); + expect(await accountApi.sync()).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("resets the session and redirects to login on an unauthorized response", async () => { + fetchMock.mockResolvedValue({ status: 401, json: async () => ({}) }); + const result = await accountApi.sync(); + + expect(result).toBeUndefined(); + expect(session.resetAndRedirect).toHaveBeenCalledWith("/login"); + }); + + it("applies language, notification prefs and subscriptions from the fetched account", async () => { + fetchMock.mockResolvedValue( + ok({ + language: "de", + notification: { sound: "ding", delete_after: 3600, min_priority: 3 }, + subscriptions: [{ topic: "t" }], + reservations: [{ topic: "t" }], + }) + ); + + await accountApi.sync(); + + expect(i18n.changeLanguage).toHaveBeenCalledWith("de"); + expect(prefs.setSound).toHaveBeenCalledWith("ding"); + expect(prefs.setDeleteAfter).toHaveBeenCalledWith(3600); + expect(prefs.setMinPriority).toHaveBeenCalledWith(3); + expect(subscriptionManager.syncFromRemote).toHaveBeenCalledWith([{ topic: "t" }], [{ topic: "t" }]); + }); +}); diff --git a/web/src/app/Api.test.js b/web/src/app/Api.test.js new file mode 100644 index 00000000..29e55994 --- /dev/null +++ b/web/src/app/Api.test.js @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import userManager from "./UserManager"; +import api from "./Api"; + +// Api.js talks to the server through the global fetch and looks up credentials via userManager. +// (vi.mock is hoisted above the imports by vitest, so the import above receives the mock.) +vi.mock("./UserManager", () => ({ default: { get: vi.fn() } })); + +let fetchMock; + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => {}); + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + userManager.get.mockResolvedValue(undefined); // anonymous by default +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("Api.poll", () => { + it("parses newline-delimited JSON and skips lines without an id", async () => { + const body = [ + JSON.stringify({ id: "1", message: "a" }), + JSON.stringify({ event: "keepalive" }), // no id -> skipped + JSON.stringify({ id: "2", message: "b" }), + ].join("\n"); + fetchMock.mockResolvedValue(new Response(body)); + + const messages = await api.poll("https://ntfy.sh", "mytopic"); + + expect(messages.map((m) => m.id)).toEqual(["1", "2"]); + }); + + it("uses the plain poll URL without a since cursor", async () => { + fetchMock.mockResolvedValue(new Response("")); + await api.poll("https://ntfy.sh", "mytopic"); + expect(fetchMock).toHaveBeenCalledWith("https://ntfy.sh/mytopic/json?poll=1", expect.anything()); + }); + + it("uses the since URL when a cursor is given", async () => { + fetchMock.mockResolvedValue(new Response("")); + await api.poll("https://ntfy.sh", "mytopic", 12345); + expect(fetchMock).toHaveBeenCalledWith("https://ntfy.sh/mytopic/json?poll=1&since=12345", expect.anything()); + }); +}); + +describe("Api.publish", () => { + it("PUTs the message body to the base URL", async () => { + fetchMock.mockResolvedValue({ status: 200 }); + await api.publish("https://ntfy.sh", "mytopic", "Hello", { priority: 5, tags: ["warning"] }); + expect(fetchMock).toHaveBeenCalledWith( + "https://ntfy.sh", + expect.objectContaining({ + method: "PUT", + body: JSON.stringify({ topic: "mytopic", message: "Hello", priority: 5, tags: ["warning"] }), + }) + ); + }); + + it("attaches basic auth when the user has a password", async () => { + userManager.get.mockResolvedValue({ username: "phil", password: "secret" }); + fetchMock.mockResolvedValue({ status: 200 }); + await api.publish("https://ntfy.sh", "mytopic", "Hi"); + const [, options] = fetchMock.mock.calls[0]; + expect(options.headers.Authorization).toBe(`Basic ${btoa("phil:secret")}`); + }); + + it("attaches bearer auth when the user has a token", async () => { + userManager.get.mockResolvedValue({ token: "tk_abc" }); + fetchMock.mockResolvedValue({ status: 200 }); + await api.publish("https://ntfy.sh", "mytopic", "Hi"); + const [, options] = fetchMock.mock.calls[0]; + expect(options.headers.Authorization).toBe("Bearer tk_abc"); + }); +}); + +describe("Api.topicAuth", () => { + it("returns true for a 2xx response", async () => { + fetchMock.mockResolvedValue({ status: 200 }); + expect(await api.topicAuth("https://ntfy.sh", "mytopic")).toBe(true); + }); + + it("returns false for 401/403", async () => { + fetchMock.mockResolvedValue({ status: 403 }); + expect(await api.topicAuth("https://ntfy.sh", "mytopic")).toBe(false); + }); + + it("throws for any other status", async () => { + fetchMock.mockResolvedValue({ status: 500 }); + await expect(api.topicAuth("https://ntfy.sh", "mytopic")).rejects.toThrow("Unexpected server response 500"); + }); +}); + +describe("Api web push", () => { + const pushSubscription = { + endpoint: "https://push.example/abc", + keys: { auth: "AUTH", p256dh: "P256" }, + }; + + it("updateWebPush POSTs endpoint, keys and topics to the web push URL", async () => { + fetchMock.mockResolvedValue({ status: 200 }); + await api.updateWebPush(pushSubscription, ["topicA", "topicB"]); + expect(fetchMock).toHaveBeenCalledWith( + "https://ntfy.sh/v1/webpush", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ endpoint: "https://push.example/abc", auth: "AUTH", p256dh: "P256", topics: ["topicA", "topicB"] }), + }) + ); + }); + + it("deleteWebPush DELETEs just the endpoint", async () => { + fetchMock.mockResolvedValue({ status: 200 }); + await api.deleteWebPush(pushSubscription); + expect(fetchMock).toHaveBeenCalledWith( + "https://ntfy.sh/v1/webpush", + expect.objectContaining({ + method: "DELETE", + body: JSON.stringify({ endpoint: "https://push.example/abc" }), + }) + ); + }); +}); diff --git a/web/src/test/setup.js b/web/src/test/setup.js index 4adf1f28..fa79bac9 100644 --- a/web/src/test/setup.js +++ b/web/src/test/setup.js @@ -1,13 +1,20 @@ /* eslint-env node, es2021 */ -// Minimal window stub so the pure-logic modules import cleanly under the node environment, -// without pulling in jsdom. utils.js -> config.js does `const { config } = window;` at import -// time, and config.js falls back to window.location.origin when base_url is empty. +// Minimal browser-global stubs so the pure-logic and API modules import + run under the node +// environment, without pulling in jsdom. utils.js -> config.js does `const { config } = window;` +// at import time, and config.js falls back to window.location.origin when base_url is empty. +const config = { base_url: "https://ntfy.sh", disallowed_topics: ["app", "account", "settings"] }; + globalThis.window = { location: { origin: "https://ntfy.sh" }, - config: { base_url: "", disallowed_topics: ["app", "account", "settings"] }, + config, atob: globalThis.atob, // urlB64ToUint8Array uses window.atob; Node provides global atob }; +// In the browser, `window` IS the global object, so `window.config` is also reachable as a bare +// `config` identifier -- Api.js/AccountApi.js/UserManager.js/SubscriptionManager.js rely on that. +// Node's `window` is just a plain object, so expose the same object as a real global too. +globalThis.config = config; + // utils.js -> Prefs.js -> db.js -> Session.username() reads localStorage at module load. // Node has no localStorage; an in-memory stand-in is enough for the pure-logic tests. const store = new Map();