diff --git a/config/platform-infra/public-edge.yaml b/config/platform-infra/public-edge.yaml index adc28a57..2e659980 100644 --- a/config/platform-infra/public-edge.yaml +++ b/config/platform-infra/public-edge.yaml @@ -134,4 +134,4 @@ targets: - id: superapi configRef: /root/superapi/config/superapi.yaml path: publicExposure - sourceCommit: 468d24c0fdb8c67fc66a9e6cdebe92c157243482 + sourceCommit: 076c1ec9f4fa7119db809599026674e5c2b9beb1 diff --git a/scripts/src/web-probe-superapi-smoke.ts b/scripts/src/web-probe-superapi-smoke.ts index a9c37b68..e8d6c445 100644 --- a/scripts/src/web-probe-superapi-smoke.ts +++ b/scripts/src/web-probe-superapi-smoke.ts @@ -16,6 +16,7 @@ interface SmokePage { } interface SmokeProfile { + actor: "admin" | "user"; pages: SmokePage[]; viewport: { width: number; height: number }; navigationTimeoutMs: number; @@ -35,6 +36,21 @@ interface SuperApiWebProbeConfig { authenticatedPath: string; authenticatedSelector: string; }; + userAuthentication: { + mode: "ui-form-memory"; + path: string; + providerId: string; + usernameSelector: string; + passwordSelector: string; + submitSelector: string; + authenticatedPath: string; + authenticatedSelector: string; + credentials: { + sourceRef: string; + usernameKey: string; + passwordKey: string; + }; + }; runner: { node: string; lane: string }; origin: { nativeBaseUrl: string; browserProxyMode: "auto" | "direct" }; defaultSmokeProfile: string; @@ -83,8 +99,10 @@ export function resolveSuperApiProductSmoke(options: SuperApiProductSmokeOptions commandLabel, suppressAdHocWarning: true, generatedHints: [ - "该命令由 SuperAPI owning YAML 驱动,并复用统一管理员密码、远端浏览器、内存门禁和证据恢复链。", - "smoke 只读取 Skill、API Key 和请求历史页面,不执行管理写操作。", + `该命令由 SuperAPI owning YAML 驱动,并使用 ${profile.actor === "admin" ? "统一管理员" : "普通测试用户"}凭据、远端浏览器、内存门禁和证据恢复链。`, + profile.actor === "admin" + ? "smoke 只读取 Skill、API Key 和请求历史页面,不执行管理写操作。" + : "smoke 只读取 Skill 和当前用户 API Key,并验证请求历史和 API Key 注册入口不可访问。", ], generatedPreferredCommands: { rerun: commandLabel }, scriptSource: { @@ -99,20 +117,30 @@ export function resolveSuperApiProductSmoke(options: SuperApiProductSmokeOptions export function runWebProbeSuperApiSmoke(options: SuperApiProductSmokeOptions): Record { const resolved = resolveSuperApiProductSmoke(options); - const password = readFileSync(resolved.config.auth.passwordPath, "utf8").trim(); - if (password === "") throw new Error("SuperAPI Web smoke 密码声明为空"); - const fingerprint = `sha256:${createHash("sha256").update(password).digest("hex")}`; + const credential = resolved.profile.actor === "admin" + ? { + username: resolved.config.auth.username, + password: readFileSync(resolved.config.auth.passwordPath, "utf8").trim(), + sourceRef: resolved.config.auth.passwordPath, + keys: ["contents"], + authentication: resolved.config.authentication, + } + : readUserCredential(resolved.config); + if (credential.password === "") throw new Error("SuperAPI Web smoke 密码声明为空"); + const fingerprint = `sha256:${createHash("sha256") + .update(`${credential.username}\0${credential.password}`) + .digest("hex")}`; const laneSpec = hwlabRuntimeLaneSpecForNode(resolved.config.runner.lane, resolved.config.runner.node); const result = runManagedNodeWebProbeScript( resolved.scriptOptions, laneSpec, - { username: resolved.config.auth.username, password }, + { username: credential.username, password: credential.password }, { product: "superapi", target: options.target, source: { - sourceRef: resolved.config.auth.passwordPath, - keys: [{ key: "contents", present: true }], + sourceRef: credential.sourceRef, + keys: credential.keys.map((key) => ({ key, present: true })), fingerprint, valuesPrinted: false, }, @@ -120,12 +148,12 @@ export function runWebProbeSuperApiSmoke(options: SuperApiProductSmokeOptions): }, { mode: "ui-form-memory", - loginPath: resolved.config.authentication.path, - usernameSelector: resolved.config.authentication.usernameSelector, - passwordSelector: resolved.config.authentication.passwordSelector, - submitSelector: resolved.config.authentication.submitSelector, - authenticatedPath: resolved.config.authentication.authenticatedPath, - authenticatedSelector: resolved.config.authentication.authenticatedSelector, + loginPath: credential.authentication.path, + usernameSelector: credential.authentication.usernameSelector, + passwordSelector: credential.authentication.passwordSelector, + submitSelector: credential.authentication.submitSelector, + authenticatedPath: credential.authentication.authenticatedPath, + authenticatedSelector: credential.authentication.authenticatedSelector, }, ); return { @@ -134,6 +162,7 @@ export function runWebProbeSuperApiSmoke(options: SuperApiProductSmokeOptions): product: "superapi", target: options.target, profile: resolved.profileName, + actor: resolved.profile.actor, configRef: resolved.configRef, mutation: false, valuesPrinted: false, @@ -168,7 +197,10 @@ export default async function superApiManagementSmoke({ page, goto, wait, screen networkCount += 1; const item = { method: response.request().method(), path: parsed.pathname, status: response.status() }; if (network.length < config.outputLimits.network) network.push(item); - if (response.status() >= 400) addFailure("HTTP " + response.status() + " " + parsed.pathname); + const expectedDenied = config.actor === "user" + && response.status() === 403 + && parsed.pathname === "/api/admin/history"; + if (response.status() >= 400 && !expectedDenied) addFailure("HTTP " + response.status() + " " + parsed.pathname); }); page.on("requestfailed", (request) => { const parsed = safeUrl(request.url()); @@ -238,31 +270,33 @@ export default async function superApiManagementSmoke({ page, goto, wait, screen screenshots.push(interactionShot); interactions.push({ id: item.interaction, ok, ...bindingEvidence, screenshot: interactionShot }); await dialog.getByLabel("关闭", { exact: true }).click(); - await page.getByRole("button", { name: "注册 API Key", exact: true }).click(); - const registerDialog = page.getByRole("dialog", { name: "注册 API Key" }); - await registerDialog.waitFor({ state: "visible", timeout: config.navigationTimeoutMs }); - const registrationEvidence = await registerDialog.evaluate((root) => { - const alias = root.querySelector("input:not([type='password'])"); - const apiKey = root.querySelector("input[type='password']"); - return { - aliasVisible: Boolean(alias && alias.getBoundingClientRect().width > 0), - apiKeyVisible: Boolean(apiKey && apiKey.getBoundingClientRect().width > 0), - providerLoginAbsent: root.querySelector("input[type='email']") === null, - }; - }); - const registrationOk = registrationEvidence.aliasVisible - && registrationEvidence.apiKeyVisible - && registrationEvidence.providerLoginAbsent; - if (!registrationOk) addFailure("管理员 API Key 手工注册入口不完整"); - const registrationShot = await screenshot("superapi-api-key-admin-register.png"); - screenshots.push(registrationShot); - interactions.push({ - id: "api-key-admin-register", - ok: registrationOk, - ...registrationEvidence, - screenshot: registrationShot, - }); - await registerDialog.getByLabel("关闭", { exact: true }).click(); + if (config.actor === "admin") { + await page.getByRole("button", { name: "注册 API Key", exact: true }).click(); + const registerDialog = page.getByRole("dialog", { name: "注册 API Key" }); + await registerDialog.waitFor({ state: "visible", timeout: config.navigationTimeoutMs }); + const registrationEvidence = await registerDialog.evaluate((root) => { + const alias = root.querySelector("input:not([type='password'])"); + const apiKey = root.querySelector("input[type='password']"); + return { + aliasVisible: Boolean(alias && alias.getBoundingClientRect().width > 0), + apiKeyVisible: Boolean(apiKey && apiKey.getBoundingClientRect().width > 0), + providerLoginAbsent: root.querySelector("input[type='email']") === null, + }; + }); + const registrationOk = registrationEvidence.aliasVisible + && registrationEvidence.apiKeyVisible + && registrationEvidence.providerLoginAbsent; + if (!registrationOk) addFailure("管理员 API Key 手工注册入口不完整"); + const registrationShot = await screenshot("superapi-api-key-admin-register.png"); + screenshots.push(registrationShot); + interactions.push({ + id: "api-key-admin-register", + ok: registrationOk, + ...registrationEvidence, + screenshot: registrationShot, + }); + await registerDialog.getByLabel("关闭", { exact: true }).click(); + } } if (item.interaction === "github-scan") { await page.getByRole("button", { name: "从 GitHub 导入" }).click(); @@ -353,6 +387,37 @@ export default async function superApiManagementSmoke({ page, goto, wait, screen pages.push({ id: item.id, path: new URL(page.url()).pathname, dom, screenshot: shot }); recordStep("读取 SuperAPI " + item.id + " 页面", { ok: dom.pageId === item.id && !dom.error, path: new URL(page.url()).pathname, rowCount: dom.rowCount }); } + if (config.actor === "user") { + await goto("/api-keys", { selectors: ["[data-page='api-keys']"], readinessTimeoutMs: config.navigationTimeoutMs, attempts: 3 }); + const accessEvidence = await page.evaluate(() => { + const navigationLabels = Array.from(document.querySelectorAll("nav a")) + .map((item) => item.textContent?.trim() || ""); + const registerVisible = Array.from(document.querySelectorAll("button")) + .some((item) => item.textContent?.includes("注册 API Key")); + return { + historyNavigationHidden: !navigationLabels.includes("请求历史"), + registerVisible, + }; + }); + const historyResponse = await page.request.get(new URL("/api/admin/history", page.url()).toString(), { + headers: { accept: "application/json" }, + }); + const historyStatus = historyResponse.status(); + await goto("/history", { selectors: ["[data-page='skills']"], readinessTimeoutMs: config.navigationTimeoutMs, attempts: 3 }); + const deepLinkPath = new URL(page.url()).pathname; + const accessOk = accessEvidence.historyNavigationHidden + && accessEvidence.registerVisible === false + && historyStatus === 403 + && deepLinkPath === "/skills"; + if (!accessOk) addFailure("普通用户请求历史或 API Key 注册入口仍可访问"); + interactions.push({ + id: "ordinary-user-access", + ok: accessOk, + ...accessEvidence, + historyStatus, + deepLinkPath, + }); + } const mutatingRequests = network.filter((item) => !["GET", "HEAD"].includes(item.method) && !(item.method === "POST" && item.path === "/api/admin/skills/github/scan")); @@ -399,6 +464,8 @@ function readSuperApiWebProbeConfig(): SuperApiWebProbeConfig { const webProbe = record(root.webProbe, "webProbe"); if (webProbe.enabled !== true) throw new Error(`${configPath} webProbe.enabled=false`); const authentication = record(webProbe.authentication, "webProbe.authentication"); + const userAuthentication = record(webProbe.userAuthentication, "webProbe.userAuthentication"); + const userCredentials = record(userAuthentication.credentials, "webProbe.userAuthentication.credentials"); const runner = record(webProbe.runner, "webProbe.runner"); const origin = record(webProbe.origin, "webProbe.origin"); const exposure = record(root.publicExposure, "publicExposure"); @@ -418,6 +485,23 @@ function readSuperApiWebProbeConfig(): SuperApiWebProbeConfig { authenticatedPath: pathValue(authentication.authenticatedPath, "webProbe.authentication.authenticatedPath"), authenticatedSelector: text(authentication.authenticatedSelector, "webProbe.authentication.authenticatedSelector"), }, + userAuthentication: { + mode: userAuthentication.mode === "ui-form-memory" + ? "ui-form-memory" + : (() => { throw new Error("webProbe.userAuthentication.mode 必须是 ui-form-memory"); })(), + path: pathValue(userAuthentication.path, "webProbe.userAuthentication.path"), + providerId: text(userAuthentication.providerId, "webProbe.userAuthentication.providerId"), + usernameSelector: text(userAuthentication.usernameSelector, "webProbe.userAuthentication.usernameSelector"), + passwordSelector: text(userAuthentication.passwordSelector, "webProbe.userAuthentication.passwordSelector"), + submitSelector: text(userAuthentication.submitSelector, "webProbe.userAuthentication.submitSelector"), + authenticatedPath: pathValue(userAuthentication.authenticatedPath, "webProbe.userAuthentication.authenticatedPath"), + authenticatedSelector: text(userAuthentication.authenticatedSelector, "webProbe.userAuthentication.authenticatedSelector"), + credentials: { + sourceRef: absolutePath(userCredentials.sourceRef, "webProbe.userAuthentication.credentials.sourceRef"), + usernameKey: text(userCredentials.usernameKey, "webProbe.userAuthentication.credentials.usernameKey"), + passwordKey: text(userCredentials.passwordKey, "webProbe.userAuthentication.credentials.passwordKey"), + }, + }, runner: { node: text(runner.node, "webProbe.runner.node"), lane: text(runner.lane, "webProbe.runner.lane") }, origin: { nativeBaseUrl: httpsUrl(origin.nativeBaseUrl, "webProbe.origin.nativeBaseUrl"), @@ -438,6 +522,9 @@ function smokeProfile(value: unknown, name: string): SmokeProfile { const limits = record(input.outputLimits, `webProbe.smokeProfiles.${name}.outputLimits`); if (!Array.isArray(input.pages) || input.pages.length === 0) throw new Error(`webProbe.smokeProfiles.${name}.pages 必须是非空列表`); return { + actor: input.actor === "admin" || input.actor === "user" + ? input.actor + : (() => { throw new Error(`webProbe.smokeProfiles.${name}.actor 必须是 admin 或 user`); })(), pages: input.pages.map((page, index) => { const item = record(page, `webProbe.smokeProfiles.${name}.pages[${index}]`); return { @@ -460,6 +547,27 @@ function smokeProfile(value: unknown, name: string): SmokeProfile { }; } +function readUserCredential(config: SuperApiWebProbeConfig): { + username: string; + password: string; + sourceRef: string; + keys: string[]; + authentication: SuperApiWebProbeConfig["userAuthentication"]; +} { + const source = config.userAuthentication.credentials; + const parsed = record( + JSON.parse(readFileSync(source.sourceRef, "utf8")) as unknown, + "webProbe.userAuthentication.credentials source", + ); + return { + username: text(parsed[source.usernameKey], `credential.${source.usernameKey}`), + password: text(parsed[source.passwordKey], `credential.${source.passwordKey}`), + sourceRef: source.sourceRef, + keys: [source.usernameKey, source.passwordKey], + authentication: config.userAuthentication, + }; +} + function interaction(value: unknown): SmokePage["interaction"] { if (value === undefined) return "none"; if (value === "github-scan" || value === "api-key-bindings" || value === "history-detail") return value;