mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-26 16:41:49 +00:00
263 lines
8.0 KiB
TypeScript
263 lines
8.0 KiB
TypeScript
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();
|
|
});
|
|
|
|
function createRelayPushParams() {
|
|
return {
|
|
relayConfig: {
|
|
baseUrl: "https://relay.example.com",
|
|
timeoutMs: 1000,
|
|
},
|
|
sendGrant: "send-grant-123",
|
|
relayHandle: "relay-handle-123",
|
|
payload: { aps: { "content-available": 1 } },
|
|
pushType: "background" as const,
|
|
priority: "5" as const,
|
|
gatewayIdentity: relayGatewayIdentity,
|
|
};
|
|
}
|
|
|
|
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(createRelayPushParams());
|
|
|
|
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(createRelayPushParams())).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(createRelayPushParams())).resolves.toEqual({
|
|
ok: false,
|
|
status: 410,
|
|
apnsId: "relay-apns-id",
|
|
reason: "Unregistered",
|
|
environment: "production",
|
|
tokenSuffix: "abcd1234",
|
|
});
|
|
});
|
|
});
|
|
});
|