|
|
@@ -0,0 +1,239 @@
|
|
|
+// scripts/manual-api-client-flow.mjs
|
|
|
+/**
|
|
|
+ * Manual API verification script for RHL-008.
|
|
|
+ *
|
|
|
+ * What this does:
|
|
|
+ * - Uses the frontend apiClient helpers against a real running app instance.
|
|
|
+ * - Implements a tiny cookie jar so login sessions work in Node (fetch has no cookie jar).
|
|
|
+ * - Runs a happy-path flow:
|
|
|
+ * login -> branches -> years -> months -> days -> files -> logout
|
|
|
+ * - Also runs a few negative tests (401/403/400/404).
|
|
|
+ *
|
|
|
+ * Usage:
|
|
|
+ * node scripts/manual-api-client-flow.mjs \
|
|
|
+ * --baseUrl=http://localhost:3000 \
|
|
|
+ * --username=branchuser \
|
|
|
+ * --password=secret-password \
|
|
|
+ * --branch=NL01
|
|
|
+ */
|
|
|
+
|
|
|
+import {
|
|
|
+ apiFetch,
|
|
|
+ login,
|
|
|
+ logout,
|
|
|
+ getBranches,
|
|
|
+ getYears,
|
|
|
+ getMonths,
|
|
|
+ getDays,
|
|
|
+ getFiles,
|
|
|
+ ApiClientError,
|
|
|
+} from "../lib/frontend/apiClient.js";
|
|
|
+
|
|
|
+function getArg(name, fallback = null) {
|
|
|
+ const prefix = `--${name}=`;
|
|
|
+ const hit = process.argv.find((a) => a.startsWith(prefix));
|
|
|
+ if (!hit) return fallback;
|
|
|
+ return hit.slice(prefix.length);
|
|
|
+}
|
|
|
+
|
|
|
+function pickLatest(items) {
|
|
|
+ if (!Array.isArray(items) || items.length === 0) return null;
|
|
|
+ // Backend currently returns ascending; "latest" is the last one.
|
|
|
+ return items[items.length - 1];
|
|
|
+}
|
|
|
+
|
|
|
+function logStep(title) {
|
|
|
+ console.log(`\n=== ${title} ===`);
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Minimal cookie jar:
|
|
|
+ * - Extracts auth_session from Set-Cookie
|
|
|
+ * - Sends it back via Cookie header on subsequent requests
|
|
|
+ */
|
|
|
+function createCookieJarFetch() {
|
|
|
+ const jar = new Map(); // cookieName -> cookieValue
|
|
|
+
|
|
|
+ return async function cookieFetch(url, init = {}) {
|
|
|
+ const headers = new Headers(init.headers || {});
|
|
|
+
|
|
|
+ // Attach cookies (only what we stored).
|
|
|
+ if (jar.size > 0) {
|
|
|
+ const cookieHeader = Array.from(jar.entries())
|
|
|
+ .map(([k, v]) => `${k}=${v}`)
|
|
|
+ .join("; ");
|
|
|
+ headers.set("cookie", cookieHeader);
|
|
|
+ }
|
|
|
+
|
|
|
+ const res = await fetch(url, { ...init, headers });
|
|
|
+
|
|
|
+ // Node/undici provides getSetCookie() in many versions. Fallback to get('set-cookie').
|
|
|
+ const setCookies =
|
|
|
+ typeof res.headers.getSetCookie === "function"
|
|
|
+ ? res.headers.getSetCookie()
|
|
|
+ : res.headers.get("set-cookie")
|
|
|
+ ? [res.headers.get("set-cookie")]
|
|
|
+ : [];
|
|
|
+
|
|
|
+ for (const sc of setCookies) {
|
|
|
+ if (!sc) continue;
|
|
|
+
|
|
|
+ // We only need auth_session for this project.
|
|
|
+ const match = sc.match(/(?:^|;\s*)auth_session=([^;]+)/);
|
|
|
+ if (match && match[1]) {
|
|
|
+ jar.set("auth_session", match[1]);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return res;
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+async function expectApiError(promise, { status, code }) {
|
|
|
+ try {
|
|
|
+ await promise;
|
|
|
+ console.error("Expected an error, but the call succeeded.");
|
|
|
+ process.exitCode = 1;
|
|
|
+ } catch (err) {
|
|
|
+ if (!(err instanceof ApiClientError)) {
|
|
|
+ console.error("Expected ApiClientError, got:", err);
|
|
|
+ process.exitCode = 1;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ console.log("Got expected error:", {
|
|
|
+ status: err.status,
|
|
|
+ code: err.code,
|
|
|
+ message: err.message,
|
|
|
+ });
|
|
|
+
|
|
|
+ if (status !== undefined && err.status !== status) {
|
|
|
+ console.error(`Expected status=${status}, got ${err.status}`);
|
|
|
+ process.exitCode = 1;
|
|
|
+ }
|
|
|
+ if (code !== undefined && err.code !== code) {
|
|
|
+ console.error(`Expected code=${code}, got ${err.code}`);
|
|
|
+ process.exitCode = 1;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function main() {
|
|
|
+ const baseUrl = getArg("baseUrl", "http://localhost:3000");
|
|
|
+ const username = getArg("username");
|
|
|
+ const password = getArg("password");
|
|
|
+ const preferredBranch = getArg("branch"); // optional
|
|
|
+
|
|
|
+ if (!username || !password) {
|
|
|
+ console.error(
|
|
|
+ "Missing credentials. Provide --username=... and --password=..."
|
|
|
+ );
|
|
|
+ process.exit(1);
|
|
|
+ }
|
|
|
+
|
|
|
+ const fetchImpl = createCookieJarFetch();
|
|
|
+
|
|
|
+ logStep("NEGATIVE: Unauthenticated call should be 401");
|
|
|
+ await expectApiError(getBranches({ baseUrl, fetchImpl }), {
|
|
|
+ status: 401,
|
|
|
+ code: "AUTH_UNAUTHENTICATED",
|
|
|
+ });
|
|
|
+
|
|
|
+ logStep("LOGIN");
|
|
|
+ const loginRes = await login({ username, password }, { baseUrl, fetchImpl });
|
|
|
+ console.log("login:", loginRes);
|
|
|
+
|
|
|
+ logStep("OPTIONAL: /api/auth/me (if implemented)");
|
|
|
+ try {
|
|
|
+ const me = await apiFetch("/api/auth/me", { baseUrl, fetchImpl });
|
|
|
+ console.log("me:", me);
|
|
|
+ } catch (err) {
|
|
|
+ // If the endpoint doesn't exist, Next may return HTML 404 -> CLIENT_HTTP_ERROR.
|
|
|
+ console.log("me endpoint not available (ok):", {
|
|
|
+ status: err?.status,
|
|
|
+ code: err?.code,
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ logStep("GET BRANCHES");
|
|
|
+ const branchesRes = await getBranches({ baseUrl, fetchImpl });
|
|
|
+ console.log("branches:", branchesRes);
|
|
|
+
|
|
|
+ const branch =
|
|
|
+ preferredBranch ||
|
|
|
+ (Array.isArray(branchesRes?.branches) ? branchesRes.branches[0] : null);
|
|
|
+
|
|
|
+ if (!branch) {
|
|
|
+ console.error("No branch found. Check NAS mount / fixtures.");
|
|
|
+ process.exit(1);
|
|
|
+ }
|
|
|
+
|
|
|
+ logStep("NEGATIVE: Forbidden branch access should be 403 (branch users)");
|
|
|
+ // Even if only one branch exists, NL99 should trigger forbidden for branch role.
|
|
|
+ await expectApiError(getYears("NL99", { baseUrl, fetchImpl }), {
|
|
|
+ status: 403,
|
|
|
+ code: "AUTH_FORBIDDEN_BRANCH",
|
|
|
+ });
|
|
|
+
|
|
|
+ logStep(`GET YEARS for ${branch}`);
|
|
|
+ const yearsRes = await getYears(branch, { baseUrl, fetchImpl });
|
|
|
+ console.log("years:", yearsRes);
|
|
|
+
|
|
|
+ const year = pickLatest(yearsRes?.years);
|
|
|
+ if (!year) {
|
|
|
+ console.error("No year folders found for branch:", branch);
|
|
|
+ process.exit(1);
|
|
|
+ }
|
|
|
+
|
|
|
+ logStep("NEGATIVE: Not found year should be 404 (authorized)");
|
|
|
+ await expectApiError(getMonths(branch, "2099", { baseUrl, fetchImpl }), {
|
|
|
+ status: 404,
|
|
|
+ code: "FS_NOT_FOUND",
|
|
|
+ });
|
|
|
+
|
|
|
+ logStep(`GET MONTHS for ${branch}/${year}`);
|
|
|
+ const monthsRes = await getMonths(branch, year, { baseUrl, fetchImpl });
|
|
|
+ console.log("months:", monthsRes);
|
|
|
+
|
|
|
+ const month = pickLatest(monthsRes?.months);
|
|
|
+ if (!month) {
|
|
|
+ console.error("No month folders found for:", branch, year);
|
|
|
+ process.exit(1);
|
|
|
+ }
|
|
|
+
|
|
|
+ logStep(`GET DAYS for ${branch}/${year}/${month}`);
|
|
|
+ const daysRes = await getDays(branch, year, month, { baseUrl, fetchImpl });
|
|
|
+ console.log("days:", daysRes);
|
|
|
+
|
|
|
+ const day = pickLatest(daysRes?.days);
|
|
|
+ if (!day) {
|
|
|
+ console.error("No day folders found for:", branch, year, month);
|
|
|
+ process.exit(1);
|
|
|
+ }
|
|
|
+
|
|
|
+ logStep(
|
|
|
+ "NEGATIVE: Validation error on /api/files (missing query params) -> 400"
|
|
|
+ );
|
|
|
+ await expectApiError(apiFetch("/api/files", { baseUrl, fetchImpl }), {
|
|
|
+ status: 400,
|
|
|
+ code: "VALIDATION_MISSING_QUERY",
|
|
|
+ });
|
|
|
+
|
|
|
+ logStep(`GET FILES for ${branch}/${year}/${month}/${day}`);
|
|
|
+ const filesRes = await getFiles(branch, year, month, day, {
|
|
|
+ baseUrl,
|
|
|
+ fetchImpl,
|
|
|
+ });
|
|
|
+ console.log("files:", filesRes);
|
|
|
+
|
|
|
+ logStep("LOGOUT");
|
|
|
+ const logoutRes = await logout({ baseUrl, fetchImpl });
|
|
|
+ console.log("logout:", logoutRes);
|
|
|
+
|
|
|
+ console.log("\nDone.");
|
|
|
+}
|
|
|
+
|
|
|
+main().catch((err) => {
|
|
|
+ console.error("Script failed:", err);
|
|
|
+ process.exit(1);
|
|
|
+});
|