/**
 * Caddy admin API CSRF Origin-header tests — DC-051
 *
 * Verifies:
 *   - _httpFetch (fetchT's :2019 raw http branch) injects `Origin: http://<host>:<port>`
 *     for any Caddy admin URL, satisfying Caddy's `enforce_origin` CSRF check
 *     that activates on non-loopback admin binds (e.g. `admin 0.0.0.0:2019`).
 *   - Caller-provided Origin via opts.headers WINS over the auto-injected
 *     default (so future proxies / tests can override).
 *   - fetchT routes :2019 URLs through _httpFetch (raw http.request) and
 *     leaves HTTPS URLs on Node's undici fetch (for self-signed cert support).
 *   - The /config/apps/http/servers/srv0/listen health probe that the readiness
 *     handler emits against http://localhost:2019 includes the Origin header.
 *
 * Regression for the live 403 spam observed on DNS2 (Caddy log:
 *   `{"error":"client is not allowed to access from origin ''","status_code":403}`
 *   from User-Agent:node + Sec-Fetch-Mode:cors at remote_port 5xxxx, repeated
 *   every ~10s while the readiness workflow probes Caddy admin). The fix is
 *   the Origin header injection here + the `origins` directive in the
 *   Caddyfile's admin block on DNS2 — both are required for Caddy's CSRF
 *   check to accept same-origin admin calls.
 */

// Capture the http.request call shape without spinning up a real server.
// We do this by reading the http.js source and exporting a probe function
// that the test calls directly — this avoids brittle mock plumbing while
// still proving the Origin header is constructed correctly.
//
// Strategy: the test imports a small wrapper that exposes the request
// construction step from _httpFetch in isolation, then asserts on the
// returned options.

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

// Strip JS comments so docblock prose doesn't false-positive on regex
// patterns that look for code (e.g. `origins`, `enforce_origin`).
// IMPORTANT: do not strip `//` inside template literals — those are
// URL/comment sequences like `http://${parsed.hostname}:${parsed.port}`.
// We do this in two passes: (1) protect template-literal contents by
// replacing them with placeholders, (2) strip comments, (3) restore
// the placeholders.
function stripComments(src) {
  // Pass 1: replace template literals (backtick-delimited) with sentinels.
  const templates = [];
  let protectedSrc = src.replace(/`(?:\\.|[^`\\])*`/g, (match) => {
    const idx = templates.length;
    templates.push(match);
    return `\u0000TPL${idx}\u0000`;
  });
  // Pass 2: strip block + line comments from the now-comment-safe string.
  protectedSrc = protectedSrc
    .replace(/\/\*[\s\S]*?\*\//g, '')        // block comments
    .replace(/(^|[^:])\/\/.*$/gm, '$1');    // line comments, leaving URLs alone
  // Pass 3: restore template literals.
  return protectedSrc.replace(/\u0000TPL(\d+)\u0000/g, (_, idx) => templates[+idx]);
}

const { fetchT } = require('../src/utils/http');

describe('Caddyfile + utils/http.js — Origin header construction (DC-051)', () => {
  test('http.js _httpFetch computes Origin from parsed URL host+port', () => {
    // Read the source file and verify the Origin line is constructed from
    // the parsed URL's hostname+port, matching what the readiness probe needs.
    const code = stripComments(fs.readFileSync(
      path.join(__dirname, '../src/utils/http.js'),
      'utf8'
    ));

    // 1. The default origin is built from the parsed URL
    expect(code).toMatch(/const defaultOrigin\s*=\s*`\$\{parsed\.protocol\}\/\/\$\{parsed\.hostname\}:\$\{parsed\.port\s*\|\|\s*2019\}`/);

    // 2. The Origin header is set, with caller opts.headers spread after
    //    (so caller wins on duplicate keys)
    expect(code).toMatch(/headers:\s*{\s*Origin:\s*defaultOrigin,\s*\.\.\.opts\.headers,/);

    // 3. The router still routes :2019 to _httpFetch (raw http.request)
    expect(code).toMatch(/if\s*\(url\.includes\(':2019'\)\)/);

    // 4. Comments explain the CSRF rationale (regression-proofing).
    //    We check the RAW (with comments) source so this catches accidental
    //    removal of the rationale docblock too.
    const raw = fs.readFileSync(
      path.join(__dirname, '../src/utils/http.js'),
      'utf8'
    );
    expect(raw).toMatch(/enforce_origin/);
    expect(raw).toMatch(/origins/);
  });

  test('all :2019 call sites use fetchT (not raw fetch)', () => {
    // Every Caddy admin API call in the API code should go through fetchT,
    // not bare fetch — fetchT routes :2019 through _httpFetch which now
    // injects Origin. A new call site using bare fetch would skip the
    // CSRF fix and re-introduce the 403 loop.
    const apiRoot = path.join(__dirname, '..');
    const offenders = [];
    function walk(dir) {
      for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
        if (entry.name === 'node_modules' || entry.name === '__tests__') continue;
        const p = path.join(dir, entry.name);
        if (entry.isDirectory()) walk(p);
        else if (entry.name.endsWith('.js')) {
          const text = stripComments(fs.readFileSync(p, 'utf8'));
          // Find every `fetch(` call and check whether the SAME call contains
          // a :2019 URL — if so, it should be `fetchT(` instead.
          const matches = text.match(/await\s+fetch\(([^)]*)\)/g) || [];
          for (const m of matches) {
            if (/:2019|adminUrl|admin_api_url|CADDY_ADMIN/.test(m)) {
              offenders.push(`${p}: ${m.slice(0, 100)}`);
              break;
            }
          }
        }
      }
    }
    walk(apiRoot);
    expect(offenders).toEqual([]);
  });

  test('all :2019 call sites in TESTS use fetchT or a mocked fetchT (not raw fetch)', () => {
    // DC-087 — the same rule, extended into __tests__. The api-code walk above
    // skips __tests__, which let two mirrored health-handler test files keep a
    // raw await-fetch caddy probe long after src/app.js moved to fetchT. On a
    // host where the suite runs alongside a live Caddy admin (the prod box
    // runs the full jest suite every 30 min via a cron adversarial check),
    // that Origin-less raw fetch 403-spammed the Caddy journal (~700
    // client-not-allowed error lines per day) while the tests still passed —
    // checks.caddy.ok=false was silently accepted as sandbox noise. Mirrors
    // MUST call fetchT (mocked at buildApp scope for hermeticity). A raw
    // await-fetch at a Caddy-admin-URL call site in a test is an offender.
    // NOTE: keep this comment free of backticks — stripComments pairs
    // backtick spans across lines, and a stray pair shields real code from
    // the comment stripper (this test self-flagged its first draft).
    //
    // Detection is deliberately FILE-LEVEL, not call-window: the historical
    // drift kept the fetch call itself token-free (the URL came from a
    // caddyUrl variable defined on a PREVIOUS line from CADDY_ADMIN_URL),
    // so a call-window regex never fired. Any raw await-fetch in a file
    // that also references the Caddy admin anywhere is an offender.
    // Escape hatch for future tests that intentionally assert Origin-less
    // 403 behavior against their own local listener: put the marker
    // DC-087-ALLOW-RAW-FETCH in the file and it is skipped.
    const testsRoot = path.join(__dirname);
    const offenders = [];
    const skipped = [];
    function walk(dir) {
      for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
        if (entry.name === 'node_modules') continue;
        const p = path.join(dir, entry.name);
        if (entry.isDirectory()) walk(p);
        else if (entry.name.endsWith('.js')) {
          const rawText = fs.readFileSync(p, 'utf8');
          // Escape hatch (checked on RAW text so a comment marker works —
          // comments are stripped below): a file carrying the
          // DC-087-ALLOW-RAW-FETCH marker declares it intentionally
          // raw-fetches the Caddy admin (e.g. asserting Origin-less 403
          // against its own local listener). The guard file itself is
          // always scanned (never skipped) so the hatch can't be used to
          // blind this very test.
          if (p !== __filename && /DC-087-ALLOW-RAW-FETCH/.test(rawText)) {
            skipped.push(p);
            continue;
          }
          const text = stripComments(rawText);
          const hasAdminToken = /:2019|adminUrl|admin_api_url|CADDY_ADMIN/.test(text);
          const hasRawAwaitFetch = /await\s+fetch\(/.test(text);
          if (hasAdminToken && hasRawAwaitFetch) {
            offenders.push(`${p}: raw await-fetch in a file referencing the Caddy admin (mock fetchT instead; documented escape-hatch marker available for intentional 403 tests)`);
          }
        }
      }
    }
    walk(testsRoot);
    if (skipped.length) {
      // Visibility for hatch use — shows up in jest output for reviewers.
      console.info('[DC-087 guard] escape-hatch skipped:', skipped.join(', '));
    }
    expect(offenders).toEqual([]);
  });

  test('readiness handler in src/app.js probes the exact URL the watcher needs', () => {
    const raw = fs.readFileSync(
      path.join(__dirname, '../src/app.js'),
      'utf8'
    );
    // The probe URL is the one that was 403-looping every 10s in prod.
    expect(raw).toMatch(/\/config\/apps\/http\/servers\/srv0\/listen/);
    // Goes through fetchT, NOT bare fetch — that's how the Origin injection
    // takes effect. Look at the 800 chars BEFORE the probe URL on the same
    // line / call site — the call must be fetchT(...), never a raw await of
    // the global fetch. (We look backward because the URL sits inside the
    // call's argument list, so the call site comes before the URL token.)
    const idx = raw.indexOf('srv0/listen');
    const around = raw.substr(Math.max(0, idx - 400), 800);
    expect(around).toMatch(/fetchT\(/);
    expect(around).not.toMatch(/await fetch\(/);
  });

  test('end-to-end: fetchT sends Origin header to a real HTTP server on :2019', async () => {
    // Spin up a minimal HTTP server on a port that LOOKS like :2019 from
    // fetchT's router perspective. We use port :20190 (contains ':2019'
    // substring so url.includes(':2019') is true → routes through _httpFetch)
    // to avoid clashing with any local Caddy on the canonical :2019.
    const http = require('http');
    let capturedHeaders = null;
    const server = http.createServer((req, res) => {
      capturedHeaders = req.headers;
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end('["::"]');
    });
    await new Promise((resolve, reject) => {
      server.once('error', reject);
      server.listen(20190, '127.0.0.1', resolve);
    });
    try {
      // fetchT routes this URL through _httpFetch because it includes
      // ':2019' as a substring. _httpFetch computes Origin from the
      // parsed URL — parsed.port is '20190' here, so Origin is
      // http://127.0.0.1:20190.
      const result = await fetchT(
        'http://127.0.0.1:20190/config/apps/http/servers/srv0/listen',
        {},
        5000
      );
      expect(result.status).toBe(200);
      expect(capturedHeaders.origin).toBe('http://127.0.0.1:20190');
      // raw http doesn't add User-Agent by default
      expect(capturedHeaders['user-agent']).toBeUndefined();
      // critical: no Sec-Fetch-Mode: cors (that's what triggers Caddy's CSRF)
      expect(capturedHeaders['sec-fetch-mode']).toBeUndefined();
    } finally {
      await new Promise((r) => server.close(r));
    }
  });

  test('Caddyfile template documents the origins directive for non-loopback admin bind', () => {
    // The HIGH-severity fix from GLM review: the live /etc/caddy/Caddyfile
    // is operator-managed (via caddy-apply, NOT in this repo), so this
    // test guards the only Caddyfile that IS in the repo — the installer
    // template — so any future operator using `admin 0.0.0.0:2019` (like
    // DNS2 does for the docker bridge to reach it) sees the same shape
    // and isn't surprised by the 403 loop. If a future change adopts
    // non-loopback admin in the template, this test demands the `origins`
    // directive alongside it.
    const tmplPath = path.join(__dirname, '../dashcaddy-installer/templates/Caddyfile.template');
    const exists = fs.existsSync(tmplPath);
    if (!exists) {
      // Template absent (maybe removed in a refactor) — skip with explicit note
      console.warn('Skipping Caddyfile template check — not present at', tmplPath);
      return;
    }
    const raw = fs.readFileSync(tmplPath, 'utf8');
    // Strip comments to look at the actual config shape.
    const code = stripComments(raw);
    const adminBlock = code.match(/admin\s+([^{\s]+)(?:\s+\{([^}]*)\})?/);
    if (!adminBlock) {
      // No admin block configured at all — operator default; nothing to check.
      return;
    }
    const listen = adminBlock[1];
    const isLoopback = listen === '127.0.0.1:2019' || listen === 'localhost:2019' || listen === '::1:2019';
    const inner = adminBlock[2] || '';
    if (!isLoopback) {
      // Non-loopback bind — the `origins` directive is REQUIRED to prevent
      // the 403 loop we just fixed. This assertion will fail if someone
      // changes the template to non-loopback without adding origins.
      expect(inner).toMatch(/origins\s/);
    } else {
      // Loopback bind — Caddy allows loopback origins implicitly, so the
      // `origins` directive is unnecessary. We just verify the template
      // shape is consistent (admin bind + optional inner block).
      expect(listen).toMatch(/:2019/);
    }
  });
});