@podosoft/podokit 1.4.2 → 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 (245) 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 -17
  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 +28 -20
  18. package/dist/deploy-schema.d.ts +61 -0
  19. package/dist/deploy-schema.js +157 -1
  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 +77 -5
  30. package/dist/provider.d.ts +1 -0
  31. package/dist/provider.js +12 -4
  32. package/dist/remove.js +1 -1
  33. package/dist/template-files.d.ts +42 -0
  34. package/dist/template-files.js +102 -0
  35. package/dist/templates/fullstack/AGENTS.md +44 -7
  36. package/dist/templates/fullstack/README.md +8 -7
  37. package/dist/templates/fullstack/apps/api/package.json +4 -4
  38. package/dist/templates/fullstack/apps/api/scripts/build.extensions.mjs +6 -0
  39. package/dist/templates/fullstack/apps/api/scripts/build.mjs +65 -16
  40. package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +7 -3
  41. package/dist/templates/fullstack/apps/api/scripts/verify-api-contract.ts +5 -1
  42. package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -4
  43. package/dist/templates/fullstack/apps/api/src/app.spec.ts +22 -0
  44. package/dist/templates/fullstack/apps/api/src/app.ts +47 -28
  45. package/dist/templates/fullstack/apps/api/src/build.spec.ts +118 -0
  46. package/dist/templates/fullstack/apps/api/src/config/env.extensions.ts +20 -0
  47. package/dist/templates/fullstack/apps/api/src/config/env.validation.spec.ts +76 -0
  48. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +46 -3
  49. package/dist/templates/fullstack/apps/api/src/config/provider-selection.spec.ts +53 -0
  50. package/dist/templates/fullstack/apps/api/src/config/provider-selection.ts +79 -0
  51. package/dist/templates/fullstack/apps/api/src/config/providers.ts +8 -2
  52. package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +17 -6
  53. package/dist/templates/fullstack/apps/api/src/config/server-options.ts +5 -8
  54. package/dist/templates/fullstack/apps/api/src/core/api-contract.extensions.ts +6 -0
  55. package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +29 -0
  56. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +39 -2
  57. package/dist/templates/fullstack/apps/api/src/core/client-address.spec.ts +82 -0
  58. package/dist/templates/fullstack/apps/api/src/core/client-address.ts +68 -0
  59. package/dist/templates/fullstack/apps/api/src/core/services.ts +43 -0
  60. package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +27 -0
  61. package/dist/templates/fullstack/apps/api/src/database/data-source.ts +7 -10
  62. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +12 -0
  63. package/dist/templates/fullstack/apps/api/src/database/database.ts +2 -3
  64. package/dist/templates/fullstack/apps/api/src/database/entities.ts +12 -0
  65. package/dist/templates/fullstack/apps/api/src/database/migrate.ts +12 -2
  66. package/dist/templates/fullstack/apps/api/src/database/migration-files.spec.ts +116 -0
  67. package/dist/templates/fullstack/apps/api/src/database/migration-files.ts +134 -0
  68. package/dist/templates/fullstack/apps/api/src/database/migration-readiness.spec.ts +36 -0
  69. package/dist/templates/fullstack/apps/api/src/database/migration-readiness.ts +25 -0
  70. package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +12 -1
  71. package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.ts +2 -36
  72. package/dist/templates/fullstack/apps/api/src/main.ts +2 -2
  73. package/dist/templates/fullstack/apps/api/tsconfig.json +2 -0
  74. package/dist/templates/fullstack/apps/web/Dockerfile +4 -0
  75. package/dist/templates/fullstack/apps/web/package.json +1 -1
  76. package/dist/templates/fullstack/apps/web/src/app.css +3 -0
  77. package/dist/templates/fullstack/apps/web/src/app.extensions.css +11 -0
  78. package/dist/templates/fullstack/apps/web/src/lib/components/site-runtime.svelte +3 -1
  79. package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +19 -0
  80. package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +4 -1
  81. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +370 -36
  82. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +250 -41
  83. package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +5 -2
  84. package/dist/templates/fullstack/apps/web/src/lib/server/proxy-timeouts.ts +23 -0
  85. package/dist/templates/fullstack/apps/web/tsconfig.json +1 -1
  86. package/dist/templates/fullstack/apps/web/vite.config.test.ts +33 -0
  87. package/dist/templates/fullstack/apps/web/vite.config.ts +55 -13
  88. package/dist/templates/fullstack/apps/web/vite.extensions.ts +23 -0
  89. package/dist/templates/fullstack/compose.dev.yaml +7 -4
  90. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/SKILL.md +2 -2
  91. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +12 -3
  92. package/dist/templates/fullstack/dot-claude/skills/podokit-update/SKILL.md +5 -2
  93. package/dist/templates/fullstack/dot-env.docker.example +7 -2
  94. package/dist/templates/fullstack/dot-env.example +21 -1
  95. package/dist/templates/fullstack/dot-podokit/dev.json +1 -1
  96. package/dist/templates/fullstack/infra/k3s/configmap.yaml +4 -0
  97. package/dist/templates/fullstack/tests/README.md +40 -1
  98. package/dist/templates/fullstack/tests/helpers/app-paths.ts +18 -0
  99. package/dist/templates/fullstack/tests/helpers/paths.ts +33 -0
  100. package/dist/templates/fullstack/tests/package.json +2 -2
  101. package/dist/templates/fullstack/tests/playwright.config.ts +7 -3
  102. package/dist/templates/fullstack/tests/playwright.extensions.ts +88 -1
  103. package/dist/templates/fullstack/tests/ui/smoke.ui.spec.ts +5 -4
  104. package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.spec.ts +152 -0
  105. package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.ts +112 -0
  106. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +24 -0
  107. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +18 -0
  108. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +36 -13
  109. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +13 -1
  110. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +37 -1
  111. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +15 -6
  112. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.test.ts +33 -0
  113. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.ts +34 -0
  114. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.test.ts +31 -0
  115. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.ts +31 -0
  116. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flags.ts +12 -0
  117. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-nav.ts +11 -0
  118. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.test.ts +39 -0
  119. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.ts +35 -0
  120. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/registry.svelte.ts +15 -0
  121. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +73 -39
  122. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/admin-sidebar.svelte +2 -23
  123. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +7 -2
  124. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.test.ts +30 -0
  125. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/impersonation-banner.svelte +3 -2
  126. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/settings/app-flags-settings.svelte +65 -0
  127. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +32 -23
  128. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.test.ts +27 -0
  129. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-footer.svelte +4 -1
  130. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +3 -1
  131. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-toolbar.svelte +3 -1
  132. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -0
  133. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -0
  134. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/app-hooks.ts +11 -0
  135. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/non-indexable-paths.ts +8 -0
  136. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.test.ts +31 -0
  137. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.ts +5 -1
  138. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +1 -1
  139. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.server.ts +2 -1
  140. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.svelte +1 -5
  141. package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -0
  142. package/dist/templates/modules/admin-dashboard/files/tests/api/account-sessions.api.spec.ts +55 -0
  143. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +1 -1
  144. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +159 -170
  145. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +72 -81
  146. package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +1 -6
  147. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +1 -1
  148. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +43 -53
  149. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +29 -1
  150. package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +24 -6
  151. package/dist/templates/modules/admin-dashboard/files/tests/helpers/app-accounts.ts +24 -0
  152. package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +55 -0
  153. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +5 -3
  154. package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +3 -9
  155. package/dist/templates/modules/admin-dashboard/files/tests/helpers/sms.ts +2 -4
  156. package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +11 -5
  157. package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +3 -10
  158. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +4 -5
  159. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-sessions.ui.spec.ts +46 -0
  160. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +62 -59
  161. package/dist/templates/modules/admin-dashboard/files/tests/ui/app-flags.ui.spec.ts +63 -0
  162. package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +12 -16
  163. package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +3 -2
  164. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +32 -35
  165. package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +4 -2
  166. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +1 -2
  167. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -2
  168. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +82 -91
  169. package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +6 -2
  170. package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +16 -20
  171. package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +6 -3
  172. package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -3
  173. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +2 -4
  174. package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +5 -2
  175. package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +7 -2
  176. package/dist/templates/modules/admin-dashboard/files/tests/ui/same-origin-requests.ui.spec.ts +89 -0
  177. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +5 -4
  178. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +67 -73
  179. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +84 -93
  180. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +33 -7
  181. package/dist/templates/modules/admin-dashboard/files/tests/ui/theme-mode.ui.spec.ts +29 -0
  182. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +51 -54
  183. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +10 -9
  184. package/dist/templates/modules/admin-dashboard/module.manifest.json +7 -2
  185. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -1
  186. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +5 -1
  187. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +33 -0
  188. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +20 -1
  189. package/dist/templates/modules/audit-log/module.manifest.json +15 -3
  190. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-app-name.spec.ts +21 -0
  191. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +13 -1
  192. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-environment.ts +9 -2
  193. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +19 -3
  194. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +6 -1
  195. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.runtime.spec.ts +10 -0
  196. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +54 -6
  197. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -4
  198. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +15 -8
  199. package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.spec.ts +56 -0
  200. package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.ts +45 -0
  201. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.spec.ts +86 -0
  202. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.ts +44 -0
  203. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +2 -1
  204. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -1
  205. package/dist/templates/modules/auth/files/apps/api/src/settings/app-flags.ts +9 -0
  206. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.spec.ts +43 -0
  207. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +30 -5
  208. package/dist/templates/modules/auth/module.manifest.json +8 -2
  209. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +57 -1
  210. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +93 -9
  211. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.spec.ts +40 -0
  212. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.ts +52 -5
  213. package/dist/templates/modules/bullmq/files/apps/api/src/main-worker.ts +9 -7
  214. package/dist/templates/modules/bullmq/files/apps/api/src/worker.extensions.ts +13 -0
  215. package/dist/templates/modules/bullmq/module.manifest.json +5 -1
  216. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +2 -0
  217. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +87 -1
  218. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +93 -3
  219. package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
  220. package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.spec.ts +16 -0
  221. package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.ts +15 -10
  222. package/dist/templates/modules/mailer/module.manifest.json +1 -1
  223. package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +1 -1
  224. package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +5 -4
  225. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +1 -1
  226. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +2 -59
  227. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +0 -65
  228. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +82 -21
  229. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +54 -15
  230. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +43 -5
  231. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +10 -2
  232. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +61 -3
  233. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +48 -15
  234. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +63 -27
  235. package/dist/templates/modules/rate-limit/module.manifest.json +6 -9
  236. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +1 -1
  237. package/dist/templates/todo/AGENTS.md +46 -6
  238. package/dist/templates/todo/README.md +6 -4
  239. package/dist/text-diff.d.ts +12 -0
  240. package/dist/text-diff.js +111 -0
  241. package/dist/update.d.ts +61 -3
  242. package/dist/update.js +152 -27
  243. package/package.json +2 -2
  244. package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +0 -75
  245. package/dist/templates/modules/admin-dashboard/files/tests/helpers/exclusive.ts +0 -80
@@ -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
 
@@ -1,7 +1,6 @@
1
1
  import { expect, test } from "@playwright/test";
2
2
  import { ADMIN } from "../helpers/accounts";
3
3
  import { expectObjectLength } from "../helpers/object-response";
4
- import { exclusive } from "../helpers/exclusive";
5
4
 
6
5
  const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
7
6
  const origin = { origin: base };
@@ -18,36 +17,30 @@ async function adminCtx(playwright: import("@playwright/test").PlaywrightWorkerA
18
17
  return ctx;
19
18
  }
20
19
 
21
- test("theme settings: valid preset/radius/accent round-trip", async ({ playwright }) => {
22
- test.slow();
23
- await exclusive("site-theme", async () => {
24
- const admin = await adminCtx(playwright);
25
- const put = await admin.put("/api/site/settings", {
26
- data: { themePreset: "slate", themeRadius: "0.75", brandColor: "#2563eb" },
27
- });
28
- expect(put.ok()).toBeTruthy();
20
+ test("theme settings: valid preset/radius/accent round-trip", { tag: "@exclusive" }, async ({ playwright }) => {
21
+ const admin = await adminCtx(playwright);
22
+ const put = await admin.put("/api/site/settings", {
23
+ data: { themePreset: "slate", themeRadius: "0.75", brandColor: "#2563eb" },
24
+ });
25
+ expect(put.ok()).toBeTruthy();
29
26
 
30
- const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
31
- const settings = await (await anon.get("/api/site/settings")).json();
32
- expect(settings.themePreset).toBe("slate");
33
- expect(settings.themeRadius).toBe("0.75");
34
- expect(settings.brandColor).toBe("#2563eb");
27
+ const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
28
+ const settings = await (await anon.get("/api/site/settings")).json();
29
+ expect(settings.themePreset).toBe("slate");
30
+ expect(settings.themeRadius).toBe("0.75");
31
+ expect(settings.brandColor).toBe("#2563eb");
35
32
 
36
- // restore
37
- await admin.put("/api/site/settings", { data: { themePreset: "", themeRadius: "", brandColor: "" } });
38
- await anon.dispose();
39
- });
33
+ // restore
34
+ await admin.put("/api/site/settings", { data: { themePreset: "", themeRadius: "", brandColor: "" } });
35
+ await anon.dispose();
40
36
  });
41
37
 
42
- test("theme settings: valid per-token overrides accepted", async ({ playwright }) => {
43
- test.slow();
44
- await exclusive("site-theme", async () => {
45
- const admin = await adminCtx(playwright);
46
- const overrides = JSON.stringify({ light: { primary: "#123456" }, dark: { background: "#0a0a0a" } });
47
- const put = await admin.put("/api/site/settings", { data: { themeOverrides: overrides } });
48
- expect(put.ok()).toBeTruthy();
49
- await admin.put("/api/site/settings", { data: { themeOverrides: "" } }); // restore
50
- });
38
+ test("theme settings: valid per-token overrides accepted", { tag: "@exclusive" }, async ({ playwright }) => {
39
+ const admin = await adminCtx(playwright);
40
+ const overrides = JSON.stringify({ light: { primary: "#123456" }, dark: { background: "#0a0a0a" } });
41
+ const put = await admin.put("/api/site/settings", { data: { themeOverrides: overrides } });
42
+ expect(put.ok()).toBeTruthy();
43
+ await admin.put("/api/site/settings", { data: { themeOverrides: "" } }); // restore
51
44
  });
52
45
 
53
46
  test("theme settings: invalid values are rejected (no CSS injection)", async ({ playwright }) => {
@@ -68,36 +61,33 @@ test("theme settings: invalid values are rejected (no CSS injection)", async ({
68
61
  }
69
62
  });
70
63
 
71
- test("site settings: one response shape and immediate reads after each write", async ({ playwright }) => {
72
- test.slow();
73
- await exclusive("site-general", async () => {
74
- const admin = await adminCtx(playwright);
75
- const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
76
- const before = await (await anon.get("/api/site/settings")).json() as Record<string, unknown>;
77
- expect(Object.keys(before)).toEqual([
78
- "name", "description", "supportEmail", "footerText", "brandColor", "themePreset", "themeRadius",
79
- "themeOverrides", "termsUrl", "privacyUrl", "locale", "timezone", "maintenanceMode", "allowSignup",
80
- "hasFavicon", "faviconVersion",
81
- ]);
82
- expect(typeof before.hasFavicon).toBe("boolean");
83
- expect(before.faviconVersion === null || typeof before.faviconVersion === "string").toBe(true);
64
+ test("site settings: one response shape and immediate reads after each write", { tag: "@exclusive" }, async ({ playwright }) => {
65
+ const admin = await adminCtx(playwright);
66
+ const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
67
+ const before = await (await anon.get("/api/site/settings")).json() as Record<string, unknown>;
68
+ expect(Object.keys(before)).toEqual([
69
+ "name", "description", "supportEmail", "footerText", "brandColor", "themePreset", "themeRadius",
70
+ "themeOverrides", "termsUrl", "privacyUrl", "locale", "timezone", "maintenanceMode", "allowSignup",
71
+ "hasFavicon", "faviconVersion",
72
+ ]);
73
+ expect(typeof before.hasFavicon).toBe("boolean");
74
+ expect(before.faviconVersion === null || typeof before.faviconVersion === "string").toBe(true);
84
75
 
85
- try {
86
- // The second write lands while the first read is still cached; it must not be hidden.
87
- for (const footerText of ["first footer", "second footer"]) {
88
- expect((await admin.put("/api/site/settings", { data: { footerText } })).ok()).toBeTruthy();
89
- expect((await (await anon.get("/api/site/settings")).json()).footerText).toBe(footerText);
90
- }
91
- } finally {
92
- const footerText = typeof before.footerText === "string" ? before.footerText : "";
93
- await admin.put("/api/site/settings", { data: { footerText } });
94
- await anon.dispose();
95
- await admin.dispose();
76
+ try {
77
+ // The second write lands while the first read is still cached; it must not be hidden.
78
+ for (const footerText of ["first footer", "second footer"]) {
79
+ expect((await admin.put("/api/site/settings", { data: { footerText } })).ok()).toBeTruthy();
80
+ expect((await (await anon.get("/api/site/settings")).json()).footerText).toBe(footerText);
96
81
  }
97
- });
82
+ } finally {
83
+ const footerText = typeof before.footerText === "string" ? before.footerText : "";
84
+ await admin.put("/api/site/settings", { data: { footerText } });
85
+ await anon.dispose();
86
+ await admin.dispose();
87
+ }
98
88
  });
99
89
 
100
- test("site favicon: the versioned URL is cached for good and other URLs revalidate", async ({ playwright }) => {
90
+ test("site favicon: the versioned URL is cached for good and other URLs revalidate", { tag: "@exclusive" }, async ({ playwright }) => {
101
91
  const admin = await adminCtx(playwright);
102
92
  const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
103
93
  try {
@@ -1,5 +1,6 @@
1
1
  import { expect, test } from "../helpers/disposable-users";
2
2
  import { ADMIN, USER } from "../helpers/accounts";
3
+ import { signInOnSeparateDevices } from "../helpers/devices";
3
4
 
4
5
  const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
5
6
  const origin = { origin: base };
@@ -41,11 +42,13 @@ test("admin can create a user, set its password, and remove it", async ({ playwr
41
42
  await admin.dispose();
42
43
  });
43
44
 
44
- test("a normal user cannot create users (403)", async ({ playwright }) => {
45
+ test("a normal user cannot create users (403)", async ({ playwright, disposableUsers }) => {
45
46
  const user = await signedIn(playwright, USER);
46
47
  const res = await user.post("/api/auth/admin/create-user", {
47
48
  data: { email: `nope-${Date.now()}@example.com`, password: "Podokit3e-Str0ng!pw", name: "Nope", role: "user" },
48
49
  });
50
+ // Remove the account if the request wrongly succeeded, before failing on it.
51
+ if (res.ok()) await disposableUsers.trackResponse(res);
49
52
  expect(res.status()).toBe(403);
50
53
  await user.dispose();
51
54
  });
@@ -125,6 +128,31 @@ test("revokeOtherSessions keeps the current session valid", async ({ playwright,
125
128
  await ctx.dispose();
126
129
  });
127
130
 
131
+ test("revokeOtherSessions removes every other session, past Better Auth's page of 100", async ({
132
+ playwright,
133
+ disposableUsers,
134
+ }) => {
135
+ test.slow();
136
+ const email = `api-revoke-many-${Date.now()}@example.com`;
137
+ const password = "Podokit3e-Str0ng!pw";
138
+ await disposableUsers.create({ email, password, name: "Many sessions" });
139
+
140
+ const statuses = await signInOnSeparateDevices(email, password, 105);
141
+ test.skip(statuses.includes(429), "needs an authentication rate limit above 105 sign-ins per window");
142
+ expect(statuses.every((status) => status === 200)).toBe(true);
143
+
144
+ const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
145
+ expect((await ctx.post("/api/auth/sign-in/email", { data: { email, password } })).ok()).toBeTruthy();
146
+ // Better Auth lists at most 100 sessions, so a full page shows the backlog.
147
+ expect(((await (await ctx.get("/api/auth/list-sessions")).json()) as unknown[]).length).toBe(100);
148
+
149
+ expect((await ctx.post("/api/auth/revoke-other-sessions")).ok()).toBeTruthy();
150
+ const left = (await (await ctx.get("/api/auth/list-sessions")).json()) as Array<{ token?: string }>;
151
+ expect(left).toHaveLength(1);
152
+ expect((await (await ctx.get("/api/auth/get-session")).json())?.user?.email).toBe(email);
153
+ await ctx.dispose();
154
+ });
155
+
128
156
  test("sessions record a client IP (forwarded through the proxy)", async ({ playwright }) => {
129
157
  const ctx = await signedIn(playwright, ADMIN);
130
158
  const res = await ctx.get("/api/auth/list-sessions");
@@ -1,7 +1,18 @@
1
+ import { appAccounts } from "./app-accounts";
2
+
1
3
  export type Account = { name: string; email: string; password: string };
2
4
 
5
+ const DEFAULT_PASSWORD = "Podokit3e-Str0ng!pw";
6
+
7
+ function fromEnv(name: string): string | undefined {
8
+ const value = process.env[name];
9
+ return value !== undefined && value.length > 0 ? value : undefined;
10
+ }
11
+
3
12
  /**
4
- * The password the suite signs in with, per account.
13
+ * The seeded accounts the suite signs in as. Each value comes from, in order,
14
+ * an `E2E_*` environment variable, the application-owned `app-accounts.ts`, and
15
+ * the generated default.
5
16
  *
6
17
  * A test must never change an existing account's credentials. `seed.setup.ts` signs up
7
18
  * idempotently and then signs in with these values, which is right on a stack the suite
@@ -19,13 +30,20 @@ export type Account = { name: string; email: string; password: string };
19
30
  * Without the override the run fails with `INVALID_EMAIL_OR_PASSWORD`, which is the correct
20
31
  * outcome — a refusal, not a silent overwrite. Do not commit a real value here.
21
32
  */
22
- function seedPassword(envVar: "E2E_ADMIN_PASSWORD" | "E2E_USER_PASSWORD"): string {
23
- const value = process.env[envVar];
24
- return value !== undefined && value.length > 0 ? value : "Podokit3e-Str0ng!pw";
33
+ function seededAccount(
34
+ role: "ADMIN" | "USER",
35
+ configured: Partial<Account> | undefined,
36
+ defaults: Pick<Account, "name" | "email">,
37
+ ): Account {
38
+ return {
39
+ name: configured?.name ?? defaults.name,
40
+ email: fromEnv(`E2E_${role}_EMAIL`) ?? configured?.email ?? defaults.email,
41
+ password: fromEnv(`E2E_${role}_PASSWORD`) ?? configured?.password ?? DEFAULT_PASSWORD,
42
+ };
25
43
  }
26
44
 
27
- export const ADMIN = { name: "Admin", email: "admin@example.com", password: seedPassword("E2E_ADMIN_PASSWORD") };
28
- export const USER = { name: "Normal User", email: "user@example.com", password: seedPassword("E2E_USER_PASSWORD") };
45
+ export const ADMIN = seededAccount("ADMIN", appAccounts.admin, { name: "Admin", email: "admin@example.com" });
46
+ export const USER = seededAccount("USER", appAccounts.user, { name: "Normal User", email: "user@example.com" });
29
47
  export const adminState = "playwright/.auth/admin.json";
30
48
  export const userState = "playwright/.auth/user.json";
31
49
  export const userBaselineState = "playwright/.auth/user-baseline.json";
@@ -0,0 +1,24 @@
1
+ import type { Account } from "./accounts";
2
+
3
+ /**
4
+ * The seeded accounts' names, addresses, or password sources when this
5
+ * application's differ from the generated `admin@example.com` and
6
+ * `user@example.com`. PodoKit creates this file once and never overwrites it.
7
+ * `E2E_ADMIN_EMAIL`, `E2E_USER_EMAIL`, `E2E_ADMIN_PASSWORD`, and
8
+ * `E2E_USER_PASSWORD` still win over what is set here.
9
+ *
10
+ * The seed signs the accounts up, so the API must promote the administrator:
11
+ * list its address in `ADMIN_EMAILS`. For example, accounts named by the
12
+ * application's own variables:
13
+ *
14
+ * ```ts
15
+ * export const appAccounts: AppAccounts = {
16
+ * admin: { email: process.env.MYAPP_ADMIN_EMAIL, password: process.env.MYAPP_ADMIN_PASSWORD },
17
+ * };
18
+ * ```
19
+ *
20
+ * Never commit a real password here.
21
+ */
22
+ export type AppAccounts = { admin?: Partial<Account>; user?: Partial<Account> };
23
+
24
+ export const appAccounts: AppAccounts = {};
@@ -0,0 +1,55 @@
1
+ const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
2
+
3
+ /** The `name=value` of the session cookie a sign-in response sets. */
4
+ function sessionCookie(response: Response): string | undefined {
5
+ for (const header of response.headers.getSetCookie()) {
6
+ const [pair = ""] = header.split(";");
7
+ const name = pair.slice(0, pair.indexOf("="));
8
+ // Better Auth names it `better-auth.session_token`, with a `__Secure-`
9
+ // prefix on HTTPS; the multi-session plugin's cookies end in the token.
10
+ if (name.endsWith("session_token")) return pair;
11
+ }
12
+ return undefined;
13
+ }
14
+
15
+ async function signIn(email: string, password: string, cookie?: string): Promise<Response> {
16
+ return fetch(`${base}/api/auth/sign-in/email`, {
17
+ method: "POST",
18
+ headers: { origin: base, "content-type": "application/json", ...(cookie ? { cookie } : {}) },
19
+ body: JSON.stringify({ email, password }),
20
+ });
21
+ }
22
+
23
+ /**
24
+ * Signs in `count` times, each as a separate device, and returns the response
25
+ * statuses.
26
+ *
27
+ * Sign-ins are authentication writes. From an anonymous client the rate
28
+ * limiter counts them against the per-address budget that every other spec's
29
+ * sign-ins share, so a hundred of them refused unrelated sign-ins elsewhere in
30
+ * the run. Only the first one is anonymous here; every later one carries the
31
+ * first sign-in's session cookie, so the limiter counts it against that
32
+ * session instead.
33
+ *
34
+ * A request carries that one cookie and nothing else: Better Auth's
35
+ * multi-session plugin deletes a same-user session that a sign-in carries in
36
+ * its multi-session cookies, so a client that kept every cookie would end with
37
+ * one session instead of `count`.
38
+ */
39
+ export async function signInOnSeparateDevices(email: string, password: string, count: number): Promise<number[]> {
40
+ if (count < 1) return [];
41
+ const first = await signIn(email, password);
42
+ await first.body?.cancel();
43
+ const statuses = [first.status];
44
+ const cookie = sessionCookie(first);
45
+ if (first.status !== 200 || !cookie) return statuses;
46
+ for (let start = 1; start < count; start += 10) {
47
+ const batch = Array.from({ length: Math.min(10, count - start) }, async () => {
48
+ const response = await signIn(email, password, cookie);
49
+ await response.body?.cancel();
50
+ return response.status;
51
+ });
52
+ statuses.push(...(await Promise.all(batch)));
53
+ }
54
+ return statuses;
55
+ }
@@ -34,17 +34,19 @@ async function readCreatedUserId(response: CreatedUserResponse): Promise<string>
34
34
 
35
35
  export const test = base.extend<{ disposableUsers: DisposableUsers }>({
36
36
  disposableUsers: async ({ context, playwright }, use, testInfo) => {
37
- // API projects do not inherit the UI project's admin storageState, while a
37
+ // API projects (`api` and its `api-exclusive` twin) do not inherit the UI
38
+ // project's admin storageState, while a
38
39
  // second admin sign-in can invalidate the shared UI session. API tests sign in
39
40
  // an isolated request context. UI tests copy the browser's admin cookies into
40
41
  // their own context once, so a test that later signs its page in as another
41
42
  // user does not turn cleanup into that user's (forbidden) requests.
43
+ const requestOnly = testInfo.project.name.replace(/-exclusive$/, "") === "api";
42
44
  const request = await playwright.request.newContext({
43
45
  baseURL,
44
46
  extraHTTPHeaders: origin,
45
- ...(testInfo.project.name === "api" ? {} : { storageState: await context.storageState() }),
47
+ ...(requestOnly ? {} : { storageState: await context.storageState() }),
46
48
  });
47
- if (testInfo.project.name === "api") {
49
+ if (requestOnly) {
48
50
  const signIn = await request.post("/api/auth/sign-in/email", {
49
51
  data: { email: ADMIN.email, password: ADMIN.password },
50
52
  });
@@ -1,5 +1,7 @@
1
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).
2
+ // exercise email flows skip when it isn't reachable (see MAILPIT_URL). Each test
3
+ // mails an address of its own and reads the newest message to it; the inbox is
4
+ // shared with tests on other workers, so no test empties it.
3
5
  const MAILPIT = process.env.MAILPIT_URL ?? "http://localhost:8025";
4
6
 
5
7
  export async function mailpitReachable(): Promise<boolean> {
@@ -10,14 +12,6 @@ export async function mailpitReachable(): Promise<boolean> {
10
12
  }
11
13
  }
12
14
 
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
15
  type MailpitList = { messages: Array<{ ID: string; To: Array<{ Address: string }> }> };
22
16
  type MailpitMessage = { Text?: string; HTML?: string };
23
17