@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,60 +1,57 @@
1
1
  import { expect, test } from "@playwright/test";
2
2
  import { ready } from "../helpers/hydration";
3
- import { exclusive } from "../helpers/exclusive";
4
3
 
5
4
  // admin storageState (project default)
6
5
 
7
- test("settings: add and remove a social login provider", async ({ page }) => {
6
+ test("settings: add and remove a social login provider", { tag: "@exclusive" }, async ({ page }) => {
8
7
  test.slow();
9
- await exclusive("social-providers", async () => {
10
- const dialog = page.getByRole("dialog");
11
- const addBtn = dialog.getByRole("button", { name: "Add provider" });
12
- const openConfigure = async (): Promise<void> => {
13
- await page.waitForLoadState("networkidle");
14
- await page.getByRole("tab", { name: "Authentication" }).click();
15
- const socialCard = page
16
- .getByText("Social login", { exact: true })
17
- .locator('xpath=ancestor::div[@data-slot="card"]');
18
- await socialCard.getByRole("button", { name: "Configure" }).click();
19
- };
8
+ const dialog = page.getByRole("dialog");
9
+ const addBtn = dialog.getByRole("button", { name: "Add provider" });
10
+ const openConfigure = async (): Promise<void> => {
11
+ await page.waitForLoadState("networkidle");
12
+ await page.getByRole("tab", { name: "Authentication" }).click();
13
+ const socialCard = page
14
+ .getByText("Social login", { exact: true })
15
+ .locator('xpath=ancestor::div[@data-slot="card"]');
16
+ await socialCard.getByRole("button", { name: "Configure" }).click();
17
+ };
20
18
 
21
- await ready(page, "/admin/settings");
22
- // The auth-config catalog can be momentarily unavailable right after another
23
- // spec toggles auth config (the auth instance rebuilds). Reload until the
24
- // catalog is loaded (Add provider becomes enabled).
25
- await expect(async () => {
26
- await page.reload();
27
- await openConfigure();
28
- await expect(addBtn).toBeEnabled({ timeout: 3000 });
29
- }).toPass({ timeout: 25000 });
19
+ await ready(page, "/admin/settings");
20
+ // The auth-config catalog can be momentarily unavailable right after another
21
+ // spec toggles auth config (the auth instance rebuilds). Reload until the
22
+ // catalog is loaded (Add provider becomes enabled).
23
+ await expect(async () => {
24
+ await page.reload();
25
+ await openConfigure();
26
+ await expect(addBtn).toBeEnabled({ timeout: 3000 });
27
+ }).toPass({ timeout: 25000 });
30
28
 
31
- await addBtn.click();
32
- const provider = dialog.locator("#social-provider");
33
- await expect(provider).toBeVisible();
34
- const callback = await dialog.locator("code").textContent();
35
- const providerId = callback?.trim().split("/").pop() ?? ""; // first addable provider (not hard-coded)
36
- expect(providerId).not.toBe("");
37
- const expectedCallback = new URL(`/api/auth/callback/${providerId}`, page.url()).toString();
38
- expect(callback?.trim()).toBe(expectedCallback);
39
- try {
40
- await dialog.locator("#social-id").fill("test-client-id");
41
- await dialog.locator("#social-secret").fill("test-secret");
42
- await dialog.getByRole("button", { name: "Save", exact: true }).click();
43
- // Save returns to the list view, where the new provider row has an enable switch.
44
- await expect(dialog.getByRole("switch").first()).toBeVisible();
45
- const saved = await (await page.request.get("/api/account/auth-config")).json();
46
- expect(saved.social[providerId].redirectURI).toBe(expectedCallback);
47
- } finally {
48
- if (providerId) {
49
- await page.request
50
- .put("/api/account/auth-config", { data: { social: { [providerId]: { delete: true } } } })
51
- .catch(() => undefined);
52
- }
29
+ await addBtn.click();
30
+ const provider = dialog.locator("#social-provider");
31
+ await expect(provider).toBeVisible();
32
+ const callback = await dialog.locator("code").textContent();
33
+ const providerId = callback?.trim().split("/").pop() ?? ""; // first addable provider (not hard-coded)
34
+ expect(providerId).not.toBe("");
35
+ const expectedCallback = new URL(`/api/auth/callback/${providerId}`, page.url()).toString();
36
+ expect(callback?.trim()).toBe(expectedCallback);
37
+ try {
38
+ await dialog.locator("#social-id").fill("test-client-id");
39
+ await dialog.locator("#social-secret").fill("test-secret");
40
+ await dialog.getByRole("button", { name: "Save", exact: true }).click();
41
+ // Save returns to the list view, where the new provider row has an enable switch.
42
+ await expect(dialog.getByRole("switch").first()).toBeVisible();
43
+ const saved = await (await page.request.get("/api/account/auth-config")).json();
44
+ expect(saved.social[providerId].redirectURI).toBe(expectedCallback);
45
+ } finally {
46
+ if (providerId) {
47
+ await page.request
48
+ .put("/api/account/auth-config", { data: { social: { [providerId]: { delete: true } } } })
49
+ .catch(() => undefined);
53
50
  }
54
- });
51
+ }
55
52
  });
56
53
 
57
- test("settings: open and save the SMTP dialog", async ({ page }) => {
54
+ test("settings: open and save the SMTP dialog", { tag: "@exclusive" }, async ({ page }) => {
58
55
  await ready(page, "/admin/settings");
59
56
  await page.getByRole("tab", { name: "Authentication" }).click();
60
57
  await page.getByRole("button", { name: "SMTP settings" }).click();
@@ -74,35 +71,32 @@ test("settings: open and save the SMTP dialog", async ({ page }) => {
74
71
  }
75
72
  });
76
73
 
77
- test("settings: configure automatic logout", async ({ page }) => {
78
- test.slow();
79
- await exclusive("session-idle-timeout", async () => {
80
- await page.request.put("/api/account/auth-config", {
81
- data: { server: { sessionIdleTimeoutMinutes: null } },
82
- });
83
- await ready(page, "/admin/settings");
84
- await page.getByRole("tab", { name: "Authentication" }).click();
74
+ test("settings: configure automatic logout", { tag: "@exclusive" }, async ({ page }) => {
75
+ await page.request.put("/api/account/auth-config", {
76
+ data: { server: { sessionIdleTimeoutMinutes: null } },
77
+ });
78
+ await ready(page, "/admin/settings");
79
+ await page.getByRole("tab", { name: "Authentication" }).click();
85
80
 
86
- const toggle = page.getByRole("switch", { name: "Automatic logout" });
87
- await expect(toggle).not.toBeChecked();
88
- await toggle.click();
89
- await expect(toggle).toBeChecked();
81
+ const toggle = page.getByRole("switch", { name: "Automatic logout" });
82
+ await expect(toggle).not.toBeChecked();
83
+ await toggle.click();
84
+ await expect(toggle).toBeChecked();
90
85
 
91
- const card = page
92
- .getByText("Automatic logout", { exact: true })
93
- .locator('xpath=ancestor::div[@data-slot="card"]');
94
- await card.getByRole("button", { name: "Configure" }).click();
95
- const dialog = page.getByRole("dialog");
96
- await dialog.getByLabel("Idle time (minutes)").fill("45");
97
- await dialog.getByRole("button", { name: "Save", exact: true }).click();
98
- await expect(page.getByText("45 min", { exact: true })).toBeVisible();
86
+ const card = page
87
+ .getByText("Automatic logout", { exact: true })
88
+ .locator('xpath=ancestor::div[@data-slot="card"]');
89
+ await card.getByRole("button", { name: "Configure" }).click();
90
+ const dialog = page.getByRole("dialog");
91
+ await dialog.getByLabel("Idle time (minutes)").fill("45");
92
+ await dialog.getByRole("button", { name: "Save", exact: true }).click();
93
+ await expect(page.getByText("45 min", { exact: true })).toBeVisible();
99
94
 
100
- const saved = await (await page.request.get("/api/account/auth-config")).json();
101
- expect(saved.server.sessionIdleTimeoutMinutes).toBe(45);
102
- const caps = await (await page.request.get("/api/account/capabilities")).json();
103
- expect(caps.sessionIdleTimeoutMinutes).toBe(45);
95
+ const saved = await (await page.request.get("/api/account/auth-config")).json();
96
+ expect(saved.server.sessionIdleTimeoutMinutes).toBe(45);
97
+ const caps = await (await page.request.get("/api/account/capabilities")).json();
98
+ expect(caps.sessionIdleTimeoutMinutes).toBe(45);
104
99
 
105
- await toggle.click();
106
- await expect(toggle).not.toBeChecked();
107
- });
100
+ await toggle.click();
101
+ await expect(toggle).not.toBeChecked();
108
102
  });
@@ -1,6 +1,6 @@
1
1
  import { expect, test, type Page } from "@playwright/test";
2
2
  import { ready } from "../helpers/hydration";
3
- import { exclusive } from "../helpers/exclusive";
3
+ import { paths } from "../helpers/paths";
4
4
 
5
5
  // admin storageState (project default)
6
6
 
@@ -30,20 +30,17 @@ test("general settings: shows the favicon uploader", async ({ page }) => {
30
30
  await expect(page.locator('input[type="file"]')).toBeVisible();
31
31
  });
32
32
 
33
- test("general settings: edit the site name, save, and apply live", async ({ page }) => {
34
- test.slow();
35
- await exclusive("site-general", async () => {
36
- await ready(page, "/admin/settings");
37
- const name = page.getByLabel("Site name");
38
- const original = await name.inputValue();
39
- await name.fill("PodoKit Test Site");
40
- await saveGeneral(page);
41
- // the browser tab title reflects the new name live (no reload)
42
- await expect(page).toHaveTitle("PodoKit Test Site");
43
- // restore so other specs start clean
44
- await name.fill(original);
45
- await saveGeneral(page);
46
- });
33
+ test("general settings: edit the site name, save, and apply live", { tag: "@exclusive" }, async ({ page }) => {
34
+ await ready(page, "/admin/settings");
35
+ const name = page.getByLabel("Site name");
36
+ const original = await name.inputValue();
37
+ await name.fill("PodoKit Test Site");
38
+ await saveGeneral(page);
39
+ // the browser tab title reflects the new name live (no reload)
40
+ await expect(page).toHaveTitle("PodoKit Test Site");
41
+ // restore so other specs start clean
42
+ await name.fill(original);
43
+ await saveGeneral(page);
47
44
  });
48
45
 
49
46
  test("settings lists the auth features under the Authentication tab", async ({ page }) => {
@@ -62,8 +59,8 @@ test("sidebar: back-to-home link returns to the landing page", async ({ page })
62
59
  // the home link lives in the sidebar footer, above the user menu
63
60
  await page.getByRole("link", { name: "Back to home" }).click();
64
61
  await expect(page).toHaveURL(/\/$/);
65
- // Landing routes are app-owned, so only verify the shared page shell.
66
- await expect(page.locator("main").first()).toBeVisible();
62
+ // The landing page is app-owned; tests/helpers/app-paths.ts says what shows it rendered.
63
+ await expect(paths.homeReady(page)).toBeVisible();
67
64
  });
68
65
 
69
66
  test("settings is reachable from the sidebar", async ({ page }) => {
@@ -72,22 +69,18 @@ test("settings is reachable from the sidebar", async ({ page }) => {
72
69
  await expect(page).toHaveURL(/\/admin\/settings/);
73
70
  });
74
71
 
75
- test("admin can toggle a feature flag and it applies live @smoke", async ({ page }) => {
76
- test.slow();
77
- await exclusive("magic-link", async () => {
78
- await ready(page, "/admin/settings");
79
- await page.getByRole("tab", { name: "Authentication" }).click();
80
- const toggle = page.getByRole("switch", { name: "Magic link" });
81
- test.skip((await toggle.count()) === 0, "magic link toggle not present");
82
- // The magic-link lock keeps the API spec that toggles the same flag out.
83
- await expect(toggle).toBeChecked();
84
- await toggle.click();
85
- await expect(page.getByText("Setting updated")).toBeVisible();
86
- await expect(toggle).not.toBeChecked(); // capabilities refreshed live
87
- // restore so the shared magic-link/2FA specs keep their expected state
88
- await toggle.click();
89
- await expect(toggle).toBeChecked();
90
- });
72
+ test("admin can toggle a feature flag and it applies live @smoke", { tag: "@exclusive" }, async ({ page }) => {
73
+ await ready(page, "/admin/settings");
74
+ await page.getByRole("tab", { name: "Authentication" }).click();
75
+ const toggle = page.getByRole("switch", { name: "Magic link" });
76
+ test.skip((await toggle.count()) === 0, "magic link toggle not present");
77
+ await expect(toggle).toBeChecked();
78
+ await toggle.click();
79
+ await expect(page.getByText("Setting updated")).toBeVisible();
80
+ await expect(toggle).not.toBeChecked(); // capabilities refreshed live
81
+ // restore so the shared magic-link/2FA specs keep their expected state
82
+ await toggle.click();
83
+ await expect(toggle).toBeChecked();
91
84
  });
92
85
 
93
86
  test("settings: the require-two-factor toggle is available", async ({ page }) => {
@@ -101,72 +94,70 @@ test("settings: the require-two-factor toggle is available", async ({ page }) =>
101
94
  await expect(page.getByRole("switch", { name: "Automatic logout" })).toBeVisible();
102
95
  });
103
96
 
104
- test("inactive browser sessions are signed out automatically @smoke", async ({ browser, page: adminPage }) => {
97
+ test("inactive browser sessions are signed out automatically @smoke", { tag: "@exclusive" }, async ({ browser, page: adminPage }) => {
105
98
  test.slow();
106
- await exclusive("session-idle-timeout", async () => {
107
- // Idle sign-out revokes the active server session. Use a dedicated login so
108
- // this test cannot invalidate the shared admin storageState used by later specs.
109
- const context = await browser.newContext({ baseURL: base });
110
- const page = await context.newPage();
111
- const email = `idle-admin-${Date.now()}@example.com`;
112
- let signOutRequests = 0;
113
- let userId = "";
114
- try {
115
- const created = await adminPage.request.post("/api/auth/admin/create-user", {
116
- headers: { origin: base },
117
- data: { email, password: "Podokit3e-Str0ng!pw", name: "Idle Admin", role: "admin" },
118
- });
119
- expect(created.ok()).toBeTruthy();
120
- userId = ((await created.json()).user?.id ?? "") as string;
121
- expect(userId).toBeTruthy();
99
+ // Idle sign-out revokes the active server session. Use a dedicated login so
100
+ // this test cannot invalidate the shared admin storageState used by later specs.
101
+ const context = await browser.newContext({ baseURL: base });
102
+ const page = await context.newPage();
103
+ const email = `idle-admin-${Date.now()}@example.com`;
104
+ let signOutRequests = 0;
105
+ let userId = "";
106
+ try {
107
+ const created = await adminPage.request.post("/api/auth/admin/create-user", {
108
+ headers: { origin: base },
109
+ data: { email, password: "Podokit3e-Str0ng!pw", name: "Idle Admin", role: "admin" },
110
+ });
111
+ expect(created.ok()).toBeTruthy();
112
+ userId = ((await created.json()).user?.id ?? "") as string;
113
+ expect(userId).toBeTruthy();
114
+
115
+ const signIn = await context.request.post("/api/auth/sign-in/email", {
116
+ headers: { origin: base },
117
+ data: { email, password: "Podokit3e-Str0ng!pw" },
118
+ });
119
+ expect(signIn.ok()).toBeTruthy();
120
+
121
+ const configured = await context.request.put("/api/account/auth-config", {
122
+ data: { server: { sessionIdleTimeoutMinutes: 5 } },
123
+ });
124
+ expect(configured.ok()).toBeTruthy();
125
+ await page.route("**/api/auth/sign-out", async (route) => {
126
+ signOutRequests += 1;
127
+ await route.fulfill({ status: 200, contentType: "application/json", body: '{"success":true}' });
128
+ });
129
+ await page.clock.install();
122
130
 
123
- const signIn = await context.request.post("/api/auth/sign-in/email", {
124
- headers: { origin: base },
125
- data: { email, password: "Podokit3e-Str0ng!pw" },
131
+ try {
132
+ await ready(page, "/admin");
133
+ await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
134
+ } finally {
135
+ // Keep the loaded page policy at five minutes while restoring the shared
136
+ // DB policy before the isolated session is revoked.
137
+ const restored = await context.request.put("/api/account/auth-config", {
138
+ data: { server: { sessionIdleTimeoutMinutes: null } },
126
139
  });
127
- expect(signIn.ok()).toBeTruthy();
140
+ expect(restored.ok()).toBeTruthy();
141
+ }
128
142
 
129
- const configured = await context.request.put("/api/account/auth-config", {
130
- data: { server: { sessionIdleTimeoutMinutes: 5 } },
131
- });
132
- expect(configured.ok()).toBeTruthy();
133
- await page.route("**/api/auth/sign-out", async (route) => {
134
- signOutRequests += 1;
135
- await route.fulfill({ status: 200, contentType: "application/json", body: '{"success":true}' });
136
- });
137
- await page.clock.install();
138
-
139
- try {
140
- await ready(page, "/admin");
141
- await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
142
- } finally {
143
- // Keep the loaded page policy at five minutes while restoring the shared
144
- // DB policy before the isolated session is revoked.
145
- const restored = await context.request.put("/api/account/auth-config", {
146
- data: { server: { sessionIdleTimeoutMinutes: null } },
147
- });
148
- expect(restored.ok()).toBeTruthy();
149
- }
150
-
151
- const removed = await adminPage.request.post("/api/auth/admin/remove-user", {
143
+ const removed = await adminPage.request.post("/api/auth/admin/remove-user", {
144
+ headers: { origin: base },
145
+ data: { userId },
146
+ });
147
+ expect(removed.ok()).toBeTruthy();
148
+ userId = "";
149
+
150
+ await page.clock.fastForward(5 * 60_000 + 1_000);
151
+ await expect.poll(() => signOutRequests).toBe(1);
152
+ await expect(page).toHaveURL(/\/login\?reason=idle$/);
153
+ await expect(page.getByTestId("session-timeout-message")).toBeVisible();
154
+ } finally {
155
+ if (userId) {
156
+ await adminPage.request.post("/api/auth/admin/remove-user", {
152
157
  headers: { origin: base },
153
158
  data: { userId },
154
159
  });
155
- expect(removed.ok()).toBeTruthy();
156
- userId = "";
157
-
158
- await page.clock.fastForward(5 * 60_000 + 1_000);
159
- await expect.poll(() => signOutRequests).toBe(1);
160
- await expect(page).toHaveURL(/\/login\?reason=idle$/);
161
- await expect(page.getByTestId("session-timeout-message")).toBeVisible();
162
- } finally {
163
- if (userId) {
164
- await adminPage.request.post("/api/auth/admin/remove-user", {
165
- headers: { origin: base },
166
- data: { userId },
167
- });
168
- }
169
- await context.close();
170
160
  }
171
- });
161
+ await context.close();
162
+ }
172
163
  });
@@ -1,4 +1,5 @@
1
1
  import { expect, test } from "@playwright/test";
2
+ import { ADMIN, USER } from "../helpers/accounts";
2
3
  import { ready } from "../helpers/hydration";
3
4
 
4
5
  // The shared toolbar: filters + search both commit on the Search button; the
@@ -6,10 +7,10 @@ import { ready } from "../helpers/hydration";
6
7
  test("users toolbar searches only when Search is pressed", async ({ page }) => {
7
8
  await ready(page, "/admin/users");
8
9
  const main = page.getByRole("main");
9
- await page.locator("#toolbar-search").fill("admin@example.com");
10
+ await page.locator("#toolbar-search").fill(ADMIN.email);
10
11
  await page.getByRole("button", { name: "Search", exact: true }).click();
11
- await expect(main.getByRole("cell", { name: "admin@example.com" })).toBeVisible();
12
- await expect(main.getByRole("cell", { name: "user@example.com" })).toHaveCount(0);
12
+ await expect(main.getByRole("cell", { name: ADMIN.email })).toBeVisible();
13
+ await expect(main.getByRole("cell", { name: USER.email })).toHaveCount(0);
13
14
  });
14
15
 
15
16
  test("users toolbar applies filter + search together on Search", async ({ page }) => {
@@ -24,8 +25,8 @@ test("users toolbar applies filter + search together on Search", async ({ page }
24
25
  });
25
26
  await page.getByRole("button", { name: "Search", exact: true }).click();
26
27
  expect((await filtered).ok()).toBeTruthy();
27
- await expect(main.getByRole("cell", { name: "admin@example.com" })).toBeVisible();
28
- await expect(main.getByRole("cell", { name: "user@example.com" })).toHaveCount(0);
28
+ await expect(main.getByRole("cell", { name: ADMIN.email })).toBeVisible();
29
+ await expect(main.getByRole("cell", { name: USER.email })).toHaveCount(0);
29
30
  });
30
31
 
31
32
  test("users toolbar combines the role and status filters", async ({ page }) => {
@@ -48,8 +49,8 @@ test("users toolbar combines the role and status filters", async ({ page }) => {
48
49
  });
49
50
  await page.getByRole("button", { name: "Search", exact: true }).click();
50
51
  expect((await filtered).ok()).toBeTruthy();
51
- await expect(main.getByRole("cell", { name: "admin@example.com" })).toBeVisible();
52
- await expect(main.getByRole("cell", { name: "user@example.com" })).toHaveCount(0);
52
+ await expect(main.getByRole("cell", { name: ADMIN.email })).toBeVisible();
53
+ await expect(main.getByRole("cell", { name: USER.email })).toHaveCount(0);
53
54
  });
54
55
 
55
56
  test("users toolbar can pick the search field", async ({ page }) => {
@@ -59,3 +60,28 @@ test("users toolbar can pick the search field", async ({ page }) => {
59
60
  await page.getByRole("option", { name: "Name", exact: true }).click();
60
61
  await expect(fieldSelect).toContainText("Name");
61
62
  });
63
+
64
+ test("users toolbar keeps the search box usable at phone width", async ({ page }) => {
65
+ await page.setViewportSize({ width: 390, height: 844 });
66
+ await ready(page, "/admin/users");
67
+ const input = await page.locator("#toolbar-search").boundingBox();
68
+ const field = await page.locator('button[data-slot="select-trigger"]').last().boundingBox();
69
+ const button = await page.getByRole("button", { name: "Search", exact: true }).boundingBox();
70
+ if (!input || !field || !button) throw new Error("expected the toolbar to render");
71
+ // The input wraps below the field select instead of shrinking beside it.
72
+ expect(input.width).toBeGreaterThanOrEqual(192);
73
+ expect(input.y).toBeGreaterThan(field.y + field.height);
74
+ expect(Math.abs(input.y + input.height / 2 - (button.y + button.height / 2))).toBeLessThan(1);
75
+ expect(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth))
76
+ .toBe(0);
77
+ });
78
+
79
+ test("users toolbar keeps the desktop search box on one row", async ({ page }) => {
80
+ await page.setViewportSize({ width: 1280, height: 800 });
81
+ await ready(page, "/admin/users");
82
+ const input = await page.locator("#toolbar-search").boundingBox();
83
+ const field = await page.locator('button[data-slot="select-trigger"]').last().boundingBox();
84
+ if (!input || !field) throw new Error("expected the toolbar to render");
85
+ expect(input.width).toBe(320);
86
+ expect(Math.abs(input.y + input.height / 2 - (field.y + field.height / 2))).toBeLessThan(1);
87
+ });
@@ -0,0 +1,29 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { ready } from "../helpers/hydration";
3
+
4
+ // mode-watcher marks <html> with `.dark` or `.light`, so component libraries
5
+ // that follow either class, and fall back to the OS setting otherwise, match the
6
+ // theme the visitor chose rather than the system one.
7
+ test("the chosen theme is named on <html> in both modes", async ({ page }) => {
8
+ await page.emulateMedia({ colorScheme: "dark" });
9
+ await ready(page, "/account");
10
+ const html = page.locator("html");
11
+
12
+ await page.getByRole("button", { name: "Toggle theme" }).click();
13
+ await page.getByRole("menuitem", { name: "Light" }).click();
14
+ await expect(html).toHaveClass(/(^|\s)light(\s|$)/);
15
+ await expect(html).not.toHaveClass(/(^|\s)dark(\s|$)/);
16
+
17
+ // The class is set before the page renders, not only after a choice.
18
+ await page.reload();
19
+ await expect(html).toHaveClass(/(^|\s)light(\s|$)/);
20
+
21
+ await page.getByRole("button", { name: "Toggle theme" }).click();
22
+ await page.getByRole("menuitem", { name: "Dark" }).click();
23
+ await expect(html).toHaveClass(/(^|\s)dark(\s|$)/);
24
+ await expect(html).not.toHaveClass(/(^|\s)light(\s|$)/);
25
+
26
+ await page.getByRole("button", { name: "Toggle theme" }).click();
27
+ await page.getByRole("menuitem", { name: "System" }).click();
28
+ await expect(html).toHaveClass(/(^|\s)dark(\s|$)/);
29
+ });
@@ -1,7 +1,6 @@
1
1
  import { expect, test, type Page } from "@playwright/test";
2
2
  import { ADMIN, USER, anonState } from "../helpers/accounts";
3
3
  import { totpCode } from "../helpers/totp";
4
- import { exclusive } from "../helpers/exclusive";
5
4
 
6
5
  test.use({ storageState: anonState });
7
6
 
@@ -85,62 +84,60 @@ test("two-factor: sign in with an authenticator code from the login page", async
85
84
  await expectAccountPage(page);
86
85
  });
87
86
 
88
- test("require-2fa: a new sign-up is forced through the enrolment page", async ({ page, playwright }) => {
87
+ test("require-2fa: a new sign-up is forced through the enrolment page", { tag: "@exclusive" }, async ({ page, playwright }) => {
89
88
  test.slow();
90
- await exclusive("require2fa", async () => {
91
- const admin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
92
- const caps = await (await admin.get("/api/account/capabilities")).json();
93
- test.skip(!caps?.twoFactor, "two-factor not enabled");
94
- await admin.post("/api/auth/sign-in/email", { data: { email: ADMIN.email, password: ADMIN.password } });
95
-
96
- const email = `rq-ui-${Date.now()}@example.com`;
97
- const pw = "Podokit3e-Str0ng!pw";
98
- try {
99
- await admin.put("/api/account/settings", { data: { require2fa: true } });
100
- // A fresh un-enrolled user; poll (via API) until the guard cache picks up the policy.
89
+ const admin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
90
+ const caps = await (await admin.get("/api/account/capabilities")).json();
91
+ test.skip(!caps?.twoFactor, "two-factor not enabled");
92
+ await admin.post("/api/auth/sign-in/email", { data: { email: ADMIN.email, password: ADMIN.password } });
93
+
94
+ const email = `rq-ui-${Date.now()}@example.com`;
95
+ const pw = "Podokit3e-Str0ng!pw";
96
+ try {
97
+ await admin.put("/api/account/settings", { data: { require2fa: true } });
98
+ // A fresh un-enrolled user; poll (via API) until the guard cache picks up the policy.
99
+ const probe = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
100
+ await probe.post("/api/auth/sign-up/email", { data: { email, password: pw, name: "RQUI" } });
101
+ await expect(async () => {
102
+ expect((await probe.get("/api/account/me")).status()).toBe(403);
103
+ }).toPass({ timeout: 8000 });
104
+ await probe.dispose();
105
+
106
+ // Signing in toward a protected route lands on the mandatory enrolment page.
107
+ await openHydratedLogin(page, "/account");
108
+ await page.getByLabel("Email").fill(email);
109
+ await page.getByLabel("Password").fill(pw);
110
+ await page.getByRole("button", { name: "Sign in", exact: true }).click();
111
+ await expect(page).toHaveURL(/\/setup-2fa/);
112
+
113
+ // Complete enrolment entirely through the UI: password → QR + backup codes →
114
+ // authenticator code → activate. The code is computed from the on-page URI.
115
+ await page.getByLabel("Confirm your password").fill(pw);
116
+ await page.getByRole("button", { name: "Continue" }).click();
117
+ await expect(page.getByTestId("backup-codes")).toBeVisible();
118
+ await expect(page.getByRole("button", { name: "Download" })).toBeVisible();
119
+ const uri = (await page.locator("code").first().innerText()).trim();
120
+ await page.getByRole("checkbox").click();
121
+ await page.getByLabel("3. Enter the 6-digit code").fill(totpCode(uri));
122
+ await page.getByRole("button", { name: "Activate and continue" }).click();
123
+
124
+ // Enrolled → the gate lets the regular user into their account page.
125
+ await expectAccountPage(page);
126
+
127
+ // An enrolled user who revisits setup is returned to the same non-admin page.
128
+ await page.goto("/setup-2fa");
129
+ await expectAccountPage(page);
130
+ } finally {
131
+ await admin.put("/api/account/settings", { data: { require2fa: false } });
132
+ await expect(async () => {
101
133
  const probe = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
102
- await probe.post("/api/auth/sign-up/email", { data: { email, password: pw, name: "RQUI" } });
103
- await expect(async () => {
104
- expect((await probe.get("/api/account/me")).status()).toBe(403);
105
- }).toPass({ timeout: 8000 });
134
+ await probe.post("/api/auth/sign-in/email", { data: { email: USER.email, password: USER.password } });
135
+ const status = (await probe.get("/api/account/me")).status();
106
136
  await probe.dispose();
107
-
108
- // Signing in toward a protected route lands on the mandatory enrolment page.
109
- await openHydratedLogin(page, "/account");
110
- await page.getByLabel("Email").fill(email);
111
- await page.getByLabel("Password").fill(pw);
112
- await page.getByRole("button", { name: "Sign in", exact: true }).click();
113
- await expect(page).toHaveURL(/\/setup-2fa/);
114
-
115
- // Complete enrolment entirely through the UI: password → QR + backup codes →
116
- // authenticator code → activate. The code is computed from the on-page URI.
117
- await page.getByLabel("Confirm your password").fill(pw);
118
- await page.getByRole("button", { name: "Continue" }).click();
119
- await expect(page.getByTestId("backup-codes")).toBeVisible();
120
- await expect(page.getByRole("button", { name: "Download" })).toBeVisible();
121
- const uri = (await page.locator("code").first().innerText()).trim();
122
- await page.getByRole("checkbox").click();
123
- await page.getByLabel("3. Enter the 6-digit code").fill(totpCode(uri));
124
- await page.getByRole("button", { name: "Activate and continue" }).click();
125
-
126
- // Enrolled → the gate lets the regular user into their account page.
127
- await expectAccountPage(page);
128
-
129
- // An enrolled user who revisits setup is returned to the same non-admin page.
130
- await page.goto("/setup-2fa");
131
- await expectAccountPage(page);
132
- } finally {
133
- await admin.put("/api/account/settings", { data: { require2fa: false } });
134
- await expect(async () => {
135
- const probe = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
136
- await probe.post("/api/auth/sign-in/email", { data: { email: USER.email, password: USER.password } });
137
- const status = (await probe.get("/api/account/me")).status();
138
- await probe.dispose();
139
- expect(status).toBe(200);
140
- }).toPass({ timeout: 8000 });
141
- await admin.dispose();
142
- }
143
- });
137
+ expect(status).toBe(200);
138
+ }).toPass({ timeout: 8000 });
139
+ await admin.dispose();
140
+ }
144
141
  });
145
142
 
146
143
  test("account: regenerate backup codes shows a fresh set", async ({ page, playwright }) => {