E2E Notify-Channel-Coverage Harness — Operator Runbook
Audience: operators and CI maintainers running the Notify channel end-to-end coverage gate, plus engineers diagnosing a failed run.
StellaOps.E2E.NotifyChannelCoverage is the Notify-channel-coverage end-to-end harness — a sibling to StellaOps.E2E.AnalyzerCoverage for the Notify channel plugin family: Discord, Slack, Teams, email, webhook, opsgenie, pagerduty, telegram.
Notify channels deliver notifications; they do not analyze repos, so this harness has no git corpus. Instead it drives each channel connector against a mock receiver and asserts delivery + payload shape.
- Sprint (archived):
docs-archive/implplan/2026-05-17-completed-sprints/SPRINT_20260514_007_Notify_e2e_channel_coverage_harness.md - Project:
src/E2E/StellaOps.E2E.NotifyChannelCoverage/ - Mock-receiver compose:
devops/compose/docker-compose.e2e-notify-coverage.yml - CI lane:
.gitea/workflows/e2e-notify-coverage.yml - Feature file:
docs/qa/feature-checks/unchecked/e2e/notify-channel-coverage.md
1. What it does — the four phases
| Phase | Name | Needs receivers? | What it asserts |
|---|---|---|---|
| 1 | Connector test runner | No | Each Notify connector test project (Email, Slack, Teams, Webhook, Discord, Telegram, OpsGenie, PagerDuty, InApp, InAppInbox) passes via dotnet exec. Connector-absent channels (cli, a read-surface over InAppInbox) are skipped. |
| 2 | Channel delivery | Yes | A deterministic synthetic alert is rendered into each channel’s wire envelope, routed through the real connector DeliverAsync seam, and delivered to a mock receiver — email → Mailpit (SMTP), webhook-shaped channels → the HTTP echo receiver, in-product channels → deterministic in-memory stores. |
| 3 | Contract assertion | Yes (consumes Phase 2) | Per channel: delivery observed, the rendered envelope carries the alert’s key fields (vulnerability id / artifact / severity), channel identity matches the etc/plugins/notify/<channel>/plugin.json manifest, and the channel-specific envelope shape validates. |
| 4 | Evidence emission | No | Writes notify-coverage-summary.json + per-channel sub-reports under docs/qa/feature-checks/runs/notify/channel-coverage/<runId>/. |
The harness exits non-zero iff any channel fails — a connector test fails (Phase 1), a delivery is not observed (Phase 2), or a payload contract fails (Phase 3). A deep envelope-shape miss downgrades a channel to warning (still exits 0) — delivery + key-fields + channel-identity are the gating assertions.
Channel coverage today
Email / Slack / Teams / Webhook / Discord / Telegram / OpsGenie / PagerDuty / InApp / InAppInbox have connector libraries + test projects. Cli has no standalone connector project; it is a read-surface over the InAppInbox connector, so Phase 1 records it skipped (connector-absent is not a failure) and Phase 2 routes it through the InAppInbox connector.
Webhook-shaped connectors that require HTTPS endpoints (Slack, Teams, Discord) are configured with deterministic synthetic HTTPS endpoints for connector validation. The harness’ connector-backed HTTP transport then posts the connector payload to the local HTTP echo receiver, keeping the run offline and deterministic while still exercising each connector’s DeliverAsync path.
2. Quick start (local, full 4-phase run)
2.1 Build the harness + the connector test projects
dotnet build src/E2E/StellaOps.E2E.NotifyChannelCoverage/StellaOps.E2E.NotifyChannelCoverage.csproj
# Phase 1 needs the connector test DLLs built:
for p in Email Slack Teams Webhook Discord Telegram OpsGenie PagerDuty InApp InAppInbox; do
dotnet build "src/Notify/__Tests/StellaOps.Notify.Connectors.$p.Tests/StellaOps.Notify.Connectors.$p.Tests.csproj"
done
2.2 Start the mock receivers
docker compose -f devops/compose/docker-compose.e2e-notify-coverage.yml up -d
# Confirm both receivers are up:
curl -fsS http://localhost:8025/ # Mailpit web UI / search API
curl -fsS http://localhost:8099/health # notify-echo-receiver
- mailpit — stock
axllent/mailpit(already indocker-compose.dev.yml). SMTP on:1025, HTTP search API + web UI on:8025. - notify-echo-receiver — a minimal in-repo ASP.NET echo service (
devops/compose/notify-echo-receiver/), built + run with the stock .NET 10 SDK image. AcceptsPOST /echo/<channelId>and echoes the body back; listens on:8099.
2.3 Run the harness
dotnet exec \
src/E2E/StellaOps.E2E.NotifyChannelCoverage/bin/Debug/net10.0/StellaOps.E2E.NotifyChannelCoverage.dll \
--phases 1,2,3,4 \
--mailpit-base-url http://localhost:8025 \
--mailpit-smtp-port 1025 \
--echo-base-url http://localhost:8099 \
--run-id "$(date -u +%Y%m%dT%H%M%SZ)"
echo "exit code: $?" # 0 = all channels passed
2.4 Inspect the evidence
docs/qa/feature-checks/runs/notify/channel-coverage/<runId>/
notify-coverage-summary.json the top-level summary
<channel-id>/
phase1-test-output.txt raw connector test-runner output
phase1-result.json parsed Phase 1 result
phase2-delivery.json Phase 2 delivery outcome + rendered envelope
phase3-contract.json Phase 3 contract result + notes
2.5 Tear down
docker compose -f devops/compose/docker-compose.e2e-notify-coverage.yml down --volumes --remove-orphans
3. CLI reference
dotnet exec StellaOps.E2E.NotifyChannelCoverage.dll [options]
--phases 1,2,3,4 Phases to run (default: all). Subsets allowed;
--phases 1 needs no live receivers.
--run-id <id> Run identifier (default: UTC timestamp).
--plugins-root <path> Notify channel plugin manifests root
(default: <repo>/etc/plugins/notify).
--mailpit-base-url <url> Mailpit HTTP API base URL
(default: http://localhost:8025).
--mailpit-smtp-port <port>
Mailpit SMTP host port
(default: 1025).
--echo-base-url <url> HTTP echo receiver base URL
(default: http://localhost:8099).
--output-root <path> Root the run directory is written under
(default: repo root).
--repo-root <path> Repository-root override (auto-detected when omitted).
--help, -h Show usage.
--phases 1 is the fast CI gate — it runs the connector test projects only and needs no Docker / receivers. The full --phases 1,2,3,4 run needs the mock-receiver compose up.
4. notify-coverage-summary.json schema
{
"runId": "20260514T093015Z",
"generatedAt": "2026-05-14T09:30:15Z",
"phases": {
"connectorTestRunner": { "status": "passed", "detail": { ... } },
"channelDelivery": { "status": "passed", "detail": { ... } },
"contractAssertion": { "status": "passed", "detail": { ... } }
},
"channels": [
{
"id": "discord",
"connector": "(none)",
"phase1Status": "skipped",
"phase3Status": "passed",
"deliveryObserved": true,
"payloadShapeValid": true
}
],
"overallStatus": "passed"
}
Field order is stable (records serialize in declaration order) and channels are ordinal-sorted by id, so two runs with the same inputs produce byte-comparable JSON.
5. CI lane
.gitea/workflows/e2e-notify-coverage.yml:
- Pull request — fast
--phases 1smoke (connector test runner only, no Docker) when the harness, the mock-receiver compose, or a Notify connector is touched. - Nightly (
schedule:03:00 UTC) — full 4-phase run: brings updocker-compose.e2e-notify-coverage.yml, runs--phases 1,2,3,4, uploads the run directory as a workflow artifact. The workflow assigns a per-run compose project plus per-run host ports for Mailpit SMTP, Mailpit HTTP, and the echo receiver, then passes those ports into the harness. workflow_dispatch:— on-demand full run with aphasesinput.
Local compose defaults remain 1025, 8025, and 8099. CI overrides the host ports with NOTIFY_COVERAGE_MAILPIT_SMTP_PORT, NOTIFY_COVERAGE_MAILPIT_HTTP_PORT, and NOTIFY_COVERAGE_ECHO_HTTP_PORT so a stale or concurrent run cannot collide with the default listener ports.
6. Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Phase 1: test assembly not found | The connector test project was not built. | dotnet build the four StellaOps.Notify.Connectors.*.Tests projects first (see §2.1). |
Phase 1: cli is skipped | Cli has no standalone connector library; it reads InAppInbox entries. | Expected — connector-absent is not a failure. |
Phase 2: echo transport error | The HTTP echo receiver is not reachable. | Confirm curl http://localhost:8099/health; check docker compose ... ps. |
Phase 2: SMTP transport error | Mailpit is not reachable on :1025. | Confirm the mailpit service is up; check the port mapping. |
Docker says Bind for 0.0.0.0:1025 failed: port is already allocated | A stale local service or another compose project owns the default SMTP port. | Use NOTIFY_COVERAGE_MAILPIT_SMTP_PORT=<free-port> when starting compose and pass the same value as --mailpit-smtp-port; CI does this automatically per run. |
Phase 2: message sent but Mailpit search returned no matching message | The Mailpit search API URL is wrong, or Mailpit dropped the message. | Confirm --mailpit-base-url points at Mailpit’s HTTP port (:8025), not SMTP. |
Phase 3: manifest id ... != catalogued plugin id | The etc/plugins/notify/<channel>/plugin.json id drifted from the catalog. | Reconcile NotifyChannelCatalog with the committed manifest. |
Phase 3: a channel is warning | The deep envelope-shape sub-check did not hold (non-gating). | Inspect phase3-contract.json notes; the channel still passed the gating assertions. |
7. Related
- Notify module overview
- Notify observability runbook — metrics, logs, and health probes for the running service.
- Dev SMTP loop (Mailpit fallback) — the same Mailpit relay used for Phase 2 Email delivery in local development.
