Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 177 additions & 0 deletions .github/workflows/e2e-pr-smoke.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
# Real-backend UI SMOKE on the PR path (Wave 2 — close the "UI PRs merge having
# touched the real api only via a cookieless CORS preflight" gap).
#
# Design ref: docs/ci/01-CI-INTEGRATION-DESIGN.md (Wave 2): "A real-backend UI
# smoke must gate the PR (or post-merge-pre-deploy), not run only on a 30-min
# schedule." This runs a SMALL, tagged subset (@pr-smoke) of the Wave 3
# real-backend UI journeys against PROD via a minted, cohort-scoped, reaped
# account — so EVERY web PR exercises the actual dashboard against the real api
# and catches the login-broke class (a backend field/enum/status rename that
# compiles + passes mocked Playwright but breaks the real /app) BEFORE merge.
#
# The @pr-smoke subset (3 journeys, ~40s):
# #1 auth round-trip — /app renders authed (not /login); /auth/me data loads.
# #2 provision render — a seeded resource lists + its detail/metrics render.
# #3 deploy row — a created deploy's row + detail logs + make-permanent.
# The FULL journey suite stays on the 30-min schedule (e2e-prod.yml).
#
# WHY this is safe to run against prod (same invariants as e2e-prod.yml):
# - The mint endpoint creates an is_test_cohort=true account; the live worker
# skip-guards neuter billing/churn/email/quota for it (no charge, no quota
# burn, no "we miss you" email, no churn of a real customer).
# - The account + every resource it creates is reaped: this job DELETEs the
# minted account AND runs the per-run ledger reaper (npm run reap:live) in an
# `if: always()` teardown; the reaper exits non-zero on any leak (rule 24).
# - cohort.ts assertSafeApiTarget() only allows a prod target for a sanctioned
# minted run; a stray invocation can never hammer prod.
#
# FORK / SECRET-LESS SAFETY (why this never hard-fails a PR that can't reach
# secrets): GitHub does NOT expose repo secrets to pull_request runs from forks.
# The gate step below no-ops the job cleanly (::notice::) when E2E_ACCOUNT_TOKEN
# is empty, so a fork PR (or a PR opened before the secret exists) goes GREEN
# without running — it is "required only when secrets are available". For
# same-repo branch PRs (where secrets ARE present) it runs for real and gates the
# merge. (Same posture as e2e-prod.yml's gate.)

name: E2E PR smoke (real-backend UI, minted account)

on:
pull_request:
# Only when the dashboard surface or the live-UI harness changes — a docs-only
# or marketing-copy PR doesn't need to spend a prod mint+reap cycle. (The
# full schedule still covers everything every 30 min.)
paths:
- 'src/**'
- 'e2e/**'
- 'playwright.live.config.ts'
- 'vite.config.ts'
- 'package.json'
- '.github/workflows/e2e-pr-smoke.yml'

concurrency:
# One PR-smoke run per branch; cancel superseded runs (a new push obsoletes the
# old). Distinct group from e2e-prod so a scheduled prod run isn't cancelled.
group: e2e-pr-smoke-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
pr-smoke:
name: UI real-backend smoke (@pr-smoke) via minted account + reap
runs-on: ubuntu-latest
timeout-minutes: 15
env:
E2E_API_URL: https://api.instanode.dev
E2E_LIVE_RUN_ID: ${{ github.run_id }}
E2E_ACCOUNT_TOKEN: ${{ secrets.E2E_ACCOUNT_TOKEN }}
steps:
- name: Gate on configured mint token (no-op cleanly on fork / secret-less PR)
run: |
set -euo pipefail
if [ -z "${E2E_ACCOUNT_TOKEN:-}" ]; then
echo "::notice::secrets.E2E_ACCOUNT_TOKEN not available (fork PR or unconfigured) — skipping UI PR smoke (no-op, GREEN)."
echo "RUN=0" >> "$GITHUB_ENV"
else
echo "RUN=1" >> "$GITHUB_ENV"
fi

- uses: actions/checkout@v6
if: env.RUN == '1'

- uses: actions/setup-node@v6
if: env.RUN == '1'
with:
node-version: '22'
cache: 'npm'

- name: Install deps
if: env.RUN == '1'
run: npm ci

- name: Install Chromium
if: env.RUN == '1'
run: npx playwright install --with-deps chromium

- name: Mint ephemeral cohort account (pro — deploy headroom for #3)
id: mint
if: env.RUN == '1'
run: |
set -euo pipefail
resp="$(curl -sS -w '\n%{http_code}' \
-X POST "${E2E_API_URL}/internal/e2e/account" \
-H "X-E2E-Token: ${E2E_ACCOUNT_TOKEN}" \
-H 'Content-Type: application/json' \
-d '{"tier":"pro","with_resources":true}')"
code="$(printf '%s' "$resp" | tail -n1)"
body="$(printf '%s' "$resp" | sed '$d')"
if [ "$code" != "200" ]; then
echo "::error::mint endpoint returned HTTP $code (expected 200). Body: $body"
exit 1
fi
jwt="$(printf '%s' "$body" | jq -r '.session_jwt // empty')"
team="$(printf '%s' "$body" | jq -r '.team_id // empty')"
email="$(printf '%s' "$body" | jq -r '.email // empty')"
tier="$(printf '%s' "$body" | jq -r '.tier // empty')"
if [ -z "$jwt" ] || [ -z "$team" ]; then
echo "::error::mint response missing session_jwt or team_id. Body: $body"
exit 1
fi
echo "::add-mask::$jwt"
echo "::add-mask::$team"
{
echo "MINTED_SESSION_JWT=$jwt"
echo "MINTED_TEAM_ID=$team"
echo "MINTED_EMAIL=$email"
echo "MINTED_TIER=$tier"
} >> "$GITHUB_ENV"
echo "minted=1" >> "$GITHUB_OUTPUT"
echo "Minted cohort account (tier=$tier) for the PR UI smoke — session + team_id masked."

- name: Run @pr-smoke UI journeys against prod
if: env.RUN == '1' && steps.mint.outputs.minted == '1'
env:
E2E_LIVE: '1'
# The minted PRO account drives the authed UI legs (cohort.ts
# mintedSession / factory mintUser). The factory mints per-test, but
# exporting the workflow-minted session here makes assertSafeApiTarget
# treat the prod target as sanctioned even for the anon login leg.
E2E_SESSION_JWT: ${{ env.MINTED_SESSION_JWT }}
E2E_TEAM_ID: ${{ env.MINTED_TEAM_ID }}
E2E_ACCOUNT_EMAIL: ${{ env.MINTED_EMAIL }}
E2E_ACCOUNT_TIER: ${{ env.MINTED_TIER }}
# Fingerprint bypass for any anon provision the smoke touches.
E2E_TEST_TOKEN: ${{ secrets.E2E_TEST_TOKEN }}
run: npm run test:e2e:live:pr-smoke

- name: Reap the workflow-minted account (teardown)
if: always() && env.RUN == '1' && env.MINTED_TEAM_ID != ''
run: |
set -euo pipefail
code="$(curl -sS -o /dev/null -w '%{http_code}' \
-X DELETE "${E2E_API_URL}/internal/e2e/account/${MINTED_TEAM_ID}" \
-H "X-E2E-Token: ${E2E_ACCOUNT_TOKEN}")"
case "$code" in
200|202|204|404|410) echo "Reaped workflow-minted account (HTTP $code)." ;;
*) echo "::error::DELETE minted account returned HTTP $code — possible leak."; exit 1 ;;
esac

- name: Reap per-test cohort resources from ledger (teardown)
# The factory mints a fresh account PER test and reaps it inline + via the
# ledger; this sweep is the no-leak backstop. Exits non-zero on any leak,
# failing the job loudly (rule 24).
if: always() && env.RUN == '1'
run: npm run reap:live

- name: Upload trace on failure
if: failure() && env.RUN == '1'
uses: actions/upload-artifact@v4
with:
name: e2e-pr-smoke-trace-${{ github.run_id }}
path: |
test-results/
playwright-report-live/
e2e/.cleanup-ledger.json
if-no-files-found: ignore
retention-days: 7
195 changes: 195 additions & 0 deletions e2e/factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
// e2e/factory.ts — Wave 3 multi-tier test-user FACTORY (TS wrapper).
//
// Design ref: docs/ci/01-CI-INTEGRATION-DESIGN.md (Wave 3) + the live api
// endpoint POST/DELETE /internal/e2e/account (api/internal/handlers/
// internal_e2e_account.go, shipped 61afc5b). This module is the thin TS
// wrapper the real-backend UI journey specs (live-ui-*.spec.ts) use to mint a
// cohort-tagged, is_test_cohort=true account at ANY non-team tier, optionally
// pre-seeded, drive the ACTUAL dashboard against it, then reap — never leaking
// a billable resource (CLAUDE.md rule 24).
//
// Why a wrapper (not inline curls in every spec): the journeys mint at several
// tiers (pro for headroom, hobby at the deployments_apps cap, free for the
// 402-gate, a SECONDARY account as a team invitee). Centralising the mint/reap
// + ledger registration here keeps every spec's safety machinery identical and
// keeps the cohort contract (the X-E2E-Token header, the team-cascade reap) in
// ONE place that the ledger reaper already understands (kind 'e2e-account').
//
// Gating (mirrors the existing live specs): the factory is INERT unless the
// e2e mint token is configured. The endpoint is also inert-by-default on the
// api (404 when the token is wrong / unarmed). A caller with no token gets a
// null/skip signal rather than a hard failure, so a fork/secret-less PR run
// SKIPS cleanly instead of redding (the e2e-prod workflow exports the token).

import type { APIRequestContext } from '@playwright/test'

import { COHORT_MARKER } from './cohort'
import { recordEntity, E2E_ACCOUNT_TOKEN_HEADER } from './cleanup-ledger'

// ── Config / env ─────────────────────────────────────────────────────────────

/**
* The mint-guard secret. The e2e-prod workflow exports it into the test step
* env (E2E_ACCOUNT_TOKEN). Empty on a local / fork / secret-less run → the
* factory is inert (mintUser returns null; specs SKIP loudly). Read lazily via
* a getter so a test can set process.env before the first call.
*/
export function accountToken(): string {
return process.env.E2E_ACCOUNT_TOKEN ?? ''
}

/** True when the factory can mint (the guard token is configured). */
export function factoryArmed(): boolean {
return accountToken().length > 0
}

/** Resolve the api base the factory mints against (absolute, trailing-slash-stripped). */
export function apiBase(): string {
return (process.env.E2E_API_URL ?? process.env.AGENT_API_URL ?? '').toString().replace(/\/$/, '')
}

// The closed set the api accepts (internal_e2e_account.go e2eAllowedTiers).
// team + growth are deliberately ABSENT — the api 400s them
// (project_team_plan_not_rolled_out). Mirrored here so a typo'd tier fails in
// TS before a wasted round-trip, and so a spec can't ask for a gated tier.
export const MINTABLE_TIERS = ['anonymous', 'free', 'hobby', 'hobby_plus', 'pro'] as const
export type MintableTier = (typeof MINTABLE_TIERS)[number]

// The deploy-cap journey (#4) needs a tier whose deployments_apps limit is
// exactly 1 so deploy #1 fills it and deploy #2 hits the 402 wall. plans.yaml:
// hobby=1, hobby_plus=2. hobby is the canonical "one deploy slot" tier.
export const DEPLOY_CAP_TIER: MintableTier = 'hobby'

// ── The minted account shape ─────────────────────────────────────────────────

/** A minted cohort account + everything a spec needs to drive + reap it. */
export interface MintedUser {
teamID: string
userID: string
email: string
tier: string
/** The session JWT the browser sets as localStorage['instanode.token']. */
sessionJWT: string
/** Pre-seeded resource tokens (empty unless mintUserWithResources was used). */
seededTokens: string[]
}

interface MintResponseBody {
team_id: string
user_id: string
email: string
tier: string
session_jwt: string
seeded_tokens?: string[]
seeded_count?: number
}

// ── Mint ─────────────────────────────────────────────────────────────────────

interface MintOpts {
tier?: MintableTier
withResources?: boolean
}

/**
* Mint a cohort account at the given tier (default `free`). Registers it with
* the cleanup ledger as kind 'e2e-account' BEFORE returning, so the afterAll
* backstop + the out-of-process reaper (npm run reap:live) cascade-delete it
* even if the spec throws between mint and inline reap (rule 24).
*
* Returns null when the factory is unarmed (no mint token) OR the endpoint is
* inert (404 — wrong token / not deployed). Callers test.skip on null.
*/
export async function mintUser(
request: APIRequestContext,
opts: MintOpts = {},
): Promise<MintedUser | null> {
if (!factoryArmed()) return null
const base = apiBase()
if (!base) return null
const tier = opts.tier ?? 'free'
const resp = await request.fetch(`${base}/internal/e2e/account`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', [E2E_ACCOUNT_TOKEN_HEADER]: accountToken() },
data: JSON.stringify({ tier, with_resources: !!opts.withResources }),
failOnStatusCode: false,
})
// Inert-by-default 404: the token is wrong or the endpoint isn't armed on
// this stack. Treat as "can't mint" → null (caller SKIPS), never a red.
if (resp.status() === 404) return null
if (resp.status() !== 200) {
const text = await resp.text().catch(() => '<unreadable>')
throw new Error(
`factory.mintUser(tier=${tier}) expected 200 from POST /internal/e2e/account; got ` +
`${resp.status()}. Body: ${text}`,
)
}
const body = (await resp.json()) as MintResponseBody
// Record the account for cascade reap the instant it exists (rule 24). The
// 'e2e-account' kind reaps via DELETE /internal/e2e/account/:team_id using
// the mint-token header (cleanup-ledger.ts handles that path).
recordEntity({
kind: 'e2e-account',
id: body.team_id,
apiUrl: base,
note: `factory ${tier} account ${body.email}`,
})
return {
teamID: body.team_id,
userID: body.user_id,
email: body.email,
tier: body.tier,
sessionJWT: body.session_jwt,
seededTokens: body.seeded_tokens ?? [],
}
}

/** Mint a pre-seeded account (one fast row per seed type). See mintUser. */
export function mintUserWithResources(
request: APIRequestContext,
opts: Omit<MintOpts, 'withResources'> = {},
): Promise<MintedUser | null> {
return mintUser(request, { ...opts, withResources: true })
}

/**
* Mint at the deployments_apps cap: a hobby account (deployments_apps=1). The
* delete-when-exhausted→replace journey (#4) fills the single slot, asserts the
* 402 wall in the UI, deletes deploy #1, then ships the replacement into the
* freed slot.
*/
export function mintAtDeployCap(request: APIRequestContext): Promise<MintedUser | null> {
return mintUser(request, { tier: DEPLOY_CAP_TIER })
}

// ── Reap ─────────────────────────────────────────────────────────────────────

/**
* Eagerly reap a minted account out-of-band via the guarded internal cascade
* (DELETE /internal/e2e/account/:team_id, mint-token-header authorized) — the
* SAME path the e2e-prod workflow teardown + the ledger reaper use. Idempotent:
* a 200/202/204/404/410 all count as success. The ledger 'e2e-account' entry is
* the backstop, so a spec calls this for promptness but never depends on it.
*/
export async function reap(request: APIRequestContext, teamID: string): Promise<void> {
if (!factoryArmed() || !teamID) return
const base = apiBase()
if (!base) return
const resp = await request.fetch(`${base}/internal/e2e/account/${teamID}`, {
method: 'DELETE',
headers: { [E2E_ACCOUNT_TOKEN_HEADER]: accountToken() },
failOnStatusCode: false,
})
const ok = [200, 202, 204, 404, 410].includes(resp.status())
if (!ok) {
const text = await resp.text().catch(() => '<unreadable>')
throw new Error(
`factory.reap(${teamID}) expected 2xx/404/410 from the cascade DELETE; got ` +
`${resp.status()}. Body: ${text} — possible leak, investigate.`,
)
}
}

// COHORT_MARKER is re-exported so a spec importing only the factory still has
// the shared brand on hand for cohort-name assertions without a second import.
export { COHORT_MARKER }
Loading
Loading