Ctrl K
ComunidadGuías…

next.config.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { createRequire } from "node:module";
import { describe, expect, it } from "vitest";
import nextConfig, { CONSENT_PATH } from "./next.config";
import { MAX_IMPORT_CSV_BYTES } from "./src/features/import/importFields";

// The exact matcher Next runs a headers() `source` through, so these tests resolve a path the way
// the server does instead of re-implementing the syntax. Next vendors it without typings, and the
// standalone package on npm is v8, which dropped the inline-regex syntax this config depends on,
// so it is reached through Next's copy.
const { pathToRegexp } = createRequire(import.meta.url)("next/dist/compiled/path-to-regexp") as {
  pathToRegexp: (source: string) => RegExp;
};

// The importer parses the CSV in the browser and POSTs the entire parsed rows array through
// the createBatchAction server action. That JSON encoding runs materially larger than the raw
// CSV bytes (measured on a real 2.0 MB file: a 4.32 MB server-action payload, ~2.2x). If the
// server-action body limit does not cover MAX_IMPORT_CSV_BYTES's JSON expansion, every
// non-trivial upload passes the 25 MB client check then dies at the ~1 MB Next.js default.
// This test ties the two limits together so they cannot silently diverge again.
// NB: a stopgap. The storage-backed import overhaul removes the cross-request row payload
// entirely, at which point this limit (and this test) become irrelevant.
const JSON_EXPANSION_HEADROOM = 2.3;

// Mirror Next's `bytes` parser (1024-based units) for the string form of SizeLimit.
function toBytes(limit: string | number | undefined): number {
  if (typeof limit === "number") return limit;
  if (limit === undefined) return 0;
  const match = /^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb|tb)?$/i.exec(limit.trim());
  if (match === null) return 0;
  const scale: Record<string, number> = {
    b: 1,
    kb: 1024,
    mb: 1024 ** 2,
    gb: 1024 ** 3,
    tb: 1024 ** 4,
  };
  return Number(match[1]) * (scale[(match[2] ?? "b").toLowerCase()] ?? 1);
}

// React Compiler auto-memoizes components. It assumes the rules of hooks hold, and silently bails
// out of any component it cannot prove pure, so it must only be enabled once
// eslint-plugin-react-hooks runs clean. The lint config is the guard; this pins that it stays on.
describe("next.config React Compiler", () => {
  it("has the compiler enabled", () => {
    expect(nextConfig.reactCompiler).toBe(true);
  });
});

// Icons are imported from the `lucide-react` barrel across the app. Without this hint Next pulls
// the whole barrel into any chunk touching one icon. Listing the package makes Next rewrite each
// barrel import to its direct module path at build time.
describe("next.config barrel optimization", () => {
  it("optimizes the lucide-react barrel", () => {
    expect(nextConfig.experimental?.optimizePackageImports).toContain("lucide-react");
  });
});

describe("next.config server-action body limit", () => {
  it("covers the JSON expansion of the client CSV import cap", () => {
    const limit = nextConfig.experimental?.serverActions?.bodySizeLimit;
    const limitBytes = toBytes(limit);
    expect(limitBytes).toBeGreaterThanOrEqual(MAX_IMPORT_CSV_BYTES * JSON_EXPANSION_HEADROOM);
  });
});

// Security response headers. The load-bearing one is frame-ancestors: /oauth/authorize/consent
// renders a one-click "Allow access" form whose CSRF token is already in the action URL, so a
// framed same-origin submit passes every check in validateCsrf (token matches, Origin is ours,
// Sec-Fetch-Site is same-origin). Without frame-ancestors an attacker page can overlay that
// consent screen and clickjack a victim into granting their OAuth client full MCP access.
// CSRF defenses do not stop clickjacking; only the frame directives do.
// Next applies EVERY headers() entry whose source matches, not just the first, and a browser
// intersects two Content-Security-Policy headers (the most restrictive value of each directive
// wins). So a route cannot loosen a directive by adding a second header; it has to be excluded
// from the strict entry. Resolving all matches here is what makes that trap visible to the tests.
async function headersForPath(path: string): Promise<Map<string, string[]>> {
  const entries = (await nextConfig.headers?.()) ?? [];
  const resolved = new Map<string, string[]>();
  for (const entry of entries) {
    if (!pathToRegexp(entry.source).test(path)) continue;
    for (const header of entry.headers) {
      const key = header.key.toLowerCase();
      resolved.set(key, [...(resolved.get(key) ?? []), header.value]);
    }
  }
  return resolved;
}

async function cspForPath(path: string): Promise<string> {
  const values = (await headersForPath(path)).get("content-security-policy") ?? [];
  // More than one is the bug this helper exists to catch: it means the path matched both entries.
  expect(values).toHaveLength(1);
  return values.join("");
}

const ORDINARY_PATH = "/deals";

describe("next.config security headers", () => {
  it("denies framing of every route via CSP frame-ancestors", async () => {
    expect(await cspForPath(ORDINARY_PATH)).toContain("frame-ancestors 'none'");
    expect(await cspForPath(CONSENT_PATH)).toContain("frame-ancestors 'none'");
  });

  it("denies framing via X-Frame-Options for pre-CSP-2 browsers", async () => {
    expect((await headersForPath(ORDINARY_PATH)).get("x-frame-options")).toEqual(["DENY"]);
    expect((await headersForPath(CONSENT_PATH)).get("x-frame-options")).toEqual(["DENY"]);
  });

  it("pins base-uri and form-action so injected markup cannot retarget forms", async () => {
    const csp = await cspForPath(ORDINARY_PATH);
    expect(csp).toContain("base-uri 'self'");
    expect(csp).toContain("form-action 'self'");
  });

  it("blocks plugin content via object-src", async () => {
    expect(await cspForPath(ORDINARY_PATH)).toContain("object-src 'none'");
    expect(await cspForPath(CONSENT_PATH)).toContain("object-src 'none'");
  });

  it("stops MIME sniffing", async () => {
    expect((await headersForPath(ORDINARY_PATH)).get("x-content-type-options")).toEqual([
      "nosniff",
    ]);
  });

  it("keeps referrers off cross-origin requests", async () => {
    expect((await headersForPath(ORDINARY_PATH)).get("referrer-policy")).toEqual([
      "strict-origin-when-cross-origin",
    ]);
  });

  it("does not advertise the framework version", () => {
    expect(nextConfig.poweredByHeader).toBe(false);
  });
});

// form-action is enforced against a form submission's ENTIRE redirect chain, not just its initial
// target. The consent form posts same-origin to /oauth/authorize, which answers with a 302 to the
// OAuth client's registered redirect_uri (e.g. http://localhost:3118/callback). Under
// form-action 'self' the browser refuses that hop with no error the user can see: the auth code is
// minted and stored, the page simply does not move, and "Allow access" looks dead. Verified in
// Chrome against a copy of these exact headers.
describe("next.config consent-screen form-action exemption", () => {
  it("does not constrain form-action on the consent screen", async () => {
    expect(await cspForPath(CONSENT_PATH)).not.toContain("form-action");
  });

  it("keeps the exemption to the consent path alone", async () => {
    for (const path of [
      ORDINARY_PATH,
      "/",
      "/oauth/authorize",
      "/oauth/authorize/consent/nested",
    ]) {
      expect(await cspForPath(path)).toContain("form-action 'self'");
    }
  });

  it("exempts the consent path in its trailing-slash form too", async () => {
    expect(await cspForPath(`${CONSENT_PATH}/`)).not.toContain("form-action");
  });
});