/**
 * Entra ID SSO Integration Tests（v2.4，issue #334）
 *
 * 覆盖 docs/modules/organization/09-test-scenarios.md §5.3 的 20 个用例。
 *
 * 测试策略：
 * - 不 mock HTTP（backend 无 nock / msw / undici 模块）
 * - mock `SsoOidcClientService`（OIDC client 封装层）—— 它就是 HTTP 边界
 * - 业务流程 / DB 写入 / audit 落库 全部走真实路径
 * - 启动期 fail-fast 测试单独实例化 `SsoConfigService` 验证 process.exit(1)
 *
 * 测试数据：
 * - email 前缀 `t_` + 时间戳 + 随机后缀
 * - cleanup 用 cleanupByPrefix（自动按 `t_` 扫已知 schema）
 *
 * 关联文档：
 * - 09-test-scenarios.md §5.3.1 ~ §5.3.20
 * - 07-api.md §「认证接口」第 6-7 端点
 * - 08-error-codes.md §「认证 / SSO 错误码」
 * - PRD §817-820 audit metadata 字段
 *
 * 用例计数说明：25 个 `it(...)` 块（含若干 `it.each(...)` 行）展开后总共
 * 跑 33 个测试 case。覆盖 09-test-scenarios.md §5.3.1 ~ §5.3.20（其中
 * 5.3.17 INACTIVE/SUSPENDED/TERMINATED 三状态用 it.each 展开 3 条、
 * 5.3.18 Entra error query 三映射用 it.each 展开 3 条、5.3.20 启动期
 * fail-fast 7 子场景用单独 it 列出）。`describe.each` / `it.each` 是
 * jest 标准做法，PR 描述里"33 用例"按 case 数计，比按 `it(` 函数数更
 * 直观反映覆盖度。
 */

// 必须最先执行：openid-client 是纯 ESM（"type": "module" + import * as oauth from 'oauth4webapi'），
// Jest 默认 transformIgnorePatterns 不转 node_modules → 加载真模块会报 SyntaxError。
// 由于 SsoOidcClientService 在测试里被 overrideProvider 替换，真模块只是被 require 一次，
// 这里用 jest.mock() 把它拦下来，给一个最小桩，让 module 解析顺利通过。
jest.mock('openid-client', () => ({
  __esModule: true,
  discovery: jest.fn(),
  buildAuthorizationUrl: jest.fn(),
  authorizationCodeGrant: jest.fn(),
  calculatePKCECodeChallenge: jest.fn(),
  ClientSecretPost: jest.fn(),
  Configuration: class {},
}));

import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { ValidationPipe } from '@nestjs/common';
import request from 'supertest';
import * as cookieParser from 'cookie-parser';
import * as bcrypt from 'bcrypt';
import { PrismaClient } from '@prisma/client';

import { AppModule } from '@/app.module';
import { PrismaService } from '@/core/database/prisma/prisma.service';
import { TransformInterceptor } from '@common/interceptors/transform.interceptor';
import { AllExceptionsFilter } from '@common/filters/http-exception.filter';

import { SsoOidcClientService } from '@/modules/organization/auth/sso/sso-oidc-client.service';
import { SsoConfigService } from '@/modules/organization/auth/sso/sso-config.service';
import { SsoError } from '@/modules/organization/auth/sso/sso-errors';

import { cleanupByPrefix } from '../../helpers/cleanup.helper';
import { pingTestDb } from '../../helpers/db-fail-fast';

// ============================================================================
// 测试常量
// ============================================================================

const TEST_TENANT_ID = 'bf5c0b82-bc9f-46e3-85df-f755cba8fd84'; // GUID 合规
const TEST_CLIENT_ID = 't_client_id_for_sso_test';
const TEST_CLIENT_SECRET = 't_client_secret_for_sso_test';
const TEST_REDIRECT_URI = 'http://localhost:4111/api/v1/auth/sso/callback';
const TEST_ALLOWED_DOMAINS = 'ff.com';

// 默认 JIT org（每个测试 setup 时建一个 t_ 前缀的 org，由 cleanupByPrefix 清理）
let TEST_JIT_DEFAULT_ORG_ID = '';

// SSO Cookie 名（与 controller 保持一致）
const SSO_COOKIE_NAMES = ['sso_state', 'sso_nonce', 'sso_redirect', 'sso_code_verifier'];

// ============================================================================
// SsoOidcClientService Mock — 模块级共享，每个测试用 setMockBehavior 注入响应
// ============================================================================

type MockBehavior = {
  buildAuthorizationUrl?: (params: any) => Promise<string> | string;
  exchangeCodeForTokens?: (params: any) => Promise<any>;
};

let __mockBehavior: MockBehavior = {};

function setMockBehavior(b: MockBehavior): void {
  __mockBehavior = b;
}

function resetMockBehavior(): void {
  __mockBehavior = {};
}

const ssoOidcClientMock: Partial<SsoOidcClientService> = {
  async buildAuthorizationUrl(params: any): Promise<string> {
    if (__mockBehavior.buildAuthorizationUrl) {
      return __mockBehavior.buildAuthorizationUrl(params);
    }
    // 默认：返回合规的 Microsoft authorize URL
    const url = new URL(
      `https://login.microsoftonline.com/${TEST_TENANT_ID}/oauth2/v2.0/authorize`,
    );
    url.searchParams.set('client_id', TEST_CLIENT_ID);
    url.searchParams.set('response_type', 'code');
    url.searchParams.set('redirect_uri', TEST_REDIRECT_URI);
    url.searchParams.set('scope', 'openid profile email');
    url.searchParams.set('state', params.state);
    url.searchParams.set('nonce', params.nonce);
    url.searchParams.set('code_challenge', 'mock_code_challenge_value_43chars_long_xxxx');
    url.searchParams.set('code_challenge_method', 'S256');
    return url.toString();
  },
  async exchangeCodeForTokens(params: any): Promise<any> {
    if (__mockBehavior.exchangeCodeForTokens) {
      return __mockBehavior.exchangeCodeForTokens(params);
    }
    throw new SsoError('SSO_PROVIDER_UNAVAILABLE', 'mock not configured');
  },
};

// ============================================================================
// 测试工具
// ============================================================================

/** 生成 SSO callback 测试用 cookie 串 */
function buildSsoCookies(p: {
  state: string;
  nonce: string;
  redirect?: string;
  codeVerifier?: string;
}): string[] {
  return [
    `sso_state=${p.state}`,
    `sso_nonce=${p.nonce}`,
    `sso_redirect=${encodeURIComponent(p.redirect || '/overview')}`,
    `sso_code_verifier=${p.codeVerifier || 'mock_code_verifier_43chars_long_xxxxxxxxxxxxx'}`,
  ];
}

function randomSuffix(): string {
  return `${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
}

/** 构造 mock id_token claims（覆盖默认值） */
function buildMockClaims(overrides: any = {}): Record<string, any> {
  return {
    iss: `https://login.microsoftonline.com/${TEST_TENANT_ID}/v2.0`,
    aud: TEST_CLIENT_ID,
    sub: `t_sub_${randomSuffix()}`,
    oid: `t_oid_${randomSuffix()}`,
    tid: TEST_TENANT_ID,
    email: `t_${randomSuffix()}@ff.com`,
    exp: Math.floor(Date.now() / 1000) + 3600,
    iat: Math.floor(Date.now() / 1000),
    nbf: Math.floor(Date.now() / 1000),
    ...overrides,
  };
}

// ============================================================================
// describe block
// ============================================================================

describe('SSO Integration Tests (Entra ID OIDC, v2.4 / issue #334)', () => {
  let app: INestApplication;
  let prisma: PrismaService;

  beforeAll(async () => {
    // 在 createTestingModule 之前覆盖关键 env，让 SsoConfigService 在 ctor 读到合规配置
    process.env.AZURE_TENANT_ID = TEST_TENANT_ID;
    process.env.AZURE_CLIENT_ID = TEST_CLIENT_ID;
    process.env.AZURE_CLIENT_SECRET = TEST_CLIENT_SECRET;
    process.env.AZURE_REDIRECT_URI = TEST_REDIRECT_URI;
    process.env.SSO_ALLOWED_DOMAINS = TEST_ALLOWED_DOMAINS;

    await pingTestDb();

    // 先用 PrismaService 直接建默认 org（绕开 NestJS bootstrap，因为 SsoConfigService
    // onApplicationBootstrap 会校验 org 存在）
    const bootstrapPrisma = new PrismaClient();
    const orgCode = `t_${randomSuffix()}_jitorg`;
    const org = await bootstrapPrisma.organization.create({
      data: {
        code: orgCode,
        name: orgCode,
        status: 'ACTIVE',
      },
    });
    TEST_JIT_DEFAULT_ORG_ID = org.id;
    process.env.SSO_JIT_DEFAULT_ORG_ID = TEST_JIT_DEFAULT_ORG_ID;
    await bootstrapPrisma.$disconnect();

    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    })
      // 禁用审计拦截器（保持与现有 app.helper.ts 一致）—— SSO audit 走 auditService.log() 直接写
      .overrideProvider(APP_INTERCEPTOR)
      .useValue({
        intercept: (_ctx: any, next: any) => next.handle(),
      })
      // 关键：mock OIDC client（HTTP 边界）
      .overrideProvider(SsoOidcClientService)
      .useValue(ssoOidcClientMock)
      .compile();

    app = moduleFixture.createNestApplication();
    const configService = app.get(ConfigService);

    // 与 main.ts 对齐
    const apiPrefix = configService.get('apiPrefix') || '/api/v1';
    app.setGlobalPrefix(apiPrefix);
    app.useGlobalPipes(
      new ValidationPipe({
        whitelist: true,
        forbidNonWhitelisted: false,
        transform: true,
        transformOptions: { enableImplicitConversion: true },
      }),
    );
    app.useGlobalFilters(new AllExceptionsFilter());
    app.useGlobalInterceptors(new TransformInterceptor());
    app.use((cookieParser as any).default ? (cookieParser as any).default() : (cookieParser as any)());
    app.enableCors({ origin: true, credentials: true });

    await app.init();
    prisma = app.get<PrismaService>(PrismaService);

    // 确保 Employee 角色存在（loginViaSSO 的 JIT 路径需要它）
    await prisma.role.upsert({
      where: { code: 'Employee' },
      create: {
        code: 'Employee',
        name: 'Employee',
        description: '内置员工角色（SSO 测试 fixture）',
        isBuiltIn: true,
        enabled: true,
      },
      update: { enabled: true },
    });
  });

  beforeEach(() => {
    resetMockBehavior();
  });

  afterAll(async () => {
    // cleanup 测试创建的所有 t_ 前缀数据
    if (prisma) {
      await cleanupByPrefix(prisma);
    }
    if (app) {
      await app.close();
    }
  });

  // ==========================================================================
  // 5.3.1 SSO start 成功跳转
  // ==========================================================================
  it('[5.3.1] should redirect to Entra authorize endpoint with state/nonce/redirect/code_verifier cookies', async () => {
    const res = await request(app.getHttpServer())
      .get('/api/v1/auth/sso/start?redirect=/overview')
      .expect(302);

    // Location: Microsoft authorize URL
    expect(res.headers.location).toMatch(
      /^https:\/\/login\.microsoftonline\.com\/[^/]+\/oauth2\/v2\.0\/authorize\?/,
    );
    const locationUrl = new URL(res.headers.location);
    expect(locationUrl.searchParams.get('client_id')).toBe(TEST_CLIENT_ID);
    expect(locationUrl.searchParams.get('response_type')).toBe('code');
    expect(locationUrl.searchParams.get('code_challenge')).toBeTruthy();
    expect(locationUrl.searchParams.get('code_challenge_method')).toBe('S256');
    expect(locationUrl.searchParams.get('scope')).toBe('openid profile email');
    expect(locationUrl.searchParams.get('state')).toBeTruthy();
    expect(locationUrl.searchParams.get('nonce')).toBeTruthy();

    // 4 个 SSO cookie 已 Set-Cookie
    const cookies = (res.headers['set-cookie'] || []).join(';');
    expect(cookies).toMatch(/sso_state=/);
    expect(cookies).toMatch(/sso_nonce=/);
    expect(cookies).toMatch(/sso_redirect=/);
    expect(cookies).toMatch(/sso_code_verifier=/);
    expect(cookies).toMatch(/HttpOnly/i);
    expect(cookies).toMatch(/SameSite=Lax/i);
  });

  // ==========================================================================
  // 5.3.2 redirect 白名单：非同源相对路径回落 /overview
  // ==========================================================================
  it('[5.3.2] should fallback to /overview when redirect target is non-relative URL (evil.com 不进 cookie)', async () => {
    const res = await request(app.getHttpServer())
      .get('/api/v1/auth/sso/start?redirect=http://evil.com/x')
      .expect(302);

    const cookies = (res.headers['set-cookie'] || []).join(';');
    expect(cookies).not.toContain('evil.com');
    // sso_redirect 应该是 /overview（默认 fallback）
    expect(cookies).toMatch(/sso_redirect=%2Foverview/);
    // Location 应该正常跳到 Microsoft（与 evil.com 无关）
    expect(res.headers.location).not.toContain('evil.com');
  });

  // ==========================================================================
  // 5.3.3 callback 成功：已存在 LOCAL 用户 → 回填 externalId（source 保持 LOCAL）
  // ==========================================================================
  it('[5.3.3] should bind externalId/externalSource on existing LOCAL user and keep source=LOCAL', async () => {
    const email = `t_${randomSuffix()}_existing@ff.com`;
    const oid = `t_oid_${randomSuffix()}`;

    const created = await prisma.user.create({
      data: {
        username: email,
        email,
        displayName: email,
        status: 'ACTIVE',
        source: 'LOCAL',
        passwordHash: '$2b$10$existinghashvalueforlocaluser',
      },
    });

    setMockBehavior({
      exchangeCodeForTokens: async () => ({
        idTokenClaims: buildMockClaims({ email, oid }),
        accessToken: 'mock_at',
        rawIdToken: 'mock_id_token',
      }),
    });

    const state = 'state_for_5_3_3';
    const nonce = 'nonce_for_5_3_3';
    const res = await request(app.getHttpServer())
      .get(`/api/v1/auth/sso/callback?code=fake&state=${state}`)
      .set('Cookie', buildSsoCookies({ state, nonce }))
      .expect(302);

    // 成功路径 → Location 含 fragment + accessToken + refreshToken
    expect(res.headers.location).toMatch(/^\/overview#/);
    expect(res.headers.location).toMatch(/accessToken=/);
    expect(res.headers.location).toMatch(/refreshToken=/);

    // DB 状态
    const dbUser = await prisma.user.findUnique({ where: { id: created.id } });
    expect(dbUser?.source).toBe('LOCAL'); // source 保留
    expect((dbUser as any)?.externalId).toBe(oid);
    expect((dbUser as any)?.externalSource).toBe('entra');
    expect(dbUser?.passwordHash).not.toBeNull();

    // audit 两条：SSO_BINDING_FILLED + SSO_LOGIN_SUCCESS
    const fillEvent = await prisma.auditLog.findFirst({
      where: { action: 'SSO_BINDING_FILLED' as any, userId: created.id } as any,
      orderBy: { createdAt: 'desc' },
    });
    expect(fillEvent).toBeTruthy();
    const fillMeta = (fillEvent as any)?.newValue || (fillEvent as any)?.metadata;
    expect(fillMeta).toMatchObject({
      email,
      externalId: oid,
      previousExternalId: null,
      entraTid: TEST_TENANT_ID,
    });

    const loginEvent = await prisma.auditLog.findFirst({
      where: { action: 'SSO_LOGIN_SUCCESS' as any, userId: created.id } as any,
      orderBy: { createdAt: 'desc' },
    });
    expect(loginEvent).toBeTruthy();
    const loginMeta = (loginEvent as any)?.newValue || (loginEvent as any)?.metadata;
    expect(loginMeta?.path).toBe('binding_filled');
    expect(loginMeta?.entraTid).toBe(TEST_TENANT_ID);

    // 4 个 cookie 已被清除
    const setCookies = (res.headers['set-cookie'] || []).join(';');
    for (const name of SSO_COOKIE_NAMES) {
      expect(setCookies).toMatch(new RegExp(`${name}=;.*Max-Age=0`));
    }
  });

  // ==========================================================================
  // 5.3.4 callback 成功：JIT 建账号（域名命中白名单）
  // ==========================================================================
  it('[5.3.4] should JIT-create new user when email is unknown and domain is allowlisted', async () => {
    const email = `t_${randomSuffix()}_new@ff.com`;
    const oid = `t_oid_${randomSuffix()}_jit`;

    setMockBehavior({
      exchangeCodeForTokens: async () => ({
        idTokenClaims: buildMockClaims({ email, oid }),
        accessToken: 'mock_at',
        rawIdToken: 'mock_id_token',
      }),
    });

    const state = 'state_for_5_3_4';
    const nonce = 'nonce_for_5_3_4';
    const res = await request(app.getHttpServer())
      .get(`/api/v1/auth/sso/callback?code=fake&state=${state}`)
      .set('Cookie', buildSsoCookies({ state, nonce }))
      .expect(302);

    expect(res.headers.location).toMatch(/^\/overview#accessToken=/);

    // DB 新建
    const dbUser = await prisma.user.findUnique({ where: { email } });
    expect(dbUser).toBeTruthy();
    expect(dbUser?.source).toBe('ENTRA');
    expect((dbUser as any)?.externalSource).toBe('entra');
    expect((dbUser as any)?.externalId).toBe(oid);
    expect(dbUser?.passwordHash).toBeNull();
    expect(dbUser?.username).toBe(email);

    // 默认 org 的 Employee UserRole
    const employeeRole = await prisma.role.findUnique({ where: { code: 'Employee' } });
    const userRoles = await prisma.userRole.findMany({
      where: {
        userId: dbUser!.id,
        roleId: employeeRole!.id,
        organizationId: TEST_JIT_DEFAULT_ORG_ID,
      },
    });
    expect(userRoles.length).toBe(1);

    // 不建 UserDepartment
    const userDepts = await prisma.userDepartment.findMany({ where: { userId: dbUser!.id } });
    expect(userDepts.length).toBe(0);

    // audit：SSO_JIT_CREATED + SSO_LOGIN_SUCCESS 必须双双落库。
    // 修复历史：ERR-20260519-010 把 audit 从 tx 内挪到 post-commit 写，
    //   解决 JIT 新建 user 时 AuditService 自建连接看不到未提交 user 导致的 P2003 FK 违例。
    const jitEvent = await prisma.auditLog.findFirst({
      where: { action: 'SSO_JIT_CREATED' as any, userId: dbUser!.id } as any,
    });
    expect(jitEvent).toBeTruthy();
    const jitMeta = (jitEvent as any)?.newValue || (jitEvent as any)?.metadata;
    expect(jitMeta).toMatchObject({
      email,
      externalId: oid,
      defaultOrgId: TEST_JIT_DEFAULT_ORG_ID,
      entraTid: TEST_TENANT_ID,
    });

    const loginEvent = await prisma.auditLog.findFirst({
      where: { action: 'SSO_LOGIN_SUCCESS' as any, userId: dbUser!.id } as any,
    });
    expect(loginEvent).toBeTruthy();
    const loginMeta = (loginEvent as any)?.newValue || (loginEvent as any)?.metadata;
    expect(loginMeta?.path).toBe('jit');
  });

  // ==========================================================================
  // 5.3.5 JIT 域名白名单拒绝
  // ==========================================================================
  it('[5.3.5] should reject JIT creation when email domain is not allowlisted', async () => {
    const email = `t_${randomSuffix()}_bad@guest.com`;
    const oid = `t_oid_${randomSuffix()}_bad`;

    setMockBehavior({
      exchangeCodeForTokens: async () => ({
        idTokenClaims: buildMockClaims({ email, oid }),
        accessToken: 'mock_at',
        rawIdToken: 'mock_id_token',
      }),
    });

    const state = 'state_for_5_3_5';
    const nonce = 'nonce_for_5_3_5';
    const res = await request(app.getHttpServer())
      .get(`/api/v1/auth/sso/callback?code=fake&state=${state}`)
      .set('Cookie', buildSsoCookies({ state, nonce }))
      .expect(302);

    expect(res.headers.location).toBe('/login?ssoError=SSO_DOMAIN_NOT_ALLOWED');

    // DB 无新增
    const dbUser = await prisma.user.findUnique({ where: { email } });
    expect(dbUser).toBeNull();
  });

  // ==========================================================================
  // 5.3.6 state 不匹配
  // ==========================================================================
  it('[5.3.6] should reject with SSO_TOKEN_INVALID when cookie state != query state', async () => {
    const res = await request(app.getHttpServer())
      .get('/api/v1/auth/sso/callback?code=fake&state=B')
      .set('Cookie', buildSsoCookies({ state: 'A', nonce: 'n' }))
      .expect(302);

    expect(res.headers.location).toBe('/login?ssoError=SSO_TOKEN_INVALID');
  });

  // ==========================================================================
  // 5.3.7 id_token 签名失败 → mock 抛 SsoError(SSO_TOKEN_INVALID)
  // ==========================================================================
  it('[5.3.7] should redirect with SSO_TOKEN_INVALID when id_token signature verification fails', async () => {
    setMockBehavior({
      exchangeCodeForTokens: async () => {
        throw new SsoError('SSO_TOKEN_INVALID', 'invalid signature');
      },
    });

    const state = 'state_for_5_3_7';
    const nonce = 'nonce_for_5_3_7';
    const res = await request(app.getHttpServer())
      .get(`/api/v1/auth/sso/callback?code=fake&state=${state}`)
      .set('Cookie', buildSsoCookies({ state, nonce }))
      .expect(302);

    expect(res.headers.location).toBe('/login?ssoError=SSO_TOKEN_INVALID');
  });

  // ==========================================================================
  // 5.3.8 id_token 缺 email claim
  // ==========================================================================
  it('[5.3.8] should redirect with SSO_EMAIL_MISSING when id_token lacks email claim', async () => {
    setMockBehavior({
      exchangeCodeForTokens: async () => {
        const claims = buildMockClaims({ oid: `t_oid_${randomSuffix()}_noemail` });
        delete claims.email;
        return {
          idTokenClaims: claims,
          accessToken: 'mock_at',
          rawIdToken: 'mock_id_token',
        };
      },
    });

    const state = 'state_for_5_3_8';
    const nonce = 'nonce_for_5_3_8';
    const res = await request(app.getHttpServer())
      .get(`/api/v1/auth/sso/callback?code=fake&state=${state}`)
      .set('Cookie', buildSsoCookies({ state, nonce }))
      .expect(302);

    expect(res.headers.location).toBe('/login?ssoError=SSO_EMAIL_MISSING');
  });

  // ==========================================================================
  // 5.3.9a 真冲突：entra 来源、externalId 不同 → 409 SSO_BINDING_CONFLICT，DB 不覆盖
  // ==========================================================================
  it('[5.3.9a] should reject with SSO_BINDING_CONFLICT and preserve externalId when entra oid conflicts', async () => {
    const email = `t_${randomSuffix()}_conflict@ff.com`;
    const oldOid = `t_oid_${randomSuffix()}_old`;
    const newOid = `t_oid_${randomSuffix()}_new`;

    const created = await prisma.user.create({
      data: {
        username: email,
        email,
        displayName: email,
        status: 'ACTIVE',
        source: 'LOCAL',
        externalId: oldOid,
        externalSource: 'entra',
      } as any,
    });

    setMockBehavior({
      exchangeCodeForTokens: async () => ({
        idTokenClaims: buildMockClaims({ email, oid: newOid }),
        accessToken: 'mock_at',
        rawIdToken: 'mock_id_token',
      }),
    });

    const state = 'state_5_3_9a';
    const nonce = 'nonce_5_3_9a';
    const res = await request(app.getHttpServer())
      .get(`/api/v1/auth/sso/callback?code=fake&state=${state}`)
      .set('Cookie', buildSsoCookies({ state, nonce }))
      .expect(302);

    expect(res.headers.location).toBe('/login?ssoError=SSO_BINDING_CONFLICT');

    // DB 保持原值
    const dbUser = await prisma.user.findUnique({ where: { id: created.id } });
    expect((dbUser as any)?.externalId).toBe(oldOid);
    expect((dbUser as any)?.externalSource).toBe('entra');

    // audit 含 SSO_BINDING_CONFLICT
    const conflict = await prisma.auditLog.findFirst({
      where: { action: 'SSO_BINDING_CONFLICT' as any, userId: created.id } as any,
    });
    expect(conflict).toBeTruthy();
    const meta = (conflict as any)?.newValue || (conflict as any)?.metadata;
    expect(meta).toMatchObject({
      email,
      existingExternalId: oldOid,
      attemptedExternalId: newOid,
      entraTid: TEST_TENANT_ID,
    });
  });

  // ==========================================================================
  // 5.3.9b LDAP 升级：externalSource=ldap → 覆盖为 entra + 专属 audit
  // ==========================================================================
  it('[5.3.9b] should upgrade LDAP-bound user to entra and emit SSO_BINDING_UPGRADED_FROM_LDAP', async () => {
    const email = `t_${randomSuffix()}_ldap@ff.com`;
    const ldapDn = `CN=${email},OU=Users,DC=corp,DC=ff,DC=com`;
    const newOid = `t_oid_${randomSuffix()}_entra`;

    const created = await prisma.user.create({
      data: {
        username: email,
        email,
        displayName: email,
        status: 'ACTIVE',
        source: 'LDAP',
        externalId: ldapDn,
        externalSource: 'ldap',
      } as any,
    });

    setMockBehavior({
      exchangeCodeForTokens: async () => ({
        idTokenClaims: buildMockClaims({ email, oid: newOid }),
        accessToken: 'mock_at',
        rawIdToken: 'mock_id_token',
      }),
    });

    const state = 'state_5_3_9b';
    const nonce = 'nonce_5_3_9b';
    const res = await request(app.getHttpServer())
      .get(`/api/v1/auth/sso/callback?code=fake&state=${state}`)
      .set('Cookie', buildSsoCookies({ state, nonce }))
      .expect(302);

    // 成功路径 → fragment 含 token
    expect(res.headers.location).toMatch(/^\/overview#accessToken=/);

    // DB 覆盖
    const dbUser = await prisma.user.findUnique({ where: { id: created.id } });
    expect((dbUser as any)?.externalId).toBe(newOid);
    expect((dbUser as any)?.externalSource).toBe('entra');
    expect(dbUser?.source).toBe('LDAP'); // source 不动

    // audit
    const upgrade = await prisma.auditLog.findFirst({
      where: { action: 'SSO_BINDING_UPGRADED_FROM_LDAP' as any, userId: created.id } as any,
    });
    expect(upgrade).toBeTruthy();
    const meta = (upgrade as any)?.newValue || (upgrade as any)?.metadata;
    expect(meta).toMatchObject({
      previousExternalId: ldapDn,
      newExternalId: newOid,
      entraTid: TEST_TENANT_ID,
    });

    const loginEvent = await prisma.auditLog.findFirst({
      where: { action: 'SSO_LOGIN_SUCCESS' as any, userId: created.id } as any,
    });
    const loginMeta = (loginEvent as any)?.newValue || (loginEvent as any)?.metadata;
    expect(loginMeta?.path).toBe('ldap_upgraded');
  });

  // ==========================================================================
  // 5.3.10 token endpoint 503 / 超时 → SSO_PROVIDER_UNAVAILABLE
  // ==========================================================================
  it('[5.3.10] should redirect with SSO_PROVIDER_UNAVAILABLE when token endpoint is unreachable', async () => {
    setMockBehavior({
      exchangeCodeForTokens: async () => {
        throw new SsoError('SSO_PROVIDER_UNAVAILABLE', 'token endpoint 503');
      },
    });

    const state = 'state_for_5_3_10';
    const nonce = 'nonce_for_5_3_10';
    const res = await request(app.getHttpServer())
      .get(`/api/v1/auth/sso/callback?code=fake&state=${state}`)
      .set('Cookie', buildSsoCookies({ state, nonce }))
      .expect(302);

    expect(res.headers.location).toBe('/login?ssoError=SSO_PROVIDER_UNAVAILABLE');
  });

  // ==========================================================================
  // 5.3.11 双通道并存：SSO 后密码登录仍可用
  // ==========================================================================
  it('[5.3.11] should keep local password channel working after SSO binding', async () => {
    const email = `t_${randomSuffix()}_dual@ff.com`;
    const password = 'Test@1234';

    await prisma.user.create({
      data: {
        username: email,
        email,
        displayName: email,
        status: 'ACTIVE',
        source: 'LOCAL',
        passwordHash: await bcrypt.hash(password, 10),
      },
    });

    setMockBehavior({
      exchangeCodeForTokens: async () => ({
        idTokenClaims: buildMockClaims({ email, oid: `t_oid_${randomSuffix()}_dual` }),
        accessToken: 'mock_at',
        rawIdToken: 'mock_id_token',
      }),
    });

    // 先走 SSO 回填
    const state = 'state_for_5_3_11';
    const nonce = 'nonce_for_5_3_11';
    await request(app.getHttpServer())
      .get(`/api/v1/auth/sso/callback?code=fake&state=${state}`)
      .set('Cookie', buildSsoCookies({ state, nonce }))
      .expect(302);

    // 再走密码通道
    const loginRes = await request(app.getHttpServer())
      .post('/api/v1/auth/login')
      .send({ username: email, password })
      .expect(200);

    expect(loginRes.body.data.accessToken).toBeTruthy();

    // passwordHash 未被清空
    const dbUser = await prisma.user.findUnique({ where: { email } });
    expect(dbUser?.passwordHash).not.toBeNull();
  });

  // ==========================================================================
  // 5.3.12 audit metadata 字段断言（5 类事件）
  // ==========================================================================
  it('[5.3.12] should write complete audit metadata for SSO_BINDING_FILLED + SSO_LOGIN_SUCCESS', async () => {
    // 用一个完整的成功 binding-fill 路径触发两类事件
    const email = `t_${randomSuffix()}_meta@ff.com`;
    const oid = `t_oid_${randomSuffix()}_meta`;
    const created = await prisma.user.create({
      data: {
        username: email,
        email,
        displayName: email,
        status: 'ACTIVE',
        source: 'LOCAL',
        passwordHash: '$2b$10$existinghashvalueforlocaluser',
      },
    });
    setMockBehavior({
      exchangeCodeForTokens: async () => ({
        idTokenClaims: buildMockClaims({ email, oid }),
        accessToken: 'mock_at',
        rawIdToken: 'mock_id_token',
      }),
    });
    const state = 'state_5_3_12';
    const nonce = 'nonce_5_3_12';
    await request(app.getHttpServer())
      .get(`/api/v1/auth/sso/callback?code=fake&state=${state}`)
      .set('Cookie', buildSsoCookies({ state, nonce }))
      .expect(302);

    const logs = await prisma.auditLog.findMany({
      where: {
        userId: created.id,
        action: { in: ['SSO_LOGIN_SUCCESS' as any, 'SSO_BINDING_FILLED' as any] } as any,
      } as any,
    });
    expect(logs.length).toBeGreaterThanOrEqual(2);

    for (const log of logs as any[]) {
      const meta = log.newValue || log.metadata;
      expect(meta).toBeTruthy();
      expect(meta.email).toBe(email);
      expect(meta.entraTid).toBe(TEST_TENANT_ID);
      if (log.action === 'SSO_LOGIN_SUCCESS') {
        expect(['existing', 'jit', 'binding_filled', 'ldap_upgraded']).toContain(meta.path);
        expect(meta.userId).toBe(created.id);
      }
      if (log.action === 'SSO_BINDING_FILLED') {
        expect(meta.previousExternalId).toBeNull();
        expect(meta.externalId).toBe(oid);
      }
    }
  });

  // ==========================================================================
  // 5.3.13 并发 callback 回填同 email（CAS UPDATE）
  // ==========================================================================
  it('[5.3.13] should handle concurrent CAS fill-in without dirty write or 5xx', async () => {
    const email = `t_${randomSuffix()}_concurrent@ff.com`;
    const oid = `t_oid_${randomSuffix()}_cc`;

    const created = await prisma.user.create({
      data: {
        username: email,
        email,
        displayName: email,
        status: 'ACTIVE',
        source: 'LOCAL',
        passwordHash: '$2b$10$x',
      },
    });

    setMockBehavior({
      exchangeCodeForTokens: async () => ({
        idTokenClaims: buildMockClaims({ email, oid }),
        accessToken: 'mock_at',
        rawIdToken: 'mock_id_token',
      }),
    });

    const state = 'state_5_3_13';
    const nonce = 'nonce_5_3_13';
    const [r1, r2] = await Promise.all([
      request(app.getHttpServer())
        .get(`/api/v1/auth/sso/callback?code=c1&state=${state}`)
        .set('Cookie', buildSsoCookies({ state, nonce })),
      request(app.getHttpServer())
        .get(`/api/v1/auth/sso/callback?code=c2&state=${state}`)
        .set('Cookie', buildSsoCookies({ state, nonce })),
    ]);

    // 无 5xx
    expect(r1.status).toBeLessThan(500);
    expect(r2.status).toBeLessThan(500);

    // 最终 externalId 等于 oid
    const dbUser = await prisma.user.findUnique({ where: { id: created.id } });
    expect((dbUser as any)?.externalId).toBe(oid);

    // SSO_BINDING_FILLED 恰好 1 条：
    //   CAS WHERE externalId IS NULL 保证只有一个 tx update.count=1 走 binding_filled 路径（写 audit），
    //   另一个 tx update.count=0 走 refetched + existing 路径（不写 BINDING_FILLED）。
    // 修复历史：ERR-20260519-010 把 audit 改为 post-commit 写，
    //   修复前 JIT 路径丢 audit；本场景是 binding_filled 路径，修复前后都应稳定写 1 条。
    const fills = await prisma.auditLog.findMany({
      where: { action: 'SSO_BINDING_FILLED' as any, userId: created.id } as any,
    });
    expect(fills.length).toBe(1);
  });

  // ==========================================================================
  // 5.3.14 并发 JIT 同 email（upsert + P2002 fallback）
  // ==========================================================================
  it('[5.3.14] should JIT-create only one user on concurrent same-email race', async () => {
    const email = `t_${randomSuffix()}_jitrace@ff.com`;
    const oid = `t_oid_${randomSuffix()}_jr`;

    setMockBehavior({
      exchangeCodeForTokens: async () => ({
        idTokenClaims: buildMockClaims({ email, oid }),
        accessToken: 'mock_at',
        rawIdToken: 'mock_id_token',
      }),
    });

    const state = 'state_5_3_14';
    const nonce = 'nonce_5_3_14';
    const [r1, r2] = await Promise.all([
      request(app.getHttpServer())
        .get(`/api/v1/auth/sso/callback?code=c1&state=${state}`)
        .set('Cookie', buildSsoCookies({ state, nonce })),
      request(app.getHttpServer())
        .get(`/api/v1/auth/sso/callback?code=c2&state=${state}`)
        .set('Cookie', buildSsoCookies({ state, nonce })),
    ]);

    expect(r1.status).toBeLessThan(500);
    expect(r2.status).toBeLessThan(500);

    const users = await prisma.user.findMany({ where: { email } });
    expect(users.length).toBe(1);

    // SSO_JIT_CREATED 恰好 1 条：
    //   upsert + P2002 fallback 保证只有一个 tx 真正进入 JIT 创建分支（写 audit），
    //   另一个走 refetched 拿到已存在 user，path=existing 不写 SSO_JIT_CREATED。
    // 修复历史：ERR-20260519-010 把 audit 改为 post-commit 写，
    //   修复前此场景 JIT 路径 FK 违例导致 audit 丢失（实际写入 0 条），
    //   修复后必稳定写 1 条。
    const jitEvents = await prisma.auditLog.findMany({
      where: { action: 'SSO_JIT_CREATED' as any, userId: users[0].id } as any,
    });
    expect(jitEvents.length).toBe(1);
  });

  // ==========================================================================
  // 5.3.15 mixed-case email 归一化
  // ==========================================================================
  it('[5.3.15] should match existing user by lower-case email regardless of Entra-side case', async () => {
    const lower = `t_${randomSuffix()}_case@ff.com`;
    const mixed = lower.replace(/^t_/, 'T_').replace('@', '@');
    const oid = `t_oid_${randomSuffix()}_case`;

    const created = await prisma.user.create({
      data: {
        username: lower,
        email: lower,
        displayName: lower,
        status: 'ACTIVE',
        source: 'LOCAL',
        passwordHash: '$2b$10$x',
      },
    });

    setMockBehavior({
      exchangeCodeForTokens: async () => ({
        idTokenClaims: buildMockClaims({ email: mixed.toUpperCase().replace(/@FF\.COM$/, '@ff.com'), oid }),
        accessToken: 'mock_at',
        rawIdToken: 'mock_id_token',
      }),
    });

    const state = 'state_5_3_15';
    const nonce = 'nonce_5_3_15';
    await request(app.getHttpServer())
      .get(`/api/v1/auth/sso/callback?code=fake&state=${state}`)
      .set('Cookie', buildSsoCookies({ state, nonce }))
      .expect(302);

    const dbUser = await prisma.user.findUnique({ where: { id: created.id } });
    expect((dbUser as any)?.externalId).toBe(oid);

    // 不会建第二个 user
    const all = await prisma.user.findMany({ where: { email: { contains: lower.toLowerCase() } } });
    expect(all.length).toBe(1);
  });

  // ==========================================================================
  // 5.3.16 sso_redirect 注入伪造 → 302 到 /login?ssoError=...，不出站
  // 说明：本期 controller 实现的"安全 redirect"由 normalizeSafeRedirect 兜底；
  // sso_redirect cookie 是 controller 自己写的 (start 端点)，攻击面只能是
  // 浏览器伪造 cookie。我们手工 set 一个 http://evil.com cookie 模拟。
  // ==========================================================================
  it('[5.3.16] should not redirect off-site even if sso_redirect cookie was forged', async () => {
    const email = `t_${randomSuffix()}_inj@ff.com`;
    const oid = `t_oid_${randomSuffix()}_inj`;
    await prisma.user.create({
      data: {
        username: email,
        email,
        displayName: email,
        status: 'ACTIVE',
        source: 'LOCAL',
        passwordHash: '$2b$10$x',
      },
    });

    setMockBehavior({
      exchangeCodeForTokens: async () => ({
        idTokenClaims: buildMockClaims({ email, oid }),
        accessToken: 'mock_at',
        rawIdToken: 'mock_id_token',
      }),
    });

    const state = 'state_5_3_16';
    const nonce = 'nonce_5_3_16';
    const res = await request(app.getHttpServer())
      .get(`/api/v1/auth/sso/callback?code=fake&state=${state}`)
      .set('Cookie', [
        `sso_state=${state}`,
        `sso_nonce=${nonce}`,
        // 伪造的 sso_redirect
        `sso_redirect=${encodeURIComponent('http://evil.com/x')}`,
        'sso_code_verifier=mock_cv',
      ])
      .expect(302);

    // 不能跳到 evil.com；fallback /overview
    expect(res.headers.location).not.toContain('evil.com');
    expect(res.headers.location).toMatch(/^\/overview#/);
  });

  // ==========================================================================
  // 5.3.17 INACTIVE 用户被拒（IAM_USER_SUSPENDED）
  // 参数化：INACTIVE / SUSPENDED / TERMINATED 三个状态
  // ==========================================================================
  it.each([['INACTIVE'], ['SUSPENDED'], ['TERMINATED']])(
    '[5.3.17] should reject %s user via SSO with IAM_USER_SUSPENDED',
    async (status) => {
      const email = `t_${randomSuffix()}_${status.toLowerCase()}@ff.com`;
      const oid = `t_oid_${randomSuffix()}_${status.toLowerCase()}`;
      await prisma.user.create({
        data: {
          username: email,
          email,
          displayName: email,
          status: status as any,
          source: 'ENTRA',
          externalId: oid,
          externalSource: 'entra',
        } as any,
      });

      setMockBehavior({
        exchangeCodeForTokens: async () => ({
          idTokenClaims: buildMockClaims({ email, oid }),
          accessToken: 'mock_at',
          rawIdToken: 'mock_id_token',
        }),
      });

      const state = `state_5_3_17_${status}`;
      const nonce = `nonce_5_3_17_${status}`;
      const res = await request(app.getHttpServer())
        .get(`/api/v1/auth/sso/callback?code=fake&state=${state}`)
        .set('Cookie', buildSsoCookies({ state, nonce }))
        .expect(302);

      expect(res.headers.location).toBe('/login?ssoError=IAM_USER_SUSPENDED');
    },
  );

  // ==========================================================================
  // 5.3.18 Entra error query 三种处理
  // ==========================================================================
  it.each([
    ['access_denied', 'SSO_USER_CANCELLED'],
    ['consent_required', 'SSO_CONSENT_REQUIRED'],
    ['interaction_required', 'SSO_CONSENT_REQUIRED'],
    ['invalid_request', 'SSO_PROVIDER_REJECTED'],
    ['server_error', 'SSO_PROVIDER_REJECTED'],
  ])('[5.3.18] should map Entra error=%s → %s', async (entraErr, expectedCode) => {
    const state = `state_5_3_18_${entraErr}`;
    const nonce = `nonce_5_3_18_${entraErr}`;
    const res = await request(app.getHttpServer())
      .get(`/api/v1/auth/sso/callback?error=${entraErr}&error_description=test`)
      .set('Cookie', buildSsoCookies({ state, nonce }))
      .expect(302);

    expect(res.headers.location).toBe(`/login?ssoError=${expectedCode}`);
  });

  // ==========================================================================
  // 5.3.19 issuer tid 替换比对（multi-tenant 防护）
  // mock OIDC client 在 tid 不一致时抛 SSO_TOKEN_INVALID
  // 真实 openid-client 在 token verification 阶段处理；这里在 mock 层模拟
  // ==========================================================================
  it('[5.3.19] should reject when id_token tid does not match expected tenant', async () => {
    setMockBehavior({
      exchangeCodeForTokens: async () => {
        // openid-client 内部在 issuer 模板 {tenantid} 替换后字符串严格比对失败时会抛
        throw new SsoError('SSO_TOKEN_INVALID', 'unexpected JWT "iss" claim value');
      },
    });

    const state = 'state_5_3_19';
    const nonce = 'nonce_5_3_19';
    const res = await request(app.getHttpServer())
      .get(`/api/v1/auth/sso/callback?code=fake&state=${state}`)
      .set('Cookie', buildSsoCookies({ state, nonce }))
      .expect(302);

    expect(res.headers.location).toBe('/login?ssoError=SSO_TOKEN_INVALID');
  });

  // ==========================================================================
  // 5.3.20 启动期 fail-fast（SsoConfigService）—— 单独实例化，不启动整个 App
  // ==========================================================================
  describe('[5.3.20] SsoConfigService startup fail-fast', () => {
    // 注意：SsoConfigService.onApplicationBootstrap 调用 process.exit(1)；
    // 我们直接调 validate() 方法验证抛错（更确定 + 不需要重启 Nest）

    function makeConfigService(env: Record<string, string>): any {
      return {
        get<T>(key: string): T | undefined {
          return env[key] as any;
        },
      };
    }

    function makePrismaFake(orgs: Array<{ id: string; deletedAt: Date | null }>): any {
      return {
        organization: {
          async findUnique({ where: { id } }: any) {
            return orgs.find((o) => o.id === id) || null;
          },
        },
      };
    }

    it('should reject AZURE_TENANT_ID="common" (not a GUID)', async () => {
      const svc = new SsoConfigService(
        makeConfigService({
          AZURE_TENANT_ID: 'common',
          AZURE_CLIENT_ID: 'x',
          AZURE_CLIENT_SECRET: 'y',
          AZURE_REDIRECT_URI: 'http://localhost/cb',
        }),
        makePrismaFake([]),
      );
      await expect(svc.validate()).rejects.toThrow(/GUID/);
    });

    it('should reject AZURE_TENANT_ID="organizations"', async () => {
      const svc = new SsoConfigService(
        makeConfigService({
          AZURE_TENANT_ID: 'organizations',
          AZURE_CLIENT_ID: 'x',
          AZURE_CLIENT_SECRET: 'y',
          AZURE_REDIRECT_URI: 'http://localhost/cb',
        }),
        makePrismaFake([]),
      );
      await expect(svc.validate()).rejects.toThrow();
    });

    it('should reject when SSO_ALLOWED_DOMAINS is set but SSO_JIT_DEFAULT_ORG_ID is empty', async () => {
      const svc = new SsoConfigService(
        makeConfigService({
          AZURE_TENANT_ID: TEST_TENANT_ID,
          AZURE_CLIENT_ID: 'x',
          AZURE_CLIENT_SECRET: 'y',
          AZURE_REDIRECT_URI: 'http://localhost/cb',
          SSO_ALLOWED_DOMAINS: 'ff.com',
          // SSO_JIT_DEFAULT_ORG_ID 缺失
        }),
        makePrismaFake([]),
      );
      await expect(svc.validate()).rejects.toThrow(/SSO_JIT_DEFAULT_ORG_ID/);
    });

    it('should reject when SSO_JIT_DEFAULT_ORG_ID points to a non-existent org', async () => {
      const ghostOrgId = '11111111-1111-1111-1111-111111111111';
      const svc = new SsoConfigService(
        makeConfigService({
          AZURE_TENANT_ID: TEST_TENANT_ID,
          AZURE_CLIENT_ID: 'x',
          AZURE_CLIENT_SECRET: 'y',
          AZURE_REDIRECT_URI: 'http://localhost/cb',
          SSO_ALLOWED_DOMAINS: 'ff.com',
          SSO_JIT_DEFAULT_ORG_ID: ghostOrgId,
        }),
        makePrismaFake([]), // org 不存在
      );
      await expect(svc.validate()).rejects.toThrow(/不存在|deleted/);
    });

    it('should reject when SSO_JIT_DEFAULT_ORG_ID points to a soft-deleted org', async () => {
      const orgId = '22222222-2222-2222-2222-222222222222';
      const svc = new SsoConfigService(
        makeConfigService({
          AZURE_TENANT_ID: TEST_TENANT_ID,
          AZURE_CLIENT_ID: 'x',
          AZURE_CLIENT_SECRET: 'y',
          AZURE_REDIRECT_URI: 'http://localhost/cb',
          SSO_ALLOWED_DOMAINS: 'ff.com',
          SSO_JIT_DEFAULT_ORG_ID: orgId,
        }),
        makePrismaFake([{ id: orgId, deletedAt: new Date() }]),
      );
      await expect(svc.validate()).rejects.toThrow();
    });

    it('should pass when all SSO env is valid and default org exists', async () => {
      const orgId = '33333333-3333-3333-3333-333333333333';
      const svc = new SsoConfigService(
        makeConfigService({
          AZURE_TENANT_ID: TEST_TENANT_ID,
          AZURE_CLIENT_ID: 'x',
          AZURE_CLIENT_SECRET: 'y',
          AZURE_REDIRECT_URI: 'http://localhost/cb',
          SSO_ALLOWED_DOMAINS: 'ff.com',
          SSO_JIT_DEFAULT_ORG_ID: orgId,
        }),
        makePrismaFake([{ id: orgId, deletedAt: null }]),
      );
      await expect(svc.validate()).resolves.toBeUndefined();
    });

    it('should skip validation when all SSO env is empty (dev mode)', async () => {
      const svc = new SsoConfigService(
        makeConfigService({}),
        makePrismaFake([]),
      );
      await expect(svc.validate()).resolves.toBeUndefined();
    });
  });
});
