Fix document upload permissions and clock correction origin

This commit is contained in:
DEls 2026-09-29 13:26:47 +02:00
parent b700252932
commit 223da69884
10 changed files with 104 additions and 15 deletions

View file

@ -55,9 +55,10 @@ Coolify will not pick up the change otherwise. (Source:
`AUTH_MODE=users` uses the PocketBase `users` collection. An app admin sees all
sites and can add users at `/dashboard/settings/users`. New accounts default to
the editable initial password `mega2026` and must choose a new password on
first sign-in. Site users have read-only dashboard access to assigned sites;
unscoped editing APIs are admin-only. Clock-only users can use the clock PWA
and delivery capture for assigned sites. An admin can reset a user's password
first sign-in. Site users can view assigned sites and upload purchase orders
and delivery notes for those sites; unscoped editing APIs are admin-only.
Clock-only users can use the clock PWA and delivery capture for assigned sites.
An admin can reset a user's password
in the app or PocketBase and set `must_change_password` for the next login.
The migration is dry-run by default:

View file

@ -13,15 +13,15 @@ import { POST } from "./route";
function request(pin = "2468", origin = "https://mega.example", body: unknown = {
actualOutLocal: "2026-09-23T17:00", reason: "Missed exit scan",
}) {
return POST(new Request("https://mega.example/api/clock/events/oldin/correct", {
method: "POST", headers: { origin, "X-App-Pin": pin, "Content-Type": "application/json" },
}, requestUrl = "https://mega.example/api/clock/events/oldin/correct") {
return POST(new Request(requestUrl, {
method: "POST", headers: { origin, host: "mega.example", "X-App-Pin": pin, "Content-Type": "application/json" },
body: JSON.stringify(body),
}), { params: Promise.resolve({ id: "oldin" }) });
}
describe("manual clock-out route", () => {
beforeEach(() => { correct.mockReset(); vi.stubEnv("APP_PIN", "2468"); });
beforeEach(() => { correct.mockReset(); vi.stubEnv("APP_PIN", "2468"); vi.stubEnv("AUTH_MODE", "pin"); });
it("rejects a missing or wrong PIN and cross-origin request before writing", async () => {
expect((await request("")).status).toBe(401);
expect((await request("wrong")).status).toBe(401);
@ -37,4 +37,14 @@ describe("manual clock-out route", () => {
actualOutLocal: "2026-09-23T17:00", reason: "Missed exit scan",
}));
});
it("accepts the browser origin when a reverse proxy reports an internal request URL", async () => {
correct.mockResolvedValue({ correctionId: "corr1", sessionHours: 1, dailyTotalHours: 10.24 });
const response = await request(
"2468", "https://mega.example",
{ actualOutLocal: "2026-09-23T17:00", reason: "Missed exit scan" },
"http://localhost:3000/api/clock/events/oldin/correct",
);
expect(response.status).toBe(200);
expect(correct).toHaveBeenCalledOnce();
});
});

View file

@ -4,6 +4,7 @@ import { ClockCorrectionError, correctMissedClockOut } from "@/lib/clock-correct
import { pbAdmin } from "@/lib/pb";
import { authMode } from "@/lib/auth/session";
import { requireApiUser } from "@/lib/auth/guard";
import { isSameOrigin } from "@/lib/agent/same-origin";
function samePin(provided: string | null, expected: string | undefined): boolean {
if (!provided || !expected) return false;
@ -16,8 +17,7 @@ export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const origin = request.headers.get("origin");
if (!origin || origin !== new URL(request.url).origin) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Request origin is not allowed" }, { status: 403 });
}
if (authMode() === "users") {

View file

@ -1,5 +1,8 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const authUser = vi.hoisted(() => vi.fn());
vi.mock("@/lib/auth/session", () => ({ getCurrentUser: authUser }));
const batchCreate = vi.fn();
const batchList = vi.fn();
const batchGet = vi.fn();
@ -38,6 +41,7 @@ function form(files: File[], fields: Record<string, string | string[]> = {}) {
describe("POST /api/delivery-notes/upload", () => {
beforeEach(() => {
vi.clearAllMocks();
authUser.mockResolvedValue({ id: "admin", role: "admin", siteIds: [], mustChangePassword: false });
batchList.mockResolvedValue([]);
sourceList.mockResolvedValue([]);
siteGet.mockResolvedValue({ project_code: "P-001" });
@ -47,6 +51,14 @@ describe("POST /api/delivery-notes/upload", () => {
batchDelete.mockResolvedValue(true);
});
it("accepts an assigned site user and rejects an unassigned site", async () => {
authUser.mockResolvedValue({ id: "u1", role: "site_user", siteIds: ["siteabc"], mustChangePassword: false });
expect((await POST(request(form([file("assigned.jpg")])))).status).toBe(201);
authUser.mockResolvedValue({ id: "u1", role: "site_user", siteIds: ["other"], mustChangePassword: false });
expect((await POST(request(form([file("unassigned.jpg")])))).status).toBe(404);
expect(batchCreate).toHaveBeenCalledTimes(1);
});
it("creates one batch and one immutable source row per accepted file", async () => {
const res = await POST(request(form([file("one.jpg", 32), file("two.jpg", 64), file("three.jpg", 96)], {
source: "mobile", capture_session_id: ["cap-1", "cap-2", "cap-3"], delivery_session_id: "delivery-1",

View file

@ -1,4 +1,7 @@
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from "vitest";
const authUser = vi.hoisted(() => vi.fn());
vi.mock("@/lib/auth/session", () => ({ getCurrentUser: authUser }));
// Mock pbAdmin so the route doesn't hit a real PocketBase instance.
const getOneMock = vi.fn();
@ -22,6 +25,19 @@ describe("GET /api/purchase-orders/[id]/status", () => {
vi.stubEnv("POCKETBASE_ADMIN_PASSWORD", "test-password");
});
beforeEach(() => {
authUser.mockResolvedValue({ id: "admin", role: "admin", siteIds: [], mustChangePassword: false });
getOneMock.mockReset();
});
it("scopes progress checks to an assigned site", async () => {
authUser.mockResolvedValue({ id: "u1", role: "site_user", siteIds: ["site0000000001"], mustChangePassword: false });
getOneMock.mockResolvedValueOnce({ id: "abcdefghijklmno", site: "site0000000001", processing_status: "pending" });
expect((await GET(new Request("http://x"), { params: Promise.resolve({ id: "abcdefghijklmno" }) })).status).toBe(200);
getOneMock.mockResolvedValueOnce({ id: "abcdefghijklmno", site: "site0000000002", processing_status: "pending" });
expect((await GET(new Request("http://x"), { params: Promise.resolve({ id: "abcdefghijklmno" }) })).status).toBe(404);
});
afterAll(() => {
vi.unstubAllEnvs();
});

View file

@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { pbAdmin } from "@/lib/pb";
import { requireApiUser, requireSiteAccess } from "@/lib/auth/guard";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
@ -20,6 +21,8 @@ export async function GET(
_request: Request,
ctx: { params: Promise<{ id: string }> },
) {
const user = await requireApiUser("dashboard");
if (user instanceof Response) return user;
const { id } = await ctx.params;
if (!ID_PATTERN.test(id)) {
return NextResponse.json({ error: "bad_id" }, { status: 400 });
@ -28,8 +31,10 @@ export async function GET(
const pb = await pbAdmin();
try {
const po = await pb.collection("purchase_orders").getOne(id, {
fields: "id,processing_status,attempts,last_error,purchase_order_number",
fields: "id,site,processing_status,attempts,last_error,purchase_order_number",
});
const denied = requireSiteAccess(user, po.site);
if (denied) return denied;
const status = po.processing_status ?? "pending";
const processed = status === "processed";

View file

@ -1,13 +1,19 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { MAX_FILE_BYTES } from "@/lib/purchase-order-schema";
const authUser = vi.hoisted(() => vi.fn());
vi.mock("@/lib/auth/session", () => ({ getCurrentUser: authUser }));
// Mock pbAdmin. Two methods on the collection are used: getFullList (dedup)
// and create (new PO row). Both are mocked below.
const getFullListMock = vi.fn();
const createMock = vi.fn();
const filterMock = vi.fn((expression: string, params: Record<string, string>) =>
expression.replace(/\{:(\w+)\}/g, (_, key: string) => JSON.stringify(params[key])));
vi.mock("@/lib/pb", () => ({
pbAdmin: vi.fn(async () => ({
filter: filterMock,
collection: (name: string) => {
if (name !== "purchase_orders") {
throw new Error(`unexpected collection in test: ${name}`);
@ -35,12 +41,37 @@ function pdf(bytes = 128, type = "application/pdf", name = "po-1234.pdf"): File
describe("POST /api/purchase-orders/upload", () => {
beforeEach(() => {
authUser.mockResolvedValue({ id: "admin", role: "admin", siteIds: [], mustChangePassword: false });
createMock.mockReset();
getFullListMock.mockReset();
// Default: no prior POs (dedup misses).
getFullListMock.mockResolvedValue([]);
});
it("allows an assigned site user to upload and rejects another site's upload", async () => {
authUser.mockResolvedValue({ id: "u1", role: "site_user", siteIds: ["site0000000001"], mustChangePassword: false });
createMock.mockResolvedValueOnce({ id: "abcdefghijklmno" });
const allowed = new FormData();
allowed.append("file", pdf());
allowed.append("site", "site0000000001");
expect((await POST(makeRequest(allowed))).status).toBe(202);
const denied = new FormData();
denied.append("file", pdf(129));
denied.append("site", "site0000000002");
expect((await POST(makeRequest(denied))).status).toBe(404);
expect(createMock).toHaveBeenCalledTimes(1);
});
it("keeps clock-only users out of purchase order uploads", async () => {
authUser.mockResolvedValue({ id: "u1", role: "clock_only", siteIds: ["site0000000001"], mustChangePassword: false });
const form = new FormData();
form.append("file", pdf());
form.append("site", "site0000000001");
expect((await POST(makeRequest(form))).status).toBe(403);
expect(createMock).not.toHaveBeenCalled();
});
it("returns 400 when the body is not multipart form data", async () => {
const res = await POST(
new Request("http://x/api/purchase-orders/upload", {
@ -183,7 +214,9 @@ describe("POST /api/purchase-orders/upload", () => {
const filter = getFullListMock.mock.calls[0][0].filter;
expect(filter).toMatch(/file_hash = "[a-f0-9]{64}"/);
expect(filter).toContain('processing_status != "failed"');
expect(filter).toContain('site = "site0000000001"');
expect(filter).toMatch(/date_uploaded >= "\d{4}-\d{2}-\d{2}T/);
expect(filterMock).toHaveBeenCalledWith(expect.stringContaining("site = {:site}"), expect.objectContaining({ site: "site0000000001" }));
});
it("falls through to create when the dedup query throws", async () => {

View file

@ -3,6 +3,7 @@ import { createHash } from "node:crypto";
import { pbAdmin } from "@/lib/pb";
import { ALLOWED_MIME, MAX_FILE_BYTES } from "@/lib/purchase-order-schema";
import { placeholderPoNumber } from "@/lib/po-placeholder";
import { requireApiUser, requireSiteAccess } from "@/lib/auth/guard";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
@ -41,6 +42,8 @@ export const runtime = "nodejs";
const DEDUP_WINDOW_DAYS = 30;
export async function POST(request: Request) {
const user = await requireApiUser("dashboard");
if (user instanceof Response) return user;
const start = Date.now();
let form: FormData;
@ -74,6 +77,8 @@ export async function POST(request: Request) {
if (!siteId) {
return NextResponse.json({ error: "missing_site" }, { status: 400 });
}
const denied = requireSiteAccess(user, siteId);
if (denied) return denied;
// Hash the file bytes. Cheap enough to do before the PB round-trip
// (20 MB worst case, but in practice POs are a few hundred KB to ~2 MB).
@ -103,10 +108,10 @@ export async function POST(request: Request) {
Date.now() - DEDUP_WINDOW_DAYS * 86400_000,
).toISOString();
existing = await pb.collection("purchase_orders").getFullList({
filter:
`file_hash = "${file_hash}" && ` +
`processing_status != "failed" && ` +
`date_uploaded >= "${cutoff}"`,
filter: pb.filter(
'file_hash = {:hash} && site = {:site} && processing_status != "failed" && date_uploaded >= {:cutoff}',
{ hash: file_hash, site: siteId, cutoff },
),
fields: "id,purchase_order_number",
});
} catch (err) {

View file

@ -16,6 +16,11 @@ describe("auth proxy paths", () => {
expect(classifyAuthPath("/api/clock/events/ev1/correct")).toBe("admin");
});
it("allows dashboard users through the purchase order upload and progress paths", () => {
expect(classifyAuthPath("/api/purchase-orders/upload")).toBe("dashboard");
expect(classifyAuthPath("/api/purchase-orders/abcdefghijklmno/status")).toBe("dashboard");
});
it("keeps unscoped data APIs behind admin access", () => {
expect(classifyAuthPath("/api/sites")).toBe("admin");
expect(classifyAuthPath("/api/pb-files/delivery_notes/rec/file.pdf")).toBe("dashboard");

View file

@ -20,6 +20,8 @@ export function classifyAuthPath(pathname: string): AuthPath {
) return "admin";
if (/^\/api\/clock\/events\/[^/]+\/correct$/.test(pathname)) return "admin";
if (pathname.startsWith("/api/pb-files/")) return "dashboard";
if (pathname === "/api/purchase-orders/upload" ||
/^\/api\/purchase-orders\/[^/]+\/status$/.test(pathname)) return "dashboard";
if (/^\/api\/dn-batches\/[^/]+\/pages\/[^/]+$/.test(pathname) ||
/^\/api\/purchase-orders\/[^/]+\/(document|pages\/[^/]+)$/.test(pathname)) return "dashboard";
if (pathname === "/clock" || pathname.startsWith("/clock/") || pathname.startsWith("/api/clock/") ||