diff --git a/src/modules/admin/webhook.routes.ts b/src/modules/admin/webhook.routes.ts index 1ed330f..d1ee2ba 100644 --- a/src/modules/admin/webhook.routes.ts +++ b/src/modules/admin/webhook.routes.ts @@ -1,6 +1,6 @@ import type { FastifyInstance, FastifySchema } from "fastify"; import { webhookController } from "./webhook.controller.js"; -import { adminGuard } from "../../middleware/auth.js"; +import { authGuard, adminGuard } from "../../middleware/auth.js"; import { validate } from "../../middleware/validation.js"; import { createWebhookSchema, @@ -15,7 +15,13 @@ const idParamSchema = z.object({ }); export async function webhookRoutes(app: FastifyInstance): Promise { - app.addHook("onRequest", adminGuard); + // adminGuard reads request.authUser, which only authGuard sets. Every + // other admin router (admin-users, dashboard) registers both; this one + // registered only adminGuard, so request.authUser was always undefined + // and every request here (admin or not) 500'd on `authUser.isAdmin` + // instead of cleanly rejecting with 401/403 (#615). + app.addHook("onRequest", authGuard); + app.addHook("preHandler", adminGuard); app.post<{ Body: import("./webhook.types.js").CreateWebhookBody }>( "/", diff --git a/tests/e2e/admin-dashboard.test.ts b/tests/e2e/admin-dashboard.test.ts new file mode 100644 index 0000000..2c1612f --- /dev/null +++ b/tests/e2e/admin-dashboard.test.ts @@ -0,0 +1,227 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import type { FastifyInstance } from "fastify"; +import { eq } from "drizzle-orm"; +import { buildApp } from "../../src/server.js"; +import { db } from "../../src/config/database.js"; +import { users } from "../../src/database/schema.js"; +import { redis } from "../../src/config/redis.js"; +import { cacheKey } from "../../src/cache/index.js"; + +describe("Admin Dashboard API (#367, #618)", () => { + let app: FastifyInstance; + + const adminUserId = "d1a2d3e4-1111-4ef8-bb6d-6bb9bd380b99"; + const adminStellarAddress = + "GDASHADMIN0000000000000000000000000000000000000000000000"; + const nonAdminUserId = "d1a2d3e4-2222-4ef8-bb6d-6bb9bd380b98"; + const nonAdminStellarAddress = + "GDASHNONADMIN000000000000000000000000000000000000000000"; + + const dashboardCacheKey = cacheKey("admin", "dashboard-stats"); + + beforeAll(async () => { + app = await buildApp(); + await app.ready(); + + await db + .insert(users) + .values({ + id: adminUserId, + stellarAddress: adminStellarAddress, + displayName: "Dashboard Admin", + isAdmin: true, + }) + .onConflictDoNothing(); + + await db + .insert(users) + .values({ + id: nonAdminUserId, + stellarAddress: nonAdminStellarAddress, + displayName: "Dashboard Non-Admin", + isAdmin: false, + }) + .onConflictDoNothing(); + }); + + afterAll(async () => { + await db.delete(users).where(eq(users.id, adminUserId)); + await db.delete(users).where(eq(users.id, nonAdminUserId)); + await redis.del(dashboardCacheKey); + await app.close(); + }); + + const adminToken = () => + app.jwt.sign({ sub: adminUserId, stellarAddress: adminStellarAddress }); + + const nonAdminToken = () => + app.jwt.sign({ + sub: nonAdminUserId, + stellarAddress: nonAdminStellarAddress, + }); + + describe("GET /api/v1/admin/dashboard", () => { + it("should reject unauthenticated requests", async () => { + const response = await app.inject({ + method: "GET", + url: "/api/v1/admin/dashboard", + }); + + expect(response.statusCode).toBe(401); + const body = JSON.parse(response.payload); + expect(body.error).toBe("UNAUTHORIZED"); + }); + + it("should reject non-admin users with 403", async () => { + const response = await app.inject({ + method: "GET", + url: "/api/v1/admin/dashboard", + headers: { authorization: `Bearer ${nonAdminToken()}` }, + }); + + expect(response.statusCode).toBe(403); + const body = JSON.parse(response.payload); + expect(body.error).toBe("FORBIDDEN"); + }); + + it("should return the full stats shape for admins", async () => { + await redis.del(dashboardCacheKey); + + const response = await app.inject({ + method: "GET", + url: "/api/v1/admin/dashboard", + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.success).toBe(true); + + const stats = body.data; + expect(typeof stats.totalUsers).toBe("number"); + expect(typeof stats.newUsersToday).toBe("number"); + expect(typeof stats.newUsersThisWeek).toBe("number"); + expect(typeof stats.newUsersThisMonth).toBe("number"); + expect(typeof stats.totalEnrollments).toBe("number"); + expect(typeof stats.quizCompletionRate).toBe("number"); + expect(typeof stats.totalCredentials).toBe("number"); + expect(typeof stats.totalRewardsClaimed).toBe("number"); + expect(typeof stats.generatedAt).toBe("string"); + + // The two seed users above must be reflected in the admin-visible + // total, since the count query filters only on deletedAt. + expect(stats.totalUsers).toBeGreaterThanOrEqual(2); + }); + + it("should include week-over-week trend metrics with current/previous/changePercent", async () => { + await redis.del(dashboardCacheKey); + + const response = await app.inject({ + method: "GET", + url: "/api/v1/admin/dashboard", + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + const { trends } = JSON.parse(response.payload).data; + + for (const metric of [trends.newUsers, trends.enrollments]) { + expect(typeof metric.current).toBe("number"); + expect(typeof metric.previous).toBe("number"); + expect( + metric.changePercent === null || typeof metric.changePercent === "number", + ).toBe(true); + } + }); + + it("should cache the response in Redis under the documented key", async () => { + await redis.del(dashboardCacheKey); + + const response = await app.inject({ + method: "GET", + url: "/api/v1/admin/dashboard", + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + + const cached = await redis.get(dashboardCacheKey); + expect(cached).not.toBeNull(); + const cachedStats = JSON.parse(cached as string); + expect(cachedStats.generatedAt).toBe( + JSON.parse(response.payload).data.generatedAt, + ); + }); + + it("should serve the identical cached response on a second request within the TTL, even after new data is written", async () => { + await redis.del(dashboardCacheKey); + + const first = await app.inject({ + method: "GET", + url: "/api/v1/admin/dashboard", + headers: { authorization: `Bearer ${adminToken()}` }, + }); + const firstBody = JSON.parse(first.payload); + + // Write a brand-new user after the first request. If the endpoint + // were not caching, this would change totalUsers on the next call. + const extraUserId = "d1a2d3e4-3333-4ef8-bb6d-6bb9bd380b97"; + await db.insert(users).values({ + id: extraUserId, + stellarAddress: + "GDASHEXTRAUSER0000000000000000000000000000000000000000", + displayName: "Extra Dashboard User", + isAdmin: false, + }); + + try { + const second = await app.inject({ + method: "GET", + url: "/api/v1/admin/dashboard", + headers: { authorization: `Bearer ${adminToken()}` }, + }); + const secondBody = JSON.parse(second.payload); + + expect(secondBody.data.totalUsers).toBe(firstBody.data.totalUsers); + expect(secondBody.data.generatedAt).toBe(firstBody.data.generatedAt); + } finally { + await db.delete(users).where(eq(users.id, extraUserId)); + } + }); + + it("should recompute (and reflect new data) once the cache entry is cleared", async () => { + await redis.del(dashboardCacheKey); + + const before = await app.inject({ + method: "GET", + url: "/api/v1/admin/dashboard", + headers: { authorization: `Bearer ${adminToken()}` }, + }); + const beforeTotal = JSON.parse(before.payload).data.totalUsers; + + const extraUserId = "d1a2d3e4-4444-4ef8-bb6d-6bb9bd380b96"; + await db.insert(users).values({ + id: extraUserId, + stellarAddress: + "GDASHEXTRAUSER20000000000000000000000000000000000000000", + displayName: "Another Extra Dashboard User", + isAdmin: false, + }); + + try { + await redis.del(dashboardCacheKey); + + const after = await app.inject({ + method: "GET", + url: "/api/v1/admin/dashboard", + headers: { authorization: `Bearer ${adminToken()}` }, + }); + const afterTotal = JSON.parse(after.payload).data.totalUsers; + + expect(afterTotal).toBe(beforeTotal + 1); + } finally { + await db.delete(users).where(eq(users.id, extraUserId)); + } + }); + }); +}); diff --git a/tests/e2e/admin-users.test.ts b/tests/e2e/admin-users.test.ts index 24a0dac..f84d036 100644 --- a/tests/e2e/admin-users.test.ts +++ b/tests/e2e/admin-users.test.ts @@ -3,7 +3,7 @@ import type { FastifyInstance } from "fastify"; import { eq } from "drizzle-orm"; import { buildApp } from "../../src/server.js"; import { db } from "../../src/config/database.js"; -import { users } from "../../src/database/schema.js"; +import { users, enrollments, courses } from "../../src/database/schema.js"; describe("Admin Users API (#288)", () => { let app: FastifyInstance; @@ -14,6 +14,19 @@ describe("Admin Users API (#288)", () => { const nonAdminUserId = "c1a2d3e4-2222-4ef8-bb6d-6bb9bd380a98"; const nonAdminStellarAddress = "GNONADMIN00000000000000000000000000000000000000000000000"; + // Separate target users for the destructive ban/credit tests, so they + // don't mutate (and leave side effects on) the shared nonAdminUserId + // fixture the listing/search tests above also read. + const banTargetUserId = "c1a2d3e4-3333-4ef8-bb6d-6bb9bd380a97"; + const banTargetStellarAddress = + "GBANTARGET0000000000000000000000000000000000000000000000"; + const creditTargetUserId = "c1a2d3e4-4444-4ef8-bb6d-6bb9bd380a96"; + const creditTargetStellarAddress = + "GCREDITTARGET0000000000000000000000000000000000000000000"; + const activityUserId = "c1a2d3e4-5555-4ef8-bb6d-6bb9bd380a95"; + const activityStellarAddress = + "GACTIVITYUSER0000000000000000000000000000000000000000000"; + const activityCourseId = "c1a2d3e4-6666-4ef8-bb6d-6bb9bd380a94"; beforeAll(async () => { app = await buildApp(); @@ -38,9 +51,63 @@ describe("Admin Users API (#288)", () => { isAdmin: false, }) .onConflictDoNothing(); + + await db + .insert(users) + .values({ + id: banTargetUserId, + stellarAddress: banTargetStellarAddress, + displayName: "Ban Target", + isAdmin: false, + }) + .onConflictDoNothing(); + + await db + .insert(users) + .values({ + id: creditTargetUserId, + stellarAddress: creditTargetStellarAddress, + displayName: "Credit Target", + isAdmin: false, + credits: 100, + }) + .onConflictDoNothing(); + + await db + .insert(users) + .values({ + id: activityUserId, + stellarAddress: activityStellarAddress, + displayName: "Activity User", + isAdmin: false, + }) + .onConflictDoNothing(); + + await db + .insert(courses) + .values({ + id: activityCourseId, + title: "Activity Test Course", + description: "Course fixture for the admin activity feed test", + difficulty: "beginner", + }) + .onConflictDoNothing(); + + await db + .insert(enrollments) + .values({ + userId: activityUserId, + courseId: activityCourseId, + }) + .onConflictDoNothing(); }); afterAll(async () => { + await db.delete(enrollments).where(eq(enrollments.userId, activityUserId)); + await db.delete(courses).where(eq(courses.id, activityCourseId)); + await db.delete(users).where(eq(users.id, activityUserId)); + await db.delete(users).where(eq(users.id, creditTargetUserId)); + await db.delete(users).where(eq(users.id, banTargetUserId)); await db.delete(users).where(eq(users.id, adminUserId)); await db.delete(users).where(eq(users.id, nonAdminUserId)); await app.close(); @@ -176,4 +243,270 @@ describe("Admin Users API (#288)", () => { expect(response.statusCode).toBe(400); }); }); + + describe("POST /api/v1/admin/users/:id/ban (#616)", () => { + it("should reject unauthenticated requests", async () => { + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/users/${banTargetUserId}/ban`, + payload: { reason: "Terms of service violation" }, + }); + + expect(response.statusCode).toBe(401); + }); + + it("should reject non-admin users with 403", async () => { + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/users/${banTargetUserId}/ban`, + headers: { authorization: `Bearer ${nonAdminToken()}` }, + payload: { reason: "Terms of service violation" }, + }); + + expect(response.statusCode).toBe(403); + }); + + it("should reject a missing reason", async () => { + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/users/${banTargetUserId}/ban`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: {}, + }); + + expect(response.statusCode).toBe(400); + }); + + it("should 404 for a non-existent user", async () => { + const response = await app.inject({ + method: "POST", + url: "/api/v1/admin/users/00000000-0000-0000-0000-000000000000/ban", + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { reason: "Terms of service violation" }, + }); + + expect(response.statusCode).toBe(404); + }); + + it("should ban a user, persist the reason, and reject their subsequent authenticated requests with 403", async () => { + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/users/${banTargetUserId}/ban`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { reason: "Terms of service violation" }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.success).toBe(true); + + const [banned] = await db + .select() + .from(users) + .where(eq(users.id, banTargetUserId)); + expect(banned?.bannedAt).not.toBeNull(); + expect(banned?.banReason).toBe("Terms of service violation"); + + // The banned user's own token must now be rejected (authGuard, #347): + // a ban takes effect on the next request without needing to revoke + // the JWT itself. + const bannedUserToken = app.jwt.sign({ + sub: banTargetUserId, + stellarAddress: banTargetStellarAddress, + }); + const selfResponse = await app.inject({ + method: "GET", + url: "/api/v1/users/me", + headers: { authorization: `Bearer ${bannedUserToken}` }, + }); + expect(selfResponse.statusCode).toBe(403); + }); + + // Issue #616 asks for ban/unban coverage, but no unban/reinstate + // entrypoint exists anywhere in the codebase (confirmed by searching + // the whole repo for "unban" and for any route that clears bannedAt). + // This test documents that gap explicitly rather than silently + // omitting it: it will start failing (in a good way) the moment an + // unban route is added, which is the signal to replace it with real + // coverage instead of deleting it. + it("has no unban/reinstate entrypoint yet (tracks the gap in #616's ban/unban scope)", async () => { + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/users/${banTargetUserId}/unban`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(404); + }); + }); + + describe("POST /api/v1/admin/users/:id/credits/grant and /deduct (#386, #616)", () => { + it("should reject unauthenticated grant requests", async () => { + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/users/${creditTargetUserId}/credits/grant`, + payload: { amount: 50, reason: "Promotion" }, + }); + + expect(response.statusCode).toBe(401); + }); + + it("should reject non-admin grant requests with 403", async () => { + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/users/${creditTargetUserId}/credits/grant`, + headers: { authorization: `Bearer ${nonAdminToken()}` }, + payload: { amount: 50, reason: "Promotion" }, + }); + + expect(response.statusCode).toBe(403); + }); + + it("should grant credits and return the balance before and after", async () => { + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/users/${creditTargetUserId}/credits/grant`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { amount: 50, reason: "Promotion", reference: "PROMO2026" }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.success).toBe(true); + expect(body.data.creditsBefore).toBe(100); + expect(body.data.creditsAfter).toBe(150); + expect(body.data.reference).toBe("PROMO2026"); + + const [row] = await db + .select() + .from(users) + .where(eq(users.id, creditTargetUserId)); + expect(row?.credits).toBe(150); + }); + + it("should reject a grant with a non-positive amount", async () => { + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/users/${creditTargetUserId}/credits/grant`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { amount: 0, reason: "Promotion" }, + }); + + expect(response.statusCode).toBe(400); + }); + + it("should reject a grant with a missing reason", async () => { + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/users/${creditTargetUserId}/credits/grant`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { amount: 10 }, + }); + + expect(response.statusCode).toBe(400); + }); + + it("should 404 a grant for a non-existent user", async () => { + const response = await app.inject({ + method: "POST", + url: "/api/v1/admin/users/00000000-0000-0000-0000-000000000000/credits/grant", + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { amount: 10, reason: "Promotion" }, + }); + + expect(response.statusCode).toBe(404); + }); + + it("should deduct credits and return the balance before and after", async () => { + // Balance is 150 after the grant test above (same fixture user, + // sequential within this file). + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/users/${creditTargetUserId}/credits/deduct`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { amount: 30, reason: "Penalty" }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.success).toBe(true); + expect(body.data.creditsBefore).toBe(150); + expect(body.data.creditsAfter).toBe(120); + + const [row] = await db + .select() + .from(users) + .where(eq(users.id, creditTargetUserId)); + expect(row?.credits).toBe(120); + }); + + it("should reject a deduction that would make the balance negative", async () => { + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/users/${creditTargetUserId}/credits/deduct`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { amount: 999999, reason: "Penalty" }, + }); + + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.payload); + expect(body.error).toBe("VALIDATION_ERROR"); + + // The balance must be unchanged after the rejected deduction. + const [row] = await db + .select() + .from(users) + .where(eq(users.id, creditTargetUserId)); + expect(row?.credits).toBe(120); + }); + }); + + describe("GET /api/v1/admin/users/:id/activity (#346, #616)", () => { + it("should reject unauthenticated requests", async () => { + const response = await app.inject({ + method: "GET", + url: `/api/v1/admin/users/${activityUserId}/activity`, + }); + + expect(response.statusCode).toBe(401); + }); + + it("should reject non-admin users with 403", async () => { + const response = await app.inject({ + method: "GET", + url: `/api/v1/admin/users/${activityUserId}/activity`, + headers: { authorization: `Bearer ${nonAdminToken()}` }, + }); + + expect(response.statusCode).toBe(403); + }); + + it("should 404 for a non-existent user", async () => { + const response = await app.inject({ + method: "GET", + url: "/api/v1/admin/users/00000000-0000-0000-0000-000000000000/activity", + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(404); + }); + + it("should return the user's enrollment as an activity entry", async () => { + const response = await app.inject({ + method: "GET", + url: `/api/v1/admin/users/${activityUserId}/activity`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.success).toBe(true); + expect(Array.isArray(body.data)).toBe(true); + const enrollmentActivity = body.data.find( + (a: { type: string }) => a.type === "enrollment", + ); + expect(enrollmentActivity).toBeDefined(); + expect(enrollmentActivity.details.courseId).toBe(activityCourseId); + }); + }); }); diff --git a/tests/e2e/admin-webhooks.test.ts b/tests/e2e/admin-webhooks.test.ts new file mode 100644 index 0000000..2ca1e72 --- /dev/null +++ b/tests/e2e/admin-webhooks.test.ts @@ -0,0 +1,620 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import type { FastifyInstance } from "fastify"; +import { eq } from "drizzle-orm"; +import { buildApp } from "../../src/server.js"; +import { db } from "../../src/config/database.js"; +import { users, webhooks, webhookAttempts } from "../../src/database/schema.js"; + +describe("Admin Webhook Management API (#615)", () => { + let app: FastifyInstance; + + const adminUserId = "f1a2d3e4-1111-4ef8-bb6d-6bb9bd380d99"; + const adminStellarAddress = + "GWEBHOOKADMIN0000000000000000000000000000000000000000000"; + const nonAdminUserId = "f1a2d3e4-2222-4ef8-bb6d-6bb9bd380d98"; + const nonAdminStellarAddress = + "GWEBHOOKNONADMIN0000000000000000000000000000000000000000"; + + beforeAll(async () => { + app = await buildApp(); + await app.ready(); + + await db + .insert(users) + .values({ + id: adminUserId, + stellarAddress: adminStellarAddress, + displayName: "Webhook Admin", + isAdmin: true, + }) + .onConflictDoNothing(); + + await db + .insert(users) + .values({ + id: nonAdminUserId, + stellarAddress: nonAdminStellarAddress, + displayName: "Webhook Non-Admin", + isAdmin: false, + }) + .onConflictDoNothing(); + }); + + afterAll(async () => { + await db.delete(users).where(eq(users.id, adminUserId)); + await db.delete(users).where(eq(users.id, nonAdminUserId)); + await app.close(); + }); + + // Every webhook this file creates is cleared before each test (cascades + // to webhookAttempts via onDelete: "cascade"), so list/pagination/stats + // assertions never see leftovers from a previous test. + beforeEach(async () => { + await db.delete(webhooks); + }); + + const adminToken = () => + app.jwt.sign({ sub: adminUserId, stellarAddress: adminStellarAddress }); + + const nonAdminToken = () => + app.jwt.sign({ + sub: nonAdminUserId, + stellarAddress: nonAdminStellarAddress, + }); + + async function createWebhookDirect( + overrides: Partial<{ url: string; events: string[]; active: boolean }> = {}, + ): Promise { + const [row] = await db + .insert(webhooks) + .values({ + url: overrides.url ?? "https://example.com/webhook", + events: overrides.events ?? ["enrollment.created"], + secret: "test-secret-0000000000000000000000000000000000000000000000", + active: overrides.active ?? true, + }) + .returning({ id: webhooks.id }); + return row.id; + } + + describe("POST /api/v1/admin/webhooks", () => { + it("should reject unauthenticated requests", async () => { + const response = await app.inject({ + method: "POST", + url: "/api/v1/admin/webhooks", + payload: { url: "https://example.com/webhook", events: ["enrollment.created"] }, + }); + + // Regression (#615): webhook.routes.ts previously registered only + // adminGuard on this router, never authGuard. adminGuard reads + // request.authUser.isAdmin directly, and only authGuard sets + // authUser, so EVERY request here (admin or not, authenticated or + // not) 500'd with "Cannot read properties of undefined (reading + // 'isAdmin')" instead of the admin-users/dashboard routers' clean + // 401/403. Fixed by adding the missing authGuard onRequest hook. + expect(response.statusCode).toBe(401); + }); + + it("should reject non-admin users with 403", async () => { + const response = await app.inject({ + method: "POST", + url: "/api/v1/admin/webhooks", + headers: { authorization: `Bearer ${nonAdminToken()}` }, + payload: { url: "https://example.com/webhook", events: ["enrollment.created"] }, + }); + + expect(response.statusCode).toBe(403); + }); + + it("should create a webhook and never return the secret in the response", async () => { + const response = await app.inject({ + method: "POST", + url: "/api/v1/admin/webhooks", + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { + url: "https://example.com/webhook", + events: ["enrollment.created", "quiz.passed"], + }, + }); + + expect(response.statusCode).toBe(201); + const body = JSON.parse(response.payload); + expect(body.success).toBe(true); + expect(body.data.url).toBe("https://example.com/webhook"); + expect(body.data.events).toEqual(["enrollment.created", "quiz.passed"]); + expect(body.data.active).toBe(true); + expect(body.data.secret).toBeUndefined(); + + const [row] = await db + .select() + .from(webhooks) + .where(eq(webhooks.id, body.data.id)); + expect(row?.secret).toBeDefined(); + expect(row?.secret.length).toBeGreaterThan(0); + }); + + it("should reject a webhook with no events", async () => { + const response = await app.inject({ + method: "POST", + url: "/api/v1/admin/webhooks", + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { url: "https://example.com/webhook", events: [] }, + }); + + expect(response.statusCode).toBe(400); + }); + + it("should reject an invalid URL", async () => { + const response = await app.inject({ + method: "POST", + url: "/api/v1/admin/webhooks", + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { url: "not-a-url", events: ["enrollment.created"] }, + }); + + expect(response.statusCode).toBe(400); + }); + + it("should reject a webhook targeting a loopback/private address (SSRF guard, #487)", async () => { + const response = await app.inject({ + method: "POST", + url: "/api/v1/admin/webhooks", + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { url: "http://localhost:3000/webhook", events: ["enrollment.created"] }, + }); + + expect(response.statusCode).toBe(400); + }); + }); + + describe("GET /api/v1/admin/webhooks", () => { + it("should reject non-admin users with 403", async () => { + const response = await app.inject({ + method: "GET", + url: "/api/v1/admin/webhooks", + headers: { authorization: `Bearer ${nonAdminToken()}` }, + }); + + expect(response.statusCode).toBe(403); + }); + + it("should list webhooks, newest first, with pagination", async () => { + await createWebhookDirect({ url: "https://example.com/first" }); + await createWebhookDirect({ url: "https://example.com/second" }); + + const response = await app.inject({ + method: "GET", + url: "/api/v1/admin/webhooks", + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.success).toBe(true); + expect(body.data).toHaveLength(2); + expect(body.pagination.total).toBe(2); + // No secret anywhere in the listing either. + for (const webhook of body.data) { + expect(webhook.secret).toBeUndefined(); + } + }); + + it("should respect page and limit query params", async () => { + await createWebhookDirect({ url: "https://example.com/a" }); + await createWebhookDirect({ url: "https://example.com/b" }); + await createWebhookDirect({ url: "https://example.com/c" }); + + const response = await app.inject({ + method: "GET", + url: "/api/v1/admin/webhooks?page=2&limit=2", + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.data).toHaveLength(1); + expect(body.pagination.page).toBe(2); + expect(body.pagination.limit).toBe(2); + }); + }); + + describe("GET /api/v1/admin/webhooks/:id", () => { + it("should get a single webhook", async () => { + const id = await createWebhookDirect({ url: "https://example.com/single" }); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/admin/webhooks/${id}`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.data.id).toBe(id); + expect(body.data.url).toBe("https://example.com/single"); + }); + + it("should 404 for a non-existent webhook", async () => { + const response = await app.inject({ + method: "GET", + url: "/api/v1/admin/webhooks/00000000-0000-0000-0000-000000000000", + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(404); + }); + + it("should reject a malformed webhook id", async () => { + const response = await app.inject({ + method: "GET", + url: "/api/v1/admin/webhooks/not-a-uuid", + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(400); + }); + }); + + describe("PUT /api/v1/admin/webhooks/:id (CRUD: update)", () => { + it("should update the url, events, and active flag independently", async () => { + const id = await createWebhookDirect({ + url: "https://example.com/original", + events: ["enrollment.created"], + active: true, + }); + + const response = await app.inject({ + method: "PUT", + url: `/api/v1/admin/webhooks/${id}`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { active: false }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.data.active).toBe(false); + // Fields not included in the PUT body must be left untouched. + expect(body.data.url).toBe("https://example.com/original"); + expect(body.data.events).toEqual(["enrollment.created"]); + }); + + it("should update the url and events together", async () => { + const id = await createWebhookDirect({ url: "https://example.com/original" }); + + const response = await app.inject({ + method: "PUT", + url: `/api/v1/admin/webhooks/${id}`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { + url: "https://example.com/updated", + events: ["quiz.passed", "credential.minted"], + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.data.url).toBe("https://example.com/updated"); + expect(body.data.events).toEqual(["quiz.passed", "credential.minted"]); + }); + + it("should 404 when updating a non-existent webhook", async () => { + const response = await app.inject({ + method: "PUT", + url: "/api/v1/admin/webhooks/00000000-0000-0000-0000-000000000000", + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { active: false }, + }); + + expect(response.statusCode).toBe(404); + }); + + it("should reject an update with an invalid URL", async () => { + const id = await createWebhookDirect(); + + const response = await app.inject({ + method: "PUT", + url: `/api/v1/admin/webhooks/${id}`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { url: "not-a-url" }, + }); + + expect(response.statusCode).toBe(400); + }); + }); + + describe("DELETE /api/v1/admin/webhooks/:id (CRUD: delete)", () => { + it("should delete a webhook", async () => { + const id = await createWebhookDirect(); + + const response = await app.inject({ + method: "DELETE", + url: `/api/v1/admin/webhooks/${id}`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.success).toBe(true); + + const [row] = await db.select().from(webhooks).where(eq(webhooks.id, id)); + expect(row).toBeUndefined(); + }); + + it("should 404 when deleting a non-existent webhook", async () => { + const response = await app.inject({ + method: "DELETE", + url: "/api/v1/admin/webhooks/00000000-0000-0000-0000-000000000000", + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(404); + }); + }); + + describe("POST /api/v1/admin/webhooks/:id/rotate-secret", () => { + it("should reject non-admin users with 403", async () => { + const id = await createWebhookDirect(); + + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/webhooks/${id}/rotate-secret`, + headers: { authorization: `Bearer ${nonAdminToken()}` }, + }); + + expect(response.statusCode).toBe(403); + }); + + it("should rotate the secret and return the new value exactly once", async () => { + const id = await createWebhookDirect(); + const [before] = await db.select().from(webhooks).where(eq(webhooks.id, id)); + + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/webhooks/${id}/rotate-secret`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.success).toBe(true); + expect(typeof body.data.secret).toBe("string"); + expect(body.data.secret.length).toBeGreaterThan(0); + expect(body.data.secret).not.toBe(before?.secret); + + const [after] = await db.select().from(webhooks).where(eq(webhooks.id, id)); + expect(after?.secret).toBe(body.data.secret); + + // A follow-up GET on the webhook must never leak the (new) secret. + const getResponse = await app.inject({ + method: "GET", + url: `/api/v1/admin/webhooks/${id}`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + expect(JSON.parse(getResponse.payload).data.secret).toBeUndefined(); + }); + + it("should produce a different secret on each successive rotation", async () => { + const id = await createWebhookDirect(); + + const first = await app.inject({ + method: "POST", + url: `/api/v1/admin/webhooks/${id}/rotate-secret`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + const second = await app.inject({ + method: "POST", + url: `/api/v1/admin/webhooks/${id}/rotate-secret`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(JSON.parse(first.payload).data.secret).not.toBe( + JSON.parse(second.payload).data.secret, + ); + }); + + it("should 404 when rotating the secret of a non-existent webhook", async () => { + const response = await app.inject({ + method: "POST", + url: "/api/v1/admin/webhooks/00000000-0000-0000-0000-000000000000/rotate-secret", + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(404); + }); + }); + + describe("GET /api/v1/admin/webhooks/:id/attempts", () => { + it("should 404 for a non-existent webhook", async () => { + const response = await app.inject({ + method: "GET", + url: "/api/v1/admin/webhooks/00000000-0000-0000-0000-000000000000/attempts", + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(404); + }); + + it("should list attempts for a webhook, newest first", async () => { + const id = await createWebhookDirect(); + await db.insert(webhookAttempts).values({ + webhookId: id, + event: "enrollment.created", + payload: { foo: "bar" }, + statusCode: 200, + succeededAt: new Date(), + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + const [secondAttempt] = await db + .insert(webhookAttempts) + .values({ + webhookId: id, + event: "quiz.passed", + payload: { foo: "baz" }, + statusCode: 500, + errorMessage: "Connection refused", + failedAt: new Date(), + }) + .returning({ id: webhookAttempts.id }); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/admin/webhooks/${id}/attempts`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.data).toHaveLength(2); + expect(body.data[0].id).toBe(secondAttempt.id); + expect(body.data[0].statusCode).toBe(500); + expect(body.data[0].errorMessage).toBe("Connection refused"); + expect(body.pagination.total).toBe(2); + }); + + it("should only return attempts for the requested webhook, not another one's", async () => { + const id1 = await createWebhookDirect({ url: "https://example.com/one" }); + const id2 = await createWebhookDirect({ url: "https://example.com/two" }); + await db.insert(webhookAttempts).values({ + webhookId: id1, + event: "enrollment.created", + payload: {}, + statusCode: 200, + succeededAt: new Date(), + }); + await db.insert(webhookAttempts).values({ + webhookId: id2, + event: "enrollment.created", + payload: {}, + statusCode: 200, + succeededAt: new Date(), + }); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/admin/webhooks/${id1}/attempts`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + const body = JSON.parse(response.payload); + expect(body.data).toHaveLength(1); + expect(body.data[0].webhookId).toBe(id1); + }); + + it("should support cursor-based pagination via nextCursor (#482)", async () => { + const id = await createWebhookDirect(); + for (let i = 0; i < 3; i++) { + await db.insert(webhookAttempts).values({ + webhookId: id, + event: "enrollment.created", + payload: { i }, + statusCode: 200, + succeededAt: new Date(), + }); + await new Promise((resolve) => setTimeout(resolve, 5)); + } + + const firstPage = await app.inject({ + method: "GET", + url: `/api/v1/admin/webhooks/${id}/attempts?limit=2`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + const firstBody = JSON.parse(firstPage.payload); + expect(firstBody.data).toHaveLength(2); + expect(firstBody.pagination.nextCursor).not.toBeNull(); + + const secondPage = await app.inject({ + method: "GET", + url: `/api/v1/admin/webhooks/${id}/attempts?limit=2&cursor=${firstBody.pagination.nextCursor}`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + const secondBody = JSON.parse(secondPage.payload); + expect(secondBody.data).toHaveLength(1); + expect(secondBody.pagination.nextCursor).toBeNull(); + + // No overlap between the two pages. + const firstIds = firstBody.data.map((a: { id: string }) => a.id); + const secondIds = secondBody.data.map((a: { id: string }) => a.id); + expect(firstIds.some((id: string) => secondIds.includes(id))).toBe(false); + }); + }); + + describe("GET /api/v1/admin/webhooks/:id/stats", () => { + it("should 404 for a non-existent webhook", async () => { + const response = await app.inject({ + method: "GET", + url: "/api/v1/admin/webhooks/00000000-0000-0000-0000-000000000000/stats", + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(404); + }); + + it("should return zeroed stats for a webhook with no attempts", async () => { + const id = await createWebhookDirect(); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/admin/webhooks/${id}/stats`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.data).toEqual({ + webhookId: id, + totalAttempts: 0, + succeeded: 0, + failed: 0, + pending: 0, + successRate: 0, + }); + }); + + it("should compute succeeded/failed/pending counts and successRate correctly", async () => { + const id = await createWebhookDirect(); + // 2 succeeded, 1 failed, 1 pending (neither succeededAt nor failedAt set). + await db.insert(webhookAttempts).values([ + { + webhookId: id, + event: "enrollment.created", + payload: {}, + statusCode: 200, + succeededAt: new Date(), + }, + { + webhookId: id, + event: "quiz.passed", + payload: {}, + statusCode: 200, + succeededAt: new Date(), + }, + { + webhookId: id, + event: "quiz.failed", + payload: {}, + statusCode: 500, + failedAt: new Date(), + }, + { + webhookId: id, + event: "credential.minted", + payload: {}, + }, + ]); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/admin/webhooks/${id}/stats`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.data.totalAttempts).toBe(4); + expect(body.data.succeeded).toBe(2); + expect(body.data.failed).toBe(1); + expect(body.data.pending).toBe(1); + expect(body.data.successRate).toBe(50); + }); + }); +}); diff --git a/tests/e2e/notifications.test.ts b/tests/e2e/notifications.test.ts new file mode 100644 index 0000000..057fba3 --- /dev/null +++ b/tests/e2e/notifications.test.ts @@ -0,0 +1,325 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import type { FastifyInstance } from "fastify"; +import { eq } from "drizzle-orm"; +import { buildApp } from "../../src/server.js"; +import { db } from "../../src/config/database.js"; +import { users, notifications } from "../../src/database/schema.js"; + +describe("Notifications API (#619)", () => { + let app: FastifyInstance; + + const userId = "e1a2d3e4-1111-4ef8-bb6d-6bb9bd380c99"; + const userStellarAddress = + "GNOTIFYUSER00000000000000000000000000000000000000000000"; + const otherUserId = "e1a2d3e4-2222-4ef8-bb6d-6bb9bd380c98"; + const otherUserStellarAddress = + "GNOTIFYOTHER0000000000000000000000000000000000000000000"; + + beforeAll(async () => { + app = await buildApp(); + await app.ready(); + + await db + .insert(users) + .values({ + id: userId, + stellarAddress: userStellarAddress, + displayName: "Notify User", + isAdmin: false, + }) + .onConflictDoNothing(); + + await db + .insert(users) + .values({ + id: otherUserId, + stellarAddress: otherUserStellarAddress, + displayName: "Other Notify User", + isAdmin: false, + }) + .onConflictDoNothing(); + }); + + afterAll(async () => { + await db.delete(users).where(eq(users.id, userId)); + await db.delete(users).where(eq(users.id, otherUserId)); + await app.close(); + }); + + // Every notification row this file creates is cleared before each test, + // so list/count/unread-count assertions never see leftovers from a + // previous test in this same file. + beforeEach(async () => { + await db.delete(notifications).where(eq(notifications.userId, userId)); + await db.delete(notifications).where(eq(notifications.userId, otherUserId)); + }); + + const token = () => + app.jwt.sign({ sub: userId, stellarAddress: userStellarAddress }); + + const otherToken = () => + app.jwt.sign({ sub: otherUserId, stellarAddress: otherUserStellarAddress }); + + async function seedNotification( + targetUserId: string, + overrides: Partial<{ type: string; title: string; message: string; read: boolean }> = {}, + ): Promise { + const [row] = await db + .insert(notifications) + .values({ + userId: targetUserId, + type: overrides.type ?? "badge_earned", + title: overrides.title ?? "You earned a badge!", + message: overrides.message ?? "Congratulations on your progress.", + read: overrides.read ?? false, + }) + .returning({ id: notifications.id }); + return row.id; + } + + describe("GET /api/v1/users/me/notifications", () => { + it("should reject unauthenticated requests", async () => { + const response = await app.inject({ + method: "GET", + url: "/api/v1/users/me/notifications", + }); + + expect(response.statusCode).toBe(401); + }); + + it("should return an empty page with unreadCount 0 for a user with no notifications", async () => { + const response = await app.inject({ + method: "GET", + url: "/api/v1/users/me/notifications", + headers: { authorization: `Bearer ${token()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.success).toBe(true); + expect(body.data).toEqual([]); + expect(body.pagination.total).toBe(0); + expect(body.unreadCount).toBe(0); + }); + + it("should list the authenticated user's notifications, newest first", async () => { + const firstId = await seedNotification(userId, { title: "First" }); + await new Promise((resolve) => setTimeout(resolve, 10)); + const secondId = await seedNotification(userId, { title: "Second" }); + + const response = await app.inject({ + method: "GET", + url: "/api/v1/users/me/notifications", + headers: { authorization: `Bearer ${token()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.data).toHaveLength(2); + expect(body.data[0].id).toBe(secondId); + expect(body.data[1].id).toBe(firstId); + expect(body.pagination.total).toBe(2); + }); + + it("should only return the authenticated user's own notifications, never another user's", async () => { + await seedNotification(userId, { title: "Mine" }); + await seedNotification(otherUserId, { title: "Not mine" }); + + const response = await app.inject({ + method: "GET", + url: "/api/v1/users/me/notifications", + headers: { authorization: `Bearer ${token()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.data).toHaveLength(1); + expect(body.data[0].title).toBe("Mine"); + }); + + it("should report unreadCount independent of the current page", async () => { + await seedNotification(userId, { read: true }); + await seedNotification(userId, { read: false }); + await seedNotification(userId, { read: false }); + + const response = await app.inject({ + method: "GET", + url: "/api/v1/users/me/notifications?page=1&limit=1", + headers: { authorization: `Bearer ${token()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.data).toHaveLength(1); + expect(body.pagination.total).toBe(3); + expect(body.unreadCount).toBe(2); + }); + + it("should respect page and limit query params", async () => { + for (let i = 0; i < 3; i++) { + await seedNotification(userId, { title: `Notification ${i}` }); + } + + const response = await app.inject({ + method: "GET", + url: "/api/v1/users/me/notifications?page=2&limit=2", + headers: { authorization: `Bearer ${token()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.data).toHaveLength(1); + expect(body.pagination.page).toBe(2); + expect(body.pagination.limit).toBe(2); + }); + + it("should reject invalid limit values", async () => { + const response = await app.inject({ + method: "GET", + url: "/api/v1/users/me/notifications?limit=0", + headers: { authorization: `Bearer ${token()}` }, + }); + + expect(response.statusCode).toBe(400); + }); + }); + + describe("PUT /api/v1/users/me/notifications/:id/read", () => { + it("should reject unauthenticated requests", async () => { + const id = await seedNotification(userId); + + const response = await app.inject({ + method: "PUT", + url: `/api/v1/users/me/notifications/${id}/read`, + }); + + expect(response.statusCode).toBe(401); + }); + + it("should mark a single notification as read", async () => { + const id = await seedNotification(userId, { read: false }); + + const response = await app.inject({ + method: "PUT", + url: `/api/v1/users/me/notifications/${id}/read`, + headers: { authorization: `Bearer ${token()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.success).toBe(true); + expect(body.data.id).toBe(id); + expect(body.data.read).toBe(true); + + const [row] = await db + .select() + .from(notifications) + .where(eq(notifications.id, id)); + expect(row?.read).toBe(true); + }); + + it("should 404 when marking another user's notification as read", async () => { + const otherNotificationId = await seedNotification(otherUserId); + + const response = await app.inject({ + method: "PUT", + url: `/api/v1/users/me/notifications/${otherNotificationId}/read`, + headers: { authorization: `Bearer ${token()}` }, + }); + + expect(response.statusCode).toBe(404); + + // Confirm it genuinely wasn't touched, not just that the response hid it. + const [row] = await db + .select() + .from(notifications) + .where(eq(notifications.id, otherNotificationId)); + expect(row?.read).toBe(false); + }); + + it("should 404 for a non-existent notification id", async () => { + const response = await app.inject({ + method: "PUT", + url: "/api/v1/users/me/notifications/00000000-0000-0000-0000-000000000000/read", + headers: { authorization: `Bearer ${token()}` }, + }); + + expect(response.statusCode).toBe(404); + }); + + it("should reject a malformed notification id", async () => { + const response = await app.inject({ + method: "PUT", + url: "/api/v1/users/me/notifications/not-a-uuid/read", + headers: { authorization: `Bearer ${token()}` }, + }); + + expect(response.statusCode).toBe(400); + }); + }); + + describe("PUT /api/v1/users/me/notifications/read-all", () => { + it("should reject unauthenticated requests", async () => { + const response = await app.inject({ + method: "PUT", + url: "/api/v1/users/me/notifications/read-all", + }); + + expect(response.statusCode).toBe(401); + }); + + it("should mark every unread notification as read and report the count", async () => { + await seedNotification(userId, { read: false }); + await seedNotification(userId, { read: false }); + await seedNotification(userId, { read: true }); + + const response = await app.inject({ + method: "PUT", + url: "/api/v1/users/me/notifications/read-all", + headers: { authorization: `Bearer ${token()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.success).toBe(true); + expect(body.data.count).toBe(2); + + const unreadResponse = await app.inject({ + method: "GET", + url: "/api/v1/users/me/notifications", + headers: { authorization: `Bearer ${token()}` }, + }); + expect(JSON.parse(unreadResponse.payload).unreadCount).toBe(0); + }); + + it("should only affect the authenticated user's own notifications", async () => { + await seedNotification(userId, { read: false }); + await seedNotification(otherUserId, { read: false }); + + await app.inject({ + method: "PUT", + url: "/api/v1/users/me/notifications/read-all", + headers: { authorization: `Bearer ${token()}` }, + }); + + const otherResponse = await app.inject({ + method: "GET", + url: "/api/v1/users/me/notifications", + headers: { authorization: `Bearer ${otherToken()}` }, + }); + expect(JSON.parse(otherResponse.payload).unreadCount).toBe(1); + }); + + it("should return count 0 when there are no unread notifications", async () => { + const response = await app.inject({ + method: "PUT", + url: "/api/v1/users/me/notifications/read-all", + headers: { authorization: `Bearer ${token()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.data.count).toBe(0); + }); + }); +});