Close Campaign interface audit gaps
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.",
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"})
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_audit_data.af52b968">
|
||||
<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>
|
||||
</LoadingFrame>
|
||||
</div>);
|
||||
|
||||
@@ -29,6 +29,11 @@ export default function CampaignJsonView({ settings, campaignId }: {settings: Ap
|
||||
</div>
|
||||
</div>
|
||||
{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">
|
||||
<Card>
|
||||
{!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 { 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 ?? "—"}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="review-flow-inline-note is-danger">{error}</p>}
|
||||
{error && (
|
||||
<DismissibleAlert tone="danger" compact dismissible={false}>
|
||||
{error}
|
||||
</DismissibleAlert>
|
||||
)}
|
||||
{!error && unlinkedCount > 0 && (
|
||||
<p className="review-flow-inline-note is-stale">
|
||||
i18n:govoplan-campaign.unlinked_candidate_files_are_not_yet_part_of_the.b8fd5998
|
||||
|
||||
+2
-5
@@ -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
|
||||
|
||||
@@ -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('<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"]) {
|
||||
const buttonLine = preview
|
||||
.split("\n")
|
||||
|
||||
Reference in New Issue
Block a user