Skip to content

test(e2e): cover real password sign-in through the Security API + Authentik - #280

Merged
izzywdev merged 11 commits into
masterfrom
claude/e2e-real-signin-coverage
Jul 16, 2026
Merged

test(e2e): cover real password sign-in through the Security API + Authentik#280
izzywdev merged 11 commits into
masterfrom
claude/e2e-real-signin-coverage

Conversation

@izzywdev

Copy link
Copy Markdown
Owner

Piece B. Sign-in has had no honest CI coverage since the Security API cutover — this adds it.

Why e2e.yml could never provide it

.github/workflows/e2e.yml ("Playwright sign-in flow") is broken by construction, not by a stale assertion:

  • it starts the monolith with no Authentik, and
  • seeds the account as a local bcrypt row.

But the SPA signs in via POST /api/v1/security/session, brokered through Authentik — a bcrypt row in the platform DB is not a credential Authentik will accept, and the monolith doesn't even serve that route. No test edit can fix that job; the stack is wrong. That's why it's been red on master since the cutover, and red on PRs that touch only markdown.

Why here instead

The plumbing workflow already has everything required — full compose stack, Authentik provisioned, a real Authentik user — and, with the security service now in that stack (#279), it's the only place real sign-in can be exercised. Running it here beats duplicating ~300 lines of Authentik provisioning into the other workflow.

What this adds

  • auth-simple: credentials from E2E_USER_EMAIL/E2E_USER_PASSWORD (defaults unchanged), so it can target the account that exists in the provider.
  • Seed the platform projection (users + personal org + owner membership) for that account. WorkspaceProvisioningGate only renders the shell once a personal org exists. A real login provisions it asynchronously — but Permit is a no-op and Kafka is absent in CI, so the gate would spin and the spec would fail for reasons unrelated to auth. Seeding by email is the trick: syncUserToDatabase matches on email and mints its own uuid, so the row is adopted by the login rather than duplicated. No password_hash — the credential lives in Authentik.
  • Verify the seed, fail loudly. A missing org looks identical to broken auth; I'd rather the workflow say so than have the spec hang at the gate.

A pass here means password sign-in genuinely works end to end: browser → Security API → Authentik flow-executor → code exchange → session → dashboard.

Verified

  • YAML PARSE OK; steps resolve in order (seed → verify → plumbing → sign-in).
  • Table/column names checked against the migrations (organization_memberships, provisioning_state, joined_at, permissions) — my first cut used a non-existent memberships table and would have failed; corrected before pushing.

Stacking

Carries #279 (security service in the compose stack) and #277 (specs repointed at /api/v1/security/*) — both are prerequisites. Merge order: #279#277 → this, or merge this last.

Follow-ups (deliberately not bundled)

  • e2e.yml's sign-in job should be retired once this is green — it cannot be repaired in place.
  • The plumbing spec's own access_denied OIDC failure is a separate real bug.
  • Playwright sign-in flow / OIDC plumbing are not required checks (only gate-* are) — which is precisely why the auth e2e could stay red for days. Worth making sign-in required once green.

hold + no auto-merge — master is deploy-on-push.

Co-Authored-By: Claude

fuzeone-bot Bot and others added 8 commits July 16, 2026 12:41
startSocialLogin sent the browser to the generic OIDC authorize endpoint, which
requires an authenticated session. With none, Authentik fell back to the brand's
authentication flow and rendered its own identification page (/if/flow/...) with
a "Google" button — stranding the user on the IdP's UI instead of Google.

Launch the Google source directly instead: /source/oauth/login/google/?next=<authorize>.
The source-redirect view 302s straight to accounts.google.com; after the callback
the source flow runs (silent enrollment first time, login when returning), then
`next` resumes authorize, which issues the code silently. Same cookie/state/PKCE
round-trip — one hop inserted ahead of authorize, so no /if/flow/ ever renders.
This also lets devops drop /if from the IdP ingress.

Also corrects the stale source-google.yaml comment (the removed auth.fuzefront.com
-> app.fuzefront.com); the callback is host-derived, not a settable field.

Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com>
Claude-Session-Id: cf830721-b1ef-4fe0-a024-035ad280dcf7
…ly uses

The security cutover moved the SPA onto /api/v1/security/* (POST /session to
log in, GET /methods for capabilities, /social/google/start, /session/exchange),
but the e2e suite still waited on /api/auth/login and mocked /api/auth/method.
The frontend never calls those anymore, so page.waitForResponse() hung until
timeout — this is why "E2E (sign-in)" has been red on master since the cutover.
The tests were asserting an endpoint we deleted from the client, not a real bug.

- auth-simple / clock-load / mobile-layout / pages/login-page: wait on
  POST /api/v1/security/session. Match the METHOD too — GET /session ("me")
  shares the URL and would otherwise satisfy the wait before login completes.
- google-signin: mock /api/v1/security/methods with the neutral descriptor
  (`social: ['google']` replaces the vendor-flavoured `oidcConfigured`), and
  route the real endpoints — /social/google/start and /session/exchange.
- federated-apps-register-activate: "me" is GET /api/v1/security/session.
- live-smoke (post-prod): smoke /methods and sign in via /session — the same
  surface prod serves browsers, so it fails if real sign-in breaks.

Also ADDS the missing coverage of the new surface to oidc-plumbing (it only
tested the deprecated /api/auth shim): capability descriptor, password sign-in,
wrong-password 401, plus a boundary assertion that no response leaks the vendor.
The /api/auth shim assertions stay — it is still mounted for one release.

Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com>
Claude-Session-Id: cf830721-b1ef-4fe0-a024-035ad280dcf7
The source path was hardcoded to google. The `provider !== 'google'` guard above
means nothing else can reach it today, so this is not a live bug — but it is a
trap: widening that guard would silently route every new provider through
Google's source. Track `provider` so the slug cannot drift from the guard.

Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com>
Claude-Session-Id: cf830721-b1ef-4fe0-a024-035ad280dcf7
…v1/security

The SPA was migrated onto the provider-agnostic Security API (/api/v1/security/*),
served ONLY by backend/security. But no e2e stack ran that service: this compose
builds backend/Dockerfile (the MONOLITH), which mounts /api/auth, /api/apps,
/api/organizations and /api/v1/app-registry — not /api/v1/security. So in CI the
SPA's login POST 404s and the sign-in e2e times out waiting for a response that
can never arrive.

That is why "E2E (sign-in)" has been red on master since the cutover, and red on
EVERY pr since — including PRs that touch only a shell script and markdown. The
tests were not flaky and the failure was not pre-existing noise: the security
service, which now serves all SPA auth, has had ZERO e2e coverage.

Adds the `security` service (Authentik + DB env — password sign-in is brokered
server-side through the flow-executor, so it needs Authentik even for the plain
email/password path) and routes /api/v1/security/ → security:3002 in the e2e
nginx, mirroring the prod Ingress. The frontend now depends on it: nginx resolves
upstreams at startup and refuses to boot on an unresolvable one.

Prod was never affected — the k8s Ingress routes /api/v1/security →
fuzefront-security ahead of nginx.

Verified:
  docker compose -f docker-compose.e2e.yml config   -> PARSE OK, `security` resolved
  nginx -t (upstreams host-mapped)                  -> syntax is ok, test successful

Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com>
Claude-Session-Id: cf830721-b1ef-4fe0-a024-035ad280dcf7
Self-review catch: I had pointed the security container at
http://localhost:4173/api/v1/security/social/callback, which is not in the OIDC
provider's redirect_uris. Authentik matches those strict, so it would have
rejected the authorize call and password login would still have failed — the
container would be present but sign-in still broken.

Password sign-in is a full authorize→code→exchange (services/authentikPassword.ts
drives the flow-executor, then GETs the authorize URL with the session cookies and
lifts `code` out of the 302 Location). The redirect_uri therefore has to be
REGISTERED, but never has to be browser-reachable — the server follows the
redirect itself.

So reuse the shared, already-registered /api/auth/oidc/callback, which is exactly
what prod's security service does (authentik.oidc.redirectUri →
https://app.fuzefront.com/api/auth/oidc/callback). Registered set:
authentik/blueprints/provider-oidc.yaml.

Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com>
Claude-Session-Id: cf830721-b1ef-4fe0-a024-035ad280dcf7
…the backend

Regression I introduced in this PR: adding the security container made the
OIDC-plumbing stack fail with

    dependency failed to start: container fuzefront-e2e-backend-1 exited (1)

which the control run (same branch, before this container existed) did NOT do —
there the stack came up and the test failed on its own merits (access_denied).

Cause: backend/security/src/index.ts runs the SAME 001-009 migration chain
against the SAME `knex_migrations` table as the monolith. Compose started both
at once, they raced for the migration lock, and the loser exited 1 — taking the
whole stack down, since frontend depends on it.

Serialise: security now waits for backend to be healthy, so the chain is already
applied and security's own run is a harmless no-op. Chain is
postgres → backend → security → frontend (no cycle; compose config parses).

Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com>
Claude-Session-Id: cf830721-b1ef-4fe0-a024-035ad280dcf7
…hentik

Sign-in has had NO honest CI coverage since the Security API cutover.

.github/workflows/e2e.yml ("Playwright sign-in flow") cannot provide it, by
construction: it starts the monolith with NO Authentik and seeds the account as
a local bcrypt row. But the SPA signs in via /api/v1/security/session, brokered
through Authentik — a bcrypt row in the platform DB is not a credential Authentik
will accept, and the monolith does not even serve that route. So that job has
been failing since the cutover for reasons no test change can fix.

The plumbing workflow already has what is needed — full compose stack, Authentik
provisioned, a real Authentik user — and (with the security service now in that
stack) is the only place real sign-in CAN be exercised. So run it here instead of
duplicating ~300 lines of Authentik provisioning into the other workflow.

- auth-simple: take credentials from E2E_USER_EMAIL/E2E_USER_PASSWORD (defaults
  unchanged) so it can target the account that exists in the provider.
- Seed the platform projection (users + personal org + owner membership) for that
  account. WorkspaceProvisioningGate only renders the shell once a personal org
  exists; a real login provisions it asynchronously, but Permit is a no-op and
  Kafka is absent in CI, so the gate would spin and the spec would fail for
  reasons unrelated to auth. Seeding by EMAIL is what makes this work:
  syncUserToDatabase matches on email and mints its own uuid, so the row is
  adopted by the login rather than duplicated. No password_hash — the credential
  lives in Authentik.
- Verify the seed and fail loudly: a missing org looks identical to broken auth.

A pass here means password sign-in genuinely works end to end: browser →
Security API → Authentik flow-executor → code exchange → session → dashboard.

Stacked on the security-service compose change and the endpoint repointing; both
are prerequisites, so this branch carries them.

Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com>
Claude-Session-Id: cf830721-b1ef-4fe0-a024-035ad280dcf7
@izzywdev izzywdev added the hold label Jul 16, 2026
@github-actions
github-actions Bot enabled auto-merge (squash) July 16, 2026 13:00
@github-actions

Copy link
Copy Markdown
Contributor

Automated code review (gate-code-review)

Credit balance is too low

Report-only — this check never blocks merge.

Comment thread deploy/e2e/nginx.e2e.conf Outdated
Comment thread deploy/e2e/nginx.e2e.conf Fixed
Comment thread deploy/e2e/nginx.e2e.conf Fixed
Comment thread deploy/e2e/nginx.e2e.conf Fixed
Comment thread deploy/e2e/nginx.e2e.conf Fixed
…ity API

Root cause of the UI sign-in failures, found in the plumbing run: the frontend
image was built with VITE_API_URL=http://localhost:3001, pointing the browser
STRAIGHT AT THE MONOLITH and bypassing nginx entirely. The monolith does not
serve /api/v1/security/*, so GET /methods 404'd, the SPA saw no `social`, the
Google button never rendered, and the test died on

    waiting for getByRole('button', { name: /sign in with google/i })

Sign-in could not work in that stack at all — and it made the nginx route added
alongside the security container dead code.

Same-origin ('' → relative URLs) sends every call through this container's nginx,
which path-routes /api/v1/security/ → security:3002 and the rest → backend:3001.
That is also the documented contract (CLAUDE.md: "same-origin API base … never
hard-code an absolute API host") and what prod does, so the e2e now exercises the
shape we actually ship instead of one we never deploy.

Also fixes my own bug from the previous commit: the new Security-API assertions
pointed at BACKEND_URL (the monolith) and so failed in ~11ms with a 404. They now
use SECURITY_URL, defaulting to the app origin — the same path the browser takes,
which additionally proves the nginx routing.

Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com>
Claude-Session-Id: cf830721-b1ef-4fe0-a024-035ad280dcf7
@github-actions

Copy link
Copy Markdown
Contributor

Automated code review (gate-code-review)

Credit balance is too low

Report-only — this check never blocks merge.

Next gap, exposed once the Security API itself started passing: the boundary
model keeps the browser on the app origin — the security service returns
SAME-ORIGIN redirects (/source/oauth/login/google/, /application/o/authorize/),
never an absolute IdP host. Prod routes those via the fuzefront-authentik-idp
Ingress; the e2e nginx routed NONE of them, so every such redirect 404'd and no
browser sign-in could finish. The UI specs showed it as

    waiting for navigation to "http://authentik-server:9000/**"

which by design never happens any more.

Mirrors the prod Ingress path list (/application /if /source /flows /ws /-
/outpost.goauthentik.io /api/v3 /static/dist /static/authentik) → authentik-server:9000.

Two placement details that matter:
- The block sits ABOVE the static-asset regexes. nginx evaluates regex locations
  in order, so Authentik's /static/dist/*.js would otherwise be swallowed by
  `~* \.(js|css|...)$` and try_files'd into a 404.
- /api/v3 must outrank the /api/ catch-all or flow-executor calls hit FuzeFront's
  backend and fail with PROVIDER_UNAVAILABLE.

Host is passed through unchanged and the proto pinned: Authentik ignores
X-Forwarded-Prefix and derives callback_url from Host + X-Forwarded-Proto.

Upgrade/Connection are deliberately NOT forwarded — prod's nginx.conf confines
WebSocket upgrades to /socket.io/ to prevent H2C smuggling, and Authentik's /ws
is admin live-updates that no e2e flow needs. Matching that posture rather than
widening it.

Verified: nginx -t (upstreams host-mapped) → syntax is ok, test is successful.

Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com>
Claude-Session-Id: cf830721-b1ef-4fe0-a024-035ad280dcf7
@github-actions

Copy link
Copy Markdown
Contributor

Automated code review (gate-code-review)

Credit balance is too low

Report-only — this check never blocks merge.

Comment thread deploy/e2e/nginx.e2e.conf
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
proxy_pass http://security:3002;
proxy_http_version 1.1;
proxy_set_header Host $host;
Comment thread deploy/e2e/nginx.e2e.conf
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
proxy_pass http://backend:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
@izzywdev izzywdev removed the hold label Jul 16, 2026
…nin-coverage

# Conflicts:
#	deploy/e2e/nginx.e2e.conf
#	frontend/tests/oidc-plumbing.e2e.spec.ts
@github-actions

Copy link
Copy Markdown
Contributor

Automated code review (gate-code-review)

Credit balance is too low

Report-only — this check never blocks merge.

@izzywdev
izzywdev merged commit 376a462 into master Jul 16, 2026
47 of 49 checks passed
@izzywdev
izzywdev deleted the claude/e2e-real-signin-coverage branch July 16, 2026 15:37
izzywdev added a commit that referenced this pull request Jul 16, 2026
…rity flows (#276)

The MFA stages (TOTP/WebAuthn/SMS) were defined but never BOUND to any flow — the
stages-mfa.yaml comment claimed a user-settings binding that did not exist, so none
of them were reachable.

Binds TOTP/WebAuthn/SMS setup into default-user-settings-flow (device enrollment +
the Google link/unlink surface) and fuzefront-mfa-validate into
default-authentication-flow with not_configured_action=skip (enforce-if-enrolled,
optional). Idempotent, state: present.

Verified: helm template → EXIT 0, blueprint present in the authentik-blueprints
ConfigMap; YAML parses with Authentik's !Find tags.

Note: the CI-autofix branch proposed for this PR was rejected — it added a bcrypt
login shim to the monolith, bypassing Authentik and testing a fake Security API.
The real cause (the e2e stack never ran the security service) is fixed in #279/#280.

Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com>
Claude-Session-Id: cf830721-b1ef-4fe0-a024-035ad280dcf7
izzywdev added a commit that referenced this pull request Jul 16, 2026
Phone-based 2FA behind the provider-agnostic surface: enroll a phone factor, verify
enrollment, challenge at login, remove factor. OTP generation/expiry is delegated to
Twilio Verify rather than rolled locally, fronted by services/sms-service so the
security-service never talks to the vendor directly. Provider-neutral in the API:
no vendor name appears in any response.

Config via env only (TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN /
TWILIO_VERIFY_SERVICE_SID / SMS_SERVICE_URL).

NOTE: the CI-autofix branch proposed here was rejected — it added a bcrypt auth shim
to the monolith, which would have greened the sign-in check against a fake Security
API that does not exist in prod. The real cause (the e2e stack never ran the security
service) is fixed in #279/#280.

Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com>
Claude-Session-Id: cf830721-b1ef-4fe0-a024-035ad280dcf7
@izzywdev
izzywdev restored the claude/e2e-real-signin-coverage branch July 27, 2026 11:43
@izzywdev
izzywdev deleted the claude/e2e-real-signin-coverage branch July 27, 2026 18:27
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.

2 participants