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
|
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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"})
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user