Close Campaign interface audit gaps

This commit is contained in:
2026-08-04 01:04:39 +02:00
parent 3f3545f080
commit df5a93d6a3
10 changed files with 153 additions and 15 deletions
+25
View File
@@ -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 implementations that bypass Core's `Dialog`. It complements rather than replaces
browser and assistive-technology testing. 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 ## Known boundary
Translation keys may be visible in source because Core resolves them at runtime. Translation keys may be visible in source because Core resolves them at runtime.
+2 -6
View File
@@ -527,7 +527,6 @@ manifest = ModuleManifest(
order=22, order=22,
surface_id=REPORTS_SURFACE_ID, surface_id=REPORTS_SURFACE_ID,
), ),
FrontendRoute(path="/templates", component="TemplatesPage", order=90),
), ),
nav_items=( nav_items=(
NavItem( NavItem(
@@ -537,9 +536,6 @@ manifest = ModuleManifest(
required_any=CAMPAIGN_MODULE_REQUIRED_ANY, required_any=CAMPAIGN_MODULE_REQUIRED_ANY,
order=20, order=20,
), ),
NavItem(
path="/templates", label="Templates", icon="layout-template", order=90
),
), ),
view_surfaces=( view_surfaces=(
ViewSurface( ViewSurface(
@@ -808,7 +804,7 @@ manifest = ModuleManifest(
id="campaigns.workflow.prepare-validate-and-build", id="campaigns.workflow.prepare-validate-and-build",
title="Prepare, validate, and build a campaign", title="Prepare, validate, and build a campaign",
summary="Turn governed recipient, template, attachment, and Mail-profile inputs into exact built messages for review.", 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", layer="configured",
documentation_types=("user",), documentation_types=("user",),
audience=("campaign_manager", "campaign_author"), audience=("campaign_manager", "campaign_author"),
@@ -836,7 +832,7 @@ manifest = ModuleManifest(
kind="repository", kind="repository",
), ),
), ),
related_modules=("addresses", "files", "mail"), related_modules=("addresses", "files", "mail", "templates"),
unlocks=( unlocks=(
"A reviewable build whose exact recipient-specific effects can be inspected before delivery.", "A reviewable build whose exact recipient-specific effects can be inspected before delivery.",
), ),
@@ -23,6 +23,9 @@ from govoplan_campaign.backend.path_security import (
assert_server_safe_campaign_paths, assert_server_safe_campaign_paths,
) )
from govoplan_campaign.backend.campaign.loader import load_campaign_json 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.attachments.resolver import resolve_campaign_attachments
from govoplan_campaign.backend.persistence.versions import ( from govoplan_campaign.backend.persistence.versions import (
is_version_final_locked, is_version_final_locked,
@@ -324,7 +327,7 @@ def preview_campaign_attachments(
include_unmatched=payload.include_unmatched, include_unmatched=payload.include_unmatched,
include_unlinked_candidates=payload.include_unlinked_candidates, include_unlinked_candidates=payload.include_unlinked_candidates,
) )
except CampaignPathSecurityError as exc: except (CampaignPathSecurityError, CampaignMailProfileBoundaryError) as exc:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc) status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc ) from exc
+37
View File
@@ -7,6 +7,7 @@ import pytest
from fastapi import HTTPException from fastapi import HTTPException
from govoplan_campaign.backend import route_support 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.routes import versions as router
from govoplan_campaign.backend.campaign.loader import CampaignSchemaError, validate_against_schema from govoplan_campaign.backend.campaign.loader import CampaignSchemaError, validate_against_schema
from govoplan_campaign.backend.campaign.mail_profile_boundary import ( 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: def test_loader_uses_only_non_secret_mail_profile_capabilities() -> None:
raw = _campaign_json({"mail_profile_id": "profile-1"}) raw = _campaign_json({"mail_profile_id": "profile-1"})
+9
View File
@@ -62,3 +62,12 @@ def test_aggregate_reports_are_an_integrated_campaign_view() -> None:
] ]
assert len(report_surfaces) == 1 assert len(report_surfaces) == 1
assert report_surfaces[0].description == "/campaigns/reports" 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}
@@ -5,6 +5,7 @@ import { DismissibleAlert } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui"; import { PageTitle } from "@govoplan/core-webui";
import VersionLine from "./components/VersionLine"; import VersionLine from "./components/VersionLine";
import { LoadingFrame } from "@govoplan/core-webui"; import { LoadingFrame } from "@govoplan/core-webui";
import { ActionBlockerHint, DocumentationHelpLink } from "@govoplan/core-webui";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
export default function CampaignAuditPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) { export default function CampaignAuditPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
@@ -27,7 +28,27 @@ export default function CampaignAuditPage({ settings, campaignId }: {settings: A
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_audit_data.af52b968"> <LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_audit_data.af52b968">
<Card title="i18n:govoplan-campaign.recent_audit_events.7ec32b1d"> <Card title="i18n:govoplan-campaign.recent_audit_events.7ec32b1d">
<p className="muted">i18n:govoplan-campaign.campaign_specific_audit_api_integration_will_be_.e53c8280</p> <ActionBlockerHint
tone="info"
reason={{
summary: "Campaign-specific audit projection is not available on this page.",
details: "Campaign actions already emit bounded platform audit evidence. Authorized readers can inspect it in the Audit administration surface.",
requiredAction: "Open tenant audit and filter by the campaign identifier.",
actor: "Audit reader or system operator",
target: "Administration > Tenant audit"
}}
documentation={{
topicId: "campaigns.reference.composition-assurance",
documentationType: "admin"
}}
/>
<DocumentationHelpLink
reference={{
topicId: "campaigns.reference.composition-assurance",
documentationType: "user"
}}
label="Open Campaign assurance documentation"
/>
</Card> </Card>
</LoadingFrame> </LoadingFrame>
</div>); </div>);
@@ -29,6 +29,11 @@ export default function CampaignJsonView({ settings, campaignId }: {settings: Ap
</div> </div>
</div> </div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
<DismissibleAlert tone="warning" dismissible={false}>
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.
</DismissibleAlert>
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_json.812c7a50"> <LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_json.812c7a50">
<Card> <Card>
{!loading || version ? <pre className="code-panel">{JSON.stringify(campaignJson, null, 2)}</pre> : <pre className="code-panel">{"{}"}</pre>} {!loading || version ? <pre className="code-panel">{JSON.stringify(campaignJson, null, 2)}</pre> : <pre className="code-panel">{"{}"}</pre>}
@@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { Link2, X } from "lucide-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 { import type {
CampaignAttachmentPreviewFile, CampaignAttachmentPreviewFile,
@@ -108,7 +108,11 @@ export default function AttachmentLinkingPreview({
value={loading ? "..." : preview?.shared_file_count ?? "—"} value={loading ? "..." : preview?.shared_file_count ?? "—"}
/> />
</div> </div>
{error && <p className="review-flow-inline-note is-danger">{error}</p>} {error && (
<DismissibleAlert tone="danger" compact dismissible={false}>
{error}
</DismissibleAlert>
)}
{!error && unlinkedCount > 0 && ( {!error && unlinkedCount > 0 && (
<p className="review-flow-inline-note is-stale"> <p className="review-flow-inline-note is-stale">
i18n:govoplan-campaign.unlinked_candidate_files_are_not_yet_part_of_the.b8fd5998 i18n:govoplan-campaign.unlinked_candidate_files_are_not_yet_part_of_the.b8fd5998
+2 -5
View File
@@ -17,7 +17,6 @@ import "./styles/campaign-workspace.css";
const CampaignModulePage = lazy(() => import("./features/campaigns/CampaignModulePage")); const CampaignModulePage = lazy(() => import("./features/campaigns/CampaignModulePage"));
const CampaignWorkspace = lazy(() => import("./features/campaigns/CampaignWorkspace")); const CampaignWorkspace = lazy(() => import("./features/campaigns/CampaignWorkspace"));
const TemplatesPage = lazy(() => import("./features/templates/TemplatesPage"));
const campaignRead = ["campaigns:campaign:read"]; const campaignRead = ["campaigns:campaign:read"];
const reportRead = ["campaigns:report:read"]; const reportRead = ["campaigns:report:read"];
@@ -91,16 +90,14 @@ export const campaignModule: PlatformWebModule = {
} }
], ],
navItems: [ navItems: [
{ to: "/campaigns", label: "i18n:govoplan-campaign.campaigns.01a23a28", iconName: "campaign", anyOf: campaignModuleRead, order: 20 }, { 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 }],
routes: [ routes: [
{ path: "/campaigns", anyOf: campaignModuleRead, order: 20, render: ({ settings, auth }) => createElement(CampaignModuleLandingRoute, { settings, auth }) }, { 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: "/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/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/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: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 22, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) }],
{ path: "/templates", order: 90, render: () => createElement(TemplatesPage) }],
uiCapabilities: { uiCapabilities: {
"dashboard.widgets": campaignDashboardWidgets, "dashboard.widgets": campaignDashboardWidgets,
"wizard.directories": campaignWizardDirectories "wizard.directories": campaignWizardDirectories
@@ -32,6 +32,47 @@ const preview = fs.readFileSync(
path.join(sourceRoot, "features/campaigns/components/MessagePreviewOverlay.tsx"), path.join(sourceRoot, "features/campaigns/components/MessagePreviewOverlay.tsx"),
"utf8", "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('<DismissibleAlert tone="danger" compact dismissible={false}>'),
"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"]) { for (const handler of ["onFirst", "onPrevious", "onNext", "onLast"]) {
const buttonLine = preview const buttonLine = preview
.split("\n") .split("\n")