Gateway: add APNs push test pipeline (#20307)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 6a1c442207
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
This commit is contained in:
Mariano
2026-02-18 19:32:42 +00:00
committed by GitHub
parent 1f5cd65d60
commit 99d099aa84
14 changed files with 839 additions and 0 deletions

View File

@@ -0,0 +1,101 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ErrorCodes } from "../protocol/index.js";
import { pushHandlers } from "./push.js";
vi.mock("../../infra/push-apns.js", () => ({
loadApnsRegistration: vi.fn(),
normalizeApnsEnvironment: vi.fn(),
resolveApnsAuthConfigFromEnv: vi.fn(),
sendApnsAlert: vi.fn(),
}));
import {
loadApnsRegistration,
normalizeApnsEnvironment,
resolveApnsAuthConfigFromEnv,
sendApnsAlert,
} from "../../infra/push-apns.js";
type RespondCall = [boolean, unknown?, { code: number; message: string }?];
function createInvokeParams(params: Record<string, unknown>) {
const respond = vi.fn();
return {
respond,
invoke: async () =>
await pushHandlers["push.test"]({
params,
respond: respond as never,
context: {} as never,
client: null,
req: { type: "req", id: "req-1", method: "push.test" },
isWebchatConnect: () => false,
}),
};
}
describe("push.test handler", () => {
beforeEach(() => {
vi.mocked(loadApnsRegistration).mockReset();
vi.mocked(normalizeApnsEnvironment).mockReset();
vi.mocked(resolveApnsAuthConfigFromEnv).mockReset();
vi.mocked(sendApnsAlert).mockReset();
});
it("rejects invalid params", async () => {
const { respond, invoke } = createInvokeParams({ title: "hello" });
await invoke();
const call = respond.mock.calls[0] as RespondCall | undefined;
expect(call?.[0]).toBe(false);
expect(call?.[2]?.code).toBe(ErrorCodes.INVALID_REQUEST);
expect(call?.[2]?.message).toContain("invalid push.test params");
});
it("returns invalid request when node has no APNs registration", async () => {
vi.mocked(loadApnsRegistration).mockResolvedValue(null);
const { respond, invoke } = createInvokeParams({ nodeId: "ios-node-1" });
await invoke();
const call = respond.mock.calls[0] as RespondCall | undefined;
expect(call?.[0]).toBe(false);
expect(call?.[2]?.code).toBe(ErrorCodes.INVALID_REQUEST);
expect(call?.[2]?.message).toContain("has no APNs registration");
});
it("sends push test when registration and auth are available", async () => {
vi.mocked(loadApnsRegistration).mockResolvedValue({
nodeId: "ios-node-1",
token: "abcd",
topic: "ai.openclaw.ios",
environment: "sandbox",
updatedAtMs: 1,
});
vi.mocked(resolveApnsAuthConfigFromEnv).mockResolvedValue({
ok: true,
value: {
teamId: "TEAM123",
keyId: "KEY123",
privateKey: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----",
},
});
vi.mocked(normalizeApnsEnvironment).mockReturnValue(null);
vi.mocked(sendApnsAlert).mockResolvedValue({
ok: true,
status: 200,
tokenSuffix: "1234abcd",
topic: "ai.openclaw.ios",
environment: "sandbox",
});
const { respond, invoke } = createInvokeParams({
nodeId: "ios-node-1",
title: "Wake",
body: "Ping",
});
await invoke();
expect(sendApnsAlert).toHaveBeenCalledTimes(1);
const call = respond.mock.calls[0] as RespondCall | undefined;
expect(call?.[0]).toBe(true);
expect(call?.[1]).toMatchObject({ ok: true, status: 200 });
});
});

View File

@@ -0,0 +1,73 @@
import {
loadApnsRegistration,
normalizeApnsEnvironment,
resolveApnsAuthConfigFromEnv,
sendApnsAlert,
} from "../../infra/push-apns.js";
import { ErrorCodes, errorShape, validatePushTestParams } from "../protocol/index.js";
import { respondInvalidParams, respondUnavailableOnThrow } from "./nodes.helpers.js";
import type { GatewayRequestHandlers } from "./types.js";
function normalizeOptionalString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
export const pushHandlers: GatewayRequestHandlers = {
"push.test": async ({ params, respond }) => {
if (!validatePushTestParams(params)) {
respondInvalidParams({
respond,
method: "push.test",
validator: validatePushTestParams,
});
return;
}
const nodeId = String(params.nodeId ?? "").trim();
if (!nodeId) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "nodeId required"));
return;
}
const title = normalizeOptionalString(params.title) ?? "OpenClaw";
const body = normalizeOptionalString(params.body) ?? `Push test for node ${nodeId}`;
await respondUnavailableOnThrow(respond, async () => {
const registration = await loadApnsRegistration(nodeId);
if (!registration) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`node ${nodeId} has no APNs registration (connect iOS node first)`,
),
);
return;
}
const auth = await resolveApnsAuthConfigFromEnv(process.env);
if (!auth.ok) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, auth.error));
return;
}
const overrideEnvironment = normalizeApnsEnvironment(params.environment);
const result = await sendApnsAlert({
auth: auth.value,
registration: {
...registration,
environment: overrideEnvironment ?? registration.environment,
},
nodeId,
title,
body,
});
respond(true, result, undefined);
});
},
};