mirror of https://github.com/openclaw/openclaw.git
test: extract apns relay coverage
This commit is contained in:
parent
7709e4a219
commit
60f2aba40d
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue