Explorar o código

RHL-016 feat(search): implement Search API with validation and error handling

Code_Uwe hai 3 semanas
pai
achega
17b322775f
Modificáronse 2 ficheiros con 384 adicións e 0 borrados
  1. 247 0
      app/api/search/route.js
  2. 137 0
      app/api/search/route.test.js

+ 247 - 0
app/api/search/route.js

@@ -0,0 +1,247 @@
+import { getSession } from "@/lib/auth/session";
+import { canAccessBranch } from "@/lib/auth/permissions";
+import {
+	withErrorHandling,
+	json,
+	badRequest,
+	unauthorized,
+	forbidden,
+} from "@/lib/api/errors";
+import { search as searchBackend } from "@/lib/search";
+
+/**
+ * Search API (RHL-016)
+ *
+ * - Sync-first (Qsirch /search/) with cursor-based pagination.
+ * - Provider abstraction allows later switch to async-search without changing this endpoint.
+ *
+ * Query params:
+ * - scope: "branch" | "multi" | "all" (admin/dev only; branch users are forced to their own branch)
+ * - branch: NLxx (for scope=branch)
+ * - branches: comma-separated NLxx list (for scope=multi)
+ * - q: optional text query
+ * - from/to: optional inclusive ISO date (YYYY-MM-DD)
+ * - limit: optional (default 100, allowed 50..200)
+ * - cursor: optional opaque string
+ */
+
+export const dynamic = "force-dynamic";
+
+const BRANCH_RE = /^NL\d+$/;
+const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
+
+function isValidIsoDate(value) {
+	if (!ISO_DATE_RE.test(value)) return false;
+
+	const [y, m, d] = value.split("-").map((x) => Number(x));
+	if (!Number.isInteger(y) || !Number.isInteger(m) || !Number.isInteger(d))
+		return false;
+
+	// Basic calendar sanity (month/day ranges; does not validate month-length precisely,
+	// but rejects obvious invalid formats and keeps behavior predictable).
+	if (m < 1 || m > 12) return false;
+	if (d < 1 || d > 31) return false;
+
+	return true;
+}
+
+function parseLimitOrThrow(raw) {
+	if (raw === null || raw === undefined || raw === "") return 100;
+
+	if (!/^\d+$/.test(String(raw))) {
+		throw badRequest("VALIDATION_SEARCH_LIMIT", "Invalid limit parameter", {
+			limit: raw,
+		});
+	}
+
+	const n = Number(raw);
+	if (!Number.isInteger(n) || n < 50 || n > 200) {
+		throw badRequest("VALIDATION_SEARCH_LIMIT", "Invalid limit parameter", {
+			limit: n,
+			min: 50,
+			max: 200,
+		});
+	}
+
+	return n;
+}
+
+function parseScope(raw) {
+	const s = (raw || "").trim().toLowerCase();
+	if (!s) return null;
+	if (s === "branch" || s === "multi" || s === "all") return s;
+	throw badRequest("VALIDATION_SEARCH_SCOPE", "Invalid scope parameter", {
+		scope: raw,
+	});
+}
+
+function parseBranchesCsv(raw) {
+	if (!raw) return [];
+
+	return String(raw)
+		.split(",")
+		.map((x) => x.trim())
+		.filter(Boolean);
+}
+
+function unique(items) {
+	return Array.from(new Set(items));
+}
+
+export const GET = withErrorHandling(
+	async function GET(request) {
+		const session = await getSession();
+
+		if (!session) {
+			throw unauthorized("AUTH_UNAUTHENTICATED", "Unauthorized");
+		}
+
+		const { searchParams } = new URL(request.url);
+
+		const rawScope = searchParams.get("scope");
+		const scope = parseScope(rawScope);
+
+		const qRaw = searchParams.get("q");
+		const q = typeof qRaw === "string" && qRaw.trim() ? qRaw.trim() : null;
+
+		const fromRaw = searchParams.get("from");
+		const toRaw = searchParams.get("to");
+
+		const from =
+			typeof fromRaw === "string" && fromRaw.trim() ? fromRaw.trim() : null;
+		const to = typeof toRaw === "string" && toRaw.trim() ? toRaw.trim() : null;
+
+		if (from && !isValidIsoDate(from)) {
+			throw badRequest("VALIDATION_SEARCH_DATE", "Invalid from date", { from });
+		}
+		if (to && !isValidIsoDate(to)) {
+			throw badRequest("VALIDATION_SEARCH_DATE", "Invalid to date", { to });
+		}
+		if (from && to && from > to) {
+			throw badRequest("VALIDATION_SEARCH_RANGE", "Invalid date range", {
+				from,
+				to,
+			});
+		}
+
+		// At least one of q or date range must be provided to avoid "match everything"
+		// queries by accident (especially dangerous for global admin searches).
+		if (!q && !from && !to) {
+			throw badRequest(
+				"VALIDATION_SEARCH_MISSING_FILTER",
+				"At least one of q or date range must be provided"
+			);
+		}
+
+		const limit = parseLimitOrThrow(searchParams.get("limit"));
+		const cursor = searchParams.get("cursor");
+
+		const branchParam = searchParams.get("branch");
+		const branchesParam = searchParams.get("branches");
+
+		const requestedSingleBranch =
+			typeof branchParam === "string" && branchParam.trim()
+				? branchParam.trim()
+				: null;
+
+		const requestedBranches =
+			branchesParam && String(branchesParam).trim()
+				? unique(parseBranchesCsv(branchesParam))
+				: [];
+
+		// RBAC: branch users are forced to their own branch.
+		if (session.role === "branch") {
+			if (!session.branchId) {
+				throw forbidden("AUTH_FORBIDDEN_BRANCH", "Forbidden");
+			}
+
+			// If the caller attempts to query other branches, reject.
+			if (requestedSingleBranch && requestedSingleBranch !== session.branchId) {
+				throw forbidden("AUTH_FORBIDDEN_BRANCH", "Forbidden");
+			}
+			if (requestedBranches.length > 0) {
+				const hasForeign = requestedBranches.some(
+					(b) => b !== session.branchId
+				);
+				if (hasForeign) throw forbidden("AUTH_FORBIDDEN_BRANCH", "Forbidden");
+			}
+			if (scope === "all") {
+				throw forbidden("AUTH_FORBIDDEN_BRANCH", "Forbidden");
+			}
+
+			return json(
+				await searchBackend({
+					mode: "branch",
+					branches: [session.branchId],
+					q,
+					from,
+					to,
+					limit,
+					cursor: cursor ? String(cursor) : null,
+				}),
+				200
+			);
+		}
+
+		// Admin/dev: scope can be branch/multi/all
+		let mode;
+		let branches = null;
+
+		if (scope === "all") {
+			mode = "all";
+			branches = null;
+		} else if (requestedBranches.length > 0 || scope === "multi") {
+			mode = "multi";
+			branches = requestedBranches;
+
+			if (!Array.isArray(branches) || branches.length === 0) {
+				throw badRequest(
+					"VALIDATION_SEARCH_BRANCHES",
+					"Missing branches parameter for multi scope"
+				);
+			}
+		} else {
+			// Default to branch scope
+			mode = "branch";
+			if (!requestedSingleBranch) {
+				throw badRequest(
+					"VALIDATION_SEARCH_BRANCH",
+					"Missing branch parameter for branch scope"
+				);
+			}
+			branches = [requestedSingleBranch];
+		}
+
+		// Validate branch patterns (even for admin/dev) to avoid weird inputs.
+		if (mode === "branch" || mode === "multi") {
+			for (const b of branches) {
+				if (!BRANCH_RE.test(b)) {
+					throw badRequest(
+						"VALIDATION_SEARCH_BRANCH",
+						"Invalid branch parameter",
+						{
+							branch: b,
+						}
+					);
+				}
+				// Enforce RBAC helper semantics consistently, even though admin/dev will pass.
+				if (!canAccessBranch(session, b)) {
+					throw forbidden("AUTH_FORBIDDEN_BRANCH", "Forbidden");
+				}
+			}
+		}
+
+		const result = await searchBackend({
+			mode,
+			branches,
+			q,
+			from,
+			to,
+			limit,
+			cursor: cursor ? String(cursor) : null,
+		});
+
+		return json(result, 200);
+	},
+	{ logPrefix: "[api/search]" }
+);

+ 137 - 0
app/api/search/route.test.js

@@ -0,0 +1,137 @@
+/* @vitest-environment node */
+
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+vi.mock("@/lib/auth/session", () => ({
+	getSession: vi.fn(),
+}));
+
+vi.mock("@/lib/search", () => ({
+	search: vi.fn(),
+}));
+
+import { getSession } from "@/lib/auth/session";
+import { search as searchBackend } from "@/lib/search";
+import { GET, dynamic } from "./route.js";
+
+describe("GET /api/search", () => {
+	beforeEach(() => {
+		vi.clearAllMocks();
+	});
+
+	it('exports dynamic="force-dynamic"', () => {
+		expect(dynamic).toBe("force-dynamic");
+	});
+
+	it("returns 401 when unauthenticated", async () => {
+		getSession.mockResolvedValue(null);
+
+		const req = new Request("http://localhost/api/search?q=test&branch=NL01");
+		const res = await GET(req);
+
+		expect(res.status).toBe(401);
+		expect(await res.json()).toEqual({
+			error: { message: "Unauthorized", code: "AUTH_UNAUTHENTICATED" },
+		});
+	});
+
+	it("returns 403 when branch user tries to access a different branch", async () => {
+		getSession.mockResolvedValue({
+			userId: "u1",
+			role: "branch",
+			branchId: "NL01",
+		});
+
+		const req = new Request("http://localhost/api/search?q=test&branch=NL02");
+		const res = await GET(req);
+
+		expect(res.status).toBe(403);
+		expect(await res.json()).toEqual({
+			error: { message: "Forbidden", code: "AUTH_FORBIDDEN_BRANCH" },
+		});
+	});
+
+	it("returns 400 when missing both q and date range", async () => {
+		getSession.mockResolvedValue({
+			userId: "u2",
+			role: "admin",
+			branchId: null,
+		});
+
+		const req = new Request("http://localhost/api/search?branch=NL01");
+		const res = await GET(req);
+
+		expect(res.status).toBe(400);
+		expect(await res.json()).toMatchObject({
+			error: {
+				code: "VALIDATION_SEARCH_MISSING_FILTER",
+			},
+		});
+	});
+
+	it("returns 400 for invalid date format", async () => {
+		getSession.mockResolvedValue({
+			userId: "u2",
+			role: "admin",
+			branchId: null,
+		});
+
+		const req = new Request(
+			"http://localhost/api/search?branch=NL01&q=x&from=2025/01/01"
+		);
+		const res = await GET(req);
+
+		expect(res.status).toBe(400);
+		expect(await res.json()).toMatchObject({
+			error: {
+				code: "VALIDATION_SEARCH_DATE",
+			},
+		});
+	});
+
+	it("returns 200 and passes through items + nextCursor", async () => {
+		getSession.mockResolvedValue({
+			userId: "u2",
+			role: "admin",
+			branchId: null,
+		});
+
+		searchBackend.mockResolvedValue({
+			items: [
+				{
+					branch: "NL20",
+					date: "2025-12-18",
+					year: "2025",
+					month: "12",
+					day: "18",
+					filename: "x.pdf",
+					relativePath: "NL20/2025/12/18/x.pdf",
+				},
+			],
+			nextCursor: "abc",
+		});
+
+		const req = new Request(
+			"http://localhost/api/search?branch=NL20&q=bridgestone&limit=100"
+		);
+		const res = await GET(req);
+
+		expect(res.status).toBe(200);
+		expect(await res.json()).toEqual({
+			items: [
+				{
+					branch: "NL20",
+					date: "2025-12-18",
+					year: "2025",
+					month: "12",
+					day: "18",
+					filename: "x.pdf",
+					relativePath: "NL20/2025/12/18/x.pdf",
+				},
+			],
+			nextCursor: "abc",
+		});
+
+		expect(searchBackend).toHaveBeenCalledTimes(1);
+	});
+});