@podosoft/podokit 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/README.md +33 -2
  2. package/dist/add.d.ts +2 -0
  3. package/dist/add.js +5 -1
  4. package/dist/assemble.d.ts +14 -0
  5. package/dist/assemble.js +60 -0
  6. package/dist/create.d.ts +2 -0
  7. package/dist/create.js +7 -0
  8. package/dist/eject.d.ts +11 -0
  9. package/dist/eject.js +27 -0
  10. package/dist/index.d.ts +2 -0
  11. package/dist/index.js +122 -1
  12. package/dist/inspect.d.ts +43 -0
  13. package/dist/inspect.js +82 -0
  14. package/dist/lockfile.d.ts +80 -0
  15. package/dist/lockfile.js +193 -0
  16. package/dist/templates/base/dot-env.example +11 -6
  17. package/dist/templates/fullstack-nest-svelte/README.md +2 -2
  18. package/dist/templates/fullstack-nest-svelte/apps/api/package.json +1 -0
  19. package/dist/templates/fullstack-nest-svelte/apps/api/src/app.module.ts +6 -3
  20. package/dist/templates/fullstack-nest-svelte/apps/api/src/common/app-exception.ts +4 -12
  21. package/dist/templates/fullstack-nest-svelte/apps/api/src/config/env.validation.ts +1 -1
  22. package/dist/templates/fullstack-nest-svelte/apps/api/src/main.ts +1 -1
  23. package/dist/templates/fullstack-nest-svelte/apps/web/package.json +2 -2
  24. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/select/select-content.svelte +1 -1
  25. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/select/select-item.svelte +1 -1
  26. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/switch/index.ts +7 -0
  27. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/switch/switch.svelte +29 -0
  28. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.ts +1 -1
  29. package/dist/templates/fullstack-nest-svelte/dot-env.example +10 -5
  30. package/dist/templates/fullstack-nest-svelte/infra/docker/docker-compose.yml +37 -0
  31. package/dist/templates/fullstack-nest-svelte/infra/docker/sms-sink.mjs +40 -0
  32. package/dist/templates/fullstack-nest-svelte/tests/README.md +1 -1
  33. package/dist/templates/fullstack-nest-svelte/tests/package.json +2 -1
  34. package/dist/templates/fullstack-nest-svelte/tests/playwright.config.ts +1 -1
  35. package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +3 -0
  36. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/app-sidebar.svelte +7 -1
  37. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +149 -0
  38. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-pagination.svelte +21 -22
  39. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-toolbar.svelte +93 -0
  40. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +224 -8
  41. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.ts +22 -0
  42. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/+layout.server.ts +19 -3
  43. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/accept-invitation/[id]/+page.svelte +36 -0
  44. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/+page.svelte +21 -1
  45. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/account/+page.svelte +360 -49
  46. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/audit/+page.server.ts +2 -4
  47. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/audit/+page.svelte +82 -37
  48. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/organizations/+page.server.ts +6 -0
  49. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/organizations/+page.svelte +264 -0
  50. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/sessions/+page.server.ts +3 -4
  51. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/sessions/+page.svelte +79 -45
  52. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.server.ts +26 -0
  53. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +322 -0
  54. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/users/+page.server.ts +3 -5
  55. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/users/+page.svelte +254 -108
  56. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/forgot-password/+page.svelte +8 -1
  57. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.server.ts +14 -0
  58. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +127 -5
  59. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/signup/+page.svelte +18 -2
  60. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/verify-email/+page.svelte +63 -0
  61. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/oauth2/consent/+page.svelte +54 -0
  62. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +51 -2
  63. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +80 -0
  64. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +264 -1
  65. package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +120 -0
  66. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +10 -10
  67. package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +2 -2
  68. package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +49 -0
  69. package/dist/templates/modules/admin-dashboard/files/tests/helpers/sms.ts +35 -0
  70. package/dist/templates/modules/admin-dashboard/files/tests/helpers/totp.ts +9 -0
  71. package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +1 -1
  72. package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +12 -1
  73. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +77 -6
  74. package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +1 -1
  75. package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +32 -0
  76. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +60 -0
  77. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +42 -0
  78. package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +1 -0
  79. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +57 -0
  80. package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +55 -0
  81. package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +36 -0
  82. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +9 -2
  83. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +34 -0
  84. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +32 -0
  85. package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +28 -12
  86. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +35 -3
  87. package/dist/templates/modules/admin-dashboard/module.manifest.json +13 -7
  88. package/dist/templates/modules/api-key-auth/module.manifest.json +3 -3
  89. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-enabled.ts +16 -0
  90. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +4 -0
  91. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.interceptor.ts +2 -0
  92. package/dist/templates/modules/audit-log/module.manifest.json +15 -9
  93. package/dist/templates/modules/auth/files/apps/api/src/account/account.controller.ts +78 -13
  94. package/dist/templates/modules/auth/files/apps/api/src/account/account.module.ts +2 -1
  95. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +73 -0
  96. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +197 -52
  97. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +12 -0
  98. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +56 -0
  99. package/dist/templates/modules/auth/files/apps/api/src/auth/org-permissions.ts +22 -0
  100. package/dist/templates/modules/auth/files/apps/api/src/auth/permissions.ts +23 -0
  101. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.controller.ts +33 -0
  102. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.entity.ts +26 -0
  103. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.module.ts +12 -0
  104. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +118 -0
  105. package/dist/templates/modules/auth/files/apps/api/src/mail/mailer.ts +65 -0
  106. package/dist/templates/modules/auth/files/apps/api/src/migrations/1720400000000-InitAppSettings.ts +38 -0
  107. package/dist/templates/modules/auth/files/apps/api/src/migrations/1720500000000-InitAuthConfig.ts +27 -0
  108. package/dist/templates/modules/auth/files/apps/api/src/settings/app-setting.entity.ts +16 -0
  109. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +25 -0
  110. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.module.ts +11 -0
  111. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.ts +57 -0
  112. package/dist/templates/modules/auth/files/apps/api/src/sms/sms.ts +23 -0
  113. package/dist/templates/modules/auth/module.manifest.json +54 -20
  114. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.ts +6 -3
  115. package/dist/templates/modules/bullmq/module.manifest.json +4 -4
  116. package/dist/templates/modules/file-upload/module.manifest.json +3 -3
  117. package/dist/templates/modules/job-progress/module.manifest.json +9 -9
  118. package/dist/templates/modules/logging/module.manifest.json +3 -3
  119. package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +5 -3
  120. package/dist/templates/modules/object-storage-s3/module.manifest.json +5 -5
  121. package/dist/templates/modules/rate-limit/module.manifest.json +2 -2
  122. package/dist/templates/modules/redis/module.manifest.json +4 -4
  123. package/dist/templates/modules/sse/module.manifest.json +4 -4
  124. package/dist/templates/todo/README.md +2 -2
  125. package/dist/templates/todo/apps/api/package.json +1 -0
  126. package/dist/templates/todo/apps/api/src/app.module.ts +6 -3
  127. package/dist/templates/todo/apps/api/src/common/app-exception.ts +4 -12
  128. package/dist/templates/todo/apps/api/src/config/env.validation.ts +1 -1
  129. package/dist/templates/todo/apps/api/src/main.ts +1 -1
  130. package/dist/templates/todo/apps/web/package.json +2 -2
  131. package/dist/templates/todo/apps/web/src/lib/components/ui/switch/index.ts +7 -0
  132. package/dist/templates/todo/apps/web/src/lib/components/ui/switch/switch.svelte +29 -0
  133. package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.ts +1 -1
  134. package/dist/templates/todo/dot-env.example +10 -5
  135. package/dist/templates/todo/infra/docker/docker-compose.yml +4 -0
  136. package/dist/templates/todo/tests/README.md +1 -1
  137. package/dist/templates/todo/tests/package.json +2 -1
  138. package/dist/templates/todo/tests/playwright.config.ts +1 -1
  139. package/dist/update.d.ts +49 -0
  140. package/dist/update.js +158 -0
  141. package/package.json +2 -2
@@ -1,7 +1,9 @@
1
1
  import { expect, test } from "@playwright/test";
2
2
  import { ADMIN, USER } from "../helpers/accounts";
3
+ import { clearSms, smsSinkReachable, waitForSmsOtp } from "../helpers/sms";
4
+ import { totpCode } from "../helpers/totp";
3
5
 
4
- const base = process.env.E2E_BASE_URL ?? "http://localhost:5173";
6
+ const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
5
7
  const origin = { origin: base };
6
8
 
7
9
  // Accounts are created by the `setup` project (this `api` project depends on it).
@@ -10,6 +12,267 @@ test("account/me is 401 when unauthenticated @smoke", async ({ request }) => {
10
12
  expect(res.status()).toBe(401);
11
13
  });
12
14
 
15
+ test("rejects a breached password on sign-up @smoke", async ({ playwright }) => {
16
+ const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
17
+ await ctx.post("/api/auth/sign-in/email", { data: { email: ADMIN.email, password: ADMIN.password } });
18
+ const caps = await (await ctx.get("/api/account/capabilities")).json();
19
+ test.skip(!caps?.passwordBreachCheck, "breach check not enabled");
20
+ // "password" is one of the most-breached passwords; Have I Been Pwned rejects it.
21
+ const res = await ctx.post("/api/auth/sign-up/email", {
22
+ data: { name: "Pwned", email: `pwned-${Date.now()}@example.com`, password: "password" },
23
+ });
24
+ expect(res.ok()).toBeFalsy();
25
+ await ctx.dispose();
26
+ });
27
+
28
+ test("phone number: verify with the code delivered to the SMS sink @smoke", async ({ playwright }) => {
29
+ const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
30
+ const caps = await (await ctx.get("/api/account/capabilities")).json();
31
+ test.skip(!caps?.phoneNumber, "phone number not enabled");
32
+ test.skip(!(await smsSinkReachable()), "SMS sink not reachable");
33
+ await clearSms();
34
+ const email = `phone-${Date.now()}@example.com`;
35
+ await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "Phone" } });
36
+ // Unique number per run so the "phone already exists" guard never trips.
37
+ const phone = `+1555${Date.now().toString().slice(-7)}`;
38
+
39
+ const sent = await ctx.post("/api/auth/phone-number/send-otp", { data: { phoneNumber: phone } });
40
+ expect(sent.ok()).toBeTruthy();
41
+ const code = await waitForSmsOtp(phone); // read the real code back from the sink
42
+
43
+ const verified = await ctx.post("/api/auth/phone-number/verify", {
44
+ data: { phoneNumber: phone, code, updatePhoneNumber: true },
45
+ });
46
+ expect(verified.ok()).toBeTruthy();
47
+ const session = await (await ctx.get("/api/auth/get-session")).json();
48
+ expect(session?.user?.phoneNumber).toBe(phone);
49
+ expect(session?.user?.phoneNumberVerified).toBe(true);
50
+ await ctx.dispose();
51
+ });
52
+
53
+ test("two-factor: enable with a real TOTP code and require it at sign-in @smoke", async ({ playwright }) => {
54
+ const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
55
+ const caps = await (await ctx.get("/api/account/capabilities")).json();
56
+ test.skip(!caps?.twoFactor, "two-factor not enabled");
57
+ const email = `tf-${Date.now()}@example.com`;
58
+ const pw = "Podokit3e-Str0ng!pw";
59
+ await ctx.post("/api/auth/sign-up/email", { data: { email, password: pw, name: "TF" } });
60
+
61
+ // Enable 2FA and confirm it with a code computed from the returned otpauth URI.
62
+ const enable = await (await ctx.post("/api/auth/two-factor/enable", { data: { password: pw } })).json();
63
+ expect(typeof enable.totpURI).toBe("string");
64
+ const verified = await ctx.post("/api/auth/two-factor/verify-totp", { data: { code: totpCode(enable.totpURI) } });
65
+ expect(verified.ok()).toBeTruthy();
66
+
67
+ // A fresh sign-in must now be challenged for the second factor (no full session).
68
+ const ctx2 = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
69
+ const signin = await (await ctx2.post("/api/auth/sign-in/email", { data: { email, password: pw } })).json();
70
+ expect(signin.twoFactorRedirect).toBe(true);
71
+ const session = await (await ctx2.get("/api/auth/get-session")).json();
72
+ expect(session?.user).toBeFalsy();
73
+ await ctx.dispose();
74
+ await ctx2.dispose();
75
+ });
76
+
77
+ test("multi-session holds several accounts and switches between them @smoke", async ({ playwright }) => {
78
+ const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
79
+ const caps = await (await ctx.get("/api/account/capabilities")).json();
80
+ test.skip(!caps?.multiSession, "multi-session not enabled");
81
+ const pw = "Podokit3e-Str0ng!pw";
82
+ const a = `ms-a-${Date.now()}@example.com`;
83
+ const b = `ms-b-${Date.now()}@example.com`;
84
+ await ctx.post("/api/auth/sign-up/email", { data: { email: a, password: pw, name: "A" } });
85
+ await ctx.post("/api/auth/sign-up/email", { data: { email: b, password: pw, name: "B" } });
86
+ type Device = { session: { token: string }; user: { email: string } };
87
+ const devices = (await (await ctx.get("/api/auth/multi-session/list-device-sessions")).json()) as Device[];
88
+ expect(devices.length).toBeGreaterThanOrEqual(2);
89
+ // most recent sign-up is active; switch back to the first account
90
+ const first = devices.find((d) => d.user?.email === a);
91
+ expect(first?.session?.token).toBeTruthy();
92
+ await ctx.post("/api/auth/multi-session/set-active", { data: { sessionToken: first!.session.token } });
93
+ const session = await (await ctx.get("/api/auth/get-session")).json();
94
+ expect(session?.user?.email).toBe(a);
95
+ await ctx.dispose();
96
+ });
97
+
98
+ test("a user can sign in with a username @smoke", async ({ playwright }) => {
99
+ const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
100
+ const caps = await (await ctx.get("/api/account/capabilities")).json();
101
+ test.skip(!caps?.username, "username not enabled");
102
+ const email = `uname-${Date.now()}@example.com`;
103
+ const uname = `user${Date.now()}`;
104
+ const pw = "Podokit3e-Str0ng!pw";
105
+ await ctx.post("/api/auth/sign-up/email", { data: { email, password: pw, name: "Uname" } });
106
+ expect((await ctx.post("/api/auth/update-user", { data: { username: uname } })).ok()).toBeTruthy();
107
+ await ctx.dispose();
108
+ const ctx2 = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
109
+ const signedIn = await ctx2.post("/api/auth/sign-in/username", { data: { username: uname, password: pw } });
110
+ expect(signedIn.ok()).toBeTruthy();
111
+ const session = await (await ctx2.get("/api/auth/get-session")).json();
112
+ expect(session?.user?.username).toBe(uname);
113
+ await ctx2.dispose();
114
+ });
115
+
116
+ test("bearer token authenticates API requests without a cookie @smoke", async ({ playwright }) => {
117
+ const email = `bearer-${Date.now()}@example.com`;
118
+ const pw = "Podokit3e-Str0ng!pw";
119
+ const signup = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
120
+ await signup.post("/api/auth/sign-up/email", { data: { email, password: pw, name: "Bearer" } });
121
+ const signIn = await signup.post("/api/auth/sign-in/email", { data: { email, password: pw } });
122
+ expect(signIn.ok()).toBeTruthy();
123
+ // The bearer plugin accepts the session token as `Authorization: Bearer`. Take it
124
+ // from the session cookie the sign-in set (equivalent to the set-auth-token header).
125
+ const state = await signup.storageState();
126
+ const token = state.cookies.find((c) => c.name.endsWith("session_token"))?.value;
127
+ expect(token, "sign-in set a session token").toBeTruthy();
128
+ await signup.dispose();
129
+ // Fresh context = no session cookie; authenticate purely via the bearer header.
130
+ const api = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
131
+ expect((await api.get("/api/account/me")).status()).toBe(401);
132
+ const authed = await api.get("/api/account/me", { headers: { Authorization: `Bearer ${token}` } });
133
+ expect(authed.ok()).toBeTruthy();
134
+ expect((await authed.json())?.email).toBe(email);
135
+ await api.dispose();
136
+ });
137
+
138
+ test("a user can create, list and revoke a personal API key @smoke", async ({ playwright }) => {
139
+ const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
140
+ const caps = await (await ctx.get("/api/account/capabilities")).json();
141
+ test.skip(!caps?.apiKey, "api keys not enabled");
142
+ const email = `apikey-${Date.now()}@example.com`;
143
+ await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "Key" } });
144
+
145
+ const created = await ctx.post("/api/auth/api-key/create", { data: { name: "ci" } });
146
+ expect(created.ok()).toBeTruthy();
147
+ const key = (await created.json())?.key as string;
148
+ expect(typeof key).toBe("string");
149
+ expect(key.length).toBeGreaterThan(0);
150
+
151
+ const body = (await (await ctx.get("/api/auth/api-key/list")).json()) as { apiKeys: Array<{ id: string; name: string | null }> };
152
+ expect(body.apiKeys.some((k) => k.name === "ci")).toBeTruthy();
153
+
154
+ const del = await ctx.post("/api/auth/api-key/delete", { data: { keyId: body.apiKeys[0].id } });
155
+ expect(del.ok()).toBeTruthy();
156
+ await ctx.dispose();
157
+ });
158
+
159
+ test("access control enforces custom role permissions @smoke", async ({ playwright }) => {
160
+ const admin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
161
+ await admin.post("/api/auth/sign-in/email", { data: { email: ADMIN.email, password: ADMIN.password } });
162
+ const caps = await (await admin.get("/api/account/capabilities")).json();
163
+ expect(caps.roles).toEqual(expect.arrayContaining(["admin", "moderator", "user"]));
164
+
165
+ // A default user lacks the content:create permission.
166
+ const email = `ac-${Date.now()}@example.com`;
167
+ const pw = "Podokit3e-Str0ng!pw";
168
+ const u = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
169
+ await u.post("/api/auth/sign-up/email", { data: { email, password: pw, name: "AC" } });
170
+ const before = await (await u.post("/api/auth/admin/has-permission", { data: { permissions: { content: ["create"] } } })).json();
171
+ expect(before.success).toBeFalsy();
172
+ const me = await (await u.get("/api/auth/get-session")).json();
173
+ await u.dispose();
174
+
175
+ // Promote to moderator; a fresh session now passes the same check.
176
+ const set = await admin.post("/api/auth/admin/set-role", { data: { userId: me.user.id, role: "moderator" } });
177
+ expect(set.ok()).toBeTruthy();
178
+ const u2 = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
179
+ await u2.post("/api/auth/sign-in/email", { data: { email, password: pw } });
180
+ const after = await (await u2.post("/api/auth/admin/has-permission", { data: { permissions: { content: ["create"] } } })).json();
181
+ expect(after.success).toBeTruthy();
182
+ await u2.dispose();
183
+ await admin.dispose();
184
+ });
185
+
186
+ test("a user can create, list and delete an organization @smoke", async ({ playwright }) => {
187
+ const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
188
+ const caps = await (await ctx.get("/api/account/capabilities")).json();
189
+ test.skip(!caps?.organization, "organizations not enabled");
190
+ const email = `org-${Date.now()}@example.com`;
191
+ await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "Org" } });
192
+
193
+ const slug = `acme-${Date.now()}`;
194
+ const created = await ctx.post("/api/auth/organization/create", { data: { name: "Acme", slug } });
195
+ expect(created.ok()).toBeTruthy();
196
+ const orgId = (await created.json())?.id as string;
197
+ expect(typeof orgId).toBe("string");
198
+
199
+ const list = (await (await ctx.get("/api/auth/organization/list")).json()) as Array<{ id: string; slug: string }>;
200
+ expect(list.some((o) => o.slug === slug)).toBeTruthy();
201
+
202
+ const del = await ctx.post("/api/auth/organization/delete", { data: { organizationId: orgId } });
203
+ expect(del.ok()).toBeTruthy();
204
+ await ctx.dispose();
205
+ });
206
+
207
+ test("an organization can have a parent and manager members @smoke", async ({ playwright }) => {
208
+ const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
209
+ const caps = await (await ctx.get("/api/account/capabilities")).json();
210
+ test.skip(!caps?.organization, "organizations not enabled");
211
+ const pw = "Podokit3e-Str0ng!pw";
212
+ await ctx.post("/api/auth/sign-up/email", { data: { email: `orgp-${Date.now()}@example.com`, password: pw, name: "Owner" } });
213
+
214
+ // A parent org, then a child that points at it.
215
+ const parent = await (await ctx.post("/api/auth/organization/create", { data: { name: "HQ", slug: `hq-${Date.now()}` } })).json();
216
+ const child = await ctx.post("/api/auth/organization/create", {
217
+ data: { name: "Sales", slug: `sales-${Date.now()}`, parentOrganizationId: parent.id },
218
+ });
219
+ expect(child.ok()).toBeTruthy();
220
+ const childId = (await child.json()).id as string;
221
+ const full = await (await ctx.get(`/api/auth/organization/get-full-organization?organizationId=${childId}`)).json();
222
+ expect(full.parentOrganizationId).toBe(parent.id);
223
+
224
+ // Add two existing users as managers (role-based → any number).
225
+ const managers: string[] = [];
226
+ for (let i = 0; i < 2; i++) {
227
+ const mCtx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
228
+ const email = `mgr-${Date.now()}-${i}@example.com`;
229
+ await mCtx.post("/api/auth/sign-up/email", { data: { email, password: pw, name: `Mgr${i}` } });
230
+ managers.push((await (await mCtx.get("/api/auth/get-session")).json()).user.id);
231
+ await mCtx.dispose();
232
+ }
233
+ for (const userId of managers) {
234
+ const added = await ctx.post("/api/account/org-member", { data: { userId, organizationId: childId, role: "manager" } });
235
+ expect(added.ok()).toBeTruthy();
236
+ }
237
+ const full2 = await (await ctx.get(`/api/auth/organization/get-full-organization?organizationId=${childId}`)).json();
238
+ const mgrCount = (full2.members ?? []).filter((m: { role: string }) => m.role === "manager").length;
239
+ expect(mgrCount).toBe(2);
240
+ await ctx.dispose();
241
+ });
242
+
243
+ // OIDC discovery + the enabled/disabled behaviour is covered by the front-channel
244
+ // test in tests/ui/oidc.ui.spec.ts (which owns toggling the oidcProvider flag), and
245
+ // the server-side gate is covered generically by the feature-toggle test above.
246
+
247
+ test("only admins can change settings @smoke", async ({ playwright }) => {
248
+ const userCtx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
249
+ await userCtx.post("/api/auth/sign-in/email", { data: { email: USER.email, password: USER.password } });
250
+ const asUser = await userCtx.put("/api/account/settings", { data: { magicLink: false } });
251
+ expect(asUser.status()).toBe(403);
252
+ await userCtx.dispose();
253
+ });
254
+
255
+ test("disabling a feature blocks its endpoint server-side and re-enabling restores it @smoke", async ({ playwright }) => {
256
+ // Uses a fresh admin context (not the shared storageState) so it never rotates
257
+ // the seeded session. Toggles a flag and confirms the feature gate enforces it.
258
+ const admin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
259
+ await admin.post("/api/auth/sign-in/email", { data: { email: ADMIN.email, password: ADMIN.password } });
260
+ const caps = await (await admin.get("/api/account/capabilities")).json();
261
+ test.skip(!caps?.magicLink, "magic link not enabled");
262
+
263
+ // A disabled feature's endpoint 404s; the gate reads the flag through a short cache.
264
+ const status = (): Promise<number> =>
265
+ admin.post("/api/auth/sign-in/magic-link", { data: { email: "probe@example.com" } }).then((r) => r.status());
266
+
267
+ expect((await admin.put("/api/account/settings", { data: { magicLink: false } })).ok()).toBeTruthy();
268
+ await expect.poll(status, { timeout: 8000 }).toBe(404);
269
+
270
+ expect((await admin.put("/api/account/settings", { data: { magicLink: true } })).ok()).toBeTruthy();
271
+ await expect.poll(status, { timeout: 8000 }).not.toBe(404);
272
+
273
+ await admin.dispose();
274
+ });
275
+
13
276
  test("sign-in with a wrong password is rejected", async ({ request }) => {
14
277
  const res = await request.post("/api/auth/sign-in/email", {
15
278
  headers: origin,
@@ -0,0 +1,120 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { clearMailpit, mailpitReachable, waitForLink, waitForOtp } from "../helpers/mailpit";
3
+
4
+ const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
5
+ const origin = { origin: base };
6
+
7
+ test("email one-time code signs a user in @smoke", async ({ playwright }) => {
8
+ test.skip(!(await mailpitReachable()), "Mailpit not available");
9
+ const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
10
+ const caps = await (await ctx.get("/api/account/capabilities")).json();
11
+ test.skip(!caps?.emailOtp, "email OTP not enabled");
12
+ const email = `otp-${Date.now()}@example.com`;
13
+ await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "Otp" } });
14
+ await clearMailpit();
15
+ const requested = await ctx.post("/api/auth/email-otp/send-verification-otp", { data: { email, type: "sign-in" } });
16
+ expect(requested.ok()).toBeTruthy();
17
+ const otp = await waitForOtp(email);
18
+ const signedIn = await ctx.post("/api/auth/sign-in/email-otp", { data: { email, otp } });
19
+ expect(signedIn.ok()).toBeTruthy();
20
+ const session = await (await ctx.get("/api/auth/get-session")).json();
21
+ expect(session?.user?.email).toBe(email);
22
+ await ctx.dispose();
23
+ });
24
+
25
+ test("organization invitation can be sent and accepted @smoke", async ({ playwright }) => {
26
+ const owner = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
27
+ const caps = await (await owner.get("/api/account/capabilities")).json();
28
+ test.skip(!caps?.organization, "organizations not enabled");
29
+ const pw = "Podokit3e-Str0ng!pw";
30
+ await owner.post("/api/auth/sign-up/email", { data: { email: `owner-${Date.now()}@example.com`, password: pw, name: "Owner" } });
31
+ const orgId = (await (await owner.post("/api/auth/organization/create", { data: { name: "Invco", slug: `invco-${Date.now()}` } })).json())?.id as string;
32
+
33
+ // invite a fresh user
34
+ const inviteeEmail = `invitee-${Date.now()}@example.com`;
35
+ const inv = await owner.post("/api/auth/organization/invite-member", { data: { email: inviteeEmail, role: "member", organizationId: orgId } });
36
+ expect(inv.ok()).toBeTruthy();
37
+ const invitationId = (await inv.json())?.id as string;
38
+
39
+ // the invitee signs up and accepts
40
+ const invitee = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
41
+ await invitee.post("/api/auth/sign-up/email", { data: { email: inviteeEmail, password: pw, name: "Invitee" } });
42
+ const accepted = await invitee.post("/api/auth/organization/accept-invitation", { data: { invitationId } });
43
+ expect(accepted.ok()).toBeTruthy();
44
+ await invitee.dispose();
45
+
46
+ // the owner now sees two members
47
+ const full = await (await owner.get(`/api/auth/organization/get-full-organization?organizationId=${orgId}`)).json();
48
+ expect((full?.members ?? []).length).toBeGreaterThanOrEqual(2);
49
+ await owner.dispose();
50
+ });
51
+
52
+ test("magic link signs a user in @smoke", async ({ playwright }) => {
53
+ test.skip(!(await mailpitReachable()), "Mailpit not available");
54
+ const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
55
+ const caps = await (await ctx.get("/api/account/capabilities")).json();
56
+ test.skip(!caps?.magicLink, "magic link not enabled");
57
+ // Use a throwaway account, not the shared ADMIN — a magic-link sign-in can
58
+ // rotate the account's sessions, which would invalidate the seeded storageState.
59
+ const email = `magic-${Date.now()}@example.com`;
60
+ await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "Magic" } });
61
+ await clearMailpit();
62
+ const requested = await ctx.post("/api/auth/sign-in/magic-link", { data: { email, callbackURL: `${base}/admin` } });
63
+ expect(requested.ok()).toBeTruthy();
64
+ const link = await waitForLink(email);
65
+ // Following the emailed link validates the token and sets the session cookie.
66
+ await ctx.get(link, { maxRedirects: 0 });
67
+ const session = await (await ctx.get("/api/auth/get-session")).json();
68
+ expect(session?.user?.email).toBe(email);
69
+ await ctx.dispose();
70
+ });
71
+
72
+ // Follow an emailed auth link (which hits the API to validate) and read the
73
+ // token the API hands back on its redirect.
74
+ function tokenFromRedirect(location: string): string | null {
75
+ return new URL(location, base).searchParams.get("token");
76
+ }
77
+
78
+ test("password reset delivers an email and updates the password @smoke", async ({ playwright }) => {
79
+ test.skip(!(await mailpitReachable()), "Mailpit not available");
80
+ const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
81
+ const email = `reset-${Date.now()}@example.com`;
82
+ const signup = await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "Reset" } });
83
+ expect(signup.ok()).toBeTruthy();
84
+ // With verification on, sign-up returns no session and the new password still
85
+ // can't sign in until confirmed — so only assert sign-in when it's off.
86
+ const verificationOn = !(await signup.json())?.token;
87
+
88
+ await clearMailpit();
89
+ const requested = await ctx.post("/api/auth/request-password-reset", { data: { email, redirectTo: `${base}/reset-password` } });
90
+ expect(requested.ok()).toBeTruthy();
91
+
92
+ const link = await waitForLink(email);
93
+ const redirected = await ctx.get(link, { maxRedirects: 0 });
94
+ const token = tokenFromRedirect(redirected.headers()["location"] ?? "");
95
+ expect(token, "the reset link resolves to a valid token").toBeTruthy();
96
+
97
+ expect((await ctx.post("/api/auth/reset-password", { data: { newPassword: "Podokit3e-N3wStr0ng!pw", token } })).ok()).toBeTruthy();
98
+ if (!verificationOn) {
99
+ expect((await ctx.post("/api/auth/sign-in/email", { data: { email, password: "Podokit3e-N3wStr0ng!pw" } })).ok()).toBeTruthy();
100
+ expect((await ctx.post("/api/auth/sign-in/email", { data: { email, password: "Podokit3e-Str0ng!pw" } })).status()).toBe(401);
101
+ }
102
+ await ctx.dispose();
103
+ });
104
+
105
+ test("email verification blocks sign-in until the address is confirmed", async ({ playwright }) => {
106
+ test.skip(!(await mailpitReachable()), "Mailpit not available");
107
+ const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
108
+ const email = `verify-${Date.now()}@example.com`;
109
+ await clearMailpit();
110
+ const signup = await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "Verify", callbackURL: `${base}/admin` } });
111
+ expect(signup.ok()).toBeTruthy();
112
+ // When verification is off, sign-up returns a session — nothing to test here.
113
+ test.skip(Boolean((await signup.json())?.token), "email verification not enabled");
114
+
115
+ expect((await ctx.post("/api/auth/sign-in/email", { data: { email, password: "Podokit3e-Str0ng!pw" } })).status()).toBe(403);
116
+ const link = await waitForLink(email);
117
+ await ctx.get(link, { maxRedirects: 0 });
118
+ expect((await ctx.post("/api/auth/sign-in/email", { data: { email, password: "Podokit3e-Str0ng!pw" } })).ok()).toBeTruthy();
119
+ await ctx.dispose();
120
+ });
@@ -1,7 +1,7 @@
1
1
  import { expect, test } from "@playwright/test";
2
2
  import { ADMIN, USER } from "../helpers/accounts";
3
3
 
4
- const base = process.env.E2E_BASE_URL ?? "http://localhost:5173";
4
+ const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
5
5
  const origin = { origin: base };
6
6
 
7
7
  async function signedIn(
@@ -18,20 +18,20 @@ test("admin can create a user, set its password, and remove it", async ({ playwr
18
18
  const email = `api-crud-${Date.now()}@example.com`;
19
19
 
20
20
  const created = await admin.post("/api/auth/admin/create-user", {
21
- data: { email, password: "password123", name: "API CRUD", role: "user" },
21
+ data: { email, password: "Podokit3e-Str0ng!pw", name: "API CRUD", role: "user" },
22
22
  });
23
23
  expect(created.ok()).toBeTruthy();
24
24
  const id = (await created.json())?.user?.id as string;
25
25
  expect(id).toBeTruthy();
26
26
 
27
27
  const setPw = await admin.post("/api/auth/admin/set-user-password", {
28
- data: { userId: id, newPassword: "newpass1234" },
28
+ data: { userId: id, newPassword: "Podokit3e-N3wStr0ng!pw" },
29
29
  });
30
30
  expect(setPw.ok()).toBeTruthy();
31
31
 
32
32
  // the new password works
33
33
  const fresh = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
34
- const signin = await fresh.post("/api/auth/sign-in/email", { data: { email, password: "newpass1234" } });
34
+ const signin = await fresh.post("/api/auth/sign-in/email", { data: { email, password: "Podokit3e-N3wStr0ng!pw" } });
35
35
  expect(signin.ok()).toBeTruthy();
36
36
  await fresh.dispose();
37
37
 
@@ -43,7 +43,7 @@ test("admin can create a user, set its password, and remove it", async ({ playwr
43
43
  test("a normal user cannot create users (403)", async ({ playwright }) => {
44
44
  const user = await signedIn(playwright, USER);
45
45
  const res = await user.post("/api/auth/admin/create-user", {
46
- data: { email: `nope-${Date.now()}@example.com`, password: "password123", name: "Nope", role: "user" },
46
+ data: { email: `nope-${Date.now()}@example.com`, password: "Podokit3e-Str0ng!pw", name: "Nope", role: "user" },
47
47
  });
48
48
  expect(res.status()).toBe(403);
49
49
  await user.dispose();
@@ -53,13 +53,13 @@ test("admin can list and revoke a user's sessions", async ({ playwright }) => {
53
53
  const admin = await signedIn(playwright, ADMIN);
54
54
  const email = `api-sess-${Date.now()}@example.com`;
55
55
  const created = await admin.post("/api/auth/admin/create-user", {
56
- data: { email, password: "password123", name: "API Sess", role: "user" },
56
+ data: { email, password: "Podokit3e-Str0ng!pw", name: "API Sess", role: "user" },
57
57
  });
58
58
  const userId = (await created.json())?.user?.id as string;
59
59
 
60
60
  // the user signs in -> creates a session
61
61
  const theirs = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
62
- expect((await theirs.post("/api/auth/sign-in/email", { data: { email, password: "password123" } })).ok()).toBeTruthy();
62
+ expect((await theirs.post("/api/auth/sign-in/email", { data: { email, password: "Podokit3e-Str0ng!pw" } })).ok()).toBeTruthy();
63
63
  await theirs.dispose();
64
64
 
65
65
  const listed = await admin.post("/api/auth/admin/list-user-sessions", { data: { userId } });
@@ -76,11 +76,11 @@ test("revokeOtherSessions keeps the current session valid", async ({ playwright
76
76
  // Use a throwaway account so we don't revoke the shared USER's ui storageState.
77
77
  const admin = await signedIn(playwright, ADMIN);
78
78
  const email = `api-revoke-others-${Date.now()}@example.com`;
79
- await admin.post("/api/auth/admin/create-user", { data: { email, password: "password123", name: "RO", role: "user" } });
79
+ await admin.post("/api/auth/admin/create-user", { data: { email, password: "Podokit3e-Str0ng!pw", name: "RO", role: "user" } });
80
80
  await admin.dispose();
81
81
 
82
82
  const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
83
- await ctx.post("/api/auth/sign-in/email", { data: { email, password: "password123" } });
83
+ await ctx.post("/api/auth/sign-in/email", { data: { email, password: "Podokit3e-Str0ng!pw" } });
84
84
  expect((await ctx.post("/api/auth/revoke-other-sessions")).ok()).toBeTruthy();
85
85
  const after = await ctx.get("/api/auth/get-session");
86
86
  expect((await after.json())?.user?.email).toBe(email); // still signed in
@@ -112,7 +112,7 @@ test("admin can update a user and impersonate them", async ({ playwright }) => {
112
112
  const admin = await signedIn(playwright, ADMIN);
113
113
  const email = `api-edit-${Date.now()}@example.com`;
114
114
  const created = await admin.post("/api/auth/admin/create-user", {
115
- data: { email, password: "password123", name: "Edit Me", role: "user" },
115
+ data: { email, password: "Podokit3e-Str0ng!pw", name: "Edit Me", role: "user" },
116
116
  });
117
117
  const userId = (await created.json())?.user?.id as string;
118
118
 
@@ -1,6 +1,6 @@
1
1
  export type Account = { name: string; email: string; password: string };
2
- export const ADMIN = { name: "Admin", email: "admin@example.com", password: "password123" };
3
- export const USER = { name: "Normal User", email: "user@example.com", password: "password123" };
2
+ export const ADMIN = { name: "Admin", email: "admin@example.com", password: "Podokit3e-Str0ng!pw" };
3
+ export const USER = { name: "Normal User", email: "user@example.com", password: "Podokit3e-Str0ng!pw" };
4
4
  export const adminState = "playwright/.auth/admin.json";
5
5
  export const userState = "playwright/.auth/user.json";
6
6
  export const anonState = { cookies: [], origins: [] } as const;
@@ -0,0 +1,49 @@
1
+ // Helpers for the local Mailpit catcher (docker-compose / CI service). Tests that
2
+ // exercise email flows skip when it isn't reachable (see MAILPIT_URL).
3
+ const MAILPIT = process.env.MAILPIT_URL ?? "http://localhost:8025";
4
+
5
+ export async function mailpitReachable(): Promise<boolean> {
6
+ try {
7
+ return (await fetch(`${MAILPIT}/readyz`)).ok;
8
+ } catch {
9
+ return false;
10
+ }
11
+ }
12
+
13
+ export async function clearMailpit(): Promise<void> {
14
+ try {
15
+ await fetch(`${MAILPIT}/api/v1/messages`, { method: "DELETE" });
16
+ } catch {
17
+ /* ignore */
18
+ }
19
+ }
20
+
21
+ type MailpitList = { messages: Array<{ ID: string; To: Array<{ Address: string }> }> };
22
+ type MailpitMessage = { Text?: string; HTML?: string };
23
+
24
+ // Poll for the newest message addressed to `to` and return the first match of
25
+ // `pattern` in its text/HTML body.
26
+ async function waitForMatch(to: string, pattern: RegExp, label: string, timeoutMs: number): Promise<string> {
27
+ const deadline = Date.now() + timeoutMs;
28
+ while (Date.now() < deadline) {
29
+ const list = (await (await fetch(`${MAILPIT}/api/v1/messages`)).json()) as MailpitList;
30
+ const msg = list.messages?.find((m) => m.To?.some((t) => t.Address === to));
31
+ if (msg) {
32
+ const full = (await (await fetch(`${MAILPIT}/api/v1/message/${msg.ID}`)).json()) as MailpitMessage;
33
+ const match = `${full.Text ?? ""} ${full.HTML ?? ""}`.match(pattern);
34
+ if (match) return match[0];
35
+ }
36
+ await new Promise((r) => setTimeout(r, 300));
37
+ }
38
+ throw new Error(`no email with ${label} to ${to} within ${timeoutMs}ms`);
39
+ }
40
+
41
+ /** The first link in the newest email addressed to `to`. */
42
+ export function waitForLink(to: string, timeoutMs = 10000): Promise<string> {
43
+ return waitForMatch(to, /https?:\/\/[^\s"<>]+/, "a link", timeoutMs);
44
+ }
45
+
46
+ /** The one-time numeric code in the newest email addressed to `to`. */
47
+ export function waitForOtp(to: string, timeoutMs = 10000): Promise<string> {
48
+ return waitForMatch(to, /\b\d{4,8}\b/, "a one-time code", timeoutMs);
49
+ }
@@ -0,0 +1,35 @@
1
+ // Read-back helper for the dev SMS sink (the "Mailpit for SMS"). The app posts
2
+ // outgoing SMS to the sink via SMS_WEBHOOK_URL; tests poll it here to obtain the
3
+ // delivered code, so the phone-number flow is verified for real (not just "sent").
4
+ const SINK = process.env.SMS_SINK_URL ?? "http://localhost:8095";
5
+
6
+ type SinkMessage = { to: string; body: string; receivedAt: string };
7
+
8
+ export async function smsSinkReachable(): Promise<boolean> {
9
+ try {
10
+ const res = await fetch(`${SINK}/readyz`);
11
+ return res.ok;
12
+ } catch {
13
+ return false;
14
+ }
15
+ }
16
+
17
+ export async function clearSms(): Promise<void> {
18
+ await fetch(`${SINK}/messages`, { method: "DELETE" }).catch(() => undefined);
19
+ }
20
+
21
+ /** Poll the sink until an SMS to `to` arrives, then return the first 4–8 digit code. */
22
+ export async function waitForSmsOtp(to: string, timeoutMs = 10_000): Promise<string> {
23
+ const deadline = Date.now() + timeoutMs;
24
+ while (Date.now() < deadline) {
25
+ const res = await fetch(`${SINK}/messages`).catch(() => null);
26
+ if (res?.ok) {
27
+ const messages = (await res.json()) as SinkMessage[];
28
+ const match = messages.find((m) => m.to === to);
29
+ const code = match?.body.match(/\b\d{4,8}\b/)?.[0];
30
+ if (code) return code;
31
+ }
32
+ await new Promise((r) => setTimeout(r, 250));
33
+ }
34
+ throw new Error(`No SMS OTP for ${to} within ${timeoutMs}ms`);
35
+ }
@@ -0,0 +1,9 @@
1
+ import { URI, type TOTP } from "otpauth";
2
+
3
+ /** Compute the current 6-digit code from an otpauth:// URI (the `totpURI` that
4
+ * two-factor/enable returns), the same way an authenticator app would. Lets
5
+ * tests complete the real 2FA verification flow. */
6
+ export function totpCode(otpauthUri: string): string {
7
+ const totp = URI.parse(otpauthUri) as TOTP;
8
+ return totp.generate();
9
+ }
@@ -3,7 +3,7 @@ import { defineConfig, devices } from "@playwright/test";
3
3
  // admin-dashboard overlay: adds a `setup` project that seeds admin/user sessions
4
4
  // (storageState) which the `ui` project reuses. Serial + single worker because
5
5
  // tests share one backend/DB. Runs against a live stack on E2E_BASE_URL.
6
- const baseURL = process.env.E2E_BASE_URL ?? "http://localhost:5173";
6
+ const baseURL = process.env.E2E_BASE_URL ?? "http://localhost:5001";
7
7
 
8
8
  export default defineConfig({
9
9
  testDir: ".",
@@ -1,7 +1,7 @@
1
1
  import { test as setup, expect } from "@playwright/test";
2
2
  import { ADMIN, USER, adminState, userState, type Account } from "./helpers/accounts";
3
3
 
4
- const base = process.env.E2E_BASE_URL ?? "http://localhost:5173";
4
+ const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
5
5
 
6
6
  // Seed a session via the API (reliable, no UI hydration races) and save its
7
7
  // cookies as storageState for the browser `ui` project to reuse.
@@ -24,6 +24,17 @@ setup("seed admin session", async ({ playwright }) => {
24
24
  await seedSession(playwright, ADMIN, adminState);
25
25
  });
26
26
 
27
+ // Feature flags are DB-backed (seeded by the app_setting migration). phoneNumber
28
+ // ships off (needs an SMS provider); turn it on here so its specs run — this also
29
+ // exercises the admin settings API on every suite run.
30
+ setup("enable optional features", async ({ playwright }) => {
31
+ const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: { origin: base } });
32
+ await ctx.post("/api/auth/sign-in/email", { data: { email: ADMIN.email, password: ADMIN.password } });
33
+ const res = await ctx.put("/api/account/settings", { data: { phoneNumber: true } });
34
+ expect(res.ok(), "enable optional features via settings").toBeTruthy();
35
+ await ctx.dispose();
36
+ });
37
+
27
38
  setup("seed user session", async ({ playwright }) => {
28
39
  await seedSession(playwright, USER, userState);
29
40
  });