M13b_VERIFICATION.md 11 KB

M13b Verification — Admin UI suite (Companies + Sources + Telegram bots)

Ship date: 2026-06-18 Milestone: M13b (W1 + W2 + W3 + W4) Exit criteria: all four checked.

  • make m13b-smoke exits 0 from a clean state.
  • 3 consecutive green runs (verified; see "3 consecutive runs" below).
  • M13a functionality not regressed (auth gate verified; see "M13a regression").
  • This document published.

1. End-to-end smoke (make m13b-smoke)

The unified smoke (scripts/m13b_smoke.sh) walks through every M13b endpoint on a single tenant in one operator flow. It runs against authd only; ingestd-dependent steps (7, 10) are auto-skipped with a warning if ingestd is not reachable, so the smoke stays green in dev environments that don't have the full pipeline up.

Step Module Endpoint What it asserts Result
1 infra GET /health authd reachable 200
2 auth POST /v1/auth/login super_admin gets JWT 200
3 W1 POST /v1/tenants super creates tenant 201
4 infra SQL upsert tenant_admin row seeded (no public invite-magic endpoint in v1) 200
5 auth POST /v1/auth/login tenant_admin gets JWT 200
6 W2 POST /v1/tenants/{id}/sources super creates source with HMAC + API key 201
7 W2 POST /v1/ingest (HMAC-signed) ingestd accepts alert (SKIP if ingestd unreachable)
8 W2 GET /v1/tenants/{id}/sources list scoped to tenant total = 1
9 W2 POST .../sources/{sid}/status suspend status flips 200
10 W2 POST /v1/ingest (suspended) ingestd rejects with 401 (SKIP if ingestd unreachable)
11 W1 GET /v1/tenants/{id} as tenant_admin own tenant readable 200
12 W1 GET /v1/tenants/<other> as tenant_admin cross-tenant 403 403
13 W2 GET .../sources/{sid} on other tenant cross-tenant 403 403
14 W2 POST .../sources on other tenant cross-tenant 403 403
14b W2 POST .../sources on own tenant tenant_admin can manage own (per W2's RequireAuth policy) 201
15 W3 POST .../telegram/bots super creates bot 201
15b W3 response body inspection bot_token is write-only (not echoed in response) absent
15c W3 POST .../telegram/bots as tenant_admin cross-role 403 (telegram is super_admin-only) 403
16 infra POST /v1/users/invite super issues magic-link token 200 + token
17 W1 POST .../status archived cleanup 200

3 consecutive runs

Run PASS FAIL SKIP
1 18 0 2 (ingestd not running locally)
2 18 0 2
3 18 0 2

Full output captured to /tmp/m13b_smoke_capture.log.


2. Cross-tenant isolation test

The most important security guarantee M13b promises is that no operator action crosses tenant boundaries. The smoke covers the critical paths:

Action as tenant_admin Path Expected Got
GET another tenant /v1/tenants/<other> 403 403
GET source on another tenant /v1/tenants/<other>/sources/{sid} 403 403
POST source on another tenant /v1/tenants/<other>/sources 403 403
POST telegram bot /v1/tenants/<own>/telegram/bots 403 (role) 403
POST source on OWN tenant /v1/tenants/<own>/sources 201 (any-auth, per-tenant scope) 201
GET source list on OWN tenant (cross-tenant fix) /v1/tenants/<own>/sources 200, total = own sources only 200, total = own

The smoke also caught a real cross-tenant data leak during W4 development: listSourcesHandler was reading tenantID from the URL path but never passing it to SourceFilter, and the SQL had no company_id = $N clause for super_admin calls — so super_admin saw sources from every tenant. Fixed in commit eb06068 / <see git log>. Same pattern as the W3 listTelegramBots leak fixed the day before. The fix:

  • SourceFilter gained a CompanyID field
  • ListSources now emits company_id = $N unconditionally (was only emitted when CallerRole != "super_admin")
  • An empty CompanyID is a hard error (rejects "list all" misuse)
  • listSourcesHandler always sets CompanyID: tenantID
  • Regression test TestListSources_RequiresCompanyID pinned

Manual cross-tenant verification (also done)

After running the smoke 6 times back-to-back (no manual cleanup), a manual psql + API cross-check showed:

DB:    6 distinct tenants × 1 source each = 6 total
API:   GET /v1/tenants/<tenant_id>/sources → total: 1 (per tenant)

Before the fix, the API would have returned 6 for every tenant.


3. Bundle size

cd web && pnpm run build produces the embedded SPA at cmd/admind/web-dist/assets/. Total payload:

File raw gzip notes
react-C4-CelXw.js 208 103 B 67 859 B React + react-dom + react-router
index-WAbnQRoa.js 142 349 B 37 529 B app shell + all 3 feature modules (companies, sources, telegram)
forms-BRRx31Es.js 80 260 B 21 902 B react-hook-form + zod + resolvers
ui-DcUwFLzq.js 42 539 B 11 744 B Radix UI primitives (shared)
query-C_q0vuOo.js 36 611 B 11 022 B @tanstack/react-query
index-BODtBP6q.css 21 166 B 5 053 B all CSS
Total 531 028 B (518 KB) 155 109 B (151 KB)

The per-feature code-splitting exit criterion ("< 30 KB gz per feature") is not yet met. All three feature modules (companies, sources, telegram) ship in the main index-*.js chunk (37 529 B gz). This is consistent with W1 and W2's behaviour; per-feature dynamic import is a v1.1 follow-up and is not blocking M13b.


4. M13a regression check

M13a's auth gate is the foundation M13b builds on. Steps 1-5 of scripts/m13a_smoke.sh exercise authd only and were run manually against this build:

M13a step Endpoint Expected Got
1. authd /health GET /health 200 200
2. super_admin login POST /v1/auth/login 200 200
3. /v1/users/me with Bearer GET /v1/users/me 200 200 (role=super_admin)
4. refresh (rotation) POST /v1/auth/refresh 200 200
5. re-use OLD refresh POST /v1/auth/refresh 401 session_killed 401

M13a steps 6+ (/v1/dlq, /v1/admin/ingest, /v1/admin/dedupe/*, /v1/admin/archiver/run, etc.) require the full stack (admind, ingestd, routerd, archiverd, deliverd-fcm, deliverd-telegram) which is not running in this environment. Those checks are unchanged in code; the smoke itself wasn't modified by M13b.


5. Tests

Suite Command Result
Go unit go test -count=1 ./... 22 packages, 0 failures
Web unit cd web && pnpm run test 31 tests across 4 files, 0 failures
Web typecheck cd web && pnpm exec tsc --noEmit clean
Web build cd web && pnpm run build clean (151 KB gz total)
W1 smoke make w1-smoke green (per W1 entry in M13b.dlog)
W2 smoke make w2-smoke green (per W2 entry in M13b.dlog)
W3 smoke make w3-smoke green; 3 consecutive runs, 32/32 OK each (cross-tenant leak fix)
W4 smoke make m13b-smoke green; 3 consecutive runs, 18/18 OK each (cross-tenant leak fix)

Regression tests for the two cross-tenant leaks:

  • internal/authd/telegrambots_test.go::TestListTelegramBots_CompanyID_RequiredForScoping
  • internal/authd/sources_test.go::TestListSources_RequiresCompanyID

Both pin the contract: handler MUST set CompanyID: tenantID on the filter, and the store MUST scope by it. An empty CompanyID is a hard error (defence in depth against any future caller that forgets the gate).


6. Screenshots

The plan calls for screenshots of the admin UI:

  • company list, company create form
  • source list, source create form, one-time secrets modal
  • telegram config
  • invites list, bindings list

These are not captured in this verification because the headless environment has no browser automation. The recommended path for capturing them is make web-dev (Vite dev server on :5173 with proxy rules to the local stack) and then driving the UI via Playwright or a real browser. The screenshot script will be added as a v1.1 follow-up alongside the per-feature code split.

In the meantime, the smoke covers the same surfaces programmatically:

Plan screenshot Smoke step that covers it
company list step 8 (sources list API; companies list has the same shape)
company create form step 3 (POST /v1/tenants)
source list step 8
source create form step 6
one-time secrets modal step 6 (server returns hmac_secret + api_key once; the modal enforces "I've saved them")
telegram config step 15 (POST bot); step 15b asserts no plaintext echo
invites list step 16 (POST invite → token issued); GET /v1/users/invites is not yet wired — v1.1
bindings list not in M13b scope; comes in M14 (bindings UI for source ↔ channel routing)

7. Open follow-ups

These are tracked in M13b.dlog (TL;DR + per-W entries), but called out here for the release notes:

  • Per-feature code-split (v1.1). All three features in index-*.js (37 KB gz). < 30 KB gz exit criterion not met yet.
  • Component tests for list/create/detail-page UI (v1.1). The W3 smoke covers format helpers and validators only; components need @testing-library/react.
  • GET /v1/users/invites (v1.1). The invite POST returns a magic-link token; a list endpoint is not yet implemented, so the W4 smoke substitutes step 14 (list invites) with a response-shape assertion on the create.
  • alerts_24h counter (v1.1). The W4 plan referenced an alerts_24h field on the source row; it's not wired. The smoke logs a note and skips that assertion.
  • Browser screenshots for this doc (v1.1). See §6.
  • canManageTelegram policy dial. Currently super_admin only; if tenant_admin should manage their own bot, the switch is in web/src/lib/scope.ts + the handler's RequireRole check.