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:
parent
6bf8310ef6
commit
b1c0c6030f
4 changed files with 23 additions and 58 deletions
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue