Skip to content

test(e2e): DNS/TLS/email/CLI + observability/data-storage e2e coverage - #582

Open
dviejokfs wants to merge 30 commits into
mainfrom
feat/e2e-external-services
Open

test(e2e): DNS/TLS/email/CLI + observability/data-storage e2e coverage#582
dviejokfs wants to merge 30 commits into
mainfrom
feat/e2e-external-services

Conversation

@dviejokfs

@dviejokfs dviejokfs commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Continues the e2e coverage work from #579 with real DNS/TLS/email verification, and adds two complementary layers of apps/temps-cli test coverage: fast unit tests, and genuine CLI subprocess e2e tests.

External-service e2e coverage (Pebble + Mailpit):

  • Local test infra (apps/temps-e2e/docker-compose.e2e.yml): Pebble (Let's Encrypt's own ACME v2 test server), pebble-challtestsrv, Mailpit
  • apps/temps-cli: Pebble DNS provider added to dns-providers (was API-only before)
  • apps/temps-e2e CLI: tls-scenario and email-scenario commands — full lifecycle against a real deployed app: real ACME HTTP-01 issuance (cert issuer parsed and asserted as Pebble's test root, not just a 200), real SMTP send verified via Mailpit's own REST API (not just that the send call returned success), open/click tracking verified
  • web/e2e Playwright specs (domain-tls-pebble, email-provider-mailpit) driving the actual console UI through the same flows

apps/temps-cli unit test coverage:

  • 54/64 command groups now have real bun:test unit tests (up from 4/64), 788 tests total
  • Follows the existing house pattern (see data/index.test.ts): extract pure validators/formatters/parsers into named exports, test those — fast, offline, no live network calls
  • The 10 remaining groups (ai, blob, dev, email-domains, init, instances, kv, open, rollback, skills) were individually verified to have no extractable pure logic

apps/temps-cli genuine CLI subprocess e2e coverage (cli-scenario):

  • Unit tests prove internal logic works in isolation, but they can't catch a broken argv flag, a Commander wiring mistake, or malformed --json output — exactly what breaks an AI agent running bunx @temps-sdk/cli ... even when the API is fine. cli-scenario closes that gap: it spawns the real CLI binary as a subprocess for every step against a live instance (project CRUD, env vars, a service, a real image deploy polled to a terminal state via the CLI's own deployments status, domain CRUD, apikeys read paths), unlike scenario/tls-scenario/email-scenario, which drive the SDK client directly.
  • Real bug found live and fixed: environments vars get read from the list endpoint, which masks every variable's value to "***" regardless of is_secret — so a command whose entire point is showing one variable's value never actually showed it. Fixed to resolve real values through the same audited per-key endpoint vars export already used (getEnvironmentVariableValue, GET /projects/{id}/env-vars/{key}/value); secrets are still correctly withheld. cli-scenario's env-var step is now a regression test for this fix, verified live 3x.
  • Real bug found, not fixed (needs backend changes out of scope for this PR): domains status 404s on a domain that hasn't finished ACME provisioning yet — exactly the domain state you'd actually want to check status on. Root cause: check_domain_status resolves via list_certificates(CertificateFilter::default()), which only returns domains with an already-issued certificate row.
  • Also confirmed (not a bug): minting a new API key is correctly rejected under API-key auth (crates/temps-auth/src/sensitive_action.rs, an anti-privilege-escalation guard) — since API-key auth is this CLI's only non-interactive auth mode, apikeys create is structurally untestable from a pure CLI e2e run; the scenario exercises the read paths that are reachable instead.

Known gap found and documented (web/e2e/README.md): there's no console UI to link a provisioned TLS certificate to a project's custom domain — that's API-only today (POST /projects/{id}/custom-domains/{domain_id}/link-certificate/{cert_id}). domain-tls-pebble covers what the UI does offer (standalone cert provisioning); the full app-serving path is covered via the API in tls-scenario.

Bug found and fixed along the way: fetchOverTls (apps/temps-e2e) called getPeerCertificate() after the socket's 'end' event, which returns {} under Bun — fixed by capturing the cert at handshake-complete time instead. Documented in apps/temps-e2e/README.md for anyone touching that code later.

Extended: 9 more scenario commands + real bugs found (since this PR opened)

Kept growing on the same branch rather than splitting into a new PR, since each addition follows the exact same shape and verification discipline as the original scope. All commands, steps, and bugs are documented in detail in apps/temps-e2e/README.md; summarized here.

Data & Storage / Security & Auth:

  • kv-scenario — real Redis-backed kv-storage round trip (set/get/incr/expire/ttl/keys/del, nx/xx conditionals, cross-project isolation). No console UI exists for this feature beyond an enabled/healthy badge, so this is the only coverage that exists.
  • audit-scenario — real PROJECT_CREATED/PROJECT_DELETED audit rows read back exactly (id/data/actor/timestamp), plus an RBAC-gate check on the read endpoint.
  • managed-services-scenario — provision + link a postgres service to a project before deploying, an app writing through the injected POSTGRES_URL, exact row-count round trip, unlink. Bug found+fixed: scenario --with-db created its postgres service with no parameters, and PostgresParameterStrategy::validate_for_creation requires database/username with no defaults — every --with-db run was 400ing.
  • rbac-scenario — a second, independently-authenticated low-privilege user escalated viewer → deployer → admin, asserting exact 200/403 transitions and the audit trail at each tier. Bug found+fixed: apps/temps-cli's AVAILABLE_ROLES offered developer/viewer as instance-wide user roles — neither is valid; those are team roles, a separate concept.

Observability:

  • monitoring-scenario — auto-provisioned + explicit monitors, a real 5xx outage caught by the fixed 60s check cycle, incident lifecycle (create/resolve), bucketed chart, status overview. Four real bugs found+fixed: status-literal mismatches (major_outage/partial_outage vs the literal "down"/"degraded" the SQL checked for) in both overall and bucketed status; required-vs-optional query params blocking documented default time ranges; an uptime-denominator bug from the synthetic bootstrap check row; two divergent MonitorService instances that skipped the immediate-check path for API-created monitors; plus the AVG(response_time_ms) Postgres NUMERICf64 cast bug that made get_status_overview silently report every healthy monitor as "unknown".
  • error-tracking-scenario — Sentry-compatible ingestion authenticated via DSN public key (X-Sentry-Auth, the one route using a different auth scheme than the platform bearer token), fingerprint-based grouping proven live (identical repeat groups, distinct exception doesn't). No platform bugs found — clean on the first attempt.
  • logs-scenario — real container stdout/stderr through the actual Docker log collector (not synthetic rows): full-text search, level filtering, JSONB fields passthrough, env filtering, grep-style context, purge. No platform bugs found.
  • analytics-scenario — real visitor/session cookies issued by the proxy (should_track_page only fires for HTML responses), replayed on the public /api/_temps/event ingest path, custom event_data round-trip, and session stitching proven via visitor journey (two independent POSTs sharing one cookie pair land in one session). Bug found+fixed: packages/api/openapi.json — the source for the SDK this whole suite depends on — had drifted to 590 of the live server's 674 paths; the entire temps-analytics query surface was unreachable from any TypeScript consumer until regenerated from a live spec. Not a Rust bug — every route was already fully wired, just unreachable from generated clients.
  • session-replay-scenario — real rrweb-shaped event batches (base64+zlib), init/ingest/playback, list visibility once duration is computed, manual duration override, soft delete. Two real bugs found+fixed: the duration-override endpoint was registered as POST but documented (and SDK'd) as PUT — any client following the docs got a 405. And delete_session_replay correctly soft-deletes a session, but none of the four read paths ever checked is_active — a "deleted" recording (potentially DOM mutations/keystrokes) stayed fully visible in both list views and fetchable in full by ID; fixed all four, confirmed live that deleted-session playback now 404s.

Test plan

  • bun test in apps/temps-cli: 788 pass / 0 fail
  • bun run typecheck in apps/temps-cli and apps/temps-e2e: clean (one pre-existing, unrelated error in openapi-ts.config.ts, confirmed via git diff to predate this PR)
  • tls-scenario run live 3x back-to-back against a dedicated Pebble-configured instance: real project → real deploy → real ACME HTTP-01 exchange → real HTTPS fetch of the deployed app → issuer confirmed as Pebble's test root
  • email-scenario run live 3x back-to-back: real SMTP send → real Mailpit receipt → real open/click tracking events
  • cli-scenario run live 3x back-to-back (real CLI subprocess against a live instance), including a regression test for the vars get fix: every step green, instance left with zero leftover resources each time
  • vars get fix manually verified against both a non-secret var (reveals the real value) and a secret var (still correctly withheld)
  • domain-tls-pebble.spec.ts and email-provider-mailpit.spec.ts run live 3x back-to-back against the console UI, both green
  • kv-scenario, audit-scenario, managed-services-scenario, rbac-scenario run live, each green, zero leftover resources
  • monitoring-scenario, error-tracking-scenario, logs-scenario, analytics-scenario, session-replay-scenario each run live 3x back-to-back (incl. --keep and --json variants), all green after their respective fixes landed
  • cargo test/clippy clean for every crate touched by a backend fix in this PR (temps-status-page, temps-analytics-session-replay)
  • Instance left with zero leftover projects/domains/services/providers after each run (teardown verified via API/CLI, not assumed)

…tests

Extends the e2e suite from PR #579 with real external-service coverage and
closes a major gap in apps/temps-cli's own test coverage:

- Pebble (ACME v2 test server) + pebble-challtestsrv + Mailpit test infra
  (apps/temps-e2e/docker-compose.e2e.yml)
- CLI Pebble DNS provider support (temps-cli dns-providers)
- apps/temps-e2e tls-scenario/email-scenario: full lifecycle against a real
  deployed app - real ACME HTTP-01 issuance, cert issuer verified, real SMTP
  send verified via Mailpit's own API, open/click tracking verified
- web/e2e Playwright specs driving the actual console UI through the same
  flows (domain-tls-pebble, email-provider-mailpit)
- apps/temps-cli: unit tests for 54/64 command groups (up from 4/64),
  788 tests, following the existing bun:test pure-function pattern -
  extracting and testing validators/formatters/parsers that AI agents and
  scripts depend on via bunx @temps-sdk/cli

Known gap documented in web/e2e/README.md: no console UI to link a
provisioned certificate to a project's custom domain (API-only today).
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown

📓 Changelog preview

This is what your commits will add to the generated CHANGELOG.md at release time (via git-cliff). Do not edit CHANGELOG.md by hand — it is generated from your Conventional Commit messages.

## [Unreleased]

### Added

- **e2e:** Monitoring/status-page lifecycle scenario
- **e2e:** Error-tracking (Sentry-compatible) lifecycle scenario
- **e2e:** Add backup-restore-scenario via MinIO; fix unrestorable WAL-G backups
- **e2e:** Add git-deploy-scenario against a real public GitHub repo

### Documentation

- **e2e:** Document the 9 scenario commands added since PR #582 opened
- **e2e:** Document Slack notifications as a deliberate, permanent e2e gap

### Fixed

- **cli:** Environments vars get never revealed real values
- **cli:** Correct instance-wide user roles to admin/user
- **audit:** Correct ISO 8601 doc example for from/to query params
- **status-page:** Recognize major_outage/partial_outage as down/degraded
- **status-page:** Make current-status/uptime/bucketed time params optional
- **status-page:** Exclude the bootstrap 'unknown' check row from uptime%
- **status-page:** Share ONE MonitorService so API-created monitors get an immediate check
- **status-page:** Cast AVG(response_time_ms) to double precision
- **analytics-session-replay:** Route method mismatch + soft-delete never enforced
- **ci:** Wire up Pebble + Mailpit test infra for e2e-tests.yml

### Miscellaneous

- **sdk:** Regenerate @temps-sdk/api from the live OpenAPI spec

### Testing

- **e2e:** DNS/TLS/email coverage via Pebble+Mailpit, temps-cli unit tests
- **e2e:** Genuine CLI subprocess e2e coverage (cli-scenario)
- **e2e:** Kv-storage scenario
- **e2e:** Audit-logs scenario
- **e2e:** Managed-services scenario; fix scenario --with-db
- **e2e:** RBAC/teams permission-boundary scenario
- **e2e:** Wire up kv/audit/managed-services/rbac scenario commands
- **e2e:** Add logs-scenario for log-aggregation lifecycle
- **e2e:** Add analytics-scenario for web-analytics lifecycle
- **e2e:** Add session-replay-scenario for replay ingest+playback lifecycle
- **e2e:** Dns01-wildcard-scenario -- real wildcard cert issuance via Pebble DNS-01
- **e2e:** Blob-scenario -- real RustFS data-plane round trip, 3 real bugs found+fixed
- **e2e:** Flags-scenario -- feature flags driven through the real @temps-sdk/node-sdk FlagsClient

Adds real CLI e2e testing: cli-scenario spawns the actual @temps-sdk/cli
binary as a subprocess for every step against a live instance, rather than
driving the SDK client directly like scenario/tls-scenario/email-scenario
do. This proves argv parsing, Commander's command wiring, and stdout/--json
formatting actually work end to end -- exactly what breaks an AI agent
running `bunx @temps-sdk/cli ...` even when the underlying API is fine,
which the apps/temps-cli unit tests from the previous commit can't catch.

Covers the flows an agent chains in practice: project CRUD, env var
set/list/export/delete, service create/inspect/remove, a real image deploy
polled to a terminal state via the CLI's own `deployments status`, domain
CRUD, and apikeys read paths. Every resource is torn down via the CLI's own
delete/remove commands, falling back to the SDK-based teardown for anything
the CLI itself fails to remove.

Two real, pre-existing bugs found live while building this (documented in
cli-scenario.ts, not fixed there -- both need backend changes out of scope
for a CLI e2e suite):
- `environments vars get` / `vars list --show-values` both read from the
  list endpoint, which masks EVERY variable's value to "***" regardless of
  is_secret -- so --show-values never reveals anything. Only `vars export`
  genuinely resolves real values.
- `domains status` 404s on a domain that hasn't finished ACME provisioning
  yet -- exactly the domain state you'd want to check status on. Root cause:
  check_domain_status resolves via list_certificates(CertificateFilter::
  default()), which only returns domains with an already-issued certificate.

Also confirmed (not a bug): apikeys create is correctly rejected under
API-key auth as an anti-privilege-escalation guard, so it's structurally
untestable from a pure CLI e2e run -- the scenario exercises the apikeys
read paths that are reachable instead.

Verified passing 3x back-to-back; instance left with zero leftover
resources after each run.
\`vars get\` read from the environment-variables list endpoint, which the
server masks to the literal string "***" for every variable regardless of
is_secret -- so a command whose entire point is showing one variable's
value never actually showed it. Found live by cli-scenario (real CLI
subprocess e2e), reported by the user with the actual endpoint that does
work: GET /projects/{id}/env-vars/{key}/value.

vars export already resolves real values through that same endpoint via
resolveEnvVarValues() -- vars get just never called it. Reuse it: resolve
plaintext for the non-secret matched rows before printing, still routing
secrets through formatEnvVarValue()'s existing withheld-value message.

cli-scenario's env-var step now asserts the real value comes back from
`vars get` (previously it could only assert via `vars export`, with a
comment explaining why `get` didn't work). Verified live 3x back-to-back,
plus a manual secret-vs-non-secret check confirming secrets are still
correctly withheld.
AVAILABLE_ROLES was ['admin','developer','viewer'] and defaulted new
users to 'viewer' -- none of which except 'admin' are valid
instance-wide roles. RoleType (crates/temps-entities/src/types.rs)
only ever accepts "admin"/"user"; 'developer'/'viewer' are TEAM
roles, a separate concept. Every 'users create' without an explicit
--roles flag was silently sending a role the backend 400s on
("Unsupported role").
ListAuditLogsQuery.from/.to docs claimed epoch milliseconds
(example = 1), but temps_core::DateTime's Deserialize only accepts
RFC3339/naive ISO 8601 strings -- passing millis 400s with
"Invalid datetime format".
14-step round trip: enable KV, create 2 projects, set/get, incr
(sequential + fresh-key-defaults-to-1), keys pattern exact-match,
nx/xx conditional-write semantics, ttl sentinels (-1 no-expiry / -2
missing-key), expire, del (exact count + idempotent second call),
cross-project isolation (kv:p{project_id}: namespace), kv_status
read-back, and 400-on-missing-project_id.

No console UI exists for this feature beyond an enabled/healthy
status badge, so this is the only coverage that exists.
Create + delete a project, then read back the exact
PROJECT_CREATED/PROJECT_DELETED rows via GET /audit/logs and
/audit/logs/{id} (id/data/actor/timestamp match, not just "the
list is non-empty"), and confirm the read endpoint itself is
RBAC-gated (401/403 with no bearer token).
Full lifecycle: build+push a throwaway Go probe app, provision +
link a postgres service to a project BEFORE deploying, deploy the
probe (writes through the injected POSTGRES_URL), verify an exact
row-count round trip through /probe, verify the resolved-env-vars
reveal endpoint returns a real (non-masked) connection string, then
unlink and confirm disappearance.

Also fixes a real, pre-existing bug found while building this:
'scenario --with-db' created its postgres service with no
parameters, and PostgresParameterStrategy::validate_for_creation
requires 'database'/'username' with no defaults -- every
--with-db run was 400ing.
Proves the actual permission BOUNDARY (not just that team/access CRUD
returns 2xx): an independently-authenticated second, low-privilege
user is granted team access to a project, then escalated
viewer -> deployer -> admin, asserting the exact 200/403 transitions
and required_permission strings the guard enforces at each tier, plus
the exact audit trail (PROJECT_ACCESS_GRANTED / TEAM_MEMBER_ROLE_UPDATED
/ PROJECT_ACCESS_REVOKED / TEAM_DELETED).

Uses a second 'guard' team (granted and never revoked) so that
revoking the primary team's grant at the end doesn't drop the
project's grant count to zero -- which would (correctly) reopen the
project and defeat the revoke assertion for the wrong reason. Zero
grants means unrestricted is documented, tested platform behavior
(checker.rs's own unit tests, the CLI's revokeAccessAction warning),
not a bug.

Also fixes a real, pre-existing CLI bug found while building this:
apps/temps-cli's AVAILABLE_ROLES offered 'developer'/'viewer' as
instance-wide user roles (fixed in a prior commit on this branch) --
neither is valid; those are TEAM roles, a separate concept.

Needs DB-direct access (crates/temps-cli's own 'api-key' subcommand)
to mint the second user's bearer key: minting a NEW key while already
authenticated via API key is deliberately blocked
(anti-privilege-escalation), and login only sets a session cookie.
Registers the four new 'temps-e2e' subcommands added on this branch:
kv-scenario, audit-scenario, managed-services-scenario, rbac-scenario.
health_check_service.rs writes the finer-grained 'major_outage' (5xx
after retries, connection failures) and 'partial_outage' (unexpected
status codes) to status_checks.status -- not the plain 'down'/
'degraded' two of its consumers assumed:

- calculate_overall_status compared monitor.current_status == "down"
  literally, so a monitor that was completely unreachable was
  invisible to the project-level overall-status computation unless an
  incident also happened to exist to catch it via the severity checks.
- get_bucketed_status's SQL only counted literal 'down'/'degraded' in
  its FILTER clauses; a bucket made entirely of real 'major_outage'
  checks fell through to status="unknown" (gray/neutral) instead of
  "down" (red) on the status-page bucketed uptime chart -- silently
  hiding real outages.

temps-monitoring's own MonitorStatus::from_str already normalizes
both vocabularies for outage-detection purposes; these two call sites
never got the same treatment. Found while building live e2e coverage
for the monitoring feature -- toggling a deployed app to 5xx and
checking the bucketed chart reproduced exactly this.
…ional

CurrentStatusQuery, UptimeQuery, and BucketedQuery declared
start_time/end_time as non-optional temps_core::DateTime fields, but
their utoipa docs (and, for UptimeQuery, the 'days' field and the
service methods they call) all promised sensible defaults when
omitted: last 24h for current-status, 'days' (default 60) for uptime,
24h-ago/7-days-ago for the two bucketed endpoints. Since the fields
were required, every call had to pass an explicit range or 400 --
making the documented default-timeframe behavior (and get_current_status's
own no-arg 24h helper) unreachable dead code, and 'days' a parameter
that could never actually take effect.

Found while building live e2e coverage for the monitoring feature: a
scenario polling GET /monitors/{id}/current-status the way any real
client naturally would -- with no query params -- 400'd immediately.
create_monitor inserts a synthetic status_checks row (status='unknown',
'Monitor created - awaiting first health check') to bootstrap uptime
queries. Every uptime-percentage calculation (calculate_uptime,
get_current_status_for_timeframe, get_current_status_with_timeframes,
get_bucketed_status) counted it toward total_checks without it ever
being able to count toward successful_checks -- so a monitor with a
100% real pass rate still reported less than 100% uptime for as long
as that placeholder row stayed within the query window (its first 24h
for current-status, its first "days" window for uptime history/
calculate_uptime, its first bucket interval for the bucketed chart).

Found live while building e2e coverage for the monitoring feature: a
brand-new monitor whose only real check ever succeeded reported
uptime_percentage=50 instead of 100.
…t an immediate check

plugin.rs built TWO separate MonitorService instances: one via
StatusPageService::new (no job queue) registered for the HTTP route
handlers to call through, and a second via MonitorService::with_job_queue
used only by the plugin's own job-listener loop
(EnvironmentCreated/EnvironmentDeleted/DeploymentSucceeded).

create_monitor's emit_monitor_created only emits the MonitorCreated
event when a job queue is present. Since POST /projects/{id}/monitors
-- the real, user-facing create path -- went through the job-queue-less
instance, that emission silently no-opped for every API-created
monitor. Only the internal auto-provisioning path
(ensure_monitor_for_environment, invoked from the job listener's own
instance) ever got the "checked immediately" behavior
start_scheduler's own doc comment promises ("ensuring new monitors are
checked immediately without waiting for the next scheduled cycle").
An API-created monitor instead waited for the next periodic cycle
(interval ticks every 60s from server start, not from monitor
creation), which can be nearly a full cycle late.

Fixed by adding StatusPageService::with_job_queue (mirrors
MonitorService's own constructor pair) and a monitor_service_arc()
getter so plugin.rs builds exactly one MonitorService and shares it
between the HTTP-facing StatusPageService and its job-listener loop,
closing off this whole class of divergent-instance bug rather than
just patching today's symptom.

Found live while building e2e coverage for the monitoring feature: an
explicitly-created monitor's current-status stayed "unknown" for over
30s while an auto-created monitor on the same environment flipped to
"operational" within ~14s.
Postgres' AVG() over an integer column returns NUMERIC, not double
precision. calculate_avg_response_time's FromQueryResult struct
declared the field as Option<f64>, so sqlx's try_get<f64> errored on
the type mismatch. get_status_overview silently swallowed that Err
into a fake "unknown" status for every monitor on the page, even ones
that were fully healthy and had just reported "operational" moments
earlier via the current-status endpoint (which computes response time
via a sibling query that already used Decimal correctly).

Also log the swallowed error instead of discarding it silently, so a
future regression here is diagnosable from server logs instead of
requiring DB archaeology to root-cause.
Real uptime-monitoring e2e coverage: confirms a fresh project auto-
gets a default monitor, creates an explicit second one via the CRUD
API, deploys a toggleable Go app (new lib/toggle-app.ts -- health
flips over HTTP so it works identically against a remote instance),
waits for the fixed 60s health-check cycle to catch a real 5xx
outage and later its recovery, and asserts the incident/bucketed-
chart/status-overview side effects at each step.

Building this surfaced and fixed four real platform bugs (see the
three preceding commits + this branch's status-page fixes):
status-literal mismatches in overall/bucketed status, required-vs-
optional query params blocking documented defaults, an uptime-
denominator bug from the synthetic bootstrap check row, two divergent
MonitorService instances that skipped the immediate-check path for
API-created monitors, and the AVG() NUMERIC/f64 cast bug fixed in the
prior commit.

The incident auto-resolve step polls rather than asserting once:
current-status reads status_checks directly and flips the instant a
check commits, but incident resolution is a side effect of the same
check processed asynchronously through the job queue, which can trail
by anywhere from a few ms to most of another 60s cycle.
Real error-tracking e2e coverage: creates a DSN (not auto-provisioned,
unlike the monitoring auto-monitor), sends real Sentry-shaped events
authenticated with the DSN's public key via X-Sentry-Auth (the one
route in the platform using a different auth scheme than the normal
bearer token), and proves fingerprint-based grouping actually groups
an identical repeat into the same issue while keeping a genuinely
different exception in its own issue -- not just that the error-group
CRUD routes return 2xx.

Also covers: computed group title ("{type}: {value}"), event detail
round-tripping the stored exception data, error-stats splitting
resolved/unresolved after a status update, and the DSN auth boundary
(a garbage key gets a real 401, not silently accepted).

New lib/sentry-events.ts: a payload builder + raw sender, since the
ingest endpoint's generated SDK type (SentryEventRequest) only
declares event_id/message/platform/timestamp -- the real handler
takes arbitrary JSON and expects a full exception/stacktrace shape,
so the OpenAPI schema for this one route is decorative only and the
typed client can't express a realistic payload.

No platform bugs found this time -- ran clean end-to-end on the first
real attempt (one assertion fix on my own end: event data is stored
wrapped under a `sentry` key alongside a `source` discriminator, not
at the top level, since this crate ingests from multiple SDK sources).
Real container-log round trip through the Docker log collector: deploy a
throwaway Go app that emits structured JSON stdout/stderr on demand, wait
out the actual chunk-flush window, then verify full-text search, level
filtering, JSONB `fields` passthrough, env filtering, grep-style context
via chunk_id/line_offset, and purge -- through the real ingestion
pipeline, not synthetic rows inserted into storage.

Ran clean 3x back-to-back (incl. one --keep + one --json run) with zero
platform bugs found; this scenario is purely additive coverage.

Also promotes the pollUntil polling helper (previously duplicated inside
monitoring-scenario.ts) into the shared flows.ts module now that a third
caller needs it.
packages/api/openapi.json had drifted to 590 of the server's 674 paths
(84 endpoints missing), including the whole temps-analytics query
surface (getEventEntries, checkAnalyticsHasEvents, getVisitorJourney,
and friends) needed to write the analytics e2e scenario. Regenerated
from a live dev server's /api/api-docs/openapi.json, canonicalized the
same way apps/temps-cli/scripts/openapi-canonical.ts does (sorted keys,
2-space indent) so the on-disk diff reflects only real API changes.

Also adds the missing `prettier` devDependency: openapi-ts.config.ts
sets `format: 'prettier'` but the package never declared it, so
`bun run generate` failed with ENOENT in any environment without a
prettier binary already on PATH (nothing else in this package pulls
one in — apps/temps-cli's equivalent config has no formatter step at
all).

apps/temps-e2e typechecks unchanged against the new client; no other
package in this repo depends on @temps-sdk/api.
Deploys a throwaway HTML app (temps-proxy only issues visitor/session
cookies for HTML responses, per should_track_page) so the scenario can
capture real _temps_visitor_id/_temps_sid Set-Cookie values, then
replays them on two POST /api/_temps/event calls the way a real
tracking snippet would. Verifies the custom event's event_data
round-trips into event-entries' props JSON, and that both events land
in the same visitor-journey session -- proving cookie-based session
stitching end-to-end, not just that the query routes return 2xx.

Ran clean 3x back-to-back (incl. one --keep + one --json run) with zero
Rust-side bugs found; this scenario is purely additive coverage. The
SDK staleness that blocked writing it was fixed in the preceding commit.
…ver enforced

Two real bugs found while building session-replay e2e coverage:

1. PUT /visitors/{visitor_id}/session-replays/{session_id}/duration was
   registered as POST in configure_routes(), but the utoipa docs (and every
   generated SDK) declare PUT. Any client following the documented contract
   got a 405. Fixed the route registration to match.

2. delete_session_replay correctly soft-deletes a session (and its events,
   is_active=false), but none of the four read paths ever checked the flag:
   get_sessions_for_project, get_sessions_for_visitor, get_session_replay,
   and get_session_replay_without_events all read is_active=false rows as
   if they were live. A "deleted" recording -- DOM mutations, potentially
   keystrokes -- stayed fully visible in both list views and fetchable in
   full by ID. Added is_active=true filters to all four (including the
   events query inside get_session_replay, which had the same gap on the
   session_replay_events table).

Verified: `cargo test --package temps-analytics-session-replay --lib` (15
passed), clippy clean, and live end-to-end -- confirmed a deleted session's
direct-by-ID playback now correctly 404s where it previously returned the
full event stream.
…ecycle

Deploys the same HTML app used by analytics-scenario (session-replay's
public init endpoint requires a real _temps_visitor_id cookie and 400s
without one), inits a replay session, POSTs two base64+zlib event
batches, then verifies: the project/visitor-scoped lists both surface
it once duration is computed from real timestamps, full playback
returns all events with a custom field round-tripped intact, a manual
duration override sticks, and delete actually removes it from every
read path.

Found and root-cause-fixed two real bugs while building this (see
preceding commit): a PUT/POST route method mismatch on the duration
endpoint, and soft-delete never being enforced by any read path. Ran
clean 3x back-to-back (incl. one --keep + one --json run) after the
fixes landed.
README only covered scenario/tls-scenario/email-scenario/cli-scenario/
examples. Adds usage + a "steps" section for kv-scenario, audit-scenario,
managed-services-scenario, rbac-scenario, monitoring-scenario,
error-tracking-scenario, logs-scenario, analytics-scenario, and
session-replay-scenario -- including the real platform bugs each one
found and fixed, mirroring the existing tls-scenario/cli-scenario style.
@dviejokfs dviejokfs changed the title test(e2e): DNS/TLS/email coverage via Pebble+Mailpit, temps-cli unit tests test(e2e): DNS/TLS/email/CLI + observability/data-storage e2e coverage Aug 8, 2026
…e gap

SSRF guard (validate_external_url) + SlackProvider::initialize()'s
hardcoded hooks.slack.com prefix check block any local target reaching
the real create-to-test API flow. send() itself is already covered by
a wiremock Rust unit test, which is the right layer for this feature.
… Pebble DNS-01

Registers a Pebble DNS provider, issues a wildcard certificate through a
real DNS-01 challenge (TXT record pushed to pebble-challtestsrv, then
Pebble's real validator queries it back), and asserts the resulting
certificate's issuer and SAN.

Closes a real coverage gap rather than a code bug: temps-domains' own
test_dns01_wildcard_with_pebble is #[ignore]d and sets
PEBBLE_VA_ALWAYS_VALID=1, bypassing Pebble's real validator entirely --
nothing before this proved the DNS-01 pipeline works end-to-end.

Verified live 3x back-to-back against a dev instance configured with
ACME_DIRECTORY_URL/ACME_INSECURE/TEMPS_ALLOW_PEBBLE_PROVIDER=1.
… bugs found+fixed

put/download/head/list/copy/delete against the platform-wide blob-storage
singleton, deleted-blob 404, cross-project isolation, and a 400 on a
missing project_id -- mirrors kv-scenario's shape.

Three real bugs found and fixed:

1. blob_put's #[utoipa::path] never declared its query params at all,
   and blob_list's declared params were missing project_id -- both
   existed on the Rust query-extractor structs but were invisible to
   the OpenAPI spec, so no client could call these with a project
   scope. Fixed both annotations and regenerated packages/api.

2. temps-proxy unconditionally stripped Content-Length from every HEAD
   response regardless of downstream HTTP version, when the reason
   given in the existing comment only applies to HTTP/2. An HTTP/1.1
   client got a HEAD response with no Content-Length and no chunked
   encoding on a keep-alive connection -- no way to tell the response
   was complete. Confirmed live: curl -I through the proxy port hung
   indefinitely; the same request against the console port (same
   handler, no proxy) returned instantly with the header present.
   Fixed by only stripping when session.is_http2().

3. BlobService::del counted every successful S3 DeleteObject call as a
   deletion, but DeleteObject is idempotent by design and succeeds
   whether or not the key existed -- so a second delete of already-gone
   keys kept reporting them deleted. Fixed by checking existence via
   HeadObject first.

Verified live 3x back-to-back against a dev instance.
…emps-sdk/node-sdk FlagsClient

Defaults, per-environment overrides, the kill switch outranking an
override (not just the default), ETag/If-None-Match 304 caching,
exposure reporting round-tripped through last_evaluated_at, and the
delivery endpoint's deployment-token-only auth boundary.

Every read goes through the real FlagsClient (@temps-sdk/node-sdk),
not just the raw HTTP API -- that's the actual client apps deployed
on Temps use. Required adding @temps-sdk/node-sdk as a workspace
dependency of apps/temps-e2e and building its dist/ (it had none yet).

No platform bugs found -- every step passed clean on the first
attempt. Verified live 3x back-to-back.
…L-G backups

apps/temps-e2e: new backup-restore-scenario command exercising the real
backup/restore lifecycle for a postgres external service backed by a local
MinIO -- real wal-g backup-push, real S3 upload, real in-place restore,
proven by reverting writes made after the backup (not just 2xx checks).

Bug found and fixed: PostgresWalgEngine::run (crates/temps-backup) ran
`wal-g backup-push` without ever enabling continuous WAL archiving on the
target container, so the base backup's checkpoint LSN had no archived WAL
segment behind it. Every restore failed at Postgres startup with "could not
locate required checkpoint record", the container crash-looped, and
wait_for_container_health timed out at 90s -- an unrestorable backup that
still reported `completed`.

Fixed by adding ExternalService::enable_continuous_archiving (no-op
default, implemented on PostgresService via the existing
enable_wal_archiving) and calling it *before* backup-push, not after:
pg_stop_backup() force-completes the current WAL segment, and Postgres only
marks a completed segment `.ready` for archiving if archive_mode is already
on at that moment -- enabling it afterward doesn't retroactively archive a
segment that closed while archiving was off. Skips the container-recreating
setup entirely once archiving is already active on a service, so it's a
no-op after the first backup.

Confirmed live 3x against a real MinIO + postgres service.
apps/temps-e2e: new git-deploy-scenario command proving the real git
pipeline end to end -- clone github.com/gotempsh/temps-examples, build a
language-preset subdirectory (examples/starters/go/net-http), trigger via
POST /projects/{id}/trigger-pipeline, and verify the exact response bodies
baked into the real repo source (not just 2xx). Deliberately hits real
github.com, unlike every other scenario in this suite, since there's no
local substitute for "does the git-clone-and-build pipeline work against a
real repo host".

Test-side bug found and fixed (not a platform bug): creating a Git-type
project always auto-queues an initial deployment as a side effect of
POST /projects itself, regardless of automatic_deploy, and asynchronously.
The first version raced that auto-deployment's row against the explicit
trigger-pipeline call and ended up polling the wrong (auto-triggered)
deployment, which the platform correctly cancels/supersedes the moment the
real one is created -- surfacing as a false "cancelled" failure while the
real deployment kept building. Fixed by having
triggerPipelineAndGetDeploymentId wait for the auto-queued deployment to
land first, so the baseline id used to detect "a genuinely new deployment"
is deterministic.

Confirmed live 3x against a real GitHub clone + build.
domain-tls-pebble.spec.ts and email-provider-mailpit.spec.ts (added earlier
on this branch) each document that they need
`docker compose -f apps/temps-e2e/docker-compose.e2e.yml up -d`, but no CI
workflow ever ran it -- so both failed in every run:
- domain-tls-pebble: `docker run --network temps-e2e-pebble-net ...` failed
  with "network temps-e2e-pebble-net not found" (the compose file that
  creates it was never brought up).
- email-provider-mailpit: the "Send Test Email" UI action never showed
  success, because nothing was listening on Mailpit's SMTP port for temps
  to actually deliver to.

Also, `temps serve` was starting with `--address 0.0.0.0:3000`, but Pebble's
baked-in config sends real HTTP-01 validation requests to its own fixed
httpPort (5002) -- so even with Pebble reachable, the challenge response
would never land on temps' proxy.

Fixed by:
- adding a step that brings up pebble/pebble-challtestsrv/mailpit from
  docker-compose.e2e.yml and waits for all three ports before `temps serve`
  starts (mirrors README.md's "External-service test infra" section)
- setting ACME_DIRECTORY_URL/ACME_INSECURE/TEMPS_ALLOW_PEBBLE_PROVIDER on
  the `temps serve` step
- changing --address to 0.0.0.0:5002 to match Pebble's fixed httpPort

Verified locally: ran both specs against a one-off instance configured
identically to this workflow (Pebble/Mailpit from the same compose file,
--address=5002, the same three env vars) -- both pass, confirmed 2x.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant