Files
crowd-funder-for-time-pwa/src/services/notifications/alertAuthorization.test.ts
T

241 lines
7.4 KiB
TypeScript

import type { AlertAuthorizationBatch } from "./alertAuthorizationBatch";
import {
type AlertAuthorizationDependencies,
uploadAlertSearchAuthorization,
} from "./alertAuthorization";
import { alertSearchNotifyTimeUtcFromLocalNineAm } from "./alertSearchNotifyTime";
jest.mock("@/libs/crypto/vc", () => ({
ETHR_DID_PREFIX: "did:ethr:",
}));
jest.mock("./alertAuthorizationBatch", () => ({
ALERT_AUTHORIZATION_BATCH_DAYS: 100,
mintAlertAuthorizationBatch: jest.fn(),
}));
jest.mock("./NotificationDebugConfig", () => ({
getNotificationApiBaseUrl: jest.fn(),
}));
jest.mock("./notificationApiAuth", () => ({
getActiveNotificationDid: jest.fn(),
getNotificationApiHeaders: jest.fn(),
httpAuthErrorMessage: (status: number) => `HTTP ${status}`,
}));
jest.mock("./notificationApiDebugMode", () => ({
shouldBypassNotificationAuth: jest.fn(),
}));
const BATCH_DAYS = 100;
const ACTIVE_DID = `did:ethr:0x${"0".repeat(40)}`;
const TIME_ZONE = "America/Denver";
const BATCH_ID = "batch-fixture";
function createMintedBatch(): AlertAuthorizationBatch {
return {
batchId: BATCH_ID,
jwts: Array.from({ length: BATCH_DAYS }, (_, index) => ({
sequence: index,
day: new Date(Date.UTC(2026, 8, index + 1)).toISOString().slice(0, 10),
nbf: 1_788_220_800 + index * 86_400,
exp: 1_788_307_200 + index * 86_400,
jwt: `delegated-token-${index}`,
})),
};
}
function createResponse(
status: number,
body: Record<string, unknown>,
): Response {
return {
ok: status >= 200 && status < 300,
status,
json: jest.fn().mockResolvedValue(body),
} as unknown as Response;
}
function createDependencies(overrides?: {
did?: string | null;
bypassAuth?: boolean;
timeZone?: string;
response?: Response;
}): {
dependencies: AlertAuthorizationDependencies;
fetchMock: jest.Mock;
mintMock: jest.Mock;
authMock: jest.Mock;
} {
const fetchMock = jest
.fn()
.mockResolvedValue(overrides?.response ?? createResponse(200, {}));
const mintMock = jest.fn().mockResolvedValue(createMintedBatch());
const authMock = jest.fn().mockResolvedValue({
ok: true,
authenticated: true,
headers: {
"Content-Type": "application/json",
Authorization: "Bearer access-token-fixture",
},
});
return {
dependencies: {
getActiveDid: jest
.fn()
.mockResolvedValue(
overrides && "did" in overrides ? overrides.did : ACTIVE_DID,
),
isAuthBypassEnabled: jest
.fn()
.mockReturnValue(overrides?.bypassAuth ?? false),
mintBatch: mintMock,
getTimeZone: () => overrides?.timeZone ?? TIME_ZONE,
getAuthHeaders: authMock,
getBaseUrl: () => "https://notification-backend.invalid",
fetch: fetchMock,
},
fetchMock,
mintMock,
authMock,
};
}
describe("uploadAlertSearchAuthorization", () => {
it("uploads the minted batch with authenticated PUT semantics", async () => {
const { dependencies, fetchMock, mintMock, authMock } =
createDependencies();
const minted = createMintedBatch();
const result = await uploadAlertSearchAuthorization(dependencies);
expect(result).toEqual({
ok: true,
status: 200,
batchId: BATCH_ID,
timezone: TIME_ZONE,
jwtCount: BATCH_DAYS,
firstDay: minted.jwts[0]!.day,
lastDay: minted.jwts[BATCH_DAYS - 1]!.day,
});
expect(mintMock).toHaveBeenCalledWith(ACTIVE_DID);
expect(authMock).toHaveBeenCalledWith(undefined, ACTIVE_DID);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe(
"https://notification-backend.invalid/notifications/alert-authorization",
);
expect(init.method).toBe("PUT");
expect(init.headers).toEqual({
"Content-Type": "application/json",
Authorization: "Bearer access-token-fixture",
});
const expectedNotify = alertSearchNotifyTimeUtcFromLocalNineAm(TIME_ZONE);
expect(JSON.parse(String(init.body))).toEqual({
batchId: BATCH_ID,
notifyHourUtc: expectedNotify.notifyHourUtc,
notifyMinuteUtc: expectedNotify.notifyMinuteUtc,
timezone: TIME_ZONE,
jwts: minted.jwts,
});
});
it("does not log delegated JWT contents", async () => {
const logSpy = jest.spyOn(console, "log").mockImplementation();
const warnSpy = jest.spyOn(console, "warn").mockImplementation();
const errorSpy = jest.spyOn(console, "error").mockImplementation();
const { dependencies } = createDependencies();
await uploadAlertSearchAuthorization(dependencies);
expect(logSpy).not.toHaveBeenCalled();
expect(warnSpy).not.toHaveBeenCalled();
expect(errorSpy).not.toHaveBeenCalled();
logSpy.mockRestore();
warnSpy.mockRestore();
errorSpy.mockRestore();
});
it("rejects unsupported identities before minting or network submission", async () => {
const { dependencies, fetchMock, mintMock, authMock } = createDependencies({
did: "did:peer:unsupported",
});
const result = await uploadAlertSearchAuthorization(dependencies);
expect(result).toEqual({
ok: false,
errorMessage:
"AlertSearch authorization requires an active did:ethr identity.",
});
expect(mintMock).not.toHaveBeenCalled();
expect(authMock).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
});
it("rejects notification auth bypass before minting or submission", async () => {
const { dependencies, fetchMock, mintMock } = createDependencies({
bypassAuth: true,
});
const result = await uploadAlertSearchAuthorization(dependencies);
expect(result.ok).toBe(false);
expect(mintMock).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
});
it("does not submit when the device zone cannot be resolved", async () => {
const { dependencies, fetchMock } = createDependencies({
timeZone: "Not/AZone",
});
const result = await uploadAlertSearchAuthorization(dependencies);
expect(result.ok).toBe(false);
expect(result.ok ? "" : result.errorMessage).toContain("Not/AZone");
expect(fetchMock).not.toHaveBeenCalled();
});
it("surfaces a coded refusal using only its code and message", async () => {
const { dependencies } = createDependencies({
response: createResponse(400, {
success: false,
error: "ALERT_AUTHORIZATION_INVALID_BATCH",
message:
"Delegated notification-JWT batch was rejected. Nothing was stored.",
details: ["jwts[0].jwt failed verification"],
jwt: "must-not-be-returned",
}),
});
const result = await uploadAlertSearchAuthorization(dependencies);
expect(result).toEqual({
ok: false,
status: 400,
errorCode: "ALERT_AUTHORIZATION_INVALID_BATCH",
errorMessage:
"Delegated notification-JWT batch was rejected. Nothing was stored.",
});
expect(JSON.stringify(result)).not.toContain("must-not-be-returned");
});
it("surfaces an uncoded auth refusal without an error code", async () => {
const { dependencies } = createDependencies({
response: createResponse(401, {
success: false,
message: "Unauthorized. See server logs at 2026-09-14T08:00:00.000Z",
}),
});
const result = await uploadAlertSearchAuthorization(dependencies);
expect(result).toEqual({
ok: false,
status: 401,
errorMessage: "Unauthorized. See server logs at 2026-09-14T08:00:00.000Z",
});
});
});