// 관리자 세션 · 인증 상태 · 로그인 잠금 저장소 함수를 격리 MySQL 에서 실제 SQL 로 돌린다(계획 8.3).
// 표는 drizzle/0041_admin_auth_session.sql 파일 그대로 만든다. 환경 변수가 없으면 던진다(건너뛰지 않는다).
jest.mock('drizzle/schema', () =>
  jest.requireActual('../../../../../../drizzle/schema'),
);

import { readFileSync } from 'fs';
import { join } from 'path';
import mysql from 'mysql2/promise';
import { drizzle } from 'drizzle-orm/mysql2';
import { sql } from 'drizzle-orm';
import {
  accountExists,
  beginSignin,
  consumePending,
  consumeStep,
  countOtherSessions,
  deleteAuthStateOf,
  insertPending,
  insertSession,
  lockAuthState,
  loginLabelOf,
  passwordSetByOther,
  readCodeLockedUntil,
  readMemberName,
  readMfaState,
  readPasswordHash,
  readPending,
  recordCodeFailure,
  revokeSessionBySid,
  revokeSessions,
  setPasswordChangeRequired,
  tokenHash,
  updateAuthState,
  writeOwnPassword,
} from './admin-session';
import {
  SIGNIN_LOCK_SECONDS,
  clearLock,
  readLockedUntil,
  recordLockFailure,
} from './signin-lock';
import { open as openSeal, seal } from './mfa-seal';
import { AdminAuthService } from './auth.service';
import { MfaService } from './mfa.service';
import { compareSync, hashSync } from 'bcrypt';
import { hotp } from './totp';
import { runOps } from '../../../seeds/admin-mfa-ops';

const DBNAME = 'it_admin_session';
const DDL = join(__dirname, '../../../../../../drizzle/0041_admin_auth_session.sql');

function cfg() {
  const port = Number(process.env.IT_MYSQL_PORT);
  const password = process.env.IT_MYSQL_PASSWORD;
  if (!port || !password) {
    throw new Error(
      '격리 MySQL 환경 변수 (IT_MYSQL_PORT · IT_MYSQL_PASSWORD) 가 없다 — 건너뛰지 않는다',
    );
  }
  return {
    host: '127.0.0.1',
    port,
    user: process.env.IT_MYSQL_USER ?? 'root',
    password,
  };
}

describe('관리자 세션 저장소 (격리 MySQL)', () => {
  let admin: mysql.Connection;
  let pool: mysql.Pool;
  let db: any;

  beforeAll(async () => {
    const c = cfg();
    admin = await mysql.createConnection({ ...c, multipleStatements: true });
    await admin.query(
      `DROP DATABASE IF EXISTS ${DBNAME}; CREATE DATABASE ${DBNAME}`,
    );
    await admin.query(`USE ${DBNAME}`);
    await admin.query(readFileSync(DDL, 'utf8'));
    //* 원천 비밀번호 칸만 가진 최소 표(로그인 재대조 · 잠금 순서 시험용) — 실제 표의 다른 칸은 이 SQL 이 읽지 않는다
    await admin.query(`
      CREATE TABLE tn_admin (adm_id INT PRIMARY KEY, adm_password VARCHAR(100) NOT NULL);
      CREATE TABLE tn_member (mem_id INT PRIMARY KEY, mem_password VARCHAR(100) NOT NULL, mem_name VARCHAR(20) NULL, mem_login_id VARCHAR(50) NULL)`);
    pool = mysql.createPool({ ...c, database: DBNAME, connectionLimit: 4 });
    db = drizzle(pool) as any;
  });

  afterAll(async () => {
    await pool?.end();
    if (admin) {
      await admin.query(`DROP DATABASE IF EXISTS ${DBNAME}`);
      await admin.end();
    }
  });

  const row = async (key: string) => {
    const [rows] = await admin.query(
      'SELECT asl_fail_count AS n, asl_locked_until AS u FROM tn_admin_signin_lock WHERE asl_key = ?',
      [key],
    );
    return (rows as { n: number; u: number | null }[])[0];
  };

  it('로그인 잠금: 9회까지는 잠기지 않고 10회째에 잠기며 실패 수가 0 으로 돌아간다', async () => {
    const key = 'admin:lock-10';
    const now = 1_700_000_000;
    for (let i = 1; i <= 9; i++) expect(await recordLockFailure(db, key, now)).toBe(false);
    expect(await row(key)).toEqual({ n: 9, u: null });
    expect(await readLockedUntil(db, key)).toBeNull();

    expect(await recordLockFailure(db, key, now)).toBe(true); //* 잠금 진입은 이 한 번만 참(감사 signin.locked)
    expect(await row(key)).toEqual({ n: 0, u: now + SIGNIN_LOCK_SECONDS });
    expect(await readLockedUntil(db, key)).toBe(now + SIGNIN_LOCK_SECONDS);

    //* 대기 뒤 다시 세기 시작한다 — 한 번 틀렸다고 다시 잠기지 않는다(D8)
    expect(await recordLockFailure(db, key, now + 1000)).toBe(false);
    expect(await row(key)).toEqual({ n: 1, u: now + SIGNIN_LOCK_SECONDS });
  });

  it('로그인 잠금: 잠긴 동안의 실패는 세지 않는다 — 같은 초 순차 20회에 진입은 한 번 · 실패 수 0', async () => {
    const key = 'admin:lock-hold';
    const now = 1_700_000_000;
    const entered: boolean[] = [];
    for (let i = 0; i < 20; i++) entered.push(await recordLockFailure(db, key, now));
    expect(entered.filter(Boolean).length).toBe(1);
    expect(await row(key)).toEqual({ n: 0, u: now + SIGNIN_LOCK_SECONDS });
  });

  it('로그인 잠금 경합: 동시 실패 25건 중 잠금 진입은 한 건, 잠긴 뒤 실패는 세지 않는다', async () => {
    const key = 'admin:lock-race';
    const now = 1_700_000_000;
    const entered = await Promise.all(
      Array.from({ length: 25 }, () => recordLockFailure(db, key, now)),
    );
    expect(entered.filter(Boolean).length).toBe(1);
    expect(await row(key)).toEqual({ n: 0, u: now + SIGNIN_LOCK_SECONDS });
  });

  it('로그인 잠금: 지우면 행이 없고 읽기는 null', async () => {
    const key = 'admin:lock-clear';
    await recordLockFailure(db, key, 1);
    await clearLock(db, key);
    expect(await row(key)).toBeUndefined();
    expect(await readLockedUntil(db, key)).toBeNull();
  });

  it('계정 잠금: 같은 계정을 두 연결이 동시에 잠가도 교착 · 키 충돌 없이 차례로 지난다', async () => {
    const errors: string[] = [];
    const once = (id: number) =>
      db
        .transaction(async (tx: any) => {
          await lockAuthState(tx, 'admin', id);
          await new Promise((r) => setTimeout(r, 15));
        })
        .catch((e: any) => errors.push(e?.code ?? String(e)));
    //* 처음 만드는 행(10~29)과 이미 있는 행(10~29 두 번째 바퀴) 둘 다
    for (let round = 0; round < 2; round++)
      for (let id = 10; id < 30; id++) await Promise.all([once(id), once(id)]);
    expect(errors).toEqual([]);
    const [rows] = await admin.query(
      "SELECT COUNT(*) AS n FROM tn_admin_auth_state WHERE aas_kind = 'admin' AND aas_account_id BETWEEN 10 AND 29",
    );
    expect((rows as { n: number }[])[0].n).toBe(20);
  });

  it('세션 끊기: 그 계정의 살아 있는 행만 끊고, 남길 해시 하나는 그대로 둔다', async () => {
    await admin.query(`
      INSERT INTO tn_admin_session (ses_token_hash, ses_kind, ses_account_id, ses_state, ses_expires_at, ses_revoked_at) VALUES
        (REPEAT('a', 64), 'sales', 4, 'ACTIVE', 9999999999, NULL),
        (REPEAT('b', 64), 'sales', 4, 'PENDING_PW', 9999999999, NULL),
        (REPEAT('c', 64), 'sales', 4, 'ACTIVE', 9999999999, NULL),
        (REPEAT('d', 64), 'sales', 4, 'ACTIVE', 9999999999, 5),
        (REPEAT('e', 64), 'sales', 5, 'ACTIVE', 9999999999, NULL),
        (REPEAT('f', 64), 'admin', 4, 'ACTIVE', 9999999999, NULL)`);
    await db.transaction((tx: any) =>
      revokeSessions(tx, 'sales', 4, 'c'.repeat(64)),
    );
    const [rows] = await admin.query(
      'SELECT LEFT(ses_token_hash, 1) AS h, ses_revoked_at AS r FROM tn_admin_session ORDER BY h',
    );
    const got = (rows as { h: string; r: number | null }[]).map(
      (x) => `${x.h}:${x.r === null ? 'live' : x.r === 5 ? 'old' : 'revoked'}`,
    );
    expect(got).toEqual(['a:revoked', 'b:revoked', 'c:live', 'd:old', 'e:live', 'f:live']);
  });

  //* 표지의 감사 행은 같은 tx 로 적재된다 — 가짜 적재가 받은 tx 와 던질 때의 롤백을 본다(감사 표는 이 DB 에 없다)
  const flagBy = (append: (tx: unknown) => Promise<void>) =>
    ({ audit: { appendAuthSession: append }, ctx: { actor: {}, requestId: null, ip: null, userAgent: null } }) as any;

  it('비밀번호 변경 표지: 감사 적재가 던지면 표지도 함께 롤백된다', async () => {
    let seen: unknown;
    await expect(
      db.transaction(async (tx: any) => {
        await setPasswordChangeRequired(tx, 'sales', 78, flagBy(async (t) => {
          seen = t;
          throw new Error('audit down');
        }));
      }),
    ).rejects.toThrow('audit down');
    expect(seen).toBeDefined();
    const [rows] = await admin.query(
      "SELECT 1 FROM tn_admin_auth_state WHERE aas_kind = 'sales' AND aas_account_id = 78",
    );
    expect(rows).toEqual([]);
  });

  it('비밀번호 변경 표지: 행이 없으면 만들고, 있으면 표지만 1 로 바꾼다', async () => {
    const ok = flagBy(async () => undefined);
    await db.transaction((tx: any) => setPasswordChangeRequired(tx, 'sales', 77, ok));
    await admin.query(
      "UPDATE tn_admin_auth_state SET aas_fail_count = 3, aas_last_step = 9 WHERE aas_kind = 'admin' AND aas_account_id = 10",
    );
    await db.transaction(async (tx: any) => {
      await lockAuthState(tx, 'admin', 10);
      await setPasswordChangeRequired(tx, 'admin', 10, ok);
    });
    const [rows] = await admin.query(
      "SELECT aas_kind AS k, aas_account_id AS id, aas_pw_change_required AS pw, aas_fail_count AS f, aas_last_step AS st FROM tn_admin_auth_state WHERE (aas_kind, aas_account_id) IN (('sales', 77), ('admin', 10)) ORDER BY k",
    );
    expect(rows).toEqual([
      { k: 'admin', id: 10, pw: 1, f: 3, st: 9 },
      { k: 'sales', id: 77, pw: 1, f: 0, st: 0 },
    ]);
  });
  it('남이 다시 정함: 관리자가 단 표지를 최고관리자가 다시 정하면 내리고, 둘 다 세션을 끊는다 (3차 리뷰 V-SQ-6)', async () => {
    const pw = async () => {
      const [rows] = await admin.query(
        "SELECT aas_pw_change_required AS pw FROM tn_admin_auth_state WHERE aas_kind = 'sales' AND aas_account_id = 79",
      );
      return (rows as { pw: number }[])[0]?.pw;
    };
    const exp = Math.floor(Date.now() / 1000) + 3600;
    await insertSession(db, { sid: 'by-other-1', kind: 'sales', accountId: 79, expiresAt: exp });
    await db.transaction(async (tx: any) => {
      await lockAuthState(tx, 'sales', 79);
      await passwordSetByOther(tx, 'sales', 79, flagBy(async () => undefined));
    });
    expect(await pw()).toBe(1);
    await insertSession(db, { sid: 'by-other-2', kind: 'sales', accountId: 79, expiresAt: exp });
    await db.transaction(async (tx: any) => {
      await lockAuthState(tx, 'sales', 79);
      await passwordSetByOther(tx, 'sales', 79, 'super');
    });
    expect(await pw()).toBe(0);
    const [live] = await admin.query(
      "SELECT COUNT(*) AS n FROM tn_admin_session WHERE ses_kind = 'sales' AND ses_account_id = 79 AND ses_revoked_at IS NULL",
    );
    expect(live).toEqual([{ n: 0 }]);
  });

  const sessions = async (kind: string, id: number) => {
    const [rows] = await admin.query(
      'SELECT ses_token_hash AS h, ses_state AS st, ses_expires_at AS e, ses_revoked_at AS r FROM tn_admin_session WHERE ses_kind = ? AND ses_account_id = ? ORDER BY ses_id',
      [kind, id],
    );
    return rows as { h: string; st: string; e: number; r: number | null }[];
  };

  it('세션 넣기: 해시 · ACTIVE · exp 로 넣고, 그 계정의 만료 행만 지운다', async () => {
    const now = Math.floor(Date.now() / 1000);
    await admin.query(`
      INSERT INTO tn_admin_session (ses_token_hash, ses_kind, ses_account_id, ses_state, ses_expires_at) VALUES
        (REPEAT('1', 64), 'org-manager', 900, 'ACTIVE', ${now - 10}),
        (REPEAT('2', 64), 'org-manager', 900, 'ACTIVE', ${now + 600}),
        (REPEAT('3', 64), 'org-manager', 901, 'ACTIVE', ${now - 10}),
        (REPEAT('4', 64), 'corp-super', 900, 'ACTIVE', ${now - 10})`);
    await insertSession(db, { sid: 'sid-900', kind: 'org-manager', accountId: 900, expiresAt: now + 21600 });
    expect(await sessions('org-manager', 900)).toEqual([
      { h: '2'.repeat(64), st: 'ACTIVE', e: now + 600, r: null },
      { h: tokenHash('sid-900'), st: 'ACTIVE', e: now + 21600, r: null },
    ]);
    expect((await sessions('org-manager', 901)).length).toBe(1);
    expect((await sessions('corp-super', 900)).length).toBe(1);
  });

  //* 만료 행 정리가 범위 DELETE 면 빈 구간에 갭 잠금을 잡아, 두 tx 의 INSERT 가 서로의 갭에 걸려 1213 이 난다(리뷰 R2-1)
  it('세션 넣기 경합: 세션 행 없는 두 계정의 겹친 tx 가 교착하지 않는다', async () => {
    const exp = Math.floor(Date.now() / 1000) + 3600;
    let arrived = 0;
    let release!: () => void;
    const both = new Promise<void>((r) => (release = r));
    //* 첫 문장(정리) 뒤 두 tx 가 모두 올 때까지 멈춘다 — 그다음 INSERT 둘이 겹친다
    const paced = (tx: any) => {
      let first = true;
      return {
        execute: async (q: unknown) => {
          const r = await tx.execute(q);
          if (first) {
            first = false;
            if (++arrived === 2) release();
            await both;
          }
          return r;
        },
      } as any;
    };
    const run = (accountId: number) =>
      db.transaction(async (tx: any) =>
        insertSession(paced(tx), { sid: `race-${accountId}`, kind: 'sales', accountId, expiresAt: exp }),
      );
    const got = await Promise.allSettled([run(930), run(931)]);
    expect(got.map((g) => (g.status === 'rejected' ? (g.reason as any)?.code ?? String(g.reason) : 'ok'))).toEqual(['ok', 'ok']);
    expect((await sessions('sales', 930)).length + (await sessions('sales', 931)).length).toBe(2);
  });

  it('세션 끊기: 같은 tx 에서 넣은 새 행도, 내 스냅숏 뒤 다른 연결이 커밋한 새 행도 놓치지 않는다', async () => {
    const exp = Math.floor(Date.now() / 1000) + 3600;
    const other = await pool.getConnection();
    try {
      await db.transaction(async (tx: any) => {
        await lockAuthState(tx, 'sales', 950);
        //* 일반 읽기로 스냅숏을 세운다. 그 뒤 다른 연결이 커밋한 행은 이 스냅숏에 없다
        await tx.execute(sql`SELECT COUNT(*) FROM tn_admin_session`);
        await other.query(
          "INSERT INTO tn_admin_session (ses_token_hash, ses_kind, ses_account_id, ses_state, ses_expires_at) VALUES (?, 'sales', 950, 'ACTIVE', ?)",
          [tokenHash('other-conn'), exp],
        );
        await insertSession(tx, { sid: 'same-tx', kind: 'sales', accountId: 950, expiresAt: exp });
        await revokeSessions(tx, 'sales', 950);
      });
    } finally {
      other.release();
    }
    const got = await sessions('sales', 950);
    expect(got.map((x) => x.h).sort()).toEqual(
      [tokenHash('same-tx'), tokenHash('other-conn')].sort(),
    );
    expect(got.every((x) => x.r !== null)).toBe(true);
  });
  it('로그아웃 끊기: 그 sid 한 행만 끊고 같은 계정의 다른 행 · 이미 끊긴 행의 시각은 그대로', async () => {
    const exp = Math.floor(Date.now() / 1000) + 3600;
    await admin.query(
      `INSERT INTO tn_admin_session (ses_token_hash, ses_kind, ses_account_id, ses_state, ses_expires_at, ses_revoked_at) VALUES
        (?, 'admin', 960, 'ACTIVE', ?, NULL), (?, 'admin', 960, 'ACTIVE', ?, NULL), (?, 'admin', 960, 'ACTIVE', ?, 7)`,
      [tokenHash('out-1'), exp, tokenHash('out-2'), exp, tokenHash('out-3'), exp],
    );
    expect(await revokeSessionBySid(db, 'out-1')).toBe(1);
    expect(await revokeSessionBySid(db, 'out-3')).toBe(0); //* 이미 끊긴 행 — 로그아웃 감사는 0 이면 남기지 않는다
    expect(await revokeSessionBySid(db, 'out-1')).toBe(0);
    const got = await sessions('admin', 960);
    expect(got.map((x) => [x.h, x.r === null ? 'live' : x.r === 7 ? 'old' : 'revoked'])).toEqual([
      [tokenHash('out-1'), 'revoked'],
      [tokenHash('out-2'), 'live'],
      [tokenHash('out-3'), 'old'],
    ]);
  });
  it('계정 삭제 정리: 그 종류 · 그 번호들의 세션 · 상태 행만 지운다', async () => {
    await admin.query(`
      INSERT INTO tn_admin_session (ses_token_hash, ses_kind, ses_account_id, ses_state, ses_expires_at) VALUES
        (REPEAT('7', 64), 'corp-super', 970, 'ACTIVE', 9999999999),
        (REPEAT('8', 64), 'corp-super', 971, 'PENDING_MFA', 9999999999),
        (REPEAT('9', 64), 'corp-super', 972, 'ACTIVE', 9999999999),
        (REPEAT('0', 64), 'org-manager', 970, 'ACTIVE', 9999999999);
      INSERT INTO tn_admin_auth_state (aas_kind, aas_account_id) VALUES
        ('corp-super', 970), ('corp-super', 972), ('org-manager', 970)`);
    await db.transaction((tx: any) => deleteAuthStateOf(tx, 'corp-super', [970, 971]));
    const [s1] = await admin.query(
      "SELECT ses_kind AS k, ses_account_id AS id FROM tn_admin_session WHERE ses_account_id BETWEEN 970 AND 972 ORDER BY k, id",
    );
    const [s2] = await admin.query(
      "SELECT aas_kind AS k, aas_account_id AS id FROM tn_admin_auth_state WHERE aas_account_id BETWEEN 970 AND 972 ORDER BY k, id",
    );
    expect(s1).toEqual([
      { k: 'corp-super', id: 972 },
      { k: 'org-manager', id: 970 },
    ]);
    expect(s2).toEqual([
      { k: 'corp-super', id: 972 },
      { k: 'org-manager', id: 970 },
    ]);
  });

  //* ── 로그인 코드 · 비밀번호 단계(계획 3.15 · 8.3) ─────────────────────────────

  it('대기 행: 상태 · 로그인 키 · 5분 만료로 넣고 해시로 읽는다, 원문은 표에 없다', async () => {
    const t0 = Math.floor(Date.now() / 1000);
    const v = await db.transaction((tx: any) =>
      insertPending(tx, { kind: 'admin', accountId: 801, state: 'PENDING_MFA', loginKey: '{"d":"admin","l":"A","c":null}' }),
    );
    const p = await readPending(db, v);
    expect(p).toEqual(
      expect.objectContaining({ kind: 'admin', accountId: 801, state: 'PENDING_MFA', revokedAt: null, loginKey: '{"d":"admin","l":"A","c":null}' }),
    );
    expect(p!.expiresAt - t0).toBeGreaterThanOrEqual(300);
    expect(p!.expiresAt - t0).toBeLessThanOrEqual(301);
    const [raw] = await admin.query('SELECT COUNT(*) AS n FROM tn_admin_session WHERE ses_token_hash = ?', [v]);
    expect((raw as { n: number }[])[0].n).toBe(0);
  });

  it('대기 소비: 동시 두 번 중 한 번만 1 (조건부 갱신)', async () => {
    const v = await db.transaction((tx: any) =>
      insertPending(tx, { kind: 'admin', accountId: 802, state: 'PENDING_MFA', loginKey: '{}' }),
    );
    const { id } = (await readPending(db, v))!;
    const got = await Promise.all(
      [0, 1].map(() =>
        db.transaction(async (tx: any) => {
          const n = await consumePending(tx, id);
          await new Promise((r) => setTimeout(r, 20));
          return n;
        }),
      ),
    );
    expect([...got].sort()).toEqual([0, 1]);
  });

  it('코드 실패: 4회까지 셈 · 5회째 15분 대기에 들며 실패 수 0 (D8), 그 뒤 한 번은 1', async () => {
    const now = 1_700_000_000;
    const fail = (t: number) =>
      db.transaction(async (tx: any) => recordCodeFailure(tx, await lockAuthState(tx, 'sales', 803), t));
    for (let i = 1; i <= 4; i++) expect(await fail(now)).toBe(false);
    const st = async () =>
      ((await admin.query("SELECT aas_fail_count AS f, aas_locked_until AS u FROM tn_admin_auth_state WHERE aas_kind = 'sales' AND aas_account_id = 803"))[0] as any[])[0];
    expect(await st()).toEqual({ f: 4, u: null });
    expect(await fail(now)).toBe(true);
    expect(await st()).toEqual({ f: 0, u: now + 900 });
    expect(await fail(now + 1000)).toBe(false);
    expect(await st()).toEqual({ f: 1, u: now + 900 });
  });

  it('단계 소비: 앞선 단계만 1 · 같은 단계 재사용 0 · 두 연결 동시에 같은 단계면 한쪽만 1, 실패 수 · 대기를 지운다', async () => {
    await admin.query(
      "INSERT INTO tn_admin_auth_state (aas_kind, aas_account_id, aas_last_step, aas_fail_count, aas_locked_until) VALUES ('admin', 804, 100, 3, 5)",
    );
    const take = (step: number) => db.transaction((tx: any) => consumeStep(tx, 'admin', 804, step));
    expect(await take(100)).toBe(0);
    expect(await take(101)).toBe(1);
    expect(await take(101)).toBe(0);
    expect([...(await Promise.all([take(102), take(102)]))].sort()).toEqual([0, 1]);
    const [rows] = await admin.query(
      "SELECT aas_last_step AS s, aas_fail_count AS f, aas_locked_until AS u FROM tn_admin_auth_state WHERE aas_kind = 'admin' AND aas_account_id = 804",
    );
    expect(rows).toEqual([{ s: 102, f: 0, u: null }]);
  });

  //* 리뷰 R2 — 비밀번호 변경 tx 가 커밋 전일 때 옛 비밀번호로 bcrypt 를 통과한 로그인이 들어오면, 계정 상태 잠금을 기다렸다가
  //*  원천 해시를 다시 읽고 null 을 받는다(세션 행을 넣지 않는다). 잠금만 있고 재대조가 없으면 변경 커밋 뒤 그대로 행을 넣는다.
  it('로그인 재대조: 겹친 비밀번호 변경이 커밋되면 옛 해시의 로그인은 null, 새 해시는 상태 행', async () => {
    await admin.query("INSERT INTO tn_admin (adm_id, adm_password) VALUES (805, 'old-hash')");
    let release!: () => void;
    const held = new Promise<void>((r) => (release = r));
    let locked!: () => void;
    const gotLock = new Promise<void>((r) => (locked = r));
    const change = db.transaction(async (tx: any) => {
      await lockAuthState(tx, 'admin', 805);
      await writeOwnPassword(tx, 'admin', 805, 'new-hash');
      locked();
      await held;
    });
    await gotLock;
    const signin = db.transaction((tx: any) => beginSignin(tx, 'admin', 805, 'old-hash'));
    await new Promise((r) => setTimeout(r, 100));
    release();
    await change;
    expect(await signin).toBeNull();
    const st = await db.transaction((tx: any) => beginSignin(tx, 'admin', 805, 'new-hash'));
    expect(st).toEqual(expect.objectContaining({ aasKind: 'admin', aasAccountId: 805, aasPwChangeRequired: 0 }));
  });

  //* 잠금 순서(계획 3.15 · 로그 #7 남은 문제) — 조직 관리자 로그인은 「인증 상태 행 → 회원 행(공유)」. 기업FC 단건 수정도 같은 순서면
  //*  기다릴 뿐 교착하지 않고, 회원 행을 먼저 잡던 옛 순서면 InnoDB 가 한쪽을 교착으로 끊는다. 두 순서를 같은 끼어들기로 재현한다.
  const editVsSignin = async (memId: number, authFirst: boolean) => {
    await admin.query('INSERT INTO tn_member (mem_id, mem_password) VALUES (?, ?)', [memId, 'h']);
    let step!: () => void;
    const first = new Promise<void>((r) => (step = r));
    let go!: () => void;
    const gate = new Promise<void>((r) => (go = r));
    const errors: string[] = [];
    const edit = db
      .transaction(async (tx: any) => {
        if (authFirst) await lockAuthState(tx, 'org-manager', memId);
        await tx.execute(sql`SELECT mem_id FROM tn_member WHERE mem_id = ${memId} FOR UPDATE`);
        step();
        await gate;
        await tx.execute(sql`UPDATE tn_member SET mem_name = 'x' WHERE mem_id = ${memId}`);
        if (!authFirst) await lockAuthState(tx, 'org-manager', memId);
      })
      .catch((e: any) => errors.push(`edit:${e?.code ?? e}`));
    await first;
    const signin = db
      .transaction((tx: any) => beginSignin(tx, 'org-manager', memId, 'h'))
      .catch((e: any) => errors.push(`signin:${e?.code ?? e}`));
    await new Promise((r) => setTimeout(r, 100));
    go();
    await Promise.all([edit, signin]);
    return errors;
  };

  it('잠금 순서: 기업FC 수정이 인증 상태 행을 먼저 잡으면 조직 관리자 로그인과 교착하지 않는다', async () => {
    expect(await editVsSignin(806, true)).toEqual([]);
  });

  it('잠금 순서(옛 순서 재현): 회원 행을 먼저 잡으면 한쪽이 ER_LOCK_DEADLOCK', async () => {
    const errors = await editVsSignin(807, false);
    expect(errors.some((e) => e.endsWith('ER_LOCK_DEADLOCK'))).toBe(true);
  });

  it('2차 인증 칸 쓰기: 그 종류 · 그 번호 행만 바꾸고, 원천 해시 · 회원 아이디를 읽는다', async () => {
    await admin.query("INSERT INTO tn_admin (adm_id, adm_password) VALUES (901, 'h901')");
    await admin.query("INSERT INTO tn_member (mem_id, mem_password, mem_login_id) VALUES (901, 'm901', 'org901')");
    await db.transaction(async (tx: any) => {
      await lockAuthState(tx, 'admin', 901);
      await lockAuthState(tx, 'org-manager', 901);
      await updateAuthState(tx, 'admin', 901, { aasSecretEnc: 'v1.a', aasPendingEnc: 'v1.b', aasPendingExpiresAt: 5 });
    });
    const [rows] = await admin.query(
      'SELECT aas_kind AS k, aas_secret_enc AS s, aas_pending_enc AS p, aas_pending_expires_at AS e FROM tn_admin_auth_state WHERE aas_account_id = 901 ORDER BY aas_kind',
    );
    expect(rows).toEqual([
      { k: 'admin', s: 'v1.a', p: 'v1.b', e: 5 },
      { k: 'org-manager', s: null, p: null, e: null },
    ]);
    expect(await readPasswordHash(db, 'admin', 901)).toBe('h901');
    expect(await readPasswordHash(db, 'org-manager', 901)).toBe('m901');
    expect(await readPasswordHash(db, 'admin', 999)).toBeNull();
    expect(await loginLabelOf(db, 'org-manager', { memId: 901, agcLoginId: 'corp' })).toBe('org901');
    expect(await loginLabelOf(db, 'org-manager', { memId: 999 })).toBeNull();
    expect(await loginLabelOf(db, 'admin', { admLoginId: 'adm' })).toBe('adm');
  });

  it('회원 이름 읽기(/admin/me account.memName): 그 번호 행의 mem_name, 행 없음 null', async () => {
    await admin.query("INSERT INTO tn_member (mem_id, mem_password, mem_name) VALUES (902, 'm902', '조직장')");
    expect(await readMemberName(db, 902)).toBe('조직장');
    expect(await readMemberName(db, 999)).toBeNull();
  });

  it('켜짐 · 대기 읽기: 사용 중 시드가 있을 때만 참, 후보만 · 행 없음은 거짓 · 대기는 그 행의 값', async () => {
    await admin.query(`INSERT INTO tn_admin_auth_state (aas_kind, aas_account_id, aas_secret_enc, aas_pending_enc, aas_locked_until)
      VALUES ('sales', 902, 'v1.s', NULL, 4000000000), ('sales', 903, NULL, 'v1.p', NULL)`);
    expect(await readMfaState(db, 'sales', 902)).toEqual({ enabled: true, lockedUntil: 4_000_000_000 });
    expect(await readMfaState(db, 'sales', 903)).toEqual({ enabled: false, lockedUntil: null });
    expect(await readMfaState(db, 'admin', 902)).toEqual({ enabled: false, lockedUntil: null });
  });

  it('계정 있음: 그 종류의 원천 표에 그 번호 행이 있을 때만 참 (리뷰 F4)', async () => {
    await admin.query(`INSERT INTO tn_admin (adm_id, adm_password) VALUES (906, 'h')`);
    expect(await accountExists(db, 'admin', 906)).toBe(true);
    expect(await accountExists(db, 'admin', 907)).toBe(false);
    expect(await accountExists(db, 'org-manager', 906)).toBe(false);
  });

  it('코드 대기 시각 읽기: 그 종류 · 번호 행의 값, 대기 없음 · 행 없음은 null (리뷰 F3)', async () => {
    await admin.query(`INSERT INTO tn_admin_auth_state (aas_kind, aas_account_id, aas_locked_until)
      VALUES ('sales', 904, 4000000000), ('sales', 905, NULL)`);
    expect(await readCodeLockedUntil(db, 'sales', 904)).toBe(4_000_000_000);
    expect(await readCodeLockedUntil(db, 'sales', 905)).toBeNull();
    expect(await readCodeLockedUntil(db, 'admin', 904)).toBeNull();
  });

  it('로그인 코드 단계 합성(실 tx): 틀린 코드는 E0104 뒤 실패 수가 커밋돼 있고, 맞는 코드는 대기를 소비하고 세션을 ACTIVE 로 넣는다 (리뷰 F7)', async () => {
    const key = Buffer.alloc(32, 9);
    const seed = Buffer.alloc(20, 3);
    process.env.ADMIN_MFA_KEY = key.toString('base64');
    await admin.query(
      "INSERT INTO tn_admin_auth_state (aas_kind, aas_account_id, aas_secret_enc) VALUES ('admin', 980, ?)",
      [seal(key, seed, 'admin', 980)],
    );
    const pv = await insertPending(db, {
      kind: 'admin',
      accountId: 980,
      state: 'PENDING_MFA',
      loginKey: JSON.stringify({ d: 'admin', l: 'it980', c: null }),
    });
    const signed: Record<string, unknown>[] = [];
    const quiet = { setContext: jest.fn(), error: jest.fn(), warn: jest.fn(), info: jest.fn() };
    const service = new AdminAuthService(
      { getAdminByLoginId: async () => ({ admId: 980, admLoginId: 'it980', admPassword: 'h' }) } as any,
      { sign: (p: Record<string, unknown>) => (signed.push(p), 'tok'), decode: () => ({ exp: 4_000_000_000 }) } as any,
      { get: () => 'x' } as any,
      { appendAuthSession: async () => undefined, appendSecurity: async () => undefined } as any,
      db,
      { isSuperAdmin: async () => false, carViewRowAllowed: async () => false } as any,
      quiet as any,
    );
    try {
      const step = Math.floor(Date.now() / 1000 / 30);
      const wrong = ['000000', '111111', '222222'].find(
        (c) => c !== hotp(seed, step) && c !== hotp(seed, step - 1) && c !== hotp(seed, step + 1),
      )!;
      const e = await service.verifyCode(pv, wrong).catch((x) => x);
      expect([e?.getStatus?.(), e?.getResponse?.().code]).toEqual([401, 'E0104']);
      const [[st]] = (await admin.query(
        "SELECT aas_fail_count AS n FROM tn_admin_auth_state WHERE aas_kind = 'admin' AND aas_account_id = 980",
      )) as any;
      expect(st.n).toBe(1); //* 던지기 전에 커밋됐다
      expect((await readPending(db, pv))?.revokedAt).toBeNull();

      const r = await service.verifyCode(pv, hotp(seed, Math.floor(Date.now() / 1000 / 30)));
      expect(r.accessToken).toBe('tok');
      expect((await readPending(db, pv))?.revokedAt).not.toBeNull();
      expect(await readPending(db, signed[0].sid as string)).toEqual(
        expect.objectContaining({ kind: 'admin', accountId: 980, state: 'ACTIVE', revokedAt: null }),
      );
      const [[after]] = (await admin.query(
        "SELECT aas_fail_count AS n FROM tn_admin_auth_state WHERE aas_kind = 'admin' AND aas_account_id = 980",
      )) as any;
      expect(after.n).toBe(0);
    } finally {
      delete process.env.ADMIN_MFA_KEY;
    }
  });

  it('로그인 잠금 경합(실 tx): 앞 검사를 지난 맞는 요청이 동시 틀린 30건의 잠금 뒤에 끝나면 429 · 401 은 10건 미만 (리뷰 F-1)', async () => {
    const hash = hashSync('Right1234!', 4);
    await admin.query('INSERT INTO tn_admin (adm_id, adm_password) VALUES (996, ?)', [hash]);
    let entered!: () => void;
    const atLookup = new Promise<void>((r) => (entered = r));
    let release!: () => void;
    const gate = new Promise<void>((r) => (release = r));
    let calls = 0;
    const quiet = { setContext: jest.fn(), error: jest.fn(), warn: jest.fn(), info: jest.fn() };
    const service = new AdminAuthService(
      {
        //* 첫 호출(맞는 요청)만 앞 잠금 검사 뒤 여기서 기다린다
        getAdminByLoginId: async () => {
          if (calls++ === 0) {
            entered();
            await gate;
          }
          return { admId: 996, admLoginId: 'race996', admPassword: hash };
        },
      } as any,
      { sign: () => 'tok', decode: () => ({ exp: 4_000_000_000 }) } as any,
      { get: () => 'x' } as any,
      { appendAuthSession: async () => undefined, appendSecurity: async () => undefined } as any,
      db,
      { isSuperAdmin: async () => false, carViewRowAllowed: async () => false } as any,
      quiet as any,
    );
    const status = (p: Promise<unknown>) =>
      p.then(
        () => 201,
        (e: any) => e?.getStatus?.() ?? String(e),
      );
    const right = status(service.signin({ admLoginId: 'race996', admPassword: 'Right1234!' }));
    await atLookup;
    const wrong = await Promise.all(
      Array.from({ length: 30 }, () =>
        status(service.signin({ admLoginId: 'race996', admPassword: 'bad' })),
      ),
    );
    release();
    expect(await right).toBe(429);
    expect(wrong.every((s) => s === 401 || s === 429)).toBe(true);
    expect(wrong.filter((s) => s === 401).length).toBeLessThan(10);
    const [[ses]] = (await admin.query(
      "SELECT COUNT(*) AS n FROM tn_admin_session WHERE ses_kind = 'admin' AND ses_account_id = 996",
    )) as any;
    expect(Number(ses.n)).toBe(0);
    expect(await readLockedUntil(db, 'admin:race996')).not.toBeNull();
  });

  it('로그인 재대조(실 tx): 조회 뒤 원천 행이 지워졌으면 401 · 인증 상태 행을 남기지 않는다', async () => {
    const hash = hashSync('Right1234!', 4);
    const quiet = { setContext: jest.fn(), error: jest.fn(), warn: jest.fn(), info: jest.fn() };
    const service = new AdminAuthService(
      //* 조회는 행을 보았지만 tn_admin 에는 997 행이 없다 — 조회와 tx 사이에 삭제가 커밋된 상태
      { getAdminByLoginId: async () => ({ admId: 997, admLoginId: 'gone997', admPassword: hash }) } as any,
      { sign: () => 'tok', decode: () => ({ exp: 4_000_000_000 }) } as any,
      { get: () => 'x' } as any,
      { appendAuthSession: async () => undefined, appendSecurity: async () => undefined } as any,
      db,
      { isSuperAdmin: async () => false, carViewRowAllowed: async () => false } as any,
      quiet as any,
    );
    const e = await service.signin({ admLoginId: 'gone997', admPassword: 'Right1234!' }).catch((x) => x);
    expect(e?.getStatus?.()).toBe(401);
    const [[st]] = (await admin.query(
      "SELECT COUNT(*) AS n FROM tn_admin_auth_state WHERE aas_kind = 'admin' AND aas_account_id = 997",
    )) as any;
    expect(Number(st.n)).toBe(0);
  });

  //* 마이페이지 비밀번호 변경(75 5절) — 실 drizzle 로 MfaService 를 세운다(지정 · 감사는 가짜, 비밀번호 변경은 둘 다 읽지 않는다)
  const pwService = () =>
    new MfaService(db, {} as any, { appendAuthSession: async () => undefined } as any);
  const pwCtx = { actor: {}, requestId: null, ip: null, userAgent: null } as any;
  const admHash = async (id: number) => {
    const [[r]] = (await admin.query('SELECT adm_password AS h FROM tn_admin WHERE adm_id = ?', [id])) as any;
    return r.h as string;
  };

  it('마이페이지 비밀번호 변경(실 tx): 새 해시 · 지금 세션만 남기고 끊기 · 실패 수는 그대로(리뷰 S2)', async () => {
    await admin.query('INSERT INTO tn_admin (adm_id, adm_password) VALUES (990, ?)', [hashSync('Old1234!', 4)]);
    await admin.query("INSERT INTO tn_admin_auth_state (aas_kind, aas_account_id, aas_fail_count) VALUES ('admin', 990, 2)");
    for (const sid of ['pw-me', 'pw-other']) await insertSession(db, { sid, kind: 'admin', accountId: 990, expiresAt: 4_000_000_000 });
    const r = await pwService().changePassword({ role: 'admin', admId: 990, sid: 'pw-me' } as any, 'Old1234!', 'New1234!', pwCtx);
    expect(r).toEqual({ changed: true });
    expect(compareSync('New1234!', await admHash(990))).toBe(true);
    expect((await readPending(db, 'pw-me'))?.revokedAt).toBeNull();
    expect((await readPending(db, 'pw-other'))?.revokedAt).not.toBeNull();
    const [[st]] = (await admin.query(
      "SELECT aas_fail_count AS n FROM tn_admin_auth_state WHERE aas_kind = 'admin' AND aas_account_id = 990",
    )) as any;
    expect(st.n).toBe(2);
  });

  it('다른 곳 모두 로그아웃(실 tx): 지금 · 다른 ACTIVE · 다른 만료 + 대기 행에서 revoked 1, 지금 세션만 산다 (리뷰 N-4)', async () => {
    await admin.query('INSERT INTO tn_admin (adm_id, adm_password) VALUES (989, ?)', [hashSync('Cur1234!', 4)]);
    for (const sid of ['ro-me', 'ro-other']) await insertSession(db, { sid, kind: 'admin', accountId: 989, expiresAt: 4_000_000_000 });
    const pv = await insertPending(db, { kind: 'admin', accountId: 989, state: 'PENDING_MFA', loginKey: '{}' });
    //* 만료 행은 마지막에 직접 — insertSession 은 넣기 전에 그 계정의 만료 행을 지운다
    await admin.query(
      "INSERT INTO tn_admin_session (ses_token_hash, ses_kind, ses_account_id, ses_state, ses_expires_at) VALUES (?, 'admin', 989, 'ACTIVE', 1)",
      [tokenHash('ro-expired')],
    );
    const me = tokenHash('ro-me');
    expect(await countOtherSessions(db, 'admin', 989, me)).toBe(1);
    const audits: any[] = [];
    const svc = new MfaService(
      db,
      { readDesignation: async () => ({ state: 'none' }) } as any,
      { appendAuthSession: async (_t: unknown, e: unknown) => void audits.push(e) } as any,
    );
    const r = await svc.revokeOthers({ role: 'admin', admId: 989, sid: 'ro-me' } as any, 'Cur1234!', pwCtx);
    expect(r).toEqual({ revoked: 1 });
    expect((await readPending(db, 'ro-me'))?.revokedAt).toBeNull();
    expect((await readPending(db, 'ro-other'))?.revokedAt).not.toBeNull();
    expect((await readPending(db, pv))?.revokedAt).not.toBeNull();
    expect(await countOtherSessions(db, 'admin', 989, me)).toBe(0);
    expect(audits.map((a) => [a.action, a.changes])).toEqual([['session.revoked.others', { kind: 'admin', revoked: 1 }]]);
  });

  it('마이페이지 비밀번호 변경 경합: 잠금 읽기 뒤 대기가 커밋되면 틀린 추측 · 맞는 같은 값 모두 429 — E0115 0건(리뷰 S1)', async () => {
    await admin.query('INSERT INTO tn_admin (adm_id, adm_password) VALUES (992, ?)', [hashSync('Old1234!', 4)]);
    let release!: () => void;
    const held = new Promise<void>((res) => (release = res));
    let locked!: () => void;
    const gotLock = new Promise<void>((res) => (locked = res));
    //* 병렬 요청이 잠금 없는 선판정을 다 지난 뒤 다섯째 실패가 대기를 커밋한 꼴
    const other = db.transaction(async (tx: any) => {
      await lockAuthState(tx, 'admin', 992);
      await tx.execute(
        sql`UPDATE tn_admin_auth_state SET aas_locked_until = ${Math.floor(Date.now() / 1000) + 900} WHERE aas_kind = 'admin' AND aas_account_id = 992`,
      );
      locked();
      await held;
    });
    await gotLock;
    const who = { role: 'admin', admId: 992, sid: 'x' } as any;
    const tries = [...['Bad1!', 'Bad2!', 'Bad3!'].map((c) => [c, 'Old1234!']), ['Old1234!', 'Old1234!']].map(([cur, next]) =>
      pwService().changePassword(who, cur, next, pwCtx).then(() => 'ok', (e: any) => `${e?.getStatus?.()}:${e?.getResponse?.().code}`),
    );
    await new Promise((res) => setTimeout(res, 300));
    release();
    await other;
    expect(await Promise.all(tries)).toEqual(Array(4).fill('429:E0105'));
    expect(compareSync('Old1234!', await admHash(992))).toBe(true);
  });

  it('마이페이지 비밀번호 변경 경합: 확인 뒤 다른 변경이 먼저 커밋되면 400 E0109 · 그 변경이 남고 실패 수는 그대로', async () => {
    await admin.query('INSERT INTO tn_admin (adm_id, adm_password) VALUES (991, ?)', [hashSync('Old1234!', 4)]);
    let release!: () => void;
    const held = new Promise<void>((res) => (release = res));
    let locked!: () => void;
    const gotLock = new Promise<void>((res) => (locked = res));
    const other = db.transaction(async (tx: any) => {
      await lockAuthState(tx, 'admin', 991);
      await writeOwnPassword(tx, 'admin', 991, 'other-hash');
      locked();
      await held;
    });
    await gotLock;
    const mine = pwService()
      .changePassword({ role: 'admin', admId: 991, sid: 'x' } as any, 'Old1234!', 'New1234!', pwCtx)
      .catch((e: any) => e);
    await new Promise((res) => setTimeout(res, 150));
    release();
    await other;
    const e = await mine;
    expect([e?.getStatus?.(), e?.getResponse?.().code]).toEqual([400, 'E0109']);
    expect(await admHash(991)).toBe('other-hash');
    const [[st]] = (await admin.query(
      "SELECT aas_fail_count AS n FROM tn_admin_auth_state WHERE aas_kind = 'admin' AND aas_account_id = 991",
    )) as any;
    expect(st.n).toBe(0);
  });

  it('운영 명령: 지정 root 만 켜고(코드 확인 · 단계 소비 · 전 세션 끊기 · 감사) 초기화한다, 다른 계정은 1', async () => {
    const key = Buffer.alloc(32, 7);
    process.env.ADMIN_MFA_KEY = key.toString('base64');
    await admin.query(`
      ALTER TABLE tn_admin ADD COLUMN adm_login_id VARCHAR(50) NULL, ADD COLUMN adm_is_super TINYINT NULL;
      CREATE TABLE tn_admin_audit (aud_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, aud_line_version TINYINT UNSIGNED NOT NULL,
        aud_actor_role VARCHAR(16) NOT NULL, aud_actor_label VARCHAR(64), aud_action VARCHAR(64) NOT NULL,
        aud_outcome VARCHAR(16) NOT NULL DEFAULT 'COMMITTED', aud_target_type VARCHAR(32) NOT NULL, aud_target_id INT UNSIGNED,
        aud_target_count INT UNSIGNED NOT NULL DEFAULT 1, aud_changes TEXT);
      INSERT INTO tn_admin (adm_id, adm_password, adm_login_id, adm_is_super) VALUES (950, 'h', 'root', 1), (951, 'h', 'other', NULL)`);
    await insertSession(db, { sid: 'root-live', kind: 'admin', accountId: 950, expiresAt: 4_000_000_000 });
    const conn = await mysql.createConnection({ ...cfg(), database: DBNAME });
    const lines: string[] = [];
    const quiet = jest.spyOn(console, 'error').mockImplementation(() => undefined);
    try {
      //* 설정 키(base32)를 되읽어 지금 단계 코드를 만든다 — 명령 밖으로 시드가 나가는 길은 이 한 줄뿐이다
      const ask = async () => {
        const b32 = lines.find((l) => l.startsWith('설정 키: '))!.slice(6);
        let bits = 0, v = 0;
        const out: number[] = [];
        for (const ch of b32) {
          v = (v << 5) | 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'.indexOf(ch);
          bits += 5;
          if (bits >= 8) { out.push((v >>> (bits - 8)) & 255); bits -= 8; }
        }
        return hotp(Buffer.from(out), Math.floor(Date.now() / 1000 / 30));
      };
      const log = (l: string) => lines.push(l);
      expect(await runOps(conn, { mode: 'enable', admId: 951, operator: 'op' }, ask, log)).toBe(1);
      //* 켠 계정 봉인 행 하나를 지금 키로 열어 본다(3차 리뷰 V-GP-3) — 앞 시험의 봉인 행을 지우고 다른 키 → 이 키 순서로 둔다
      await admin.query('DELETE FROM tn_admin_auth_state WHERE aas_secret_enc IS NOT NULL');
      const sealedBy = (k: Buffer) => seal(k, Buffer.alloc(20, 1), 'sales', 952);
      await admin.query("INSERT INTO tn_admin_auth_state (aas_kind, aas_account_id, aas_secret_enc) VALUES ('sales', 952, ?)", [
        sealedBy(Buffer.alloc(32, 8)),
      ]);
      expect(await runOps(conn, { mode: 'enable', admId: 950, operator: 'op' }, ask, log)).toBe(1);
      await admin.query("UPDATE tn_admin_auth_state SET aas_secret_enc = ? WHERE aas_kind = 'sales' AND aas_account_id = 952", [
        sealedBy(key),
      ]);
      expect(await runOps(conn, { mode: 'enable', admId: 950, operator: 'op' }, ask, log)).toBe(0);
      const [[st]] = (await admin.query(
        "SELECT aas_secret_enc AS s, aas_last_step AS t FROM tn_admin_auth_state WHERE aas_kind = 'admin' AND aas_account_id = 950",
      )) as any;
      expect(openSeal(key, st.s, 'admin', 950).length).toBe(20);
      expect(Math.abs(st.t - Math.floor(Date.now() / 1000 / 30))).toBeLessThanOrEqual(1); //* 단계 소비
      expect((await readPending(db, 'root-live'))?.revokedAt).not.toBeNull();
      //* 이미 켜짐 → 1, 초기화 → 시드 비움
      expect(await runOps(conn, { mode: 'enable', admId: 950, operator: 'op' }, ask, log)).toBe(1);
      //* 초기화는 지정 root 로그인 아이디를 쳐야 한다(3차 리뷰 V-GP-3)
      expect(await runOps(conn, { mode: 'reset', admId: 950, operator: 'op' }, async () => 'other', log)).toBe(1);
      expect(await runOps(conn, { mode: 'reset', admId: 950, operator: 'op' }, async () => 'root', log)).toBe(0);
      const [[after]] = (await admin.query(
        "SELECT aas_secret_enc AS s FROM tn_admin_auth_state WHERE aas_kind = 'admin' AND aas_account_id = 950",
      )) as any;
      expect(after.s).toBeNull();
      const [audits] = await admin.query(
        'SELECT aud_actor_role AS r, aud_actor_label AS l, aud_action AS a, aud_target_type AS t, aud_target_id AS i FROM tn_admin_audit ORDER BY aud_id',
      );
      expect(audits).toEqual([
        { r: 'SYSTEM', l: 'op', a: 'mfa.enable', t: 'session', i: 950 },
        { r: 'SYSTEM', l: 'op', a: 'mfa.reset', t: 'session', i: 950 },
      ]);
    } finally {
      quiet.mockRestore();
      await conn.end();
      delete process.env.ADMIN_MFA_KEY;
    }
  });
});
