// kb#179 HTTP-layer proof: exercises the REAL server.js /mcp handler, not a
// reimplementation of it. Run with: node src/trust-gate-http.test.mjs
//
// kb#147's original version of this file re-implemented the gate inline
// (its own express app + its own copy of the "if vault tool and not
// allowed" check). That proved trust-gate.js's exported functions compose
// correctly, but never proved the shipped server.js actually wires
// requireVaultAccess() into every vw_* tool -- a vw_* tool registered
// without the gate would still pass that test.
//
// This version instead:
//   1. Sets AGAP_MCP_ENFORCE_VAULT_TRUST=1 and a synthetic
//      AGAP_MCP_AGENT_TOKENS map BEFORE importing server.js (both are read
//      once at module-load time), then dynamically imports server.js so it
//      picks up ENFORCE=1 with a harness-only token map -- never the live
//      container's tokens.
//   2. Overrides trust-gate's registry cache with a synthetic registry (no
//      real agent-registry.yaml read) via _resetRegistryCacheForTests --
//      the exact test hook trust-gate.js already exports for this purpose.
//   3. Boots server.js's real `app` (its actual app.all('/mcp', ...)
//      handler, its real createServer()/requireVaultAccess()) on an
//      ephemeral local port, and drives it over real HTTP using the MCP
//      SDK's own Client + StreamableHTTPClientTransport -- real
//      initialize + tools/call JSON-RPC round trips, not raw fetch().
//   4. Does NOT call init() (never touches Vaultwarden/Gitea/HA/Zabbix) and
//      never touches the live :3100 container -- server.js's init()+
//      app.listen() side effects are guarded behind an isMainModule check
//      specifically so this file can import the module safely (kb#179).
//
// Because init() never runs, the underlying vw* functions (vaultwarden.js)
// are never given a bw session. For a TRUSTED caller the gate must let the
// call through to that downstream code -- which then fails for its own
// unrelated reason (no bw session) -- so "allowed" is asserted as "did NOT
// fail with the gate's specific denial message", not "the vault call
// succeeded". That's deliberate: it proves requireVaultAccess() did not
// block the call, without shelling out to a real `bw` session anywhere in
// this test.

import assert from 'node:assert/strict';

process.env.AGAP_MCP_ENFORCE_VAULT_TRUST = '1';
process.env.AGAP_MCP_AGENT_TOKENS = JSON.stringify({
  'tok-adolf-e2e-test': 'adolf',
  'tok-torgash-e2e-test': 'torgash',
});

const registry = {
  trust_classes: {
    trusted: { rank: 2 },
    sandboxed: { rank: 1 },
    untrusted: { rank: 0 },
  },
  agents: [
    { id: 'adolf', trust_class: 'trusted' },
    { id: 'torgash', trust_class: 'sandboxed' },
  ],
};

const { _resetRegistryCacheForTests } = await import('./trust-gate.js');
_resetRegistryCacheForTests(registry);

const { app, createServer, requireVaultAccess, ENFORCE_VAULT_TRUST, AGENT_TOKENS, sseTransports } = await import('./server.js');
const {
  listenerAuth,
  assertListenerAuthConfig,
  ListenerAuthConfigError,
  requireAuthEnabled,
  bindSseSession,
  authorizeSseSession,
} = await import('./listener-auth.js');

assert.equal(ENFORCE_VAULT_TRUST, true, 'sanity: server.js must have picked up AGAP_MCP_ENFORCE_VAULT_TRUST=1 at import time');
assert.equal(Object.keys(AGENT_TOKENS).length, 2, 'sanity: server.js must have picked up the synthetic AGAP_MCP_AGENT_TOKENS');

const VW_TOOLS = ['vw_get_password', 'vw_get_item', 'vw_list_items', 'vw_create_login', 'vw_update_password'];
const DENIED_RE = /vault access denied/;

// Bind explicitly to 127.0.0.1, with retries: `app.listen(0)` binds the IPv6
// wildcard `::` on this host and intermittently fails EADDRINUSE under
// ephemeral-port pressure, which made this harness flaky (~2 runs in 3)
// regardless of what it asserts. Loopback-only is also the right posture for
// a test that deliberately probes an unauthenticated endpoint.
async function listenOnFreeLoopbackPort(attempts = 10) {
  for (let i = 0; i < attempts; i++) {
    const s = app.listen(0, '127.0.0.1');
    const outcome = await new Promise(resolve => {
      s.once('listening', () => resolve('ok'));
      s.once('error', e => resolve(e));
    });
    if (outcome === 'ok') return s;
    if (outcome.code !== 'EADDRINUSE') throw outcome;
  }
  throw new Error('could not bind a free loopback port after multiple attempts');
}

const server = await listenOnFreeLoopbackPort();
const port = server.address().port;
const baseUrl = `http://127.0.0.1:${port}/mcp`;

const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
const { StreamableHTTPClientTransport } = await import('@modelcontextprotocol/sdk/client/streamableHttp.js');

// Minimal argument stubs matching each tool's zod schema -- the gate must
// fire before any of these are used for real (requireVaultAccess is the
// first statement in every vw_* handler in server.js), so their exact
// values don't matter for the deny path.
const ARGS = {
  vw_get_password: { name: 'probe' },
  vw_get_item: { name: 'probe' },
  vw_list_items: {},
  vw_create_login: { name: 'probe', password: 'x' },
  vw_update_password: { name: 'probe', password: 'x' },
};

// callToolAsCaller: connects a fresh MCP client to the real /mcp route with
// the given bearer token (or none), calls `tool`, and returns the tool
// result. `undefined`/omitted mimics resolveCallerAgent's "no header at
// all" path; a token string that isn't in AGENT_TOKENS mimics "unknown
// token".
//
// kb#180 note: since the listener itself now requires auth, an unauthenticated
// or unknown-token client is rejected at the HTTP layer (401) during
// connect() — before any tool handler runs. That is a STRICTER outcome than
// the tool-level "vault access denied" this file originally asserted for
// those two cases, so those checks now assert the 401 instead. The
// authenticated-but-sandboxed (torgash) case is what still proves the kb#147
// vault gate itself, and the trusted (adolf) case still proves the gate lets
// a trusted caller through.
const AUTH_REJECTED = Symbol('listener-auth-rejected');

async function callToolAsCaller(token, tool) {
  const requestInit = token ? { headers: { Authorization: `Bearer ${token}` } } : {};
  const transport = new StreamableHTTPClientTransport(new URL(baseUrl), { requestInit });
  const client = new Client({ name: 'kb179-test-client', version: '1.0.0' });
  try {
    await client.connect(transport);
  } catch (e) {
    if (/401|unauthenticated/i.test(e.message)) return AUTH_REJECTED;
    throw e;
  }
  try {
    return await client.callTool({ name: tool, arguments: ARGS[tool] });
  } catch (e) {
    if (/401|unauthenticated/i.test(e.message)) return AUTH_REJECTED;
    throw e;
  } finally {
    await client.close().catch(() => {});
  }
}

function toolErrorText(result) {
  const block = result?.content?.find(c => c.type === 'text');
  return block?.text || '';
}

let passed = 0;
async function check(label, fn) {
  await fn();
  passed++;
  console.log(`ok - ${label}`);
}

try {
  for (const tool of VW_TOOLS) {
    await check(`${tool}: no Authorization header -> DENIED over real HTTP (kb#180: 401 at the listener)`, async () => {
      const result = await callToolAsCaller(null, tool);
      assert.equal(result, AUTH_REJECTED, 'expected the listener to reject an unauthenticated MCP client');
    });

    await check(`${tool}: unknown/never-issued token -> DENIED over real HTTP (kb#180: 401 at the listener)`, async () => {
      const result = await callToolAsCaller('this-token-was-never-issued', tool);
      assert.equal(result, AUTH_REJECTED, 'expected the listener to reject an unknown bearer token');
    });

    await check(`${tool}: sandboxed agent (torgash) token -> DENIED over real HTTP`, async () => {
      const result = await callToolAsCaller('tok-torgash-e2e-test', tool);
      assert.equal(result.isError, true);
      assert.match(toolErrorText(result), DENIED_RE);
    });

    await check(`${tool}: trusted agent (adolf) token -> gate ALLOWS (not blocked by requireVaultAccess) over real HTTP`, async () => {
      const result = await callToolAsCaller('tok-adolf-e2e-test', tool);
      // The gate must not be what blocks this call. Downstream vaultwarden.js
      // has no bw session here (init() deliberately never ran), so the call
      // may still fail -- just not with the gate's denial message.
      assert.doesNotMatch(toolErrorText(result), DENIED_RE);
    });
  }

  // --- Guard test (kb#179 acceptance bar #2) ---------------------------
  // Fails if any vw_* tool is ever registered on createServer() without
  // calling requireVaultAccess(). Rather than re-deriving this from source
  // text (fragile to refactors), it drives the real registered handler for
  // every tool name starting with "vw_" as an untrusted/denied caller and
  // asserts the gate's specific denial message comes back. A vw_* tool
  // that forgot to call requireVaultAccess would either succeed or throw a
  // different (non-gate) error here, and this test would catch it.
  await check('guard: every registered vw_* tool enforces requireVaultAccess()', async () => {
    // A caller id that resolves to no agent in the (synthetic) registry --
    // trustRankOf() returns -1 for it, so vaultAllowed() must be false and
    // requireVaultAccess() must throw for every vw_* tool.
    const probeServer = createServer('this-agent-id-does-not-exist-in-registry');
    // McpServer keeps its registrations on `._registeredTools` (name ->
    // { handler, ... }); read the real registry createServer() just
    // populated rather than re-deriving tool names by hand, so a future
    // vw_* tool is covered automatically.
    const registeredTools = probeServer._registeredTools;
    assert.ok(registeredTools && Object.keys(registeredTools).length > 0,
      'expected createServer() to have registered tools onto the McpServer instance');

    const vwToolNames = Object.keys(registeredTools).filter(n => n.startsWith('vw_'));
    assert.ok(vwToolNames.length >= VW_TOOLS.length, `expected at least ${VW_TOOLS.length} vw_* tools registered, found: ${vwToolNames.join(', ')}`);

    for (const name of vwToolNames) {
      const handler = registeredTools[name].handler;
      assert.ok(typeof handler === 'function', `could not locate callable handler for ${name}`);
      const result = await handler(ARGS[name] || {}, {});
      assert.equal(result.isError, true, `${name}: expected denial for an unregistered agent id, got success -- is requireVaultAccess() missing?`);
      assert.match(toolErrorText(result), DENIED_RE, `${name}: expected the gate's denial message, got: ${toolErrorText(result)} -- is requireVaultAccess() missing or not the first check?`);
    }
  });

  // --- kb#180: the LISTENER is authenticated, not just vault tools --------
  const base = `http://127.0.0.1:${port}`;
  const authed = { Authorization: 'Bearer tok-torgash-e2e-test' }; // sandboxed but *known*

  await check('kb#180: unauthenticated POST /mcp -> 401 (raw curl-equivalent)', async () => {
    const res = await fetch(`${base}/mcp`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' },
      body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }),
    });
    assert.equal(res.status, 401);
    assert.equal(res.headers.get('www-authenticate'), 'Bearer realm="agap-mcp"');
    const body = await res.json();
    assert.equal(body.jsonrpc, '2.0', 'MCP routes must answer with a JSON-RPC error envelope');
    assert.match(body.error.message, /unauthenticated/);
  });

  await check('kb#180: unauthenticated POST /capture-idea -> 401 (no Todoist write)', async () => {
    const res = await fetch(`${base}/capture-idea`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ text: 'unauthenticated probe — must never reach Todoist' }),
    });
    assert.equal(res.status, 401);
    const body = await res.json();
    assert.match(body.error, /unauthenticated/);
  });

  await check('kb#180: unknown token on /capture-idea -> 401', async () => {
    const res = await fetch(`${base}/capture-idea`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Authorization: 'Bearer nope' },
      body: JSON.stringify({ text: 'probe' }),
    });
    assert.equal(res.status, 401);
  });

  await check('kb#180: unauthenticated GET /sse -> 401', async () => {
    const res = await fetch(`${base}/sse`, { headers: { Accept: 'text/event-stream' } });
    assert.equal(res.status, 401);
    await res.arrayBuffer();
  });

  await check('kb#180: unauthenticated POST /messages -> 401 (was: any sessionId accepted)', async () => {
    const res = await fetch(`${base}/messages?sessionId=whatever`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }),
    });
    assert.equal(res.status, 401);
  });

  await check('kb#180: /health stays open and reports listenerAuthEnabled=true', async () => {
    const res = await fetch(`${base}/health`);
    assert.equal(res.status, 200);
    const body = await res.json();
    assert.equal(body.listenerAuthEnabled, true);
  });

  await check('kb#180: an authenticated (known-token) caller still reaches the tool surface', async () => {
    const res = await fetch(`${base}/mcp`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', ...authed },
      body: JSON.stringify({
        jsonrpc: '2.0', id: 1, method: 'initialize',
        params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 't', version: '1' } },
      }),
    });
    assert.notEqual(res.status, 401, 'a known token must not be rejected by the listener gate');
    assert.ok(res.status < 500, `expected the request to be served, got ${res.status}`);
    await res.arrayBuffer();
  });

  // --- kb#180: /messages must match its /sse handshake identity ----------
  await check('kb#180: /messages rejects a session opened by a DIFFERENT agent (hijack)', () => {
    const sessions = new Map();
    bindSseSession(sessions, 'sess-1', { id: 'transport-a' },
      { callerAgentId: 'adolf', callerToken: 'tok-adolf-e2e-test' });

    const sameCaller = authorizeSseSession(sessions, 'sess-1',
      { callerAgentId: 'adolf', callerToken: 'tok-adolf-e2e-test' });
    assert.equal(sameCaller.ok, true, 'the agent that opened the session must keep using it');

    const otherAgent = authorizeSseSession(sessions, 'sess-1',
      { callerAgentId: 'torgash', callerToken: 'tok-torgash-e2e-test' });
    assert.equal(otherAgent.ok, false);
    assert.equal(otherAgent.status, 403);
    assert.equal(otherAgent.reason, 'session-caller-mismatch');

    const noCreds = authorizeSseSession(sessions, 'sess-1', { callerAgentId: null, callerToken: null });
    assert.equal(noCreds.ok, false);
    assert.equal(noCreds.status, 401);

    const unknownSession = authorizeSseSession(sessions, 'nope',
      { callerAgentId: 'adolf', callerToken: 'tok-adolf-e2e-test' });
    assert.equal(unknownSession.ok, false);
    assert.equal(unknownSession.status, 400);
  });

  await check('kb#180: the live server binds real /sse sessions to a caller identity', () => {
    // sseTransports is the exact map the /sse and /messages routes use; its
    // entries must be {transport, agentId, token}, not a bare transport.
    assert.ok(sseTransports instanceof Map);
    bindSseSession(sseTransports, 'probe-session', { probe: true },
      { callerAgentId: 'adolf', callerToken: 'tok-adolf-e2e-test' });
    const entry = sseTransports.get('probe-session');
    assert.equal(entry.agentId, 'adolf');
    assert.equal(entry.token, 'tok-adolf-e2e-test');
    sseTransports.delete('probe-session');
  });

  // --- kb#180: boot-time config guard ------------------------------------
  await check('kb#180: auth on + empty token map = refuse to boot (not silent all-deny)', () => {
    assert.throws(() => assertListenerAuthConfig({}, { }), ListenerAuthConfigError);
    assert.throws(() => assertListenerAuthConfig(Object.create(null), { AGAP_MCP_REQUIRE_AUTH: '1' }), ListenerAuthConfigError);
    // Explicit opt-out is allowed (warns, does not throw) — the rollback path.
    assert.doesNotThrow(() => assertListenerAuthConfig({}, { AGAP_MCP_REQUIRE_AUTH: '0' }));
    // Configured normally: fine.
    assert.doesNotThrow(() => assertListenerAuthConfig({ t: 'adolf' }, {}));
  });

  await check('kb#180: AGAP_MCP_REQUIRE_AUTH=0 is the only way to get the old open listener', () => {
    assert.equal(requireAuthEnabled({}), true, 'auth must default ON');
    assert.equal(requireAuthEnabled({ AGAP_MCP_REQUIRE_AUTH: '1' }), true);
    assert.equal(requireAuthEnabled({ AGAP_MCP_REQUIRE_AUTH: '0' }), false);
    // ...and with it off, an unauthenticated request passes through the
    // middleware while still resolving an agent id when a token IS present.
    const mw = listenerAuth({ 'tok-adolf-e2e-test': 'adolf' }, { AGAP_MCP_REQUIRE_AUTH: '0' });
    let nexted = false;
    mw({ headers: {}, path: '/mcp' }, null, () => { nexted = true; });
    assert.equal(nexted, true);
    const req = { headers: { authorization: 'Bearer tok-adolf-e2e-test' }, path: '/mcp' };
    mw(req, null, () => {});
    assert.equal(req.callerAgentId, 'adolf', 'vault gate must still see the caller id when listener auth is off');
  });

  console.log(`\n${passed} passed`);
} finally {
  server.close();
  _resetRegistryCacheForTests(null);
}
