import { createMockMteamApi, createMockNexusPhpSite, type MockMteamApi, type MockNexusPhpSite } from "@umt/testkit"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { scheduleRegistry } from "../../src/modules/schedules/registry.ts"; import { refreshSiteAccount, tickSiteUserData } from "../../src/modules/sources-identity/service/account.ts"; import { createHarness, type Harness, pgAvailable } from "./harness.ts"; // W9-A 站点中心:凭证(账密 / Cookie / API Key)、刷新登录、用户数据与历史、健康检测、删除、定时任务登记。 const up = await pgAvailable(); const TiB = 1024 ** 4; // biome-ignore lint/suspicious/noExplicitAny: 响应体断言 const json = async (r: Response): Promise => { const body = await r.json(); if (!r.ok) throw new Error(`${r.status} ${JSON.stringify(body)}`); return body.data; }; describe.skipIf(!up)("站点中心(mock NexusPHP)", () => { let h: Harness; let mock: MockNexusPhpSite; let siteId: string; const put = (body: unknown) => h.req(`/sites/${siteId}/credentials`, { method: "PUT", body }); const refresh = async (accountId?: string) => ( await json( await h.req(`/sites/${siteId}/refresh-login`, { body: { wait: true, ...(accountId ? { accountId } : {}) } }), ) ).result; const account = (label = "default") => h.db .selectFrom("site_account") .selectAll() .where("site_id", "=", siteId) .where("label", "=", label) .executeTakeFirstOrThrow(); const openDecisions = (accountId: string) => h.db .selectFrom("decision") .selectAll() .where("dedupe_key", "=", `site-login:${accountId}`) .where("status", "=", "open") .execute(); beforeAll(async () => { mock = await createMockNexusPhpSite({ username: "alice", password: "s3cret-pw" }); h = await createHarness("sitecenter"); const site = await json( await h.req("/sites", { body: { key: "audiences", name: "观众", adapter: "nexusphp", baseUrl: `${mock.baseUrl}/`, rateLimit: { minIntervalMs: 0, concurrency: 10 }, }, }), ); siteId = site.id; expect(site).toMatchObject({ proxy: false, lastHealth: null, lastHealthAt: null }); }); afterAll(async () => { await h?.close(); await mock?.close(); }); it("账密登录成功:会话 Cookie 存 secret、状态 active、用户数据写入当日历史;密码不回显", async () => { const r = await json(await put({ kind: "password", username: "alice", password: "s3cret-pw" })); expect(r).toMatchObject({ created: true, authKind: "password", label: "default" }); expect(JSON.stringify(r)).not.toContain("s3cret-pw"); const res = await refresh(); expect(res).toMatchObject({ ok: true, status: "active", reason: null }); expect(res.userData).toMatchObject({ username: "mockuser", userLevel: "Elite User", upload: 12.5 * TiB, ratio: 20.48, seeding: 31, vip: null, }); const acc = await account(); expect(acc.status).toBe("active"); expect(acc.last_login_at).toBeTruthy(); expect(acc.session_secret_ref).toBeTruthy(); expect(h.secrets.plain.get(acc.session_secret_ref!)).toBe( mock.cookie .split(";") .map((s) => s.trim()) .join("; "), ); expect(acc.login_name).toBe("alice"); const hist = await json(await h.req(`/sites/${siteId}/user-data?days=30`)); expect(hist.items).toHaveLength(1); expect(hist.items[0]).toMatchObject({ siteAccountId: acc.id, upload: 12.5 * TiB, seeding: 31 }); // 同日再刷新:upsert 不新增行 await refresh(); expect((await json(await h.req(`/sites/${siteId}/user-data`))).items).toHaveLength(1); // 列表带账号摘要,secret 引用不外露 const list = await json(await h.req("/sites?include=accounts")); const item = list.items.find((s: { id: string }) => s.id === siteId); expect(item.accountStatus).toBe("active"); expect(item.accounts[0]).toMatchObject({ hasSession: true, hasTotp: false, loginName: "alice", status: "active" }); expect(item.accounts[0].userData.upload).toBe(12.5 * TiB); expect(JSON.stringify(list)).not.toMatch(/sessionSecretRef|s3cret-pw|mock-session-secret/); // 会话 Cookie 给适配器用 const page = await h.rt .siteAdapter(await h.db.selectFrom("site").selectAll().where("id", "=", siteId).executeTakeFirstOrThrow()) .list(1); expect(page.items.length).toBeGreaterThan(0); }); it("密码错:status invalid + 待处理(无重试选项);更新凭证后回到 unknown 并关闭待处理", async () => { await json(await put({ kind: "password", label: "bad", username: "alice", password: "wrong" })); const acc = await account("bad"); const res = await refresh(acc.id); expect(res).toMatchObject({ ok: false, status: "invalid", reason: "bad_credentials" }); const ds = await openDecisions(acc.id); expect(ds).toHaveLength(1); expect((ds[0]!.options as { key: string }[]).map((o) => o.key)).toEqual(["use_cookie", "dismiss"]); await json(await put({ kind: "password", label: "bad", username: "alice", password: "s3cret-pw" })); // 同类凭证原地覆盖:secret 引用不变、明文已换(不新建 secret,不撞 secret.name 唯一) const again = await account("bad"); expect(again.secret_ref).toBe(acc.secret_ref); expect(h.secrets.plain.get(again.secret_ref!)).toBe("s3cret-pw"); expect((await account("bad")).status).toBe("unknown"); expect(await openDecisions(acc.id)).toHaveLength(0); await json(await h.req(`/site-accounts/${acc.id}`, { method: "DELETE" })); }); it("验证码 / 二次验证:status challenge + 待处理提示改用 Cookie;TOTP 密钥可过二次验证", async () => { await json(await put({ kind: "password", label: "cap", username: "alice", password: "s3cret-pw" })); const acc = await account("cap"); mock.setLoginGuard("captcha"); const r1 = await refresh(acc.id); expect(r1).toMatchObject({ ok: false, status: "challenge", reason: "captcha" }); const [d] = await openDecisions(acc.id); expect(d!.title).toContain("登录需要人工验证"); expect(d!.summary).toContain("改用 Cookie"); expect((d!.options as { key: string }[]).map((o) => o.key)).toEqual(["use_cookie", "retry", "dismiss"]); mock.setLoginGuard("two_factor"); const r2 = await refresh(acc.id); expect(r2).toMatchObject({ ok: false, status: "challenge", reason: "two_factor" }); await json( await put({ kind: "password", label: "cap", username: "alice", password: "s3cret-pw", totpSecret: "JBSWY3DPEHPK3PXP", }), ); const r3 = await refresh(acc.id); expect(r3).toMatchObject({ ok: true, status: "active" }); expect((await account("cap")).totp_secret_ref).toBeTruthy(); mock.setLoginGuard("none"); await json(await h.req(`/site-accounts/${acc.id}`, { method: "DELETE" })); }); it("只换 Cookie:登录成功后凭证类型落为 cookie,密码丢弃", async () => { await json( await put({ kind: "password", label: "co", username: "alice", password: "s3cret-pw", saveCookieOnly: true }), ); const before = await account("co"); const res = await refresh(before.id); expect(res.ok).toBe(true); const after = await account("co"); expect(after.auth_kind).toBe("cookie"); expect(after.session_secret_ref).toBeNull(); expect(h.secrets.plain.has(before.secret_ref!)).toBe(false); expect(h.secrets.plain.get(after.secret_ref!)).toContain("c_secure_pass"); await json(await h.req(`/site-accounts/${after.id}`, { method: "DELETE" })); }); it("Cookie 失效判定:status expired + 待处理「Cookie 已失效」;网络失败不改状态", async () => { await json(await put({ kind: "cookie", label: "ck", cookie: "c_secure_uid=1; c_secure_pass=stale" })); const acc = await account("ck"); const res = await refresh(acc.id); expect(res).toMatchObject({ ok: false, status: "expired", reason: "auth_expired" }); expect(res.message).toContain("Cookie 已失效"); const [d] = await openDecisions(acc.id); expect(d!.title).toContain("登录失效"); await json(await put({ kind: "cookie", label: "ck", cookie: mock.cookie })); expect((await refresh(acc.id)).ok).toBe(true); mock.failNext(1, 502); const net = await refresh(acc.id); expect(net).toMatchObject({ ok: false, reason: "network", status: "active" }); expect((await account("ck")).status).toBe("active"); expect((await account("ck")).last_error).toMatchObject({ reason: "network" }); await json(await h.req(`/site-accounts/${acc.id}`, { method: "DELETE" })); }); it("refresh-login 缺省异步入队 refreshSiteAccount;定时 sites.user_data 只对 active 账号入队", async () => { const r = await json(await h.req(`/sites/${siteId}/refresh-login`, { body: {} })); expect(r.taskId).toBeTruthy(); expect(r.result).toBeNull(); expect(h.engine.enqueued.at(-1)).toMatchObject({ task: "refreshSiteAccount", input: { mode: "login" } }); const n = await tickSiteUserData(h.rt, "slot-1"); expect(n).toBe(1); expect(h.engine.enqueued.at(-1)).toMatchObject({ task: "refreshSiteAccount", input: { mode: "userData" } }); // userData 模式:会话失效时账密账号自动重登 mock.setLoggedIn(false); const acc = await account(); const res = await refreshSiteAccount(h.rt, acc.id, { mode: "userData" }); expect(res.ok).toBe(true); expect(mock.loginAttempts).toBeGreaterThan(0); }); it("健康检测:同步返回结构化结果并写站点健康列;未登录判 failing", async () => { const r = await json(await h.req(`/sites/${siteId}/health-check`, { body: undefined, method: "POST" })); expect(r).toMatchObject({ siteId, ok: true, status: "ok", loggedIn: true, healthStatus: "healthy" }); const site = await json(await h.req(`/sites/${siteId}`)); expect(site.lastHealth).toMatchObject({ ok: true, status: "ok" }); expect(site.lastHealthAt).toBeTruthy(); expect(site.healthStatus).toBe("healthy"); mock.setLoggedIn(false); const bad = await json(await h.req(`/sites/${siteId}/health-check`, { method: "POST" })); expect(bad).toMatchObject({ ok: false, loggedIn: false, healthStatus: "failing" }); expect(bad.detail).toMatchObject({ reason: "login_page", path: "index.php" }); expect(bad.message).toBe("健康检查:首页返回登录页,Cookie 已失效"); mock.setLoggedIn(true); }); it("HC-1 健康检测:首页不是登录页、只是找不到登录标志时,以近期用户数据解析成功为准", async () => { mock.setHeaderLayout("bare"); try { const r = await json(await h.req(`/sites/${siteId}/health-check`, { method: "POST" })); expect(r).toMatchObject({ ok: true, status: "ok", loggedIn: true, healthStatus: "healthy", message: null }); expect(r.detail).toMatchObject({ reason: "user_data", homepage: "marker_missing" }); expect(r.detail.userDataAt).toBeTruthy(); // 没有近期用户数据:照实报"找不到登录标志(选择器 …)" await h.db .updateTable("site_account") .set({ user_data_at: new Date(Date.now() - 3 * 86_400_000) }) .where("site_id", "=", siteId) .execute(); const bad = await json(await h.req(`/sites/${siteId}/health-check`, { method: "POST" })); expect(bad).toMatchObject({ ok: false, status: "auth_expired", loggedIn: false }); expect(bad.message).toContain("首页里找不到登录标志(选择器 #info_block"); expect(bad.detail).toMatchObject({ reason: "marker_missing" }); const site = await json(await h.req(`/sites/${siteId}`)); expect(site.lastHealth.detail).toMatchObject({ reason: "marker_missing" }); } finally { mock.setHeaderLayout("classic"); } }); it("删除站点:账号与凭证删除,资源保留并标站点已关闭;同一定义可再登记", async () => { const rel = await h.db .insertInto("source_release") .values({ site_id: siteId, site_release_id: "x1", title: "Some.Movie.2024.2160p" }) .returning("id") .executeTakeFirstOrThrow(); const accs = await h.db.selectFrom("site_account").selectAll().where("site_id", "=", siteId).execute(); const refs = accs.flatMap((a) => [a.secret_ref, a.session_secret_ref]).filter(Boolean) as string[]; expect(refs.length).toBeGreaterThan(0); await json(await h.req(`/sites/${siteId}`, { method: "DELETE" })); expect((await h.req(`/sites/${siteId}`)).status).toBe(404); const release = await h.db .selectFrom("source_release") .selectAll() .where("id", "=", rel.id) .executeTakeFirstOrThrow(); expect(release.availability).toBe("site_disabled"); expect(await h.db.selectFrom("site_account").selectAll().where("site_id", "=", siteId).execute()).toHaveLength(0); for (const r of refs) expect(h.secrets.plain.has(r)).toBe(false); const list = await json(await h.req("/sites")); expect(list.items.some((s: { id: string }) => s.id === siteId)).toBe(false); const again = await json( await h.req("/sites", { body: { key: "audiences", name: "观众", adapter: "nexusphp", baseUrl: `${mock.baseUrl}/` }, }), ); expect(again.id).not.toBe(siteId); }); it("定时任务登记:sites.user_data 6h(一个周期后首跑)、sites.health_check 1h", async () => { await h.module.start?.(h.ctx); const reg = scheduleRegistry(h.ctx); expect(reg.get("sites.user_data")?.defaults).toMatchObject({ group: "collect", schedule: { type: "interval", everyMs: 6 * 3_600_000 }, firstRun: "after_interval", }); expect(reg.get("sites.health_check")?.defaults).toMatchObject({ schedule: { type: "interval", everyMs: 3_600_000 }, }); await h.module.stop?.(); }); }); describe.skipIf(!up)("站点中心(mock M-Team API Key)", () => { let h: Harness; let api: MockMteamApi; let siteId: string; beforeAll(async () => { api = await createMockMteamApi(); h = await createHarness("sitecenter_mt"); const site = await json( await h.req("/sites", { body: { key: "mteam", name: "馒头", adapter: "mteam_api", baseUrl: "https://kp.m-team.cc/", rateLimit: { minIntervalMs: 0, concurrency: 10 }, config: { apiBaseUrl: api.baseUrl }, }, }), ); siteId = site.id; }); afterAll(async () => { await h?.close(); await api?.close(); }); it("API Key 校验:有效 → active + 用户数据;无效 → invalid;账密不支持", async () => { await json( await h.req(`/sites/${siteId}/credentials`, { method: "PUT", body: { kind: "api_key", apiKey: api.apiKey } }), ); const ok = (await json(await h.req(`/sites/${siteId}/refresh-login`, { body: { wait: true } }))).result; expect(ok).toMatchObject({ ok: true, status: "active" }); expect(ok.userData).toMatchObject({ userId: "10001", username: "mockuser", upload: 1000 }); await json( await h.req(`/sites/${siteId}/credentials`, { method: "PUT", body: { kind: "api_key", apiKey: "wrong-key" } }), ); const bad = (await json(await h.req(`/sites/${siteId}/refresh-login`, { body: { wait: true } }))).result; expect(bad).toMatchObject({ ok: false, status: "invalid", reason: "auth_expired" }); expect(bad.message).toContain("API Key 无效"); await json( await h.req(`/sites/${siteId}/credentials`, { method: "PUT", body: { kind: "password", label: "pw", username: "u", password: "p" }, }), ); const acc = await h.db.selectFrom("site_account").select("id").where("label", "=", "pw").executeTakeFirstOrThrow(); const pw = (await json(await h.req(`/sites/${siteId}/refresh-login`, { body: { wait: true, accountId: acc.id } }))) .result; expect(pw).toMatchObject({ ok: false, reason: "unsupported", status: "unknown" }); }); it("换凭证类型再换回、同类覆盖:不撞 secret 名(联调:更新凭证 500)", async () => { const put = (body: unknown) => h.req(`/sites/${siteId}/credentials`, { method: "PUT", body }); await json(await put({ kind: "cookie", cookie: "a=1" })); await json(await put({ kind: "cookie", cookie: "a=2" })); await json(await put({ kind: "api_key", apiKey: api.apiKey })); const acc = await h.db .selectFrom("site_account") .selectAll() .where("site_id", "=", siteId) .where("label", "=", "default") .executeTakeFirstOrThrow(); expect(acc.auth_kind).toBe("api_key"); expect(h.secrets.plain.get(acc.secret_ref!)).toBe(api.apiKey); }); it("旧格式凭证 {authKind, credential} 仍可用", async () => { const r = await json( await h.req(`/sites/${siteId}/credentials`, { method: "PUT", body: { label: "legacy", authKind: "passkey", credential: "pk-123" }, }), ); expect(r).toMatchObject({ created: true, authKind: "passkey" }); expect(JSON.stringify(r)).not.toContain("pk-123"); }); }); describe.skipIf(!up)("站点中心(馒头 API 基址)", () => { it("站点地址是 IP / 本地、未配 apiBaseUrl:用户数据走定义里的 API 基址,不拼出 api. 非法 URL(联调:Invalid URL)", async () => { const seen: string[] = []; const fetch = (async (input: RequestInfo | URL) => { seen.push(input instanceof Request ? input.url : String(input)); return new Response(JSON.stringify({ code: "0", data: { id: 7, username: "mt", memberCount: {} } }), { headers: { "content-type": "application/json" }, }); }) as typeof globalThis.fetch; const h = await createHarness("sitecenter_mtbase", { fetch }); try { const site = await json( await h.req("/sites", { body: { key: "mteam", name: "馒头", adapter: "mteam_api", baseUrl: "http://127.0.0.1:9/" }, }), ); await json( await h.req(`/sites/${site.id}/credentials`, { method: "PUT", body: { kind: "api_key", apiKey: "k-1" } }), ); const r = (await json(await h.req(`/sites/${site.id}/refresh-login`, { body: { wait: true } }))).result; expect(r, JSON.stringify(r)).toMatchObject({ ok: true, status: "active" }); expect(seen[0]).toBe("https://api.m-team.cc/api/member/profile"); } finally { await h.close(); } }, 60_000); });