Browse Source

RHI-004-feat(auth): implement session-based access control for API routes and enhance tests

Code_Uwe 2 days ago
parent
commit
bdac8a94bc

+ 12 - 4
app/api/branches/[branch]/[year]/[month]/days/route.js

@@ -1,14 +1,19 @@
 // app/api/branches/[branch]/[year]/[month]/days/route.js
 import { NextResponse } from "next/server";
 import { listDays } from "@/lib/storage";
+import { getSession } from "@/lib/auth/session";
+import { canAccessBranch } from "@/lib/auth/permissions";
 
 /**
  * GET /api/branches/[branch]/[year]/[month]/days
- *
- * Returns the list of day folders for a given branch, year, and month.
- * Example: /api/branches/NL01/2024/10/days → { days: ["01", "02", ...] }
  */
 export async function GET(request, ctx) {
+	const session = await getSession();
+
+	if (!session) {
+		return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+	}
+
 	const { branch, year, month } = await ctx.params;
 	console.log("[/api/branches/[branch]/[year]/[month]/days] params:", {
 		branch,
@@ -23,9 +28,12 @@ export async function GET(request, ctx) {
 		);
 	}
 
+	if (!canAccessBranch(session, branch)) {
+		return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+	}
+
 	try {
 		const days = await listDays(branch, year, month);
-
 		return NextResponse.json({ branch, year, month, days });
 	} catch (error) {
 		console.error("[/api/branches/[branch]/[year]/[month]/days] Error:", error);

+ 89 - 36
app/api/branches/[branch]/[year]/[month]/days/route.test.js

@@ -1,41 +1,83 @@
-// app/api/branches/[branch]/[year]/[month]/days/route.test.js
-import { describe, it, expect, beforeAll, afterAll } from "vitest";
+/* @vitest-environment node */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
 import fs from "node:fs/promises";
 import os from "node:os";
 import path from "node:path";
 
-import { GET as getDays } from "./route.js";
+vi.mock("@/lib/auth/session", () => ({
+	getSession: vi.fn(),
+}));
+
+import { getSession } from "@/lib/auth/session";
+import { GET } from "./route.js";
 
-let tmpRoot;
+describe("GET /api/branches/[branch]/[year]/[month]/days", () => {
+	let tmpRoot;
+	const originalNasRoot = process.env.NAS_ROOT_PATH;
 
-beforeAll(async () => {
-	tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "api-days-"));
-	process.env.NAS_ROOT_PATH = tmpRoot;
+	beforeEach(async () => {
+		vi.clearAllMocks();
+		tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "api-days-"));
+		process.env.NAS_ROOT_PATH = tmpRoot;
 
-	// tmpRoot/NL01/2024/10/23
-	await fs.mkdir(path.join(tmpRoot, "NL01", "2024", "10", "23"), {
-		recursive: true,
+		await fs.mkdir(path.join(tmpRoot, "NL01", "2024", "10", "23"), {
+			recursive: true,
+		});
 	});
-});
 
-afterAll(async () => {
-	await fs.rm(tmpRoot, { recursive: true, force: true });
-});
+	afterEach(async () => {
+		process.env.NAS_ROOT_PATH = originalNasRoot;
+		if (tmpRoot) await fs.rm(tmpRoot, { recursive: true, force: true });
+	});
 
-describe("GET /api/branches/[branch]/[year]/[month]/days", () => {
-	it("returns days for a valid branch/year/month", async () => {
-		const req = new Request("http://localhost/api/branches/NL01/2024/10/days");
-		const ctx = {
-			params: Promise.resolve({
-				branch: "NL01",
-				year: "2024",
-				month: "10",
-			}),
-		};
-
-		const res = await getDays(req, ctx);
-		expect(res.status).toBe(200);
+	it("returns 401 when unauthenticated", async () => {
+		getSession.mockResolvedValue(null);
+
+		const res = await GET(
+			new Request("http://localhost/api/branches/NL01/2024/10/days"),
+			{
+				params: Promise.resolve({ branch: "NL01", year: "2024", month: "10" }),
+			}
+		);
+
+		expect(res.status).toBe(401);
+		expect(await res.json()).toEqual({ error: "Unauthorized" });
+	});
+
+	it("returns 403 when branch user accesses a different branch", async () => {
+		getSession.mockResolvedValue({
+			role: "branch",
+			branchId: "NL01",
+			userId: "u1",
+		});
+
+		const res = await GET(
+			new Request("http://localhost/api/branches/NL02/2024/10/days"),
+			{
+				params: Promise.resolve({ branch: "NL02", year: "2024", month: "10" }),
+			}
+		);
+
+		expect(res.status).toBe(403);
+		expect(await res.json()).toEqual({ error: "Forbidden" });
+	});
+
+	it("returns days for a valid branch/year/month when allowed", async () => {
+		getSession.mockResolvedValue({
+			role: "admin",
+			branchId: null,
+			userId: "u2",
+		});
+
+		const res = await GET(
+			new Request("http://localhost/api/branches/NL01/2024/10/days"),
+			{
+				params: Promise.resolve({ branch: "NL01", year: "2024", month: "10" }),
+			}
+		);
 
+		expect(res.status).toBe(200);
 		const body = await res.json();
 		expect(body).toEqual({
 			branch: "NL01",
@@ -45,16 +87,27 @@ describe("GET /api/branches/[branch]/[year]/[month]/days", () => {
 		});
 	});
 
-	it("returns 400 when any param is missing", async () => {
-		const req = new Request("http://localhost/api/branches/NL01/2024/10/days");
-		const ctx = {
-			params: Promise.resolve({ branch: "NL01", year: "2024" }), // month missing
-		};
+	it("returns 400 when any param is missing (authenticated)", async () => {
+		getSession.mockResolvedValue({
+			role: "admin",
+			branchId: null,
+			userId: "u2",
+		});
 
-		const res = await getDays(req, ctx);
-		expect(res.status).toBe(400);
+		const res = await GET(
+			new Request("http://localhost/api/branches/NL01/2024//days"),
+			{
+				params: Promise.resolve({
+					branch: "NL01",
+					year: "2024",
+					month: undefined,
+				}),
+			}
+		);
 
-		const body = await res.json();
-		expect(body.error).toBe("branch, year oder month fehlt");
+		expect(res.status).toBe(400);
+		expect(await res.json()).toEqual({
+			error: "branch, year oder month fehlt",
+		});
 	});
 });

+ 12 - 4
app/api/branches/[branch]/[year]/months/route.js

@@ -1,14 +1,19 @@
 // app/api/branches/[branch]/[year]/months/route.js
 import { NextResponse } from "next/server";
 import { listMonths } from "@/lib/storage";
+import { getSession } from "@/lib/auth/session";
+import { canAccessBranch } from "@/lib/auth/permissions";
 
 /**
  * GET /api/branches/[branch]/[year]/months
- *
- * Returns the list of month folders for a given branch and year.
- * Example: /api/branches/NL01/2024/months → { months: ["01", "02", ...] }
  */
 export async function GET(request, ctx) {
+	const session = await getSession();
+
+	if (!session) {
+		return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+	}
+
 	const { branch, year } = await ctx.params;
 	console.log("[/api/branches/[branch]/[year]/months] params:", {
 		branch,
@@ -22,9 +27,12 @@ export async function GET(request, ctx) {
 		);
 	}
 
+	if (!canAccessBranch(session, branch)) {
+		return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+	}
+
 	try {
 		const months = await listMonths(branch, year);
-
 		return NextResponse.json({ branch, year, months });
 	} catch (error) {
 		console.error("[/api/branches/[branch]/[year]/months] Error:", error);

+ 82 - 35
app/api/branches/[branch]/[year]/months/route.test.js

@@ -1,55 +1,102 @@
-// app/api/branches/[branch]/[year]/months/route.test.js
-import { describe, it, expect, beforeAll, afterAll } from "vitest";
+/* @vitest-environment node */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
 import fs from "node:fs/promises";
 import os from "node:os";
 import path from "node:path";
 
-import { GET as getMonths } from "./route.js";
+vi.mock("@/lib/auth/session", () => ({
+	getSession: vi.fn(),
+}));
+
+import { getSession } from "@/lib/auth/session";
+import { GET } from "./route.js";
 
-let tmpRoot;
+describe("GET /api/branches/[branch]/[year]/months", () => {
+	let tmpRoot;
+	const originalNasRoot = process.env.NAS_ROOT_PATH;
 
-beforeAll(async () => {
-	tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "api-months-"));
-	process.env.NAS_ROOT_PATH = tmpRoot;
+	beforeEach(async () => {
+		vi.clearAllMocks();
+		tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "api-months-"));
+		process.env.NAS_ROOT_PATH = tmpRoot;
 
-	// tmpRoot/NL01/2024/10
-	await fs.mkdir(path.join(tmpRoot, "NL01", "2024", "10"), {
-		recursive: true,
+		await fs.mkdir(path.join(tmpRoot, "NL01", "2024", "10"), {
+			recursive: true,
+		});
 	});
-});
 
-afterAll(async () => {
-	await fs.rm(tmpRoot, { recursive: true, force: true });
-});
+	afterEach(async () => {
+		process.env.NAS_ROOT_PATH = originalNasRoot;
+		if (tmpRoot) await fs.rm(tmpRoot, { recursive: true, force: true });
+	});
 
-describe("GET /api/branches/[branch]/[year]/months", () => {
-	it("returns months for a valid branch/year", async () => {
-		const req = new Request("http://localhost/api/branches/NL01/2024/months");
-		const ctx = {
-			params: Promise.resolve({ branch: "NL01", year: "2024" }),
-		};
+	it("returns 401 when unauthenticated", async () => {
+		getSession.mockResolvedValue(null);
 
-		const res = await getMonths(req, ctx);
-		expect(res.status).toBe(200);
+		const res = await GET(
+			new Request("http://localhost/api/branches/NL01/2024/months"),
+			{
+				params: Promise.resolve({ branch: "NL01", year: "2024" }),
+			}
+		);
 
-		const body = await res.json();
-		expect(body).toEqual({
-			branch: "NL01",
-			year: "2024",
-			months: ["10"],
+		expect(res.status).toBe(401);
+		expect(await res.json()).toEqual({ error: "Unauthorized" });
+	});
+
+	it("returns 403 when branch user accesses a different branch", async () => {
+		getSession.mockResolvedValue({
+			role: "branch",
+			branchId: "NL01",
+			userId: "u1",
 		});
+
+		const res = await GET(
+			new Request("http://localhost/api/branches/NL02/2024/months"),
+			{
+				params: Promise.resolve({ branch: "NL02", year: "2024" }),
+			}
+		);
+
+		expect(res.status).toBe(403);
+		expect(await res.json()).toEqual({ error: "Forbidden" });
 	});
 
-	it("returns 400 when branch or year is missing", async () => {
-		const req = new Request("http://localhost/api/branches/NL01/2024/months");
-		const ctx = {
-			params: Promise.resolve({ branch: "NL01" }), // year missing
-		};
+	it("returns months for a valid branch/year when allowed", async () => {
+		getSession.mockResolvedValue({
+			role: "admin",
+			branchId: null,
+			userId: "u2",
+		});
 
-		const res = await getMonths(req, ctx);
-		expect(res.status).toBe(400);
+		const res = await GET(
+			new Request("http://localhost/api/branches/NL01/2024/months"),
+			{
+				params: Promise.resolve({ branch: "NL01", year: "2024" }),
+			}
+		);
 
+		expect(res.status).toBe(200);
 		const body = await res.json();
-		expect(body.error).toBe("branch oder year fehlt");
+		expect(body).toEqual({ branch: "NL01", year: "2024", months: ["10"] });
+	});
+
+	it("returns 400 when branch or year is missing (authenticated)", async () => {
+		getSession.mockResolvedValue({
+			role: "admin",
+			branchId: null,
+			userId: "u2",
+		});
+
+		const res = await GET(
+			new Request("http://localhost/api/branches/NL01//months"),
+			{
+				params: Promise.resolve({ branch: "NL01", year: undefined }),
+			}
+		);
+
+		expect(res.status).toBe(400);
+		expect(await res.json()).toEqual({ error: "branch oder year fehlt" });
 	});
 });

+ 12 - 5
app/api/branches/[branch]/years/route.js

@@ -1,19 +1,23 @@
 // app/api/branches/[branch]/years/route.js
 import { NextResponse } from "next/server";
 import { listYears } from "@/lib/storage";
+import { getSession } from "@/lib/auth/session";
+import { canAccessBranch } from "@/lib/auth/permissions";
 
 /**
  * GET /api/branches/[branch]/years
- *
- * Returns the list of year folders for a given branch.
- * Example: /api/branches/NL01/years → { branch: "NL01", years: ["2023", "2024"] }
  */
 export async function GET(request, ctx) {
+	const session = await getSession();
+
+	if (!session) {
+		return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+	}
+
 	// Next.js 16: params are resolved asynchronously via ctx.params
 	const { branch } = await ctx.params;
 	console.log("[/api/branches/[branch]/years] params:", { branch });
 
-	// Basic validation of required params
 	if (!branch) {
 		return NextResponse.json(
 			{ error: "branch Parameter fehlt" },
@@ -21,9 +25,12 @@ export async function GET(request, ctx) {
 		);
 	}
 
+	if (!canAccessBranch(session, branch)) {
+		return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+	}
+
 	try {
 		const years = await listYears(branch);
-
 		return NextResponse.json({ branch, years });
 	} catch (error) {
 		console.error("[/api/branches/[branch]/years] Error:", error);

+ 90 - 46
app/api/branches/[branch]/years/route.test.js

@@ -1,75 +1,119 @@
-// app/api/branches/[branch]/years/route.test.js
-import { describe, it, expect, beforeAll, afterAll } from "vitest";
+/* @vitest-environment node */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
 import fs from "node:fs/promises";
 import os from "node:os";
 import path from "node:path";
 
-import { GET as getYears } from "./route.js";
+vi.mock("@/lib/auth/session", () => ({
+	getSession: vi.fn(),
+}));
+
+import { getSession } from "@/lib/auth/session";
+import { GET } from "./route.js";
 
-let tmpRoot;
+describe("GET /api/branches/[branch]/years", () => {
+	let tmpRoot;
+	const originalNasRoot = process.env.NAS_ROOT_PATH;
 
-beforeAll(async () => {
-	tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "api-years-"));
-	process.env.NAS_ROOT_PATH = tmpRoot;
+	beforeEach(async () => {
+		vi.clearAllMocks();
+		tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "api-years-"));
+		process.env.NAS_ROOT_PATH = tmpRoot;
 
-	// tmpRoot/NL01/2024
-	await fs.mkdir(path.join(tmpRoot, "NL01", "2024"), {
-		recursive: true,
+		// Minimal structure for NL01
+		await fs.mkdir(path.join(tmpRoot, "NL01", "2024"), { recursive: true });
 	});
-});
 
-afterAll(async () => {
-	await fs.rm(tmpRoot, { recursive: true, force: true });
-});
+	afterEach(async () => {
+		process.env.NAS_ROOT_PATH = originalNasRoot;
+		if (tmpRoot) await fs.rm(tmpRoot, { recursive: true, force: true });
+	});
 
-describe("GET /api/branches/[branch]/years", () => {
-	it("returns years for a valid branch", async () => {
-		const req = new Request("http://localhost/api/branches/NL01/years");
+	it("returns 401 when unauthenticated", async () => {
+		getSession.mockResolvedValue(null);
 
-		// In Next.js 16, ctx.params is a Promise the framework would resolve.
-		// In tests we simulate that.
-		const ctx = {
-			params: Promise.resolve({ branch: "NL01" }),
-		};
+		const res = await GET(
+			new Request("http://localhost/api/branches/NL01/years"),
+			{
+				params: Promise.resolve({ branch: "NL01" }),
+			}
+		);
 
-		const res = await getYears(req, ctx);
-		expect(res.status).toBe(200);
+		expect(res.status).toBe(401);
+		expect(await res.json()).toEqual({ error: "Unauthorized" });
+	});
 
-		const body = await res.json();
-		expect(body).toEqual({
-			branch: "NL01",
-			years: ["2024"],
+	it("returns 403 when branch user accesses a different branch", async () => {
+		getSession.mockResolvedValue({
+			role: "branch",
+			branchId: "NL01",
+			userId: "u1",
 		});
+
+		const res = await GET(
+			new Request("http://localhost/api/branches/NL02/years"),
+			{
+				params: Promise.resolve({ branch: "NL02" }),
+			}
+		);
+
+		expect(res.status).toBe(403);
+		expect(await res.json()).toEqual({ error: "Forbidden" });
 	});
 
-	it("returns 400 when branch param is missing", async () => {
-		const req = new Request("http://localhost/api/branches/UNKNOWN/years");
-		const ctx = {
-			params: Promise.resolve({}), // no branch
-		};
+	it("returns years for a valid branch when allowed", async () => {
+		getSession.mockResolvedValue({
+			role: "branch",
+			branchId: "NL01",
+			userId: "u1",
+		});
 
-		const res = await getYears(req, ctx);
-		expect(res.status).toBe(400);
+		const res = await GET(
+			new Request("http://localhost/api/branches/NL01/years"),
+			{
+				params: Promise.resolve({ branch: "NL01" }),
+			}
+		);
 
+		expect(res.status).toBe(200);
 		const body = await res.json();
-		expect(body.error).toBe("branch Parameter fehlt");
+		expect(body).toEqual({ branch: "NL01", years: ["2024"] });
 	});
 
-	it("returns 500 when NAS_ROOT_PATH is invalid", async () => {
-		const originalRoot = process.env.NAS_ROOT_PATH;
+	it("returns 400 when branch param is missing (authenticated)", async () => {
+		getSession.mockResolvedValue({
+			role: "admin",
+			branchId: null,
+			userId: "u2",
+		});
+
+		const res = await GET(new Request("http://localhost/api/branches//years"), {
+			params: Promise.resolve({ branch: undefined }),
+		});
+
+		expect(res.status).toBe(400);
+		expect(await res.json()).toEqual({ error: "branch Parameter fehlt" });
+	});
+
+	it("returns 500 when NAS_ROOT_PATH is invalid (authenticated)", async () => {
+		getSession.mockResolvedValue({
+			role: "admin",
+			branchId: null,
+			userId: "u2",
+		});
+
 		process.env.NAS_ROOT_PATH = path.join(tmpRoot, "does-not-exist");
 
-		const req = new Request("http://localhost/api/branches/NL01/years");
-		const ctx = {
-			params: Promise.resolve({ branch: "NL01" }),
-		};
+		const res = await GET(
+			new Request("http://localhost/api/branches/NL01/years"),
+			{
+				params: Promise.resolve({ branch: "NL01" }),
+			}
+		);
 
-		const res = await getYears(req, ctx);
 		expect(res.status).toBe(500);
-
 		const body = await res.json();
 		expect(body.error).toContain("Fehler beim Lesen der Jahre:");
-
-		process.env.NAS_ROOT_PATH = originalRoot;
 	});
 });

+ 15 - 3
app/api/branches/route.js

@@ -1,23 +1,35 @@
 // app/api/branches/route.js
 import { NextResponse } from "next/server";
 import { listBranches } from "@/lib/storage";
+import { getSession } from "@/lib/auth/session";
+import { filterBranchesForSession } from "@/lib/auth/permissions";
 
 /**
  * GET /api/branches
  *
  * Returns the list of branches (e.g. ["NL01", "NL02", ...]) based on the
  * directory names under NAS_ROOT_PATH.
+ *
+ * RBAC:
+ * - 401 if no session
+ * - branch role: only returns its own branch
+ * - admin/dev: returns all branches
  */
 export async function GET() {
+	const session = await getSession();
+
+	if (!session) {
+		return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+	}
+
 	try {
 		const branches = await listBranches();
+		const visibleBranches = filterBranchesForSession(session, branches);
 
-		return NextResponse.json({ branches });
+		return NextResponse.json({ branches: visibleBranches });
 	} catch (error) {
-		// Log the full error on the server for debugging
 		console.error("[api/branches] Error reading branches:", error);
 
-		// Return a generic error message to the client
 		return NextResponse.json(
 			{ error: "Fehler beim Lesen der Niederlassungen" },
 			{ status: 500 }

+ 69 - 29
app/api/branches/route.test.js

@@ -1,51 +1,91 @@
-// app/api/branches/route.test.js
-import { describe, it, expect, beforeAll, afterAll } from "vitest";
+/* @vitest-environment node */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
 import fs from "node:fs/promises";
 import os from "node:os";
 import path from "node:path";
 
-import { GET as getBranches } from "./route.js";
+vi.mock("@/lib/auth/session", () => ({
+	getSession: vi.fn(),
+}));
 
-let tmpRoot;
+import { getSession } from "@/lib/auth/session";
+import { GET } from "./route.js";
 
-beforeAll(async () => {
-	// Create a dedicated temporary root for this test suite
-	tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "api-branches-"));
+describe("GET /api/branches", () => {
+	let tmpRoot;
+	const originalNasRoot = process.env.NAS_ROOT_PATH;
 
-	// Make this temp root the NAS root for the duration of these tests
-	process.env.NAS_ROOT_PATH = tmpRoot;
+	beforeEach(async () => {
+		vi.clearAllMocks();
+		tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "api-branches-"));
+		process.env.NAS_ROOT_PATH = tmpRoot;
+	});
 
-	// Create a branch and a snapshot folder
-	await fs.mkdir(path.join(tmpRoot, "NL01"), { recursive: true });
-	await fs.mkdir(path.join(tmpRoot, "@Recently-Snapshot"));
-});
+	afterEach(async () => {
+		process.env.NAS_ROOT_PATH = originalNasRoot;
+		if (tmpRoot) {
+			await fs.rm(tmpRoot, { recursive: true, force: true });
+		}
+	});
 
-afterAll(async () => {
-	await fs.rm(tmpRoot, { recursive: true, force: true });
-});
+	it("returns 401 when unauthenticated", async () => {
+		getSession.mockResolvedValue(null);
 
-describe("GET /api/branches", () => {
-	it("returns branch names and filters snapshot folders", async () => {
-		const req = new Request("http://localhost/api/branches");
-		const res = await getBranches(req);
+		const res = await GET();
+		expect(res.status).toBe(401);
+
+		const body = await res.json();
+		expect(body).toEqual({ error: "Unauthorized" });
+	});
+
+	it("returns only the own branch for branch users", async () => {
+		getSession.mockResolvedValue({
+			role: "branch",
+			branchId: "NL01",
+			userId: "u1",
+		});
+
+		await fs.mkdir(path.join(tmpRoot, "NL01"), { recursive: true });
+		await fs.mkdir(path.join(tmpRoot, "NL02"), { recursive: true });
+
+		const res = await GET();
 		expect(res.status).toBe(200);
 
 		const body = await res.json();
-		expect(body).toEqual({ branches: ["NL01"] });
+		expect(body.branches).toEqual(["NL01"]);
 	});
 
-	it("returns 500 when NAS_ROOT_PATH is invalid", async () => {
-		const originalRoot = process.env.NAS_ROOT_PATH;
+	it("returns all branches for admin/dev users", async () => {
+		getSession.mockResolvedValue({
+			role: "admin",
+			branchId: null,
+			userId: "u2",
+		});
+
+		await fs.mkdir(path.join(tmpRoot, "NL01"), { recursive: true });
+		await fs.mkdir(path.join(tmpRoot, "NL02"), { recursive: true });
+
+		const res = await GET();
+		expect(res.status).toBe(200);
+
+		const body = await res.json();
+		expect([...body.branches].sort()).toEqual(["NL01", "NL02"]);
+	});
+
+	it("returns 500 when NAS_ROOT_PATH is invalid (authenticated)", async () => {
+		getSession.mockResolvedValue({
+			role: "admin",
+			branchId: null,
+			userId: "u2",
+		});
+
 		process.env.NAS_ROOT_PATH = path.join(tmpRoot, "does-not-exist");
 
-		const req = new Request("http://localhost/api/branches");
-		const res = await getBranches(req);
+		const res = await GET();
 		expect(res.status).toBe(500);
 
 		const body = await res.json();
-		expect(body.error).toContain("Fehler beim Lesen der Niederlassungen");
-
-		// Restore valid root for subsequent tests
-		process.env.NAS_ROOT_PATH = originalRoot;
+		expect(body).toEqual({ error: "Fehler beim Lesen der Niederlassungen" });
 	});
 });

+ 12 - 6
app/api/files/route.js

@@ -1,15 +1,19 @@
 // app/api/files/route.js
 import { NextResponse } from "next/server";
 import { listFiles } from "@/lib/storage";
+import { getSession } from "@/lib/auth/session";
+import { canAccessBranch } from "@/lib/auth/permissions";
 
 /**
  * GET /api/files?branch=&year=&month=&day=
- *
- * Returns the list of PDF files for a specific branch + date.
- * Example:
- *   /api/files?branch=NL01&year=2024&month=10&day=23
  */
 export async function GET(request) {
+	const session = await getSession();
+
+	if (!session) {
+		return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+	}
+
 	const { searchParams } = new URL(request.url);
 	const branch = searchParams.get("branch");
 	const year = searchParams.get("year");
@@ -18,7 +22,6 @@ export async function GET(request) {
 
 	console.log("[/api/files] query:", { branch, year, month, day });
 
-	// Validate required query params
 	if (!branch || !year || !month || !day) {
 		return NextResponse.json(
 			{ error: "branch, year, month, day sind erforderlich" },
@@ -26,9 +29,12 @@ export async function GET(request) {
 		);
 	}
 
+	if (!canAccessBranch(session, branch)) {
+		return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+	}
+
 	try {
 		const files = await listFiles(branch, year, month, day);
-
 		return NextResponse.json({ branch, year, month, day, files });
 	} catch (error) {
 		console.error("[/api/files] Error:", error);

+ 76 - 34
app/api/files/route.test.js

@@ -1,60 +1,102 @@
-// app/api/files/route.test.js
-import { describe, it, expect, beforeAll, afterAll } from "vitest";
+/* @vitest-environment node */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
 import fs from "node:fs/promises";
 import os from "node:os";
 import path from "node:path";
 
-import { GET as getFiles } from "./route.js";
+vi.mock("@/lib/auth/session", () => ({
+	getSession: vi.fn(),
+}));
+
+import { getSession } from "@/lib/auth/session";
+import { GET } from "./route.js";
 
-let tmpRoot;
+describe("GET /api/files", () => {
+	let tmpRoot;
+	const originalNasRoot = process.env.NAS_ROOT_PATH;
 
-beforeAll(async () => {
-	tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "api-files-"));
-	process.env.NAS_ROOT_PATH = tmpRoot;
+	beforeEach(async () => {
+		vi.clearAllMocks();
+		tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "api-files-"));
+		process.env.NAS_ROOT_PATH = tmpRoot;
 
-	// tmpRoot/NL01/2024/10/23/test.pdf
-	await fs.mkdir(path.join(tmpRoot, "NL01", "2024", "10", "23"), {
-		recursive: true,
+		const dir = path.join(tmpRoot, "NL01", "2024", "10", "23");
+		await fs.mkdir(dir, { recursive: true });
+		await fs.writeFile(path.join(dir, "test.pdf"), "dummy-pdf-content");
 	});
-	await fs.writeFile(
-		path.join(tmpRoot, "NL01", "2024", "10", "23", "test.pdf"),
-		"content"
-	);
-});
 
-afterAll(async () => {
-	await fs.rm(tmpRoot, { recursive: true, force: true });
-});
+	afterEach(async () => {
+		process.env.NAS_ROOT_PATH = originalNasRoot;
+		if (tmpRoot) await fs.rm(tmpRoot, { recursive: true, force: true });
+	});
+
+	it("returns 401 when unauthenticated", async () => {
+		getSession.mockResolvedValue(null);
+
+		const req = new Request(
+			"http://localhost/api/files?branch=NL01&year=2024&month=10&day=23"
+		);
+
+		const res = await GET(req);
+		expect(res.status).toBe(401);
+		expect(await res.json()).toEqual({ error: "Unauthorized" });
+	});
+
+	it("returns 403 when branch user accesses a different branch", async () => {
+		getSession.mockResolvedValue({
+			role: "branch",
+			branchId: "NL01",
+			userId: "u1",
+		});
+
+		const req = new Request(
+			"http://localhost/api/files?branch=NL02&year=2024&month=10&day=23"
+		);
+
+		const res = await GET(req);
+		expect(res.status).toBe(403);
+		expect(await res.json()).toEqual({ error: "Forbidden" });
+	});
+
+	it("returns files for a valid query when allowed", async () => {
+		getSession.mockResolvedValue({
+			role: "branch",
+			branchId: "NL01",
+			userId: "u1",
+		});
 
-describe("GET /api/files", () => {
-	it("returns files for a valid query", async () => {
 		const req = new Request(
 			"http://localhost/api/files?branch=NL01&year=2024&month=10&day=23"
 		);
 
-		const res = await getFiles(req);
+		const res = await GET(req);
 		expect(res.status).toBe(200);
 
 		const body = await res.json();
 		expect(body.branch).toBe("NL01");
-		expect(body.year).toBe("2024");
-		expect(body.month).toBe("10");
-		expect(body.day).toBe("23");
-		expect(body.files).toEqual([
-			{
-				name: "test.pdf",
-				relativePath: "NL01/2024/10/23/test.pdf",
-			},
-		]);
+		expect(body.files).toHaveLength(1);
+		expect(body.files[0]).toMatchObject({
+			name: "test.pdf",
+			relativePath: "NL01/2024/10/23/test.pdf",
+		});
 	});
 
-	it("returns 400 when query params are missing", async () => {
-		const req = new Request("http://localhost/api/files"); // no params
+	it("returns 400 when query params are missing (authenticated)", async () => {
+		getSession.mockResolvedValue({
+			role: "admin",
+			branchId: null,
+			userId: "u2",
+		});
+
+		const req = new Request("http://localhost/api/files");
 
-		const res = await getFiles(req);
+		const res = await GET(req);
 		expect(res.status).toBe(400);
 
 		const body = await res.json();
-		expect(body.error).toBe("branch, year, month, day sind erforderlich");
+		expect(body).toEqual({
+			error: "branch, year, month, day sind erforderlich",
+		});
 	});
 });