From 57c810ea65188d72f9d3ede7055f88ae97d76c16 Mon Sep 17 00:00:00 2001 From: Walter Oggioni Date: Fri, 23 Oct 2026 05:44:32 +0000 Subject: [PATCH] Polish PWA assets, accessible tabs and bulk selection, harden bulk revoke tests --- README.md | 4 +-- e2e/tests/advanced.spec.js | 2 +- e2e/tests/core.spec.js | 8 +++--- server/tests/test_routes_extra.py | 22 +++++++++++++++ web/index.html | 5 ++++ web/pwa/icons/icon-192.png | Bin 0 -> 6739 bytes web/pwa/icons/icon-512.png | Bin 0 -> 18884 bytes web/pwa/manifest.webmanifest | 12 +++++++++ web/src/pages/certificate_detail.rs | 40 +++++++++++++++++----------- web/src/pages/certificates.rs | 11 ++++---- 10 files changed, 77 insertions(+), 27 deletions(-) create mode 100644 web/pwa/icons/icon-192.png create mode 100644 web/pwa/icons/icon-512.png diff --git a/README.md b/README.md index 12d6c46..1d3d9d8 100644 --- a/README.md +++ b/README.md @@ -237,7 +237,7 @@ e2e/ # Playwright browser suite (Node) fields plus an advanced JSON editor carrying raw OID/DER and other less common extensions. OpenSSL config remains an export format. - The PWA service worker is intentionally not registered: certificate and key - data must never be served stale from a cache. The manifest and icons are - still available for installation. + data must never be served stale from a cache. The manifest and 192/512 + icons remain available for installation. - Column layouts, window geometry, language packs and other desktop-only XCA preferences are not part of the web UI. diff --git a/e2e/tests/advanced.spec.js b/e2e/tests/advanced.spec.js index 57feb54..b111c78 100644 --- a/e2e/tests/advanced.spec.js +++ b/e2e/tests/advanced.spec.js @@ -51,7 +51,7 @@ test("advanced extensions, validation and vault export", async ({ page }) => { await expect(page.getByRole("heading", { name: certName })).toBeVisible(); const certUrl = page.url(); const certApiUrl = certUrl.replace(/\/vaults\//, "/api/vaults/"); - await page.locator(".nav-tabs").getByRole("button", { name: "Extensions", exact: true }).click(); + await page.locator(".nav-tabs").getByRole("tab", { name: "Extensions", exact: true }).click(); await expect(page.getByText("CRL Distribution Points", { exact: false })).toBeVisible(); await expect(page.getByText("1.2.3.4", { exact: false })).toBeVisible(); diff --git a/e2e/tests/core.spec.js b/e2e/tests/core.spec.js index 0dc5ef9..020e34f 100644 --- a/e2e/tests/core.spec.js +++ b/e2e/tests/core.spec.js @@ -54,7 +54,7 @@ test("core PKI workflow", async ({ page }) => { await page.getByRole("button", { name: "Create certificate" }).click(); await expect(page.getByText("CA certificate", { exact: false })).toBeVisible(); const caUrl = page.url(); - await page.locator(".nav-tabs").getByRole("button", { name: "CA", exact: true }).click(); + await page.locator(".nav-tabs").getByRole("tab", { name: "CA", exact: true }).click(); await (await field(page, "CA · default signing template")).selectOption("builtin:tls-server"); await page.getByRole("button", { name: "Save CA" }).click(); await expect(page.getByText("CA properties saved.")).toBeVisible(); @@ -73,7 +73,7 @@ test("core PKI workflow", async ({ page }) => { await page.locator("input[placeholder*='DNS:example.com']").fill(`DNS:${leafName}`); await page.getByRole("button", { name: "Create certificate" }).click(); await expect(page.getByRole("heading", { name: leafName })).toBeVisible(); - await page.locator(".nav-tabs").getByRole("button", { name: "Extensions", exact: true }).click(); + await page.locator(".nav-tabs").getByRole("tab", { name: "Extensions", exact: true }).click(); await expect(page.getByText("serverAuth", { exact: false })).toBeVisible(); // Search @@ -84,14 +84,14 @@ test("core PKI workflow", async ({ page }) => { // Revoke await page.getByRole("link", { name: leafName, exact: true }).click(); - await page.locator(".nav-tabs").getByRole("button", { name: "Revoke", exact: true }).click(); + await page.locator(".nav-tabs").getByRole("tab", { name: "Revoke", exact: true }).click(); await page.getByRole("button", { name: "Revoke certificate" }).click(); await expect(page.getByText("revoked", { exact: true })).toBeVisible(); // CRL from the CA await page.goto(caUrl); await expect(page.getByRole("heading", { name: caName })).toBeVisible(); - await page.locator(".nav-tabs").getByRole("button", { name: "CA", exact: true }).click(); + await page.locator(".nav-tabs").getByRole("tab", { name: "CA", exact: true }).click(); await page.getByRole("button", { name: "Generate CRL" }).click(); await expect(page).toHaveURL(/\/crls\//); await expect(page.getByText(/CRL number \d+ · 1 revoked/)).toBeVisible(); diff --git a/server/tests/test_routes_extra.py b/server/tests/test_routes_extra.py index 2c48ab4..6156a4c 100644 --- a/server/tests/test_routes_extra.py +++ b/server/tests/test_routes_extra.py @@ -379,3 +379,25 @@ class BulkRevocationTests(unittest.TestCase): ) assert response.status_code == 200 assert response.json()["errors"][0]["error"] == "invalid certificate id" + + @async_test + async def test_bulk_revoke_validation_and_isolation(self) -> None: + async with api_client() as client: + with oidc_user("alice"): + vault_id = await _vault(client) + response = await client.post( + f"/api/vaults/{vault_id}/certificates/bulk-revoke", + json={"certificate_ids": ["00000000-0000-0000-0000-000000000000"], "reason": "superseded", "invalidity_date": "not-a-date"}, + ) + assert response.status_code == 400 + response = await client.post( + f"/api/vaults/{vault_id}/certificates/bulk-revoke", + json={"certificate_ids": [str(i) for i in range(201)], "reason": "superseded"}, + ) + assert response.status_code == 400 + with oidc_user("bob"): + response = await client.post( + f"/api/vaults/{vault_id}/certificates/bulk-revoke", + json={"certificate_ids": ["00000000-0000-0000-0000-000000000000"], "reason": "superseded"}, + ) + assert response.status_code == 404 diff --git a/web/index.html b/web/index.html index 9ef5fdd..68db389 100644 --- a/web/index.html +++ b/web/index.html @@ -8,12 +8,17 @@ + + + + + diff --git a/web/pwa/icons/icon-192.png b/web/pwa/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..45adc6bd223519d2882b6fef5901635941d8c6eb GIT binary patch literal 6739 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 literal 0 HcmV?d00001 diff --git a/web/pwa/manifest.webmanifest b/web/pwa/manifest.webmanifest index dd03828..92d9a48 100644 --- a/web/pwa/manifest.webmanifest +++ b/web/pwa/manifest.webmanifest @@ -12,6 +12,18 @@ "src": "/icons/icon.svg", "sizes": "any", "type": "image/svg+xml", + "purpose": "any" + }, + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", "purpose": "any maskable" } ] diff --git a/web/src/pages/certificate_detail.rs b/web/src/pages/certificate_detail.rs index f9c4df2..08179e8 100644 --- a/web/src/pages/certificate_detail.rs +++ b/web/src/pages/certificate_detail.rs @@ -276,9 +276,16 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { }; let tab = move |key: &'static str, label: &'static str| -> View { + let panel_id = format!("panel-{key}"); + let tab_id = format!("tab-{key}"); view! { - li(class="nav-item") { + li(class="nav-item", role="presentation") { button(class=if active_tab.get_clone() == key { "nav-link active" } else { "nav-link" }, + id=tab_id, + r#type="button", + role="tab", + aria-controls=panel_id, + aria-selected=if active_tab.get_clone() == key { "true" } else { "false" }, on:click=move |_| { active_tab.set(key.to_string()); if let Some(window) = web_sys::window() { @@ -326,7 +333,7 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { } } } - ul(class="nav nav-tabs mb-3") { + ul(class="nav nav-tabs mb-3", role="tablist", aria-label="Certificate sections") { (tab("overview", "Overview")) (tab("extensions", "Extensions")) (tab("validation", "Validation")) @@ -338,7 +345,7 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { } (if active_tab.get_clone() == "overview" { view! { - div(class="row g-3 mb-3") { + div(id="panel-overview", role="tabpanel", aria-labelledby="tab-overview", class="row g-3 mb-3") { div(class="col-12 col-lg-6") { div(class="card") { div(class="card-header") { "Subject" } @@ -358,25 +365,27 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { } } - } else { view! {} }) (if active_tab.get_clone() == "extensions" { + } else { view! {} }) + (if active_tab.get_clone() == "extensions" { view! { - div(class="card mb-3") { + div(id="panel-extensions", role="tabpanel", aria-labelledby="tab-extensions", class="card mb-3") { div(class="card-header d-flex justify-content-between align-items-center") { span() { "Extensions" } (copy_button(openssl_config.get_clone(), "Copy OpenSSL config")) } div(class="table-responsive") { table(class="table table-sm ext-table mb-0") { - thead() { tr() { th() { "Extension" } th() { "Value" } } } + thead() { tr() { th(scope="col") { "Extension" } th(scope="col") { "Value" } } } tbody() { Indexed(list=extensions, view=|ext| view! { tr() { td() { (ext.name) (if ext.critical { view! { span(class="badge badge-critical ms-2") { "critical" } } } else { view! {} }) } td(class="extension-value") { (ext.value) } } }) } } } } } - } else { view! {} }) (if active_tab.get_clone() == "validation" { + } else { view! {} }) + (if active_tab.get_clone() == "validation" { view! { - div(class="card mb-3") { + div(id="panel-validation", role="tabpanel", aria-labelledby="tab-validation", class="card mb-3") { div(class="card-header") { "Validation" } div(class="card-body") { (if validation_errors.get_clone().is_empty() { @@ -389,9 +398,10 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { } } - } else { view! {} }) (if active_tab.get_clone() == "export" { + } else { view! {} }) + (if active_tab.get_clone() == "export" { view! { - div(class="row g-3") { + div(id="panel-export", role="tabpanel", aria-labelledby="tab-export", class="row g-3") { div(class="col-12 col-lg-6") { div(class="card mb-3") { div(class="card-header") { "Export" } @@ -427,7 +437,7 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { } else { view! {} }) (if active_tab.get_clone() == "metadata" { view! { - div(class="row g-3") { + div(id="panel-metadata", role="tabpanel", aria-labelledby="tab-metadata", class="row g-3") { div(class="col-12 col-lg-6") { div(class="card") { div(class="card-header") { "Metadata" } @@ -449,7 +459,7 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { } else { view! {} }) (if active_tab.get_clone() == "revoke" { view! { - div(class="row g-3") { + div(id="panel-revoke", role="tabpanel", aria-labelledby="tab-revoke", class="row g-3") { div(class="col-12 col-lg-6") { div(class="card") { div(class="card-header") { "Revoke" } @@ -464,7 +474,7 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { } else { view! {} }) (if active_tab.get_clone() == "transform" { view! { - div(class="row g-3") { + div(id="panel-transform", role="tabpanel", aria-labelledby="tab-transform", class="row g-3") { div(class="col-12 col-lg-6") { div(class="card") { div(class="card-header") { "Transform" } @@ -481,7 +491,7 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { } else { view! {} }) (if is_ca && active_tab.get_clone() == "ca" { view! { - div(class="card mt-3") { + div(id="panel-ca", role="tabpanel", aria-labelledby="tab-ca", class="card mt-3") { div(class="card-header") { "CA operations" } div(class="card-body") { div(class="row g-2 align-items-end mb-3") { @@ -554,7 +564,7 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { } } table(class="table table-sm mb-0") { - thead() { tr() { th() { "Serial" } th() { "Revoked" } th() { "Reason" } th() { "Invalid since" } th() {} } } + thead() { tr() { th(scope="col") { "Serial" } th(scope="col") { "Revoked" } th(scope="col") { "Reason" } th(scope="col") { "Invalid since" } th(scope="col") {} } } tbody() { Indexed(list=revocations, view=move |rev| { let rev_id = rev.id.clone(); let rev_for_edit = rev.clone(); view! { tr() { td(class="fingerprint") { (rev.serial_hex) } diff --git a/web/src/pages/certificates.rs b/web/src/pages/certificates.rs index f94ded4..2fa0c83 100644 --- a/web/src/pages/certificates.rs +++ b/web/src/pages/certificates.rs @@ -188,14 +188,14 @@ pub fn CertificatesPage(vault_id: String) -> View { div(class="card mb-3") { div(class="card-body py-2") { input(class="form-control", placeholder="Filter by name, subject, issuer, serial or fingerprint…", - bind:value=filter) + aria-label="Filter certificates", bind:value=filter) } } div(class="card mb-3") { div(class="card-body") { div(class="d-flex flex-wrap gap-2 align-items-end") { div(class="me-auto") { - div(class="small text-secondary") { (format!("{} selected", selected.get_clone().len())) } + div(class="small text-secondary", aria-live="polite") { (format!("{} selected", selected.get_clone().len())) } div(class="d-flex gap-2 mt-1") { button(class="btn btn-sm btn-outline-light", on:click=select_all) { "Select all revocable" } button(class="btn btn-sm btn-outline-light", on:click=clear_selection) { "Clear" } @@ -259,7 +259,7 @@ fn flat_html(rows: &[CertificateSummary], vault_id: &str, selected: &[String]) - )); } format!( - "
{body}
NameIssuerTypeSerialExpiresStatusSHA-256
" + "
{body}
SelectNameIssuerTypeSerialExpiresStatusSHA-256
" ) } @@ -333,7 +333,7 @@ fn node_html( fn checkbox(cert: &CertificateSummary, selected: &[String]) -> String { if cert.issuer_id.is_none() { - return "".to_string(); + return "".to_string(); } let checked = if selected.contains(&cert.id) { " checked" @@ -341,8 +341,9 @@ fn checkbox(cert: &CertificateSummary, selected: &[String]) -> String { "" }; format!( - "", + "", escape(&cert.id), + escape(&cert.name), checked ) }