feat: 验收 SuperAPI 普通用户权限
This commit is contained in:
@@ -134,4 +134,4 @@ targets:
|
||||
- id: superapi
|
||||
configRef: /root/superapi/config/superapi.yaml
|
||||
path: publicExposure
|
||||
sourceCommit: 468d24c0fdb8c67fc66a9e6cdebe92c157243482
|
||||
sourceCommit: 076c1ec9f4fa7119db809599026674e5c2b9beb1
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user