fix(security,repo-honesty): real security headers + a doctor that checks truth, not presence
masterplan-lock / check (pull_request) Successful in 4s
masterplan-lock / check (push) Successful in 7s
CI (SIAX Cloud) / security (pull_request) Successful in 17s
CI (SIAX Cloud) / security (push) Successful in 18s
CI (SIAX Cloud) / quality (push) Successful in 39s
CI (SIAX Cloud) / integration (pull_request) Successful in 41s
CI (SIAX Cloud) / integration (push) Successful in 44s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 1m37s
Security Scan (SIAX Cloud) / security (push) Successful in 1m35s
CI (SIAX Cloud) / quality (pull_request) Successful in 3m4s
masterplan-lock / check (pull_request) Successful in 4s
masterplan-lock / check (push) Successful in 7s
CI (SIAX Cloud) / security (pull_request) Successful in 17s
CI (SIAX Cloud) / security (push) Successful in 18s
CI (SIAX Cloud) / quality (push) Successful in 39s
CI (SIAX Cloud) / integration (pull_request) Successful in 41s
CI (SIAX Cloud) / integration (push) Successful in 44s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 1m37s
Security Scan (SIAX Cloud) / security (push) Successful in 1m35s
CI (SIAX Cloud) / quality (pull_request) Successful in 3m4s
Two defects of the same kind: the repo asserted things about itself that were
not true, and nothing in it could tell.
A. CHANGELOG.md claimed CSP/HSTS/X-Frame-Options since 2026-08. `main`
contained none of it -- a repo-wide grep for
`content-security-policy|strict-transport-security|x-frame-options` matched
exactly one file, CHANGELOG.md itself. The only header on 0pt.app was
Vercel's own HSTS default, which does not survive the move to
Coolify/Traefik that siax.config.json and .gitea/workflows/cd.yml are
already pointed at.
- next.config.mjs: `headers()` on `/(.*)` with CSP (frame-ancestors 'none',
base-uri 'self', object-src 'none', form-action 'self'), HSTS,
X-Frame-Options DENY, X-Content-Type-Options, Referrer-Policy,
Permissions-Policy; plus poweredByHeader: false. Same idiom as
sax3l/siax-io's next.config.mjs.
- Carve-outs re-derived against THIS tree, not inherited: the stale
`ci/security-headers-and-scanning` branch (8d2ff73, 142 commits behind)
carried a `form-action https://checkout.stripe.com` allowance justified
by a redirect that is actually `window.location.href`, which form-action
does not govern -- dropped rather than kept as decorative permission.
frame-src is derived from NOCODB_BASE_URL/METABASE_SITE_URL, the same env
the components/embed.tsx iframe is configured from, so wiring that
component up cannot be silently blanked by this header (the failure
sax3l/siax-io recorded as SIO-013). 'unsafe-eval' is dev-only.
- tests/security/security-headers.test.ts: blocking regression gate inside
`pnpm run test`, i.e. the required "CI (SIAX Cloud) / quality" context.
Asserts against expectations written out in the test, not imported from
the config. Proven to fail: removing X-Frame-Options -> `pnpm run test`
exits 1; weakening frame-ancestors to 'self' -> exits 1.
B. scripts/siax-doctor.mjs validated that siax.repo.json fields were PRESENT,
never what they said. Measured on this tree: operationalState "running"
(false) and "down" (true) both gave 40/40, exit 0, byte-identical output.
- Value checks (enums, git.siax.io const, fleet node pattern, bare-hostname
form), cross-file consistency (declared prod hostnames must appear in
this repo's own siax.config.json / contracts/openapi.yaml, and vice
versa), and GOV-06 substantiation: operationalState must be backed by a
dated probe record in evidence/operational-state.json that agrees with
it. Liveness is not knowable offline; whether a liveness CLAIM is backed
is. Offline, deterministic, no network -- CI can never go red for want of
an internet connection.
- `--live` (opt-in) probes the declared hosts for real and checks the
headers on the wire; `--write-evidence` refreshes the record. Not wired
into CI on purpose.
- tests/repo/siax-doctor.test.ts runs the doctor inside `pnpm run test` and
asserts the negative cases against fixture trees (SIAX_DOCTOR_ROOT), so
the "cannot fail" defect cannot come back.
C. CHANGELOG.md is now true. The header claim stands; the four claims it was
bundled with (Trivy --exit-code 1, gitleaks, SHA-pinned actions, SBOM +
license artifacts) are still false on main and are listed as not
implemented instead of left asserted. All four live in .gitea/workflows/**,
a protected path on this repo, so they need an authorised workflow change.
Nothing under .gitea/workflows/** or .gitignore is touched.
Verified: pnpm run typecheck exit 0; pnpm run test exit 0 (74 files / 965
tests passed, up from 72/945); pnpm run build exit 0 and the built
.next/routes-manifest.json carries all six headers; node scripts/siax-doctor.mjs
55/55 exit 0; --live 57/57 exit 0. eslint on the new files exit 0 (repo lint is
advisory and its 17 pre-existing problems are unchanged).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d41f30535b
commit
8ebc90ffa3
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* AGENT-04 / GOV-06 -- the doctor is itself gated, and is itself proven able to fail.
|
||||
*
|
||||
* Two things this file exists for:
|
||||
*
|
||||
* 1. `pnpm siax:doctor` is otherwise invoked only by `make doctor`, i.e. by
|
||||
* whoever remembers. Running it here puts it inside `pnpm run test`, which
|
||||
* is the BLOCKING "CI (SIAX Cloud) / quality" job -- no .gitea/workflows
|
||||
* change required (that path is protected on this repo).
|
||||
*
|
||||
* 2. The defect being fixed was a check that COULD NOT FAIL: the old doctor
|
||||
* reported 40/40 and byte-identical output with `operationalState` set to
|
||||
* "running" (false) and to "down" (true). So a green doctor is not enough
|
||||
* evidence here -- the negative cases are asserted too, against a fixture
|
||||
* tree via SIAX_DOCTOR_ROOT.
|
||||
*
|
||||
* Offline only. Nothing here touches the network: `--live` is opt-in and is
|
||||
* deliberately never exercised by CI.
|
||||
*/
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
const DOCTOR = path.join(REPO_ROOT, "scripts", "siax-doctor.mjs");
|
||||
|
||||
type DoctorResult = { id: string; label: string; mode: string; pass: boolean; detail: string | null };
|
||||
type DoctorReport = { total: number; failed: number; live: boolean; results: DoctorResult[] };
|
||||
|
||||
function runDoctor(root?: string): { code: number; report: DoctorReport } {
|
||||
const env = { ...process.env, ...(root ? { SIAX_DOCTOR_ROOT: root } : {}) };
|
||||
try {
|
||||
const stdout = execFileSync(process.execPath, [DOCTOR, "--json"], { env, encoding: "utf8" });
|
||||
return { code: 0, report: JSON.parse(stdout) as DoctorReport };
|
||||
} catch (e) {
|
||||
const err = e as { status?: number; stdout?: string };
|
||||
return { code: err.status ?? -1, report: JSON.parse(err.stdout ?? "{}") as DoctorReport };
|
||||
}
|
||||
}
|
||||
|
||||
/** A fixture tree carrying only the two files the GOV-06 checks read. */
|
||||
function fixture(repo: unknown, evidence: unknown): string {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "siax-doctor-fixture-"));
|
||||
writeFileSync(path.join(dir, "siax.repo.json"), JSON.stringify(repo, null, 2));
|
||||
mkdirSync(path.join(dir, "evidence"), { recursive: true });
|
||||
writeFileSync(path.join(dir, "evidence", "operational-state.json"), JSON.stringify(evidence, null, 2));
|
||||
return dir;
|
||||
}
|
||||
|
||||
const REAL_REPO = JSON.parse(readFileSync(path.join(REPO_ROOT, "siax.repo.json"), "utf8"));
|
||||
const REAL_EVIDENCE = JSON.parse(
|
||||
readFileSync(path.join(REPO_ROOT, "evidence", "operational-state.json"), "utf8"),
|
||||
);
|
||||
|
||||
/** The GOV-06 failure detail for a fixture, or null when GOV-06 passed. */
|
||||
function gov06Failure(root: string): string | null {
|
||||
const { report } = runDoctor(root);
|
||||
const failed = report.results.filter((r) => r.id.startsWith("GOV-06") && !r.pass);
|
||||
return failed.length > 0 ? failed.map((r) => r.detail).join(" | ") : null;
|
||||
}
|
||||
|
||||
describe("AGENT-04: siax-doctor passes on this repo", () => {
|
||||
it("exits 0 with zero failures", () => {
|
||||
const { code, report } = runDoctor();
|
||||
const failures = report.results.filter((r) => !r.pass);
|
||||
expect(failures.map((f) => `${f.id}: ${f.detail}`).join("\n")).toBe("");
|
||||
expect(report.failed).toBe(0);
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
|
||||
it("runs offline by default -- CI must never depend on reaching a host", () => {
|
||||
const { report } = runDoctor();
|
||||
expect(report.live).toBe(false);
|
||||
expect(report.results.every((r) => r.mode === "offline")).toBe(true);
|
||||
});
|
||||
|
||||
it("still carries the truth checks (they cannot be quietly deleted)", () => {
|
||||
const { report } = runDoctor();
|
||||
const ids = new Set(report.results.map((r) => r.id));
|
||||
for (const id of ["GOV-06", "NET-01", "NET-01/REPO-06", "SEC-06/P0-13", "REPO-04/GOV-06"]) {
|
||||
expect(ids.has(id), `siax-doctor no longer runs any "${id}" check`).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("GOV-06: a FALSE operationalState is detected (the historical defect)", () => {
|
||||
const dirs: string[] = [];
|
||||
const track = (d: string) => {
|
||||
dirs.push(d);
|
||||
return d;
|
||||
};
|
||||
afterAll(() => {
|
||||
for (const d of dirs) rmSync(d, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("accepts the repo's real, honest pairing (down + 402/404) as the control", () => {
|
||||
expect(gov06Failure(track(fixture(REAL_REPO, REAL_EVIDENCE)))).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects operationalState "running" when the evidence was recorded for another state', () => {
|
||||
const detail = gov06Failure(track(fixture({ ...REAL_REPO, operationalState: "running" }, REAL_EVIDENCE)));
|
||||
expect(detail).toMatch(/running/);
|
||||
expect(detail).toMatch(/operational-state\.json/);
|
||||
});
|
||||
|
||||
it('rejects operationalState "running" when the recorded probes are 402/404', () => {
|
||||
const detail = gov06Failure(
|
||||
track(
|
||||
fixture(
|
||||
{ ...REAL_REPO, operationalState: "running" },
|
||||
{ ...REAL_EVIDENCE, operationalState: "running" },
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(detail).toMatch(/but the recorded probes say otherwise/);
|
||||
expect(detail).toMatch(/402|404/);
|
||||
});
|
||||
|
||||
it("rejects an `ok: true` hand-written next to a non-2xx status", () => {
|
||||
const detail = gov06Failure(
|
||||
track(
|
||||
fixture(
|
||||
{ ...REAL_REPO, operationalState: "running" },
|
||||
{
|
||||
...REAL_EVIDENCE,
|
||||
operationalState: "running",
|
||||
probes: (REAL_EVIDENCE.probes as { ok: boolean }[]).map((p) => ({ ...p, ok: true })),
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(detail).toMatch(/records ok=true next to httpStatus/);
|
||||
});
|
||||
|
||||
it('rejects operationalState "down" when a prod host is recorded as answering', () => {
|
||||
const detail = gov06Failure(
|
||||
track(
|
||||
fixture(REAL_REPO, {
|
||||
...REAL_EVIDENCE,
|
||||
probes: (REAL_EVIDENCE.probes as { httpStatus: number; ok: boolean }[]).map((p) => ({
|
||||
...p,
|
||||
httpStatus: 200,
|
||||
ok: true,
|
||||
})),
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(detail).toMatch(/"down" but these prod hosts answered/);
|
||||
});
|
||||
|
||||
it("rejects a liveness claim backed only by stale evidence", () => {
|
||||
const stale = new Date(Date.now() - 400 * 86_400_000).toISOString();
|
||||
const detail = gov06Failure(
|
||||
track(
|
||||
fixture(
|
||||
{ ...REAL_REPO, operationalState: "running" },
|
||||
{
|
||||
...REAL_EVIDENCE,
|
||||
operationalState: "running",
|
||||
checkedAt: stale,
|
||||
probes: (REAL_EVIDENCE.probes as { httpStatus: number; ok: boolean }[]).map((p) => ({
|
||||
...p,
|
||||
httpStatus: 200,
|
||||
ok: true,
|
||||
})),
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(detail).toMatch(/unrefreshed liveness claim/);
|
||||
});
|
||||
|
||||
it("rejects a prod hostname with no probe on record at all", () => {
|
||||
const detail = gov06Failure(
|
||||
track(
|
||||
fixture(
|
||||
{
|
||||
...REAL_REPO,
|
||||
domains: [
|
||||
...(REAL_REPO.domains as unknown[]),
|
||||
{ fqdn: "invented.0pt.app", env: "prod", purpose: "x", registrar: "x" },
|
||||
],
|
||||
},
|
||||
REAL_EVIDENCE,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(detail).toMatch(/no probe recorded for: invented\.0pt\.app/);
|
||||
});
|
||||
|
||||
it("rejects a malformed hostname (NET-01)", () => {
|
||||
const { report } = runDoctor(
|
||||
track(
|
||||
fixture(
|
||||
{
|
||||
...REAL_REPO,
|
||||
domains: [{ fqdn: "https://0pt.app/health", env: "prod", purpose: "x", registrar: "x" }],
|
||||
},
|
||||
REAL_EVIDENCE,
|
||||
),
|
||||
),
|
||||
);
|
||||
const net01 = report.results.find((r) => r.id === "NET-01" && !r.pass);
|
||||
expect(net01?.detail).toMatch(/not a bare lowercase hostname/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* BACKLOG P0-13 / SEC-06 -- security-header REGRESSION GATE.
|
||||
*
|
||||
* Why this file exists: CHANGELOG.md asserted "security headers
|
||||
* (CSP/HSTS/X-Frame-Options/etc.)" while `main` contained zero such code for
|
||||
* weeks, and nothing in the repo could tell the difference. Adding the headers
|
||||
* without adding a gate would just reset that clock.
|
||||
*
|
||||
* WHAT THIS PROVES: that `next.config.mjs` -- the object Next.js actually
|
||||
* consumes -- still declares the required headers, with values that are still
|
||||
* strict. It reads the real config module; it does not re-import the header
|
||||
* list and compare it to itself. Every expectation below is written out here,
|
||||
* independently, so deleting or weakening a header in next.config.mjs fails
|
||||
* this test rather than silently changing both sides.
|
||||
*
|
||||
* WHAT IT DOES NOT PROVE: that a running instance serves them. Next freezes
|
||||
* `headers()` into .next/routes-manifest.json at build time, so the manifest
|
||||
* assertion at the bottom runs opportunistically when a build exists (locally,
|
||||
* or in any job that builds before testing). End-to-end, against a live
|
||||
* origin, is `node scripts/siax-doctor.mjs --live`.
|
||||
*
|
||||
* Runs inside `pnpm run test` -> the BLOCKING "CI (SIAX Cloud) / quality" job.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
|
||||
type HeaderEntry = { key: string; value: string };
|
||||
type HeaderRule = { source: string; headers: HeaderEntry[] };
|
||||
|
||||
/**
|
||||
* Required headers and how strict each must stay. Hardcoded on purpose --
|
||||
* this is the independent side of the comparison.
|
||||
*/
|
||||
const REQUIRED: { key: string; assert: (value: string) => void }[] = [
|
||||
{
|
||||
key: "Strict-Transport-Security",
|
||||
assert: (v) => {
|
||||
const maxAge = /max-age=(\d+)/.exec(v);
|
||||
expect(maxAge, `HSTS must carry a max-age, got: ${v}`).not.toBeNull();
|
||||
// >= 1 year: below that the header is decorative and ineligible for preload.
|
||||
expect(Number(maxAge![1])).toBeGreaterThanOrEqual(31536000);
|
||||
expect(v).toMatch(/includeSubDomains/i);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "X-Frame-Options",
|
||||
// SAMEORIGIN would be a weakening for this app: nothing here frames itself.
|
||||
assert: (v) => expect(v.trim().toUpperCase()).toBe("DENY"),
|
||||
},
|
||||
{
|
||||
key: "X-Content-Type-Options",
|
||||
assert: (v) => expect(v.trim().toLowerCase()).toBe("nosniff"),
|
||||
},
|
||||
{
|
||||
key: "Referrer-Policy",
|
||||
assert: (v) =>
|
||||
expect(["no-referrer", "same-origin", "strict-origin", "strict-origin-when-cross-origin"]).toContain(
|
||||
v.trim().toLowerCase(),
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "Permissions-Policy",
|
||||
assert: (v) => {
|
||||
for (const feature of ["camera", "microphone", "geolocation"]) {
|
||||
expect(v, `Permissions-Policy must disable ${feature}`).toMatch(
|
||||
new RegExp(`${feature}\\s*=\\s*\\(\\s*\\)`),
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
assert: (v) => {
|
||||
const directives = new Map(
|
||||
v
|
||||
.split(";")
|
||||
.map((d) => d.trim())
|
||||
.filter(Boolean)
|
||||
.map((d) => {
|
||||
const [name, ...rest] = d.split(/\s+/);
|
||||
return [name.toLowerCase(), rest.join(" ")] as const;
|
||||
}),
|
||||
);
|
||||
expect(directives.get("default-src")).toBe("'self'");
|
||||
// Clickjacking defence that actually applies to modern browsers.
|
||||
expect(directives.get("frame-ancestors")).toBe("'none'");
|
||||
expect(directives.get("base-uri")).toBe("'self'");
|
||||
expect(directives.get("object-src")).toBe("'none'");
|
||||
expect(directives.get("form-action")).toBe("'self'");
|
||||
expect(directives.has("script-src")).toBe(true);
|
||||
expect(directives.has("connect-src")).toBe(true);
|
||||
expect(directives.has("frame-src")).toBe(true);
|
||||
|
||||
// No wildcard sources anywhere: a bare `*` re-opens what this header exists to close.
|
||||
for (const [name, value] of directives) {
|
||||
expect(value.split(/\s+/), `CSP directive ${name} must not allow *`).not.toContain("*");
|
||||
}
|
||||
|
||||
// 'unsafe-eval' is a dev-only relaxation (see next.config.mjs). Vitest runs
|
||||
// with NODE_ENV=test, so it must be absent here; if it ever appears outside
|
||||
// `next dev`, this fails.
|
||||
expect(process.env.NODE_ENV, "vitest is expected to run outside NODE_ENV=development").not.toBe(
|
||||
"development",
|
||||
);
|
||||
expect(v, "'unsafe-eval' must never ship outside next dev").not.toContain("unsafe-eval");
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** Rule sources that cover every path Next can serve. */
|
||||
const CATCH_ALL_SOURCES = new Set(["/(.*)", "/:path*", "/(.*)?"]);
|
||||
|
||||
async function loadNextConfig(): Promise<{ poweredByHeader?: boolean; headers?: () => Promise<HeaderRule[]> }> {
|
||||
// Computed specifier on purpose: keeps `next.config.mjs` (a JS file outside
|
||||
// tsconfig.check.json's include) out of the blocking typecheck's graph.
|
||||
const url = new URL("../../next.config.mjs", import.meta.url).href;
|
||||
const mod = (await import(/* @vite-ignore */ url)) as { default: Awaited<ReturnType<typeof loadNextConfig>> };
|
||||
return mod.default;
|
||||
}
|
||||
|
||||
describe("SEC-06: 0pt serves its own security headers (P0-13)", () => {
|
||||
it("next.config.mjs declares a headers() rule covering every path", async () => {
|
||||
const config = await loadNextConfig();
|
||||
expect(typeof config.headers, "next.config.mjs must export an async headers()").toBe("function");
|
||||
|
||||
const rules = await config.headers!();
|
||||
expect(Array.isArray(rules)).toBe(true);
|
||||
|
||||
const catchAll = rules.filter((r) => CATCH_ALL_SOURCES.has(r.source));
|
||||
expect(
|
||||
catchAll.length,
|
||||
`no catch-all header rule; sources were: ${rules.map((r) => r.source).join(", ")}`,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("does not advertise the framework via X-Powered-By", async () => {
|
||||
const config = await loadNextConfig();
|
||||
expect(config.poweredByHeader).toBe(false);
|
||||
});
|
||||
|
||||
for (const { key, assert } of REQUIRED) {
|
||||
it(`sets ${key} on every path, and keeps it strict`, async () => {
|
||||
const config = await loadNextConfig();
|
||||
const rules = await config.headers!();
|
||||
const applied = rules
|
||||
.filter((r) => CATCH_ALL_SOURCES.has(r.source))
|
||||
.flatMap((r) => r.headers)
|
||||
.filter((h) => h.key.toLowerCase() === key.toLowerCase());
|
||||
|
||||
expect(
|
||||
applied.length,
|
||||
`${key} is not set for all paths -- CHANGELOG.md claims 0pt ships it. ` +
|
||||
`Either restore it in next.config.mjs or stop claiming it.`,
|
||||
).toBe(1);
|
||||
|
||||
assert(applied[0].value);
|
||||
});
|
||||
}
|
||||
|
||||
it("the built routes-manifest carries the same headers (skipped when no build exists)", async () => {
|
||||
const manifestPath = path.join(REPO_ROOT, ".next", "routes-manifest.json");
|
||||
if (!existsSync(manifestPath)) {
|
||||
// `pnpm run test` runs before `pnpm run build` in .gitea/workflows/ci.yml,
|
||||
// so on a clean CI checkout there is nothing to read. Not a silent pass:
|
||||
// the config-level assertions above already ran and are blocking.
|
||||
return;
|
||||
}
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as {
|
||||
headers?: { source?: string; regex?: string; headers?: HeaderEntry[] }[];
|
||||
};
|
||||
const built = (manifest.headers ?? []).flatMap((r) => r.headers ?? []);
|
||||
expect(built.length, "a build exists but its routes-manifest has no headers").toBeGreaterThan(0);
|
||||
for (const { key } of REQUIRED) {
|
||||
expect(
|
||||
built.some((h) => h.key.toLowerCase() === key.toLowerCase()),
|
||||
`${key} missing from .next/routes-manifest.json -- it would not be served`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user