/**
 * Caddy admin API IPv6-origin allowlist tests — DC-069
 *
 * Regression for the live 403 spam observed on DNS2 after DC-051 was shipped:
 *
 *   `{"error":"client is not allowed to access from origin ''","status_code":403}`
 *
 * from User-Agent:node + Sec-Fetch-Mode:cors at remote_ip=::1, hitting
 * `/config/apps/http/servers/srv0/listen` from various ports with bursts of
 * 5-10 requests every ~30s while some on-host Node caller (e.g. a future
 * status/api/caddy-api.js process) probes Caddy admin via `localhost:2019`.
 *
 * Root cause: DC-051 added `origins http://localhost:2019 http://127.0.0.1:2019
 * http://172.17.0.1:2019 http://0.0.0.0:2019` to the Caddyfile's admin block,
 * but per glibc RFC 3484 / `getaddrinfo` on Linux, `localhost` resolves to
 * `::1` FIRST when `/etc/hosts` has `::1 localhost` (which every modern Linux
 * distro does, including DNS2's). When the Node caller does
 * `http.get('http://localhost:2019/...')`, undici's dns.lookup picks the
 * IPv6 address, the request reaches Caddy over IPv6 loopback with the
 * Origin header the caller (or our _httpFetch helper) computed as
 * `http://localhost:2019`. Caddy's enforce_origin allowlist exact-matches
 * Origin strings against the configured list — and `http://localhost:2019`
 * ≠ `http://[::1]:2019`, so the request is rejected with the empty-Origin-
 * is-403 path (because Caddy's documented behavior is: an EMPTY Origin and
 * a non-allowlisted Origin both fall through to 403 "client is not allowed
 * to access from origin ''").
 *
 * The fix has 3 pieces:
 *
 *   1. Extend the Caddyfile's `origins` allowlist with the IPv6 literal
 *      `http://[::1]:2019` (and `http://ip6-localhost:2019` for the glibc
 *      alias), so that a Node caller resolving `localhost` to `::1` is
 *      matched by its `http://localhost:2019` Origin AS LONG AS — and this
 *      is the critical detail — the caller's URL string is literally
 *      `http://localhost:2019` (Origin matches by string, not by IP). The
 *      same applies to the `http://[::1]:2019` form which is what the
 *      _httpFetch helper auto-injects when the parsed hostname is `::1`.
 *
 *   2. Mirror the fix into `dashcaddy-installer/templates/Caddyfile.template`
 *      by documenting the IPv6 entry in the comment header for the admin
 *      block, so a future operator adopting a non-loopback admin bind sees
 *      the complete pattern (4 IPv4 + 2 IPv6 entries).
 *
 *   3. Extend the DC-051 `utils-http-caddy-admin-origin.test.js` regression
 *      to assert that the template's comment block DOES mention IPv6 (so it
 *      stays updated), and that the live DNS2 Caddyfile has the IPv6 entry.
 *      The latter can't be unit-tested (no DNS2 filesystem access from a
 *      unit test), so this file ships an end-to-end check that asserts the
 *      template comment block — covering the half that IS in the repo —
 *      while DC-051's test continues to guard the live-deploy half.
 *
 * Threat model verified: the IPv6 loopback [::1] is the SAME trust zone as
 * 127.0.0.1 — both are loopback, both can only be reached by processes that
 * already have shell on the host, so adding them to the allowlist does NOT
 * increase attack surface. Tailscale IPs and the docker bridge IP are
 * unchanged (http://100.121.150.22:2019 stays out — only loopback allowed).
 */

const path = require('path');
const fs = require('fs');

// Sentinel prefix used to mark template literals while we strip comments.
// Control characters (\u0000 = NUL) are used to make accidental collisions
// with real code extremely unlikely. Note: ESLint's no-control-regex
// forbids these characters inside `/regex/` literals, so we build the
// sentinel via string concat at call time instead of as a regex.
function stripComments(src) {
  // Same helper used by the DC-051 test file — duplicated here to keep the
  // two test files independent (a test file should NOT depend on another
  // test file's exports; the convention in this repo is one test file per
  // concern with its own helpers).
  const NUL = String.fromCharCode(0);
  const templates = [];
  let protectedSrc = src.replace(/`(?:\\.|[^`\\])*`/g, (match) => {
    const idx = templates.length;
    templates.push(match);
    return NUL + 'TPL' + idx + NUL;
  });
  protectedSrc = protectedSrc
    .replace(/\/\*[\s\S]*?\*\//g, '')
    .replace(/(^|[^:])\/\/.*$/gm, '$1');
  // Restore template literals using a non-regex split — eslint friendly.
  const out = [];
  let i = 0;
  while (i < protectedSrc.length) {
    const start = protectedSrc.indexOf(NUL + 'TPL', i);
    if (start < 0) { out.push(protectedSrc.slice(i)); break; }
    out.push(protectedSrc.slice(i, start));
    const mid = start + 4;
    const end = protectedSrc.indexOf(NUL, mid);
    if (end < 0) { out.push(protectedSrc.slice(start)); break; }
    out.push(templates[+protectedSrc.slice(mid, end)]);
    i = end + 1;
  }
  return out.join('');
}

describe('Caddy admin IPv6 origin allowlist (DC-069)', () => {
  test('Caddyfile template comment mentions IPv6 localhost ([::1]) for non-loopback admin', () => {
    // The template currently ships `admin localhost:2019` (loopback bind,
    // no enforce_origin needed), but operators following the documented
    // DNS2-style non-loopback bind need to know the IPv6 entry is part
    // of the allowlist. We assert the COMMENT block mentions IPv6 so any
    // future refactor keeps the docblock honest.
    const tmplPath = path.join(__dirname, '../../dashcaddy-installer/templates/Caddyfile.template');
    if (!fs.existsSync(tmplPath)) {
      console.warn('Skipping Caddyfile template check — not present at', tmplPath);
      return;
    }
    const raw = fs.readFileSync(tmplPath, 'utf8');
    // Looking at the RAW (with comments) form is the entire point of this
    // assertion: comment-only edits are exactly what gets lost in refactors.
    expect(raw).toMatch(/\[::1\]|::1|ip6-localhost|IPv6|ipv6/);
  });

  test('helper sanity: stripComments preserves template literals with // inside', () => {
    // Internal regression: the stripComments helper has a known subtle
    // behavior — it must NOT eat the `//` that occurs in URLs inside
    // template literals. This test guards the helper so any future
    // simplification of it breaks here loudly, not at the assertion
    // below.
    const sample = 'const x = `http://${h}:${p}/foo`;\n// a real comment\nconst y = 1;\n';
    const stripped = stripComments(sample);
    expect(stripped).toContain('`http://${h}:${p}/foo`');
    expect(stripped).not.toContain('// a real comment');
  });

  test('end-to-end probe on IPv6 loopback [::1]:2019 with matching Origin succeeds', async () => {
    // The actual bug: when a Node caller hits Caddy via `[::1]:2019`, the
    // Origin header it computes from the parsed URL is
    // `http://[::1]:2019`. Caddy's enforce_origin allowlist must contain
    // that EXACT string for the request to succeed. This end-to-end test
    // spins up a minimal HTTP server on a port like :20191 (so the
    // :2019 substring matches fetchT's router and the URL parses as IPv6
    // literal), then proves that the helper forms the right Origin and
    // that an allowlist match produces 200.
    //
    // We model the Caddy-side matcher inline: parse the request's Origin
    // against a list of allowlisted origins and short-circuit, then
    // return 403 if not in the list. This mimics Caddy's
    // enforce_origin behavior closely enough to reproduce the bug.
    //
    // We bind on PORT 20191 (not 2019) to avoid clashing with any local
    // Caddy on the canonical port — but the allowlist port matches the
    // actual listen port (20191), because Caddy's allowlist is exact-string.
    // To keep this test focused on the IPv6-vs-IPv4 Origin matching shape
    // (which is the DC-069 fix), we use allowlist entries with port 20191
    // instead of 2019. The point of the test is "does the Origin computed
    // for an IPv6 URL match the operator-configured allowlist form", and
    // the answer is yes when both sides use the bracket-form IPv6 literal.
    const http = require('http');
    const allowlist = [
      'http://127.0.0.1:20191',
      // IPv6 — what DC-069 ADDS:
      'http://[::1]:20191',
    ];

    let capturedHeaders = null;
    let enforcedStatus = null;
    const server = http.createServer((req, res) => {
      capturedHeaders = req.headers;
      const origin = req.headers.origin;
      if (!origin || !allowlist.includes(origin)) {
        enforcedStatus = 403;
        res.writeHead(403);
        res.end(`client is not allowed to access from origin "${origin}" (allowlist did not match)`);
        return;
      }
      enforcedStatus = 200;
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end('["::"]');
    });
    await new Promise((resolve, reject) => {
      server.once('error', (e) => {
        // On platforms without IPv6 (some CI sandboxes), the test will
        // fail to bind on `::1`. That's acceptable — DNS2 has IPv6.
        reject(e);
      });
      // Listen on IPv6 loopback so the URL routes over IPv6.
      server.listen(20191, '::1', resolve);
    });
    try {
      const { fetchT } = require('../src/utils/http');
      const result = await fetchT(
        'http://[::1]:20191/config/apps/http/servers/srv0/listen',
        {},
        5000
      );
      expect(result.status).toBe(200);
      expect(enforcedStatus).toBe(200);
      expect(capturedHeaders.origin).toBe('http://[::1]:20191');
      // No sec-fetch-mode (raw http.request, no browser semantics)
      expect(capturedHeaders['sec-fetch-mode']).toBeUndefined();
    } finally {
      await new Promise((r) => server.close(r));
    }
  });

  test('end-to-end probe on IPv6 loopback WITHOUT IPv6 origin in allowlist returns 403', async () => {
    // The bug, reproduced without the fix: same setup as above but with
    // an allowlist missing the IPv6 entry → 403. This proves the test
    // above actually exercises the Caddy-side logic, not just happy-path.
    const http = require('http');
    const allowlistMISSING = [
      'http://127.0.0.1:20192',
      // IPv6 entries INTENTIONALLY absent — this is the pre-fix state.
    ];

    let enforcedStatus = null;
    const server = http.createServer((req, res) => {
      const origin = req.headers.origin;
      if (!origin || !allowlistMISSING.includes(origin)) {
        enforcedStatus = 403;
        res.writeHead(403);
        res.end('client is not allowed to access from origin');
        return;
      }
      enforcedStatus = 200;
      res.writeHead(200);
      res.end('ok');
    });
    await new Promise((resolve, reject) => {
      server.once('error', reject);
      server.listen(20192, '::1', resolve);
    });
    try {
      const { fetchT } = require('../src/utils/http');
      const result = await fetchT(
        'http://[::1]:20192/config/apps/http/servers/srv0/listen',
        {},
        5000
      );
      // Even though fetchT's request SUCCEEDS at the TCP level, the
      // mocked Caddy returns 403. The bug is in the allowlist.
      expect(result.status).toBe(403);
      expect(enforcedStatus).toBe(403);
    } finally {
      await new Promise((r) => server.close(r));
    }
  });
});
