diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md index 4cb0cd3..7ec2e0f 100644 --- a/API_DOCUMENTATION.md +++ b/API_DOCUMENTATION.md @@ -589,6 +589,9 @@ Errors: ### List Books - `GET /api/books/` - Public +- Optional query params: + - `page` (default `1`) + - `limit` (default `20`) Response `200`: ```json @@ -598,6 +601,14 @@ Response `200`: "id": "algorithms", "title": "Algorithms", "count": 10, + "pagination": { + "totalItems": 10, + "totalPages": 1, + "currentPage": 1, + "hasNextPage": false, + "hasPreviousPage": false, + "limit": 20 + }, "items": [{"id": "...", "name": "...", "size": 1234, "url": "..."}] } ], diff --git a/README.md b/README.md index 7b1971f..eeb78e4 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ Before getting started, ensure you have the following installed: #### 1️⃣ Clone the Repository ```bash -git clone https://github.com/yourusername/PrepPilot.git +git clone https://github.com/Canopus-Labs/PrepPilot.git cd PrepPilot ``` diff --git a/backend/routes/booksRoutes.js b/backend/routes/booksRoutes.js index f8c38ed..a8aec89 100644 --- a/backend/routes/booksRoutes.js +++ b/backend/routes/booksRoutes.js @@ -4,6 +4,7 @@ const router = express.Router(); const GITHUB_OWNER = "KaranUnique"; const GITHUB_REPO = "Free-programming-books"; const BRANCH = "main"; +const ALLOWED_DOWNLOAD_HOSTS = new Set(["raw.githubusercontent.com"]); const GITHUB_TOKEN = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; async function fetchJson(url) { @@ -24,9 +25,14 @@ function buildRawUrl(path) { return `https://raw.githubusercontent.com/${GITHUB_OWNER}/${GITHUB_REPO}/${BRANCH}/${encodeURI(path)}`; } -async function listFilesRecursive(prefix) { +async function listFilesRecursive(prefix, page, limit) { const files = []; const queue = [prefix]; + const requestedPage = Number.isFinite(page) && page >= 1 ? page : 1; + const itemsPerPage = Number.isFinite(limit) && limit >= 1 ? limit : 20; + const startIndex = (requestedPage - 1) * itemsPerPage; + const endIndex = requestedPage * itemsPerPage; + let totalItems = 0; while (queue.length) { const current = queue.shift(); @@ -36,30 +42,43 @@ async function listFilesRecursive(prefix) { if (entry.type === "dir") { queue.push(`${current}/${entry.name}`); } else if (entry.type === "file") { - files.push({ - path: `${current}/${entry.name}`, - name: entry.name, - size: entry.size, - url: entry.download_url || buildRawUrl(`${current}/${entry.name}`), - }); + if (totalItems >= startIndex && totalItems < endIndex) { + files.push({ + path: `${current}/${entry.name}`, + name: entry.name, + size: entry.size, + url: entry.download_url || buildRawUrl(`${current}/${entry.name}`), + }); + } + totalItems += 1; } } } - return files; + return { + totalItems, + items: files, + currentPage: requestedPage, + pageSize: itemsPerPage, + totalPages: Math.max(1, Math.ceil(totalItems / itemsPerPage)), + hasNextPage: requestedPage * itemsPerPage < totalItems, + hasPreviousPage: requestedPage > 1, + }; } /** * List programming book categories and files sourced from the GitHub repository. * @route GET /api/books/ + * @query page optional page number, default 1 + * @query limit optional page size, default 20 * @param {import('express').Request} _req * @param {import('express').Response} res * @returns {Promise} * @throws {Error} When the GitHub API cannot be reached. * @example - * GET /api/books/ + * GET /api/books/?page=2&limit=20 * @example - * 200 {"categories": [{"id":"...","title":"...","items":[...]}], "warnings": []} + * 200 {"categories": [{"id":"...","title":"...","pagination":{"totalItems":100,...},"items":[...]}], "warnings": []} */ router.get("/", async (_req, res) => { try { @@ -71,15 +90,26 @@ router.get("/", async (_req, res) => { ); const warnings = []; + const page = Number.parseInt(_req.query.page, 10); + const limit = Number.parseInt(_req.query.limit, 10); + const categories = await Promise.all( categoryDirs.map(async (dir) => { try { - const files = await listFilesRecursive(dir.name); + const result = await listFilesRecursive(dir.name, page, limit); return { id: dir.name.toLowerCase().replace(/\s+/g, "-"), title: dir.name, - count: files.length, - items: files.map((f) => ({ + count: result.totalItems, + pagination: { + totalItems: result.totalItems, + totalPages: result.totalPages, + currentPage: result.currentPage, + hasNextPage: result.hasNextPage, + hasPreviousPage: result.hasPreviousPage, + limit: result.pageSize, + }, + items: result.items.map((f) => ({ id: `${dir.name}-${f.path}`, name: f.path.slice(dir.name.length + 1), size: f.size, @@ -122,8 +152,27 @@ router.get("/", async (_req, res) => { */ router.get("/download", (req, res) => { const { url } = req.query; - if (!url) return res.status(400).json({ message: "url query is required" }); - return res.redirect(url); + if (typeof url !== "string" || !url.trim()) { + return res.status(400).json({ message: "url query is required" }); + } + + let parsedUrl; + try { + parsedUrl = new URL(url); + } catch { + return res.status(400).json({ message: "url query must be a valid URL" }); + } + + if (parsedUrl.protocol !== "https:") { + return res.status(403).json({ message: "download URL must use https" }); + } + + if (!ALLOWED_DOWNLOAD_HOSTS.has(parsedUrl.hostname)) { + return res.status(403).json({ message: "download URL host is not allowed" }); + } + + return res.redirect(parsedUrl.toString()); }); module.exports = router; +module.exports.listFilesRecursive = listFilesRecursive; diff --git a/backend/tests/authController.register.login.unit.test.js b/backend/tests/authController.register.login.unit.test.js new file mode 100644 index 0000000..dc697a8 --- /dev/null +++ b/backend/tests/authController.register.login.unit.test.js @@ -0,0 +1,293 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// ─── Module Mocks ───────────────────────────────────────────────────────────── +// Mirror the same mock shape used in authController.tokens.unit.test.js + +vi.mock("../models/User.js", () => ({ + findById: vi.fn(), + findOne: vi.fn(), + create: vi.fn(), +})); + +vi.mock("bcryptjs", () => ({ + compare: vi.fn(), + genSalt: vi.fn().mockResolvedValue("salt"), + hash: vi.fn().mockResolvedValue("hashed_refresh_token"), +})); + +vi.mock("jsonwebtoken", () => ({ + sign: vi.fn().mockReturnValue("mock_jwt_token"), + verify: vi.fn(), +})); + +// passwordPolicy is called inside registerUser to validate the password +vi.mock("../utils/passwordPolicy.js", () => ({ + validatePassword: vi.fn().mockReturnValue({ valid: true, errors: [] }), +})); + +// sendVerificationEmail is imported at the top of authController even though +// it is not called during register/login (email verification is currently +// disabled). Mocking it avoids errors from missing SMTP configuration. +vi.mock("../utils/sendEmail.js", () => ({ + sendVerificationEmail: vi.fn().mockResolvedValue(undefined), +})); + +// ─── Test Variables ─────────────────────────────────────────────────────────── + +let registerUser; +let loginUser; +let User; +let bcrypt; +let validatePassword; + +// ─── Setup ─────────────────────────────────────────────────────────────────── + +beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + + process.env.JWT_SECRET = "test-secret"; + + const ctrl = await import("../controllers/authController.js"); + registerUser = ctrl.registerUser ?? ctrl.default?.registerUser; + loginUser = ctrl.loginUser ?? ctrl.default?.loginUser; + + const UserModule = await import("../models/User.js"); + User = UserModule.default ?? UserModule; + + const bcryptModule = await import("bcryptjs"); + bcrypt = bcryptModule.default ?? bcryptModule; + + const policyModule = await import("../utils/passwordPolicy.js"); + validatePassword = policyModule.validatePassword ?? policyModule.default?.validatePassword; +}); + +// ─── Helper ─────────────────────────────────────────────────────────────────── + +/** Build a minimal Express-style res mock with chainable .status() */ +const makeRes = () => { + const res = { status: vi.fn(), json: vi.fn() }; + res.status.mockReturnValue(res); + return res; +}; + +// ───────────────────────────────────────────────────────────────────────────── +// registerUser +// ───────────────────────────────────────────────────────────────────────────── + +describe("registerUser", () => { + it("returns 400 when the password fails policy validation", async () => { + validatePassword.mockReturnValueOnce({ + valid: false, + errors: ["Password must be at least 8 characters long."], + }); + + const req = { + body: { name: "Test User", email: "test@example.com", password: "weak" }, + }; + const res = makeRes(); + + await registerUser(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + message: "Password must be at least 8 characters long.", + }) + ); + // User should never be created when validation fails + expect(User.create).not.toHaveBeenCalled(); + }); + + it("returns 400 when the email address is already registered", async () => { + validatePassword.mockReturnValueOnce({ valid: true, errors: [] }); + User.findOne.mockResolvedValueOnce({ _id: "existing-id", email: "test@example.com" }); + + const req = { + body: { name: "Test User", email: "test@example.com", password: "StrongPass1!" }, + }; + const res = makeRes(); + + await registerUser(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + message: expect.stringContaining("already exists"), + }) + ); + expect(User.create).not.toHaveBeenCalled(); + }); + + it("creates the user, hashes the refresh token, and returns 201 with tokens", async () => { + validatePassword.mockReturnValueOnce({ valid: true, errors: [] }); + User.findOne.mockResolvedValueOnce(null); // email not taken + + const mockUser = { + _id: "new-user-id", + name: "Test User", + email: "test@example.com", + profileImageUrl: "https://example.com/avatar.png", + refreshTokenHash: null, + refreshTokenExpiresAt: null, + save: vi.fn().mockResolvedValue(undefined), + }; + User.create.mockResolvedValueOnce(mockUser); + + const req = { + body: { + name: "Test User", + email: "test@example.com", + password: "StrongPass1!", + profileImageUrl: "https://example.com/avatar.png", + }, + }; + const res = makeRes(); + + await registerUser(req, res); + + // Password must be hashed before storing + expect(bcrypt.genSalt).toHaveBeenCalledWith(10); + expect(bcrypt.hash).toHaveBeenCalled(); + + // User document must be persisted after setting the refresh token hash + expect(mockUser.save).toHaveBeenCalledOnce(); + + expect(res.status).toHaveBeenCalledWith(201); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + accessToken: expect.any(String), + refreshToken: expect.any(String), + _id: "new-user-id", + email: "test@example.com", + }) + ); + }); + + it("returns 500 when an unexpected database error occurs", async () => { + validatePassword.mockReturnValueOnce({ valid: true, errors: [] }); + User.findOne.mockRejectedValueOnce(new Error("DB connection lost")); + + const req = { + body: { name: "Test", email: "test@example.com", password: "StrongPass1!" }, + }; + const res = makeRes(); + + await registerUser(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false }) + ); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// loginUser +// ───────────────────────────────────────────────────────────────────────────── + +describe("loginUser", () => { + it("returns 401 when no account exists for the given email", async () => { + User.findOne.mockResolvedValueOnce(null); + + const req = { body: { email: "nobody@example.com", password: "any" } }; + const res = makeRes(); + + await loginUser(req, res); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false }) + ); + }); + + it("returns 401 when the password does not match the stored hash", async () => { + User.findOne.mockResolvedValueOnce({ + _id: "user-id", + password: "hashed_existing_password", + isEmailVerified: true, + }); + bcrypt.compare.mockResolvedValueOnce(false); + + const req = { body: { email: "test@example.com", password: "WrongPassword1!" } }; + const res = makeRes(); + + await loginUser(req, res); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false }) + ); + }); + + it("returns 403 when the account email has not been verified", async () => { + User.findOne.mockResolvedValueOnce({ + _id: "user-id", + password: "hashed_password", + isEmailVerified: false, + }); + bcrypt.compare.mockResolvedValueOnce(true); + + const req = { body: { email: "test@example.com", password: "StrongPass1!" } }; + const res = makeRes(); + + await loginUser(req, res); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false }) + ); + }); + + it("stores a hashed refresh token and returns 200 with tokens on valid credentials", async () => { + const mockUser = { + _id: "user-id", + name: "Test User", + email: "test@example.com", + password: "hashed_password", + profileImageUrl: null, + isEmailVerified: true, + refreshTokenHash: null, + refreshTokenExpiresAt: null, + save: vi.fn().mockResolvedValue(undefined), + }; + + User.findOne.mockResolvedValueOnce(mockUser); + bcrypt.compare.mockResolvedValueOnce(true); // password matches + + const req = { body: { email: "test@example.com", password: "StrongPass1!" } }; + const res = makeRes(); + + await loginUser(req, res); + + // Refresh token must be hashed before saving (not stored in plaintext) + expect(bcrypt.hash).toHaveBeenCalled(); + expect(mockUser.save).toHaveBeenCalledOnce(); + + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + accessToken: expect.any(String), + refreshToken: expect.any(String), + }) + ); + }); + + it("returns 500 when an unexpected error occurs during login", async () => { + User.findOne.mockRejectedValueOnce(new Error("DB timeout")); + + const req = { body: { email: "test@example.com", password: "any" } }; + const res = makeRes(); + + await loginUser(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false }) + ); + }); +}); + diff --git a/backend/tests/booksDownloadRedirect.unit.test.js b/backend/tests/booksDownloadRedirect.unit.test.js new file mode 100644 index 0000000..9dce7ab --- /dev/null +++ b/backend/tests/booksDownloadRedirect.unit.test.js @@ -0,0 +1,92 @@ +import express from "express"; +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import booksRoutes from "../routes/booksRoutes.js"; + +function buildApp() { + const app = express(); + app.use("/api/books", booksRoutes); + return app; +} + +function buildPath(rawUrl) { + return `/api/books/download?${new URLSearchParams({ url: rawUrl }).toString()}`; +} + +let server; +let baseUrl; + +beforeAll(async () => { + const app = buildApp(); + server = app.listen(0); + + await new Promise((resolve) => { + server.once("listening", resolve); + }); + + const { port } = server.address(); + baseUrl = `http://127.0.0.1:${port}`; +}); + +afterAll(async () => { + if (!server) { + return; + } + + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +}); + +describe("books download redirect validation", () => { + it("redirects allowed raw GitHub download URLs", async () => { + const allowedUrl = "https://raw.githubusercontent.com/owner/repo/main/file.pdf"; + + const response = await fetch(`${baseUrl}${buildPath(allowedUrl)}`, { + redirect: "manual", + }); + + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe(allowedUrl); + }); + + it("blocks non-allowlisted hosts", async () => { + const response = await fetch(`${baseUrl}${buildPath("https://evil.com/file.pdf")}`, { + redirect: "manual", + }); + + expect(response.status).toBe(403); + }); + + it("blocks non-https raw GitHub URLs", async () => { + const response = await fetch( + `${baseUrl}${buildPath("http://raw.githubusercontent.com/file.pdf")}`, + { + redirect: "manual", + } + ); + + expect(response.status).toBe(403); + }); + + it("rejects malformed URLs", async () => { + const response = await fetch(`${baseUrl}${buildPath("not-a-url")}`, { + redirect: "manual", + }); + + expect(response.status).toBe(400); + }); + + it("rejects javascript URLs", async () => { + const response = await fetch(`${baseUrl}${buildPath("javascript:alert(1)")}`, { + redirect: "manual", + }); + + expect([400, 403]).toContain(response.status); + }); +}); diff --git a/backend/tests/booksRoutes.pagination.unit.test.mjs b/backend/tests/booksRoutes.pagination.unit.test.mjs new file mode 100644 index 0000000..db226b1 --- /dev/null +++ b/backend/tests/booksRoutes.pagination.unit.test.mjs @@ -0,0 +1,44 @@ +import { createRequire } from "module"; +import { describe, it, expect, vi } from "vitest"; + +const require = createRequire(import.meta.url); +const { listFilesRecursive } = require("../routes/booksRoutes"); + +const sampleEntries = [ + { type: "file", name: "a.txt", size: 10, download_url: "http://example.com/a.txt" }, + { type: "file", name: "b.txt", size: 20, download_url: "http://example.com/b.txt" }, + { type: "file", name: "c.txt", size: 30, download_url: "http://example.com/c.txt" }, +]; + +vi.stubGlobal("fetch", vi.fn(async () => ({ + ok: true, + json: async () => sampleEntries, + text: async () => "", +}))); + +describe("listFilesRecursive pagination", () => { + it("returns first page of items with default limit", async () => { + const result = await listFilesRecursive("category", 1, 2); + + expect(result.totalItems).toBe(3); + expect(result.items).toHaveLength(2); + expect(result.currentPage).toBe(1); + expect(result.totalPages).toBe(2); + expect(result.hasNextPage).toBe(true); + expect(result.hasPreviousPage).toBe(false); + expect(result.items[0].name).toBe("a.txt"); + expect(result.items[1].name).toBe("b.txt"); + }); + + it("returns second page of items", async () => { + const result = await listFilesRecursive("category", 2, 2); + + expect(result.totalItems).toBe(3); + expect(result.items).toHaveLength(1); + expect(result.currentPage).toBe(2); + expect(result.totalPages).toBe(2); + expect(result.hasNextPage).toBe(false); + expect(result.hasPreviousPage).toBe(true); + expect(result.items[0].name).toBe("c.txt"); + }); +}); diff --git a/frontend/src/LandingPage.jsx b/frontend/src/LandingPage.jsx index d95cd8a..f740d37 100644 --- a/frontend/src/LandingPage.jsx +++ b/frontend/src/LandingPage.jsx @@ -350,7 +350,15 @@ const LandingPage = () => { {/* Login – outlined dark button (like opensox "Contribute") */}