/**
 * DC-068: Fleet SSRF hardening — routes-layer integration tests
 *
 * Verifies that:
 *   - POST /api/v1/fleet/hosts rejects a public-DNS name that resolves to a
 *     private IP (DNS rebinding defense)
 *   - POST /api/v1/fleet/hosts accepts a public-DNS name that resolves to a
 *     public IP and stores the resolved IP
 *   - POST /api/v1/fleet/hosts rejects literal IPv4 in loopback / link-local
 *     / RFC 1918 / CGNAT / broadcast ranges
 *   - POST /api/v1/fleet/hosts accepts a literal public IPv4
 *   - POST /api/v1/fleet/hosts rejects port 22 (SSH collision)
 *   - POST /api/v1/fleet/hosts rejects control characters in name/tag
 *   - POST /api/v1/fleet/hosts stores the resolved IP and dnsFamily so
 *     /fleet/status and /fleet/deploy can probe by IP
 *   - FLEET_ALLOW_PRIVATE_HOSTS=true opts in to private-range hosts
 *
 * The route tests live alongside the existing DC-108 suite in
 * caddycode-fleet.routes.test.js. We extend that file with two new describe
 * blocks so we can co-locate SSRF regression tests with their feature.
 */
const express = require('express');
const request = require('supertest');

function createFleetApp(log, opts = {}) {
  const app = express();
  app.use(express.json());
  const routes = require('../../routes/fleet');
  const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
  app.use('/api/v1', routes({
    log: log || { info: jest.fn(), warn: jest.fn(), error: jest.fn() },
    asyncHandler: wrap,
  }));
  return app;
}

describe('DC-068: Fleet POST /hosts — SSRF hardening', () => {
  let dnsBackup;
  let filePath;

  beforeEach(() => {
    filePath = `/tmp/fleet-ssrf-${Date.now()}-${Math.random().toString(36).slice(2)}.json`;
    process.env.FLEET_HOSTS_FILE = filePath;
    dnsBackup = require('dns').promises.lookup;
  });

  afterEach(() => {
    require('dns').promises.lookup = dnsBackup;
    delete process.env.FLEET_HOSTS_FILE;
    try { require('fs').unlinkSync(filePath); } catch {}
    delete process.env.FLEET_ALLOW_PRIVATE_HOSTS;
  });

  it('rejects 127.0.0.1 (loopback) with PRIVATE_IPV4', async () => {
    const app = createFleetApp();
    const res = await request(app)
      .post('/api/v1/fleet/hosts')
      .send({ name: 'Local', hostname: '127.0.0.1', port: 3001 });
    expect(res.status).toBe(400);
    expect(res.body.code).toBe('PRIVATE_IPV4');
    expect(res.body.error).toMatch(/loopback/i);
  });

  it('rejects 169.254.169.254 (AWS IMDS)', async () => {
    const app = createFleetApp();
    const res = await request(app)
      .post('/api/v1/fleet/hosts')
      .send({ name: 'IMDS', hostname: '169.254.169.254', port: 80 });
    expect(res.status).toBe(400);
    expect(res.body.code).toBe('PRIVATE_IPV4');
    expect(res.body.error).toMatch(/metadata|link-local/i);
  });

  it('rejects 10.0.0.1 (RFC 1918)', async () => {
    const app = createFleetApp();
    const res = await request(app)
      .post('/api/v1/fleet/hosts')
      .send({ name: 'RFC1918', hostname: '10.0.0.1', port: 3001 });
    expect(res.status).toBe(400);
    expect(res.body.code).toBe('PRIVATE_IPV4');
    expect(res.body.error).toMatch(/RFC 1918/);
  });

  it('rejects 192.168.1.1 (LAN)', async () => {
    const app = createFleetApp();
    const res = await request(app)
      .post('/api/v1/fleet/hosts')
      .send({ name: 'LAN', hostname: '192.168.1.1', port: 3001 });
    expect(res.status).toBe(400);
    expect(res.body.code).toBe('PRIVATE_IPV4');
  });

  it('rejects 100.64.0.1 (Tailscale CGNAT)', async () => {
    const app = createFleetApp();
    const res = await request(app)
      .post('/api/v1/fleet/hosts')
      .send({ name: 'Tailscale', hostname: '100.64.0.1', port: 3001 });
    expect(res.status).toBe(400);
    expect(res.body.code).toBe('PRIVATE_IPV4');
  });

  it('rejects ::1 (IPv6 loopback)', async () => {
    const app = createFleetApp();
    const res = await request(app)
      .post('/api/v1/fleet/hosts')
      .send({ name: 'v6loop', hostname: '::1', port: 3001 });
    expect(res.status).toBe(400);
    expect(res.body.code).toBe('PRIVATE_IPV6');
  });

  it('rejects port 22 (SSH)', async () => {
    const app = createFleetApp();
    const res = await request(app)
      .post('/api/v1/fleet/hosts')
      .send({ name: 'h', hostname: 'fleet.example.com', port: 22 });
    expect(res.status).toBe(400);
    expect(res.body.code).toBe('INVALID_PORT');
    expect(res.body.error).toMatch(/22.*reserved|reserved.*22/);
  });

  it('rejects port > 65535', async () => {
    const app = createFleetApp();
    const res = await request(app)
      .post('/api/v1/fleet/hosts')
      .send({ name: 'h', hostname: 'fleet.example.com', port: 65536 });
    expect(res.status).toBe(400);
    expect(res.body.code).toBe('INVALID_PORT');
  });

  it('rejects port = 0', async () => {
    const app = createFleetApp();
    const res = await request(app)
      .post('/api/v1/fleet/hosts')
      .send({ name: 'h', hostname: 'fleet.example.com', port: 0 });
    expect(res.status).toBe(400);
    expect(res.body.code).toBe('INVALID_PORT');
  });

  it('rejects garbage hostname', async () => {
    const app = createFleetApp();
    const res = await request(app)
      .post('/api/v1/fleet/hosts')
      .send({ name: 'h', hostname: 'not a host!', port: 3001 });
    expect(res.status).toBe(400);
    expect(res.body.code).toBe('INVALID_HOSTNAME');
  });

  it('rejects control characters in name', async () => {
    const app = createFleetApp();
    const res = await request(app)
      .post('/api/v1/fleet/hosts')
      .send({ name: 'evil\nname', hostname: 'fleet.example.com', port: 3001 });
    expect(res.status).toBe(400);
    expect(res.body.code).toBe('INVALID_NAME');
  });

  it('rejects control characters in tags', async () => {
    const app = createFleetApp();
    const res = await request(app)
      .post('/api/v1/fleet/hosts')
      .send({ name: 'h', hostname: 'fleet.example.com', port: 3001, tags: ['good', 'bad\ntag'] });
    expect(res.status).toBe(400);
    expect(res.body.code).toBe('INVALID_TAGS');
  });

  it('accepts a literal public IPv4', async () => {
    const app = createFleetApp();
    const res = await request(app)
      .post('/api/v1/fleet/hosts')
      .send({ name: 'Public', hostname: '8.8.8.8', port: 3001 });
    expect(res.status).toBe(201);
    expect(res.body.host.resolvedIp).toBe('8.8.8.8');
    expect(res.body.host.dnsFamily).toBe(4);
  });

  it('accepts a public DNS name and resolves it', async () => {
    require('dns').promises.lookup = async () => [{ address: '93.184.216.34', family: 4 }];
    const app = createFleetApp();
    const res = await request(app)
      .post('/api/v1/fleet/hosts')
      .send({ name: 'Public DNS', hostname: 'public.example.com', port: 3001 });
    expect(res.status).toBe(201);
    expect(res.body.host.hostname).toBe('public.example.com');
    expect(res.body.host.resolvedIp).toBe('93.184.216.34');
    expect(res.body.host.dnsFamily).toBe(4);
  });

  it('rejects a DNS name that resolves to a private IP (DNS rebinding)', async () => {
    // Simulate a rebinding attacker: registration-time DNS returns a public
    // IP, but a follow-up resolve returns a loopback IP. We mock with the
    // private IP directly — the validator catches it at registration time.
    require('dns').promises.lookup = async () => [{ address: '10.0.0.5', family: 4 }];
    const app = createFleetApp();
    const res = await request(app)
      .post('/api/v1/fleet/hosts')
      .send({ name: 'Rebind', hostname: 'attacker.example.com', port: 3001 });
    expect(res.status).toBe(400);
    expect(res.body.code).toBe('PRIVATE_IPV4');
  });

  it('opts in to private hosts when FLEET_ALLOW_PRIVATE_HOSTS=true', async () => {
    process.env.FLEET_ALLOW_PRIVATE_HOSTS = 'true';
    require('dns').promises.lookup = async () => [{ address: '100.100.50.25', family: 4 }];
    const app = createFleetApp();
    const res = await request(app)
      .post('/api/v1/fleet/hosts')
      .send({ name: 'Tailscale', hostname: 'tailnet.example.com', port: 3001 });
    expect(res.status).toBe(201);
    expect(res.body.host.resolvedIp).toBe('100.100.50.25');
  });

  it('rejects unresolvable DNS name', async () => {
    // .invalid is a guaranteed-non-resolving TLD per RFC 6761.
    const app = createFleetApp();
    const res = await request(app)
      .post('/api/v1/fleet/hosts')
      .send({ name: 'NoDNS', hostname: 'does-not-resolve.invalid', port: 3001 });
    expect(res.status).toBe(400);
    expect(['DNS_RESOLUTION_FAILED', 'DNS_NO_RECORDS']).toContain(res.body.code);
  });
});

describe('DC-068: Fleet GET /status — probes use resolved IP, not hostname', () => {
  let dnsBackup;
  let filePath;

  beforeEach(() => {
    filePath = `/tmp/fleet-ssrf-status-${Date.now()}-${Math.random().toString(36).slice(2)}.json`;
    process.env.FLEET_HOSTS_FILE = filePath;
    dnsBackup = require('dns').promises.lookup;
  });
  afterEach(() => {
    require('dns').promises.lookup = dnsBackup;
    delete process.env.FLEET_HOSTS_FILE;
    try { require('fs').unlinkSync(filePath); } catch {}
  });

  it('reports validation_failed for a stored host whose hostname resolves to a private IP', async () => {
    // Step 1: register a host with a public DNS name. Mock lookup so
    // registration succeeds.
    require('dns').promises.lookup = async () => [{ address: '93.184.216.34', family: 4 }];
    let app = createFleetApp();
    let res = await request(app)
      .post('/api/v1/fleet/hosts')
      .send({ name: 'Was Good', hostname: 'fleet.example.com', port: 3001 });
    expect(res.status).toBe(201);

    // Step 2: flip the DNS to a private IP (simulating DNS rebinding).
    // Now GET /status should re-validate, detect the rebind, and tag the
    // host validation_failed instead of probing the internal address.
    require('dns').promises.lookup = async () => [{ address: '127.0.0.1', family: 4 }];
    app = createFleetApp();
    res = await request(app).get('/api/v1/fleet/status');
    expect(res.status).toBe(200);
    const host = res.body.hosts[0];
    expect(host.status).toBe('validation_failed');
    expect(host.validationError).toBeTruthy();
    expect(res.body.summary.validation_failed).toBe(1);
    expect(res.body.summary.offline).toBe(0);
  });

  it('probes using stored resolvedIp, not raw hostname', async () => {
    // This is the route-level safety net: even if the stored resolvedIp
    // somehow no longer resolves correctly, /fleet/status must probe the
    // captured IP. We assert by checking the host.lastSeen / probe data is
    // driven by the resolved IP endpoint — but since we can't easily mock
    // fetch in this test, we verify the structural invariant: hosts with a
    // valid stored resolvedIp pass validation when DNS lookup ALSO returns
    // a public IP at probe time.
    require('dns').promises.lookup = async () => [{ address: '93.184.216.34', family: 4 }];
    let app = createFleetApp();
    await request(app)
      .post('/api/v1/fleet/hosts')
      .send({ name: 'Test', hostname: 'fleet.example.com', port: 3001 });
    app = createFleetApp();
    const res = await request(app).get('/api/v1/fleet/status');
    expect(res.status).toBe(200);
    // Status will be offline because the probed host (93.184.216.34:3001)
    // doesn't actually serve our health endpoint in the test environment —
    // but it should NOT be validation_failed.
    const host = res.body.hosts[0];
    expect(host.status).not.toBe('validation_failed');
    // The validation_failed counter should remain 0.
    expect(res.body.summary.validation_failed).toBe(0);
  });
});

describe('DC-068: Fleet POST /deploy — deployUrl uses resolvedIp', () => {
  let dnsBackup;
  let filePath;

  beforeEach(() => {
    filePath = `/tmp/fleet-ssrf-deploy-${Date.now()}-${Math.random().toString(36).slice(2)}.json`;
    process.env.FLEET_HOSTS_FILE = filePath;
    dnsBackup = require('dns').promises.lookup;
  });
  afterEach(() => {
    require('dns').promises.lookup = dnsBackup;
    delete process.env.FLEET_HOSTS_FILE;
    try { require('fs').unlinkSync(filePath); } catch {}
  });

  it('emits deployUrl from the resolved IP, not the raw hostname', async () => {
    require('dns').promises.lookup = async () => [{ address: '93.184.216.34', family: 4 }];
    let app = createFleetApp();
    await request(app)
      .post('/api/v1/fleet/hosts')
      .send({ name: 'Test', hostname: 'fleet.example.com', port: 3001 });
    app = createFleetApp();
    const res = await request(app)
      .post('/api/v1/fleet/deploy')
      .send({ templateId: 'plex' });
    expect(res.status).toBe(200);
    expect(res.body.plan).toHaveLength(1);
    // The deployUrl was built from the resolved IP, not the user-supplied
    // hostname — defending against a DNS rebinding pivot at deploy time.
    expect(res.body.plan[0].deployUrl).toBe('http://93.184.216.34:3001/api/v1/apps/deploy');
    // The user-visible hostname is preserved on the plan entry.
    expect(res.body.plan[0].hostname).toBe('fleet.example.com');
  });

  it('emits deployUrl from the literal IP for IPv4-literal hosts', async () => {
    const app = createFleetApp();
    await request(app)
      .post('/api/v1/fleet/hosts')
      .send({ name: 'Literal', hostname: '8.8.8.8', port: 3001 });
    const res = await request(app)
      .post('/api/v1/fleet/deploy')
      .send({ templateId: 'plex' });
    expect(res.status).toBe(200);
    expect(res.body.plan[0].deployUrl).toBe('http://8.8.8.8:3001/api/v1/apps/deploy');
  });

  it('wraps IPv6 resolved IPs in [brackets] so the URL parses correctly', async () => {
    require('dns').promises.lookup = async () => [{ address: '2001:4860:4860::8888', family: 6 }];
    let app = createFleetApp();
    await request(app)
      .post('/api/v1/fleet/hosts')
      .send({ name: 'v6 DNS', hostname: 'dns.example.com', port: 3001 });
    app = createFleetApp();
    const res = await request(app)
      .post('/api/v1/fleet/deploy')
      .send({ templateId: 'plex' });
    expect(res.status).toBe(200);
    expect(res.body.plan[0].deployUrl).toBe('http://[2001:4860:4860::8888]:3001/api/v1/apps/deploy');
  });

  it('wraps IPv6 literal hosts in [brackets]', async () => {
    const app = createFleetApp();
    await request(app)
      .post('/api/v1/fleet/hosts')
      .send({ name: 'v6', hostname: '2001:4860:4860::8888', port: 3001 });
    const res = await request(app)
      .post('/api/v1/fleet/deploy')
      .send({ templateId: 'plex' });
    expect(res.status).toBe(200);
    expect(res.body.plan[0].deployUrl).toBe('http://[2001:4860:4860::8888]:3001/api/v1/apps/deploy');
  });
});
