feat: 验收 SuperAPI 普通用户权限
Pipelines as Code CI / hwlab-web-probe-sentinel-nc01- Success
Pipelines as Code CI / platform-infra-gitea-nc01- Failed
Pipelines as Code CI / unidesk-host- Success

This commit is contained in:
pikastech
2026-07-19 11:18:07 +02:00
parent 68e3a8e99a
commit 64c2c19bd1
2 changed files with 149 additions and 41 deletions
+1 -1
View File
@@ -134,4 +134,4 @@ targets:
- id: superapi
configRef: /root/superapi/config/superapi.yaml
path: publicExposure
sourceCommit: 468d24c0fdb8c67fc66a9e6cdebe92c157243482
sourceCommit: 076c1ec9f4fa7119db809599026674e5c2b9beb1
+148 -40
View File
@@ -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<string, unknown> {
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;