diff --git a/.github/workflows/deployed-health.yml b/.github/workflows/deployed-health.yml index 94edc55..9e8da27 100644 --- a/.github/workflows/deployed-health.yml +++ b/.github/workflows/deployed-health.yml @@ -164,6 +164,7 @@ jobs: set -euo pipefail PRODUCTION_BASE_URL="${DEPLOYMENT_STATUS_BASE_URL:-${PRODUCTION_BASE_URL_OVERRIDE:-${PRODUCTION_BASE_URL_VAR:-}}}" + PRODUCTION_CANONICAL_BASE_URL="${PRODUCTION_BASE_URL_OVERRIDE:-${PRODUCTION_BASE_URL_VAR:-}}" if [ -z "$PRODUCTION_BASE_URL" ]; then echo "enabled=false" >> "$GITHUB_OUTPUT" @@ -176,6 +177,7 @@ jobs: echo "enabled=true" >> "$GITHUB_OUTPUT" echo "base_url=$PRODUCTION_BASE_URL" >> "$GITHUB_OUTPUT" + echo "canonical_base_url=$PRODUCTION_CANONICAL_BASE_URL" >> "$GITHUB_OUTPUT" - name: Setup pnpm if: steps.gate.outputs.enabled == 'true' @@ -206,9 +208,42 @@ jobs: PLAYWRIGHT_SKIP_WEBSERVERS: "true" run: pnpm test:e2e:hosted:smoke + - name: Check production authenticated smoke configuration + if: steps.gate.outputs.enabled == 'true' + id: auth_gate + env: + PRODUCTION_AUTH_BASE_URL: ${{ steps.gate.outputs.canonical_base_url }} + PRODUCTION_AUTH_STORAGE_STATE_B64: ${{ secrets.VRDEX_PRODUCTION_AUTH_SMOKE_STORAGE_STATE_B64 }} + run: | + set -euo pipefail + + if [ -z "$PRODUCTION_AUTH_BASE_URL" ] || [ -z "$PRODUCTION_AUTH_STORAGE_STATE_B64" ]; then + echo "enabled=false" >> "$GITHUB_OUTPUT" + { + echo "## Production authenticated account smoke" + echo "Skipped because VRDEX_PRODUCTION_SMOKE_BASE_URL or VRDEX_PRODUCTION_AUTH_SMOKE_STORAGE_STATE_B64 is not configured." + } >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + echo "enabled=true" >> "$GITHUB_OUTPUT" + + - name: Run production authenticated account smoke + if: steps.auth_gate.outputs.enabled == 'true' + id: auth_smoke + env: + PLAYWRIGHT_BASE_URL: ${{ steps.gate.outputs.canonical_base_url }} + PLAYWRIGHT_RECORD_VIDEO: "true" + PLAYWRIGHT_SKIP_WEBSERVERS: "true" + VRDEX_PRODUCTION_AUTH_SMOKE_PROVIDER: ${{ vars.VRDEX_PRODUCTION_AUTH_SMOKE_PROVIDER }} + VRDEX_PRODUCTION_AUTH_SMOKE_STORAGE_STATE_B64: ${{ secrets.VRDEX_PRODUCTION_AUTH_SMOKE_STORAGE_STATE_B64 }} + run: pnpm test:e2e:hosted:auth-smoke + - name: Write production smoke summary if: always() && steps.gate.outputs.enabled == 'true' env: + AUTH_SMOKE_OUTCOME: ${{ steps.auth_smoke.outcome }} + PRODUCTION_AUTH_BASE_URL: ${{ steps.gate.outputs.canonical_base_url }} SMOKE_OUTCOME: ${{ steps.smoke.outcome }} PRODUCTION_BASE_URL: ${{ steps.gate.outputs.base_url }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} @@ -221,9 +256,14 @@ jobs: Target: ${PRODUCTION_BASE_URL} + Authenticated account smoke: ${AUTH_SMOKE_OUTCOME:-skipped} + + Authenticated account target: ${PRODUCTION_AUTH_BASE_URL:-not configured} + Captured flow: - hosted public route smoke checks - hosted deployment and server-status pages + - optional authenticated account readback with a pre-authenticated production test-account storage state - no mutation-backed E2E helpers Run: ${RUN_URL} diff --git a/.gitignore b/.gitignore index bce539f..5ec064c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ AGENTS.local.md .opencode/state/ +.playwright-mcp/ node_modules/ .husky/_/ .convex-home/ diff --git a/apps/web/e2e/production-auth.smoke.spec.ts b/apps/web/e2e/production-auth.smoke.spec.ts new file mode 100644 index 0000000..b2af97f --- /dev/null +++ b/apps/web/e2e/production-auth.smoke.spec.ts @@ -0,0 +1,56 @@ +import { expect, test, type BrowserContextOptions } from "@playwright/test"; + +type BrowserStorageState = Exclude; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isStorageState(value: unknown): value is BrowserStorageState { + return isRecord(value) && Array.isArray(value.cookies) && Array.isArray(value.origins); +} + +function readStorageState() { + const encoded = process.env.VRDEX_PRODUCTION_AUTH_SMOKE_STORAGE_STATE_B64?.trim(); + + if (!encoded) { + return undefined; + } + + const decoded = Buffer.from(encoded, "base64").toString("utf8"); + const parsed: unknown = JSON.parse(decoded); + + if (!isStorageState(parsed)) { + throw new Error("VRDEX_PRODUCTION_AUTH_SMOKE_STORAGE_STATE_B64 must decode to a Playwright storageState JSON object."); + } + + return parsed; +} + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +const storageState = readStorageState(); +const expectedProvider = process.env.VRDEX_PRODUCTION_AUTH_SMOKE_PROVIDER?.trim().toLowerCase(); + +test.describe("production authenticated account smoke @production-auth", () => { + test.skip(!process.env.PLAYWRIGHT_BASE_URL, "Production auth smoke is hosted-only."); + test.skip(!storageState, "Configure VRDEX_PRODUCTION_AUTH_SMOKE_STORAGE_STATE_B64 to enable production auth smoke."); + test.use(storageState ? { storageState } : {}); + + test("OAuth-backed account session renders account readiness", async ({ page }) => { + await page.goto("/account"); + await expect(page.getByRole("heading", { name: "Not signed in" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Sign out" })).toBeVisible(); + await expect(page.getByText("Linked providers", { exact: true })).toBeVisible(); + await expect(page.getByText("No providers linked yet", { exact: true })).toHaveCount(0); + + if (expectedProvider) { + await expect(page.getByText(new RegExp(`^${escapeRegExp(expectedProvider)}$`, "i"))).toBeVisible(); + return; + } + + await expect(page.getByText(/^(discord|google)$/i).first()).toBeVisible(); + }); +}); diff --git a/apps/web/package.json b/apps/web/package.json index 3fcbe13..dacac94 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -16,7 +16,8 @@ "lint": "eslint", "test:e2e": "playwright test --grep-invert \"@visual|@snapshot|@storybook\"", "test:e2e:hosted": "playwright test --grep @flow --project=desktop-chromium", - "test:e2e:hosted:smoke": "playwright test --grep-invert \"@visual|@flow|@snapshot|@storybook\"", + "test:e2e:hosted:auth-smoke": "playwright test --grep @production-auth --project=desktop-chromium", + "test:e2e:hosted:smoke": "playwright test --grep-invert \"@visual|@flow|@snapshot|@storybook|@production-auth\"", "test:e2e:snapshots": "playwright test --grep @snapshot --grep-invert @storybook", "test:e2e:snapshots:update": "playwright test --grep @snapshot --grep-invert @storybook --update-snapshots", "test:e2e:visual": "playwright test --grep @visual", diff --git a/docs/deployment/convex-environments.md b/docs/deployment/convex-environments.md index 3e94bd4..0900e5f 100644 --- a/docs/deployment/convex-environments.md +++ b/docs/deployment/convex-environments.md @@ -114,7 +114,7 @@ Staging HTTP Actions domain bootstrap, started 2026-06-15: 5. Add staging OAuth redirect URIs for both providers: - `https://db.staging.vrdex.net/api/auth/callback/discord` - `https://db.staging.vrdex.net/api/auth/callback/google` -6. Keep the legacy `https://scrupulous-corgi-247.convex.site/api/auth/callback/...` redirects until the custom callback host is verified in end-to-end sign-in. +6. Keep the legacy `https://scrupulous-corgi-247.convex.site/api/auth/callback/...` redirects until the custom callback host is verified in end-to-end sign-in and has stayed stable through scheduled health checks. 7. Override the staging deployment's `CONVEX_SITE_URL` to `https://db.staging.vrdex.net` in the Convex custom domain settings. 8. Rerun staging auth smoke checks from `https://staging.vrdex.net/sign-in`. @@ -131,12 +131,23 @@ Production HTTP Actions domain bootstrap, completed 2026-06-16: 5. Add production OAuth redirect URIs for each configured provider: - `https://db.vrdex.net/api/auth/callback/google` - `https://db.vrdex.net/api/auth/callback/discord` -6. Keep the legacy `https://superb-pig-954.convex.site/api/auth/callback/...` redirects until the custom callback host is verified in end-to-end sign-in. +6. Keep the legacy `https://superb-pig-954.convex.site/api/auth/callback/...` redirects until the custom callback host is verified in end-to-end sign-in and has stayed stable through scheduled health checks. 7. Select `https://db.vrdex.net` as the production deployment's canonical `CONVEX_SITE_URL`. 8. Rerun production auth smoke checks from `https://vrdex.net/sign-in`. Current production status: `db.vrdex.net` is configured and verified for the production deployment, Google and Discord allow the new callback URLs, production `CONVEX_SITE_URL` is selected as `https://db.vrdex.net`, and Google and Discord sign-in from `https://vrdex.net/sign-in` return to an authenticated `/account` session. +## Legacy OAuth Callback Retention + +Current recommendation: keep the legacy `.convex.site` OAuth callback URLs configured as provider fallbacks for now. + +- staging fallback callbacks: `https://scrupulous-corgi-247.convex.site/api/auth/callback/...` +- production fallback callbacks: `https://superb-pig-954.convex.site/api/auth/callback/...` + +Do not remove those provider callback URLs in the same change that enables or documents a custom Convex Auth callback host. Revisit removal only after the custom hosts have stayed stable through scheduled deployed health checks and at least one manual OAuth callback recheck per provider after any Auth, Convex custom-domain, or provider-app change. + +Earliest removal-review target: 2026-07-06, assuming production and staging auth health remains green. + ## Notes Resolved 2026-06-15: the duplicate Convex project `vrdex-85631` was deleted after confirming the active project is `vrdex`, the duplicate deployments had no env variables or custom domains, and their `profiles` tables were empty. diff --git a/docs/deployment/vercel-preview.md b/docs/deployment/vercel-preview.md index 7bbefa5..fbd2595 100644 --- a/docs/deployment/vercel-preview.md +++ b/docs/deployment/vercel-preview.md @@ -150,6 +150,20 @@ Current production auth status: - Discord sign-in from `https://vrdex.net/sign-in` returns to an authenticated `https://vrdex.net/account` session. - Convex production includes `JWT_PRIVATE_KEY` and matching `JWKS`, required for Convex Auth to mint web session cookies after OAuth callbacks. +### Production authenticated account smoke + +The `Deployed Health Checks` workflow always runs a production read-only route smoke when `VRDEX_PRODUCTION_SMOKE_BASE_URL` or a deployment status URL is available. It can also run a gated authenticated account smoke when a safe production test-account path exists. + +Repository settings for the authenticated lane: + +- variable `VRDEX_PRODUCTION_SMOKE_BASE_URL=https://vrdex.net`: required so auth cookies target the stable public domain instead of a generated Vercel deployment URL +- optional variable `VRDEX_PRODUCTION_AUTH_SMOKE_PROVIDER`: expected linked provider, usually `discord` or `google`; if unset, the smoke accepts either provider +- secret `VRDEX_PRODUCTION_AUTH_SMOKE_STORAGE_STATE_B64`: base64-encoded Playwright `storageState` JSON from a dedicated production test account that has completed OAuth sign-in + +This smoke does not enable production E2E helpers, does not mutate data, and does not store OAuth provider passwords in CI. It checks that the pre-authenticated production account can load `/account`, sees a sign-out control, and has at least one linked OAuth provider. Refresh the storage-state secret by manually signing in as the dedicated test account and exporting Playwright storage state before the session expires or after OAuth/provider/callback changes. + +This lane validates the signed-in production account path and linked-provider rendering. It is not a full automated provider-login robot; provider credential entry and fresh OAuth consent should remain manual or use a provider-approved non-interactive test-account mechanism. + ## Validation The Vercel build runs `pnpm build:vercel`, which executes `apps/web/scripts/check-vercel-env.mjs` before `next build`. diff --git a/package.json b/package.json index d9a3451..8514878 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "lint:web": "pnpm --filter web lint", "test:e2e": "pnpm --filter web test:e2e", "test:e2e:hosted": "pnpm --filter web test:e2e:hosted", + "test:e2e:hosted:auth-smoke": "pnpm --filter web test:e2e:hosted:auth-smoke", "test:e2e:hosted:smoke": "pnpm --filter web test:e2e:hosted:smoke", "test:e2e:snapshots": "pnpm --filter web test:e2e:snapshots", "test:e2e:snapshots:update": "pnpm --filter web test:e2e:snapshots:update",