diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 988f9847..8e0c002c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,18 +99,48 @@ jobs: cache: npm cache-dependency-path: frontend/package-lock.json - - name: Cypress run - uses: cypress-io/github-action@v6 - with: - working-directory: frontend - install-command: npm ci - build: npm run build - # Bind to loopback to avoid CI network flakiness. - start: npm start -- -H 127.0.0.1 -p 3000 - wait-on: http://127.0.0.1:3000 - command: npm run e2e - browser: chrome + - name: Install frontend dependencies + run: make frontend-sync + + - name: Start frontend (dev server) env: + NEXT_PUBLIC_API_URL: "http://localhost:3000" NEXT_TELEMETRY_DISABLED: "1" - # Force Clerk disabled in E2E to keep tests secretless/deterministic. - NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: "" + CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ vars.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} + CLERK_JWKS_URL: ${{ vars.CLERK_JWKS_URL }} + run: | + cd frontend + npm run dev -- --hostname 0.0.0.0 --port 3000 & + for i in {1..60}; do + if curl -sf http://localhost:3000/ > /dev/null; then exit 0; fi + sleep 2 + done + echo "Frontend did not start" + exit 1 + + - name: Run Cypress E2E + env: + NEXT_PUBLIC_API_URL: "http://localhost:3000" + NEXT_TELEMETRY_DISABLED: "1" + # Clerk testing tokens (official @clerk/testing Cypress integration) + CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} + CLERK_PUBLISHABLE_KEY: ${{ vars.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} + # Also set for the app itself. + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ vars.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} + CLERK_JWKS_URL: ${{ vars.CLERK_JWKS_URL }} + # Test user identifier (used by cy.clerkSignIn) + CYPRESS_CLERK_TEST_EMAIL: "jane+clerk_test@example.com" + run: | + cd frontend + npm run e2e -- --browser chrome + + - name: Upload Cypress artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: cypress-artifacts + if-no-files-found: ignore + path: | + frontend/cypress/screenshots/** + frontend/cypress/videos/** diff --git a/docs/e2e-auth.md b/docs/e2e-auth.md new file mode 100644 index 00000000..13ae5b98 --- /dev/null +++ b/docs/e2e-auth.md @@ -0,0 +1,10 @@ +# E2E auth (Cypress) + +Hard requirement: **no auth bypass** for Cypress E2E. + +- Cypress tests must use real Clerk sign-in. +- CI should inject Clerk keys into the Cypress job environment. + +Test account (non-secret): +- email: `jane+clerk_test@example.com` +- OTP: `424242` diff --git a/frontend/cypress.config.ts b/frontend/cypress.config.ts index 218a5061..0f461372 100644 --- a/frontend/cypress.config.ts +++ b/frontend/cypress.config.ts @@ -1,12 +1,20 @@ import { defineConfig } from "cypress"; +import { clerkSetup } from "@clerk/testing/cypress"; export default defineConfig({ + env: { + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, + // Optional overrides. + CLERK_ORIGIN: process.env.CYPRESS_CLERK_ORIGIN, + CLERK_TEST_EMAIL: process.env.CYPRESS_CLERK_TEST_EMAIL, + CLERK_TEST_OTP: process.env.CYPRESS_CLERK_TEST_OTP, + }, e2e: { - // Use loopback to avoid network/proxy flakiness in CI. - baseUrl: "http://127.0.0.1:3000", - video: false, - screenshotOnRunFailure: true, - specPattern: "cypress/e2e/**/*.cy.{ts,tsx,js,jsx}", + baseUrl: "http://localhost:3000", + specPattern: "cypress/e2e/**/*.cy.{js,jsx,ts,tsx}", supportFile: "cypress/support/e2e.ts", + setupNodeEvents(on, config) { + return clerkSetup({ config }); + }, }, }); diff --git a/frontend/cypress/e2e/activity_feed.cy.ts b/frontend/cypress/e2e/activity_feed.cy.ts index 8192249e..a80af55e 100644 --- a/frontend/cypress/e2e/activity_feed.cy.ts +++ b/frontend/cypress/e2e/activity_feed.cy.ts @@ -2,9 +2,9 @@ describe("/activity feed", () => { const apiBase = "**/api/v1"; + const email = Cypress.env("CLERK_TEST_EMAIL") || "jane+clerk_test@example.com"; function stubStreamEmpty() { - // Return a minimal SSE response that ends immediately. cy.intercept( "GET", `${apiBase}/activity/task-comments/stream*`, @@ -18,12 +18,16 @@ describe("/activity feed", () => { ).as("activityStream"); } - function isSignedOutView(): Cypress.Chainable { - return cy - .get("body") - .then(($body) => $body.text().toLowerCase().includes("sign in to view the feed")); + function assertSignedInAndLanded() { + cy.contains(/live feed/i, { timeout: 30_000 }).should("be.visible"); } + it("auth negative: signed-out user cannot access /activity", () => { + // Story: signed-out user tries to visit /activity and is redirected to sign-in. + cy.visit("/activity"); + cy.location("pathname", { timeout: 20_000 }).should("match", /\/sign-in/); + }); + it("happy path: renders task comment cards", () => { cy.intercept("GET", `${apiBase}/activity/task-comments*`, { statusCode: 200, @@ -40,43 +44,23 @@ describe("/activity feed", () => { task_title: "CI hardening", created_at: "2026-02-07T00:00:00Z", }, - { - id: "c2", - message: "Second comment", - agent_name: "Riya", - agent_role: "QA", - board_id: "b1", - board_name: "Testing", - task_id: "t2", - task_title: "Coverage policy", - created_at: "2026-02-07T00:01:00Z", - }, ], }, }).as("activityList"); stubStreamEmpty(); - cy.visit("/activity", { - onBeforeLoad(win: Window) { - win.localStorage.clear(); - }, - }); + // Story: user signs in, then visits /activity and sees the live feed. + cy.visit("/sign-in"); + cy.clerkLoaded(); + cy.clerkSignIn({ strategy: "email_code", identifier: email }); - isSignedOutView().then((signedOut) => { - if (signedOut) { - // In secretless CI (no Clerk), the SignedOut UI is expected and no API calls should happen. - cy.contains(/sign in to view the feed/i).should("be.visible"); - return; - } + cy.visit("/activity"); + assertSignedInAndLanded(); - cy.wait("@activityList"); - - cy.contains(/live feed/i).should("be.visible"); - cy.contains("CI hardening").should("be.visible"); - cy.contains("Coverage policy").should("be.visible"); - cy.contains("Hello world").should("be.visible"); - }); + cy.wait("@activityList"); + cy.contains("CI hardening").should("be.visible"); + cy.contains("Hello world").should("be.visible"); }); it("empty state: shows waiting message when no items", () => { @@ -87,17 +71,16 @@ describe("/activity feed", () => { stubStreamEmpty(); + // Story: user signs in, then visits /activity and sees an empty-state message. + cy.visit("/sign-in"); + cy.clerkLoaded(); + cy.clerkSignIn({ strategy: "email_code", identifier: email }); + cy.visit("/activity"); + assertSignedInAndLanded(); - isSignedOutView().then((signedOut) => { - if (signedOut) { - cy.contains(/sign in to view the feed/i).should("be.visible"); - return; - } - - cy.wait("@activityList"); - cy.contains(/waiting for new comments/i).should("be.visible"); - }); + cy.wait("@activityList"); + cy.contains(/waiting for new comments/i).should("be.visible"); }); it("error state: shows failure UI when API errors", () => { @@ -108,18 +91,15 @@ describe("/activity feed", () => { stubStreamEmpty(); + // Story: user signs in, then visits /activity; API fails and user sees an error. + cy.visit("/sign-in"); + cy.clerkLoaded(); + cy.clerkSignIn({ strategy: "email_code", identifier: email }); + cy.visit("/activity"); + assertSignedInAndLanded(); - isSignedOutView().then((signedOut) => { - if (signedOut) { - cy.contains(/sign in to view the feed/i).should("be.visible"); - return; - } - - cy.wait("@activityList"); - - // UI uses query.error.message or fallback. - cy.contains(/unable to load feed|boom/i).should("be.visible"); - }); + cy.wait("@activityList"); + cy.contains(/unable to load feed|boom/i).should("be.visible"); }); }); diff --git a/frontend/cypress/e2e/activity_smoke.cy.ts b/frontend/cypress/e2e/activity_smoke.cy.ts index 447c040b..ae60a51a 100644 --- a/frontend/cypress/e2e/activity_smoke.cy.ts +++ b/frontend/cypress/e2e/activity_smoke.cy.ts @@ -1,7 +1,6 @@ describe("/activity page", () => { - it("loads without crashing", () => { + it("signed-out user is redirected to sign-in", () => { cy.visit("/activity"); - // In secretless/unsigned state, UI should render the signed-out prompt. - cy.contains(/sign in to view the feed/i).should("be.visible"); + cy.location("pathname", { timeout: 20_000 }).should("match", /\/sign-in/); }); }); diff --git a/frontend/cypress/e2e/clerk_login.cy.ts b/frontend/cypress/e2e/clerk_login.cy.ts index 369213a5..8b091d0d 100644 --- a/frontend/cypress/e2e/clerk_login.cy.ts +++ b/frontend/cypress/e2e/clerk_login.cy.ts @@ -1,19 +1,15 @@ -describe("Clerk login (OTP)", () => { - it("can sign in via Clerk modal", () => { - // Skip unless explicitly configured. - const clerkOrigin = Cypress.env("CLERK_ORIGIN"); - const email = Cypress.env("CLERK_TEST_EMAIL"); - const otp = Cypress.env("CLERK_TEST_OTP"); +describe("Clerk login", () => { + it("user can sign in via Clerk testing commands", () => { + const email = Cypress.env("CLERK_TEST_EMAIL") || "jane+clerk_test@example.com"; - if (!clerkOrigin || !email || !otp) { - cy.log("Skipping: missing CYPRESS_CLERK_ORIGIN / CYPRESS_CLERK_TEST_EMAIL / CYPRESS_CLERK_TEST_OTP"); - return; - } + // Prereq per Clerk docs: visit a non-protected page that loads Clerk. + cy.visit("/sign-in"); + cy.clerkLoaded(); + cy.clerkSignIn({ strategy: "email_code", identifier: email }); + + // After login, user should be able to access protected route. cy.visit("/activity"); - cy.loginWithClerkOtp(); - - // After login, the SignedIn UI should render. - cy.contains(/live feed/i, { timeout: 20_000 }).should("be.visible"); + cy.contains(/live feed/i, { timeout: 30_000 }).should("be.visible"); }); }); diff --git a/frontend/cypress/support/commands.ts b/frontend/cypress/support/commands.ts index 43e0a070..28b51cf8 100644 --- a/frontend/cypress/support/commands.ts +++ b/frontend/cypress/support/commands.ts @@ -6,15 +6,29 @@ type ClerkOtpLoginOptions = { otp: string; }; -function requireEnv(name: string): string { +function getEnv(name: string, fallback?: string): string { const value = Cypress.env(name) as string | undefined; - if (!value) { - throw new Error( - `Missing Cypress env var ${name}. ` + - `Set it via CYPRESS_${name}=... in CI/local before running Clerk login tests.`, - ); - } - return value; + if (value) return value; + if (fallback !== undefined) return fallback; + throw new Error( + `Missing Cypress env var ${name}. ` + + `Set it via CYPRESS_${name}=... in CI/local before running Clerk login tests.`, + ); +} + +function clerkOriginFromPublishableKey(): string { + const key = getEnv("NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY"); + + // pk_test_ OR pk_live_<...> + const m = /^pk_(?:test|live)_(.+)$/.exec(key); + if (!m) throw new Error(`Unexpected Clerk publishable key format: ${key}`); + + const decoded = atob(m[1]); // e.g. beloved-ghost-73.clerk.accounts.dev$ + const domain = decoded.replace(/\$$/, ""); + + // Some flows redirect to *.accounts.dev (no clerk. subdomain) + const normalized = domain.replace(".clerk.accounts.dev", ".accounts.dev"); + return `https://${normalized}`; } function normalizeOrigin(value: string): string { @@ -22,62 +36,158 @@ function normalizeOrigin(value: string): string { const url = new URL(value); return url.origin; } catch { - // allow providing just an origin-like string return value.replace(/\/$/, ""); } } Cypress.Commands.add("loginWithClerkOtp", () => { - const clerkOrigin = normalizeOrigin(requireEnv("CLERK_ORIGIN")); - const email = requireEnv("CLERK_TEST_EMAIL"); - const otp = requireEnv("CLERK_TEST_OTP"); + const clerkOrigin = normalizeOrigin( + getEnv("CLERK_ORIGIN", clerkOriginFromPublishableKey()), + ); + const email = getEnv("CLERK_TEST_EMAIL", "jane+clerk_test@example.com"); + const otp = getEnv("CLERK_TEST_OTP", "424242"); const opts: ClerkOtpLoginOptions = { clerkOrigin, email, otp }; - // Trigger the modal from the app first. - cy.get('[data-testid="activity-signin"]').click({ force: true }); + // Navigate to a dedicated sign-in route that renders Clerk SignIn top-level. + // Cypress cannot reliably drive Clerk modal/iframe flows. + cy.visit("/sign-in"); - // The Clerk UI is typically hosted on a different origin (clerk.accounts.dev / clerk.com). - // Use cy.origin to drive the UI in Chrome. - cy.origin( - opts.clerkOrigin, - { args: { email: opts.email, otp: opts.otp } }, - ({ email, otp }) => { - // Email / identifier input - cy.get('input[type="email"], input[name="identifier"], input[autocomplete="email"]', { - timeout: 20_000, - }) - .first() - .clear() - .type(email, { delay: 10 }); + const emailSelector = + 'input[type="email"], input[name="identifier"], input[autocomplete="email"]'; + const otpSelector = + 'input[autocomplete="one-time-code"], input[name*="code"], input[name^="code"], input[name^="code."], input[inputmode="numeric"]'; + const continueSelector = 'button[type="submit"], button'; + const methodSelector = /email|code|otp|send code|verification|verify|use email/i; - // Submit / continue - cy.get('button[type="submit"], button') - .contains(/continue|sign in|send|next/i) - .click({ force: true }); + const fillEmailStep = (email: string) => { + cy.get(emailSelector, { timeout: 20_000 }) + .first() + .clear() + .type(email, { delay: 10 }); - // OTP input - Clerk commonly uses autocomplete=one-time-code - cy.get('input[autocomplete="one-time-code"], input[name*="code"], input[inputmode="numeric"]', { - timeout: 20_000, - }) - .first() - .clear() - .type(otp, { delay: 10 }); + cy.contains(continueSelector, /continue|sign in|send|next/i, { timeout: 20_000 }) + .should("be.visible") + .click({ force: true }); + }; - // Final submit (some flows auto-submit) - cy.get("body").then(($body) => { - const hasSubmit = $body - .find('button[type="submit"], button') - .toArray() - .some((el) => /verify|continue|sign in|confirm/i.test(el.textContent || "")); - if (hasSubmit) { - cy.get('button[type="submit"], button') - .contains(/verify|continue|sign in|confirm/i) - .click({ force: true }); - } - }); - }, - ); + const maybeSelectEmailCodeMethod = () => { + cy.get("body").then(($body) => { + const hasOtp = $body.find(otpSelector).length > 0; + if (hasOtp) return; + + const candidates = $body + .find("button,a") + .toArray() + .filter((el) => methodSelector.test((el.textContent || "").trim())); + + if (candidates.length > 0) { + cy.wrap(candidates[0]).click({ force: true }); + } + }); + }; + + const waitForOtpOrMethod = () => { + cy.get("body", { timeout: 60_000 }).should(($body) => { + const hasOtp = $body.find(otpSelector).length > 0; + const hasMethod = $body + .find("button,a") + .toArray() + .some((el) => methodSelector.test((el.textContent || "").trim())); + expect( + hasOtp || hasMethod, + "waiting for OTP input or verification method UI", + ).to.equal(true); + }); + }; + + const fillOtpAndSubmit = (otp: string) => { + waitForOtpOrMethod(); + maybeSelectEmailCodeMethod(); + + cy.get(otpSelector, { timeout: 60_000 }).first().clear().type(otp, { delay: 10 }); + + cy.get("body").then(($body) => { + const hasSubmit = $body + .find(continueSelector) + .toArray() + .some((el) => /verify|continue|sign in|confirm/i.test(el.textContent || "")); + if (hasSubmit) { + cy.contains(continueSelector, /verify|continue|sign in|confirm/i, { timeout: 20_000 }) + .should("be.visible") + .click({ force: true }); + } + }); + }; + + // Clerk SignIn can start on our app origin and then redirect to Clerk-hosted UI. + // Do email step first, then decide where the OTP step lives based on the *current* origin. + fillEmailStep(opts.email); + + cy.location("origin", { timeout: 60_000 }).then((origin) => { + const current = normalizeOrigin(origin); + if (current === opts.clerkOrigin) { + cy.origin( + opts.clerkOrigin, + { args: { otp: opts.otp } }, + ({ otp }) => { + const otpSelector = + 'input[autocomplete="one-time-code"], input[name*="code"], input[name^="code"], input[name^="code."], input[inputmode="numeric"]'; + const continueSelector = 'button[type="submit"], button'; + const methodSelector = /email|code|otp|send code|verification|verify|use email/i; + + const maybeSelectEmailCodeMethod = () => { + cy.get("body").then(($body) => { + const hasOtp = $body.find(otpSelector).length > 0; + if (hasOtp) return; + + const candidates = $body + .find("button,a") + .toArray() + .filter((el) => methodSelector.test((el.textContent || "").trim())); + + if (candidates.length > 0) { + cy.wrap(candidates[0]).click({ force: true }); + } + }); + }; + + const waitForOtpOrMethod = () => { + cy.get("body", { timeout: 60_000 }).should(($body) => { + const hasOtp = $body.find(otpSelector).length > 0; + const hasMethod = $body + .find("button,a") + .toArray() + .some((el) => methodSelector.test((el.textContent || "").trim())); + expect( + hasOtp || hasMethod, + "waiting for OTP input or verification method UI", + ).to.equal(true); + }); + }; + + waitForOtpOrMethod(); + maybeSelectEmailCodeMethod(); + + cy.get(otpSelector, { timeout: 60_000 }).first().clear().type(otp, { delay: 10 }); + + cy.get("body").then(($body) => { + const hasSubmit = $body + .find(continueSelector) + .toArray() + .some((el) => /verify|continue|sign in|confirm/i.test(el.textContent || "")); + if (hasSubmit) { + cy.contains(continueSelector, /verify|continue|sign in|confirm/i, { timeout: 20_000 }) + .should("be.visible") + .click({ force: true }); + } + }); + }, + ); + } else { + fillOtpAndSubmit(opts.otp); + } + }); }); declare global { @@ -85,12 +195,13 @@ declare global { namespace Cypress { interface Chainable { /** - * Logs in via the real Clerk modal using deterministic OTP credentials. + * Logs in via the real Clerk SignIn page using deterministic OTP credentials. * - * Requires env vars: - * - CYPRESS_CLERK_ORIGIN (e.g. https://.clerk.accounts.dev) - * - CYPRESS_CLERK_TEST_EMAIL - * - CYPRESS_CLERK_TEST_OTP + * Optional env vars (CYPRESS_*): + * - CLERK_ORIGIN (e.g. https://.accounts.dev) + * - NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY (used to derive origin when CLERK_ORIGIN not set) + * - CLERK_TEST_EMAIL (default: jane+clerk_test@example.com) + * - CLERK_TEST_OTP (default: 424242) */ loginWithClerkOtp(): Chainable; } diff --git a/frontend/cypress/support/e2e.ts b/frontend/cypress/support/e2e.ts index 8c97169f..369182a5 100644 --- a/frontend/cypress/support/e2e.ts +++ b/frontend/cypress/support/e2e.ts @@ -1,4 +1,10 @@ // Cypress support file. // Place global hooks/commands here. +/// + +import { addClerkCommands } from "@clerk/testing/cypress"; + +addClerkCommands({ Cypress, cy }); + import "./commands"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3894dfd4..00dec71e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -26,6 +26,7 @@ "remark-gfm": "^4.0.1" }, "devDependencies": { + "@clerk/testing": "^1.13.35", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.1.0", "@testing-library/user-event": "^14.5.2", @@ -364,9 +365,9 @@ "license": "MIT" }, "node_modules/@clerk/backend": { - "version": "2.29.7", - "resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-2.29.7.tgz", - "integrity": "sha512-OSfFQ85L0FV2wSzqlr0hRvluIu3Z5ClgLiBE6Qx7XjSGyJoqEvP5OP4fl5Nt5icgGvH0EwA1dljPGyQpaqbQEw==", + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-2.30.1.tgz", + "integrity": "sha512-GoxnJzVH0ycNPAGCDMfo3lPBFbo5nehpLSVFjgGEnzIRGGahBtAB8PQT7KM2zo58pD8apjb/+suhcB/WCiEasQ==", "license": "MIT", "dependencies": { "@clerk/shared": "^3.44.0", @@ -453,6 +454,34 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, + "node_modules/@clerk/testing": { + "version": "1.13.35", + "resolved": "https://registry.npmjs.org/@clerk/testing/-/testing-1.13.35.tgz", + "integrity": "sha512-y95kJZrMt0tvbNek1AWhWrNrgnOy+a53PSzHTHPF9d0kkOgzzu9l/Wq+Y0kBk6p64wtupYomeb7oVCQD7yCc0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@clerk/backend": "^2.30.1", + "@clerk/shared": "^3.44.0", + "@clerk/types": "^4.101.14", + "dotenv": "17.2.2" + }, + "engines": { + "node": ">=18.17.0" + }, + "peerDependencies": { + "@playwright/test": "^1", + "cypress": "^13 || ^14" + }, + "peerDependenciesMeta": { + "@playwright/test": { + "optional": true + }, + "cypress": { + "optional": true + } + } + }, "node_modules/@clerk/types": { "version": "4.101.14", "resolved": "https://registry.npmjs.org/@clerk/types/-/types-4.101.14.tgz", @@ -6634,6 +6663,19 @@ "license": "MIT", "peer": true }, + "node_modules/dotenv": { + "version": "17.2.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.2.tgz", + "integrity": "sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 207cc25b..4a3ddd36 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -33,6 +33,7 @@ "remark-gfm": "^4.0.1" }, "devDependencies": { + "@clerk/testing": "^1.13.35", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.1.0", "@testing-library/user-event": "^14.5.2", diff --git a/frontend/src/app/activity/page.tsx b/frontend/src/app/activity/page.tsx index 2b8ba0d1..a44a7ed1 100644 --- a/frontend/src/app/activity/page.tsx +++ b/frontend/src/app/activity/page.tsx @@ -298,7 +298,7 @@ export default function ActivityPage() {

Sign in to view the feed.

diff --git a/frontend/src/app/sign-in/[[...rest]]/page.tsx b/frontend/src/app/sign-in/[[...rest]]/page.tsx new file mode 100644 index 00000000..bf55b4bb --- /dev/null +++ b/frontend/src/app/sign-in/[[...rest]]/page.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { SignIn } from "@clerk/nextjs"; + +export default function SignInPage() { + // Dedicated sign-in route for Cypress E2E. + // Avoids modal/iframe auth flows and gives Cypress a stable top-level page. + return ( +
+ +
+ ); +} diff --git a/frontend/src/auth/clerk.tsx b/frontend/src/auth/clerk.tsx index e5e4483e..c134a699 100644 --- a/frontend/src/auth/clerk.tsx +++ b/frontend/src/auth/clerk.tsx @@ -1,10 +1,10 @@ "use client"; -import type { ReactNode } from "react"; - // NOTE: We intentionally keep this file very small and dependency-free. // It provides CI/secretless-build safe fallbacks for Clerk hooks/components. +import type { ReactNode, ComponentProps } from "react"; + import { ClerkProvider, SignedIn as ClerkSignedIn, @@ -15,33 +15,22 @@ import { useUser as clerkUseUser, } from "@clerk/nextjs"; -import type { ComponentProps } from "react"; - import { isLikelyValidClerkPublishableKey } from "@/auth/clerkKey"; -function isE2EAuthBypassEnabled(): boolean { - // Used only for Cypress E2E to keep tests secretless and deterministic. - // When enabled, we treat the user as signed in and skip Clerk entirely. - return process.env.NEXT_PUBLIC_E2E_AUTH_BYPASS === "1"; -} - export function isClerkEnabled(): boolean { // IMPORTANT: keep this in sync with AuthProvider; otherwise components like // may render without a and crash during prerender. - if (isE2EAuthBypassEnabled()) return false; return isLikelyValidClerkPublishableKey( process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, ); } export function SignedIn(props: { children: ReactNode }) { - if (isE2EAuthBypassEnabled()) return <>{props.children}; if (!isClerkEnabled()) return null; return {props.children}; } export function SignedOut(props: { children: ReactNode }) { - if (isE2EAuthBypassEnabled()) return null; if (!isClerkEnabled()) return <>{props.children}; return {props.children}; } @@ -67,15 +56,6 @@ export function useUser() { } export function useAuth() { - if (isE2EAuthBypassEnabled()) { - return { - isLoaded: true, - isSignedIn: true, - userId: "e2e-user", - sessionId: "e2e-session", - getToken: async () => "e2e-token", - } as const; - } if (!isClerkEnabled()) { return { isLoaded: true, diff --git a/frontend/src/proxy.ts b/frontend/src/proxy.ts index c147ef4a..6f1dfb2b 100644 --- a/frontend/src/proxy.ts +++ b/frontend/src/proxy.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { clerkMiddleware } from "@clerk/nextjs/server"; +import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; import { isLikelyValidClerkPublishableKey } from "@/auth/clerkKey"; @@ -8,7 +8,23 @@ const isClerkEnabled = () => process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, ); -export default isClerkEnabled() ? clerkMiddleware() : () => NextResponse.next(); +// Public routes must include Clerk sign-in paths to avoid redirect loops. +const isPublicRoute = createRouteMatcher(["/sign-in(.*)"]); + +export default isClerkEnabled() + ? clerkMiddleware(async (auth, req) => { + if (isPublicRoute(req)) return NextResponse.next(); + + // In middleware, `auth()` resolves to a session/auth context (Promise in current typings). + // Use redirectToSignIn() (instead of protect()) for unauthenticated requests. + const { userId, redirectToSignIn } = await auth(); + if (!userId) { + return redirectToSignIn({ returnBackUrl: req.url }); + } + + return NextResponse.next(); + }) + : () => NextResponse.next(); export const config = { matcher: [