/**
 * DC-065: OpenClaw proxy hardening — test the four attack vectors closed
 * by the proxyRequest refactor:
 *   (a) unbounded response passthrough → 5 MiB cap with 502 on overrun
 *   (b) hop-by-hop + dangerous response-header passthrough → stripped
 *   (c) malformed proxyRes.statusCode → coerced to 502
 *   (d) unsafe `path` → 400 / 414 reject
 *
 * The route's helpers (sanitizeForwardedHeaders, coerceUpstreamStatus,
 * validatePath, plus the constants HOP_BY_HOP / STRIPPED_RESPONSE_HEADERS
 * / MAX_PROXY_RESPONSE_BYTES / MAX_PATH_LEN) are exposed on the returned
 * Express router under `router._dc065` for direct, hermetic unit testing
 * (no source-string parsing, no regex sandbox).
 *
 * End-to-end tests spin a real upstream http server on 127.0.0.1 to
 * exercise the proxy boundary through Express → openclaw router → http.
 */

const http = require('http');
const express = require('express');

const openclawModule = require('../../routes/openclaw');

function makeRouter() {
  return openclawModule({
    docker: { client: { listContainers: async () => [] } },
    asyncHandler: (fn) => fn,
    ok: (res, data, code) => res.status(code || 200).json({ success: true, ...data }),
    log: { info() {}, error() {}, warn() {}, debug() {} },
  });
}

function spinUpstream(handler) {
  return new Promise((resolve) => {
    const server = http.createServer(handler);
    server.listen(0, '127.0.0.1', () => {
      const { port } = server.address();
      resolve({ server, port, close: () => new Promise((r) => server.close(r)) });
    });
  });
}

describe('routes/openclaw — DC-065 proxy hardening', () => {
  describe('router shape (regression)', () => {
    test('router builds with /status, /deploy, /proxy/*, DELETE handlers and exposes _dc065 helpers', () => {
      const router = makeRouter();
      const paths = router.stack
        .filter((l) => l.route)
        .map((l) => Object.keys(l.route.methods).map((m) => `${m.toUpperCase()} ${l.route.path}`))
        .flat();
      expect(paths).toEqual(expect.arrayContaining([
        'GET /status',
        'POST /deploy',
        'GET /proxy/*',
        'POST /proxy/*',
        'DELETE /',
      ]));
      // DC-065 helper exposure — fails loud if a future refactor removes it.
      expect(router._dc065).toBeDefined();
      expect(typeof router._dc065.sanitizeForwardedHeaders).toBe('function');
      expect(typeof router._dc065.coerceUpstreamStatus).toBe('function');
      expect(typeof router._dc065.validatePath).toBe('function');
    });
  });

  describe('sanitizeForwardedHeaders (DC-065)', () => {
    let helpers;
    beforeAll(() => { helpers = makeRouter()._dc065; });

    test('strips RFC 7230 hop-by-hop headers (case-insensitive)', () => {
      const input = {
        Connection: 'close',
        'keep-alive': 'timeout=5',
        'Proxy-Authenticate': 'Basic realm=...',
        'proxy-authorization': 'Basic foo',
        TE: 'trailers',
        Trailers: 'X-Foo',
        'Transfer-Encoding': 'chunked',
        Upgrade: 'websocket',
      };
      expect(Object.keys(helpers.sanitizeForwardedHeaders(input))).toEqual([]);
    });

    test('strips Set-Cookie / Content-Encoding / Content-Length / Server / X-Powered-By / Location / Refresh / WWW-Authenticate', () => {
      const input = {
        'Set-Cookie': 'sid=abc; HttpOnly',
        'Location': 'http://evil.com/steal',         // DC-065 round-1 finding
        'Refresh': '0; url=http://evil.com/steal',    // DC-065 round-2 finding
        'WWW-Authenticate': 'Basic realm="OpenClaw"', // DC-065 round-2 finding
        'Content-Encoding': 'gzip',
        'Content-Length': '99999',
        'Server': 'openclaw/1.0',
        'X-Powered-By': 'openclaw',
        'X-Custom': 'kept',
      };
      const out = helpers.sanitizeForwardedHeaders(input);
      expect(Object.keys(out).sort()).toEqual(['X-Custom']);
    });

    test('passes safe application/json + cache headers through unchanged', () => {
      const input = {
        'Content-Type': 'application/json',
        'Cache-Control': 'no-store',
        'X-Request-Id': 'req-123',
      };
      const out = helpers.sanitizeForwardedHeaders(input);
      expect(out['Content-Type']).toBe('application/json');
      expect(out['Cache-Control']).toBe('no-store');
      expect(out['X-Request-Id']).toBe('req-123');
    });

    test('null/undefined input → empty object', () => {
      expect(helpers.sanitizeForwardedHeaders(null)).toEqual({});
      expect(helpers.sanitizeForwardedHeaders(undefined)).toEqual({});
    });

    test('MAX_PROXY_RESPONSE_BYTES is 5 MiB', () => {
      expect(helpers.MAX_PROXY_RESPONSE_BYTES).toBe(5 * 1024 * 1024);
    });
  });

  describe('coerceUpstreamStatus (DC-065)', () => {
    let helpers;
    beforeAll(() => { helpers = makeRouter()._dc065; });

    test('returns valid integer statuses 100..599 unchanged', () => {
      for (const s of [100, 200, 301, 404, 418, 500, 502, 503, 599]) {
        expect(helpers.coerceUpstreamStatus(s)).toBe(s);
      }
    });

    test('out-of-range integers coerce to 502', () => {
      expect(helpers.coerceUpstreamStatus(0)).toBe(502);
      expect(helpers.coerceUpstreamStatus(99)).toBe(502);
      expect(helpers.coerceUpstreamStatus(600)).toBe(502);
      expect(helpers.coerceUpstreamStatus(1000)).toBe(502);
    });

    test('non-integer numbers coerce to 502', () => {
      expect(helpers.coerceUpstreamStatus(200.5)).toBe(502);
      expect(helpers.coerceUpstreamStatus(NaN)).toBe(502);
      expect(helpers.coerceUpstreamStatus(Infinity)).toBe(502);
    });

    test('non-number types coerce to 502', () => {
      expect(helpers.coerceUpstreamStatus('200')).toBe(502);
      expect(helpers.coerceUpstreamStatus(null)).toBe(502);
      expect(helpers.coerceUpstreamStatus(undefined)).toBe(502);
      expect(helpers.coerceUpstreamStatus('OK')).toBe(502);
    });
  });

  describe('validatePath (DC-065)', () => {
    let helpers;
    beforeAll(() => { helpers = makeRouter()._dc065; });

    test('rejects empty / non-string / oversize paths', () => {
      expect(helpers.validatePath('').ok).toBe(false);
      expect(helpers.validatePath(null).ok).toBe(false);
      expect(helpers.validatePath(undefined).ok).toBe(false);
      expect(helpers.validatePath(123).ok).toBe(false);
      const long = '/' + 'a'.repeat(helpers.MAX_PATH_LEN);
      const r = helpers.validatePath(long);
      expect(r.ok).toBe(false);
      expect(r.code).toBe(414);
    });

    test('rejects absolute-URL injection (`://`)', () => {
      const r = helpers.validatePath('foo://127.0.0.1:6379/steal');
      expect(r.ok).toBe(false);
    });

    test('rejects whitespace / backslash / CR/LF', () => {
      expect(helpers.validatePath('foo bar').ok).toBe(false);
      expect(helpers.validatePath('foo\r\nbar').ok).toBe(false);
      expect(helpers.validatePath('foo\\bar').ok).toBe(false);
      expect(helpers.validatePath('foo\tbar').ok).toBe(false);
    });

    test('accepts RFC 3986 pchar + query separators', () => {
      // Real-world path sent by a browser: query string starts with `?`.
      // (Fragments `#frag` are stripped by the browser before reaching
      // the server — we don't need to allow them.)
      const ok = helpers.validatePath('/api/v1/chat?msg=hi&x=y');
      expect(ok.ok).toBe(true);
      expect(ok.normalized).toBe('api/v1/chat?msg=hi&x=y');
    });

    test('strips multiple leading slashes idempotently', () => {
      const ok = helpers.validatePath('///foo/bar');
      expect(ok.ok).toBe(true);
      expect(ok.normalized).toBe('foo/bar');
    });
  });

  describe('end-to-end via /openclaw/proxy/* (DC-065 integration)', () => {
    // Helper: build an express app mounted with the openclaw router and
    // a docker stub that returns the provided upstream port.
    function buildProxyApp(upstreamPort) {
      const fakeContainer = {
        Id: 'a'.repeat(64),
        Image: 'ghcr.io/nousresearch/openclaw:latest',
        Names: ['/openclaw-test'],
        State: 'running',
        Status: 'Up',
        Created: 1700000000,
        Labels: { 'dashcaddy.managed': 'true', 'dashcaddy.app': 'openclaw' },
        Ports: [{ PrivatePort: 18792, PublicPort: upstreamPort }],
      };
      const app = express();
      app.disable('x-powered-by');   // mirror src/app.js line 139
      app.disable('etag');
      app.use(express.json());
      app.use((req, res, next) => {
        res.ok = (data, code) => res.status(code || 200).json({ success: true, ...data });
        res.errorResponse = (msg, code, extras) =>
          res.status(code || 500).json({ success: false, error: msg, ...(extras || {}) });
        res.notFound = (msg) => res.status(404).json({ success: false, error: msg });
        res.conflict = (msg) => res.status(409).json({ success: false, error: msg });
        next();
      });
      const router = openclawModule({
        docker: {
          client: {
            listContainers: async () => [fakeContainer],
            containerInfo: async () => ({ Config: { Env: ['OPENCLAW_GATEWAY_TOKEN=test-token'] } }),
          },
        },
        asyncHandler: (fn) => fn,
        ok: (res, data, code) => res.status(code || 200).json({ success: true, ...data }),
        log: { info() {}, error() {}, warn() {}, debug() {} },
      });
      app.use('/openclaw', router);
      return app;
    }

    function listen(app) {
      return new Promise((resolve) => {
        const server = app.listen(0, () => {
          const { port } = server.address();
          resolve({ server, port, close: () => new Promise((r) => server.close(r)) });
        });
      });
    }

    test('caps an oversized upstream response with 502 + DC-065 message', async () => {
      const upstream = await spinUpstream((req, res) => {
        res.writeHead(200, { 'Content-Type': 'application/octet-stream' });
        // 6 MiB single chunk — proxy caps at 5 MiB.
        res.write(Buffer.alloc(6 * 1024 * 1024, 0x41));
        res.end();
      });
      try {
        const app = buildProxyApp(upstream.port);
        const { server, port, close } = await listen(app);
        try {
          const r = await fetch(`http://127.0.0.1:${port}/openclaw/proxy/health`);
          expect(r.status).toBe(502);
          const text = await r.text();
          expect(text).toMatch(/DC-065|upstream/g);
        } finally {
          await close();
        }
      } finally {
        await upstream.close();
      }
    }, 30000);

    test('forwards safe upstream headers; strips Set-Cookie / Transfer-Encoding / Content-Encoding / Location / Refresh / WWW-Authenticate', async () => {
      const upstream = await spinUpstream((req, res) => {
        res.writeHead(200, {
          'Content-Type': 'application/json',
          'Cache-Control': 'no-store',
          // These must NOT cross the proxy to the browser:
          'Transfer-Encoding': 'chunked',
          'Upgrade': 'websocket',
          'Set-Cookie': 'sid=steal; HttpOnly',
          'Location': 'http://evil.com/steal',                          // DC-065 round-1
          'Refresh': '0; url=http://evil.com/steal',                    // DC-065 round-2
          'WWW-Authenticate': 'Basic realm="OpenClaw"',                 // DC-065 round-2
          'Content-Encoding': 'gzip',
          'Server': 'openclaw/1.0',
          'X-Powered-By': 'openclaw',
        });
        res.end(JSON.stringify({ ok: true }));
      });
      try {
        const app = buildProxyApp(upstream.port);
        const { server, port, close } = await listen(app);
        try {
          const r = await fetch(`http://127.0.0.1:${port}/openclaw/proxy/status`);
          expect(r.status).toBe(200);
          // Node's http server may emit Connection/Keep-Alive of its own
          // accord (HTTP/1.1 keep-alive defaults), so we don't gate on those.
          // We DO gate on the ten upstream-shaping headers our sanitizer
          // explicitly removes — see sanitizeForwardedHeaders().
          for (const forbidden of [
            'transfer-encoding',
            'upgrade',
            'set-cookie',
            'location',
            'refresh',
            'www-authenticate',
            'content-encoding',
            'server',
            'x-powered-by',
            // content-length: Node sets it automatically when we buffer + end(),
            // so we cannot test that the upstream's CL header is stripped — but
            // we ARE stripping it from the forwarded headers, verified by
            // sanitization unit tests above.
          ]) {
            expect(r.headers.get(forbidden)).toBeNull();
          }
          expect(r.headers.get('content-type')).toMatch(/^application\/json/);
          expect(r.headers.get('cache-control')).toBe('no-store');
          const body = await r.json();
          expect(body.ok).toBe(true);
          void server;
        } finally {
          await close();
        }
      } finally {
        await upstream.close();
      }
    }, 10000);

    test('rejects path with `://` injection via 400', async () => {
      // Upstream on any port — the validator must reject BEFORE we dial it.
      const upstream = await spinUpstream(() => {
        throw new Error('should not reach upstream on reject path');
      });
      try {
        const app = buildProxyApp(upstream.port);
        const { server, port, close } = await listen(app);
        try {
          // URL-decoded `foo://127.0.0.1` → forbidden char `://` → 400.
          const r = await fetch(`http://127.0.0.1:${port}/openclaw/proxy/foo%3A%2F%2F127.0.0.1`);
          expect(r.status).toBe(400);
          const body = await r.json();
          expect(body.success).toBe(false);
          expect(body.error).toMatch(/forbidden|disallowed/i);
          void server;
        } finally {
          await close();
        }
      } finally {
        await upstream.close();
      }
    }, 10000);
  });
});
