Fix document upload permissions and clock correction origin
This commit is contained in:
parent
b700252932
commit
223da69884
10 changed files with 104 additions and 15 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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/") ||
|
||||
|
|
|
|||
Loading…
Reference in a new issue