@podosoft/podokit 1.5.0 → 1.6.0

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 (211) hide show
  1. package/README.md +2 -1
  2. package/dist/app-proxy-timeouts.d.ts +27 -0
  3. package/dist/app-proxy-timeouts.js +180 -0
  4. package/dist/assemble.d.ts +5 -2
  5. package/dist/assemble.js +14 -4
  6. package/dist/deploy-checks.d.ts +44 -0
  7. package/dist/deploy-checks.js +267 -0
  8. package/dist/deploy-compose-profile.d.ts +6 -2
  9. package/dist/deploy-compose-profile.js +15 -6
  10. package/dist/deploy-compose-render.d.ts +12 -4
  11. package/dist/deploy-compose-render.js +47 -18
  12. package/dist/deploy-compose.d.ts +2 -5
  13. package/dist/deploy-compose.js +7 -2
  14. package/dist/deploy-profile.d.ts +6 -2
  15. package/dist/deploy-profile.js +11 -7
  16. package/dist/deploy-render.d.ts +4 -1
  17. package/dist/deploy-render.js +27 -21
  18. package/dist/deploy-schema.d.ts +56 -2
  19. package/dist/deploy-schema.js +130 -14
  20. package/dist/deploy.d.ts +2 -5
  21. package/dist/deploy.js +5 -2
  22. package/dist/dev.d.ts +21 -0
  23. package/dist/dev.js +78 -3
  24. package/dist/eject.js +2 -0
  25. package/dist/index.js +85 -5
  26. package/dist/lib.d.ts +2 -0
  27. package/dist/lib.js +5 -2
  28. package/dist/lockfile.d.ts +24 -0
  29. package/dist/lockfile.js +74 -4
  30. package/dist/provider.js +10 -5
  31. package/dist/remove.js +1 -1
  32. package/dist/template-files.d.ts +42 -0
  33. package/dist/template-files.js +102 -0
  34. package/dist/templates/fullstack/AGENTS.md +28 -4
  35. package/dist/templates/fullstack/README.md +8 -7
  36. package/dist/templates/fullstack/apps/api/package.json +4 -4
  37. package/dist/templates/fullstack/apps/api/scripts/build.extensions.mjs +6 -0
  38. package/dist/templates/fullstack/apps/api/scripts/build.mjs +65 -16
  39. package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +7 -3
  40. package/dist/templates/fullstack/apps/api/scripts/verify-api-contract.ts +5 -1
  41. package/dist/templates/fullstack/apps/api/src/app.spec.ts +22 -0
  42. package/dist/templates/fullstack/apps/api/src/app.ts +41 -25
  43. package/dist/templates/fullstack/apps/api/src/build.spec.ts +118 -0
  44. package/dist/templates/fullstack/apps/api/src/config/env.extensions.ts +20 -0
  45. package/dist/templates/fullstack/apps/api/src/config/env.validation.spec.ts +76 -0
  46. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +18 -3
  47. package/dist/templates/fullstack/apps/api/src/config/provider-selection.spec.ts +53 -0
  48. package/dist/templates/fullstack/apps/api/src/config/provider-selection.ts +79 -0
  49. package/dist/templates/fullstack/apps/api/src/config/providers.ts +7 -3
  50. package/dist/templates/fullstack/apps/api/src/core/api-contract.extensions.ts +6 -0
  51. package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +29 -0
  52. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +39 -2
  53. package/dist/templates/fullstack/apps/api/src/core/services.ts +13 -0
  54. package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +27 -0
  55. package/dist/templates/fullstack/apps/api/src/database/data-source.ts +7 -10
  56. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +12 -0
  57. package/dist/templates/fullstack/apps/api/src/database/database.ts +2 -3
  58. package/dist/templates/fullstack/apps/api/src/database/entities.ts +12 -0
  59. package/dist/templates/fullstack/apps/api/src/database/migrate.ts +12 -2
  60. package/dist/templates/fullstack/apps/api/src/database/migration-files.spec.ts +116 -0
  61. package/dist/templates/fullstack/apps/api/src/database/migration-files.ts +134 -0
  62. package/dist/templates/fullstack/apps/api/src/database/migration-readiness.spec.ts +36 -0
  63. package/dist/templates/fullstack/apps/api/src/database/migration-readiness.ts +25 -0
  64. package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +12 -1
  65. package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.ts +2 -36
  66. package/dist/templates/fullstack/apps/api/src/main.ts +2 -2
  67. package/dist/templates/fullstack/apps/api/tsconfig.json +2 -0
  68. package/dist/templates/fullstack/apps/web/package.json +1 -1
  69. package/dist/templates/fullstack/apps/web/src/app.css +3 -0
  70. package/dist/templates/fullstack/apps/web/src/app.extensions.css +11 -0
  71. package/dist/templates/fullstack/apps/web/src/lib/components/site-runtime.svelte +3 -1
  72. package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +19 -0
  73. package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +4 -1
  74. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +79 -0
  75. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +55 -1
  76. package/dist/templates/fullstack/apps/web/src/lib/server/proxy-timeouts.ts +2 -1
  77. package/dist/templates/fullstack/apps/web/tsconfig.json +1 -1
  78. package/dist/templates/fullstack/apps/web/vite.config.test.ts +33 -0
  79. package/dist/templates/fullstack/apps/web/vite.config.ts +55 -13
  80. package/dist/templates/fullstack/apps/web/vite.extensions.ts +23 -0
  81. package/dist/templates/fullstack/compose.dev.yaml +7 -4
  82. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/SKILL.md +2 -2
  83. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +12 -3
  84. package/dist/templates/fullstack/dot-claude/skills/podokit-update/SKILL.md +5 -2
  85. package/dist/templates/fullstack/dot-env.docker.example +5 -2
  86. package/dist/templates/fullstack/dot-env.example +4 -0
  87. package/dist/templates/fullstack/dot-podokit/dev.json +1 -1
  88. package/dist/templates/fullstack/infra/k3s/configmap.yaml +1 -0
  89. package/dist/templates/fullstack/tests/README.md +40 -1
  90. package/dist/templates/fullstack/tests/helpers/app-paths.ts +18 -0
  91. package/dist/templates/fullstack/tests/helpers/paths.ts +33 -0
  92. package/dist/templates/fullstack/tests/package.json +2 -2
  93. package/dist/templates/fullstack/tests/playwright.config.ts +7 -3
  94. package/dist/templates/fullstack/tests/playwright.extensions.ts +88 -1
  95. package/dist/templates/fullstack/tests/ui/smoke.ui.spec.ts +5 -4
  96. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +37 -1
  97. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +15 -6
  98. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.test.ts +31 -0
  99. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.ts +31 -0
  100. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flags.ts +12 -0
  101. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-nav.ts +11 -0
  102. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.test.ts +39 -0
  103. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.ts +35 -0
  104. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/registry.svelte.ts +15 -0
  105. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +11 -1
  106. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/admin-sidebar.svelte +2 -23
  107. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +7 -2
  108. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.test.ts +30 -0
  109. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/impersonation-banner.svelte +3 -2
  110. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/settings/app-flags-settings.svelte +65 -0
  111. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +32 -23
  112. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.test.ts +27 -0
  113. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-footer.svelte +4 -1
  114. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +3 -1
  115. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -0
  116. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -0
  117. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/app-hooks.ts +11 -0
  118. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/non-indexable-paths.ts +8 -0
  119. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.test.ts +31 -0
  120. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.ts +5 -1
  121. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +1 -1
  122. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.server.ts +2 -1
  123. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.svelte +1 -5
  124. package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -0
  125. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +1 -1
  126. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +159 -170
  127. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +72 -81
  128. package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +1 -6
  129. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +1 -1
  130. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +43 -53
  131. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +3 -1
  132. package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +24 -6
  133. package/dist/templates/modules/admin-dashboard/files/tests/helpers/app-accounts.ts +24 -0
  134. package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +41 -11
  135. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +5 -3
  136. package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +3 -9
  137. package/dist/templates/modules/admin-dashboard/files/tests/helpers/sms.ts +2 -4
  138. package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +11 -5
  139. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +4 -5
  140. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +55 -55
  141. package/dist/templates/modules/admin-dashboard/files/tests/ui/app-flags.ui.spec.ts +63 -0
  142. package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +12 -16
  143. package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +3 -2
  144. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +32 -35
  145. package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +4 -2
  146. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +1 -2
  147. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -2
  148. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +82 -91
  149. package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +6 -2
  150. package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +16 -20
  151. package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +6 -3
  152. package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -3
  153. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +2 -4
  154. package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +5 -2
  155. package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +7 -2
  156. package/dist/templates/modules/admin-dashboard/files/tests/ui/same-origin-requests.ui.spec.ts +89 -0
  157. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +5 -4
  158. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +67 -73
  159. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +84 -93
  160. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +8 -7
  161. package/dist/templates/modules/admin-dashboard/files/tests/ui/theme-mode.ui.spec.ts +29 -0
  162. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +51 -54
  163. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +10 -9
  164. package/dist/templates/modules/admin-dashboard/module.manifest.json +7 -2
  165. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +5 -1
  166. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +17 -0
  167. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +18 -0
  168. package/dist/templates/modules/audit-log/module.manifest.json +15 -3
  169. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-app-name.spec.ts +21 -0
  170. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +13 -1
  171. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-environment.ts +9 -2
  172. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +19 -3
  173. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +2 -0
  174. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.runtime.spec.ts +10 -0
  175. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +7 -5
  176. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.spec.ts +86 -0
  177. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.ts +44 -0
  178. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +2 -1
  179. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -1
  180. package/dist/templates/modules/auth/files/apps/api/src/settings/app-flags.ts +9 -0
  181. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.spec.ts +43 -0
  182. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +30 -5
  183. package/dist/templates/modules/auth/module.manifest.json +8 -2
  184. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +57 -1
  185. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +93 -9
  186. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.spec.ts +40 -0
  187. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.ts +52 -5
  188. package/dist/templates/modules/bullmq/files/apps/api/src/main-worker.ts +9 -7
  189. package/dist/templates/modules/bullmq/files/apps/api/src/worker.extensions.ts +13 -0
  190. package/dist/templates/modules/bullmq/module.manifest.json +5 -1
  191. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +2 -0
  192. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +87 -1
  193. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +93 -3
  194. package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
  195. package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.spec.ts +16 -0
  196. package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.ts +15 -10
  197. package/dist/templates/modules/mailer/module.manifest.json +1 -1
  198. package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +1 -1
  199. package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +5 -4
  200. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +1 -1
  201. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +45 -20
  202. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +1 -1
  203. package/dist/templates/todo/AGENTS.md +5 -1
  204. package/dist/templates/todo/README.md +6 -4
  205. package/dist/text-diff.d.ts +12 -0
  206. package/dist/text-diff.js +111 -0
  207. package/dist/update.d.ts +61 -3
  208. package/dist/update.js +152 -27
  209. package/package.json +2 -2
  210. package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +0 -75
  211. package/dist/templates/modules/admin-dashboard/files/tests/helpers/exclusive.ts +0 -80
@@ -4,7 +4,6 @@ import {
4
4
  isUserCreationAllowed,
5
5
  } from "../../apps/api/src/auth/feature-gate";
6
6
  import { ADMIN } from "../helpers/accounts";
7
- import { exclusive } from "../helpers/exclusive";
8
7
 
9
8
  const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
10
9
  const origin = { origin: base };
@@ -17,69 +16,65 @@ async function adminCtx(playwright: import("@playwright/test").PlaywrightWorkerA
17
16
  return ctx;
18
17
  }
19
18
 
20
- test("admin can enable Google OAuth from the DB and it applies without a restart", async ({ playwright }) => {
19
+ test("admin can enable Google OAuth from the DB and it applies without a restart", { tag: "@exclusive" }, async ({ playwright }) => {
21
20
  test.slow();
22
- await exclusive("social-providers", async () => {
23
- const admin = await adminCtx(playwright);
24
- await admin.put("/api/account/auth-config", { data: { social: { google: { enabled: false } } } }); // clean start
25
- const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
26
- const social = () => anon.post("/api/auth/sign-in/social", { data: { provider: "google", callbackURL: `${base}/admin` } });
27
-
28
- // Before: provider not configured.
29
- await expect.poll(async () => (await social()).status(), { timeout: 8000 }).toBe(404);
30
-
31
- // Paste credentials via the admin API.
32
- const put = await admin.put("/api/account/auth-config", {
33
- data: { social: { google: { enabled: true, clientId: "dummy-google-client-id", clientSecret: "dummy-google-client-secret" } } },
34
- });
35
- expect(put.ok()).toBeTruthy();
36
- const view = await put.json();
37
- expect(view.social.google.enabled).toBe(true);
38
- expect(view.social.google.hasSecret).toBe(true);
39
- expect(view.social.google.clientId).toBe("dummy-google-client-id");
40
- expect("clientSecret" in view.social.google).toBe(false); // secret is never returned
41
-
42
- // Applied live (no restart): social sign-in now redirects to Google.
43
- await expect
44
- .poll(async () => {
45
- const r = await social();
46
- return r.ok() ? (((await r.json())?.url as string) ?? "") : "";
47
- }, { timeout: 8000 })
48
- .toContain("accounts.google.com");
49
- const caps = await (await anon.get("/api/account/capabilities")).json();
50
- expect(caps.providers).toContain("google");
51
-
52
- await admin.put("/api/account/auth-config", { data: { social: { google: { enabled: false } } } }); // restore
53
- await anon.dispose();
54
- await admin.dispose();
21
+ const admin = await adminCtx(playwright);
22
+ await admin.put("/api/account/auth-config", { data: { social: { google: { enabled: false } } } }); // clean start
23
+ const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
24
+ const social = () => anon.post("/api/auth/sign-in/social", { data: { provider: "google", callbackURL: `${base}/admin` } });
25
+
26
+ // Before: provider not configured.
27
+ await expect.poll(async () => (await social()).status(), { timeout: 8000 }).toBe(404);
28
+
29
+ // Paste credentials via the admin API.
30
+ const put = await admin.put("/api/account/auth-config", {
31
+ data: { social: { google: { enabled: true, clientId: "dummy-google-client-id", clientSecret: "dummy-google-client-secret" } } },
55
32
  });
33
+ expect(put.ok()).toBeTruthy();
34
+ const view = await put.json();
35
+ expect(view.social.google.enabled).toBe(true);
36
+ expect(view.social.google.hasSecret).toBe(true);
37
+ expect(view.social.google.clientId).toBe("dummy-google-client-id");
38
+ expect("clientSecret" in view.social.google).toBe(false); // secret is never returned
39
+
40
+ // Applied live (no restart): social sign-in now redirects to Google.
41
+ await expect
42
+ .poll(async () => {
43
+ const r = await social();
44
+ return r.ok() ? (((await r.json())?.url as string) ?? "") : "";
45
+ }, { timeout: 8000 })
46
+ .toContain("accounts.google.com");
47
+ const caps = await (await anon.get("/api/account/capabilities")).json();
48
+ expect(caps.providers).toContain("google");
49
+
50
+ await admin.put("/api/account/auth-config", { data: { social: { google: { enabled: false } } } }); // restore
51
+ await anon.dispose();
52
+ await admin.dispose();
56
53
  });
57
54
 
58
- test("admin can add and remove a social provider dynamically", async ({ playwright }) => {
55
+ test("admin can add and remove a social provider dynamically", { tag: "@exclusive" }, async ({ playwright }) => {
59
56
  test.slow();
60
- await exclusive("social-providers", async () => {
61
- const admin = await adminCtx(playwright);
62
- // The catalog offers providers beyond the two legacy env-backed ones.
63
- const catalog = (await (await admin.get("/api/account/auth-config")).json()).catalog as Array<{ id: string }>;
64
- expect(catalog.map((c) => c.id)).toEqual(expect.arrayContaining(["google", "github", "apple"]));
65
-
66
- const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
67
- const github = () => anon.post("/api/auth/sign-in/social", { data: { provider: "github", callbackURL: `${base}/admin` } });
68
-
69
- // Add GitHub (a provider with no env fallback) → applies live, no restart.
70
- await admin.put("/api/account/auth-config", { data: { social: { github: { enabled: true, clientId: "dummy-github-client-id", clientSecret: "dummy-github-client-secret" } } } });
71
- await expect
72
- .poll(async () => { const r = await github(); return r.ok() ? (((await r.json())?.url as string) ?? "") : ""; }, { timeout: 8000 })
73
- .toContain("github.com");
74
-
75
- // Remove it → the row is deleted and the provider stops working (again live).
76
- await admin.put("/api/account/auth-config", { data: { social: { github: { delete: true } } } });
77
- expect("github" in (await (await admin.get("/api/account/auth-config")).json()).social).toBe(false);
78
- await expect.poll(async () => (await github()).status(), { timeout: 8000 }).toBe(404);
57
+ const admin = await adminCtx(playwright);
58
+ // The catalog offers providers beyond the two legacy env-backed ones.
59
+ const catalog = (await (await admin.get("/api/account/auth-config")).json()).catalog as Array<{ id: string }>;
60
+ expect(catalog.map((c) => c.id)).toEqual(expect.arrayContaining(["google", "github", "apple"]));
79
61
 
80
- await anon.dispose();
81
- await admin.dispose();
82
- });
62
+ const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
63
+ const github = () => anon.post("/api/auth/sign-in/social", { data: { provider: "github", callbackURL: `${base}/admin` } });
64
+
65
+ // Add GitHub (a provider with no env fallback) → applies live, no restart.
66
+ await admin.put("/api/account/auth-config", { data: { social: { github: { enabled: true, clientId: "dummy-github-client-id", clientSecret: "dummy-github-client-secret" } } } });
67
+ await expect
68
+ .poll(async () => { const r = await github(); return r.ok() ? (((await r.json())?.url as string) ?? "") : ""; }, { timeout: 8000 })
69
+ .toContain("github.com");
70
+
71
+ // Remove it → the row is deleted and the provider stops working (again live).
72
+ await admin.put("/api/account/auth-config", { data: { social: { github: { delete: true } } } });
73
+ expect("github" in (await (await admin.get("/api/account/auth-config")).json()).social).toBe(false);
74
+ await expect.poll(async () => (await github()).status(), { timeout: 8000 }).toBe(404);
75
+
76
+ await anon.dispose();
77
+ await admin.dispose();
83
78
  });
84
79
 
85
80
  test("auth-config is admin-only and never leaks secrets @smoke", async ({ playwright }) => {
@@ -91,50 +86,47 @@ test("auth-config is admin-only and never leaks secrets @smoke", async ({ playwr
91
86
  await user.dispose();
92
87
  });
93
88
 
94
- test("automatic logout validates and applies a sliding session lifetime", async ({ playwright }) => {
95
- test.slow();
96
- await exclusive("session-idle-timeout", async () => {
97
- const admin = await adminCtx(playwright);
98
- const freshAdmin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
99
-
100
- try {
101
- expect(
102
- (await admin.put("/api/account/auth-config", {
103
- data: { server: { sessionIdleTimeoutMinutes: 4 } },
104
- })).status(),
105
- ).toBe(400);
106
-
107
- const enabled = await admin.put("/api/account/auth-config", {
108
- data: { server: { sessionIdleTimeoutMinutes: 30 } },
109
- });
110
- expect(enabled.ok()).toBeTruthy();
111
- expect((await enabled.json()).server.sessionIdleTimeoutMinutes).toBe(30);
112
-
113
- const caps = await (await admin.get("/api/account/capabilities")).json();
114
- expect(caps.sessionIdleTimeoutMinutes).toBe(30);
115
-
116
- const signIn = await freshAdmin.post("/api/auth/sign-in/email", {
117
- data: { email: ADMIN.email, password: ADMIN.password },
118
- });
119
- expect(signIn.ok()).toBeTruthy();
120
- expect(signIn.headers()["set-cookie"]).toMatch(/Max-Age=1800(?:;|,)/i);
121
-
122
- const sessions = (await (await admin.get("/api/auth/list-sessions")).json()) as Array<{
123
- expiresAt: string;
124
- }>;
125
- const latestExpiry = Math.max(...sessions.map((session) => new Date(session.expiresAt).getTime()));
126
- expect(latestExpiry).toBeLessThanOrEqual(Date.now() + 30 * 60_000 + 10_000);
127
- } finally {
128
- await admin.put("/api/account/auth-config", {
129
- data: { server: { sessionIdleTimeoutMinutes: null } },
130
- });
131
- await freshAdmin.dispose();
132
- await admin.dispose();
133
- }
134
- });
89
+ test("automatic logout validates and applies a sliding session lifetime", { tag: "@exclusive" }, async ({ playwright }) => {
90
+ const admin = await adminCtx(playwright);
91
+ const freshAdmin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
92
+
93
+ try {
94
+ expect(
95
+ (await admin.put("/api/account/auth-config", {
96
+ data: { server: { sessionIdleTimeoutMinutes: 4 } },
97
+ })).status(),
98
+ ).toBe(400);
99
+
100
+ const enabled = await admin.put("/api/account/auth-config", {
101
+ data: { server: { sessionIdleTimeoutMinutes: 30 } },
102
+ });
103
+ expect(enabled.ok()).toBeTruthy();
104
+ expect((await enabled.json()).server.sessionIdleTimeoutMinutes).toBe(30);
105
+
106
+ const caps = await (await admin.get("/api/account/capabilities")).json();
107
+ expect(caps.sessionIdleTimeoutMinutes).toBe(30);
108
+
109
+ const signIn = await freshAdmin.post("/api/auth/sign-in/email", {
110
+ data: { email: ADMIN.email, password: ADMIN.password },
111
+ });
112
+ expect(signIn.ok()).toBeTruthy();
113
+ expect(signIn.headers()["set-cookie"]).toMatch(/Max-Age=1800(?:;|,)/i);
114
+
115
+ const sessions = (await (await admin.get("/api/auth/list-sessions")).json()) as Array<{
116
+ expiresAt: string;
117
+ }>;
118
+ const latestExpiry = Math.max(...sessions.map((session) => new Date(session.expiresAt).getTime()));
119
+ expect(latestExpiry).toBeLessThanOrEqual(Date.now() + 30 * 60_000 + 10_000);
120
+ } finally {
121
+ await admin.put("/api/account/auth-config", {
122
+ data: { server: { sessionIdleTimeoutMinutes: null } },
123
+ });
124
+ await freshAdmin.dispose();
125
+ await admin.dispose();
126
+ }
135
127
  });
136
128
 
137
- test("new registrations require approval when the policy is enabled", async ({ playwright }) => {
129
+ test("new registrations require approval when the policy is enabled", { tag: "@exclusive" }, async ({ playwright }) => {
138
130
  const admin = await adminCtx(playwright);
139
131
  const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
140
132
  const password = "Podokit3e-Str0ng!pw";
@@ -213,79 +205,76 @@ test("new registrations require approval when the policy is enabled", async ({ p
213
205
  }
214
206
  });
215
207
 
216
- test("closed public sign-up blocks new OAuth users but preserves existing sign-in", async ({ playwright }) => {
217
- test.slow();
218
- await exclusive(["site-general", "social-providers"], async () => {
219
- const admin = await adminCtx(playwright);
220
- const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
221
- const password = "Podokit3e-Str0ng!pw";
222
- const adminCreatedEmail = `closed-admin-${Date.now()}@example.com`;
223
- const initialSite = (await (await anon.get("/api/site/settings")).json()) as {
224
- allowSignup?: string | null;
225
- };
226
- const initialAllowSignup = initialSite.allowSignup === "false" ? "false" : "true";
227
- let adminCreatedId = "";
228
-
229
- try {
230
- expect(
231
- (await admin.put("/api/account/auth-config", {
232
- data: {
233
- social: {
234
- google: {
235
- enabled: true,
236
- clientId: "dummy-google-client-id",
237
- clientSecret: "dummy-google-client-secret",
238
- },
208
+ test("closed public sign-up blocks new OAuth users but preserves existing sign-in", { tag: "@exclusive" }, async ({ playwright }) => {
209
+ const admin = await adminCtx(playwright);
210
+ const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
211
+ const password = "Podokit3e-Str0ng!pw";
212
+ const adminCreatedEmail = `closed-admin-${Date.now()}@example.com`;
213
+ const initialSite = (await (await anon.get("/api/site/settings")).json()) as {
214
+ allowSignup?: string | null;
215
+ };
216
+ const initialAllowSignup = initialSite.allowSignup === "false" ? "false" : "true";
217
+ let adminCreatedId = "";
218
+
219
+ try {
220
+ expect(
221
+ (await admin.put("/api/account/auth-config", {
222
+ data: {
223
+ social: {
224
+ google: {
225
+ enabled: true,
226
+ clientId: "dummy-google-client-id",
227
+ clientSecret: "dummy-google-client-secret",
239
228
  },
240
229
  },
241
- })).ok(),
242
- ).toBeTruthy();
243
- expect((await admin.put("/api/site/settings", { data: { allowSignup: "false" } })).ok()).toBeTruthy();
230
+ },
231
+ })).ok(),
232
+ ).toBeTruthy();
233
+ expect((await admin.put("/api/site/settings", { data: { allowSignup: "false" } })).ok()).toBeTruthy();
244
234
 
245
- // The site policy uses a short cache shared by sign-up requests and user
246
- // creation hooks, so wait for the setting to become live.
247
- await new Promise((resolve) => setTimeout(resolve, 3_200));
235
+ // The site policy uses a short cache shared by sign-up requests and user
236
+ // creation hooks, so wait for the setting to become live.
237
+ await new Promise((resolve) => setTimeout(resolve, 3_200));
248
238
 
249
- const signup = await anon.post("/api/auth/sign-up/email", {
250
- data: {
251
- email: `closed-${Date.now()}@example.com`,
252
- password,
253
- name: "Closed Registration",
254
- },
255
- });
256
- expect(signup.status()).toBe(403);
257
- expect((await signup.json()).code).toBe(PUBLIC_SIGNUP_DISABLED);
258
-
259
- // OAuth initiation must stay available for existing social users. Only a
260
- // callback that attempts to create a new user is rejected by the DB hook.
261
- const social = await anon.post("/api/auth/sign-in/social", {
262
- data: { provider: "google", callbackURL: `${base}/` },
263
- });
264
- expect(social.ok()).toBeTruthy();
265
- expect(((await social.json()).url as string)).toContain("accounts.google.com");
266
- expect(isUserCreationAllowed(false, "/callback/google")).toBe(false);
267
- expect(isUserCreationAllowed(false, undefined)).toBe(false);
268
-
269
- // Deliberate administrator provisioning remains available while the public
270
- // site is closed.
271
- expect(isUserCreationAllowed(false, "/admin/create-user")).toBe(true);
272
- const adminCreated = await admin.post("/api/auth/admin/create-user", {
273
- data: { email: adminCreatedEmail, password, name: "Admin Created", role: "user" },
274
- });
275
- expect(adminCreated.ok()).toBeTruthy();
276
- adminCreatedId = ((await adminCreated.json()).user as { id: string }).id;
277
- } finally {
278
- if (adminCreatedId) {
279
- await admin.post("/api/auth/admin/remove-user", { data: { userId: adminCreatedId } });
280
- }
281
- await admin.put("/api/site/settings", { data: { allowSignup: initialAllowSignup } });
282
- await admin.put("/api/account/auth-config", {
283
- data: { social: { google: { enabled: false } } },
284
- });
285
- await new Promise((resolve) => setTimeout(resolve, 3_200));
286
- await anon.get("/api/auth/get-session");
287
- await anon.dispose();
288
- await admin.dispose();
239
+ const signup = await anon.post("/api/auth/sign-up/email", {
240
+ data: {
241
+ email: `closed-${Date.now()}@example.com`,
242
+ password,
243
+ name: "Closed Registration",
244
+ },
245
+ });
246
+ expect(signup.status()).toBe(403);
247
+ expect((await signup.json()).code).toBe(PUBLIC_SIGNUP_DISABLED);
248
+
249
+ // OAuth initiation must stay available for existing social users. Only a
250
+ // callback that attempts to create a new user is rejected by the DB hook.
251
+ const social = await anon.post("/api/auth/sign-in/social", {
252
+ data: { provider: "google", callbackURL: `${base}/` },
253
+ });
254
+ expect(social.ok()).toBeTruthy();
255
+ expect(((await social.json()).url as string)).toContain("accounts.google.com");
256
+ expect(isUserCreationAllowed(false, "/callback/google")).toBe(false);
257
+ expect(isUserCreationAllowed(false, undefined)).toBe(false);
258
+
259
+ // Deliberate administrator provisioning remains available while the public
260
+ // site is closed.
261
+ expect(isUserCreationAllowed(false, "/admin/create-user")).toBe(true);
262
+ const adminCreated = await admin.post("/api/auth/admin/create-user", {
263
+ data: { email: adminCreatedEmail, password, name: "Admin Created", role: "user" },
264
+ });
265
+ expect(adminCreated.ok()).toBeTruthy();
266
+ adminCreatedId = ((await adminCreated.json()).user as { id: string }).id;
267
+ } finally {
268
+ if (adminCreatedId) {
269
+ await admin.post("/api/auth/admin/remove-user", { data: { userId: adminCreatedId } });
289
270
  }
290
- });
271
+ await admin.put("/api/site/settings", { data: { allowSignup: initialAllowSignup } });
272
+ await admin.put("/api/account/auth-config", {
273
+ data: { social: { google: { enabled: false } } },
274
+ });
275
+ await new Promise((resolve) => setTimeout(resolve, 3_200));
276
+ await anon.get("/api/auth/get-session");
277
+ await anon.dispose();
278
+ await admin.dispose();
279
+ }
291
280
  });
@@ -1,8 +1,7 @@
1
1
  import { expect, test } from "@playwright/test";
2
2
  import { createPublicKey, verify, type JsonWebKey } from "node:crypto";
3
3
  import { ADMIN, USER } from "../helpers/accounts";
4
- import { exclusive } from "../helpers/exclusive";
5
- import { clearSms, smsSinkReachable, waitForSmsOtp } from "../helpers/sms";
4
+ import { smsSinkReachable, waitForSmsOtp } from "../helpers/sms";
6
5
  import { totpCode } from "../helpers/totp";
7
6
 
8
7
  const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
@@ -32,7 +31,6 @@ test("phone number: verify with the code delivered to the SMS sink", async ({ pl
32
31
  const caps = await (await ctx.get("/api/account/capabilities")).json();
33
32
  test.skip(!caps?.phoneNumber, "phone number not enabled");
34
33
  test.skip(!(await smsSinkReachable()), "SMS sink not reachable");
35
- await clearSms();
36
34
  const email = `phone-${Date.now()}@example.com`;
37
35
  await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "Phone" } });
38
36
  // Unique number per run so the "phone already exists" guard never trips.
@@ -144,50 +142,48 @@ test("two-factor: regenerating backup codes invalidates the old set", async ({ p
144
142
  await ctxNew.dispose();
145
143
  });
146
144
 
147
- test("require-2fa: gates an un-enrolled user until they enrol", async ({ playwright }) => {
145
+ test("require-2fa: gates an un-enrolled user until they enrol", { tag: "@exclusive" }, async ({ playwright }) => {
148
146
  test.slow();
149
- await exclusive("require2fa", async () => {
150
- const admin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
151
- const caps = await (await admin.get("/api/account/capabilities")).json();
152
- test.skip(!caps?.twoFactor, "two-factor not enabled");
153
- await admin.post("/api/auth/sign-in/email", { data: { email: ADMIN.email, password: ADMIN.password } });
154
-
155
- const user = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
156
- const email = `rq-${Date.now()}@example.com`;
157
- const pw = "Podokit3e-Str0ng!pw";
158
- await user.post("/api/auth/sign-up/email", { data: { email, password: pw, name: "RQ" } });
159
-
160
- try {
161
- // Turn the policy on (admin; the toggle endpoint is allow-listed by the guard).
162
- await admin.put("/api/account/settings", { data: { require2fa: true } });
163
- // Capabilities carry the same policy as the dedicated endpoint.
164
- await expect(async () => {
165
- expect((await (await admin.get("/api/account/capabilities")).json()).require2fa).toBe(true);
166
- expect(await (await admin.get("/api/account/require-2fa")).json()).toEqual({ require2fa: true });
167
- }).toPass({ timeout: 8000 });
168
- // The guard caches for a few seconds — poll until the un-enrolled user is gated.
169
- await expect(async () => {
170
- expect((await user.get("/api/account/me")).status()).toBe(403);
171
- }).toPass({ timeout: 8000 });
172
-
173
- // Enrolling restores access.
174
- const enable = await (await user.post("/api/auth/two-factor/enable", { data: { password: pw } })).json();
175
- await user.post("/api/auth/two-factor/verify-totp", { data: { code: totpCode(enable.totpURI) } });
176
- expect((await user.get("/api/account/me")).status()).toBe(200);
177
- } finally {
178
- // Always turn it back off so the shared (un-enrolled) sessions aren't gated.
179
- await admin.put("/api/account/settings", { data: { require2fa: false } });
180
- await expect(async () => {
181
- const probe = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
182
- await probe.post("/api/auth/sign-in/email", { data: { email: USER.email, password: USER.password } });
183
- const status = (await probe.get("/api/account/me")).status();
184
- await probe.dispose();
185
- expect(status).toBe(200);
186
- }).toPass({ timeout: 8000 });
187
- await admin.dispose();
188
- await user.dispose();
189
- }
190
- });
147
+ const admin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
148
+ const caps = await (await admin.get("/api/account/capabilities")).json();
149
+ test.skip(!caps?.twoFactor, "two-factor not enabled");
150
+ await admin.post("/api/auth/sign-in/email", { data: { email: ADMIN.email, password: ADMIN.password } });
151
+
152
+ const user = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
153
+ const email = `rq-${Date.now()}@example.com`;
154
+ const pw = "Podokit3e-Str0ng!pw";
155
+ await user.post("/api/auth/sign-up/email", { data: { email, password: pw, name: "RQ" } });
156
+
157
+ try {
158
+ // Turn the policy on (admin; the toggle endpoint is allow-listed by the guard).
159
+ await admin.put("/api/account/settings", { data: { require2fa: true } });
160
+ // Capabilities carry the same policy as the dedicated endpoint.
161
+ await expect(async () => {
162
+ expect((await (await admin.get("/api/account/capabilities")).json()).require2fa).toBe(true);
163
+ expect(await (await admin.get("/api/account/require-2fa")).json()).toEqual({ require2fa: true });
164
+ }).toPass({ timeout: 8000 });
165
+ // The guard caches for a few seconds — poll until the un-enrolled user is gated.
166
+ await expect(async () => {
167
+ expect((await user.get("/api/account/me")).status()).toBe(403);
168
+ }).toPass({ timeout: 8000 });
169
+
170
+ // Enrolling restores access.
171
+ const enable = await (await user.post("/api/auth/two-factor/enable", { data: { password: pw } })).json();
172
+ await user.post("/api/auth/two-factor/verify-totp", { data: { code: totpCode(enable.totpURI) } });
173
+ expect((await user.get("/api/account/me")).status()).toBe(200);
174
+ } finally {
175
+ // Always turn it back off so the shared (un-enrolled) sessions aren't gated.
176
+ await admin.put("/api/account/settings", { data: { require2fa: false } });
177
+ await expect(async () => {
178
+ const probe = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
179
+ await probe.post("/api/auth/sign-in/email", { data: { email: USER.email, password: USER.password } });
180
+ const status = (await probe.get("/api/account/me")).status();
181
+ await probe.dispose();
182
+ expect(status).toBe(200);
183
+ }).toPass({ timeout: 8000 });
184
+ await admin.dispose();
185
+ await user.dispose();
186
+ }
191
187
  });
192
188
 
193
189
  test("multi-session holds several accounts and switches between them", async ({ playwright }) => {
@@ -384,10 +380,9 @@ test("an organization can have a parent and manager members", async ({ playwrigh
384
380
  await ctx.dispose();
385
381
  });
386
382
 
387
- test("the OIDC provider's discovery and endpoints follow its setting", async ({ playwright }) => {
383
+ test("the OIDC provider's discovery and endpoints follow its setting", { tag: "@exclusive" }, async ({ playwright }) => {
388
384
  // The provider's metadata is answered before endpoint hooks run, so it needs its
389
- // own check that "off" hides it. The lock keeps tests/ui/oidc.ui.spec.ts, which
390
- // also toggles the provider, from running at the same time.
385
+ // own check that "off" hides it.
391
386
  test.slow();
392
387
  const admin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
393
388
  await admin.post("/api/auth/sign-in/email", { data: { email: ADMIN.email, password: ADMIN.password } });
@@ -399,22 +394,20 @@ test("the OIDC provider's discovery and endpoints follow its setting", async ({
399
394
  const allStatuses = (): Promise<number[]> =>
400
395
  Promise.all([...discovery, "/api/auth/oauth2/authorize?client_id=probe"].map(status));
401
396
 
402
- await exclusive("oidc-provider", async () => {
403
- try {
404
- expect((await admin.put("/api/account/settings", { data: { oidcProvider: false } })).ok()).toBeTruthy();
405
- await expect.poll(allStatuses, { timeout: 8000 }).toEqual([404, 404, 404]);
406
- // The jwt plugin's keys and session tokens are not part of the provider.
407
- expect(await status("/api/auth/jwks")).toBe(200);
408
-
409
- expect((await admin.put("/api/account/settings", { data: { oidcProvider: true } })).ok()).toBeTruthy();
410
- await expect.poll(() => Promise.all(discovery.map(status)), { timeout: 8000 }).toEqual([200, 200]);
411
- const metadata = await (await admin.get(discovery[0]!)).json();
412
- expect(metadata.authorization_endpoint).toContain("/api/auth/oauth2/authorize");
413
- } finally {
414
- expect((await admin.put("/api/account/settings", { data: { oidcProvider: false } })).ok()).toBeTruthy();
415
- }
397
+ try {
398
+ expect((await admin.put("/api/account/settings", { data: { oidcProvider: false } })).ok()).toBeTruthy();
416
399
  await expect.poll(allStatuses, { timeout: 8000 }).toEqual([404, 404, 404]);
417
- });
400
+ // The jwt plugin's keys and session tokens are not part of the provider.
401
+ expect(await status("/api/auth/jwks")).toBe(200);
402
+
403
+ expect((await admin.put("/api/account/settings", { data: { oidcProvider: true } })).ok()).toBeTruthy();
404
+ await expect.poll(() => Promise.all(discovery.map(status)), { timeout: 8000 }).toEqual([200, 200]);
405
+ const metadata = await (await admin.get(discovery[0]!)).json();
406
+ expect(metadata.authorization_endpoint).toContain("/api/auth/oauth2/authorize");
407
+ } finally {
408
+ expect((await admin.put("/api/account/settings", { data: { oidcProvider: false } })).ok()).toBeTruthy();
409
+ }
410
+ await expect.poll(allStatuses, { timeout: 8000 }).toEqual([404, 404, 404]);
418
411
  await admin.dispose();
419
412
  });
420
413
 
@@ -426,28 +419,26 @@ test("only admins can change settings @smoke", async ({ playwright }) => {
426
419
  await userCtx.dispose();
427
420
  });
428
421
 
429
- test("disabling a feature blocks its endpoint server-side and re-enabling restores it @smoke", async ({ playwright }) => {
422
+ test("disabling a feature blocks its endpoint server-side and re-enabling restores it @smoke", { tag: "@exclusive" }, async ({ playwright }) => {
430
423
  test.slow();
431
- await exclusive("magic-link", async () => {
432
- // Uses a fresh admin context (not the shared storageState) so it never rotates
433
- // the seeded session. Toggles a flag and confirms the feature gate enforces it.
434
- const admin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
435
- await admin.post("/api/auth/sign-in/email", { data: { email: ADMIN.email, password: ADMIN.password } });
436
- const caps = await (await admin.get("/api/account/capabilities")).json();
437
- test.skip(!caps?.magicLink, "magic link not enabled");
424
+ // Uses a fresh admin context (not the shared storageState) so it never rotates
425
+ // the seeded session. Toggles a flag and confirms the feature gate enforces it.
426
+ const admin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
427
+ await admin.post("/api/auth/sign-in/email", { data: { email: ADMIN.email, password: ADMIN.password } });
428
+ const caps = await (await admin.get("/api/account/capabilities")).json();
429
+ test.skip(!caps?.magicLink, "magic link not enabled");
438
430
 
439
- // A disabled feature's endpoint 404s; the gate reads the flag through a short cache.
440
- const status = (): Promise<number> =>
441
- admin.post("/api/auth/sign-in/magic-link", { data: { email: "probe@example.com" } }).then((r) => r.status());
431
+ // A disabled feature's endpoint 404s; the gate reads the flag through a short cache.
432
+ const status = (): Promise<number> =>
433
+ admin.post("/api/auth/sign-in/magic-link", { data: { email: "probe@example.com" } }).then((r) => r.status());
442
434
 
443
- expect((await admin.put("/api/account/settings", { data: { magicLink: false } })).ok()).toBeTruthy();
444
- await expect.poll(status, { timeout: 8000 }).toBe(404);
435
+ expect((await admin.put("/api/account/settings", { data: { magicLink: false } })).ok()).toBeTruthy();
436
+ await expect.poll(status, { timeout: 8000 }).toBe(404);
445
437
 
446
- expect((await admin.put("/api/account/settings", { data: { magicLink: true } })).ok()).toBeTruthy();
447
- await expect.poll(status, { timeout: 8000 }).not.toBe(404);
438
+ expect((await admin.put("/api/account/settings", { data: { magicLink: true } })).ok()).toBeTruthy();
439
+ await expect.poll(status, { timeout: 8000 }).not.toBe(404);
448
440
 
449
- await admin.dispose();
450
- });
441
+ await admin.dispose();
451
442
  });
452
443
 
453
444
  test("sign-in with a wrong password is rejected", async ({ request }) => {
@@ -1,5 +1,5 @@
1
1
  import { expect, test } from "@playwright/test";
2
- import { clearMailpit, mailpitReachable, waitForLink, waitForOtp } from "../helpers/mailpit";
2
+ import { mailpitReachable, waitForLink, waitForOtp } from "../helpers/mailpit";
3
3
 
4
4
  const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
5
5
  const origin = { origin: base };
@@ -11,7 +11,6 @@ test("email one-time code signs a user in", async ({ playwright }) => {
11
11
  test.skip(!caps?.emailOtp, "email OTP not enabled");
12
12
  const email = `otp-${Date.now()}@example.com`;
13
13
  await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "Otp" } });
14
- await clearMailpit();
15
14
  const requested = await ctx.post("/api/auth/email-otp/send-verification-otp", { data: { email, type: "sign-in" } });
16
15
  expect(requested.ok()).toBeTruthy();
17
16
  const otp = await waitForOtp(email);
@@ -58,7 +57,6 @@ test("magic link signs a user in", async ({ playwright }) => {
58
57
  // rotate the account's sessions, which would invalidate the seeded storageState.
59
58
  const email = `magic-${Date.now()}@example.com`;
60
59
  await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "Magic" } });
61
- await clearMailpit();
62
60
  const requested = await ctx.post("/api/auth/sign-in/magic-link", { data: { email, callbackURL: `${base}/admin` } });
63
61
  expect(requested.ok()).toBeTruthy();
64
62
  const link = await waitForLink(email);
@@ -84,8 +82,6 @@ test("password reset delivers an email and updates the password @smoke", async (
84
82
  // With verification on, sign-up returns no session and the new password still
85
83
  // can't sign in until confirmed — so only assert sign-in when it's off.
86
84
  const verificationOn = !(await signup.json())?.token;
87
-
88
- await clearMailpit();
89
85
  const requested = await ctx.post("/api/auth/request-password-reset", { data: { email, redirectTo: `${base}/reset-password` } });
90
86
  expect(requested.ok()).toBeTruthy();
91
87
 
@@ -106,7 +102,6 @@ test("email verification blocks sign-in until the address is confirmed", async (
106
102
  test.skip(!(await mailpitReachable()), "Mailpit not available");
107
103
  const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
108
104
  const email = `verify-${Date.now()}@example.com`;
109
- await clearMailpit();
110
105
  const signup = await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "Verify", callbackURL: `${base}/admin` } });
111
106
  expect(signup.ok()).toBeTruthy();
112
107
  // When verification is off, sign-up returns a session — nothing to test here.
@@ -30,7 +30,7 @@ test("profile image upload requires authentication", async ({ playwright }) => {
30
30
  await anonymous.dispose();
31
31
  });
32
32
 
33
- test("profile image upload, replacement, public read, and removal", async ({ playwright }) => {
33
+ test("profile image upload, replacement, public read, and removal", { tag: "@exclusive" }, async ({ playwright }) => {
34
34
  const admin = await adminContext(playwright);
35
35
  await admin.delete("/api/account/profile-image");
36
36