Files
UHD_Media_Tool/apps/server/test/sources-identity/site-center.test.ts
T
rootandClaude Opus 5.5 b1d30bd6ee fix(sites): 观众健康检查误报未登录,健康失败原因细分(HC-1)
观众 2026 版首页没有 <a href=logout>:退出按钮在 userbar 的 data-uploader-actions JSON
里由脚本渲染,旧登录标志 a[href*="logout"] 永远不命中,健康检查报"未登录"而用户数据正常。

- audiences 定义 loginSuccess 改为 #info_block / usercp.php / logout 任一(0.2.1)
- 健康检查判定细分:登录页 / 权限页 / 疑似人机验证 / 找不到登录标志(带选择器),
  原始判定写入 last_health.detail,站点概览技术信息展示
- 首页只是找不到登录标志时,以 24 小时内用户数据解析成功为已登录证据(同 MP)
- 新 SiteError 类 challenge(cf-mitigated: challenge),不算凭据错误;健康状态 challenge
- testkit mock 增加 setHeaderLayout;加观众首页脱敏结构样本的差分测试

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 20:38:48 +08:00

402 lines
18 KiB
TypeScript

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<any> => {
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.<IP> 非法 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);
});