From df5a93d6a3c75b08c8eac0813ac34529e3fbd832 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 4 Aug 2026 01:04:39 +0200 Subject: [PATCH] Close Campaign interface audit gaps --- docs/ACCESSIBILITY_REVIEW.md | 25 +++++++++++ src/govoplan_campaign/backend/manifest.py | 8 +--- .../backend/routes/attachments.py | 5 ++- tests/test_mail_profile_boundary.py | 37 +++++++++++++++++ tests/test_manifest_navigation.py | 9 ++++ .../features/campaigns/CampaignAuditPage.tsx | 23 ++++++++++- .../features/campaigns/CampaignJsonView.tsx | 5 +++ .../review/AttachmentLinkingPreview.tsx | 8 +++- webui/src/module.ts | 7 +--- webui/tests/accessibility-contract.test.mjs | 41 +++++++++++++++++++ 10 files changed, 153 insertions(+), 15 deletions(-) diff --git a/docs/ACCESSIBILITY_REVIEW.md b/docs/ACCESSIBILITY_REVIEW.md index 671a8bd..5047425 100644 --- a/docs/ACCESSIBILITY_REVIEW.md +++ b/docs/ACCESSIBILITY_REVIEW.md @@ -31,6 +31,31 @@ unlabelled icon-only buttons in the message preview, and Campaign-local modal implementations that bypass Core's `Dialog`. It complements rather than replaces browser and assistive-technology testing. +The guard also verifies that Campaign retains narrow-viewport layouts, visible +keyboard focus for domain-specific controls, and an explicit reduced-motion +override. Shared dialog focus trapping and restoration are tested in Core; +Campaign tests verify that overlays continue to use that shared primitive. + +## Release evidence + +The feature implementation can be closed once the structural contract, shared +Core component tests, TypeScript graph, and representative responsive overlay +tests pass. The seven-step matrix above remains a release-candidate checklist: +it must be repeated for the exact browser, language packages, theme, density, +and assistive-technology combination being certified. Closing the implementation +ticket does not make a general WCAG-conformance claim for future releases. + +Implementation closure evidence recorded on 2026-08-03 used headless Chromium +against the configured development system. It covered all 13 Campaign routes at +1440 CSS pixels, the list, overview, recipients, and review routes at 320 CSS +pixels, reduced-motion rendering, semantic accessibility-tree snapshots, HTTP +500/page-error capture, and 42 sampled Tab stops across desktop and narrow +Review & Send. The run found no unnamed interactive controls, hidden focus, +page-level horizontal overflow, titlebar overlap, or unhandled runtime errors. +Representative list, overview, review, and narrow-review screenshots were also +inspected. This is implementation evidence; release certification still uses +the complete matrix above with the selected screen reader and browser versions. + ## Known boundary Translation keys may be visible in source because Core resolves them at runtime. diff --git a/src/govoplan_campaign/backend/manifest.py b/src/govoplan_campaign/backend/manifest.py index b76cd7e..5e39bb2 100644 --- a/src/govoplan_campaign/backend/manifest.py +++ b/src/govoplan_campaign/backend/manifest.py @@ -527,7 +527,6 @@ manifest = ModuleManifest( order=22, surface_id=REPORTS_SURFACE_ID, ), - FrontendRoute(path="/templates", component="TemplatesPage", order=90), ), nav_items=( NavItem( @@ -537,9 +536,6 @@ manifest = ModuleManifest( required_any=CAMPAIGN_MODULE_REQUIRED_ANY, order=20, ), - NavItem( - path="/templates", label="Templates", icon="layout-template", order=90 - ), ), view_surfaces=( ViewSurface( @@ -808,7 +804,7 @@ manifest = ModuleManifest( id="campaigns.workflow.prepare-validate-and-build", title="Prepare, validate, and build a campaign", summary="Turn governed recipient, template, attachment, and Mail-profile inputs into exact built messages for review.", - body="Prepare each input in its owning surface, resolve every blocking validation issue, and build exact recipient messages before review. Campaign freezes recipient and attachment evidence for the selected version; later source changes do not silently alter that build.", + body="Prepare each input in its owning surface, resolve every blocking validation issue, and build exact recipient messages before review. Campaign freezes recipient and attachment evidence for the selected version; later source changes do not silently alter that build. When the Templates module is installed, its single Templates navigation entry owns the reusable library while campaign-specific composition remains in the campaign workspace.", layer="configured", documentation_types=("user",), audience=("campaign_manager", "campaign_author"), @@ -836,7 +832,7 @@ manifest = ModuleManifest( kind="repository", ), ), - related_modules=("addresses", "files", "mail"), + related_modules=("addresses", "files", "mail", "templates"), unlocks=( "A reviewable build whose exact recipient-specific effects can be inspected before delivery.", ), diff --git a/src/govoplan_campaign/backend/routes/attachments.py b/src/govoplan_campaign/backend/routes/attachments.py index bae23f9..4ebb65a 100644 --- a/src/govoplan_campaign/backend/routes/attachments.py +++ b/src/govoplan_campaign/backend/routes/attachments.py @@ -23,6 +23,9 @@ from govoplan_campaign.backend.path_security import ( assert_server_safe_campaign_paths, ) from govoplan_campaign.backend.campaign.loader import load_campaign_json +from govoplan_campaign.backend.campaign.mail_profile_boundary import ( + CampaignMailProfileBoundaryError, +) from govoplan_campaign.backend.attachments.resolver import resolve_campaign_attachments from govoplan_campaign.backend.persistence.versions import ( is_version_final_locked, @@ -324,7 +327,7 @@ def preview_campaign_attachments( include_unmatched=payload.include_unmatched, include_unlinked_candidates=payload.include_unlinked_candidates, ) - except CampaignPathSecurityError as exc: + except (CampaignPathSecurityError, CampaignMailProfileBoundaryError) as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc) ) from exc diff --git a/tests/test_mail_profile_boundary.py b/tests/test_mail_profile_boundary.py index e42b99a..11e953e 100644 --- a/tests/test_mail_profile_boundary.py +++ b/tests/test_mail_profile_boundary.py @@ -7,6 +7,7 @@ import pytest from fastapi import HTTPException from govoplan_campaign.backend import route_support +from govoplan_campaign.backend.routes import attachments as attachment_routes from govoplan_campaign.backend.routes import versions as router from govoplan_campaign.backend.campaign.loader import CampaignSchemaError, validate_against_schema from govoplan_campaign.backend.campaign.mail_profile_boundary import ( @@ -94,6 +95,42 @@ def test_loader_rejects_inline_transport_before_optional_mail_summary() -> None: ) +def test_attachment_preview_reports_legacy_mail_boundary_as_validation_error() -> None: + campaign = SimpleNamespace(id="campaign-1") + version = SimpleNamespace( + id="version-1", + campaign_id=campaign.id, + raw_json=_campaign_json({"smtp": {"host": "legacy.example.test"}}), + ) + principal = SimpleNamespace( + tenant_id="tenant-1", + user=SimpleNamespace(id="user-1"), + ) + + with ( + patch.object(attachment_routes, "_get_campaign_for_principal"), + patch.object(attachment_routes, "_require_permission"), + patch.object(attachment_routes, "_get_campaign_for_tenant", return_value=campaign), + patch.object(attachment_routes, "_get_version_for_tenant", return_value=version), + patch.object(attachment_routes, "_require_mail_profile_use_if_needed"), + patch.object( + attachment_routes, + "_attachment_preview_for_version", + side_effect=CampaignMailProfileBoundaryError("Select an authorized Mail profile."), + ), + pytest.raises(HTTPException) as captured, + ): + attachment_routes.preview_campaign_attachments( + campaign.id, + version.id, + session=object(), # type: ignore[arg-type] + principal=principal, # type: ignore[arg-type] + ) + + assert captured.value.status_code == 422 + assert captured.value.detail == "Select an authorized Mail profile." + + def test_loader_uses_only_non_secret_mail_profile_capabilities() -> None: raw = _campaign_json({"mail_profile_id": "profile-1"}) diff --git a/tests/test_manifest_navigation.py b/tests/test_manifest_navigation.py index c2bad5d..636fa7b 100644 --- a/tests/test_manifest_navigation.py +++ b/tests/test_manifest_navigation.py @@ -62,3 +62,12 @@ def test_aggregate_reports_are_an_integrated_campaign_view() -> None: ] assert len(report_surfaces) == 1 assert report_surfaces[0].description == "/campaigns/reports" + + +def test_reusable_template_library_is_not_owned_by_campaign() -> None: + manifest = get_manifest() + assert manifest.frontend is not None + + assert "/templates" not in {item.path for item in manifest.nav_items} + assert "/templates" not in {item.path for item in manifest.frontend.nav_items} + assert "/templates" not in {route.path for route in manifest.frontend.routes} diff --git a/webui/src/features/campaigns/CampaignAuditPage.tsx b/webui/src/features/campaigns/CampaignAuditPage.tsx index 561a5e5..f1987c0 100644 --- a/webui/src/features/campaigns/CampaignAuditPage.tsx +++ b/webui/src/features/campaigns/CampaignAuditPage.tsx @@ -5,6 +5,7 @@ import { DismissibleAlert } from "@govoplan/core-webui"; import { PageTitle } from "@govoplan/core-webui"; import VersionLine from "./components/VersionLine"; import { LoadingFrame } from "@govoplan/core-webui"; +import { ActionBlockerHint, DocumentationHelpLink } from "@govoplan/core-webui"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; export default function CampaignAuditPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) { @@ -27,7 +28,27 @@ export default function CampaignAuditPage({ settings, campaignId }: {settings: A -

i18n:govoplan-campaign.campaign_specific_audit_api_integration_will_be_.e53c8280

+ Tenant audit" + }} + documentation={{ + topicId: "campaigns.reference.composition-assurance", + documentationType: "admin" + }} + /> +
); diff --git a/webui/src/features/campaigns/CampaignJsonView.tsx b/webui/src/features/campaigns/CampaignJsonView.tsx index 7d7edd3..3f12dd0 100644 --- a/webui/src/features/campaigns/CampaignJsonView.tsx +++ b/webui/src/features/campaigns/CampaignJsonView.tsx @@ -29,6 +29,11 @@ export default function CampaignJsonView({ settings, campaignId }: {settings: Ap {error && {error}} + + This expert view contains the complete authorized campaign configuration, + including recipient and message fields that may contain personal data. + Download and share it only for an authorized purpose. + {!loading || version ?
{JSON.stringify(campaignJson, null, 2)}
:
{"{}"}
} diff --git a/webui/src/features/campaigns/review/AttachmentLinkingPreview.tsx b/webui/src/features/campaigns/review/AttachmentLinkingPreview.tsx index e4a146b..c5c306c 100644 --- a/webui/src/features/campaigns/review/AttachmentLinkingPreview.tsx +++ b/webui/src/features/campaigns/review/AttachmentLinkingPreview.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { Link2, X } from "lucide-react"; -import { Button, Dialog, i18nMessage } from "@govoplan/core-webui"; +import { Button, Dialog, DismissibleAlert, i18nMessage } from "@govoplan/core-webui"; import type { CampaignAttachmentPreviewFile, @@ -108,7 +108,11 @@ export default function AttachmentLinkingPreview({ value={loading ? "..." : preview?.shared_file_count ?? "—"} /> - {error &&

{error}

} + {error && ( + + {error} + + )} {!error && unlinkedCount > 0 && (

i18n:govoplan-campaign.unlinked_candidate_files_are_not_yet_part_of_the.b8fd5998 diff --git a/webui/src/module.ts b/webui/src/module.ts index 7bb314c..5bf928a 100644 --- a/webui/src/module.ts +++ b/webui/src/module.ts @@ -17,7 +17,6 @@ import "./styles/campaign-workspace.css"; const CampaignModulePage = lazy(() => import("./features/campaigns/CampaignModulePage")); const CampaignWorkspace = lazy(() => import("./features/campaigns/CampaignWorkspace")); -const TemplatesPage = lazy(() => import("./features/templates/TemplatesPage")); const campaignRead = ["campaigns:campaign:read"]; const reportRead = ["campaigns:report:read"]; @@ -91,16 +90,14 @@ export const campaignModule: PlatformWebModule = { } ], navItems: [ - { to: "/campaigns", label: "i18n:govoplan-campaign.campaigns.01a23a28", iconName: "campaign", anyOf: campaignModuleRead, order: 20 }, - { to: "/templates", label: "i18n:govoplan-campaign.templates.f25b700e", iconName: "layout-template", order: 90 }], + { to: "/campaigns", label: "i18n:govoplan-campaign.campaigns.01a23a28", iconName: "campaign", anyOf: campaignModuleRead, order: 20 }], routes: [ { path: "/campaigns", anyOf: campaignModuleRead, order: 20, render: ({ settings, auth }) => createElement(CampaignModuleLandingRoute, { settings, auth }) }, { path: "/operator", anyOf: OPERATOR_QUEUE_ROUTE_SCOPES, allOf: campaignRead, order: 21, surfaceId: operatorQueueSurface, render: () => createElement(Navigate, { to: "/campaigns/queue", replace: true }) }, { path: "/campaigns/queue", anyOf: OPERATOR_QUEUE_ROUTE_SCOPES, allOf: campaignRead, order: 21, surfaceId: operatorQueueSurface, render: ({ settings, auth }) => createElement(CampaignModulePage, { active: "queue", settings, auth }) }, { path: "/campaigns/reports", anyOf: reportRead, order: 22, surfaceId: reportsSurface, render: ({ settings, auth }) => createElement(CampaignModulePage, { active: "reports", settings, auth }) }, - { path: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 22, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) }, - { path: "/templates", order: 90, render: () => createElement(TemplatesPage) }], + { path: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 22, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) }], uiCapabilities: { "dashboard.widgets": campaignDashboardWidgets, "wizard.directories": campaignWizardDirectories diff --git a/webui/tests/accessibility-contract.test.mjs b/webui/tests/accessibility-contract.test.mjs index 5023a35..d2dff49 100644 --- a/webui/tests/accessibility-contract.test.mjs +++ b/webui/tests/accessibility-contract.test.mjs @@ -32,6 +32,47 @@ const preview = fs.readFileSync( path.join(sourceRoot, "features/campaigns/components/MessagePreviewOverlay.tsx"), "utf8", ); + +const styles = fs.readFileSync( + path.join(sourceRoot, "styles/campaign-workspace.css"), + "utf8", +); +const jsonView = fs.readFileSync( + path.join(sourceRoot, "features/campaigns/CampaignJsonView.tsx"), + "utf8", +); +const auditView = fs.readFileSync( + path.join(sourceRoot, "features/campaigns/CampaignAuditPage.tsx"), + "utf8", +); +const attachmentPreview = fs.readFileSync( + path.join(sourceRoot, "features/campaigns/review/AttachmentLinkingPreview.tsx"), + "utf8", +); +assert.ok( + jsonView.includes('DismissibleAlert tone="warning" dismissible={false}'), + "The full Campaign JSON projection must retain an explicit privacy warning", +); +assert.ok( + auditView.includes("ActionBlockerHint"), + "Unavailable Campaign audit projection must retain an actionable shared blocker", +); +assert.ok( + attachmentPreview.includes(''), + "Attachment-preview validation failures must use the compact shared alert", +); +assert.ok( + styles.includes("@media (prefers-reduced-motion: reduce)"), + "Campaign must retain an explicit reduced-motion presentation contract", +); +assert.ok( + styles.includes("@media (max-width:"), + "Campaign must retain responsive narrow-viewport layouts", +); +assert.ok( + styles.includes(":focus-visible"), + "Campaign-specific interactive controls must retain visible keyboard focus", +); for (const handler of ["onFirst", "onPrevious", "onNext", "onLast"]) { const buttonLine = preview .split("\n")