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" });
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" }),
};
})();
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",
});
});
});
});