1
17import { test, expect, DEMO_EMAIL, DEMO_PASSWORD } from "../fixtures";
18import { register, login, me } from "../utils/api";
19
20const uniqueEmail = () =>
21 `e2e-auth-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@factory.local`;
22
23test.describe("Authentication — Phase 1", () => {
24 test("register → verify email → login lands in personal workspace", async ({
25 browser,
26 }) => {
27
28
29
30
31 const email = uniqueEmail();
32 const password = "correct-horse-battery-9";
33 const name = "Auth E2E User";
34
35
36 const plain = await browser.newContext();
37 const plainApi = plain.request;
38 await register(plainApi, email, password, name);
39 await login(plainApi, email, password);
40
41
42
43
44
45
46
47 const context = await browser.newContext();
48 await context.addCookies(
49 (await plain.cookies()).map((c) => ({
50 name: c.name,
51 value: c.value,
52 domain: c.domain,
53 path: c.path,
54 expires: c.expires,
55 httpOnly: c.httpOnly,
56 secure: c.secure,
57 sameSite: c.sameSite,
58 })),
59 );
60 const page = await context.newPage();
61 await page.goto("/");
62
63
64 await expect(page.getByTestId("sidebar")).toBeVisible({ timeout: 20_000 });
65 await context.close();
66 await plain.close();
67 });
68
69 test("login → redirect to personal workspace", async ({ browser }) => {
70
71 const context = await browser.newContext();
72 const page = await context.newPage();
73 await page.goto("/login");
74
75 await page.getByTestId("auth-email").fill(DEMO_EMAIL);
76 await page.getByTestId("auth-password").fill(DEMO_PASSWORD);
77 await page.getByTestId("auth-submit").click();
78
79
80 await page.waitForURL((url) => !url.pathname.startsWith("/login"), {
81 timeout: 20_000,
82 });
83 await expect(page.getByTestId("sidebar")).toBeVisible();
84 await context.close();
85 });
86
87 test("logout → redirect to login page", async ({ authPage }) => {
88 await authPage.goto("/");
89 await expect(authPage.getByTestId("sidebar")).toBeVisible();
90
91 await authPage.getByTestId("user-menu").click();
92 await authPage.getByTestId("user-menu-logout").click();
93
94 await authPage.waitForURL((url) => url.pathname.startsWith("/login"), {
95 timeout: 15_000,
96 });
97
98 await expect(authPage.getByTestId("sidebar")).toHaveCount(0);
99 });
100
101 test("wrong password shows error and does not log in", async ({ browser }) => {
102 const context = await browser.newContext();
103 const page = await context.newPage();
104 await page.goto("/login");
105
106 await page.getByTestId("auth-email").fill(DEMO_EMAIL);
107 await page.getByTestId("auth-password").fill("definitely-wrong-password-123");
108 await page.getByTestId("auth-submit").click();
109
110
111 const error = page.getByTestId("auth-error");
112 await expect(error).toBeVisible({ timeout: 10_000 });
113 await expect(error).toContainText(/invalid|incorrect|wrong/i);
114 await expect(page).toHaveURL(/\/login/);
115 await expect(page.getByTestId("sidebar")).toHaveCount(0);
116 await context.close();
117 });
118
119 test("registering an existing email is rejected", async ({ api }) => {
120
121 const res = await api.post("/api/v1/auth/register", {
122 data: { email: DEMO_EMAIL, password: "another-password-123", name: "Dupe" },
123 });
124 expect([400, 409]).toContain(res.status());
125 });
126
127 test("verify email (skip: no mail server in dev)", async () => {
128
129
130
131 test.skip(true, "lands with a mail sink in dev (Mailpit in docker-compose) — Phase 1");
132
133
134 });
135
136 test("password reset flow (skip: token not exposed to the client in dev)", async () => {
137
138
139
140 test.skip(true, "covered by tests/integration (auth.integration.test.ts) — Phase 1");
141
142
143
144 });
145
146 test("rate limit kicks in after 10+ failed logins", async ({ browser }) => {
147
148
149
150 const context = await browser.newContext();
151 const api = context.request;
152 const email = uniqueEmail();
153
154
155
156 const regApi = await browser.newContext();
157 await register(regApi.request, email, "pw-12345678", "Rate Limit User");
158 await regApi.close();
159
160 let got429 = false;
161 let lastStatus = 0;
162 for (let i = 0; i < 12; i++) {
163 const res = await api.post("/api/v1/auth/login", {
164 data: { email, password: "wrong-password-" + i },
165 });
166 lastStatus = res.status();
167 if (res.status() === 429) {
168 got429 = true;
169 break;
170 }
171 }
172 expect(
173 got429,
174 `expected a 429 within 12 failed logins (last status: ${lastStatus}) — ` +
175 "check RATE_LIMIT_* env / auth rate limiter config",
176 ).toBe(true);
177
178
179
180 const resAfter = await api.post("/api/v1/auth/login", {
181 data: { email, password: "pw-12345678" },
182 });
183 expect(resAfter.status()).toBe(429);
184 await context.close();
185 });
186
187 test("refresh token rotation: reuse of a rotated token is rejected", async ({
188 api,
189 }) => {
190
191
192 const context = api;
193 const email = uniqueEmail();
194
195 const reg = await register(context, email, "rotate-pw-12345678", "Rotation User");
196 void reg;
197 const l1 = await login(context, email, "rotate-pw-12345678");
198 const first = l1.refreshToken;
199 expect(first, "login response must include a refresh token").toBeTruthy();
200
201
202
203 const r1 = await context.post("/api/v1/auth/refresh", {
204 data: { refreshToken: first },
205 });
206 expect(r1.status()).toBe(200);
207 const r1body = await r1.json();
208 const second: string | undefined =
209 r1body?.refreshToken ?? r1body?.refresh_token;
210 expect(second).toBeTruthy();
211 expect(second).not.toBe(first);
212
213
214 const reuse = await context.post("/api/v1/auth/refresh", {
215 data: { refreshToken: first },
216 });
217 expect([400, 401, 403]).toContain(reuse.status());
218 });
219
220 test("me endpoint reflects the authenticated user", async ({ api, user }) => {
221 const who = await me(api);
222 expect(who.email).toBe(user.email);
223 });
224});
225