feat(sources): site center backend (credentials, refresh login, user data, health check, delete)
PUT /sites/{id}/credentials by kind (moved from auth-settings, legacy body
accepted), POST refresh-login (async task or wait), POST health-check,
GET user-data history, DELETE /sites/{id} (soft delete, releases kept),
DELETE /site-accounts/{id}, GET /sites?include=accounts; refreshSiteAccount
task with login-failure decisions and auto re-login on auth_expired;
scheduled jobs sites.user_data (6h) and sites.health_check (1h);
settings.sites defaults for rate limit, snapshot interval, UA and proxy.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
d6fca25679
commit
05a7706dde
15 files changed
+1602
-179
No files matched your search
@@ -238,43 +238,6 @@ describe.skipIf(!hasPg)("W2-A 管理 API(真实 PG)", () => {
|
||||
expect(v.verifyDetail.code).toBe("ENOENT");
|
||||
expect((await admin("DELETE", `/path-mappings/${m.id}`)).status).toBe(200);
|
||||
});
|
||||
|
||||
it("PUT /sites/{id}/credentials:新建/替换账号凭证,不回显,旧 secret 删除;未知站点 404", async () => {
|
||||
const site = await sql<{ id: string }>`insert into site (key, name, adapter, base_url)
|
||||
values ('audiences', '观众', 'nexusphp', 'https://audiences.me') returning id`.execute(h.db);
|
||||
const siteId = site.rows[0]!.id;
|
||||
const r1 = await json(
|
||||
await admin("PUT", `/sites/${siteId}/credentials`, {
|
||||
authKind: "cookie",
|
||||
credential: "c_secure_uid=1; pass=abc",
|
||||
}),
|
||||
);
|
||||
expect(r1.data).toMatchObject({ created: true, label: "default", authKind: "cookie" });
|
||||
expect(JSON.stringify(r1)).not.toContain("pass=abc");
|
||||
expect(await h.ctx.secrets.resolve(r1.data.secretRef)).toBe("c_secure_uid=1; pass=abc");
|
||||
const r2 = await json(
|
||||
await admin("PUT", `/sites/${siteId}/credentials`, {
|
||||
authKind: "password",
|
||||
credential: "pw-123456",
|
||||
username: "me",
|
||||
}),
|
||||
);
|
||||
expect(r2.data).toMatchObject({ created: false, siteAccountId: r1.data.siteAccountId });
|
||||
await expect(h.ctx.secrets.resolve(r1.data.secretRef)).rejects.toThrow(/不存在/);
|
||||
const acc = await sql<{ auth_kind: string; user_info: { username?: string } }>`
|
||||
select auth_kind, user_info from site_account where id = ${r1.data.siteAccountId}`.execute(h.db);
|
||||
expect(acc.rows[0]).toMatchObject({ auth_kind: "password", user_info: { username: "me" } });
|
||||
expect(
|
||||
(
|
||||
await admin("PUT", "/sites/00000000-0000-4000-8000-000000000000/credentials", {
|
||||
authKind: "cookie",
|
||||
credential: "x",
|
||||
})
|
||||
).status,
|
||||
).toBe(404);
|
||||
noLeak("pass=abc");
|
||||
noLeak("pw-123456");
|
||||
});
|
||||
});
|
||||
|
||||
describe("审计查询", () => {
|
||||
@@ -289,11 +252,9 @@ describe.skipIf(!hasPg)("W2-A 管理 API(真实 PG)", () => {
|
||||
const ids = [...p1.data.items, ...p2.data.items].map((e: { id: string }) => e.id);
|
||||
expect(new Set(ids).size).toBe(6);
|
||||
expect(p1.data.items[0].at >= p2.data.items[0].at).toBe(true);
|
||||
const t = await json(await admin("GET", "/audit-events?targetType=site_account"));
|
||||
expect(t.data.items.map((e: { action: string }) => e.action)).toEqual([
|
||||
"site_account.credentials_set",
|
||||
"site_account.credentials_set",
|
||||
]);
|
||||
const t = await json(await admin("GET", "/audit-events?targetType=api_key&limit=200"));
|
||||
expect(t.data.items.length).toBeGreaterThan(0);
|
||||
expect(t.data.items.every((e: { action: string }) => e.action.startsWith("api_key."))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { EventBus } from "../../src/http/sse.ts";
|
||||
import { createSourcesIdentityModule } from "../../src/modules/sources-identity/index.ts";
|
||||
import type { RuntimeOverrides } from "../../src/modules/sources-identity/runtime.ts";
|
||||
import type { SecretStore, ServerContext, SettingsReader } from "../../src/modules/types.ts";
|
||||
import { withSchemaLock } from "../auth-settings/harness.ts";
|
||||
import { DEV_KEY, memoryLogger, memoryStore } from "../helpers.ts";
|
||||
|
||||
export const READ_KEY = "w2b-read-key";
|
||||
@@ -141,7 +142,8 @@ export async function createHarness(name: string, opts: HarnessOptions = {}): Pr
|
||||
pool: new pg.Pool({ connectionString: PG_URL, max: 8, options: `-c search_path=${schema},public` }),
|
||||
}),
|
||||
}) as unknown as Kysely<DB>;
|
||||
await migrateToLatest(db, { migrationTableSchema: schema });
|
||||
// 与其他测试进程共用 PG:迁移内省会看到别处正在删除的 schema,用同一把咨询锁排队并重试
|
||||
await withSchemaLock(() => migrateToLatest(db, { migrationTableSchema: schema }));
|
||||
|
||||
const { logger } = memoryLogger();
|
||||
const engine = fakeEngine();
|
||||
@@ -206,7 +208,7 @@ export async function createHarness(name: string, opts: HarnessOptions = {}): Pr
|
||||
},
|
||||
async close() {
|
||||
await db.destroy();
|
||||
await sql.raw(`drop schema if exists ${schema} cascade`).execute(admin);
|
||||
await withSchemaLock(() => sql.raw(`drop schema if exists ${schema} cascade`).execute(admin));
|
||||
await admin.destroy();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -69,12 +69,13 @@ describe.skipIf(!up)("M-Team 快照与任务注册", () => {
|
||||
api.setFailMode(null);
|
||||
});
|
||||
|
||||
it("模块注册 4 个任务与 3 条 outbox 路由;任务处理器经 step.run 调服务", async () => {
|
||||
it("模块注册 6 个任务与 3 条 outbox 路由;任务处理器经 step.run 调服务", async () => {
|
||||
const reg = new TaskRegistry();
|
||||
h.module.registerTasks(reg, h.ctx);
|
||||
expect(reg.names()).toEqual([
|
||||
"ingestFeed",
|
||||
"refreshMetadata",
|
||||
"refreshSiteAccount",
|
||||
"resolveObservation",
|
||||
"searchSubscription",
|
||||
"snapshotSite",
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
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" }));
|
||||
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" });
|
||||
mock.setLoggedIn(true);
|
||||
});
|
||||
|
||||
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("旧格式凭证 {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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user