import type { SiteError } from "@umt/adapters"; import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { SAMPLES_PER_SITE } from "../../src/modules/sources-identity/service/failure-sample.ts"; import { snapshotSite } from "../../src/modules/sources-identity/service/snapshot.ts"; import { createHarness, type Harness, pgAvailable, READ_KEY } from "./harness.ts"; // 站点请求失败留样:列表 / 搜索报「页面结构无法识别」时存脱敏页面样本(site_failure_sample),每站只留最近 5 份; // 站点页技术信息经 GET /sites/{id}/failure-samples(列表不带正文)与 /failure-samples/{sampleId}(带正文)取。 // 站点前面是可控的 fetch:返回什么页面由测试决定(不访问真实站点)。 const up = await pgAvailable(); const SECRET = "0123456789abcdef0123456789abcdef"; /** 改版页:认不出种子表,还带着要脱敏的东西 */ const BROKEN = `Audiences :: 种子
欢迎 alice_cn UID 29191

邮箱 alice@example.com

dl
Dune
`; // 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; }; /** 轮询到条件成立(留样是异步写入的) */ async function until(f: () => Promise, ok: (v: T) => boolean, ms = 5_000): Promise { const deadline = Date.now() + ms; for (;;) { const v = await f(); if (ok(v) || Date.now() > deadline) return v; await new Promise((r) => setTimeout(r, 50)); } } describe.skipIf(!up)("站点失败留样", () => { let h: Harness; let siteId: string; let page = BROKEN; const fetchImpl = (async () => new Response(page, { status: 200, headers: { "content-type": "text/html; charset=utf-8", server: "nginx" }, })) as typeof fetch; const site = () => h.db.selectFrom("site").selectAll().where("id", "=", siteId).executeTakeFirstOrThrow(); const rows = () => h.db.selectFrom("site_failure_sample").selectAll().where("site_id", "=", siteId).orderBy("created_at").execute(); beforeAll(async () => { h = await createHarness("failsample", { fetch: fetchImpl }); const s = await json( await h.req("/sites", { body: { key: "audiences", name: "观众", adapter: "nexusphp", baseUrl: "https://aud.test/", rateLimit: { minIntervalMs: 0, concurrency: 10 }, }, }), ); siteId = s.id; await json( await h.req(`/sites/${siteId}/credentials`, { method: "PUT", body: { kind: "cookie", cookie: "c_secure_uid=1; c_secure_pass=abc" }, }), ); }); afterAll(async () => { await h?.close(); }); beforeEach(async () => { page = BROKEN; await h.db.deleteFrom("site_failure_sample").where("site_id", "=", siteId).execute(); }); it("列表快照报 parse_error → 留脱敏样本(URL、状态码、关键响应头、请求类型);接口可看", async () => { const r = await snapshotSite(h.rt, { siteId, kind: "manual" }); expect(r.status).toBe("failed"); expect(r.error).toContain("parse_error"); const got = await until(rows, (x) => x.length > 0); expect(got).toHaveLength(1); const s = got[0]!; expect(s).toMatchObject({ kind: "parse_error", request_kind: "list", http_status: 200, headers: { server: "nginx" }, }); expect(s.message).toContain("页面结构无法识别"); for (const leak of [SECRET, "alice@example.com", "alice_cn", "29191", "topsecretvalue"]) { expect(s.body).not.toContain(leak); expect(s.url).not.toContain(leak); } expect(s.body).toContain("new-grid"); const list = await json(await h.req(`/sites/${siteId}/failure-samples`, { key: READ_KEY })); expect(list.items).toHaveLength(1); expect(list.items[0]).toMatchObject({ id: s.id, kind: "parse_error", requestKind: "list", httpStatus: 200 }); expect(list.items[0].body).toBeUndefined(); expect(list.items[0].bodyBytes).toBeGreaterThan(0); const one = await json(await h.req(`/sites/${siteId}/failure-samples/${s.id}`, { key: READ_KEY })); expect(one.body).toBe(s.body); const miss = await h.req(`/sites/${siteId}/failure-samples/00000000-0000-4000-8000-000000000000`); expect(miss.status).toBe(404); }); it(`每站只留最近 ${SAMPLES_PER_SITE} 份`, async () => { const adapter = h.rt.siteAdapter(await site()); for (let i = 1; i <= 7; i++) { const e = await adapter.search(`k${i}`).catch((x) => x); expect((e as SiteError).kind).toBe("parse_error"); } const kw = (x: { url: string }[]) => x.map((r) => /search=(k\d)/.exec(r.url)?.[1]).join(","); const got = await until(rows, (x) => kw(x) === "k3,k4,k5,k6,k7"); expect(kw(got)).toBe("k3,k4,k5,k6,k7"); expect(got.every((r) => r.request_kind === "search")).toBe(true); }); it("正常页面不留样", async () => { page = `Audiences :: 种子
没有种子
`; const adapter = h.rt.siteAdapter(await site()); await adapter.search("x").catch(() => null); await new Promise((r) => setTimeout(r, 200)); expect(await rows()).toEqual([]); }); });