diff --git a/src/server.ts b/src/server.ts index 29bf3c4..3e0b64b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -19,7 +19,7 @@ import { registerErrorHandler } from "./middleware/error-handler.js"; import { registerRequestTimeout } from "./middleware/timeout.js"; import { rateLimitOptions } from "./middleware/rate-limit.js"; import { listBlockedIps, clearIpBlock } from "./middleware/auth-brute-force.js"; -import { authGuard } from "./middleware/auth.js"; +import { authGuard, adminGuard } from "./middleware/auth.js"; import { db } from "./config/database.js"; import { redis } from "./config/redis.js"; import { stellarClient } from "./stellar/client.js"; @@ -391,13 +391,18 @@ async function buildApp() { }); // ─── Admin: Auth IP Blocks ───────────────────────────────────────────── - app.get("/admin/auth-blocks", async (_request, reply) => { - const blocks = await listBlockedIps(); - reply.send({ blocks }); - }); + app.get( + "/admin/auth-blocks", + { preHandler: [authGuard, adminGuard] }, + async (_request, reply) => { + const blocks = await listBlockedIps(); + reply.send({ blocks }); + }, + ); app.delete<{ Params: { ip: string } }>( "/admin/auth-blocks/:ip", + { preHandler: [authGuard, adminGuard] }, async (request, reply) => { const cleared = await clearIpBlock(request.params.ip); reply.send({ cleared }); diff --git a/tests/e2e/admin-auth-blocks.test.ts b/tests/e2e/admin-auth-blocks.test.ts new file mode 100644 index 0000000..7fcb12d --- /dev/null +++ b/tests/e2e/admin-auth-blocks.test.ts @@ -0,0 +1,101 @@ +import { afterAll, beforeAll, describe, expect, it } 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 { redis } from "../../src/config/redis.js"; +import { users } from "../../src/database/schema.js"; + +describe("Admin auth blocks E2E API (#661)", () => { + let app: FastifyInstance; + + const adminUserId = "a6610000-0000-4000-8000-000000000001"; + const adminAddress = "GADMINAUTHBLOCKS0000000000000000000000000000000000000000".slice(0, 56); + const learnerUserId = "a6610000-0000-4000-8000-000000000002"; + const learnerAddress = "GUSERAUTHBLOCKS00000000000000000000000000000000000000000".slice(0, 56); + const listedIp = "203.0.113.61"; + const injectedIp = "127.0.0.1"; + const blockKey = (ip: string) => `auth:block:${ip}`; + + beforeAll(async () => { + app = await buildApp(); + await app.ready(); + + await db + .insert(users) + .values({ id: adminUserId, stellarAddress: adminAddress, displayName: "Auth Block Admin", isAdmin: true }) + .onConflictDoNothing(); + await db + .insert(users) + .values({ id: learnerUserId, stellarAddress: learnerAddress, displayName: "Auth Block Learner", isAdmin: false }) + .onConflictDoNothing(); + }); + + afterAll(async () => { + await redis.del(blockKey(listedIp), blockKey(injectedIp)); + await db.delete(users).where(eq(users.id, adminUserId)); + await db.delete(users).where(eq(users.id, learnerUserId)); + await app.close(); + }); + + const adminHeaders = () => ({ + authorization: `Bearer ${app.jwt.sign({ sub: adminUserId, stellarAddress: adminAddress })}`, + }); + const learnerHeaders = () => ({ + authorization: `Bearer ${app.jwt.sign({ sub: learnerUserId, stellarAddress: learnerAddress })}`, + }); + + it("requires an authenticated admin to list or clear blocked IPs", async () => { + const unauthenticated = await app.inject({ method: "GET", url: "/admin/auth-blocks" }); + expect(unauthenticated.statusCode).toBe(401); + + const learnerList = await app.inject({ method: "GET", url: "/admin/auth-blocks", headers: learnerHeaders() }); + expect(learnerList.statusCode).toBe(403); + + const learnerClear = await app.inject({ method: "DELETE", url: `/admin/auth-blocks/${listedIp}`, headers: learnerHeaders() }); + expect(learnerClear.statusCode).toBe(403); + }); + + it("lists active IP blocks and clears a selected block", async () => { + await redis.setex(blockKey(listedIp), 120, "1"); + + const listResponse = await app.inject({ method: "GET", url: "/admin/auth-blocks", headers: adminHeaders() }); + expect(listResponse.statusCode).toBe(200); + const { blocks } = listResponse.json<{ blocks: Array<{ ip: string; ttl: number }> }>(); + expect(blocks).toEqual(expect.arrayContaining([expect.objectContaining({ ip: listedIp })])); + expect(blocks.find((block) => block.ip === listedIp)?.ttl).toBeGreaterThan(0); + + const clearResponse = await app.inject({ method: "DELETE", url: `/admin/auth-blocks/${listedIp}`, headers: adminHeaders() }); + expect(clearResponse.statusCode).toBe(200); + expect(clearResponse.json()).toEqual({ cleared: true }); + expect(await redis.get(blockKey(listedIp))).toBeNull(); + }); + + it("blocks authentication requests from a blocked IP and allows them after an admin clears it", async () => { + await redis.setex(blockKey(injectedIp), 120, "1"); + const verifyRequest = { + method: "POST" as const, + url: "/api/v1/auth/verify", + payload: { + stellarAddress: "GTEST00000000000000000000000000000000000000000000000000", + challengeId: "a6610000-0000-4000-8000-000000000003", + signedChallenge: "invalid-signature", + }, + }; + + const blocked = await app.inject(verifyRequest); + expect(blocked.statusCode).toBe(429); + expect(Number(blocked.headers["retry-after"])).toBeGreaterThan(0); + + const clear = await app.inject({ + method: "DELETE", + url: `/admin/auth-blocks/${injectedIp}`, + headers: adminHeaders(), + }); + expect(clear.statusCode).toBe(200); + + const allowedThroughBlockCheck = await app.inject(verifyRequest); + expect(allowedThroughBlockCheck.statusCode).not.toBe(429); + expect(allowedThroughBlockCheck.statusCode).toBe(401); + }); +}); diff --git a/tests/e2e/admin-course-drafts.test.ts b/tests/e2e/admin-course-drafts.test.ts new file mode 100644 index 0000000..094bcc0 --- /dev/null +++ b/tests/e2e/admin-course-drafts.test.ts @@ -0,0 +1,111 @@ +import { afterAll, beforeAll, describe, expect, it } 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 { courses, quizzes, users } from "../../src/database/schema.js"; + +describe("Admin course drafts E2E API (#660)", () => { + let app: FastifyInstance; + + const adminUserId = "a6600000-0000-4000-8000-000000000001"; + const adminAddress = "GADMINDRAFTS000000000000000000000000000000000000000000".slice(0, 56); + const learnerUserId = "a6600000-0000-4000-8000-000000000002"; + const learnerAddress = "GUSERDRAFTS00000000000000000000000000000000000000000000".slice(0, 56); + const moduleId = "draft-module-1"; + const courseIds: string[] = []; + + beforeAll(async () => { + app = await buildApp(); + await app.ready(); + await db.insert(users).values({ id: adminUserId, stellarAddress: adminAddress, displayName: "Draft Admin", isAdmin: true }).onConflictDoNothing(); + await db.insert(users).values({ id: learnerUserId, stellarAddress: learnerAddress, displayName: "Draft Learner", isAdmin: false }).onConflictDoNothing(); + }); + + afterAll(async () => { + for (const id of courseIds) { + await db.delete(quizzes).where(eq(quizzes.courseId, id)); + await db.delete(courses).where(eq(courses.id, id)); + } + await db.delete(users).where(eq(users.id, adminUserId)); + await db.delete(users).where(eq(users.id, learnerUserId)); + await app.close(); + }); + + const adminHeaders = () => ({ authorization: `Bearer ${app.jwt.sign({ sub: adminUserId, stellarAddress: adminAddress })}` }); + const learnerHeaders = () => ({ authorization: `Bearer ${app.jwt.sign({ sub: learnerUserId, stellarAddress: learnerAddress })}` }); + + it("allows an admin to save a draft while keeping it hidden from learners", async () => { + const [course] = await db.insert(courses).values({ + title: "Draft before save", + description: "Initial course description", + difficulty: "beginner", + modules: [{ id: moduleId, title: "First Module", description: "Learn the basics", order: 0 }], + courseModules: [{ id: moduleId, title: "First Module", description: "Learn the basics" }], + isDraft: true, + isActive: false, + }).returning(); + courseIds.push(course!.id); + + await db.insert(quizzes).values({ + courseId: course!.id, + moduleId, + questions: [{ id: "q1", text: "What is Stellar?", options: ["A network", "A database"], correctIndex: 0 }], + }); + + const unauthenticated = await app.inject({ method: "POST", url: `/api/v1/admin/courses/${course!.id}/draft`, payload: { title: "Saved Draft" } }); + expect(unauthenticated.statusCode).toBe(401); + + const forbidden = await app.inject({ method: "POST", url: `/api/v1/admin/courses/${course!.id}/draft`, headers: learnerHeaders(), payload: { title: "Saved Draft" } }); + expect(forbidden.statusCode).toBe(403); + + const saved = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${course!.id}/draft`, + headers: adminHeaders(), + payload: { title: "Saved Draft", description: "Updated draft content" }, + }); + expect(saved.statusCode).toBe(200); + expect(saved.json().data).toMatchObject({ title: "Saved Draft", description: "Updated draft content", isDraft: true, isActive: false }); + + const publicList = await app.inject({ method: "GET", url: "/api/v1/courses" }); + expect(publicList.statusCode).toBe(200); + expect(publicList.json().data.courses.some((item: { id: string }) => item.id === course!.id)).toBe(false); + }); + + it("publishes the saved draft and exposes it in the public course list", async () => { + const [course] = await db.insert(courses).values({ + title: "Publishable Draft", + description: "A complete course ready to publish", + difficulty: "beginner", + modules: [{ id: moduleId, title: "Module One", description: "Course material", order: 0 }], + courseModules: [{ id: moduleId, title: "Module One", description: "Course material" }], + isDraft: true, + isActive: false, + }).returning(); + courseIds.push(course!.id); + await db.insert(quizzes).values({ + courseId: course!.id, + moduleId, + questions: [{ id: "q1", text: "What is Stellar?", options: ["A network", "A database"], correctIndex: 0 }], + }); + + const saved = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${course!.id}/draft`, + headers: adminHeaders(), + payload: { description: "Reviewed and ready to publish" }, + }); + expect(saved.statusCode).toBe(200); + expect(saved.json().data.isDraft).toBe(true); + expect(saved.json().data.isActive).toBe(false); + + const published = await app.inject({ method: "POST", url: `/api/v1/admin/courses/${course!.id}/publish`, headers: adminHeaders() }); + expect(published.statusCode).toBe(200); + expect(published.json().data).toMatchObject({ isDraft: false, isActive: true }); + + const publicList = await app.inject({ method: "GET", url: "/api/v1/courses" }); + expect(publicList.statusCode).toBe(200); + expect(publicList.json().data.courses.some((item: { id: string }) => item.id === course!.id)).toBe(true); + }); +}); diff --git a/tests/e2e/admin-course-enrollment-trends.test.ts b/tests/e2e/admin-course-enrollment-trends.test.ts new file mode 100644 index 0000000..2d9cf3d --- /dev/null +++ b/tests/e2e/admin-course-enrollment-trends.test.ts @@ -0,0 +1,105 @@ +import { afterAll, beforeAll, describe, expect, it } 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 { courses, enrollments, users } from "../../src/database/schema.js"; + +describe("Admin course enrollment trends E2E API (#659)", () => { + let app: FastifyInstance; + + const adminUserId = "a6590000-0000-4000-8000-000000000001"; + const adminAddress = "GADMINENROLLTRENDS00000000000000000000000000000000000000".slice(0, 56); + const learnerIds = [ + "a6590000-0000-4000-8000-000000000002", + "a6590000-0000-4000-8000-000000000003", + "a6590000-0000-4000-8000-000000000004", + "a6590000-0000-4000-8000-000000000005", + ]; + const learnerAddresses = [ + "GUSERENROLLTRENDSA000000000000000000000000000000000000000", + "GUSERENROLLTRENDSB000000000000000000000000000000000000000", + "GUSERENROLLTRENDSE00000000000000000000000000000000000000", + "GUSERENROLLTRENDSE00000000000000000000000000000000000001", + ].map((address) => address.slice(0, 56)); + let courseId = ""; + + beforeAll(async () => { + app = await buildApp(); + await app.ready(); + await db.insert(users).values({ id: adminUserId, stellarAddress: adminAddress, displayName: "Enrollment Trends Admin", isAdmin: true }).onConflictDoNothing(); + await db.insert(users).values(learnerIds.map((id, index) => ({ + id, + stellarAddress: learnerAddresses[index]!, + displayName: `Enrollment Trends Learner ${index + 1}`, + isAdmin: false, + }))).onConflictDoNothing(); + + const [course] = await db.insert(courses).values({ + title: "Enrollment Trends Fixture", + description: "Fixture for enrollment trend aggregation", + difficulty: "beginner", + isActive: true, + isDraft: false, + }).returning(); + courseId = course!.id; + + const now = Date.now(); + await db.insert(enrollments).values([ + { userId: learnerIds[0]!, courseId, enrolledAt: new Date(now - 24 * 60 * 60 * 1000) }, + { userId: learnerIds[1]!, courseId, enrolledAt: new Date(now - 3 * 24 * 60 * 60 * 1000) }, + { userId: learnerIds[2]!, courseId, enrolledAt: new Date(now - 10 * 24 * 60 * 60 * 1000) }, + ]); + }); + + afterAll(async () => { + if (courseId) { + await db.delete(enrollments).where(eq(enrollments.courseId, courseId)); + await db.delete(courses).where(eq(courses.id, courseId)); + } + await db.delete(users).where(eq(users.id, adminUserId)); + for (const id of learnerIds) await db.delete(users).where(eq(users.id, id)); + await app.close(); + }); + + const adminHeaders = () => ({ authorization: `Bearer ${app.jwt.sign({ sub: adminUserId, stellarAddress: adminAddress })}` }); + + it("rejects unauthenticated and non-admin requests", async () => { + const url = `/api/v1/admin/courses/${courseId}/enrollment-trends`; + const unauthenticated = await app.inject({ method: "GET", url }); + expect(unauthenticated.statusCode).toBe(401); + + const learnerToken = app.jwt.sign({ sub: learnerIds[0], stellarAddress: learnerAddresses[0] }); + const forbidden = await app.inject({ method: "GET", url, headers: { authorization: `Bearer ${learnerToken}` } }); + expect(forbidden.statusCode).toBe(403); + }); + + it("filters by range, groups daily enrollments, and caches the aggregation", async () => { + const url = `/api/v1/admin/courses/${courseId}/enrollment-trends?range=7d&granularity=daily`; + const firstResponse = await app.inject({ method: "GET", url, headers: adminHeaders() }); + expect(firstResponse.statusCode).toBe(200); + const first = firstResponse.json().data; + expect(first).toMatchObject({ courseId, range: "7d", granularity: "daily", totalEnrollments: 2 }); + expect(first.trends).toHaveLength(2); + expect(first.trends.every((point: { count: number; date: string }) => point.count === 1 && /^\d{4}-\d{2}-\d{2}/.test(point.date))).toBe(true); + + await db.insert(enrollments).values({ + userId: learnerIds[3]!, + courseId, + enrolledAt: new Date(), + }).onConflictDoNothing(); + + const cachedResponse = await app.inject({ method: "GET", url, headers: adminHeaders() }); + expect(cachedResponse.statusCode).toBe(200); + expect(cachedResponse.json().data.totalEnrollments).toBe(2); + + const weeklyResponse = await app.inject({ + method: "GET", + url: `/api/v1/admin/courses/${courseId}/enrollment-trends?range=30d&granularity=weekly`, + headers: adminHeaders(), + }); + expect(weeklyResponse.statusCode).toBe(200); + expect(weeklyResponse.json().data).toMatchObject({ range: "30d", granularity: "weekly", totalEnrollments: 4 }); + expect(weeklyResponse.json().data.trends.length).toBeGreaterThan(0); + }); +}); diff --git a/tests/e2e/admin-course-quiz-history.test.ts b/tests/e2e/admin-course-quiz-history.test.ts new file mode 100644 index 0000000..9fa7861 --- /dev/null +++ b/tests/e2e/admin-course-quiz-history.test.ts @@ -0,0 +1,118 @@ +import { afterAll, beforeAll, describe, expect, it } 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 { courses, quizSubmissions, quizzes, users } from "../../src/database/schema.js"; + +describe("Admin course quiz history E2E API (#658)", () => { + let app: FastifyInstance; + + const adminUserId = "a6580000-0000-4000-8000-000000000001"; + const adminAddress = "GADMINQUIZHISTORY00000000000000000000000000000000000000".slice(0, 56); + const learnerIds = [ + "a6580000-0000-4000-8000-000000000002", + "a6580000-0000-4000-8000-000000000003", + "a6580000-0000-4000-8000-000000000004", + "a6580000-0000-4000-8000-000000000005", + ]; + const learnerAddresses = [ + "GUSERQUIZHISTORYA0000000000000000000000000000000000000000", + "GUSERQUIZHISTORYB0000000000000000000000000000000000000000", + "GUSERQUIZHISTORYC0000000000000000000000000000000000000000", + "GUSERQUIZHISTORYD0000000000000000000000000000000000000000", + ].map((address) => address.slice(0, 56)); + const moduleId = "quiz-history-module"; + let courseId = ""; + const quizIds: string[] = []; + + beforeAll(async () => { + app = await buildApp(); + await app.ready(); + await db.insert(users).values({ id: adminUserId, stellarAddress: adminAddress, displayName: "Quiz History Admin", isAdmin: true }).onConflictDoNothing(); + await db.insert(users).values(learnerIds.map((id, index) => ({ + id, + stellarAddress: learnerAddresses[index]!, + displayName: `Quiz History Learner ${index + 1}`, + isAdmin: false, + }))).onConflictDoNothing(); + + const [course] = await db.insert(courses).values({ + title: "Quiz History Fixture", + description: "Fixture for module quiz history aggregation", + difficulty: "beginner", + modules: [{ id: moduleId, title: "Quiz History Module", description: "Assessment module", order: 0 }], + isActive: true, + isDraft: false, + }).returning(); + courseId = course!.id; + + const [fourQuestionQuiz] = await db.insert(quizzes).values({ + courseId, + moduleId, + questions: ["q1", "q2", "q3", "q4"].map((id) => ({ id, text: `${id}?`, options: ["A", "B"], correctIndex: 0 })), + }).returning(); + quizIds.push(fourQuestionQuiz!.id); + + const [twoQuestionQuiz] = await db.insert(quizzes).values({ + courseId, + moduleId, + questions: ["q1", "q2"].map((id) => ({ id, text: `${id}?`, options: ["A", "B"], correctIndex: 0 })), + }).returning(); + quizIds.push(twoQuestionQuiz!.id); + + await db.insert(quizSubmissions).values([ + { quizId: fourQuestionQuiz!.id, userId: learnerIds[0]!, answers: [], score: 4 }, + { quizId: twoQuestionQuiz!.id, userId: learnerIds[1]!, answers: [], score: 1 }, + { quizId: fourQuestionQuiz!.id, userId: learnerIds[2]!, answers: [], score: 0, superseded: true }, + ]); + }); + + afterAll(async () => { + if (quizIds.length) await db.delete(quizSubmissions).where(eq(quizSubmissions.quizId, quizIds[0]!)); + if (quizIds.length > 1) await db.delete(quizSubmissions).where(eq(quizSubmissions.quizId, quizIds[1]!)); + for (const id of quizIds) await db.delete(quizzes).where(eq(quizzes.id, id)); + if (courseId) await db.delete(courses).where(eq(courses.id, courseId)); + await db.delete(users).where(eq(users.id, adminUserId)); + for (const id of learnerIds) await db.delete(users).where(eq(users.id, id)); + await app.close(); + }); + + const adminHeaders = () => ({ authorization: `Bearer ${app.jwt.sign({ sub: adminUserId, stellarAddress: adminAddress })}` }); + + it("requires an administrator to inspect quiz history", async () => { + const url = `/api/v1/courses/${courseId}/modules/${moduleId}/quiz-history`; + const unauthenticated = await app.inject({ method: "GET", url }); + expect(unauthenticated.statusCode).toBe(401); + const learnerToken = app.jwt.sign({ sub: learnerIds[0], stellarAddress: learnerAddresses[0] }); + const forbidden = await app.inject({ method: "GET", url, headers: { authorization: `Bearer ${learnerToken}` } }); + expect(forbidden.statusCode).toBe(403); + }); + + it("aggregates module scores, excludes superseded attempts, and serves the cached result", async () => { + const url = `/api/v1/courses/${courseId}/modules/${moduleId}/quiz-history`; + const firstResponse = await app.inject({ method: "GET", url, headers: adminHeaders() }); + expect(firstResponse.statusCode).toBe(200); + expect(firstResponse.json().data).toMatchObject({ + courseId, + moduleId, + quizCount: 2, + totalAttempts: 2, + averageScore: 75, + passRate: 50, + scoreDistribution: { "0-20": 0, "21-40": 0, "41-60": 1, "61-80": 0, "81-100": 1 }, + }); + + await db.insert(quizSubmissions).values({ + quizId: quizIds[1]!, + userId: learnerIds[3]!, + answers: [], + score: 2, + }); + + const cachedResponse = await app.inject({ method: "GET", url, headers: adminHeaders() }); + expect(cachedResponse.statusCode).toBe(200); + expect(cachedResponse.json().data.totalAttempts).toBe(2); + expect(cachedResponse.json().data.averageScore).toBe(75); + }); +});