-
Notifications
You must be signed in to change notification settings - Fork 0
206 lines (190 loc) · 8.78 KB
/
Copy pathauth-contract-e2e.yml
File metadata and controls
206 lines (190 loc) · 8.78 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
# Layer-1 PR-gate: AUTH-004 CORS contract + magic-link round-trip
#
# Why: the 2026-05-29 → 2026-05-30 login outage had THREE stacked failures
# (web missing /auth/exchange POST, web sending Accept:application/json,
# api missing access-control-allow-credentials). The worker auth-probe
# catches it 5 min post-deploy; the OpenAPI contract CI catches schema
# drift at PR time. Neither catches it END-TO-END IN A BROWSER before
# merge. This workflow does — see e2e/auth-contract.spec.ts.
#
# What it runs:
# - Three Chromium-driven asserts against PROD api.instanode.dev +
# instanode.dev (override with E2E_API_URL / E2E_WEB_ORIGIN).
# - Bounded under 2 minutes (live tests are ~3s; lion's share is
# npm ci + Chromium download).
#
# Triggers:
# - pull_request to main (any path — auth surface is implicit to every PR).
# - push to main (post-merge canary).
# - workflow_dispatch (manual smoke).
# - repository_dispatch with type `auth-contract-e2e-from-api` (fired by
# the api repo's CI workflow when an api PR opens/pushes — closes the
# cross-repo gap, since an api-side CORS regression would otherwise
# ship without this gate firing).
name: Auth contract E2E (PR gate)
on:
pull_request:
branches: [main]
push:
branches: [main]
workflow_dispatch:
inputs:
api_url:
description: 'Override E2E_API_URL'
required: false
default: 'https://api.instanode.dev'
web_origin:
description: 'Override E2E_WEB_ORIGIN'
required: false
default: 'https://instanode.dev'
run_roundtrip:
description: 'Also run the cookie-exchange ROUND-TRIP spec (needs a NON-prod api + E2E_JWT_SECRET secret)'
type: boolean
required: false
default: false
repository_dispatch:
types: [auth-contract-e2e-from-api]
concurrency:
# Cancel an in-flight run when a new commit lands on the same PR.
# Different PRs / triggers still run in parallel.
group: auth-contract-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
auth-contract-e2e:
name: Auth contract smoke against prod
runs-on: ubuntu-latest
timeout-minutes: 5
env:
# SECURITY: never interpolate raw repository_dispatch client_payload
# values into `run:` scripts (workflow-injection sink). Pipe through
# env: and let the validate step sanitise. workflow_dispatch inputs
# are gated by repo write permission so are lower-risk but get the
# same treatment for consistency.
RAW_API_URL: ${{ github.event.inputs.api_url || github.event.client_payload.api_url || '' }}
RAW_WEB_ORIGIN: ${{ github.event.inputs.web_origin || github.event.client_payload.web_origin || '' }}
TRIGGER: ${{ github.event_name }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
- name: Validate + resolve E2E targets
# Allowlist the only hosts this smoke is permitted to drive. An
# attacker who fires a repository_dispatch with a hostile api_url
# cannot redirect the test to an attacker-controlled origin and
# exfiltrate the GITHUB_TOKEN — the value is rejected here.
# `set -u` would NOT catch the injection; explicit validation does.
env:
DEFAULT_API_URL: https://api.instanode.dev
DEFAULT_WEB_ORIGIN: https://instanode.dev
run: |
set -euo pipefail
api="${RAW_API_URL:-$DEFAULT_API_URL}"
web="${RAW_WEB_ORIGIN:-$DEFAULT_WEB_ORIGIN}"
case "$api" in
https://api.instanode.dev|https://api.instanode.dev/) ;;
*) echo "::error::E2E_API_URL '$api' not in allowlist {https://api.instanode.dev}"; exit 1 ;;
esac
case "$web" in
https://instanode.dev|https://instanode.dev/|https://www.instanode.dev|https://www.instanode.dev/) ;;
*) echo "::error::E2E_WEB_ORIGIN '$web' not in allowlist {https://instanode.dev,https://www.instanode.dev}"; exit 1 ;;
esac
# Strip trailing slash for stable comparison in the spec.
api="${api%/}"
web="${web%/}"
echo "E2E_API_URL=$api" >> "$GITHUB_ENV"
echo "E2E_WEB_ORIGIN=$web" >> "$GITHUB_ENV"
echo "Resolved E2E_API_URL=$api E2E_WEB_ORIGIN=$web trigger=$TRIGGER"
- run: npm ci
# Only Chromium — this is a smoke, not a cross-browser matrix.
- run: npx playwright install --with-deps chromium
- name: Run auth-contract smoke
run: npx playwright test --config=playwright.auth-contract.config.ts
# Upload trace + screenshots on failure so the PR author can replay
# the exact browser session locally (`npx playwright show-trace ...`).
- name: Upload Playwright trace on failure
if: failure()
uses: actions/upload-artifact@v7
with:
name: auth-contract-trace-${{ github.run_id }}
path: |
test-results/
playwright-report-auth-contract/
if-no-files-found: ignore
retention-days: 14
# ── Cookie-exchange ROUND-TRIP (manual / staging only) ───────────────────
#
# The Layer-1 smoke above probes the CORS envelope with NO cookie. This job
# runs the full round-trip spec (e2e/auth-roundtrip.spec.ts): provision →
# claim → plant the bridge cookie → cross-origin exchange → Bearer
# /auth/me 200. That needs a NON-prod api it can mint a bridge cookie for
# (E2E_JWT_SECRET = the api's JWT_SECRET) + a provisioning backend.
#
# WHY workflow_dispatch-only (not on every PR): instanode-web CI cannot
# build/boot the api binary (cross-repo), and we never put the prod
# JWT_SECRET in this repo's CI. The AUTHORITATIVE pre-merge round-trip gate
# lives in the api repo's Layer-2 docker-compose workflow (which builds the
# api from PR source + has the stack-local secret). This job is the
# instanode-web-side companion an operator points at a staging/compose api
# on demand. The spec self-skips loudly when the secret is absent or the
# backend returns 503, so a misfire reports as skipped, never a false red.
auth-roundtrip:
name: Cookie-exchange round-trip (staging/compose, on demand)
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.run_roundtrip == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 8
env:
# Route dispatch inputs through env: + validate (workflow-injection
# hygiene — same pattern as the smoke job above). The round-trip MUST
# NOT target prod (planting a bridge cookie needs the stack JWT_SECRET).
RAW_API_URL: ${{ github.event.inputs.api_url || '' }}
RAW_WEB_ORIGIN: ${{ github.event.inputs.web_origin || '' }}
# E2E_JWT_SECRET is the api's JWT_SECRET for the TARGET (staging/compose)
# api — provided as a repo/environment secret by the operator who runs
# this. Never the prod secret.
E2E_JWT_SECRET: ${{ secrets.E2E_JWT_SECRET }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
- name: Validate + resolve round-trip targets
run: |
set -euo pipefail
api="${RAW_API_URL:-}"
web="${RAW_WEB_ORIGIN:-http://localhost:5173}"
if [ -z "$api" ]; then
echo "::error::run_roundtrip requires a non-empty api_url pointing at a staging/compose api (NOT prod)."
exit 1
fi
# Hard refuse the prod api — the round-trip would need the prod
# JWT_SECRET, which must never enter this repo's CI.
case "$api" in
https://api.instanode.dev|https://api.instanode.dev/)
echo "::error::round-trip cannot target prod api (api.instanode.dev). Point it at a staging/compose api."
exit 1 ;;
esac
api="${api%/}"
web="${web%/}"
echo "E2E_API_URL=$api" >> "$GITHUB_ENV"
echo "E2E_WEB_ORIGIN=$web" >> "$GITHUB_ENV"
echo "Resolved round-trip E2E_API_URL=$api E2E_WEB_ORIGIN=$web"
- run: npm ci
- run: npx playwright install --with-deps chromium
- name: Run cookie-exchange round-trip
# Spec self-skips (loudly) when E2E_JWT_SECRET is empty or the
# provisioning backend returns 503 — so a missing secret reports as
# skipped, not a hard failure.
run: npx playwright test --config=playwright.auth-roundtrip.config.ts
- name: Upload round-trip trace on failure
if: failure()
uses: actions/upload-artifact@v7
with:
name: auth-roundtrip-trace-${{ github.run_id }}
path: |
test-results/
playwright-report-auth-roundtrip/
if-no-files-found: ignore
retention-days: 14