diff --git a/src/infra/push-apns.relay.test.ts b/src/infra/push-apns.relay.test.ts new file mode 100644 index 00000000000..4e8e8054311 --- /dev/null +++ b/src/infra/push-apns.relay.test.ts @@ -0,0 +1,284 @@ +import { generateKeyPairSync } from "node:crypto"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + deriveDeviceIdFromPublicKey, + publicKeyRawBase64UrlFromPem, + verifyDeviceSignature, +} from "./device-identity.js"; +import { resolveApnsRelayConfigFromEnv, sendApnsRelayPush } from "./push-apns.relay.js"; + +const relayGatewayIdentity = (() => { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const publicKeyPem = publicKey.export({ format: "pem", type: "spki" }).toString(); + const publicKeyRaw = publicKeyRawBase64UrlFromPem(publicKeyPem); + const deviceId = deriveDeviceIdFromPublicKey(publicKeyRaw); + if (!deviceId) { + throw new Error("failed to derive test gateway device id"); + } + return { + deviceId, + publicKey: publicKeyRaw, + privateKeyPem: privateKey.export({ format: "pem", type: "pkcs8" }).toString(), + }; +})(); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("push-apns.relay", () => { + describe("resolveApnsRelayConfigFromEnv", () => { + it("returns a missing-config error when no relay base URL is configured", () => { + expect(resolveApnsRelayConfigFromEnv({} as NodeJS.ProcessEnv)).toEqual({ + ok: false, + error: + "APNs relay config missing: set gateway.push.apns.relay.baseUrl or OPENCLAW_APNS_RELAY_BASE_URL", + }); + }); + + it("lets env overrides win and clamps tiny timeout values", () => { + const resolved = resolveApnsRelayConfigFromEnv( + { + OPENCLAW_APNS_RELAY_BASE_URL: " https://relay-override.example.com/base/ ", + OPENCLAW_APNS_RELAY_TIMEOUT_MS: "999", + } as NodeJS.ProcessEnv, + { + push: { + apns: { + relay: { + baseUrl: "https://relay.example.com", + timeoutMs: 2500, + }, + }, + }, + }, + ); + + expect(resolved).toMatchObject({ + ok: true, + value: { + baseUrl: "https://relay-override.example.com/base", + timeoutMs: 1000, + }, + }); + }); + + it("allows loopback http URLs for alternate truthy env values", () => { + const resolved = resolveApnsRelayConfigFromEnv({ + OPENCLAW_APNS_RELAY_BASE_URL: "http://[::1]:8787", + OPENCLAW_APNS_RELAY_ALLOW_HTTP: "yes", + OPENCLAW_APNS_RELAY_TIMEOUT_MS: "nope", + } as NodeJS.ProcessEnv); + + expect(resolved).toMatchObject({ + ok: true, + value: { + baseUrl: "http://[::1]:8787", + timeoutMs: 10_000, + }, + }); + }); + + it.each([ + { + name: "unsupported protocol", + env: { OPENCLAW_APNS_RELAY_BASE_URL: "ftp://relay.example.com" }, + expected: "unsupported protocol", + }, + { + name: "http non-loopback host", + env: { + OPENCLAW_APNS_RELAY_BASE_URL: "http://relay.example.com", + OPENCLAW_APNS_RELAY_ALLOW_HTTP: "true", + }, + expected: "loopback hosts", + }, + { + name: "query string", + env: { OPENCLAW_APNS_RELAY_BASE_URL: "https://relay.example.com/path?debug=1" }, + expected: "query and fragment are not allowed", + }, + { + name: "userinfo", + env: { OPENCLAW_APNS_RELAY_BASE_URL: "https://user:pass@relay.example.com/path" }, + expected: "userinfo is not allowed", + }, + ])("rejects invalid relay URL: $name", ({ env, expected }) => { + const resolved = resolveApnsRelayConfigFromEnv(env as NodeJS.ProcessEnv); + expect(resolved.ok).toBe(false); + if (!resolved.ok) { + expect(resolved.error).toContain(expected); + } + }); + }); + + describe("sendApnsRelayPush", () => { + it("signs relay payloads and forwards the request through the injected sender", async () => { + vi.spyOn(Date, "now").mockReturnValue(123_456_789); + const sender = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + apnsId: "relay-apns-id", + environment: "production", + tokenSuffix: "abcd1234", + }); + + const result = await sendApnsRelayPush({ + relayConfig: { + baseUrl: "https://relay.example.com", + timeoutMs: 1000, + }, + sendGrant: "send-grant-123", + relayHandle: "relay-handle-123", + payload: { aps: { alert: { title: "Wake", body: "Ping" } } }, + pushType: "alert", + priority: "10", + gatewayIdentity: relayGatewayIdentity, + requestSender: sender, + }); + + expect(sender).toHaveBeenCalledTimes(1); + const sent = sender.mock.calls[0]?.[0]; + expect(sent).toMatchObject({ + relayConfig: { + baseUrl: "https://relay.example.com", + timeoutMs: 1000, + }, + sendGrant: "send-grant-123", + relayHandle: "relay-handle-123", + gatewayDeviceId: relayGatewayIdentity.deviceId, + signedAtMs: 123_456_789, + pushType: "alert", + priority: "10", + payload: { aps: { alert: { title: "Wake", body: "Ping" } } }, + }); + expect(sent?.bodyJson).toBe( + JSON.stringify({ + relayHandle: "relay-handle-123", + pushType: "alert", + priority: 10, + payload: { aps: { alert: { title: "Wake", body: "Ping" } } }, + }), + ); + expect( + verifyDeviceSignature( + relayGatewayIdentity.publicKey, + [ + "openclaw-relay-send-v1", + sent?.gatewayDeviceId, + String(sent?.signedAtMs), + sent?.bodyJson, + ].join("\n"), + sent?.signature ?? "", + ), + ).toBe(true); + expect(result).toMatchObject({ + ok: true, + status: 200, + apnsId: "relay-apns-id", + environment: "production", + tokenSuffix: "abcd1234", + }); + }); + + it("does not follow relay redirects", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 302, + json: vi.fn().mockRejectedValue(new Error("no body")), + }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const result = await sendApnsRelayPush({ + relayConfig: { + baseUrl: "https://relay.example.com", + timeoutMs: 1000, + }, + sendGrant: "send-grant-123", + relayHandle: "relay-handle-123", + payload: { aps: { "content-available": 1 } }, + pushType: "background", + priority: "5", + gatewayIdentity: relayGatewayIdentity, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ redirect: "manual" }); + expect(result).toMatchObject({ + ok: false, + status: 302, + reason: "RelayRedirectNotAllowed", + environment: "production", + }); + }); + + it("falls back to fetch status when the relay body is not JSON", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 202, + json: vi.fn().mockRejectedValue(new Error("bad json")), + }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + await expect( + sendApnsRelayPush({ + relayConfig: { + baseUrl: "https://relay.example.com", + timeoutMs: 1000, + }, + sendGrant: "send-grant-123", + relayHandle: "relay-handle-123", + payload: { aps: { "content-available": 1 } }, + pushType: "background", + priority: "5", + gatewayIdentity: relayGatewayIdentity, + }), + ).resolves.toEqual({ + ok: true, + status: 202, + apnsId: undefined, + reason: undefined, + environment: "production", + tokenSuffix: undefined, + }); + }); + + it("normalizes relay JSON response fields", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 202, + json: vi.fn().mockResolvedValue({ + ok: false, + status: 410, + apnsId: " relay-apns-id ", + reason: " Unregistered ", + tokenSuffix: " abcd1234 ", + }), + }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + await expect( + sendApnsRelayPush({ + relayConfig: { + baseUrl: "https://relay.example.com", + timeoutMs: 1000, + }, + sendGrant: "send-grant-123", + relayHandle: "relay-handle-123", + payload: { aps: { "content-available": 1 } }, + pushType: "background", + priority: "5", + gatewayIdentity: relayGatewayIdentity, + }), + ).resolves.toEqual({ + ok: false, + status: 410, + apnsId: "relay-apns-id", + reason: "Unregistered", + environment: "production", + tokenSuffix: "abcd1234", + }); + }); + }); +}); diff --git a/src/infra/push-apns.test.ts b/src/infra/push-apns.test.ts index a2c616e81b4..5abaa4556ea 100644 --- a/src/infra/push-apns.test.ts +++ b/src/infra/push-apns.test.ts @@ -16,13 +16,11 @@ import { registerApnsRegistration, registerApnsToken, resolveApnsAuthConfigFromEnv, - resolveApnsRelayConfigFromEnv, sendApnsAlert, sendApnsBackgroundWake, shouldClearStoredApnsRegistration, shouldInvalidateApnsRegistration, } from "./push-apns.js"; -import { sendApnsRelayPush } from "./push-apns.relay.js"; const tempDirs: string[] = []; const testAuthPrivateKey = generateKeyPairSync("ec", { namedCurve: "prime256v1" }) @@ -331,141 +329,6 @@ describe("push APNs env config", () => { } expect(resolved.error).toContain("OPENCLAW_APNS_TEAM_ID"); }); - - it("resolves APNs relay config from env", () => { - const resolved = resolveApnsRelayConfigFromEnv({ - OPENCLAW_APNS_RELAY_BASE_URL: "https://relay.example.com", - OPENCLAW_APNS_RELAY_TIMEOUT_MS: "2500", - } as NodeJS.ProcessEnv); - expect(resolved).toMatchObject({ - ok: true, - value: { - baseUrl: "https://relay.example.com", - timeoutMs: 2500, - }, - }); - }); - - it("resolves APNs relay config from gateway config", () => { - const resolved = resolveApnsRelayConfigFromEnv({} as NodeJS.ProcessEnv, { - push: { - apns: { - relay: { - baseUrl: "https://relay.example.com/base/", - timeoutMs: 2500, - }, - }, - }, - }); - expect(resolved).toMatchObject({ - ok: true, - value: { - baseUrl: "https://relay.example.com/base", - timeoutMs: 2500, - }, - }); - }); - - it("lets relay env overrides win over gateway config", () => { - const resolved = resolveApnsRelayConfigFromEnv( - { - OPENCLAW_APNS_RELAY_BASE_URL: "https://relay-override.example.com", - OPENCLAW_APNS_RELAY_TIMEOUT_MS: "3000", - } as NodeJS.ProcessEnv, - { - push: { - apns: { - relay: { - baseUrl: "https://relay.example.com", - timeoutMs: 2500, - }, - }, - }, - }, - ); - expect(resolved).toMatchObject({ - ok: true, - value: { - baseUrl: "https://relay-override.example.com", - timeoutMs: 3000, - }, - }); - }); - - it("rejects insecure APNs relay http URLs by default", () => { - const resolved = resolveApnsRelayConfigFromEnv({ - OPENCLAW_APNS_RELAY_BASE_URL: "http://relay.example.com", - } as NodeJS.ProcessEnv); - expect(resolved).toMatchObject({ - ok: false, - }); - if (resolved.ok) { - return; - } - expect(resolved.error).toContain("OPENCLAW_APNS_RELAY_ALLOW_HTTP=true"); - }); - - it("allows APNs relay http URLs only when explicitly enabled", () => { - const resolved = resolveApnsRelayConfigFromEnv({ - OPENCLAW_APNS_RELAY_BASE_URL: "http://127.0.0.1:8787", - OPENCLAW_APNS_RELAY_ALLOW_HTTP: "true", - } as NodeJS.ProcessEnv); - expect(resolved).toMatchObject({ - ok: true, - value: { - baseUrl: "http://127.0.0.1:8787", - timeoutMs: 10_000, - }, - }); - }); - - it("rejects http relay URLs for non-loopback hosts even when explicitly enabled", () => { - const resolved = resolveApnsRelayConfigFromEnv({ - OPENCLAW_APNS_RELAY_BASE_URL: "http://relay.example.com", - OPENCLAW_APNS_RELAY_ALLOW_HTTP: "true", - } as NodeJS.ProcessEnv); - expect(resolved).toMatchObject({ - ok: false, - }); - if (resolved.ok) { - return; - } - expect(resolved.error).toContain("loopback hosts"); - }); - - it("rejects APNs relay URLs with query, fragment, or userinfo components", () => { - const withQuery = resolveApnsRelayConfigFromEnv({ - OPENCLAW_APNS_RELAY_BASE_URL: "https://relay.example.com/path?debug=1", - } as NodeJS.ProcessEnv); - expect(withQuery.ok).toBe(false); - if (!withQuery.ok) { - expect(withQuery.error).toContain("query and fragment are not allowed"); - } - - const withUserinfo = resolveApnsRelayConfigFromEnv({ - OPENCLAW_APNS_RELAY_BASE_URL: "https://user:pass@relay.example.com/path", - } as NodeJS.ProcessEnv); - expect(withUserinfo.ok).toBe(false); - if (!withUserinfo.ok) { - expect(withUserinfo.error).toContain("userinfo is not allowed"); - } - }); - - it("reports the config key name for invalid gateway relay URLs", () => { - const resolved = resolveApnsRelayConfigFromEnv({} as NodeJS.ProcessEnv, { - push: { - apns: { - relay: { - baseUrl: "https://relay.example.com/path?debug=1", - }, - }, - }, - }); - expect(resolved.ok).toBe(false); - if (!resolved.ok) { - expect(resolved.error).toContain("gateway.push.apns.relay.baseUrl"); - } - }); }); describe("push APNs send semantics", () => { @@ -645,37 +508,6 @@ describe("push APNs send semantics", () => { }); }); - it("does not follow relay redirects", async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: false, - status: 302, - json: vi.fn().mockRejectedValue(new Error("no body")), - }); - vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); - - const result = await sendApnsRelayPush({ - relayConfig: { - baseUrl: "https://relay.example.com", - timeoutMs: 1000, - }, - sendGrant: "send-grant-123", - relayHandle: "relay-handle-123", - payload: { aps: { "content-available": 1 } }, - pushType: "background", - priority: "5", - gatewayIdentity: relayGatewayIdentity, - }); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ redirect: "manual" }); - expect(result).toMatchObject({ - ok: false, - status: 302, - reason: "RelayRedirectNotAllowed", - environment: "production", - }); - }); - it("flags invalid device responses for registration invalidation", () => { expect(shouldInvalidateApnsRegistration({ status: 400, reason: "BadDeviceToken" })).toBe(true); expect(shouldInvalidateApnsRegistration({ status: 410, reason: "Unregistered" })).toBe(true);