Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 });
Expand Down
101 changes: 101 additions & 0 deletions tests/e2e/admin-auth-blocks.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
111 changes: 111 additions & 0 deletions tests/e2e/admin-course-drafts.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
105 changes: 105 additions & 0 deletions tests/e2e/admin-course-enrollment-trends.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading