/**
 * portal-auth P2 測試（design §2/§4/§5，Gitea #24/#25）
 *
 * 覆蓋（=tasks.md P2 測試項）：
 *   1. KDF：pbkdf2-sha256$100000$… 格式（CF Workers runtime 上限 100k，2026-07-14 真雲實撞）、
 *      驗證對錯、壞格式誠實 false、600k 舊 hash 相容（迭代數從儲存值解析）
 *   2. bootstrap 閘：無 console session → 401；建 admin 寫進 **KBDB**；已有 admin → 409
 *   3. 登入對錯：成功發 token（回應**無租戶字串**）、密碼錯 401、停用 403、未知 email 401
 *   4. 節流：5 次失敗 → 429（KV TTL 計數）
 *   5. session：每請求回讀 record；停用即拒（既有 session 立即失效）
 *   6. 改密碼：驗舊密；新 hash 以 100k 格式落 slot
 *   7. role 閘：非 admin 打 admin 端點 → 403；admin 列表**剝除 password_hash**
 *
 * 2026-08-14 起（D61 補充，leo confirm「走C」，pending-changes.md「認證儲存要不要搬回
 * D1/KV」）：帳號的家從認證儲存（CF Workers Secrets，D61）改回 **KBDB**（binding，走 base
 * HTTP API，不需要外部 CF token）。額外覆蓋：
 *   8. 整台實例沒有任何認證資料 → 登入回「讀不到認證資料」（不是密碼錯），且不計入鎖定
 *   9. 舊實例相容：帳號只存在認證儲存（D61 舊家）時仍登得進去，登入成功後自動搬進 KBDB，
 *      且**這次登入發出的 session 就直接指向新 record_id**；搬遷失敗（KBDB 拒寫）不擋登入
 *
 * KBDB 打 fetchMock 假 host（wrangler.test.toml KBDB_BASE_URL=https://kbdb.test）＋
 * disableNetConnect——絕不外連。子 namespace 隔離的「搜 email 搜不到」由本機雙 worker
 * 端到端 curl 驗證（PR 驗收證據表），這裡驗「寫入時 owner_id=leo::portal」的機械事實。
 */
import { SELF, env, fetchMock } from 'cloudflare:test';
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
import { hashPassword, verifyPassword, PBKDF2_ITERATIONS } from '../src/lib/portal-auth';
import { PORTAL_TEMPLATE_SEEDS } from '../src/lib/portal-seeds';
import { AUTH_ID_PREFIX, isAuthStoreId, mutateAuthStore } from '../src/lib/portal-auth-store';
import { portalRouter } from '../src/routes/portal';
import type { Bindings, ExecutionContext } from '../src/types';

const KBDB = 'https://kbdb.test';
const CF_API = 'https://api.cloudflare.com';
const NS = 'leo::portal'; // wrangler.test.toml CONSOLE_TENANT=leo → 子 namespace
const EMAIL = 'user@example.com';
const PASSWORD = 'correct-horse-9';

// 測試用低迭代 hash（verify 從儲存格式解析 iterations → 舊/低參數 hash 也驗得動＝漸進遷移特性）
let storedHash: string;

beforeAll(async () => {
  fetchMock.activate();
  fetchMock.disableNetConnect();
  storedHash = await hashPassword(PASSWORD, 10_000);
});
afterEach(() => fetchMock.assertNoPendingInterceptors());

function json(method: string, path: string, body?: unknown, headers: Record<string, string> = {}) {
  return SELF.fetch(`http://localhost${path}`, {
    method,
    headers: { 'Content-Type': 'application/json', ...headers },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
}

// ── KBDB mock helpers ──────────────────────────────────────────────────────

/**
 * head entry 查找（GET /entries?page_name=…&entry_type=portal_user&owner_id=ns&limit=1）。
 * `times`：`findUserRecordId`／`promoteToKbdb` 都可能各查一次同一個 email，需要幾次命中就傳幾。
 */
function mockHeadLookup(email: string, recordId: string | null, times = 1) {
  const needle = new URLSearchParams({ page_name: email }).toString();
  fetchMock
    .get(KBDB)
    .intercept({
      path: (p: string) =>
        p.startsWith('/entries?') && p.includes(needle) && p.includes(encodeURIComponent(NS)),
      method: 'GET',
    })
    .reply(200, { success: true, entries: recordId ? [{ content: recordId }] : [], count: recordId ? 1 : 0, total: recordId ? 1 : 0 })
    .times(times);
}

/** 建 portal_user 的 KBDB 寫入路徑（POST /records + POST /entries head entry）。 */
function mockCreateUser(recordId: string): { recordBody: () => string } {
  let recordBody = '';
  fetchMock
    .get(KBDB)
    .intercept({ path: '/records', method: 'POST' })
    .reply(200, (opts) => {
      recordBody = String(opts.body);
      return { success: true, record: { record_id: recordId, template_id: 'tpl_pu', values: {} } };
    });
  fetchMock
    .get(KBDB)
    .intercept({ path: '/entries', method: 'POST' })
    .reply(200, { success: true, entry: { id: `${recordId}_head` } });
  return { recordBody: () => recordBody };
}

/** POST /records 建立失敗（模擬 KBDB 拒寫，用於「搬遷失敗不擋登入」的測試）。 */
function mockCreateUserFails(status = 500) {
  fetchMock
    .get(KBDB)
    .intercept({ path: '/records', method: 'POST' })
    .reply(status, { success: false, error: 'kbdb write failed (test)' });
}

/**
 * 種一筆帳號進**認證儲存**（D61 舊家，CF Workers Secrets）——直接呼叫 lib，不經任何 HTTP
 * route（consoleAuthRouter/portalRouter 現在都不再寫這個家了）。需要攔截一次 CF Secrets PUT
 * （wrangler.test.toml 已預設 CF_SECRETS_API_TOKEN/CF_ACCOUNT_ID 就緒）。
 */
async function seedLegacySecretsUser(overrides: Partial<{
  email: string; display_name: string; status: string; role: string; libraries: string[]; password_hash: string;
}> = {}): Promise<void> {
  fetchMock
    .get(CF_API)
    .intercept({ path: (p: string) => p.includes('/secrets'), method: 'PUT' })
    .reply(200, { success: true });
  const now = new Date().toISOString();
  await mutateAuthStore(env as unknown as Bindings, (data) => {
    data.users.push({
      id: `auth:${crypto.randomUUID().replace(/-/g, '')}`,
      email: (overrides.email ?? EMAIL).toLowerCase(),
      display_name: overrides.display_name ?? '舊實例同仁',
      status: overrides.status ?? 'active',
      role: overrides.role ?? 'user',
      libraries: overrides.libraries ?? ['general'],
      password_hash: overrides.password_hash ?? storedHash,
      created_at: now,
      updated_at: now,
    });
  });
}

function mockGetRecord(recordId: string, values: Record<string, string>) {
  fetchMock
    .get(KBDB)
    .intercept({ path: `/records/${recordId}`, method: 'GET' })
    .reply(200, { success: true, record: { record_id: recordId, template_id: 'tpl_pu', values } });
}

function mockListByTemplate(template: string, records: { record_id: string; values: Record<string, string> }[]) {
  fetchMock
    .get(KBDB)
    .intercept({ path: (p: string) => p.startsWith(`/records/by-template/${template}`), method: 'GET' })
    .reply(200, { success: true, records: records.map((r) => ({ ...r, template_id: 'tpl' })), count: records.length });
}

function mockTemplatesExist() {
  for (const name of ['portal_user', 'portal_library', 'triplet']) {
    fetchMock
      .get(KBDB)
      .intercept({ path: `/templates/${name}`, method: 'GET' })
      .reply(200, { success: true, template: { id: `tpl-${name}`, name } });
  }
}

function activeUserValues(overrides: Record<string, string> = {}): Record<string, string> {
  return {
    email: EMAIL,
    display_name: '測試同仁',
    status: 'active',
    role: 'user',
    password_hash: storedHash,
    libraries: '["general","finance"]',
    created_at: '2026-07-14T00:00:00.000Z',
    updated_at: '2026-07-14T00:00:00.000Z',
    ...overrides,
  };
}

async function seedPortalSession(token: string, recordId: string) {
  await env.SESSIONS_KV.put(`portal_sess:${token}`, JSON.stringify({ record_id: recordId }));
}

// ═══════════════ 1. KDF 單元 ═══════════════

describe('PBKDF2 模組（lib/portal-auth）', () => {
  it('hashPassword 預設格式 = pbkdf2-sha256$100000$salt$hash（CF runtime 上限），且驗證通過', async () => {
    const h = await hashPassword('some-password-123');
    const parts = h.split('$');
    expect(parts.length).toBe(4);
    expect(parts[0]).toBe('pbkdf2-sha256');
    expect(parts[1]).toBe(String(PBKDF2_ITERATIONS));
    // CF Workers 正式 runtime PBKDF2 封頂 100k（600k 在真雲 deriveBits 直接拒絕 → bootstrap 500）
    expect(PBKDF2_ITERATIONS).toBe(100_000);
    expect(PBKDF2_ITERATIONS).toBeLessThanOrEqual(100_000);
    expect(await verifyPassword('some-password-123', h)).toBe(true);
    expect(await verifyPassword('wrong-password-x', h)).toBe(false);
  });

  it('600k 舊 hash 相容：verify 從儲存值解析迭代數，仍驗得動（miniflare 無平台上限可造）', async () => {
    // 誠實註：此相容性在 miniflare / 未來平台放寬時成立；真 CF runtime 對 deriveBits 一律封頂
    // 100k，600k hash 在真雲仍會被拒——但真雲 bootstrap 本身就 500，雲端不存在 600k hash，無遷移面。
    const legacy = await hashPassword(PASSWORD, 600_000);
    expect(legacy.startsWith('pbkdf2-sha256$600000$')).toBe(true);
    expect(await verifyPassword(PASSWORD, legacy)).toBe(true);
    expect(await verifyPassword('wrong-password-x', legacy)).toBe(false);
  });

  it('壞格式 / 前綴不認得 / 被竄改 → false（誠實拒絕不拋錯）', async () => {
    expect(await verifyPassword('x', '')).toBe(false);
    expect(await verifyPassword('x', 'bcrypt$10$abc$def')).toBe(false);
    expect(await verifyPassword('x', 'pbkdf2-sha256$notanumber$AA$BB')).toBe(false);
    const tampered = storedHash.slice(0, -4) + 'AAA=';
    expect(await verifyPassword(PASSWORD, tampered)).toBe(false);
  });
});

// ═══════════════ 1.5 D61：整台實例沒有任何認證資料 ═══════════════
//
// 🔴 這個 describe 必須留在檔案裡「第一個會種入認證儲存（舊家）的測試」之前——見
// seedLegacySecretsUser 檔頭註解：portal-auth-store.ts 的 per-isolate overlay 是模組級全域
// 變數，同一支測試檔案跑起來不會在測試之間重置，一旦有測試種過舊家資料，後面的測試都會看到
// 那筆資料，「乾淨無帳號」的前提就不成立了。
describe('整台實例沒有任何認證資料（arcrun-rag#55，leo 2026-08-09 被誤鎖 15 分鐘的事故）', () => {
  it('登入回「讀不到認證資料」而不是「密碼錯誤」，且不計入失敗鎖定', async () => {
    // KBDB（新家）此刻回空——head lookup 查無此人＋by-template 列表也空；
    // 認證儲存（舊家）此刻也還是空的（本測試特意排在任何種子測試之前）——兩邊都沒有帳號，
    // 才是「這台實例真的沒有認證資料」。
    mockHeadLookup('anyone@example.com', null);
    mockListByTemplate('portal_user', []);
    const res = await json('POST', '/portal/login', { email: 'anyone@example.com', password: 'whatever-pw-1' });
    expect(res.status).toBe(503);
    const data = (await res.json()) as { error: string; code: string; auth_store: { users: number } };
    expect(data.code).toBe('auth_store_empty');
    // 分得出來的錯：這句要誠實講「不是密碼錯」，而且**不能**是密碼錯誤那句通用訊息
    // （文案含混是 leo 被鎖 15 分鐘的根因——他的密碼從頭到尾是對的）。
    expect(data.error).toContain('不是密碼錯');
    expect(data.error).not.toBe('email 或密碼錯誤'); // 不是密碼錯誤路徑用的那句通用訊息
    expect(data.auth_store.users).toBe(0);
    // 不計入鎖定：lockfail 計數器完全沒被寫入
    expect(await env.SESSIONS_KV.get('portal_lockfail:anyone@example.com')).toBeNull();
  });
});

// ═══════════════ 2. bootstrap 閘 ═══════════════

describe('POST /portal/admin/bootstrap', () => {
  it('無 console owner session → 401，不碰 KBDB', async () => {
    const res = await json('POST', '/portal/admin/bootstrap', { email: 'a@b.co', password: 'longenough' });
    expect(res.status).toBe(401);
  });

  it('console session OK → 建第一個 admin：寫進 KBDB，不需要任何外部 CF token', async () => {
    await env.SESSIONS_KV.put('console_sess:owner-token', JSON.stringify({ created_at: Date.now() }));
    mockTemplatesExist();
    mockListByTemplate('portal_user', []); // 尚無 admin（KBDB 空，認證儲存舊家也空）
    mockHeadLookup('admin@example.com', null); // email 未占用
    const { recordBody } = mockCreateUser('rec_admin_new');

    const res = await json(
      'POST',
      '/portal/admin/bootstrap',
      { email: 'Admin@Example.com', password: 'bootstrap-pw-1', display_name: '管理員' },
      { Authorization: 'Bearer owner-token' },
    );
    expect(res.status).toBe(200);
    const data = (await res.json()) as Record<string, unknown>;
    expect(data.success).toBe(true);
    expect(data.record_id).toBe('rec_admin_new'); // 住 KBDB（新 record_id 來自 KBDB 回應）
    expect(data.email).toBe('admin@example.com'); // 存小寫（design §2.1）

    // 一次 POST /records＝一筆，body 含 owner_id 子 namespace，明碼絕不落地
    const rec = JSON.parse(recordBody()) as { owner_id: string; values: Record<string, string> };
    expect(rec.owner_id).toBe(NS);
    expect(rec.values.email).toBe('admin@example.com');
    expect(rec.values.role).toBe('admin');
    expect(rec.values.status).toBe('active');
    expect(JSON.parse(rec.values.libraries)).toEqual(['*']);
    expect(rec.values.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
    expect(recordBody()).not.toContain('bootstrap-pw-1'); // 明碼絕不落地
  });

  it('已有 admin → 409 拒絕重複 bootstrap', async () => {
    await env.SESSIONS_KV.put('console_sess:owner-token', JSON.stringify({ created_at: Date.now() }));
    mockTemplatesExist();
    mockListByTemplate('portal_user', [{ record_id: 'rec_a', values: activeUserValues({ role: 'admin' }) }]);
    const res = await json(
      'POST',
      '/portal/admin/bootstrap',
      { email: 'x@y.co', password: 'whatever-123' },
      { Authorization: 'Bearer owner-token' },
    );
    expect(res.status).toBe(409);
  });
});

// ═══════════════ 3. 登入對錯 ═══════════════

describe('POST /portal/login', () => {
  // 🔴 這一區塊全部共用 EMAIL/'rec_1' 這組 **KBDB 原生** fixture（非 auth: 開頭 id）——
  // 2026-08-14 起 KBDB 是新家，這批帳號本來就住在該住的地方，**不會**觸發任何搬遷嘗試
  // （isAuthStoreId('rec_1') 為 false）。搬遷本身的驗證另開一組使用**專屬、不共用**email
  // 的 describe（見檔案最後「舊實例登入自癒」），避免污染這裡的既有 fixture。
  it('成功：發 session token；回 display_name/role/libraries；**無任何租戶字串欄位**', async () => {
    mockHeadLookup(EMAIL, 'rec_1');
    mockGetRecord('rec_1', activeUserValues());
    const res = await json('POST', '/portal/login', { email: EMAIL, password: PASSWORD });
    expect(res.status).toBe(200);
    const data = (await res.json()) as Record<string, unknown>;
    expect(data.success).toBe(true);
    expect(typeof data.session_token).toBe('string');
    expect(data.display_name).toBe('測試同仁');
    expect(data.role).toBe('user');
    expect(data.libraries).toEqual(['general', 'finance']);
    expect('tenant' in data).toBe(false); // design §3.3：Portal 絕不下發租戶字串
    expect(JSON.stringify(data)).not.toContain('leo'); // 連值都不含租戶字串

    const sess = await env.SESSIONS_KV.get(`portal_sess:${data.session_token}`);
    expect(sess).toBeTruthy();
    expect((JSON.parse(sess!) as { record_id: string }).record_id).toBe('rec_1'); // 只存 record_id
  });

  it('密碼錯 → 401 通用訊息＋lockfail 計數 +1', async () => {
    mockHeadLookup(EMAIL, 'rec_1');
    mockGetRecord('rec_1', activeUserValues());
    const res = await json('POST', '/portal/login', { email: EMAIL, password: 'wrong-password' });
    expect(res.status).toBe(401);
    const raw = await env.SESSIONS_KV.get(`portal_lockfail:${EMAIL}`);
    expect(raw).toBeTruthy();
    expect((JSON.parse(raw!) as { count: number }).count).toBe(1);
  });

  it('未知 email → 401 同樣通用訊息（不洩帳號存在性）', async () => {
    mockHeadLookup('ghost@example.com', null);
    // instanceHasNoAuthData 在「查無此帳號」時會確認「是不是整台實例都沒帳號」——
    // 這裡要證明的是「查無此人」而非「這台實例是空的」，故 by-template 要回非空列表
    // （這台實例確實有別的帳號，只是不是 ghost@example.com）。
    mockListByTemplate('portal_user', [{ record_id: 'rec_1', values: activeUserValues() }]);
    const res = await json('POST', '/portal/login', { email: 'ghost@example.com', password: 'whatever-123' });
    expect(res.status).toBe(401);
    const data = (await res.json()) as { error: string };
    expect(data.error).toBe('email 或密碼錯誤');
  });

  it('停用帳號 → 403（正確密碼也拒）', async () => {
    mockHeadLookup(EMAIL, 'rec_1');
    mockGetRecord('rec_1', activeUserValues({ status: 'disabled' }));
    const res = await json('POST', '/portal/login', { email: EMAIL, password: PASSWORD });
    expect(res.status).toBe(403);
  });

  it('節流：計數達 5 → 429，不碰 KBDB；正確密碼也擋', async () => {
    await env.SESSIONS_KV.put(`portal_lockfail:${EMAIL}`, JSON.stringify({ count: 5 }), { expirationTtl: 900 });
    const res = await json('POST', '/portal/login', { email: EMAIL, password: PASSWORD });
    expect(res.status).toBe(429);
  });

  it('第 5 次失敗後下一次直接 429（KV 計數累加）', async () => {
    await env.SESSIONS_KV.put(`portal_lockfail:${EMAIL}`, JSON.stringify({ count: 4 }), { expirationTtl: 900 });
    mockHeadLookup(EMAIL, 'rec_1');
    mockGetRecord('rec_1', activeUserValues());
    const res5 = await json('POST', '/portal/login', { email: EMAIL, password: 'wrong-again' });
    expect(res5.status).toBe(401);
    const res6 = await json('POST', '/portal/login', { email: EMAIL, password: PASSWORD });
    expect(res6.status).toBe(429); // 第 6 次不再打 KBDB（無 pending interceptor 可證）
  });
});

// ═══════════════ 4. session 回讀（真相源＝record）═══════════════

describe('GET /portal/session', () => {
  it('有效 session → 回 display_name/role/libraries，無租戶字串', async () => {
    await seedPortalSession('tok-1', 'rec_1');
    mockGetRecord('rec_1', activeUserValues());
    // P3：session 多回 graph_allowed（D-4）——非 ["*"] 用戶要查庫目錄算 graph 來源庫
    mockListByTemplate('portal_library', []);
    const res = await json('GET', '/portal/session', undefined, { Authorization: 'Bearer tok-1' });
    expect(res.status).toBe(200);
    const data = (await res.json()) as Record<string, unknown>;
    expect(data.valid).toBe(true);
    expect(data.libraries).toEqual(['general', 'finance']);
    expect('tenant' in data).toBe(false);
    // P3 能力欄位：來源庫預設 general、本用戶有 general → graph 放行；workflows 預設 admin-only
    expect(data.graph_allowed).toBe(true);
    expect(data.workflows_visible).toBe(false);
  });

  it('帳號被停用 → 既有 session 立即失效（403）且 KV session 被清', async () => {
    await seedPortalSession('tok-2', 'rec_1');
    mockGetRecord('rec_1', activeUserValues({ status: 'disabled' }));
    const res = await json('GET', '/portal/session', undefined, { Authorization: 'Bearer tok-2' });
    expect(res.status).toBe(403);
    expect(await env.SESSIONS_KV.get('portal_sess:tok-2')).toBeNull();
  });

  it('無 token / 壞 token → 401', async () => {
    expect((await json('GET', '/portal/session')).status).toBe(401);
    expect((await json('GET', '/portal/session', undefined, { Authorization: 'Bearer nope' })).status).toBe(401);
  });
});

// ═══════════════ 5. 改自己密碼 ═══════════════

describe('POST /portal/me/password', () => {
  it('舊密碼錯 → 401，不發 PATCH', async () => {
    await seedPortalSession('tok-3', 'rec_1');
    mockGetRecord('rec_1', activeUserValues());
    const res = await json(
      'POST',
      '/portal/me/password',
      { current: 'wrong-old', new: 'new-password-1' },
      { Authorization: 'Bearer tok-3' },
    );
    expect(res.status).toBe(401);
  });

  it('舊密碼對 → PATCH 新 hash（現行 100k 格式、非明碼、與舊 hash 不同）', async () => {
    await seedPortalSession('tok-4', 'rec_1');
    mockGetRecord('rec_1', activeUserValues());
    let patched = '';
    fetchMock
      .get(KBDB)
      .intercept({ path: '/records/rec_1', method: 'PATCH' })
      .reply(200, (opts) => {
        patched = String(opts.body);
        return { success: true, record: { record_id: 'rec_1', template_id: 'tpl_pu', values: activeUserValues() } };
      });
    const res = await json(
      'POST',
      '/portal/me/password',
      { current: PASSWORD, new: 'brand-new-pw-1' },
      { Authorization: 'Bearer tok-4' },
    );
    expect(res.status).toBe(200);
    const sent = JSON.parse(patched) as { values: Record<string, string> };
    expect(sent.values.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
    expect(sent.values.password_hash).not.toBe(storedHash);
    expect(patched).not.toContain('brand-new-pw-1'); // 明碼不落 KBDB
  });
});

// ═══════════════ 6. role 閘＋admin 列表剝敏 ═══════════════

describe('admin 端點 role 閘', () => {
  it('一般 user 打 GET /portal/admin/users → 403', async () => {
    await seedPortalSession('tok-5', 'rec_1');
    mockGetRecord('rec_1', activeUserValues({ role: 'user' }));
    const res = await json('GET', '/portal/admin/users', undefined, { Authorization: 'Bearer tok-5' });
    expect(res.status).toBe(403);
  });

  it('admin 列表 → 200 且**每筆都不含 password_hash**', async () => {
    await seedPortalSession('tok-6', 'rec_admin');
    mockGetRecord('rec_admin', activeUserValues({ role: 'admin', email: 'admin@example.com' }));
    mockListByTemplate('portal_user', [
      { record_id: 'rec_admin', values: activeUserValues({ role: 'admin', email: 'admin@example.com' }) },
      { record_id: 'rec_1', values: activeUserValues() },
    ]);
    const res = await json('GET', '/portal/admin/users', undefined, { Authorization: 'Bearer tok-6' });
    expect(res.status).toBe(200);
    const data = (await res.json()) as { users: Record<string, unknown>[] };
    expect(data.users.length).toBe(2);
    for (const u of data.users) {
      expect('password_hash' in u).toBe(false);
      expect(Array.isArray(u.libraries)).toBe(true);
    }
    expect(JSON.stringify(data)).not.toContain('pbkdf2-sha256'); // 整包回應無雜湊外洩
  });

  it('admin 停用同仁：PATCH status=disabled → 經 head entry 成員驗證後改 record', async () => {
    await seedPortalSession('tok-7', 'rec_admin');
    mockGetRecord('rec_admin', activeUserValues({ role: 'admin', email: 'admin@example.com' }));
    mockGetRecord('rec_1', activeUserValues()); // assertPortalUserRecord 回讀目標
    mockHeadLookup(EMAIL, 'rec_1'); // head 指回同 record → 成員資格成立
    let patched = '';
    fetchMock
      .get(KBDB)
      .intercept({ path: '/records/rec_1', method: 'PATCH' })
      .reply(200, (opts) => {
        patched = String(opts.body);
        return {
          success: true,
          record: { record_id: 'rec_1', template_id: 'tpl_pu', values: activeUserValues({ status: 'disabled' }) },
        };
      });
    const res = await json(
      'PATCH',
      '/portal/admin/users/rec_1',
      { status: 'disabled' },
      { Authorization: 'Bearer tok-7' },
    );
    expect(res.status).toBe(200);
    const sent = JSON.parse(patched) as { values: Record<string, string> };
    expect(sent.values.status).toBe('disabled');
    const data = (await res.json()) as { user: { status: string } };
    expect(data.user.status).toBe('disabled');
  });

  it('head entry 指向別的 record（成員資格不符）→ 404 不 PATCH', async () => {
    await seedPortalSession('tok-8', 'rec_admin');
    mockGetRecord('rec_admin', activeUserValues({ role: 'admin', email: 'admin@example.com' }));
    mockGetRecord('rec_evil', activeUserValues({ email: EMAIL }));
    mockHeadLookup(EMAIL, 'rec_1'); // head 指 rec_1 ≠ rec_evil
    const res = await json(
      'PATCH',
      '/portal/admin/users/rec_evil',
      { status: 'disabled' },
      { Authorization: 'Bearer tok-8' },
    );
    expect(res.status).toBe(404);
  });
});

// ═══════════════ t130 — triplet template seed ═══════════════

describe('t130 — triplet template seed（PORTAL_TEMPLATE_SEEDS 補 triplet，ensurePortalTemplates 冪等）', () => {
  it('PORTAL_TEMPLATE_SEEDS 含 triplet 且必要 slots 齊備（pure data）', () => {
    const seed = PORTAL_TEMPLATE_SEEDS.find((s) => s.name === 'triplet');
    expect(seed).toBeDefined();
    for (const slot of ['subject', 'predicate', 'object', 'source_uri', 'status', 'library']) {
      expect(seed!.slots).toContain(slot);
    }
  });

  it('POST /init/seed — triplet 已存 → existing（冪等，不重建）', async () => {
    for (const name of ['portal_user', 'portal_library', 'triplet']) {
      fetchMock
        .get(KBDB)
        .intercept({ path: `/templates/${name}`, method: 'GET' })
        .reply(200, { success: true, template: { id: `tpl-${name}`, name } });
    }
    const res = await SELF.fetch('http://localhost/init/seed', { method: 'POST' });
    expect(res.status).toBe(200);
    const data = (await res.json()) as { portal_templates: { created: string[]; existing: string[] } };
    expect(data.portal_templates.existing).toContain('triplet');
    expect(data.portal_templates.created).not.toContain('triplet');
  });

  it('POST /init/seed — triplet 缺 → 自動補建（新實例首次 seed）', async () => {
    for (const name of ['portal_user', 'portal_library']) {
      fetchMock
        .get(KBDB)
        .intercept({ path: `/templates/${name}`, method: 'GET' })
        .reply(200, { success: true, template: { id: `tpl-${name}`, name } });
    }
    fetchMock
      .get(KBDB)
      .intercept({ path: '/templates/triplet', method: 'GET' })
      .reply(404, { success: false, error: 'template not found: triplet' });
    fetchMock
      .get(KBDB)
      .intercept({ path: '/templates', method: 'POST' })
      .reply(200, { success: true, template: { id: 'tpl-triplet-new', name: 'triplet' } });

    const res = await SELF.fetch('http://localhost/init/seed', { method: 'POST' });
    expect(res.status).toBe(200);
    const data = (await res.json()) as { portal_templates: { created: string[]; existing: string[] } };
    expect(data.portal_templates.created).toContain('triplet');
    expect(data.portal_templates.existing).not.toContain('triplet');
  });
});

// ═══════════════ 舊實例登入自癒（認證儲存舊家 → 搬進 KBDB 新家）═══════════════
//
// 🔴 放在檔案最後、用**專屬 email**（不與上面任何一則共用）：portal-auth-store.ts 的
// per-isolate overlay 是模組級全域變數，seedLegacySecretsUser 種一次資料就會留在同一支測試
// 檔案的後續測試裡（見該函式檔頭的長註解）。這裡就是要驗證那次「留下」，所以刻意隔離在最後，
// 不會有更後面的測試共用這個 email 而被污染。
describe('舊實例登入自癒（帳號只在認證儲存舊家，登入成功後搬進 KBDB 新家）', () => {
  const LEGACY_EMAIL = 'legacy-promote@example.com';

  it('登入成功；自動把這筆帳號寫進 KBDB（含正確 email/hash），且**這次的 session 就指向新 record_id**', async () => {
    await seedLegacySecretsUser({ email: LEGACY_EMAIL });
    // KBDB 查無此人：一次給 findUserRecordId（登入查找）、一次給 promoteToKbdb（搬遷前的
    // 「已經搬過了嗎」確認）——兩次都要 mock，見 mockHeadLookup 的 times 參數說明。
    mockHeadLookup(LEGACY_EMAIL, null, 2);
    const { recordBody } = mockCreateUser('rec_legacy_promoted_1');

    const res = await json('POST', '/portal/login', { email: LEGACY_EMAIL, password: PASSWORD });
    expect(res.status).toBe(200);
    const data = (await res.json()) as { success: boolean; session_token: string };
    expect(data.success).toBe(true);

    const rec = JSON.parse(recordBody()) as { owner_id: string; values: Record<string, string> };
    expect(rec.owner_id).toBe(NS);
    expect(rec.values.email).toBe(LEGACY_EMAIL);
    expect(rec.values.password_hash).toBe(storedHash); // 原樣搬過去，不重新雜湊

    // 這次登入發出的 session 已經指向新 record_id（不必等下一次登入才生效）
    const sess = await env.SESSIONS_KV.get(`portal_sess:${data.session_token}`);
    expect((JSON.parse(sess!) as { record_id: string }).record_id).toBe('rec_legacy_promoted_1');
  });

  it('KBDB 拒寫（搬遷失敗）——照樣登入成功，session 沿用舊家 record_id，搬不動不擋門', async () => {
    const email = 'legacy-promote-writefail@example.com';
    await seedLegacySecretsUser({ email });
    mockHeadLookup(email, null, 2); // 登入查找 + 搬遷前確認，KBDB 兩次都查無此人
    mockCreateUserFails(500); // 搬遷寫入本身失敗（模擬 KBDB 不可達／拒寫）

    const res = await json('POST', '/portal/login', { email, password: PASSWORD });
    expect(res.status).toBe(200);
    const data = (await res.json()) as { success: boolean; session_token: string };
    expect(data.success).toBe(true);

    // 搬不動 → session 沿用舊家（認證儲存）的 record_id（"auth:" 開頭），讀路徑仍然通
    const sess = await env.SESSIONS_KV.get(`portal_sess:${data.session_token}`);
    const sessRecordId = (JSON.parse(sess!) as { record_id: string }).record_id;
    expect(isAuthStoreId(sessRecordId)).toBe(true);
  });
});

// ═══════════════ D62 ＋ arcrun-rag#66（2026-08-10）═══════════════
//
// ⚠️ 順序刻意：這兩個 describe 放在檔案最後，而且「D62」在前、「#66」在後。
//    原因＝#66 那組會**故意把 per-isolate overlay 灌成一份沒有任何帳號的資料**（模擬傳播空窗），
//    而 overlay 是模組級全域變數、不隨 test 重置（見 seedLegacySecretsUser 檔頭長註解）。
//    任何需要「認證儲存裡有帳號」的測試都不能排在它後面。

describe('D62：改密碼與忘記密碼是同一個機制（同一支端點、同一條寫入路徑）', () => {
  const D62_EMAIL = 'd62-reset@example.com';

  it('/portal/password/change 帶 reset_token：**不需要登入、不需要現有密碼**，且票用完即失效', async () => {
    // 直接把一張票種進 KV（等同 /portal/password/forgot 發出來的那張），
    // 存的是 token 的 sha256——KV 裡看不到可用的連結。
    const { sha256Hex } = await import('../src/lib/portal-auth');
    const token = 'a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90';
    const recordId = `${AUTH_ID_PREFIX}d62test000000000000000`;
    await env.SESSIONS_KV.put(
      `portal_pwreset:${await sha256Hex(token)}`,
      JSON.stringify({ record_id: recordId, email: D62_EMAIL, created_at: new Date().toISOString() }),
    );

    // 票有效時，先「看一眼」不會消耗它
    const peek = await json('GET', `/portal/password/reset?token=${token}`);
    expect(peek.status).toBe(200);
    expect((await peek.json() as { valid: boolean; email: string }).email).toBe(D62_EMAIL);

    // 認證儲存裡沒有這個 record_id → 覆蓋密碼會失敗，但**票必須已經被消耗**（先刪再回）
    const used = await json('POST', '/portal/password/change', { reset_token: token, new: 'brand-new-pw-1' });
    expect(used.status).not.toBe(200); // 這個 record 不存在，寫入失敗是預期的
    // 關鍵斷言：同一條連結**不能再用第二次**
    const again = await json('POST', '/portal/password/change', { reset_token: token, new: 'second-try-pw-1' });
    expect(again.status).toBe(400);
    expect((await again.json() as { code: string }).code).toBe('reset_token_invalid');
    // 而且票在 KV 裡真的沒了
    expect(await env.SESSIONS_KV.get(`portal_pwreset:${await sha256Hex(token)}`)).toBeNull();
  });

  it('亂猜的 token / 格式不對的 token → 400，不洩漏任何東西', async () => {
    for (const t of ['deadbeef'.repeat(8), 'not-hex-at-all', '']) {
      const res = await json('GET', `/portal/password/reset?token=${t}`);
      expect(res.status).toBe(400);
      expect((await res.json() as { valid: boolean }).valid).toBe(false);
    }
  });

  it('沒帶 reset_token 又沒登入 → 401（修改密碼那一格仍然要身分）', async () => {
    const res = await json('POST', '/portal/password/change', { current: 'x', new: 'brand-new-pw-1' });
    expect(res.status).toBe(401);
  });

  it('新密碼太短 → 400（兩條路共用同一組驗證）', async () => {
    const res = await json('POST', '/portal/password/change', { reset_token: 'a'.repeat(64), new: 'short' });
    expect(res.status).toBe(400);
  });

  it('/portal/password/forgot：沒設代寄服務 → 誠實回 503，不假裝信寄出去了', async () => {
    const res = await json('POST', '/portal/password/forgot', { email: D62_EMAIL });
    expect(res.status).toBe(503);
    expect((await res.json() as { code: string }).code).toBe('mail_relay_not_configured');
  });
});

describe('arcrun-rag#66：傳播空窗期不可以銷毀 session', () => {
  const TOKEN_A = 'sess-token-66-propagating';
  const TOKEN_B = 'sess-token-66-really-gone';
  const MISSING = `${AUTH_ID_PREFIX}notinstore0000000000000`;

  it('正在傳播（加速器 key 還在）＋讀不到 record → 503 auth_store_propagating，且 **session 沒被刪**', async () => {
    await seedPortalSession(TOKEN_A, MISSING);
    // 加速器 key 存在＝「剛剛有人動過認證儲存」＝現在是傳播空窗
    await env.SESSIONS_KV.put(
      'auth_store_recent',
      JSON.stringify({ written_at: Date.now() + 10_000_000, data: { version: 1, console: null, users: [] } }),
    );

    const res = await json('GET', '/portal/session', undefined, { Authorization: `Bearer ${TOKEN_A}` });
    expect(res.status).toBe(503);
    expect((await res.json() as { code: string }).code).toBe('auth_store_propagating');
    // 🔴 這是整張票的重點：舊碼會在這裡把 KV 那筆刪掉，等 secret 鋪開也回不來
    expect(await env.SESSIONS_KV.get(`portal_sess:${TOKEN_A}`)).not.toBeNull();
  });

  it('不在傳播空窗（加速器 key 不存在）＋讀不到 record → 401 擋下，但**仍然不刪 session**', async () => {
    await seedPortalSession(TOKEN_B, MISSING);
    await env.SESSIONS_KV.delete('auth_store_recent');

    const res = await json('GET', '/portal/session', undefined, { Authorization: `Bearer ${TOKEN_B}` });
    expect(res.status).toBe(401);
    // 刪 session 是 best-effort 清潔工，而它清掉的是使用者唯一的憑據；KV 的 TTL 本來就會回收
    expect(await env.SESSIONS_KV.get(`portal_sess:${TOKEN_B}`)).not.toBeNull();
  });

  it('session 內容本身壞掉（不是讀不到）→ 401 且**該刪**（確定的事實，不是暫時性）', async () => {
    await env.SESSIONS_KV.put('portal_sess:broken-66', 'not-json-at-all');
    const res = await json('GET', '/portal/session', undefined, { Authorization: 'Bearer broken-66' });
    expect(res.status).toBe(401);
    expect(await env.SESSIONS_KV.get('portal_sess:broken-66')).toBeNull();
  });
});

// ═══════════════ arcrun-rag#99（2026-08-14）：全新安裝從沒種過 CF_SECRETS_API_TOKEN ═══════════════
//
// 每一台裝好的新實例過去都會在 bootstrap 這裡卡死（leo 本人＋封測者都撞到「裝得起來，
// 但卡在註冊」——`/console/auth-status` 永遠回 `writable:false`）。止血版（安裝精靈遞一把
// 臨時 OAuth token）只解掉「建第一個帳號」這一格；2026-08-14 confirm 走 C 之後，帳號改回
// 住 KBDB（binding），**從根拔掉整個問題**：`x-arcrun-install-token` 這條路已經不存在了，
// 因為建帳號這件事本來就不再需要任何 CF API 憑證，無論裝機當下還是裝完之後都一樣。
//
// 下面這則是回歸守衛：**一台連 `CF_SECRETS_API_TOKEN`／`CF_ACCOUNT_ID` 都沒有的全新實例**，
// 從 bootstrap 建第一個帳號、到之後用 /portal/admin/users 加第二個帳號，全程都要成功——
// 若哪天有人把帳號寫入路徑又改回去揹 CF Secrets 依賴，這裡會紅。
describe('arcrun-rag#99：全新實例（連 CF_SECRETS_API_TOKEN／CF_ACCOUNT_ID 都沒有）不再卡死', () => {
  it('bootstrap 建第一個 admin、再用 admin session 加第二個 user，全程零 CF token', async () => {
    await env.SESSIONS_KV.put('console_sess:owner-token-fresh', JSON.stringify({ created_at: Date.now() }));
    mockTemplatesExist();
    mockListByTemplate('portal_user', []); // 尚無 admin
    mockHeadLookup('fresh-install-admin@example.com', null);
    const { recordBody: bootstrapBody } = mockCreateUser('rec_fresh_admin');

    const fakeEnv = { ...env, CF_SECRETS_API_TOKEN: undefined, CF_ACCOUNT_ID: undefined } as unknown as Bindings;
    const bootstrapRes = await portalRouter.fetch(
      new Request('http://localhost/portal/admin/bootstrap', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Authorization: 'Bearer owner-token-fresh' },
        body: JSON.stringify({ email: 'fresh-install-admin@example.com', password: 'bootstrap-pw-3' }),
      }),
      fakeEnv,
      {} as ExecutionContext,
    );
    expect(bootstrapRes.status).toBe(200);
    const bootstrapData = (await bootstrapRes.json()) as Record<string, unknown>;
    expect(bootstrapData.success).toBe(true);
    expect(bootstrapData.record_id).toBe('rec_fresh_admin');
    expect(JSON.parse(bootstrapBody()).values.password_hash.startsWith('pbkdf2-sha256$')).toBe(true);
    expect(bootstrapBody()).not.toContain('bootstrap-pw-3'); // 明碼絕不落地

    // 安裝精靈離場之後：管理員事後手動加第二個人（POST /portal/admin/users），
    // 這條路本來就沒帶過任何臨時 token，過去在 D61 底下永遠 502——現在也要成功。
    await env.SESSIONS_KV.put('portal_sess:tok-fresh-admin', JSON.stringify({ record_id: 'rec_fresh_admin' }));
    mockGetRecord('rec_fresh_admin', activeUserValues({ email: 'fresh-install-admin@example.com', role: 'admin' }));
    mockHeadLookup('second-user@example.com', null);
    const { recordBody: secondBody } = mockCreateUser('rec_fresh_second');
    mockGetRecord('rec_fresh_second', activeUserValues({ email: 'second-user@example.com' }));

    const addUserRes = await portalRouter.fetch(
      new Request('http://localhost/portal/admin/users', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Authorization: 'Bearer tok-fresh-admin' },
        body: JSON.stringify({ email: 'second-user@example.com', password: 'second-user-pw-1' }),
      }),
      fakeEnv,
      {} as ExecutionContext,
    );
    expect(addUserRes.status).toBe(200);
    const addUserData = (await addUserRes.json()) as { success: boolean };
    expect(addUserData.success).toBe(true);
    expect(JSON.parse(secondBody()).owner_id).toBe(NS);
  });
});
