feat(web): token 数以 K 输入显示,已存凭证显示掩码占位(FIX-8)

- 新增 UnitInput 与 kind=tokens:上下文预算、单次 token 上限、自定义模型上下文窗口以 K 输入(允许小数),存储仍为原始 token 数;校验与提示用 K 表述;列表/详情 token 数统一 K/M 简写(formatTokens)
- 新增 SecretInput 与 kind=secret:已保存时显示「已设置 ••••••••」(有服务端掩码时带末 4 位),聚焦直接输入替换,留空保持,「清除」确认后保存生效;替换模型服务、TMDB、网页搜索、下载器、媒体服务器、浏览器服务、站点账号、Passkey、本机 Token 各处
- 统一只写凭证服务端语义:省略或空串 = 不变,null = 清除(contracts secretInput);下载器/媒体服务器新增清除;站点凭证同类更新可留空保持密码/Cookie/API Key/TOTP,TOTP 可 null 清除,不再误删 TOTP 与 UA
- contextBudget 最小值 4096→4000、contextWindow 1024→1000,便于按 K 填写
- 测试:换算与校验、SecretInput 渲染与状态、各接口留空不变/显式清除

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-30 00:10:46 +08:00
1 parent 68f53390f6
commit fa344a7184
39 files changed
+1175 -232

No files matched your search

@@ -19,6 +19,7 @@ import {
PathMappingCreate,
PathMappingUpdate,
type SecretPurpose,
secretInput,
} from "@umt/contracts";
import { z } from "zod";
import { ApiException } from "../../http/errors.ts";
@@ -172,6 +173,14 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
return await ctx.secrets.create({ purpose, plaintext });
};
/** 审计里的字段名:凭证只记"设置 / 清除",不记值;空串(不变)不记 */
const auditFields = (body: Record<string, unknown>) =>
Object.keys(body).flatMap((k) => {
if (k !== "credential") return [k];
const c = secretInput(body.credential as string | null | undefined);
return c === undefined ? [] : [c === null ? "credential(cleared)" : "credential(set)"];
});
// ── library_root ──
const sentinel = async (localPath: string, file: string | null | undefined) => {
const name = file || DEFAULT_ROOT_SENTINEL;
@@ -254,7 +263,8 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
return { ...curSettings, ...(next.settings ?? {}), authKind };
};
endpoint(app, DL.create, async ({ body, actor }) => {
const { credential, authKind, ...rest } = body;
const { credential: rawCredential, authKind, ...rest } = body;
const credential = secretInput(rawCredential) ?? undefined;
const settings = dlAuth(authKind, { ...rest, ...(credential ? { credential } : {}) });
const keep = authKind !== "none" && credential;
const ref = keep ? await ctx.secrets.create({ purpose: "downloader_password", plaintext: credential }) : null;
@@ -277,13 +287,18 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
}
});
endpoint(app, DL.update, async ({ params, body, actor }) => {
const { credential, authKind, ...rest } = body;
// 只写凭证统一语义:省略或空串 = 不变,null = 清除
const { credential: rawCredential, authKind, ...rest } = body;
const credential = secretInput(rawCredential);
const cur = await getRow("downloader_instance", params.id);
const curAuth = ((cur.settings ?? {}) as Record<string, unknown>).authKind;
if (credential === null && (authKind ?? curAuth) === "api_key")
throw new ApiException("validation_failed", "API Key 认证不能清除 API Key;不用认证时把认证方式改为无认证");
const settings = dlAuth(authKind, { ...rest, ...(credential ? { credential } : {}) }, cur);
const values = toDb({ ...rest, ...(settings ? { settings } : {}) }, JSON_COLS);
let dropRef: string | null = null;
if (authKind === "none") {
// 无认证:不再保留密码 / Key
if (authKind === "none" || credential === null) {
// 无认证或显式清除:不再保留密码 / Key
if (cur.secret_ref) {
values.secret_ref = null;
dropRef = cur.secret_ref;
@@ -299,7 +314,7 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
"downloader_instance.update",
{ type: "downloader_instance", id: row.id },
{
fields: Object.keys(body).map((k) => (k === "credential" ? "credential(set)" : k)),
fields: auditFields(body),
},
);
return toApi(row);
@@ -354,7 +369,8 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
list("media_server_instance", "media-server-instances", MS.list);
get("media_server_instance", "media-server-instances", MS.get);
endpoint(app, MS.create, async ({ body, actor }) => {
const { credential, ...rest } = body;
const { credential: rawCredential, ...rest } = body;
const credential = secretInput(rawCredential) ?? undefined;
const ref = credential
? await ctx.secrets.create({ purpose: "media_server_api_key", plaintext: credential })
: null;
@@ -374,20 +390,27 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
}
});
endpoint(app, MS.update, async ({ params, body, actor }) => {
const { credential, ...rest } = body;
// 只写凭证统一语义:省略或空串 = 不变,null = 清除
const { credential: rawCredential, ...rest } = body;
const credential = secretInput(rawCredential);
const values = toDb(rest, JSON_COLS);
if (credential) {
let dropRef: string | null = null;
if (credential !== undefined) {
const cur = await getRow("media_server_instance", params.id);
values.secret_ref = await putCredential(cur.secret_ref, "media_server_api_key", credential);
if (credential === null) {
values.secret_ref = null;
dropRef = cur.secret_ref ?? null;
} else values.secret_ref = await putCredential(cur.secret_ref, "media_server_api_key", credential);
}
const row = await update("media_server_instance", params.id, values);
if (dropRef) await ctx.secrets.remove(dropRef).catch(() => {});
await audit(
ctx,
actor,
"media_server_instance.update",
{ type: "media_server_instance", id: row.id },
{
fields: Object.keys(body).map((k) => (k === "credential" ? "credential(set)" : k)),
fields: auditFields(body),
},
);
return toApi(row);
@@ -5,6 +5,7 @@ import {
mergeSettings,
type SecretPurpose,
type SettingsKey,
secretInput,
} from "@umt/contracts";
import { ApiException } from "../../http/errors.ts";
import type { ModuleApp, ServerContext } from "../types.ts";
@@ -75,11 +76,14 @@ export function registerSettingsRoutes(app: ModuleApp, ctx: ServerContext) {
void ctx.events.publish(SETTINGS_CHANGED_EVENT, { keys });
// 审计里不留明文密钥
const logged: Record<string, unknown> = { ...patch };
const mark = (v: string | null | undefined) => {
const x = secretInput(v);
return x === undefined ? undefined : x === null ? "(cleared)" : "(set)";
};
if (patch.metadata && "tmdbApiKey" in patch.metadata)
logged.metadata = { ...patch.metadata, tmdbApiKey: patch.metadata.tmdbApiKey === null ? "(cleared)" : "(set)" };
logged.metadata = { ...patch.metadata, tmdbApiKey: mark(patch.metadata.tmdbApiKey) };
const ws = patch.agent?.webSearch;
if (ws && "apiKey" in ws)
logged.agent = { ...patch.agent, webSearch: { ...ws, apiKey: ws.apiKey === null ? "(cleared)" : "(set)" } };
if (ws && "apiKey" in ws) logged.agent = { ...patch.agent, webSearch: { ...ws, apiKey: mark(ws.apiKey) } };
await audit(ctx, actor, "settings.update", { type: "settings", id: keys.join(",") }, { patch: logged });
return next;
});
@@ -96,7 +100,8 @@ interface SecretSlot {
/** PATCH 里出现的只写密钥字段(undefined = 不改;null = 清除;字符串 = 设置) */
function secretSlots(patch: GlobalSettingsUpdate): SecretSlot[] {
const out: SecretSlot[] = [];
const tmdb = patch.metadata?.tmdbApiKey;
// 只写凭证统一语义:省略或空串 = 不变,null = 清除
const tmdb = secretInput(patch.metadata?.tmdbApiKey);
if (tmdb !== undefined)
out.push({
plaintext: tmdb,
@@ -107,7 +112,7 @@ function secretSlots(patch: GlobalSettingsUpdate): SecretSlot[] {
s.metadata.tmdbApiKeySecretRef = ref;
},
});
const ws = patch.agent?.webSearch?.apiKey;
const ws = secretInput(patch.agent?.webSearch?.apiKey);
if (ws !== undefined)
out.push({
plaintext: ws,
@@ -80,6 +80,9 @@ describe.skipIf(!up)("浏览器服务登记", () => {
const p2 = await h.req(`/browser/providers/${id}/probe`, { body: {} });
expect(p2.status).toBe(200);
expect((p2.body.data as Loose).capabilities.upstreamVersion).toBe("1.17.0-mock");
// 空串 = 保持原凭据(FIX-8 只写凭证统一语义)
const kept = await h.req(`/browser/providers/${id}`, { method: "PATCH", body: { accessKey: "", enabled: true } });
expect(kept.body.data).toMatchObject({ hasAccessKey: true, secretRef: u.body.data.secretRef });
const cleared = await h.req(`/browser/providers/${id}`, { method: "PATCH", body: { accessKey: null } });
expect(cleared.body.data).toMatchObject({ hasAccessKey: false, secretRef: null });
expect((await h.req(`/browser/providers/${id}`, { method: "DELETE" })).status).toBe(200);
+7 -3
View File
@@ -39,6 +39,7 @@ import type {
DownloadArtifact,
NavigationBudget,
} from "@umt/contracts";
import { secretInput } from "@umt/contracts";
import {
type BrowserControlLeaseRow,
type BrowserProviderRow,
@@ -354,8 +355,9 @@ export class BrowserService {
.where("name", "=", body.name)
.executeTakeFirst();
if (exists) throw new BrowserServiceError("conflict", `浏览器服务「${body.name}」已存在`);
const secretRef = body.accessKey
? await this.ctx.secrets.create({ purpose: "other", plaintext: body.accessKey, label: `browser:${body.name}` })
const accessKey = secretInput(body.accessKey);
const secretRef = accessKey
? await this.ctx.secrets.create({ purpose: "other", plaintext: accessKey, label: `browser:${body.name}` })
: null;
const row = await this.ctx.db
.insertInto("browser_provider")
@@ -374,8 +376,10 @@ export class BrowserService {
/** 改地址 / 凭据 / 启用;accessKey = null 清除凭据。camofox-env 的地址与凭据归环境变量管,只能改启用 */
async updateProvider(
id: string,
body: { baseUrl?: string | undefined; accessKey?: string | null | undefined; enabled?: boolean | undefined },
input: { baseUrl?: string | undefined; accessKey?: string | null | undefined; enabled?: boolean | undefined },
): Promise<BrowserProviderView> {
// 只写凭证统一语义:省略或空串 = 不变,null = 清除
const body = { ...input, accessKey: secretInput(input.accessKey) };
const row = await this.ctx.db.selectFrom("browser_provider").selectAll().where("id", "=", id).executeTakeFirst();
if (!row) throw new BrowserServiceError("not_found", "浏览器服务不存在");
const changesAddr = body.baseUrl !== undefined && body.baseUrl !== row.base_url;
+9 -3
View File
@@ -7,7 +7,7 @@ import {
type UpstreamCredentialStore,
type UpstreamSpec,
} from "@umt/agent";
import type { Actor, PiUpstream, PiUpstreamCreate, PiUpstreamUpdate } from "@umt/contracts";
import { type Actor, type PiUpstream, type PiUpstreamCreate, type PiUpstreamUpdate, secretInput } from "@umt/contracts";
import type { PiUpstreamRow } from "@umt/db";
import { ApiException } from "../../http/errors.ts";
import type { ServerContext } from "../types.ts";
@@ -91,8 +91,10 @@ export async function listUpstreams(
export async function createUpstream(
ctx: ServerContext,
actor: Actor | undefined,
body: PiUpstreamCreate,
input: PiUpstreamCreate,
): Promise<PiUpstream> {
// 只写凭证统一语义:空串 = 不设置
const body = { ...input, apiKey: secretInput(input.apiKey) ?? undefined };
validateShape(body);
const id = randomUUID();
let secretRef: string | null = null;
@@ -132,9 +134,13 @@ export async function updateUpstream(
ctx: ServerContext,
actor: Actor | undefined,
row: PiUpstreamRow,
body: PiUpstreamUpdate,
input: PiUpstreamUpdate,
onCredentialDropped: () => void,
): Promise<PiUpstream> {
// 只写凭证统一语义:省略或空串 = 不变,null = 清除
const { apiKey: rawKey, ...rest } = input;
const apiKey = secretInput(rawKey);
const body: PiUpstreamUpdate = apiKey === undefined ? rest : { ...rest, apiKey };
const authKind = body.authKind ?? row.auth_kind;
const baseUrl = body.baseUrl !== undefined ? body.baseUrl : authKind === "oauth" ? null : row.base_url;
const defaultModel = body.defaultModel !== undefined ? body.defaultModel : row.default_model;
@@ -21,7 +21,9 @@ import type {
SiteLoginResult,
SiteUserDataPoint,
} from "@umt/contracts";
import { secretInput } from "@umt/contracts";
import { type SiteAccountRow, type SiteRow, sql } from "@umt/db";
import { ApiException } from "../../../http/errors.ts";
import { toApi } from "../rows.ts";
import type { SourcesRuntime } from "../runtime.ts";
import { type Actor, audit, closeDecisions, type Exec, openDecision, SYSTEM } from "./common.ts";
@@ -92,24 +94,40 @@ const PURPOSE: Record<string, SecretPurpose> = {
password: "site_password",
};
const MISSING_SECRET: Record<string, string> = {
password: "填写密码",
cookie: "粘贴 Cookie",
api_key: "填写 API Key",
};
type NormalizedCredentials =
| {
kind: "password";
label: string;
username: string;
password: string;
totpSecret?: string;
/** undefined = 保持已存密码 */
password?: string | undefined;
/** undefined = 保持;null = 清除 */
totpSecret?: string | null | undefined;
saveCookieOnly?: boolean;
userAgent?: string;
enabled?: boolean;
}
| { kind: "cookie"; label: string; cookie: string; userAgent?: string; enabled?: boolean }
| { kind: "api_key"; label: string; apiKey: string; enabled?: boolean }
| { kind: "cookie"; label: string; cookie?: string | undefined; userAgent?: string; enabled?: boolean }
| { kind: "api_key"; label: string; apiKey?: string | undefined; enabled?: boolean }
| { kind: "passkey"; label: string; passkey: string; enabled?: boolean };
/** 旧格式 {authKind, credential, username?} → 新格式 */
export function normalizeCredentials(body: SiteCredentialsPut): NormalizedCredentials {
if ("kind" in body) return body as NormalizedCredentials;
if ("kind" in body) {
// 只写凭证统一语义:省略或空串 = 保持已存值;TOTP 的 null = 清除
const b = body as NormalizedCredentials;
if (b.kind === "password")
return { ...b, password: secretInput(b.password) ?? undefined, totpSecret: secretInput(b.totpSecret) };
if (b.kind === "cookie") return { ...b, cookie: secretInput(b.cookie) ?? undefined };
if (b.kind === "api_key") return { ...b, apiKey: secretInput(b.apiKey) ?? undefined };
return b;
}
const label = body.label;
const enabled = body.enabled;
const opt = enabled === undefined ? {} : { enabled };
@@ -148,7 +166,7 @@ export async function setSiteCredentials(
// 已有账号的同类凭证原地覆盖(replace),不新建 secret
const pre = await db
.selectFrom("site_account")
.select(["auth_kind", "secret_ref", "passkey_secret_ref", "totp_secret_ref"])
.select(["auth_kind", "secret_ref", "passkey_secret_ref", "totp_secret_ref", "session_secret_ref", "login_name"])
.where("site_id", "=", site.id)
.where("label", "=", label)
.executeTakeFirst();
@@ -213,11 +231,35 @@ export async function setSiteCredentials(
});
} else {
const plaintext = body.kind === "password" ? body.password : body.kind === "cookie" ? body.cookie : body.apiKey;
const ref = await put(pre?.auth_kind === body.kind ? pre.secret_ref : null, PURPOSE[body.kind]!, plaintext);
const sameKind = pre?.auth_kind === body.kind;
// 留空 = 保持已存的主凭证:只在同类账号且已存时可以
const keepMain = plaintext === undefined;
if (keepMain && !(sameKind && pre?.secret_ref))
throw new ApiException("validation_failed", MISSING_SECRET[body.kind] ?? "填写凭证");
const ref =
plaintext === undefined
? pre!.secret_ref!
: await put(sameKind ? pre?.secret_ref : null, PURPOSE[body.kind]!, plaintext);
if (keepMain) reused.add(ref);
// TOTP:新值替换;省略 = 同类账号保持;null 或换类型 = 清除
const keepTotp = body.kind === "password" && body.totpSecret === undefined && sameKind && pre?.totp_secret_ref;
const totpRef =
body.kind === "password" && body.totpSecret
? await put(pre?.totp_secret_ref, "site_password", body.totpSecret, ":totp")
: keepTotp
? pre!.totp_secret_ref
: null;
if (totpRef) reused.add(totpRef);
// 只改 UA / 启用等、密码与登录名都没变:会话 Cookie 仍有效,不丢
const keepSession =
keepMain &&
body.kind === "password" &&
pre?.session_secret_ref &&
pre.login_name === body.username &&
totpRef === pre.totp_secret_ref
? pre.session_secret_ref
: null;
if (keepSession) reused.add(keepSession);
result = await db.transaction().execute(async (trx) => {
const cur = await trx
.selectFrom("site_account")
@@ -236,7 +278,7 @@ export async function setSiteCredentials(
secret_ref: ref,
status: "unknown" as const,
last_error: null,
session_secret_ref: null,
session_secret_ref: keepSession,
totp_secret_ref: totpRef,
login_name: body.kind === "password" ? body.username : null,
user_agent: "userAgent" in body ? (body.userAgent ?? null) : null,
@@ -0,0 +1,144 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { DEV_KEY, json } from "../helpers.ts";
import { bearer, createHarness, type Harness, pgAvailable } from "./harness.ts";
// FIX-8 只写凭证统一语义:省略或空串 = 保持原值,null = 清除,非空 = 替换;明文不出 API。
const hasPg = await pgAvailable();
describe.skipIf(!hasPg)("只写凭证:留空不变 / 显式清除(真实 PG)", () => {
let h: Harness;
let admin: ReturnType<typeof bearer>;
beforeAll(async () => {
h = await createHarness({ engine: false });
admin = bearer(h.app, DEV_KEY);
}, 60_000);
afterAll(async () => {
await h?.close();
});
const secretOf = (ref: string) => h.ctx.secrets.resolve(ref);
it("下载器(账密):空串保持;null 清除;新值原地替换", async () => {
const c = (
await json(
await admin("POST", "/downloader-instances", {
name: "qb-keep",
baseUrl: "http://127.0.0.1:1",
authKind: "password",
username: "u",
credential: "pw-original",
}),
)
).data;
const ref = c.secretRef as string;
expect(ref).toBeTruthy();
const kept = (await json(await admin("PATCH", `/downloader-instances/${c.id}`, { credential: "", name: "qb-k2" })))
.data;
expect(kept.secretRef).toBe(ref);
expect(await secretOf(ref)).toBe("pw-original");
const ws = (await json(await admin("PATCH", `/downloader-instances/${c.id}`, { credential: " " }))).data;
expect(ws.secretRef).toBe(ref);
const replaced = (await json(await admin("PATCH", `/downloader-instances/${c.id}`, { credential: "pw-new" }))).data;
expect(replaced.secretRef).toBe(ref);
expect(await secretOf(ref)).toBe("pw-new");
const cleared = (await json(await admin("PATCH", `/downloader-instances/${c.id}`, { credential: null }))).data;
expect(cleared.secretRef).toBeNull();
await expect(secretOf(ref)).rejects.toThrow(/不存在/);
expect(JSON.stringify(cleared)).not.toContain("pw-");
});
it("下载器(API Key):不能清除 Key(422),留空保持", async () => {
const c = (
await json(
await admin("POST", "/downloader-instances", {
name: "qb-key",
baseUrl: "http://127.0.0.1:1",
authKind: "api_key",
credential: "qbt_key_1",
}),
)
).data;
expect((await admin("PATCH", `/downloader-instances/${c.id}`, { credential: null })).status).toBe(422);
const kept = (await json(await admin("PATCH", `/downloader-instances/${c.id}`, { credential: "" }))).data;
expect(kept.secretRef).toBe(c.secretRef);
// 新建时空串 = 没填:API Key 认证必须有 Key
expect(
(
await admin("POST", "/downloader-instances", {
name: "qb-empty",
baseUrl: "http://127.0.0.1:1",
authKind: "api_key",
credential: "",
})
).status,
).toBe(422);
});
it("媒体服务器:空串保持;null 清除", async () => {
const c = (
await json(
await admin("POST", "/media-server-instances", {
name: "emby-keep",
baseUrl: "http://127.0.0.1:1",
credential: "emby-key-1",
}),
)
).data;
const ref = c.secretRef as string;
const kept = (await json(await admin("PATCH", `/media-server-instances/${c.id}`, { credential: "" }))).data;
expect(kept.secretRef).toBe(ref);
expect(await secretOf(ref)).toBe("emby-key-1");
const cleared = (await json(await admin("PATCH", `/media-server-instances/${c.id}`, { credential: null }))).data;
expect(cleared.secretRef).toBeNull();
await expect(secretOf(ref)).rejects.toThrow(/不存在/);
// 空串新建 = 不存 key
const n = (
await json(
await admin("POST", "/media-server-instances", {
name: "emby-nokey",
baseUrl: "http://127.0.0.1:1",
credential: "",
}),
)
).data;
expect(n.secretRef).toBeNull();
});
it("设置(TMDB / 网页搜索 Key):空串保持;null 清除;审计不留明文", async () => {
const set = (await json(await admin("PATCH", "/settings", { metadata: { tmdbApiKey: "tmdb-secret-1" } }))).data;
const ref = set.metadata.tmdbApiKeySecretRef as string;
expect(ref).toBeTruthy();
const kept = (
await json(await admin("PATCH", "/settings", { metadata: { tmdbApiKey: "", tmdbBaseUrl: "https://t.local" } }))
).data;
expect(kept.metadata.tmdbApiKeySecretRef).toBe(ref);
expect(kept.metadata.tmdbBaseUrl).toBe("https://t.local");
expect(await secretOf(ref)).toBe("tmdb-secret-1");
const cleared = (await json(await admin("PATCH", "/settings", { metadata: { tmdbApiKey: null } }))).data;
expect(cleared.metadata.tmdbApiKeySecretRef).toBeNull();
const ws = (
await json(await admin("PATCH", "/settings", { agent: { webSearch: { provider: "brave", apiKey: "brave-1" } } }))
).data;
const wref = ws.agent.webSearch.apiKeySecretRef as string;
const wkept = (await json(await admin("PATCH", "/settings", { agent: { webSearch: { apiKey: "" } } }))).data;
expect(wkept.agent.webSearch.apiKeySecretRef).toBe(wref);
const wcleared = (await json(await admin("PATCH", "/settings", { agent: { webSearch: { apiKey: null } } }))).data;
expect(wcleared.agent.webSearch.apiKeySecretRef).toBeNull();
const audits = await h.db
.selectFrom("audit_event")
.select("payload")
.where("action", "=", "settings.update")
.execute();
const text = JSON.stringify(audits);
expect(text).not.toContain("tmdb-secret-1");
expect(text).not.toContain("brave-1");
});
});
+5
View File
@@ -148,6 +148,11 @@ describe.skipIf(!hasPg)("Pi 上游(真实 PG)", () => {
(await req("POST", "/pi/upstreams", { name: "OpenAI 主号", provider: "openai", authKind: "api_key" })).status,
).toBe(409);
// 空串 = 保持原 key(FIX-8 只写凭证统一语义);掩码只给前后 4 位
const kept = (await req("PATCH", `/pi/upstreams/${a.id}`, { apiKey: "", name: "OpenAI 主号" })).body.data;
expect(kept).toMatchObject({ hasCredential: true });
expect(kept.credentialMask).toMatch(/^.{4}….{4}$/);
expect(JSON.stringify(kept)).not.toMatch(/secret-\d/);
// 清 key → auth_required;测试报 auth
const cleared = (await req("PATCH", `/pi/upstreams/${a.id}`, { apiKey: null })).body.data;
expect(cleared).toMatchObject({ status: "auth_required", hasCredential: false });
@@ -0,0 +1,87 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createHarness, type Harness, pgAvailable } from "./harness.ts";
// FIX-8 站点账号凭证:同类账号更新时密码 / Cookie / API Key 留空 = 保持,TOTP 省略保持、null 清除;
// 新账号或换类型时必须填。明文不出 API。
const up = await pgAvailable();
// 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)("站点凭证留空不变 / 显式清除", () => {
let h: Harness;
let siteId: string;
const put = (body: unknown) => h.req(`/sites/${siteId}/credentials`, { method: "PUT", body });
const account = () =>
h.db
.selectFrom("site_account")
.selectAll()
.where("site_id", "=", siteId)
.where("label", "=", "default")
.executeTakeFirstOrThrow();
beforeAll(async () => {
h = await createHarness("credkeep");
const site = await json(
await h.req("/sites", {
body: { key: "audiences", name: "观众", adapter: "nexusphp", baseUrl: "http://127.0.0.1:1/" },
}),
);
siteId = site.id;
}, 60_000);
afterAll(async () => {
await h?.close();
});
it("新账号不填密码 → 422", async () => {
expect((await put({ kind: "password", username: "alice" })).status).toBe(422);
expect((await put({ kind: "password", username: "alice", password: " " })).status).toBe(422);
});
it("账密:改登录名不填密码 = 保持密码与 TOTP;null 清除 TOTP", async () => {
const r = await json(await put({ kind: "password", username: "alice", password: "pw-1", totpSecret: "JBSWY3DP" }));
expect(JSON.stringify(r)).not.toContain("pw-1");
const a1 = await account();
expect(h.secrets.plain.get(a1.secret_ref!)).toBe("pw-1");
expect(a1.totp_secret_ref).toBeTruthy();
await json(await put({ kind: "password", username: "alice2", password: "", userAgent: "UA/1" }));
const a2 = await account();
expect(a2.secret_ref).toBe(a1.secret_ref);
expect(h.secrets.plain.get(a2.secret_ref!)).toBe("pw-1");
expect(a2.totp_secret_ref).toBe(a1.totp_secret_ref);
expect(a2.login_name).toBe("alice2");
expect(a2.user_agent).toBe("UA/1");
await json(await put({ kind: "password", username: "alice2", totpSecret: null }));
const a3 = await account();
expect(a3.totp_secret_ref).toBeNull();
expect(h.secrets.plain.get(a3.secret_ref!)).toBe("pw-1");
expect(h.secrets.plain.has(a1.totp_secret_ref!)).toBe(false);
await json(await put({ kind: "password", username: "alice2", password: "pw-2" }));
expect(h.secrets.plain.get((await account()).secret_ref!)).toBe("pw-2");
});
it("换类型必须填新凭证;同类 Cookie / API Key 留空保持", async () => {
expect((await put({ kind: "cookie" })).status).toBe(422);
await json(await put({ kind: "cookie", cookie: "a=1" }));
const c1 = await account();
await json(await put({ kind: "cookie", cookie: "", userAgent: "UA/2" }));
const c2 = await account();
expect(c2.secret_ref).toBe(c1.secret_ref);
expect(h.secrets.plain.get(c2.secret_ref!)).toBe("a=1");
expect((await put({ kind: "api_key", apiKey: "" })).status).toBe(422);
await json(await put({ kind: "api_key", apiKey: "k-1" }));
const k1 = await account();
await json(await put({ kind: "api_key" }));
expect(h.secrets.plain.get((await account()).secret_ref!)).toBe("k-1");
expect((await account()).secret_ref).toBe(k1.secret_ref);
});
});