/**
 * Tests for cross-node RBAC preservation: the forwarding primary asserts the
 * signed-in user's role on PROXY_ROLE_HEADER, and the remote's authMiddleware
 * honors it for node_proxy / pilot_tunnel bearers instead of granting every
 * proxied request blanket admin.
 *
 * The probe route is `GET /api/users` (admin-only, no Docker dependency). A
 * forwarded non-admin role must be denied there; an absent header (a direct
 * instance-to-instance / background service call) must keep admin.
 */
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import http from 'http';
import bcrypt from 'bcrypt';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import {
  setupTestDb,
  cleanupTestDb,
  loginAsTestAdmin,
  TEST_JWT_SECRET,
} from './helpers/setupTestDb';
import { PROXY_ROLE_HEADER, PROXY_DEPLOY_SOURCE_HEADER, PROXY_DEPLOY_ACTOR_HEADER } from '../services/license-headers';

let tmpDir: string;
let app: import('express').Express;
let viewerBearer: string;
let nodeAdminBearer: string;
let auditorBearer: string;
let deployerBearer: string;

const VIEWER_USER = 'proxy-role-viewer';
const NODE_ADMIN_USER = 'proxy-role-node-admin';
const AUDITOR_USER = 'proxy-role-auditor';
const DEPLOYER_USER = 'proxy-role-deployer';

beforeAll(async () => {
  tmpDir = await setupTestDb();
  ({ app } = await import('../index'));
  const { DatabaseService } = await import('../services/DatabaseService');
  const db = DatabaseService.getInstance();

  // Seed bearer tokens for each non-admin role, matching the original
  // viewer pattern exactly to avoid drift.
  const seed = (username: string, role: string): string => {
    const hash = bcrypt.hashSync('password123', 1);
    db.addUser({ username, password_hash: hash, role: role as any });
    const user = db.getUserByUsername(username)!;
    return jwt.sign(
      { username, role, tv: user.token_version },
      TEST_JWT_SECRET,
      { expiresIn: '1m' },
    );
  };

  viewerBearer = seed(VIEWER_USER, 'viewer');
  deployerBearer = seed(DEPLOYER_USER, 'deployer');
  nodeAdminBearer = seed(NODE_ADMIN_USER, 'node-admin');
  auditorBearer = seed(AUDITOR_USER, 'auditor');
});

afterAll(() => {
  cleanupTestDb(tmpDir);
});

const signToken = (payload: Record<string, unknown>, expiresIn: string | number = '1m') =>
  jwt.sign(payload, TEST_JWT_SECRET, { expiresIn: expiresIn as jwt.SignOptions['expiresIn'] });

const ADMIN_ONLY = '/api/users';
// Docker-free, gated by requirePermission('stack:read'); every role carries it,
// so a 200 here proves a forwarded non-admin retained its legitimate access.
const READ_ONLY = '/api/stacks';

describe('authMiddleware - forwarded actor role (node_proxy)', () => {
  it('keeps admin when no role header is present (direct service call)', async () => {
    const token = signToken({ scope: 'node_proxy' });
    const res = await request(app).get(ADMIN_ONLY).set('Authorization', `Bearer ${token}`);
    expect(res.status).toBe(200);
  });

  it('keeps admin when the forwarded role is admin', async () => {
    const token = signToken({ scope: 'node_proxy' });
    const res = await request(app)
      .get(ADMIN_ONLY)
      .set('Authorization', `Bearer ${token}`)
      .set(PROXY_ROLE_HEADER, 'admin');
    expect(res.status).toBe(200);
  });

  it('denies an admin-only route when the forwarded role is viewer', async () => {
    const token = signToken({ scope: 'node_proxy' });
    const res = await request(app)
      .get(ADMIN_ONLY)
      .set('Authorization', `Bearer ${token}`)
      .set(PROXY_ROLE_HEADER, 'viewer');
    expect(res.status).toBe(403);
  });

  it('denies an admin-only route when the forwarded role is deployer', async () => {
    const token = signToken({ scope: 'node_proxy' });
    const res = await request(app)
      .get(ADMIN_ONLY)
      .set('Authorization', `Bearer ${token}`)
      .set(PROXY_ROLE_HEADER, 'deployer');
    expect(res.status).toBe(403);
  });

  it('fails closed to read-only for an unrecognized forwarded role', async () => {
    const token = signToken({ scope: 'node_proxy' });
    const res = await request(app)
      .get(ADMIN_ONLY)
      .set('Authorization', `Bearer ${token}`)
      .set(PROXY_ROLE_HEADER, 'superadmin');
    expect(res.status).toBe(403);
  });

  it('applies the same trust to pilot_tunnel bearers', async () => {
    const token = signToken({ scope: 'pilot_tunnel' });
    const res = await request(app)
      .get(ADMIN_ONLY)
      .set('Authorization', `Bearer ${token}`)
      .set(PROXY_ROLE_HEADER, 'viewer');
    expect(res.status).toBe(403);
  });

  it('still grants a forwarded viewer its legitimate read access', async () => {
    // The fail-closed branch must not over-restrict: a forwarded viewer keeps
    // stack:read, so this is a 200 rather than a blanket denial.
    const token = signToken({ scope: 'node_proxy' });
    const res = await request(app)
      .get(READ_ONLY)
      .set('Authorization', `Bearer ${token}`)
      .set(PROXY_ROLE_HEADER, 'viewer');
    expect(res.status).toBe(200);
  });

  it('treats an unrecognized forwarded role as read-only, not zero access', async () => {
    // "Fails closed to read-only" means viewer-equivalent: denied on admin
    // routes (asserted above) but still allowed on stack:read reads.
    const token = signToken({ scope: 'node_proxy' });
    const res = await request(app)
      .get(READ_ONLY)
      .set('Authorization', `Bearer ${token}`)
      .set(PROXY_ROLE_HEADER, 'superadmin');
    expect(res.status).toBe(200);
  });

  // All five built-in roles: denied on admin-only, allowed on read-only.
  const NON_ADMIN_ROLES = [
    { role: 'viewer', desc: 'viewer' },
    { role: 'deployer', desc: 'deployer' },
    { role: 'node-admin', desc: 'node-admin' },
    { role: 'auditor', desc: 'auditor' },
  ];

  for (const { role, desc } of NON_ADMIN_ROLES) {
    it(`denies admin-only route for forwarded ${desc}`, async () => {
      const token = signToken({ scope: 'node_proxy' });
      const res = await request(app)
        .get(ADMIN_ONLY)
        .set('Authorization', `Bearer ${token}`)
        .set(PROXY_ROLE_HEADER, role);
      expect(res.status).toBe(403);
    });

    it(`grants read access for forwarded ${desc}`, async () => {
      const token = signToken({ scope: 'node_proxy' });
      const res = await request(app)
        .get(READ_ONLY)
        .set('Authorization', `Bearer ${token}`)
        .set(PROXY_ROLE_HEADER, role);
      expect(res.status).toBe(200);
    });

    it(`applies same trust to pilot_tunnel for forwarded ${desc}`, async () => {
      const token = signToken({ scope: 'pilot_tunnel' });
      const res = await request(app)
        .get(ADMIN_ONLY)
        .set('Authorization', `Bearer ${token}`)
        .set(PROXY_ROLE_HEADER, role);
      expect(res.status).toBe(403);
    });
  }
});

describe('Security - actor-role header cannot be smuggled by a user session', () => {
  // Verify each non-admin bearer token authenticates locally before testing
  // proxy paths. Catches token-version drift between seeding and signing.
  // Bearer variables are set in beforeAll; resolve lazily inside each test.
  const BEARER_REFS: Array<{ name: string; get: () => string }> = [
    { name: 'viewer', get: () => viewerBearer },
    { name: 'deployer', get: () => deployerBearer },
    { name: 'node-admin', get: () => nodeAdminBearer },
    { name: 'auditor', get: () => auditorBearer },
  ];

  for (const { name, get } of BEARER_REFS) {
    it(`${name} token authenticates on /api/auth/status`, async () => {
      const res = await request(app)
        .get('/api/auth/status')
        .set('Authorization', `Bearer ${get()}`);
      expect(res.status).toBe(200);
    });
  }

  it('ignores the role header on a cookie session (uses the DB role)', async () => {
    const cookie = await loginAsTestAdmin(app);
    // A browser session that tries to downgrade itself (or, by the same path,
    // elevate) via the header must be unaffected: the header is honored only on
    // node_proxy / pilot_tunnel bearers, and the gateway overwrites it anyway.
    const res = await request(app)
      .get(ADMIN_ONLY)
      .set('Cookie', cookie)
      .set(PROXY_ROLE_HEADER, 'viewer');
    expect(res.status).toBe(200);
  });

  it('rejects an unauthenticated request even with a role header set', async () => {
    const res = await request(app).get(ADMIN_ONLY).set(PROXY_ROLE_HEADER, 'admin');
    expect(res.status).toBe(401);
  });

  it('does not let a non-admin session elevate by setting the role header', async () => {
    // The attack the header could enable: a viewer setting x-sencho-actor-role:
    // admin on their own session. The user-session branch never reads the
    // header (only node_proxy/pilot_tunnel does), so the DB role wins -> 403.
    const res = await request(app)
      .get(ADMIN_ONLY)
      .set('Authorization', `Bearer ${viewerBearer}`)
      .set(PROXY_ROLE_HEADER, 'admin');
    expect(res.status).toBe(403);
  });
});

// The gateway half of the fix: the forwarding primary must overwrite the actor
// role header from the authenticated session before forwarding, so a client
// cannot smuggle an elevated role through to the remote. Exercised end-to-end
// by routing a real proxied request to a loopback capture server and asserting
// the header it received.
describe('remote proxy gateway - actor role header forwarding', () => {
  let captured: http.IncomingHttpHeaders | null = null;
  let captureServer: http.Server;
  let remoteNodeId: number;

  beforeAll(async () => {
    captureServer = http.createServer((req, res) => {
      // The proxy gate probes /api/meta first to confirm the remote enforces
      // cross-node RBAC before forwarding a non-admin request; advertise it so
      // the viewer forward is allowed and we can assert the forwarded headers.
      if (req.url?.startsWith('/api/meta')) {
        res.writeHead(200, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({ version: '0.93.0', capabilities: ['cross-node-rbac'] }));
        return;
      }
      captured = req.headers;
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end('[]');
    });
    await new Promise<void>((resolve) => captureServer.listen(0, '127.0.0.1', resolve));
    const port = (captureServer.address() as import('net').AddressInfo).port;

    const { DatabaseService } = await import('../services/DatabaseService');
    remoteNodeId = DatabaseService.getInstance().addNode({
      name: 'capture-remote',
      type: 'remote',
      mode: 'proxy',
      compose_dir: '/tmp',
      is_default: false,
      api_url: `http://127.0.0.1:${port}`,
      api_token: 'capture-node-token',
    });
  });

  afterAll(async () => {
    await new Promise<void>((resolve) => captureServer.close(() => resolve()));
  });

  it('overwrites a smuggled actor-role header with the session role', async () => {
    captured = null;
    const res = await request(app)
      .get(READ_ONLY)
      .set('Authorization', `Bearer ${viewerBearer}`)
      .set('x-node-id', String(remoteNodeId))
      .set(PROXY_ROLE_HEADER, 'admin'); // smuggled
    expect(res.status).toBe(200);
    expect(captured?.[PROXY_ROLE_HEADER]).toBe('viewer');
  });

  it('forwards the session role when no header is supplied', async () => {
    captured = null;
    const res = await request(app)
      .get(READ_ONLY)
      .set('Authorization', `Bearer ${viewerBearer}`)
      .set('x-node-id', String(remoteNodeId));
    expect(res.status).toBe(200);
    expect(captured?.[PROXY_ROLE_HEADER]).toBe('viewer');
  });

  it('strips smuggled deploy provenance and overwrites interactive manual + username', async () => {
    captured = null;
    const res = await request(app)
      .get(READ_ONLY)
      .set('Authorization', `Bearer ${viewerBearer}`)
      .set('x-node-id', String(remoteNodeId))
      .set(PROXY_DEPLOY_SOURCE_HEADER, 'scheduler')
      .set(PROXY_DEPLOY_ACTOR_HEADER, 'system:scheduler');
    expect(res.status).toBe(200);
    expect(captured?.[PROXY_DEPLOY_SOURCE_HEADER]).toBe('manual');
    expect(captured?.[PROXY_DEPLOY_ACTOR_HEADER]).toBe(VIEWER_USER);
  });

  // Anti-smuggling extended to the remaining non-admin roles: each persona's
  // real role must be forwarded even when an attacker sets the admin header.
  // The bearer variables are set in beforeAll; resolve them lazily inside each
  // test so we never capture undefined at describe-registration time.
  const SMUGGLE_PERSONAS: Array<{ name: string; bearerRef: () => string; role: string }> = [
    { name: 'deployer', bearerRef: () => deployerBearer, role: 'deployer' },
    { name: 'node-admin', bearerRef: () => nodeAdminBearer, role: 'node-admin' },
    { name: 'auditor', bearerRef: () => auditorBearer, role: 'auditor' },
  ];

  for (const { name, bearerRef, role } of SMUGGLE_PERSONAS) {
    it(`overwrites smuggled admin header with ${name} session role`, async () => {
      captured = null;
      const res = await request(app)
        .get(READ_ONLY)
        .set('Authorization', `Bearer ${bearerRef()}`)
        .set('x-node-id', String(remoteNodeId))
        .set(PROXY_ROLE_HEADER, 'admin');
      expect(res.status).toBe(200);
      expect(captured?.[PROXY_ROLE_HEADER]).toBe(role);
    });
  }
});

