fix(api): make list-pdf routes browser-accessible

Both new routes — GET /api/v1/stock-purchases/list-pdf and
GET /api/v1/stock-items/stocktake-pdf — were gated on
`assertAgentAuth`, which requires an `Authorization: Bearer
<AGENT_API_KEY>` header. Browser <a target='_blank'> clicks
don't send that header, so the buttons returned 401
'Unauthorized'.

The existing single-PO PDF route at
/api/v1/stock-purchases/[id]/pdf doesn't gate on
`assertAgentAuth` — it relies on the in-app session and uses
the admin PB instance. The two list PDFs are the same shape
(throwing reports, in-app origin, admin-scoped reads) so the
fix is to drop the gate. Auth now matches the single-PO PDF
and the invoicing delivery-note PDFs.

- Removed `assertAgentAuth(req)` from both routes; dropped the
  `agent-auth` import and the unused `req` parameter.
- Updated route JSDoc to describe the new auth contract and
  reference the single-PO PDF route as the precedent.
- Updated both route tests: dropped the 'returns 401 when auth
  is missing' case (no longer applicable) and the
  `AGENT_API_KEY` env-setup boilerplate. Test names updated
  to reflect the new contract — 'no auth required'.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-03 18:12:38 +02:00
parent 6bf8310ef6
commit b1c0c6030f
4 changed files with 23 additions and 58 deletions

View file

@ -1,5 +1,4 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { NextRequest } from "next/server";
const renderStockItemStocktakePdf = vi.fn();
vi.mock("@/lib/pdf/render", () => ({
@ -19,29 +18,19 @@ vi.mock("@/lib/server/pocketbase-admin", () => ({
getAdminPocketBase: vi.fn().mockResolvedValue({}),
}));
const ORIGINAL_KEY = process.env.AGENT_API_KEY;
function authedRequest(url: string) {
return new NextRequest(url, {
headers: { authorization: "Bearer test-token" },
});
}
import { GET } from "./route";
describe("/api/v1/stock-items/stocktake-pdf", () => {
beforeEach(() => {
process.env.AGENT_API_KEY = "test-token";
renderStockItemStocktakePdf.mockReset();
listStockItemsForStocktake.mockReset();
});
afterEach(() => {
vi.useRealTimers();
if (ORIGINAL_KEY === undefined) delete process.env.AGENT_API_KEY;
else process.env.AGENT_API_KEY = ORIGINAL_KEY;
});
it("returns 200 with an application/pdf body and the correct headers", async () => {
it("returns 200 with an application/pdf body and the correct headers — no auth required", async () => {
// Pin the clock to a known SAST instant so the filename is deterministic.
// 2026-08-03 14:00:00 SAST = 2026-08-03 12:00:00 UTC.
vi.useFakeTimers();
@ -52,7 +41,7 @@ describe("/api/v1/stock-items/stocktake-pdf", () => {
]);
renderStockItemStocktakePdf.mockResolvedValueOnce(Buffer.from("%PDF-1.4\nbinary stuff", "binary"));
const res = await GET(authedRequest("http://localhost/api/v1/stock-items/stocktake-pdf"));
const res = await GET();
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toBe("application/pdf");
expect(res.headers.get("Content-Disposition")).toBe(
@ -72,20 +61,11 @@ describe("/api/v1/stock-items/stocktake-pdf", () => {
vi.useRealTimers();
});
it("returns 401 when auth is missing", async () => {
process.env.AGENT_API_KEY = "test-token";
const req = new NextRequest("http://localhost/api/v1/stock-items/stocktake-pdf");
const res = await GET(req);
expect(res.status).toBe(401);
expect(listStockItemsForStocktake).not.toHaveBeenCalled();
expect(renderStockItemStocktakePdf).not.toHaveBeenCalled();
});
it("returns 500 when the service throws", async () => {
listStockItemsForStocktake.mockRejectedValueOnce(new Error("PB is down"));
const res = await GET(authedRequest("http://localhost/api/v1/stock-items/stocktake-pdf"));
const res = await GET();
expect(res.status).toBe(500);
const body = await res.json();
expect(body.error).toBe("PB is down");
});
});
});

View file

@ -1,5 +1,4 @@
import { NextRequest, NextResponse } from "next/server";
import { assertAgentAuth } from "@/lib/server/agent-auth";
import { NextResponse } from "next/server";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { listStockItemsForStocktake } from "@/lib/server/stock-items-service";
import { renderStockItemStocktakePdf } from "@/lib/pdf/render";
@ -11,16 +10,20 @@ import { renderStockItemStocktakePdf } from "@/lib/pdf/render";
* sorted by name, with a blank "Actual" column for the operator to
* fill in on the printed page during the walk-through.
*
* Auth: same `assertAgentAuth` as the rest of `/api/v1`.
* Auth: same as the single-PO PDF route at
* `/api/v1/stock-purchases/[id]/pdf` — no per-request gate. The
* route is intended to be triggered from the in-app table (cookie
* session). The route uses the admin PB instance, so it does not
* need to forward cookies.
*
* No query params — always all rows.
*/
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
export async function GET() {
try {
assertAgentAuth(req);
const pb = await getAdminPocketBase();
const items = await listStockItemsForStocktake(pb);
const now = new Date();

View file

@ -1,5 +1,4 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { NextRequest } from "next/server";
const renderStockPurchaseListPdf = vi.fn();
vi.mock("@/lib/pdf/render", () => ({
@ -19,29 +18,19 @@ vi.mock("@/lib/server/pocketbase-admin", () => ({
getAdminPocketBase: vi.fn().mockResolvedValue({}),
}));
const ORIGINAL_KEY = process.env.AGENT_API_KEY;
function authedRequest(url: string) {
return new NextRequest(url, {
headers: { authorization: "Bearer test-token" },
});
}
import { GET } from "./route";
describe("/api/v1/stock-purchases/list-pdf", () => {
beforeEach(() => {
process.env.AGENT_API_KEY = "test-token";
renderStockPurchaseListPdf.mockReset();
listStockPurchases.mockReset();
});
afterEach(() => {
vi.useRealTimers();
if (ORIGINAL_KEY === undefined) delete process.env.AGENT_API_KEY;
else process.env.AGENT_API_KEY = ORIGINAL_KEY;
});
it("returns 200 with an application/pdf body and the correct headers", async () => {
it("returns 200 with an application/pdf body and the correct headers — no auth required", async () => {
// Pin the clock to a known SAST instant so the filename is deterministic.
// 2026-08-03 14:00:00 SAST = 2026-08-03 12:00:00 UTC.
vi.useFakeTimers();
@ -56,7 +45,7 @@ describe("/api/v1/stock-purchases/list-pdf", () => {
});
renderStockPurchaseListPdf.mockResolvedValueOnce(Buffer.from("%PDF-1.4\nbinary stuff", "binary"));
const res = await GET(authedRequest("http://localhost/api/v1/stock-purchases/list-pdf"));
const res = await GET();
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toBe("application/pdf");
expect(res.headers.get("Content-Disposition")).toBe(
@ -80,20 +69,11 @@ describe("/api/v1/stock-purchases/list-pdf", () => {
vi.useRealTimers();
});
it("returns 401 when auth is missing", async () => {
process.env.AGENT_API_KEY = "test-token";
const req = new NextRequest("http://localhost/api/v1/stock-purchases/list-pdf");
const res = await GET(req);
expect(res.status).toBe(401);
expect(listStockPurchases).not.toHaveBeenCalled();
expect(renderStockPurchaseListPdf).not.toHaveBeenCalled();
});
it("returns 500 when the service throws", async () => {
listStockPurchases.mockRejectedValueOnce(new Error("PB is down"));
const res = await GET(authedRequest("http://localhost/api/v1/stock-purchases/list-pdf"));
const res = await GET();
expect(res.status).toBe(500);
const body = await res.json();
expect(body.error).toBe("PB is down");
});
});
});

View file

@ -1,5 +1,4 @@
import { NextRequest, NextResponse } from "next/server";
import { assertAgentAuth } from "@/lib/server/agent-auth";
import { NextResponse } from "next/server";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { listStockPurchases } from "@/lib/server/stock-purchases-service";
import { renderStockPurchaseListPdf } from "@/lib/pdf/render";
@ -12,7 +11,11 @@ import { renderStockPurchaseListPdf } from "@/lib/pdf/render";
* supplier-facing stock PO; this one is the internal report (every
* PO in a table, no letterhead, no signing block).
*
* Auth: same `assertAgentAuth` as the rest of `/api/v1`.
* Auth: same as the single-PO PDF route — no per-request gate. The
* route is intended to be triggered from the in-app table (cookie
* session). The route uses the admin PB instance, not the user's
* PB session, so it does not need to forward cookies.
*
* No query params — always all rows, sorted by created desc (the
* default service sort).
*
@ -24,9 +27,8 @@ import { renderStockPurchaseListPdf } from "@/lib/pdf/render";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
export async function GET() {
try {
assertAgentAuth(req);
const pb = await getAdminPocketBase();
const result = await listStockPurchases(pb, { status: "", page: 1, perPage: 200 });
const now = new Date();