diff --git a/backend/src/services/__tests__/adminAudit.service.test.ts b/backend/src/services/__tests__/adminAudit.service.test.ts new file mode 100644 index 00000000..466c4ded --- /dev/null +++ b/backend/src/services/__tests__/adminAudit.service.test.ts @@ -0,0 +1,277 @@ +import { PrismaClient } from "@prisma/client"; +import { AdminAuditService } from "../adminAudit.service"; +import { computeAuditEntryHash } from "../../lib/auditChain"; +import { encodeCursor, InvalidCursorError } from "../../lib/cursorPagination"; + +type AuditRow = { + id: number; + action: string; + actorAddress: string; + targetReference: string | null; + note: string | null; + createdAt: Date; + hash: string; + prevHash: string; +}; + +type AuditPrisma = { + adminActionAudit: { findMany: jest.Mock; count: jest.Mock }; +}; + +function makeRow(overrides: Partial = {}): AuditRow { + return { + id: 1, + action: "TREASURY_WITHDRAW", + actorAddress: "GADMIN", + targetReference: "GDEST", + note: "OPS-42", + createdAt: new Date("2026-07-02T00:00:00.000Z"), + hash: "", + prevHash: "", + ...overrides, + }; +} + +function makeChainedRows(): AuditRow[] { + const first = makeRow({ id: 1 }); + first.hash = computeAuditEntryHash("", first); + + const second = makeRow({ + id: 2, + action: "STREAM_TERMINATE", + targetReference: "stream-1", + createdAt: new Date("2026-07-03T00:00:00.000Z"), + prevHash: first.hash, + }); + second.hash = computeAuditEntryHash(first.hash, second); + + return [first, second]; +} + +function makeService(mockPrisma: AuditPrisma): AdminAuditService { + return new AdminAuditService( + mockPrisma as unknown as Pick, + ); +} + +describe("AdminAuditService.verifyChain", () => { + let mockPrisma: AuditPrisma; + let service: AdminAuditService; + + beforeEach(() => { + mockPrisma = { + adminActionAudit: { + findMany: jest.fn().mockResolvedValue([]), + count: jest.fn().mockResolvedValue(0), + }, + }; + service = makeService(mockPrisma); + }); + + it("returns an empty, valid result when there are no audit rows", async () => { + const result = await service.verifyChain(); + + expect(mockPrisma.adminActionAudit.findMany).toHaveBeenCalledWith({ + orderBy: { id: "asc" }, + }); + expect(result).toEqual({ + valid: true, + entriesChecked: 0, + brokenAtId: undefined, + checkpoint: null, + }); + }); + + it("skips legacy rows that were written before hash chaining", async () => { + mockPrisma.adminActionAudit.findMany.mockResolvedValue([ + makeRow({ id: 1, hash: "", prevHash: "" }), + makeRow({ id: 2, hash: "", prevHash: "" }), + ]); + + const result = await service.verifyChain(); + + expect(result).toEqual({ + valid: true, + entriesChecked: 0, + brokenAtId: undefined, + checkpoint: null, + }); + }); + + it("accepts a valid multi-entry chain and exposes the latest hash as checkpoint", async () => { + const rows = makeChainedRows(); + mockPrisma.adminActionAudit.findMany.mockResolvedValue(rows); + + const result = await service.verifyChain(); + + expect(result.valid).toBe(true); + expect(result.entriesChecked).toBe(2); + expect(result.brokenAtId).toBeUndefined(); + expect(result.checkpoint).toBe(rows[1].hash); + }); + + it("continues verifying chained rows that follow legacy rows", async () => { + const rows = makeChainedRows(); + mockPrisma.adminActionAudit.findMany.mockResolvedValue([ + makeRow({ id: 0, hash: "", prevHash: "" }), + rows[0], + rows[1], + ]); + + const result = await service.verifyChain(); + + expect(result.valid).toBe(true); + expect(result.entriesChecked).toBe(2); + expect(result.checkpoint).toBe(rows[1].hash); + }); + + it("detects a tampered prevHash and reports the first broken id", async () => { + const rows = makeChainedRows(); + rows[1].prevHash = "tampered-prev-hash"; + mockPrisma.adminActionAudit.findMany.mockResolvedValue(rows); + + const result = await service.verifyChain(); + + expect(result.valid).toBe(false); + expect(result.brokenAtId).toBe(2); + expect(result.entriesChecked).toBe(2); + }); + + it("detects tampered row data even when prevHash is unchanged", async () => { + const rows = makeChainedRows(); + rows[1].note = "edited-after-write"; + mockPrisma.adminActionAudit.findMany.mockResolvedValue(rows); + + const result = await service.verifyChain(); + + expect(result.valid).toBe(false); + expect(result.brokenAtId).toBe(2); + }); + + it("reports only the first broken entry when several rows are broken", async () => { + const rows = makeChainedRows(); + rows[0].hash = "broken-first-hash"; + rows[1].prevHash = rows[0].hash; + rows[1].hash = "broken-second-hash"; + mockPrisma.adminActionAudit.findMany.mockResolvedValue(rows); + + const result = await service.verifyChain(); + + expect(result.valid).toBe(false); + expect(result.brokenAtId).toBe(1); + expect(result.entriesChecked).toBe(2); + }); + + it("propagates database failures from findMany", async () => { + mockPrisma.adminActionAudit.findMany.mockRejectedValue(new Error("db down")); + + await expect(service.verifyChain()).rejects.toThrow("db down"); + }); +}); + +describe("AdminAuditService.list", () => { + let mockPrisma: AuditPrisma; + let service: AdminAuditService; + + beforeEach(() => { + mockPrisma = { + adminActionAudit: { + findMany: jest.fn().mockResolvedValue([]), + count: jest.fn().mockResolvedValue(0), + }, + }; + service = makeService(mockPrisma); + }); + + it("uses cursor pagination and trims the look-ahead row", async () => { + mockPrisma.adminActionAudit.findMany.mockResolvedValue([ + makeRow({ id: 3 }), + makeRow({ id: 2 }), + makeRow({ id: 1 }), + ]); + + const result = await service.list({ limit: 2 }); + + expect(mockPrisma.adminActionAudit.findMany).toHaveBeenCalledWith({ + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + take: 3, + }); + expect(result.items.map((item) => item.id)).toEqual([3, 2]); + expect(result.pageInfo).toEqual({ + nextCursor: encodeCursor({ id: 2 }), + hasNextPage: true, + limit: 2, + }); + expect(result.pagination).toBeUndefined(); + }); + + it("returns no next cursor when the final page is not full", async () => { + mockPrisma.adminActionAudit.findMany.mockResolvedValue([makeRow({ id: 9 })]); + + const result = await service.list({ limit: 5 }); + + expect(result.items).toHaveLength(1); + expect(result.pageInfo).toEqual({ nextCursor: null, hasNextPage: false, limit: 5 }); + }); + + it("rejects a malformed cursor without querying the database", async () => { + await expect(service.list({ cursor: "not-a-valid-cursor" })).rejects.toBeInstanceOf( + InvalidCursorError, + ); + + expect(mockPrisma.adminActionAudit.findMany).not.toHaveBeenCalled(); + }); + + it("propagates findMany errors in cursor mode", async () => { + mockPrisma.adminActionAudit.findMany.mockRejectedValue(new Error("find failed")); + + await expect(service.list({ limit: 10 })).rejects.toThrow("find failed"); + }); + + it("supports legacy offset pagination with a stable total", async () => { + mockPrisma.adminActionAudit.findMany.mockResolvedValue([makeRow({ id: 10 })]); + mockPrisma.adminActionAudit.count.mockResolvedValue(45); + + const result = await service.list({ page: 2, limit: 10 }); + + expect(mockPrisma.adminActionAudit.findMany).toHaveBeenCalledWith({ + orderBy: { createdAt: "desc" }, + skip: 10, + take: 10, + }); + expect(result.items).toHaveLength(1); + expect(result.pagination).toEqual({ page: 2, limit: 10, total: 45, totalPages: 5 }); + expect(result.pageInfo).toEqual({ nextCursor: null, hasNextPage: true, limit: 10 }); + }); + + it("normalizes invalid legacy page and limit values", async () => { + await service.list({ page: Number.NaN, limit: Number.NaN }); + + expect(mockPrisma.adminActionAudit.findMany).toHaveBeenCalledWith({ + orderBy: { createdAt: "desc" }, + skip: 0, + take: 20, + }); + }); + + it("caps cursor limits at the maximum and floors fractional values", async () => { + await service.list({ limit: 500 }); + expect(mockPrisma.adminActionAudit.findMany).toHaveBeenLastCalledWith({ + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + take: 101, + }); + + await service.list({ limit: 2.9 }); + expect(mockPrisma.adminActionAudit.findMany).toHaveBeenLastCalledWith({ + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + take: 3, + }); + }); + + it("propagates count failures in legacy mode", async () => { + mockPrisma.adminActionAudit.count.mockRejectedValue(new Error("count failed")); + + await expect(service.list({ page: 1, limit: 10 })).rejects.toThrow("count failed"); + expect(mockPrisma.adminActionAudit.findMany).toHaveBeenCalled(); + }); +}); diff --git a/backend/src/services/__tests__/adminNotification.service.test.ts b/backend/src/services/__tests__/adminNotification.service.test.ts new file mode 100644 index 00000000..24a8d023 --- /dev/null +++ b/backend/src/services/__tests__/adminNotification.service.test.ts @@ -0,0 +1,238 @@ +jest.mock("../../middleware/logger", () => ({ + appLogger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +import { appLogger } from "../../middleware/logger"; +import { + AdminNotificationEvents, + AdminNotificationService, + extractErrorInfo, + OperationFailedPayload, + StreamLockedPayload, + StreamTerminatedPayload, + StreamUnlockedPayload, +} from "../adminNotification.service"; + +const mockLogger = appLogger as unknown as { + info: jest.Mock; + error: jest.Mock; +}; + +function makeLockedPayload(): StreamLockedPayload { + return { + streamId: "stream-123", + adminAddress: "GADMIN", + reason: "Maintenance", + timestamp: "2026-07-30T12:00:00.000Z", + }; +} + +function makeUnlockedPayload(): StreamUnlockedPayload { + return { + streamId: "stream-123", + adminAddress: "GADMIN", + reason: null, + timestamp: "2026-07-30T12:01:00.000Z", + }; +} + +function makeTerminatedPayload(): StreamTerminatedPayload { + return { + streamId: "stream-123", + adminAddress: "GADMIN", + reason: "Fraud investigation", + previousStatus: "ACTIVE", + terminatedAt: "2026-07-30T12:02:00.000Z", + unclaimed: "7500", + }; +} + +function makeFailedPayload(): OperationFailedPayload { + return { + streamId: "stream-123", + adminAddress: "GADMIN", + action: "STREAM_LOCK", + error: { message: "Stream not found", code: "NOT_FOUND" }, + timestamp: "2026-07-30T12:03:00.000Z", + }; +} + +describe("AdminNotificationService", () => { + let service: AdminNotificationService; + + beforeEach(() => { + service = new AdminNotificationService(); + jest.clearAllMocks(); + }); + + describe("notifications", () => { + it("emits the stream-locked event with the provided payload", () => { + const listener = jest.fn(); + const payload = makeLockedPayload(); + service.onSuccess(AdminNotificationEvents.STREAM_LOCKED, listener); + + service.notifyStreamLocked(payload); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith(payload); + }); + + it("emits the stream-unlocked event with the provided payload", () => { + const listener = jest.fn(); + const payload = makeUnlockedPayload(); + service.onSuccess(AdminNotificationEvents.STREAM_UNLOCKED, listener); + + service.notifyStreamUnlocked(payload); + + expect(listener).toHaveBeenCalledWith(payload); + }); + + it("emits the stream-terminated event with the provided payload", () => { + const listener = jest.fn(); + const payload = makeTerminatedPayload(); + service.onFailure(AdminNotificationEvents.STREAM_TERMINATED, listener); + + service.notifyStreamTerminated(payload); + + expect(listener).toHaveBeenCalledWith(payload); + }); + + it("emits the operation-failed event with the provided payload", () => { + const listener = jest.fn(); + const payload = makeFailedPayload(); + service.onFailure(AdminNotificationEvents.OPERATION_FAILED, listener); + + service.notifyOperationFailed(payload); + + expect(listener).toHaveBeenCalledWith(payload); + }); + + it("notifies every registered listener", () => { + const first = jest.fn(); + const second = jest.fn(); + service.onSuccess(AdminNotificationEvents.STREAM_LOCKED, first); + service.onSuccess(AdminNotificationEvents.STREAM_LOCKED, second); + + service.notifyStreamLocked(makeLockedPayload()); + + expect(first).toHaveBeenCalledTimes(1); + expect(second).toHaveBeenCalledTimes(1); + }); + + it("does not throw when an event has no listeners beyond the defaults", () => { + expect(() => service.notifyStreamUnlocked(makeUnlockedPayload())).not.toThrow(); + }); + + it("propagates listener errors so callers can fail fast", () => { + service.onSuccess(AdminNotificationEvents.STREAM_LOCKED, () => { + throw new Error("listener boom"); + }); + + expect(() => service.notifyStreamLocked(makeLockedPayload())).toThrow("listener boom"); + }); + }); + + describe("default listeners", () => { + it("logs stream lock/unlock/termination notifications at info level", () => { + service.notifyStreamLocked(makeLockedPayload()); + service.notifyStreamUnlocked(makeUnlockedPayload()); + service.notifyStreamTerminated(makeTerminatedPayload()); + + expect(mockLogger.info).toHaveBeenCalledTimes(3); + expect(mockLogger.info).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ event: AdminNotificationEvents.STREAM_LOCKED }), + "Admin notification: stream locked", + ); + expect(mockLogger.info).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ event: AdminNotificationEvents.STREAM_UNLOCKED }), + "Admin notification: stream unlocked", + ); + expect(mockLogger.info).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ event: AdminNotificationEvents.STREAM_TERMINATED }), + "Admin notification: stream terminated", + ); + }); + + it("logs operation failures at error level", () => { + const payload = makeFailedPayload(); + + service.notifyOperationFailed(payload); + + expect(mockLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ event: AdminNotificationEvents.OPERATION_FAILED }), + "Admin notification: operation failed", + ); + }); + + it("removes custom listeners but restores the default listeners", () => { + const listener = jest.fn(); + service.onSuccess(AdminNotificationEvents.STREAM_LOCKED, listener); + + service.removeAllListeners(); + service.notifyStreamLocked(makeLockedPayload()); + + expect(listener).not.toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledTimes(1); + }); + }); +}); + +describe("extractErrorInfo", () => { + it("extracts message, code, and details from an error-like object", () => { + const result = extractErrorInfo({ + message: "Stream not found", + code: "NOT_FOUND", + details: { streamId: "s1" }, + }); + + expect(result).toEqual({ + message: "Stream not found", + code: "NOT_FOUND", + details: { streamId: "s1" }, + }); + }); + + it("extracts the message from an Error instance without optional fields", () => { + const result = extractErrorInfo(new Error("Something went wrong")); + + expect(result).toEqual({ message: "Something went wrong", code: undefined, details: undefined }); + }); + + it("omits non-string codes and non-object details", () => { + const result = extractErrorInfo({ + message: "bad request", + code: 400, + details: "not-an-object", + }); + + expect(result).toEqual({ message: "bad request", code: undefined, details: undefined }); + }); + + it("falls back to stringifying objects without a string message", () => { + expect(extractErrorInfo({ message: 42 })).toEqual({ + message: "[object Object]", + code: undefined, + details: undefined, + }); + expect(extractErrorInfo({ code: "E_FAIL" })).toEqual({ + message: "[object Object]", + code: "E_FAIL", + details: undefined, + }); + }); + + it("stringifies primitive errors", () => { + expect(extractErrorInfo("raw error string").message).toBe("raw error string"); + expect(extractErrorInfo(42).message).toBe("42"); + expect(extractErrorInfo(null).message).toBe("null"); + expect(extractErrorInfo(undefined).message).toBe("undefined"); + }); +}); diff --git a/backend/src/services/__tests__/adminStreams.service.test.ts b/backend/src/services/__tests__/adminStreams.service.test.ts new file mode 100644 index 00000000..c44eb7c4 --- /dev/null +++ b/backend/src/services/__tests__/adminStreams.service.test.ts @@ -0,0 +1,268 @@ +import { PrismaClient, StreamStatus } from "@prisma/client"; +import { AdminStreamsService, vestingStateOf } from "../adminStreams.service"; + +type StreamRecord = { + streamId: string; + recipient: string; + totalVested: string; + claimed: string; + unclaimed: string; + pendingClawback: string; + status: StreamStatus; + adminTags: string[]; + createdAt: Date; + updatedAt: Date; +}; + +type StreamPrisma = { + stream: { findMany: jest.Mock; findUnique: jest.Mock }; +}; + +function makeStream(overrides: Partial = {}): StreamRecord { + return { + streamId: "stream-1", + recipient: "GRECIPIENT", + totalVested: "10000", + claimed: "0", + unclaimed: "10000", + pendingClawback: "0", + status: StreamStatus.ACTIVE, + adminTags: [], + createdAt: new Date("2026-07-01T00:00:00.000Z"), + updatedAt: new Date("2026-07-02T00:00:00.000Z"), + ...overrides, + }; +} + +function makeService(mockPrisma: StreamPrisma): AdminStreamsService { + return new AdminStreamsService(mockPrisma as unknown as Pick); +} + +describe("vestingStateOf", () => { + it("returns not_started when nothing has been claimed", () => { + expect(vestingStateOf({ claimed: "0", totalVested: "10000" })).toBe("not_started"); + }); + + it("treats blank amounts as zero", () => { + expect(vestingStateOf({ claimed: "", totalVested: "" })).toBe("not_started"); + }); + + it("returns vesting for a partial claim", () => { + expect(vestingStateOf({ claimed: "2500", totalVested: "10000" })).toBe("vesting"); + }); + + it("returns fully_vested when claimed equals totalVested", () => { + expect(vestingStateOf({ claimed: "10000", totalVested: "10000" })).toBe("fully_vested"); + }); + + it("returns fully_vested when claimed exceeds totalVested", () => { + expect(vestingStateOf({ claimed: "12000", totalVested: "10000" })).toBe("fully_vested"); + }); + + it("returns vesting when totalVested is zero but a claim exists", () => { + expect(vestingStateOf({ claimed: "1", totalVested: "0" })).toBe("vesting"); + }); + + it("compares amounts as arbitrary-precision integers", () => { + expect( + vestingStateOf({ claimed: "9007199254740993", totalVested: "9007199254740992" }), + ).toBe("fully_vested"); + }); + + it("throws when an amount is not a valid integer string", () => { + expect(() => vestingStateOf({ claimed: "1.5", totalVested: "2" })).toThrow(); + expect(() => vestingStateOf({ claimed: "1", totalVested: "not-a-number" })).toThrow(); + }); +}); + +describe("AdminStreamsService.list", () => { + let mockPrisma: StreamPrisma; + let service: AdminStreamsService; + + beforeEach(() => { + mockPrisma = { + stream: { + findMany: jest.fn().mockResolvedValue([]), + findUnique: jest.fn().mockResolvedValue(null), + }, + }; + service = makeService(mockPrisma); + }); + + it("maps streams to summaries and computes vestingState", async () => { + mockPrisma.stream.findMany.mockResolvedValue([ + makeStream({ streamId: "s1", claimed: "10000", totalVested: "10000" }), + ]); + + const result = await service.list(); + + expect(mockPrisma.stream.findMany).toHaveBeenCalledWith({ + where: {}, + orderBy: { createdAt: "desc" }, + }); + expect(result.items).toEqual([ + { + streamId: "s1", + recipient: "GRECIPIENT", + status: StreamStatus.ACTIVE, + vestingState: "fully_vested", + totalVested: "10000", + claimed: "10000", + unclaimed: "10000", + pendingClawback: "0", + adminTags: [], + createdAt: new Date("2026-07-01T00:00:00.000Z"), + updatedAt: new Date("2026-07-02T00:00:00.000Z"), + }, + ]); + expect(result.pagination).toEqual({ page: 1, limit: 20, total: 1, totalPages: 1 }); + }); + + it("returns an empty page with one total page when no streams exist", async () => { + const result = await service.list(); + + expect(result.items).toEqual([]); + expect(result.pagination).toEqual({ page: 1, limit: 20, total: 0, totalPages: 1 }); + }); + + it("filters by status at the database level", async () => { + await service.list({ status: StreamStatus.SUSPENDED }); + + expect(mockPrisma.stream.findMany).toHaveBeenCalledWith({ + where: { status: StreamStatus.SUSPENDED }, + orderBy: { createdAt: "desc" }, + }); + }); + + it("filters by adminTag at the database level", async () => { + await service.list({ adminTag: "high-value" }); + + expect(mockPrisma.stream.findMany).toHaveBeenCalledWith({ + where: { adminTags: { has: "high-value" } }, + orderBy: { createdAt: "desc" }, + }); + }); + + it("combines status and adminTag filters", async () => { + await service.list({ status: StreamStatus.ACTIVE, adminTag: "high-value" }); + + expect(mockPrisma.stream.findMany).toHaveBeenCalledWith({ + where: { status: StreamStatus.ACTIVE, adminTags: { has: "high-value" } }, + orderBy: { createdAt: "desc" }, + }); + }); + + it("filters by derived vestingState in memory", async () => { + mockPrisma.stream.findMany.mockResolvedValue([ + makeStream({ streamId: "not-started", claimed: "0" }), + makeStream({ streamId: "vesting", claimed: "2500" }), + makeStream({ streamId: "fully-vested", claimed: "10000" }), + ]); + + const result = await service.list({ vestingState: "vesting" }); + + expect(result.items.map((item) => item.streamId)).toEqual(["vesting"]); + expect(result.pagination.total).toBe(1); + }); + + it("returns an empty result when no stream matches the vestingState", async () => { + mockPrisma.stream.findMany.mockResolvedValue([makeStream({ claimed: "0" })]); + + const result = await service.list({ vestingState: "fully_vested" }); + + expect(result.items).toEqual([]); + expect(result.pagination).toEqual({ page: 1, limit: 20, total: 0, totalPages: 1 }); + }); + + it("paginates the filtered result set", async () => { + mockPrisma.stream.findMany.mockResolvedValue([ + makeStream({ streamId: "s1" }), + makeStream({ streamId: "s2" }), + makeStream({ streamId: "s3" }), + ]); + + const result = await service.list({ page: 2, limit: 2 }); + + expect(result.items.map((item) => item.streamId)).toEqual(["s3"]); + expect(result.pagination).toEqual({ page: 2, limit: 2, total: 3, totalPages: 2 }); + }); + + it("returns an empty page beyond the last page", async () => { + mockPrisma.stream.findMany.mockResolvedValue([makeStream({ streamId: "s1" })]); + + const result = await service.list({ page: 5, limit: 10 }); + + expect(result.items).toEqual([]); + expect(result.pagination).toEqual({ page: 5, limit: 10, total: 1, totalPages: 1 }); + }); + + it("caps the limit at 100 and floors fractional pagination values", async () => { + mockPrisma.stream.findMany.mockResolvedValue( + Array.from({ length: 150 }, (_, index) => makeStream({ streamId: `s${index}` })), + ); + + const capped = await service.list({ page: 1, limit: 500 }); + expect(capped.pagination.limit).toBe(100); + expect(capped.items).toHaveLength(100); + + const floored = await service.list({ page: 2.9, limit: 2.9 }); + expect(floored.pagination).toEqual({ page: 2, limit: 2, total: 150, totalPages: 75 }); + expect(floored.items.map((item) => item.streamId)).toEqual(["s2", "s3"]); + }); + + it("defaults invalid or non-finite page and limit values", async () => { + const result = await service.list({ + page: Number.NaN, + limit: Number.POSITIVE_INFINITY, + }); + + expect(result.pagination).toEqual({ page: 1, limit: 20, total: 0, totalPages: 1 }); + }); + + it("defaults zero and negative page and limit values", async () => { + const result = await service.list({ page: 0, limit: -5 }); + + expect(result.pagination).toEqual({ page: 1, limit: 20, total: 0, totalPages: 1 }); + }); + + it("propagates database failures from findMany", async () => { + mockPrisma.stream.findMany.mockRejectedValue(new Error("db down")); + + await expect(service.list()).rejects.toThrow("db down"); + }); +}); + +describe("AdminStreamsService.getByStreamId", () => { + let mockPrisma: StreamPrisma; + let service: AdminStreamsService; + + beforeEach(() => { + mockPrisma = { + stream: { + findMany: jest.fn(), + findUnique: jest.fn().mockResolvedValue(null), + }, + }; + service = makeService(mockPrisma); + }); + + it("returns the raw stream record for a known id", async () => { + const stream = makeStream({ streamId: "s1" }); + mockPrisma.stream.findUnique.mockResolvedValue(stream); + + const result = await service.getByStreamId("s1"); + + expect(mockPrisma.stream.findUnique).toHaveBeenCalledWith({ where: { streamId: "s1" } }); + expect(result).toEqual(stream); + }); + + it("returns null for an unknown id", async () => { + expect(await service.getByStreamId("missing")).toBeNull(); + }); + + it("propagates database failures from findUnique", async () => { + mockPrisma.stream.findUnique.mockRejectedValue(new Error("lookup failed")); + + await expect(service.getByStreamId("s1")).rejects.toThrow("lookup failed"); + }); +}); diff --git a/backend/src/services/__tests__/alert.service.test.ts b/backend/src/services/__tests__/alert.service.test.ts new file mode 100644 index 00000000..2509cc7c --- /dev/null +++ b/backend/src/services/__tests__/alert.service.test.ts @@ -0,0 +1,228 @@ +import crypto from "crypto"; +import { AlertService } from "../alert.service"; +import { ALERT_REGISTRY } from "../../config/alertRegistry"; + +jest.mock("../../middleware/logger", () => ({ + appLogger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +import { appLogger } from "../../middleware/logger"; + +const mockLogger = appLogger as unknown as { + debug: jest.Mock; + info: jest.Mock; + warn: jest.Mock; + error: jest.Mock; +}; + +describe("AlertService", () => { + const fetchMock = jest.fn(); + const originalFetch = global.fetch; + const webhookUrl = "https://alerts.example.com/hook"; + + beforeEach(() => { + jest.clearAllMocks(); + global.fetch = fetchMock as unknown as typeof fetch; + fetchMock.mockResolvedValue({ ok: true, status: 200 }); + }); + + afterEach(() => { + jest.useRealTimers(); + global.fetch = originalFetch; + }); + + describe("isConfigured", () => { + it("returns false when no webhook URL is configured", () => { + expect(new AlertService(undefined, undefined, 1000).isConfigured()).toBe(false); + }); + + it("returns true when a webhook URL is configured", () => { + expect(new AlertService(webhookUrl, undefined, 1000).isConfigured()).toBe(true); + }); + }); + + describe("dispatch", () => { + it("does nothing when the webhook URL is not configured", async () => { + const service = new AlertService(undefined, undefined, 1000); + + await service.dispatch("db_connection_failure", "Database down"); + await service.dispatch("db_connection_failure", "Database down again"); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("posts a signed payload with routing metadata and a timestamp", async () => { + jest.useFakeTimers().setSystemTime(new Date("2026-07-30T12:00:00.000Z")); + const service = new AlertService(webhookUrl, "test-secret", 1000); + + await service.dispatch("redis_connection_failure", "Redis unavailable", { + responseTime: 42, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, options] = fetchMock.mock.calls[0]; + expect(url).toBe(webhookUrl); + expect(options.method).toBe("POST"); + + const body = options.body as string; + const payload = JSON.parse(body); + expect(payload).toEqual({ + type: "redis_connection_failure", + severity: "critical", + routing: ALERT_REGISTRY.redis_connection_failure.routing, + runbookUrl: ALERT_REGISTRY.redis_connection_failure.runbookUrl, + timestamp: "2026-07-30T12:00:00.000Z", + message: "Redis unavailable", + details: { responseTime: 42 }, + }); + expect(options.headers["Content-Type"]).toBe("application/json"); + + const expectedSignature = crypto + .createHmac("sha256", "test-secret") + .update(body) + .digest("hex"); + expect(options.headers["X-Alert-Signature"]).toBe(expectedSignature); + }); + + it("omits the signature header when no secret is configured", async () => { + const service = new AlertService(webhookUrl, undefined, 1000); + + await service.dispatch("cache_unavailable", "Cache down"); + + const [, options] = fetchMock.mock.calls[0]; + expect(options.headers).not.toHaveProperty("X-Alert-Signature"); + }); + + it("defaults details to an empty object", async () => { + const service = new AlertService(webhookUrl, undefined, 1000); + + await service.dispatch("cache_unavailable", "Cache down"); + + const payload = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(payload.details).toEqual({}); + }); + + it("suppresses duplicate alerts of the same type during the cooldown", async () => { + const service = new AlertService(webhookUrl, undefined, 60_000); + + await service.dispatch("cache_unavailable", "Cache down"); + await service.dispatch("cache_unavailable", "Cache down again"); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(mockLogger.debug).toHaveBeenCalledWith( + { type: "cache_unavailable" }, + "Alert suppressed by cooldown", + ); + }); + + it("applies cooldowns independently per alert type", async () => { + const service = new AlertService(webhookUrl, undefined, 60_000); + + await service.dispatch("db_connection_failure", "Database down"); + await service.dispatch("redis_connection_failure", "Redis down"); + await service.dispatch("db_connection_failure", "Database still down"); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("dispatches again once the cooldown has elapsed", async () => { + jest.useFakeTimers().setSystemTime(new Date("2026-07-30T12:00:00.000Z")); + const service = new AlertService(webhookUrl, undefined, 1000); + + await service.dispatch("db_connection_failure", "Database down"); + jest.advanceTimersByTime(1000); + await service.dispatch("db_connection_failure", "Database down again"); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("prefers a per-alert-type dedupe window over the global cooldown", async () => { + const service = new AlertService(webhookUrl, undefined, 0); + + await service.dispatch("db_connection_failure", "Database down"); + await service.dispatch("db_connection_failure", "Database down again"); + expect(fetchMock).toHaveBeenCalledTimes(2); + + await service.dispatch("synthetic_probe_failure", "Probe failed"); + await service.dispatch("synthetic_probe_failure", "Probe failed again"); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it("logs a warning and does not set the cooldown on a non-OK response", async () => { + fetchMock.mockResolvedValueOnce({ ok: false, status: 503 }); + const service = new AlertService(webhookUrl, undefined, 60_000); + + await service.dispatch("db_connection_failure", "Database down"); + await service.dispatch("db_connection_failure", "Database down again"); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(mockLogger.warn).toHaveBeenCalledWith( + { type: "db_connection_failure", statusCode: 503 }, + "Alert dispatch returned non-OK status", + ); + }); + + it("logs and swallows network failures without setting the cooldown", async () => { + fetchMock.mockRejectedValueOnce(new Error("network error")); + const service = new AlertService(webhookUrl, undefined, 60_000); + + await expect( + service.dispatch("db_connection_failure", "Database down"), + ).resolves.toBeUndefined(); + + expect(mockLogger.error).toHaveBeenCalledWith( + { error: expect.any(Error), type: "db_connection_failure" }, + "Failed to dispatch alert", + ); + + await service.dispatch("db_connection_failure", "Database down again"); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("logs successful dispatches and sets the cooldown", async () => { + const service = new AlertService(webhookUrl, undefined, 60_000); + + await service.dispatch("db_connection_failure", "Database down"); + await service.dispatch("db_connection_failure", "Database down again"); + + expect(mockLogger.info).toHaveBeenCalledWith( + { type: "db_connection_failure" }, + "Alert dispatched successfully", + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + }); + + describe("resetCooldown", () => { + it("clears the cooldown for a single alert type", async () => { + const service = new AlertService(webhookUrl, undefined, 60_000); + + await service.dispatch("db_connection_failure", "Database down"); + await service.dispatch("redis_connection_failure", "Redis down"); + service.resetCooldown("db_connection_failure"); + + await service.dispatch("db_connection_failure", "Database down again"); + await service.dispatch("redis_connection_failure", "Redis down again"); + + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it("clears every cooldown when no type is provided", async () => { + const service = new AlertService(webhookUrl, undefined, 60_000); + + await service.dispatch("db_connection_failure", "Database down"); + await service.dispatch("redis_connection_failure", "Redis down"); + service.resetCooldown(); + + await service.dispatch("db_connection_failure", "Database down again"); + await service.dispatch("redis_connection_failure", "Redis down again"); + + expect(fetchMock).toHaveBeenCalledTimes(4); + }); + }); +});