241 lines
7.4 KiB
TypeScript
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",
|
|
});
|
|
});
|
|
});
|