256 lines
7.4 KiB
TypeScript
256 lines
7.4 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
WEB_PUSH_PROTOCOLS,
|
|
webPushSuccess,
|
|
} from "../../src/contracts/web-push.ts";
|
|
import {
|
|
clickDataFromHint,
|
|
decodeNotificationClickData,
|
|
decodeWebPushHint,
|
|
} from "../../src/adapters/web-push/push-codec.ts";
|
|
import {
|
|
createAssociationNotificationTag,
|
|
createWebPushNotificationRegistry,
|
|
} from "../../src/adapters/web-push/notification-registry.ts";
|
|
import {
|
|
WEB_PUSH_REGISTRATION_OPERATIONS,
|
|
createWebPushRegistrationGateway,
|
|
type WebPushRegistrationExecutor,
|
|
} from "../../src/adapters/web-push/push-registration-gateway.ts";
|
|
|
|
const now = Date.parse("2026-07-28T00:00:00.000Z");
|
|
|
|
function hint(overrides: Readonly<Record<string, unknown>> = {}) {
|
|
return {
|
|
protocol: WEB_PUSH_PROTOCOLS.hint,
|
|
notificationType: "INBOX_ACTIVITY",
|
|
notificationId: "notification_01",
|
|
associationEpoch: "association_01",
|
|
releaseEpoch: "release_01",
|
|
issuedAt: "2026-07-27T23:59:00.000Z",
|
|
expiresAt: "2026-07-28T01:00:00.000Z",
|
|
routeIntent: "OPEN_INBOX",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function encoded(value: unknown): Uint8Array {
|
|
return new TextEncoder().encode(JSON.stringify(value));
|
|
}
|
|
|
|
describe("Web Push contracts", () => {
|
|
it("decodes an exact bounded hint and persists only typed click data", () => {
|
|
const decoded = decodeWebPushHint(encoded(hint()), now);
|
|
expect(decoded).toEqual({ ok: true, value: hint() });
|
|
if (!decoded.ok) throw new Error("expected a decoded hint");
|
|
|
|
const click = clickDataFromHint(decoded.value);
|
|
expect(decodeNotificationClickData(click, now)).toEqual({
|
|
ok: true,
|
|
value: click,
|
|
});
|
|
expect(click).not.toHaveProperty("notificationType");
|
|
expect(click).not.toHaveProperty("issuedAt");
|
|
});
|
|
|
|
it("fails closed for declarative, unknown, expired, and oversized payloads", () => {
|
|
expect(
|
|
decodeWebPushHint(encoded({ web_push: 8030 }), now),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { code: "DECLARATIVE_PUSH_FORBIDDEN" },
|
|
});
|
|
expect(
|
|
decodeWebPushHint(encoded(hint({ unexpected: true })), now),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { code: "CONTRACT_REJECTED" },
|
|
});
|
|
expect(
|
|
decodeWebPushHint(
|
|
encoded(
|
|
hint({
|
|
issuedAt: "2026-07-27T22:00:00.000Z",
|
|
expiresAt: "2026-07-27T23:00:00.000Z",
|
|
}),
|
|
),
|
|
now,
|
|
),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { code: "EXPIRED" },
|
|
});
|
|
expect(
|
|
decodeWebPushHint(new Uint8Array(3 * 1024 + 1), now),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { code: "LIMIT_EXCEEDED" },
|
|
});
|
|
expect(
|
|
decodeWebPushHint(
|
|
encoded(
|
|
hint({
|
|
issuedAt: "2026-07-28T00:06:00.000Z",
|
|
expiresAt: "2026-07-28T01:00:00.000Z",
|
|
}),
|
|
),
|
|
now,
|
|
),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { code: "CONTRACT_REJECTED" },
|
|
});
|
|
expect(
|
|
decodeWebPushHint(
|
|
encoded(
|
|
hint({
|
|
issuedAt: "2026-07-28T00:00:00.000Z",
|
|
expiresAt: "2026-07-29T00:00:00.001Z",
|
|
}),
|
|
),
|
|
now,
|
|
),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { code: "CONTRACT_REJECTED" },
|
|
});
|
|
const duplicateNotificationId = JSON.stringify(hint()).replace(
|
|
'"notificationId":"notification_01"',
|
|
'"notificationId":"notification_old","notificationId":"notification_01"',
|
|
);
|
|
expect(
|
|
decodeWebPushHint(
|
|
new TextEncoder().encode(duplicateNotificationId),
|
|
now,
|
|
),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { code: "CONTRACT_REJECTED" },
|
|
});
|
|
});
|
|
|
|
it("uses an injected closed notification and route registry", async () => {
|
|
const registry = createWebPushNotificationRegistry([
|
|
{
|
|
notificationType: "INBOX_ACTIVITY",
|
|
routeIntent: "OPEN_INBOX",
|
|
title: "새 알림이 있습니다",
|
|
body: "앱을 열어 최신 내용을 확인하세요.",
|
|
path: "/inbox",
|
|
},
|
|
]);
|
|
expect(
|
|
registry.resolve("INBOX_ACTIVITY", "OPEN_INBOX"),
|
|
).toMatchObject({ path: "/inbox" });
|
|
expect(registry.resolve("UNKNOWN", "OPEN_INBOX")).toBeNull();
|
|
expect(registry.routePath("OPEN_INBOX")).toBe("/inbox");
|
|
|
|
const tag = await createAssociationNotificationTag(
|
|
"association_01",
|
|
"INBOX_ACTIVITY",
|
|
async () => new Uint8Array(32).fill(7).buffer,
|
|
);
|
|
expect(tag).toMatch(/^ca-push-v1-[0-9a-f]{24}-inbox_activity$/u);
|
|
expect(tag).not.toContain("association_01");
|
|
});
|
|
|
|
it("binds backend calls to fixed operations and strict response codecs", async () => {
|
|
const calls: Array<Readonly<{ operationId: string; body: unknown }>> = [];
|
|
const executor: WebPushRegistrationExecutor = {
|
|
async execute(input) {
|
|
calls.push(input);
|
|
switch (input.operationId) {
|
|
case WEB_PUSH_REGISTRATION_OPERATIONS.register:
|
|
return webPushSuccess({
|
|
protocol: WEB_PUSH_PROTOCOLS.registration,
|
|
associationEpoch: "association_01",
|
|
sessionBindingEpoch: "session_01",
|
|
});
|
|
case WEB_PUSH_REGISTRATION_OPERATIONS.reconcile:
|
|
return webPushSuccess({
|
|
protocol: WEB_PUSH_PROTOCOLS.reconciliation,
|
|
state: "ABSENT",
|
|
});
|
|
case WEB_PUSH_REGISTRATION_OPERATIONS.revoke:
|
|
return webPushSuccess({
|
|
protocol: WEB_PUSH_PROTOCOLS.revoke,
|
|
state: "ALREADY_GONE",
|
|
});
|
|
}
|
|
},
|
|
};
|
|
const gateway = createWebPushRegistrationGateway(executor);
|
|
const p256dh = new Uint8Array(65);
|
|
p256dh[0] = 4;
|
|
const material = {
|
|
endpoint: "https://push.example.test/subscription/opaque",
|
|
p256dh: Buffer.from(p256dh).toString("base64url"),
|
|
auth: Buffer.from(new Uint8Array(16)).toString("base64url"),
|
|
expirationTime: null,
|
|
};
|
|
const authority = {
|
|
fenceGeneration: "fence_01",
|
|
sessionBindingEpoch: "session_01",
|
|
releaseEpoch: "release_01",
|
|
};
|
|
|
|
expect(
|
|
await gateway.register({
|
|
material,
|
|
authority,
|
|
idempotencyKey: "idempotency_01",
|
|
}),
|
|
).toEqual({
|
|
ok: true,
|
|
value: {
|
|
associationEpoch: "association_01",
|
|
sessionBindingEpoch: "session_01",
|
|
},
|
|
});
|
|
expect(await gateway.reconcile({ material, authority })).toEqual({
|
|
ok: true,
|
|
value: { state: "ABSENT" },
|
|
});
|
|
expect(
|
|
await gateway.revoke({ associationEpoch: "association_01" }),
|
|
).toEqual({
|
|
ok: true,
|
|
value: { state: "ALREADY_GONE" },
|
|
});
|
|
expect(calls.map((call) => call.operationId)).toEqual([
|
|
WEB_PUSH_REGISTRATION_OPERATIONS.register,
|
|
WEB_PUSH_REGISTRATION_OPERATIONS.reconcile,
|
|
WEB_PUSH_REGISTRATION_OPERATIONS.revoke,
|
|
]);
|
|
expect(
|
|
await gateway.register({
|
|
material: {
|
|
...material,
|
|
p256dh: Buffer.from(new Uint8Array(65)).toString(
|
|
"base64url",
|
|
),
|
|
},
|
|
authority,
|
|
idempotencyKey: "idempotency_02",
|
|
}),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { code: "INVALID_INPUT" },
|
|
});
|
|
const throwing = createWebPushRegistrationGateway({
|
|
async execute() {
|
|
throw new Error("native transport detail must not escape");
|
|
},
|
|
});
|
|
expect(
|
|
await throwing.revoke({
|
|
associationEpoch: "association_01",
|
|
}),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { code: "NATIVE_FAILURE" },
|
|
});
|
|
});
|
|
});
|