Files
story/test/e2e/visual-family-firmware-signing-key-lifecycle.mjs
T

129 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createRequire } from "node:module";
import { createHash, randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
import { mkdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url);
const { chromium } = require("playwright");
const outputDirectory = new URL("./screenshots/", import.meta.url);
const webURL = process.env.VISUAL_WEB_URL ?? "http://localhost:3100";
const webHostPattern = new RegExp(new URL(webURL).host.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const oidcURLPattern = new RegExp(process.env.VISUAL_OIDC_URL_PATTERN ?? "localhost:58180");
const username = process.env.VISUAL_USERNAME ?? "owner";
const password = process.env.VISUAL_PASSWORD ?? "LocalOnly123!";
const marker = randomUUID().slice(0, 8);
const householdPublicID = "10000000-0000-0000-0000-000000000211";
const freeKey = `visual-free-key-${marker}`;
const blockedKey = `visual-blocked-key-${marker}`;
const revokedKey = `visual-revoked-key-${marker}`;
const freePublicBytes = Buffer.alloc(32, 0);
const blockedPublicBytes = Buffer.alloc(32, 1);
const revokedPublicBytes = Buffer.alloc(32, 2);
const freeFingerprint = createHash("sha256").update(freePublicBytes).digest("hex");
const freeKeyInternalID = randomUUID();
const blockedKeyInternalID = randomUUID();
const revokedKeyInternalID = randomUUID();
const readyReleaseInternalID = randomUUID();
const readyReleasePublicID = randomUUID();
const uploadingReleaseInternalID = randomUUID();
const uploadingReleasePublicID = randomUUID();
await mkdir(outputDirectory, { recursive: true });
function psql(statement) {
return execFileSync("docker", ["compose", "exec", "-T", "postgres", "psql", "-v", "ON_ERROR_STOP=1", "-U", "voice", "-d", "voice_platform", "-Atc", statement], {
cwd: process.cwd(), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"]
}).trim();
}
psql(`
INSERT INTO firmware_signing_key(id,tenant_id,key_id,public_key_base64,status,created_by)
VALUES
('${freeKeyInternalID}','10000000-0000-0000-0000-000000000001','${freeKey}','${freePublicBytes.toString("base64")}','ACTIVE','10000000-0000-0000-0000-000000000101'),
('${blockedKeyInternalID}','10000000-0000-0000-0000-000000000001','${blockedKey}','${blockedPublicBytes.toString("base64")}','ACTIVE','10000000-0000-0000-0000-000000000101'),
('${revokedKeyInternalID}','10000000-0000-0000-0000-000000000001','${revokedKey}','${revokedPublicBytes.toString("base64")}','REVOKED','10000000-0000-0000-0000-000000000101');
UPDATE firmware_signing_key SET revoked_at=now() WHERE id='${revokedKeyInternalID}';
INSERT INTO firmware_release(id,public_id,tenant_id,household_id,model,hardware_revision,version,object_ref,expected_size,expected_digest,signature,signing_key_id,minimum_security_version,release_notes,status,created_by,verified_at)
VALUES
('${readyReleaseInternalID}','${readyReleasePublicID}','10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000201','simulated-terminal','sim-rev-a','6.6.${Number.parseInt(marker.slice(0, 2), 16)}','visual/keys/${readyReleasePublicID}.bin',16,'${"ab".repeat(32)}','${Buffer.alloc(64).toString("base64")}','${freeKey}','1.0.0','页面公钥台账已验证夹具','READY','10000000-0000-0000-0000-000000000101',now()),
('${uploadingReleaseInternalID}','${uploadingReleasePublicID}','10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000201','simulated-terminal','sim-rev-a','6.5.${Number.parseInt(marker.slice(2, 4), 16)}','visual/keys/${uploadingReleasePublicID}.bin',16,'${"cd".repeat(32)}','${Buffer.alloc(64, 1).toString("base64")}','${blockedKey}','1.0.0','页面公钥台账上传中夹具','UPLOADING','10000000-0000-0000-0000-000000000101',NULL);
`);
const keyCountBefore = Number(psql("SELECT count(*) FROM firmware_signing_key;"));
const auditCountBefore = Number(psql("SELECT count(*) FROM audit_event;"));
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? "C:/Program Files/Google/Chrome/Application/chrome.exe" });
const page = await browser.newPage({ viewport: { width: 1440, height: 1150 }, deviceScaleFactor: 1 });
const consoleErrors = [];
const failedRequests = [];
const mutationRequests = [];
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
page.on("request", (request) => {
const pathname = new URL(request.url()).pathname;
if (request.method() === "POST" && (/^\/api\/v1\/households\/[^/]+\/firmware-signing-keys$/.test(pathname) || /^\/api\/v1\/households\/[^/]+\/firmware-signing-keys\/revoke$/.test(pathname))) mutationRequests.push(`${request.method()} ${pathname}`);
});
page.on("requestfailed", (request) => {
const failure = request.failure()?.errorText ?? "failed";
const requestURL = new URL(request.url());
if (request.method() === "GET" && requestURL.pathname === "/api/v1/events" && failure.includes("ERR_ABORTED")) return;
failedRequests.push(`${request.method()} ${request.url()} ${failure}`);
});
try {
await page.goto(webURL, { waitUntil: "networkidle" });
await page.getByRole("button", { name: "登录研发环境" }).click();
await page.waitForURL(oidcURLPattern);
await page.locator("#username").fill(username);
await page.locator("#password").fill(password);
await page.locator("#kc-login").click();
await page.waitForURL(webHostPattern, { timeout: 15000 });
await page.getByRole("heading", { name: "家庭业务工作台" }).waitFor();
await page.getByRole("button", { name: "固件发布", exact: true }).click();
const ledger = page.getByRole("region", { name: "可信固件公钥台账" });
await ledger.getByText("2把活动公钥").waitFor();
const freeCard = ledger.locator("article.firmware-key-card").filter({ hasText: freeKey });
const blockedCard = ledger.locator("article.firmware-key-card").filter({ hasText: blockedKey });
const revokedCard = ledger.locator("article.firmware-key-card").filter({ hasText: revokedKey });
const freeReferencesShown = (await freeCard.textContent())?.includes(freeFingerprint) === true && (await freeCard.textContent())?.includes("上传中 0 · 已验证 1 · 运行中灰度 0") === true;
const blockedReferencesShown = (await blockedCard.textContent())?.includes("上传中 1 · 已验证 0 · 运行中灰度 0") === true;
const blockedRevokeDisabled = await blockedCard.getByRole("button", { name: "吊销公钥" }).isDisabled();
const revokedStatusShown = (await revokedCard.textContent())?.includes("已吊销") === true;
const keyForm = page.locator("form.tool-form").filter({ hasText: "登记可信签名公钥" });
await keyForm.getByLabel("密钥编号").fill(revokedKey);
await keyForm.getByLabel("Ed25519公钥(Base64)").fill(revokedPublicBytes.toString("base64"));
const revokedRegistrationBlocked = await keyForm.getByRole("button", { name: "核对并登记公钥" }).isDisabled();
const revokedRegistrationExplained = (await keyForm.textContent())?.includes("该编号已经吊销,必须使用新的轮换编号") === true;
await keyForm.getByLabel("密钥编号").fill(freeKey);
await keyForm.getByLabel("Ed25519公钥(Base64)").fill(Buffer.alloc(32, 9).toString("base64"));
await keyForm.getByRole("button", { name: "核对并登记公钥" }).click();
await page.getByRole("alert").filter({ hasText: "该密钥编号已绑定另一把公钥,不能静默替换" }).waitFor();
const replacementBlockedBeforeControl = await page.getByRole("form", { name: "家庭业务受控操作" }).count() === 0;
await freeCard.getByRole("button", { name: "吊销公钥" }).click();
const control = page.getByRole("form", { name: "家庭业务受控操作" });
await control.waitFor();
const text = await control.textContent();
const revokeTargetBound = text?.includes(householdPublicID) === true && text?.includes(freeKey) === true && text?.includes(freeFingerprint) === true && text?.includes("上传中版本 0") === true && text?.includes("已验证版本 1") === true && text?.includes("运行中灰度 0") === true && text?.includes("不能再创建新灰度") === true;
const action = control.getByRole("button", { name: "确认吊销可信公钥" });
const revokeNeedsAcknowledgement = await action.isDisabled();
await control.getByLabel(/我确认吊销密钥/).check();
const revokeActionEnabled = await action.isEnabled();
await control.screenshot({ path: fileURLToPath(new URL("family-firmware-signing-key-revoke-control.png", outputDirectory)) });
await control.getByRole("button", { name: "取消" }).click();
const revokeCancelClosed = await control.count() === 0;
const keysUnchanged = Number(psql("SELECT count(*) FROM firmware_signing_key;")) === keyCountBefore && psql(`SELECT status FROM firmware_signing_key WHERE id='${freeKeyInternalID}';`) === "ACTIVE";
const auditUnchanged = Number(psql("SELECT count(*) FROM audit_event;")) === auditCountBefore;
const overflow = await page.evaluate(() => [...document.querySelectorAll("body *")]
.filter((element) => element instanceof HTMLElement && element.scrollWidth > element.clientWidth + 2 && getComputedStyle(element).overflowX === "visible")
.slice(0, 20)
.map((element) => `${element.tagName}.${element.className}: ${element.clientWidth}/${element.scrollWidth}`));
const result = { freeReferencesShown, blockedReferencesShown, blockedRevokeDisabled, revokedStatusShown, revokedRegistrationBlocked, revokedRegistrationExplained, replacementBlockedBeforeControl, revokeTargetBound, revokeNeedsAcknowledgement, revokeActionEnabled, revokeCancelClosed, keysUnchanged, auditUnchanged, mutationRequests, overflow, consoleErrors, failedRequests };
console.log(JSON.stringify(result, null, 2));
if (Object.entries(result).some(([key, value]) => !["mutationRequests", "overflow", "consoleErrors", "failedRequests"].includes(key) && value !== true) || mutationRequests.length || overflow.length || consoleErrors.length || failedRequests.length) throw new Error("Firmware signing key lifecycle visual smoke failed");
} finally {
await browser.close();
psql(`DELETE FROM firmware_release WHERE id IN ('${readyReleaseInternalID}','${uploadingReleaseInternalID}'); DELETE FROM firmware_signing_key WHERE id IN ('${freeKeyInternalID}','${blockedKeyInternalID}','${revokedKeyInternalID}');`);
}