diff --git a/TODO.md b/TODO.md index 3d4b49e..947bc48 100644 --- a/TODO.md +++ b/TODO.md @@ -189,7 +189,7 @@ This file is the single source of truth for roadmap and delivery progress. - [x] [P1] Unit tests for content schemas and service logic - [ ] [P1] Component tests for admin forms (pages/media/navigation) - [ ] [P1] Integration tests for owner invariant and hidden support-user protection -- [ ] [P1] Integration tests for registration allow/deny behavior +- [x] [P1] Integration tests for registration allow/deny behavior - [ ] [P1] Integration tests for translated content CRUD and locale-specific validation - [~] [P1] E2E happy paths: create page, publish, see on public app - [~] [P1] E2E happy paths: media upload + artwork refinement display @@ -281,6 +281,7 @@ This file is the single source of truth for roadmap and delivery progress. - [2026-02-12] Public news routes now exist at `/news` and `/news/:slug` (detail restricted to published posts). - [2026-02-12] Added `e2e/happy-paths.pw.ts` covering admin login, page publish/public rendering, announcement rendering, media upload, and commission status transition. - [2026-02-12] Expanded unit coverage for content/domain schemas and post service behavior (`packages/content/src/domain-schemas.test.ts`, `packages/db/src/posts.test.ts`). +- [2026-02-12] Added auth flow integration tests for `/login`, `/register`, `/welcome` to validate registration allow/deny and owner bootstrap redirects. ## How We Use This File diff --git a/apps/admin/src/app/login/page.test.tsx b/apps/admin/src/app/login/page.test.tsx new file mode 100644 index 0000000..3f6b859 --- /dev/null +++ b/apps/admin/src/app/login/page.test.tsx @@ -0,0 +1,67 @@ +import type { ReactElement } from "react" +import { beforeEach, describe, expect, it, vi } from "vitest" + +const { redirectMock, resolveRoleFromServerContextMock, hasOwnerUserMock } = vi.hoisted(() => ({ + redirectMock: vi.fn((path: string) => { + throw new Error(`REDIRECT:${path}`) + }), + resolveRoleFromServerContextMock: vi.fn(), + hasOwnerUserMock: vi.fn(), +})) + +vi.mock("next/navigation", () => ({ + redirect: redirectMock, +})) + +vi.mock("@/lib/access-server", () => ({ + resolveRoleFromServerContext: resolveRoleFromServerContextMock, +})) + +vi.mock("@/lib/auth/server", () => ({ + hasOwnerUser: hasOwnerUserMock, +})) + +vi.mock("./login-form", () => ({ + LoginForm: ({ mode }: { mode: string }) => ({ type: "login-form", props: { mode } }), +})) + +import LoginPage from "./page" + +function expectRedirect(call: () => Promise, path: string) { + return expect(call()).rejects.toThrow(`REDIRECT:${path}`) +} + +describe("login page", () => { + beforeEach(() => { + redirectMock.mockClear() + resolveRoleFromServerContextMock.mockReset() + hasOwnerUserMock.mockReset() + }) + + it("redirects authenticated users to dashboard", async () => { + resolveRoleFromServerContextMock.mockResolvedValue("manager") + + await expectRedirect(() => LoginPage({ searchParams: Promise.resolve({}) }), "/") + }) + + it("redirects to welcome if owner is missing", async () => { + resolveRoleFromServerContextMock.mockResolvedValue(null) + hasOwnerUserMock.mockResolvedValue(false) + + await expectRedirect( + () => LoginPage({ searchParams: Promise.resolve({ next: "/settings" }) }), + "/welcome?next=%2Fsettings", + ) + }) + + it("renders sign-in mode once owner exists", async () => { + resolveRoleFromServerContextMock.mockResolvedValue(null) + hasOwnerUserMock.mockResolvedValue(true) + + const page = (await LoginPage({ searchParams: Promise.resolve({}) })) as ReactElement<{ + mode: string + }> + + expect(page.props.mode).toBe("signin") + }) +}) diff --git a/apps/admin/src/app/register/page.test.tsx b/apps/admin/src/app/register/page.test.tsx new file mode 100644 index 0000000..b5cf6c0 --- /dev/null +++ b/apps/admin/src/app/register/page.test.tsx @@ -0,0 +1,91 @@ +import type { ReactElement } from "react" +import { beforeEach, describe, expect, it, vi } from "vitest" + +const { + redirectMock, + resolveRoleFromServerContextMock, + hasOwnerUserMock, + isSelfRegistrationEnabledMock, +} = vi.hoisted(() => ({ + redirectMock: vi.fn((path: string) => { + throw new Error(`REDIRECT:${path}`) + }), + resolveRoleFromServerContextMock: vi.fn(), + hasOwnerUserMock: vi.fn(), + isSelfRegistrationEnabledMock: vi.fn(), +})) + +vi.mock("next/navigation", () => ({ + redirect: redirectMock, +})) + +vi.mock("@/lib/access-server", () => ({ + resolveRoleFromServerContext: resolveRoleFromServerContextMock, +})) + +vi.mock("@/lib/auth/server", () => ({ + hasOwnerUser: hasOwnerUserMock, + isSelfRegistrationEnabled: isSelfRegistrationEnabledMock, +})) + +vi.mock("@/app/login/login-form", () => ({ + LoginForm: ({ mode }: { mode: string }) => ({ type: "login-form", props: { mode } }), +})) + +import RegisterPage from "./page" + +function expectRedirect(call: () => Promise, path: string) { + return expect(call()).rejects.toThrow(`REDIRECT:${path}`) +} + +describe("register page", () => { + beforeEach(() => { + redirectMock.mockClear() + resolveRoleFromServerContextMock.mockReset() + hasOwnerUserMock.mockReset() + isSelfRegistrationEnabledMock.mockReset() + }) + + it("redirects authenticated users to dashboard", async () => { + resolveRoleFromServerContextMock.mockResolvedValue("admin") + + await expectRedirect( + () => RegisterPage({ searchParams: Promise.resolve({ next: "/pages" }) }), + "/", + ) + }) + + it("redirects to welcome when no owner exists", async () => { + resolveRoleFromServerContextMock.mockResolvedValue(null) + hasOwnerUserMock.mockResolvedValue(false) + + await expectRedirect( + () => RegisterPage({ searchParams: Promise.resolve({ next: "/pages" }) }), + "/welcome?next=%2Fpages", + ) + }) + + it("shows disabled mode when self registration is off", async () => { + resolveRoleFromServerContextMock.mockResolvedValue(null) + hasOwnerUserMock.mockResolvedValue(true) + isSelfRegistrationEnabledMock.mockResolvedValue(false) + + const page = (await RegisterPage({ searchParams: Promise.resolve({}) })) as ReactElement<{ + mode: string + }> + + expect(page.props.mode).toBe("signup-disabled") + }) + + it("shows sign-up mode when self registration is enabled", async () => { + resolveRoleFromServerContextMock.mockResolvedValue(null) + hasOwnerUserMock.mockResolvedValue(true) + isSelfRegistrationEnabledMock.mockResolvedValue(true) + + const page = (await RegisterPage({ searchParams: Promise.resolve({}) })) as ReactElement<{ + mode: string + }> + + expect(page.props.mode).toBe("signup-user") + }) +}) diff --git a/apps/admin/src/app/welcome/page.test.tsx b/apps/admin/src/app/welcome/page.test.tsx new file mode 100644 index 0000000..b92b64a --- /dev/null +++ b/apps/admin/src/app/welcome/page.test.tsx @@ -0,0 +1,70 @@ +import type { ReactElement } from "react" +import { beforeEach, describe, expect, it, vi } from "vitest" + +const { redirectMock, resolveRoleFromServerContextMock, hasOwnerUserMock } = vi.hoisted(() => ({ + redirectMock: vi.fn((path: string) => { + throw new Error(`REDIRECT:${path}`) + }), + resolveRoleFromServerContextMock: vi.fn(), + hasOwnerUserMock: vi.fn(), +})) + +vi.mock("next/navigation", () => ({ + redirect: redirectMock, +})) + +vi.mock("@/lib/access-server", () => ({ + resolveRoleFromServerContext: resolveRoleFromServerContextMock, +})) + +vi.mock("@/lib/auth/server", () => ({ + hasOwnerUser: hasOwnerUserMock, +})) + +vi.mock("@/app/login/login-form", () => ({ + LoginForm: ({ mode }: { mode: string }) => ({ type: "login-form", props: { mode } }), +})) + +import WelcomePage from "./page" + +function expectRedirect(call: () => Promise, path: string) { + return expect(call()).rejects.toThrow(`REDIRECT:${path}`) +} + +describe("welcome page", () => { + beforeEach(() => { + redirectMock.mockClear() + resolveRoleFromServerContextMock.mockReset() + hasOwnerUserMock.mockReset() + }) + + it("redirects authenticated users to dashboard", async () => { + resolveRoleFromServerContextMock.mockResolvedValue("admin") + + await expectRedirect( + () => WelcomePage({ searchParams: Promise.resolve({ next: "/media" }) }), + "/", + ) + }) + + it("redirects to login after owner exists", async () => { + resolveRoleFromServerContextMock.mockResolvedValue(null) + hasOwnerUserMock.mockResolvedValue(true) + + await expectRedirect( + () => WelcomePage({ searchParams: Promise.resolve({ next: "/media" }) }), + "/login?next=%2Fmedia", + ) + }) + + it("renders owner sign-up mode when owner is missing", async () => { + resolveRoleFromServerContextMock.mockResolvedValue(null) + hasOwnerUserMock.mockResolvedValue(false) + + const page = (await WelcomePage({ searchParams: Promise.resolve({}) })) as ReactElement<{ + mode: string + }> + + expect(page.props.mode).toBe("signup-owner") + }) +})