From 5b158bb260e5cc5e64cc1610c6a27122d92b9064 Mon Sep 17 00:00:00 2001 From: Krish Prasad Date: Sat, 4 Apr 2026 15:20:56 -0400 Subject: [PATCH] implement test utils --- app/members/page.tsx | 24 ++----- tests/e2e/home.spec.ts | 11 +-- tests/e2e/login.spec.ts | 9 ++- tests/e2e/members.spec.ts | 13 ++-- tests/e2e/profile.spec.ts | 12 ++-- tests/e2e/testUtils.ts | 142 +++++++++++++++++++++++++++++++++++++- 6 files changed, 179 insertions(+), 32 deletions(-) diff --git a/app/members/page.tsx b/app/members/page.tsx index 70be709f..ab17142a 100644 --- a/app/members/page.tsx +++ b/app/members/page.tsx @@ -1,6 +1,10 @@ import { redirect } from "next/navigation"; import { headers } from "next/headers"; import { auth } from "@/lib/auth"; +import { + getActiveOrganizationIdFromHeaders, + redirectIfNotAdmin, +} from "@/lib/authUtils"; import { MembersService } from "@/lib/services/MemberService"; import { OrganizationConfigService } from "@/lib/services/OrganizationConfigService"; import { OrganizationConfigKey } from "@/lib/schema"; @@ -13,28 +17,14 @@ interface MembersPageProps { } export default async function MembersPage({ searchParams }: MembersPageProps) { - const session = await auth.api.getSession({ - headers: await headers(), - }); + const session = await redirectIfNotAdmin(); + const headerList = await headers(); + const organizationId = await getActiveOrganizationIdFromHeaders(headerList); - if (!session?.user) { - redirect("/login"); - } - - const organizationId = session.session.activeOrganizationId; if (!organizationId) { redirect("/"); } - const membership = await MembersService.findByUserAndOrganization( - session.user.id, - organizationId, - ); - - if (!MembersService.isAdminOrOwner(membership?.role)) { - redirect("/"); - } - const config = await OrganizationConfigService.getConfig(organizationId, [ OrganizationConfigKey.MembersPageEnabled, ]); diff --git a/tests/e2e/home.spec.ts b/tests/e2e/home.spec.ts index 6e468361..4459f4f0 100644 --- a/tests/e2e/home.spec.ts +++ b/tests/e2e/home.spec.ts @@ -2,20 +2,21 @@ import { test, expect } from "@playwright/test"; import { createTestAdminAndSignIn, createTestUserWithPendingJoinRequestAndSignIn, + expectPageRedirectsUnlessApprovedMember, } from "./testUtils"; test.describe("Home route", () => { - test("redirects guests from / to /login", async ({ page }) => { - await page.goto("/"); - await expect(page).toHaveURL(/\/login/); + test("redirects guests and pending join requests away from home", async ({ + page, + }) => { + await expectPageRedirectsUnlessApprovedMember(page, "/"); }); - test("redirects users with a pending join request to /joinrequeststatus", async ({ + test("pending join request users see status copy on joinrequeststatus", async ({ page, }) => { await createTestUserWithPendingJoinRequestAndSignIn(page); await page.goto("/"); - await expect(page).toHaveURL(/\/joinrequeststatus/); await expect(page.getByTestId("join-request-status-heading")).toBeVisible(); }); diff --git a/tests/e2e/login.spec.ts b/tests/e2e/login.spec.ts index 535a3220..6e36d2a2 100644 --- a/tests/e2e/login.spec.ts +++ b/tests/e2e/login.spec.ts @@ -1,5 +1,8 @@ import { test, expect } from "@playwright/test"; -import { createTestUserAndSignIn } from "./testUtils"; +import { + createTestUserAndSignIn, + expectPageDoesNotRedirect, +} from "./testUtils"; import { buildTestUser, signUpAndGetSession } from "../unit/testUtils"; test.describe("Login Page", () => { @@ -23,6 +26,10 @@ test.describe("Login Page", () => { await expect(page).toHaveURL(/\//); }); + test("public static assets are not redirected by auth", async ({ page }) => { + await expectPageDoesNotRedirect(page, "/logo.svg"); + }); + test("login page background uses a linear gradient", async ({ page }) => { await page.goto("/login"); diff --git a/tests/e2e/members.spec.ts b/tests/e2e/members.spec.ts index 1832ee25..443eabff 100644 --- a/tests/e2e/members.spec.ts +++ b/tests/e2e/members.spec.ts @@ -1,5 +1,9 @@ import { test, expect } from "@playwright/test"; -import { createTestAdminAndSignIn, createTestUserAndSignIn } from "./testUtils"; +import { + createTestAdminAndSignIn, + createTestUserAndSignIn, + expectPageRedirectsUnlessAdmin, +} from "./testUtils"; import { addMember, buildTestUser, @@ -9,9 +13,10 @@ import { OrganizationConfigService } from "@/lib/services/OrganizationConfigServ import { OrganizationConfigKey } from "@/lib/schema"; test.describe("Members Page", () => { - test("redirects to login when not authenticated", async ({ page }) => { - await page.goto("/members"); - await expect(page).toHaveURL(/\/login/); + test("admin-only access: guests, pending join, and non-admins are redirected", async ({ + page, + }) => { + await expectPageRedirectsUnlessAdmin(page, "/members"); }); test("redirects when authenticated but not an org member or admin", async ({ diff --git a/tests/e2e/profile.spec.ts b/tests/e2e/profile.spec.ts index 8b37948f..bc075e89 100644 --- a/tests/e2e/profile.spec.ts +++ b/tests/e2e/profile.spec.ts @@ -1,10 +1,14 @@ import { test, expect } from "@playwright/test"; -import { createTestUserAndSignIn } from "./testUtils"; +import { + createTestUserAndSignIn, + expectPageRedirectsGuestButAllowsPendingJoin, +} from "./testUtils"; test.describe("Profile Page", () => { - test("redirects to login when not authenticated", async ({ page }) => { - await page.goto("/profile"); - await expect(page).toHaveURL(/\/login/); + test("requires sign-in but allows users with a pending join request", async ({ + page, + }) => { + await expectPageRedirectsGuestButAllowsPendingJoin(page, "/profile"); }); test("shows user name and email when signed in", async ({ page }) => { diff --git a/tests/e2e/testUtils.ts b/tests/e2e/testUtils.ts index aa8599a4..f36c121d 100644 --- a/tests/e2e/testUtils.ts +++ b/tests/e2e/testUtils.ts @@ -1,4 +1,4 @@ -import type { Page } from "@playwright/test"; +import { expect, type Page } from "@playwright/test"; import { eq } from "drizzle-orm"; import db from "@/lib/db"; import { organizations } from "@/lib/schema"; @@ -171,3 +171,143 @@ export async function createTestUserWithPendingJoinRequestAndSignIn( return { user: credentials }; } + +function normalizedPathname(url: string): string { + const pathname = new URL(url).pathname; + return pathname.length > 1 && pathname.endsWith("/") + ? pathname.slice(0, -1) + : pathname; +} + +function pathnamesEqual( + actualUrl: string | URL, + expectedPath: string, +): boolean { + const href = typeof actualUrl === "string" ? actualUrl : actualUrl.href; + const expected = expectedPath.startsWith("/") + ? expectedPath + : `/${expectedPath}`; + return normalizedPathname(href) === normalizedPathname(expected); +} + +/** + * Creates a test user, adds them as a non-admin member of the default host org + * (`servicestart`), sets the active organization on the session, and signs them in. + */ +export async function createTestMemberAndSignIn( + page: Page, + options: SignInOptions = {}, +) { + const org = await ensureServicestartOrganization(); + const user = buildTestUser(); + const { session, headers } = await signUpAndGetSession(user); + await addMember(session.userId, org.id, "member"); + await setActiveOrganization(session.id, org.id); + + const cookieHeader = headers["Cookie"]; + if (!cookieHeader) { + throw new Error("No auth cookie returned when signing up test user."); + } + + const { name, value } = getCookieParts(cookieHeader); + const baseUrl = + options.baseUrl || process.env.BASE_URL || "http://localhost:3000"; + const { hostname } = new URL(baseUrl); + + await page.context().addCookies([ + { + name, + value, + domain: hostname, + path: "/", + httpOnly: true, + sameSite: "Lax", + }, + ]); + + return { user, org }; +} + +/** + * For routes that require an approved organization member: guests are sent to + * `/login`, and users with a pending join request for the host org are sent to + * `/joinrequeststatus`. + */ +export async function expectPageRedirectsUnlessApprovedMember( + page: Page, + path: string, +) { + await page.context().clearCookies(); + await page.goto(path); + await expect(page).toHaveURL(/\/login/); + + await createTestUserWithPendingJoinRequestAndSignIn(page); + await page.goto(path); + await expect(page).toHaveURL(/\/joinrequeststatus/); +} + +/** + * For routes that require sign-in but still allow users with a pending join + * request: guests are sent to `/login`; pending users remain on `path`. + */ +export async function expectPageRedirectsGuestButAllowsPendingJoin( + page: Page, + path: string, +) { + await page.context().clearCookies(); + await page.goto(path); + await expect(page).toHaveURL(/\/login/); + + await createTestUserWithPendingJoinRequestAndSignIn(page); + await page.goto(path); + await expect(page).toHaveURL((url) => pathnamesEqual(url, path)); +} + +/** + * For admin-only routes (same membership rules as `redirectIfNotAdmin` in authUtils): + * guests → `/login`, pending join → `/joinrequeststatus`, approved non-admins → `/`. + */ +export async function expectPageRedirectsUnlessAdmin(page: Page, path: string) { + await page.context().clearCookies(); + await page.goto(path); + await expect(page).toHaveURL(/\/login/); + + await createTestUserWithPendingJoinRequestAndSignIn(page); + await page.goto(path); + await expect(page).toHaveURL(/\/joinrequeststatus/); + + await page.context().clearCookies(); + await createTestMemberAndSignIn(page); + await page.goto(path); + await expect(page).toHaveURL((url) => pathnamesEqual(url, "/")); + + await page.context().clearCookies(); + await createTestAdminAndSignIn(page); + await page.goto(path); + await expect(page).toHaveURL((url) => pathnamesEqual(url, path)); +} + +/** + * Asserts that guests, pending-join users, members, and admins all remain on + * `path` after navigation (no auth redirect). Use for routes that do not gate + * on session (for example static files under `/public`). + */ +export async function expectPageDoesNotRedirect(page: Page, path: string) { + await page.context().clearCookies(); + await page.goto(path); + await expect(page).toHaveURL((url) => pathnamesEqual(url, path)); + + await createTestUserWithPendingJoinRequestAndSignIn(page); + await page.goto(path); + await expect(page).toHaveURL((url) => pathnamesEqual(url, path)); + + await page.context().clearCookies(); + await createTestMemberAndSignIn(page); + await page.goto(path); + await expect(page).toHaveURL((url) => pathnamesEqual(url, path)); + + await page.context().clearCookies(); + await createTestAdminAndSignIn(page); + await page.goto(path); + await expect(page).toHaveURL((url) => pathnamesEqual(url, path)); +}