Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 30s
CI / tests-integration (push) Successful in 1m39s
CI / tests-unit (push) Successful in 1m43s
CI / tests-ui (push) Successful in 2m31s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m17s
`next build` had never completed on this host, so three real defects were
sitting in the tree untested. All three are now fixed and the build is green.
- The build was not memory-bound the way it looked. Turbopack's builder reached
20.5GB RSS and died, and raising `--max-old-space-size` could never have
helped: that flag caps the V8 heap, while the 20GB sat in Turbopack's own Rust
allocator. The first symptom was misleading because the process doing the
allocating is a grandchild of `npx`, so watching the direct child shows a
95MB shim the whole time. Building with `--webpack` puts the build back under
the JS heap, where the flag actually applies: peak 5.9GB, 150s, exit 0.
- withAdmin's second parameter was typed `{ params?: ... }` and given a `= {}`
default, which made it optional and `RouteContext | undefined`. Next's
generated route types assert that argument against `ParamCheck<RouteContext>`
and reject it, across 113 route files. `tsc --noEmit` cannot see this, because
Next only adds `.next/types` to the project during a production build — so the
type check that everyone runs locally was structurally incapable of catching
the only type error that blocks a deploy. `params` is now required, which is
also what the code already assumed: it is awaited with no guard. The 35 test
call sites that invoked a handler with one argument now pass a real context,
and the await got a guard so a direct internal call cannot turn a missing
context into a 500.
- `src/app/api/admin/import/furni/route.ts` re-exported `ensureDirectories` and
`importSingleFurni` for "backward compatibility" that nothing used; the batch
route imports from `@/lib/services/furni-import` directly. Next rejects any
value export from a route module that is not an HTTP verb or config, so this
had been breaking the build for as long as it existed. Removed.
- `isomorphic-dompurify` builds its server-side DOM through jsdom. Bundled, that
pulls jsdom's `browser/default-stylesheet.css` into the server chunk, where the
path no longer resolves, and page-data collection dies with ENOENT on every
page that sanitizes HTML. Marked external so Node resolves it from
node_modules and the standalone tracer includes it.
The remaining build warning is a pre-existing circular dependency between
chunks that share the webpack runtime. It costs hash reuse, not correctness, and
is left alone rather than churned here.
Verified: build exit 0, 276 static pages generated, 3223 tests pass, tsc and
biome clean.
148 lines
4.9 KiB
TypeScript
148 lines
4.9 KiB
TypeScript
// @ts-nocheck
|
|
import { NextRequest, NextResponse } from "next/server";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { getOperationContext } from "./foundation/request-context";
|
|
|
|
const state = vi.hoisted(() => ({
|
|
authorized: true,
|
|
permitted: true,
|
|
csrf: true,
|
|
record: vi.fn(),
|
|
}));
|
|
vi.mock("@/lib/admin/authorization-events", () => ({
|
|
logAuthorizationEvent: vi.fn(),
|
|
}));
|
|
vi.mock("@/lib/foundation/security", () => ({
|
|
validateCsrfToken: async () => state.csrf,
|
|
}));
|
|
vi.mock("@/lib/permissions", () => ({
|
|
getApiAdminContext: async () =>
|
|
state.authorized
|
|
? { session: { user: { id: 42, rank: 7 } }, permissions: [] }
|
|
: null,
|
|
canAccess: () => state.permitted,
|
|
}));
|
|
vi.mock("@/lib/performance-store", () => ({ recordPerformance: state.record }));
|
|
vi.mock("@/lib/server-log", () => ({
|
|
logServerError: () => "error-reference",
|
|
}));
|
|
vi.mock("@/lib/services/catalog-git-queue", () => ({
|
|
catalogExportEnabled: () => false,
|
|
isCatalogMutation: () => false,
|
|
beginCatalogExport: vi.fn(),
|
|
}));
|
|
|
|
import { emptyRouteContext } from "@/test/route-context";
|
|
import { withAdmin } from "./api-handler";
|
|
|
|
const request = () =>
|
|
new NextRequest("https://hotel.test/api/admin/example", {
|
|
headers: { "x-operation-id": "forged-client-id" },
|
|
});
|
|
describe("admin API correlation", () => {
|
|
beforeEach(() => {
|
|
state.authorized = true;
|
|
state.permitted = true;
|
|
state.csrf = true;
|
|
state.record.mockClear();
|
|
});
|
|
it("matches the response ID to asynchronous work without trusting a supplied ID", async () => {
|
|
const handler = withAdmin({ permission: "users.view" }, async () => {
|
|
await Promise.resolve();
|
|
return NextResponse.json(getOperationContext());
|
|
});
|
|
const [a, b] = await Promise.all([
|
|
handler(request(), emptyRouteContext()),
|
|
handler(request(), emptyRouteContext()),
|
|
]);
|
|
expect(a.headers.get("x-operation-id")).not.toBe("forged-client-id");
|
|
expect(a.headers.get("x-operation-id")).not.toBe(
|
|
b.headers.get("x-operation-id"),
|
|
);
|
|
expect(await a.json()).toEqual({
|
|
operationId: a.headers.get("x-operation-id"),
|
|
userId: 42,
|
|
});
|
|
expect(getOperationContext()).toEqual({});
|
|
});
|
|
it("preserves redirects and separate set-cookie values", async () => {
|
|
const handler = withAdmin({}, () => {
|
|
const response = NextResponse.redirect(
|
|
"https://hotel.test/admin/users",
|
|
307,
|
|
);
|
|
response.cookies.set("first", "one", { httpOnly: true });
|
|
response.cookies.set("second", "two", { httpOnly: true });
|
|
return response;
|
|
});
|
|
const response = await handler(request(), emptyRouteContext());
|
|
expect(response.status).toBe(307);
|
|
expect(response.headers.get("location")).toBe(
|
|
"https://hotel.test/admin/users",
|
|
);
|
|
expect(response.headers.getSetCookie()).toHaveLength(2);
|
|
expect(response.headers.getSetCookie()).toEqual(
|
|
expect.arrayContaining([
|
|
expect.stringContaining("first=one"),
|
|
expect.stringContaining("second=two"),
|
|
]),
|
|
);
|
|
expect(response.headers.get("x-operation-id")).toBeTruthy();
|
|
});
|
|
it("returns streaming headers before completion and preserves every chunk", async () => {
|
|
let controller: ReadableStreamDefaultController<Uint8Array> | undefined;
|
|
const handler = withAdmin(
|
|
{},
|
|
() =>
|
|
new Response(
|
|
new ReadableStream({
|
|
start(value) {
|
|
controller = value;
|
|
},
|
|
}),
|
|
{
|
|
headers: {
|
|
"content-type": "text/event-stream",
|
|
"x-custom": "retained",
|
|
},
|
|
},
|
|
),
|
|
);
|
|
const response = await handler(request(), emptyRouteContext());
|
|
expect(response.headers.get("content-type")).toBe("text/event-stream");
|
|
expect(response.headers.get("x-custom")).toBe("retained");
|
|
const body = response.text();
|
|
controller?.enqueue(new TextEncoder().encode("data: first\n\n"));
|
|
controller?.enqueue(new TextEncoder().encode("data: last\n\n"));
|
|
controller?.close();
|
|
expect(await body).toBe("data: first\n\ndata: last\n\n");
|
|
expect(state.record).toHaveBeenCalledWith(
|
|
expect.objectContaining({ streaming: true }),
|
|
);
|
|
});
|
|
it("adds correlation to authentication, permission and handler failures", async () => {
|
|
const handler = withAdmin({ permission: "users.view" }, () => {
|
|
throw new Error("private-database-message");
|
|
});
|
|
state.authorized = false;
|
|
const unauthorized = await handler(request(), emptyRouteContext());
|
|
expect(unauthorized.status).toBe(401);
|
|
expect(state.record).not.toHaveBeenCalled();
|
|
expect(unauthorized.headers.get("x-operation-id")).toBeTruthy();
|
|
state.authorized = true;
|
|
state.permitted = false;
|
|
const forbidden = await handler(request(), emptyRouteContext());
|
|
expect(forbidden.status).toBe(403);
|
|
expect(forbidden.headers.get("x-operation-id")).toBeTruthy();
|
|
state.permitted = true;
|
|
const failed = await handler(request(), emptyRouteContext());
|
|
expect(failed.status).toBe(500);
|
|
expect(failed.headers.get("x-operation-id")).toBeTruthy();
|
|
expect(await failed.json()).toEqual({
|
|
ok: false,
|
|
error: "Internal server error",
|
|
errorId: "error-reference",
|
|
});
|
|
});
|
|
});
|