multica/apps/web/app/(auth)/login/page.test.tsx
LinYushen 5c9c2f69fd
feat(auth): email verification login and personal access tokens
* feat(auth): add email verification login flow with 401 auto-redirect

Replace the old OAuth-based login with email verification codes:
- Backend: send-code / verify-code endpoints, verification_codes table (migration 009), rate limiting, Resend email service
- Frontend: two-step login UI (email → 6-digit OTP), auth store with sendCode/verifyCode
- SDK: ApiClient gains onUnauthorized callback; 401 responses auto-clear token and redirect to /login
- Fix login button staying disabled due to global isLoading state

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(auth): add brute-force protection, redirect loop guard, and expired code cleanup

- VerifyCode: increment attempts on wrong code, reject after 5 failed tries (migration 010)
- onUnauthorized: skip redirect if already on /login to prevent infinite loops
- SendCode: best-effort cleanup of expired verification codes older than 1 hour

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(auth): add master verification code for non-production environments

Allow code "888888" to bypass email verification in non-production
environments to simplify development and testing workflows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(auth): add personal access tokens for CLI and API authentication

Add full-stack PAT support: users create tokens in Settings, CLI authenticates
via `multica auth login`. Server stores SHA-256 hashes only. Auth middleware
extended to accept both JWTs and PATs (distinguished by `mul_` prefix).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 14:32:30 +08:00

125 lines
3.6 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
// Mock next/navigation
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
usePathname: () => "/login",
useSearchParams: () => new URLSearchParams(),
}));
// Mock auth store
const mockSendCode = vi.fn();
const mockVerifyCode = vi.fn();
vi.mock("@/features/auth", () => ({
useAuthStore: (selector: (s: any) => any) =>
selector({
sendCode: mockSendCode,
verifyCode: mockVerifyCode,
isLoading: false,
}),
}));
// Mock workspace store
const mockHydrateWorkspace = vi.fn();
vi.mock("@/features/workspace", () => ({
useWorkspaceStore: (selector: (s: any) => any) =>
selector({
hydrateWorkspace: mockHydrateWorkspace,
}),
}));
// Mock api
vi.mock("@/shared/api", () => ({
api: {
listWorkspaces: vi.fn().mockResolvedValue([]),
},
}));
import LoginPage from "./page";
describe("LoginPage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders email form with heading and button", () => {
render(<LoginPage />);
expect(screen.getByText("Multica")).toBeInTheDocument();
expect(screen.getByText("AI-native task management")).toBeInTheDocument();
expect(screen.getByLabelText("Email")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /continue/i })
).toBeInTheDocument();
});
it("does not call sendCode when email is empty", async () => {
const user = userEvent.setup();
render(<LoginPage />);
await user.click(screen.getByRole("button", { name: /continue/i }));
expect(mockSendCode).not.toHaveBeenCalled();
});
it("calls sendCode on submit and shows code step", async () => {
mockSendCode.mockResolvedValueOnce(undefined);
const user = userEvent.setup();
render(<LoginPage />);
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
await user.click(screen.getByRole("button", { name: /continue/i }));
await waitFor(() => {
expect(mockSendCode).toHaveBeenCalledWith("test@multica.ai");
});
await waitFor(() => {
expect(screen.getByText("Check your email")).toBeInTheDocument();
});
});
it("shows 'Sending code...' while submitting", async () => {
mockSendCode.mockReturnValueOnce(new Promise(() => {}));
const user = userEvent.setup();
render(<LoginPage />);
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
await user.click(screen.getByRole("button", { name: /continue/i }));
await waitFor(() => {
expect(screen.getByText("Sending code...")).toBeInTheDocument();
});
});
it("shows error when sendCode fails", async () => {
mockSendCode.mockRejectedValueOnce(new Error("Network error"));
const user = userEvent.setup();
render(<LoginPage />);
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
await user.click(screen.getByRole("button", { name: /continue/i }));
await waitFor(() => {
expect(screen.getByText("Network error")).toBeInTheDocument();
});
});
it("shows back button on code step", async () => {
mockSendCode.mockResolvedValueOnce(undefined);
const user = userEvent.setup();
render(<LoginPage />);
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
await user.click(screen.getByRole("button", { name: /continue/i }));
await waitFor(() => {
expect(screen.getByText("Check your email")).toBeInTheDocument();
});
expect(
screen.getByRole("button", { name: /back/i })
).toBeInTheDocument();
});
});