@podosoft/podokit 1.1.6 → 1.2.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 (165) hide show
  1. package/dist/deploy-render.js +5 -0
  2. package/dist/templates/fullstack/apps/api/package.json +2 -2
  3. package/dist/templates/fullstack/apps/api/src/app.spec.ts +81 -0
  4. package/dist/templates/fullstack/apps/api/src/app.ts +53 -5
  5. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +6 -0
  6. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +9 -1
  7. package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +30 -0
  8. package/dist/templates/fullstack/apps/api/src/core/services.ts +19 -3
  9. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +149 -4
  10. package/dist/templates/fullstack/apps/api/src/database/database.ts +66 -13
  11. package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +86 -0
  12. package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +4 -9
  13. package/dist/templates/fullstack/apps/api/src/health/readiness.service.spec.ts +141 -1
  14. package/dist/templates/fullstack/apps/api/src/health/readiness.service.ts +86 -12
  15. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +188 -1
  16. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +30 -0
  17. package/dist/templates/fullstack/apps/web/src/routes/api/[...path]/+server.ts +3 -3
  18. package/dist/templates/fullstack/apps/web/src/routes/api/auth/[...all]/+server.ts +3 -3
  19. package/dist/templates/fullstack/compose.dev.yaml +21 -0
  20. package/dist/templates/fullstack/dot-env.docker +3 -0
  21. package/dist/templates/fullstack/dot-env.example +14 -0
  22. package/dist/templates/fullstack/infra/k3s/api-deployment.yaml +2 -0
  23. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.spec.ts +25 -0
  24. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +26 -0
  25. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.spec.ts +122 -0
  26. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.ts +46 -0
  27. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +187 -0
  28. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +160 -0
  29. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +17 -28
  30. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +3 -5
  31. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +6 -17
  32. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.spec.ts +167 -0
  33. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.ts +50 -0
  34. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.spec.ts +158 -0
  35. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +78 -28
  36. package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +3 -0
  37. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +124 -0
  38. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +19 -11
  39. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-data.server.ts +6 -32
  40. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.test.ts +74 -0
  41. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.ts +73 -0
  42. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.test.ts +75 -0
  43. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +71 -0
  44. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.test.ts +68 -0
  45. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.ts +108 -0
  46. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +14 -8
  47. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/session-idle-timeout.svelte +74 -49
  48. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -1
  49. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -1
  50. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.test.ts +29 -0
  51. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +23 -6
  52. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.test.ts +21 -0
  53. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.ts +15 -0
  54. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.test.ts +98 -0
  55. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.ts +54 -0
  56. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/protected-layout.ts +7 -50
  57. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/audit/+page.svelte +88 -45
  58. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/sessions/+page.svelte +76 -75
  59. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.server.ts +15 -14
  60. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +2 -2
  61. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +86 -38
  62. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +10 -17
  63. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.ts +10 -0
  64. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/oauth2/consent/+page.svelte +4 -3
  65. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +14 -8
  66. package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -1
  67. package/dist/templates/modules/admin-dashboard/files/tests/api/admin-sessions.api.spec.ts +124 -0
  68. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +75 -12
  69. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +34 -0
  70. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +7 -0
  71. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +72 -0
  72. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +36 -2
  73. package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +41 -0
  74. package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +32 -13
  75. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +13 -4
  76. package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +46 -0
  77. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +64 -1
  78. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +24 -0
  79. package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +8 -1
  80. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +20 -0
  81. package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +46 -5
  82. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-events.ts +19 -7
  83. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -0
  84. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +81 -0
  85. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +13 -11
  86. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.spec.ts +115 -0
  87. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.ts +44 -0
  88. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +421 -43
  89. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +382 -35
  90. package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1792000000000-IndexAuditLogFilters.ts +35 -0
  91. package/dist/templates/modules/audit-log/module.manifest.json +7 -4
  92. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +20 -5
  93. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +24 -29
  94. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +28 -13
  95. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.spec.ts +56 -2
  96. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.ts +22 -0
  97. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +81 -5
  98. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -2
  99. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +15 -9
  100. package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.spec.ts +38 -0
  101. package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.ts +43 -0
  102. package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +24 -0
  103. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +31 -2
  104. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +58 -0
  105. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +46 -57
  106. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +7 -2
  107. package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.spec.ts +23 -0
  108. package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.ts +10 -0
  109. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +10 -0
  110. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.spec.ts +81 -0
  111. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.ts +37 -34
  112. package/dist/templates/modules/auth/module.manifest.json +2 -2
  113. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +136 -0
  114. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +34 -13
  115. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.spec.ts +60 -0
  116. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.ts +51 -0
  117. package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +70 -9
  118. package/dist/templates/modules/file-upload/module.manifest.json +9 -3
  119. package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts +26 -32
  120. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +16 -1
  121. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts +31 -4
  122. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +145 -0
  123. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.ts +37 -0
  124. package/dist/templates/modules/job-progress/files/tests/api/progress.api.spec.ts +94 -4
  125. package/dist/templates/modules/job-progress/module.manifest.json +3 -3
  126. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.spec.ts +54 -0
  127. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.ts +47 -25
  128. package/dist/templates/modules/mailer/module.manifest.json +1 -1
  129. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.spec.ts +86 -0
  130. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +159 -0
  131. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-demo.ts +38 -0
  132. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +172 -4
  133. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +110 -33
  134. package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +88 -0
  135. package/dist/templates/modules/object-storage-local/module.manifest.json +6 -1
  136. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +80 -0
  137. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +13 -7
  138. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +156 -0
  139. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.ts +38 -0
  140. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts +39 -21
  141. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +101 -2
  142. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +84 -9
  143. package/dist/templates/modules/object-storage-s3/module.manifest.json +8 -3
  144. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +9 -0
  145. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +7 -0
  146. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +139 -21
  147. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +80 -11
  148. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +12 -6
  149. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +15 -2
  150. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +46 -0
  151. package/dist/templates/modules/rate-limit/module.manifest.json +4 -3
  152. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +125 -0
  153. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +27 -13
  154. package/dist/templates/modules/sse/files/apps/api/src/events/event-envelope.ts +123 -0
  155. package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +191 -0
  156. package/dist/templates/modules/sse/files/apps/api/src/events/event-topics.ts +64 -0
  157. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.spec.ts +203 -0
  158. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +107 -14
  159. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +372 -18
  160. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +136 -40
  161. package/dist/templates/modules/sse/files/apps/web/src/lib/events.test.ts +216 -0
  162. package/dist/templates/modules/sse/files/apps/web/src/lib/events.ts +207 -0
  163. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +181 -70
  164. package/dist/templates/modules/sse/module.manifest.json +17 -4
  165. package/package.json +2 -2
@@ -4,6 +4,8 @@ import { ADMIN, USER } from "../helpers/accounts";
4
4
  const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
5
5
  const origin = { origin: base };
6
6
 
7
+ type Entry = { id: string; action: string; actorEmail: string | null; targetLabel: string | null; createdAt: string };
8
+
7
9
  async function signedIn(
8
10
  playwright: import("@playwright/test").PlaywrightWorkerArgs["playwright"],
9
11
  account: { email: string; password: string },
@@ -13,6 +15,10 @@ async function signedIn(
13
15
  return ctx;
14
16
  }
15
17
 
18
+ function auditQuery(params: Record<string, string>): string {
19
+ return `/api/audit-logs?${new URLSearchParams(params).toString()}`;
20
+ }
21
+
16
22
  test("audit log records auth/admin actions", async ({ playwright, disposableUsers }) => {
17
23
  const admin = await signedIn(playwright, ADMIN);
18
24
  const probe = await admin.get("/api/audit-logs");
@@ -26,12 +32,11 @@ test("audit log records auth/admin actions", async ({ playwright, disposableUser
26
32
  expect(created.ok()).toBeTruthy();
27
33
  const userId = await disposableUsers.trackResponse(created);
28
34
 
29
- const res = await admin.get("/api/audit-logs");
35
+ const res = await admin.get(auditQuery({ action: "user.create", target: email }));
30
36
  expect(res.ok()).toBeTruthy();
31
- const entries = (await res.json()) as Array<{ action: string; actorEmail: string | null; targetLabel: string | null }>;
32
- const entry = entries.find((e) => e.action === "user.create" && e.targetLabel === email);
33
- expect(entry, "user.create should be audited with the target").toBeTruthy();
34
- expect(entry?.actorEmail, "audit entry records the acting admin").toBe(ADMIN.email);
37
+ const entries = (await res.json()) as Entry[];
38
+ expect(entries, "user.create should be audited with the target").toHaveLength(1);
39
+ expect(entries[0]?.actorEmail, "audit entry records the acting admin").toBe(ADMIN.email);
35
40
 
36
41
  await admin.post("/api/auth/admin/remove-user", { data: { userId } });
37
42
  disposableUsers.forget(userId);
@@ -45,6 +50,7 @@ test("the audit log is admin-only", async ({ playwright }) => {
45
50
 
46
51
  const user = await signedIn(playwright, USER);
47
52
  expect((await user.get("/api/audit-logs")).status()).toBe(403);
53
+ expect((await user.get("/api/audit-logs/actions")).status()).toBe(403);
48
54
  await user.dispose();
49
55
  });
50
56
 
@@ -57,8 +63,66 @@ test("sending a verification email is audited", async ({ playwright, disposableU
57
63
  });
58
64
  await disposableUsers.trackResponse(created);
59
65
  await admin.post("/api/auth/send-verification-email", { data: { email, callbackURL: `${base}/admin` } });
60
- const entries = (await (await admin.get("/api/audit-logs")).json()) as Array<{ action: string; targetLabel: string | null }>;
61
- expect(entries.some((e) => e.action === "auth.verification_sent"), "verification send should be audited").toBeTruthy();
66
+ const entries = (await (await admin.get(auditQuery({ action: "auth.verification_sent", target: email }))).json()) as Entry[];
67
+ expect(entries, "verification send should be audited").toHaveLength(1);
68
+ await admin.dispose();
69
+ });
70
+
71
+ test("the audit log pages with a cursor header through the web proxy", async ({ playwright }) => {
72
+ const admin = await signedIn(playwright, ADMIN);
73
+ test.skip((await admin.get("/api/audit-logs")).status() === 404, "audit-log module not installed");
74
+ // Every sign-in is audited; one more guarantees at least two pages of one entry.
75
+ await (await signedIn(playwright, ADMIN)).dispose();
76
+
77
+ const query = { action: "auth.login", actor: ADMIN.email, limit: "1" };
78
+ const first = await admin.get(auditQuery(query));
79
+ expect(first.ok()).toBeTruthy();
80
+ const firstPage = (await first.json()) as Entry[];
81
+ expect(Array.isArray(firstPage)).toBe(true);
82
+ expect(firstPage).toHaveLength(1);
83
+ const cursor = first.headers()["x-next-cursor"];
84
+ expect(cursor, "the proxy relays the next-page cursor").toBeTruthy();
85
+
86
+ const second = await admin.get(auditQuery({ ...query, cursor: cursor ?? "" }));
87
+ const secondPage = (await second.json()) as Entry[];
88
+ expect(secondPage).toHaveLength(1);
89
+ expect(secondPage[0]?.id).not.toBe(firstPage[0]?.id);
90
+ expect(Date.parse(secondPage[0]?.createdAt ?? "")).toBeLessThanOrEqual(Date.parse(firstPage[0]?.createdAt ?? ""));
91
+
92
+ const ascending = (await (await admin.get(auditQuery({ ...query, limit: "2", order: "asc" }))).json()) as Entry[];
93
+ expect(Date.parse(ascending[0]?.createdAt ?? "")).toBeLessThanOrEqual(Date.parse(ascending[1]?.createdAt ?? ""));
94
+ await admin.dispose();
95
+ });
96
+
97
+ test("the audit log filters on the server and lists its action codes", async ({ playwright }) => {
98
+ const admin = await signedIn(playwright, ADMIN);
99
+ test.skip((await admin.get("/api/audit-logs")).status() === 404, "audit-log module not installed");
100
+
101
+ const logins = (await (await admin.get(auditQuery({ action: "auth.login", limit: "100" }))).json()) as Entry[];
102
+ expect(logins.length).toBeGreaterThan(0);
103
+ expect(logins.every((entry) => entry.action === "auth.login")).toBe(true);
104
+
105
+ const mine = (await (await admin.get(auditQuery({ actor: ADMIN.email, limit: "100" }))).json()) as Entry[];
106
+ expect(mine.length).toBeGreaterThan(0);
107
+ expect(mine.every((entry) => entry.actorEmail === ADMIN.email)).toBe(true);
108
+
109
+ const nobody = await admin.get(auditQuery({ actor: `nobody-${Date.now()}@example.com` }));
110
+ expect(await nobody.json()).toEqual([]);
111
+ expect(nobody.headers()["x-next-cursor"]).toBeUndefined();
112
+
113
+ const actions = (await (await admin.get("/api/audit-logs/actions")).json()) as string[];
114
+ expect(actions).toContain("auth.login");
115
+ expect([...actions].sort()).toEqual(actions);
116
+ await admin.dispose();
117
+ });
118
+
119
+ test("the audit log rejects bad paging parameters", async ({ playwright }) => {
120
+ const admin = await signedIn(playwright, ADMIN);
121
+ test.skip((await admin.get("/api/audit-logs")).status() === 404, "audit-log module not installed");
122
+ expect((await admin.get(auditQuery({ limit: "101" }))).status()).toBe(400);
123
+ const badCursor = await admin.get(auditQuery({ cursor: "not-a-cursor" }));
124
+ expect(badCursor.status()).toBe(400);
125
+ expect(((await badCursor.json()) as { error: { code: string } }).error.code).toBe("INVALID_CURSOR");
62
126
  await admin.dispose();
63
127
  });
64
128
 
@@ -69,9 +133,7 @@ test("audit logging honours the DB on/off toggle @smoke", async ({ playwright, d
69
133
  test.skip((await admin.get("/api/audit-logs")).status() === 404, "audit-log module not installed");
70
134
  const auditLog = async () => (await (await admin.get("/api/account/capabilities")).json()).auditLog as boolean;
71
135
  const recorded = async (label: string) =>
72
- ((await (await admin.get("/api/audit-logs")).json()) as Array<{ action: string; targetLabel: string | null }>).some(
73
- (e) => e.action === "user.create" && e.targetLabel === label,
74
- );
136
+ ((await (await admin.get(auditQuery({ action: "user.create", target: label }))).json()) as Entry[]).length > 0;
75
137
  const createUser = async (email: string): Promise<string> => {
76
138
  const response = await admin.post("/api/auth/admin/create-user", {
77
139
  data: { email, password: "Podokit3e-Str0ng!pw", name: "T", role: "user" },
@@ -80,8 +142,9 @@ test("audit logging honours the DB on/off toggle @smoke", async ({ playwright, d
80
142
  };
81
143
 
82
144
  try {
83
- // OFF → the action is not recorded. Wait past the config-store cache TTL so the
84
- // audit gate sees the new value (its cache is independent of the capabilities one).
145
+ // OFF → the action is not recorded. The write refreshes this process's config
146
+ // store, but other API replicas keep their snapshot for the store's cache TTL,
147
+ // so wait it out before relying on the new value.
85
148
  await admin.put("/api/account/auth-config", { data: { server: { auditLog: false } } });
86
149
  await expect.poll(auditLog, { timeout: 8000 }).toBe(false);
87
150
  await new Promise((r) => setTimeout(r, 4000));
@@ -1,4 +1,5 @@
1
1
  import { expect, test } from "@playwright/test";
2
+ import { createPublicKey, verify, type JsonWebKey } from "node:crypto";
2
3
  import { ADMIN, USER } from "../helpers/accounts";
3
4
  import { clearSms, smsSinkReachable, waitForSmsOtp } from "../helpers/sms";
4
5
  import { totpCode } from "../helpers/totp";
@@ -156,6 +157,11 @@ test("require-2fa: gates an un-enrolled user until they enrol", async ({ playwri
156
157
  try {
157
158
  // Turn the policy on (admin; the toggle endpoint is allow-listed by the guard).
158
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 });
159
165
  // The guard caches for a few seconds — poll until the un-enrolled user is gated.
160
166
  await expect(async () => {
161
167
  expect((await user.get("/api/account/me")).status()).toBe(403);
@@ -238,9 +244,37 @@ test("bearer token authenticates API requests without a cookie", async ({ playwr
238
244
  const authed = await api.get("/api/account/me", { headers: { Authorization: `Bearer ${token}` } });
239
245
  expect(authed.ok()).toBeTruthy();
240
246
  expect((await authed.json())?.email).toBe(email);
247
+ // Only the signed value authenticates; the bare token before the signature does not.
248
+ const unsigned = decodeURIComponent(token ?? "").split(".")[0];
249
+ const rejected = await api.get("/api/account/me", { headers: { Authorization: `Bearer ${unsigned}` } });
250
+ expect(rejected.status()).toBe(401);
241
251
  await api.dispose();
242
252
  });
243
253
 
254
+ test("a session JWT is issued on request and verifies against the JWKS", async ({ playwright }) => {
255
+ const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
256
+ const email = `jwt-${Date.now()}@example.com`;
257
+ await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "JWT" } });
258
+ // Session reads do not sign a JWT; a client that needs one requests it here.
259
+ const issued = await ctx.get("/api/auth/token");
260
+ expect(issued.ok(), await issued.text()).toBeTruthy();
261
+ const token = String((await issued.json())?.token ?? "");
262
+ const [header = "", payload = "", signature = ""] = token.split(".");
263
+ const { kid } = JSON.parse(Buffer.from(header, "base64url").toString("utf8")) as { kid?: string };
264
+ const jwks = (await (await ctx.get("/api/auth/jwks")).json()) as { keys: Array<JsonWebKey & { kid?: string }> };
265
+ const key = jwks.keys.find((candidate) => candidate.kid === kid);
266
+ expect(key, "the token's signing key is published").toBeTruthy();
267
+ const valid = verify(
268
+ null,
269
+ Buffer.from(`${header}.${payload}`),
270
+ createPublicKey({ key: key ?? {}, format: "jwk" }),
271
+ Buffer.from(signature, "base64url"),
272
+ );
273
+ expect(valid).toBe(true);
274
+ expect(JSON.parse(Buffer.from(payload, "base64url").toString("utf8"))?.email).toBe(email);
275
+ await ctx.dispose();
276
+ });
277
+
244
278
  test("a user can create, list and revoke a personal API key", async ({ playwright }) => {
245
279
  const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
246
280
  const caps = await (await ctx.get("/api/account/capabilities")).json();
@@ -48,6 +48,13 @@ test("profile image upload, replacement, public read, and removal", async ({ pla
48
48
  expect(firstImage.ok()).toBeTruthy();
49
49
  expect(firstImage.headers()["content-type"]).toContain("image/png");
50
50
  expect(firstImage.headers()["cache-control"]).toBe("public, max-age=31536000, immutable");
51
+ expect(firstImage.headers()["content-length"]).toBe(String(PNG.byteLength));
52
+ expect((await firstImage.body()).equals(PNG)).toBe(true);
53
+ const etag = firstImage.headers()["etag"];
54
+ expect(etag).toMatch(/^"[^"]+"$/);
55
+ const revalidated = await admin.get(firstBody.image, { headers: { "if-none-match": etag ?? "" } });
56
+ expect(revalidated.status()).toBe(304);
57
+ expect(revalidated.headers()["cache-control"]).toBe("public, max-age=31536000, immutable");
51
58
 
52
59
  const replacement = await admin.post("/api/account/profile-image", {
53
60
  multipart: { file: { name: "replacement.png", mimeType: "image/png", buffer: PNG } },
@@ -3,6 +3,11 @@ import { ADMIN } from "../helpers/accounts";
3
3
 
4
4
  const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
5
5
  const origin = { origin: base };
6
+ const IMMUTABLE = "public, max-age=31536000, immutable";
7
+ const ICON = Buffer.from(
8
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=",
9
+ "base64",
10
+ );
6
11
 
7
12
  // Fresh admin context so these never rotate the seeded admin session.
8
13
  async function adminCtx(playwright: import("@playwright/test").PlaywrightWorkerArgs["playwright"]) {
@@ -54,3 +59,70 @@ test("theme settings: invalid values are rejected (no CSS injection)", async ({
54
59
  expect((await admin.put("/api/site/settings", { data })).status(), JSON.stringify(data)).toBe(400);
55
60
  }
56
61
  });
62
+
63
+ test("site settings: one response shape and immediate reads after each write", async ({ playwright }) => {
64
+ const admin = await adminCtx(playwright);
65
+ const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
66
+ const before = await (await anon.get("/api/site/settings")).json() as Record<string, unknown>;
67
+ expect(Object.keys(before)).toEqual([
68
+ "name", "description", "supportEmail", "footerText", "brandColor", "themePreset", "themeRadius",
69
+ "themeOverrides", "termsUrl", "privacyUrl", "locale", "timezone", "maintenanceMode", "allowSignup",
70
+ "hasFavicon", "faviconVersion",
71
+ ]);
72
+ expect(typeof before.hasFavicon).toBe("boolean");
73
+ expect(before.faviconVersion === null || typeof before.faviconVersion === "string").toBe(true);
74
+
75
+ try {
76
+ // The second write lands while the first read is still cached; it must not be hidden.
77
+ for (const footerText of ["first footer", "second footer"]) {
78
+ expect((await admin.put("/api/site/settings", { data: { footerText } })).ok()).toBeTruthy();
79
+ expect((await (await anon.get("/api/site/settings")).json()).footerText).toBe(footerText);
80
+ }
81
+ } finally {
82
+ const footerText = typeof before.footerText === "string" ? before.footerText : "";
83
+ await admin.put("/api/site/settings", { data: { footerText } });
84
+ await anon.dispose();
85
+ await admin.dispose();
86
+ }
87
+ });
88
+
89
+ test("site favicon: the versioned URL is cached for good and other URLs revalidate", async ({ playwright }) => {
90
+ const admin = await adminCtx(playwright);
91
+ const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
92
+ try {
93
+ const upload = await admin.post("/api/site/favicon", {
94
+ multipart: { file: { name: "favicon.png", mimeType: "image/png", buffer: ICON } },
95
+ });
96
+ expect(upload.status()).toBe(201);
97
+ const { version } = await upload.json() as { version: string };
98
+ expect((await (await anon.get("/api/site/settings")).json()).faviconVersion).toBe(version);
99
+
100
+ const current = await anon.get(`/api/site/favicon?v=${encodeURIComponent(version)}`);
101
+ expect(current.status()).toBe(200);
102
+ expect(current.headers()["cache-control"]).toBe(IMMUTABLE);
103
+ expect(current.headers()["content-type"]).toContain("image/png");
104
+ expect(current.headers()["content-length"]).toBe(String(ICON.byteLength));
105
+ expect((await current.body()).equals(ICON)).toBe(true);
106
+ const etag = current.headers()["etag"] ?? "";
107
+ expect(etag).toMatch(/^"[^"]+"$/);
108
+
109
+ for (const path of ["/api/site/favicon?v=stale", "/api/site/favicon"]) {
110
+ const response = await anon.get(path);
111
+ expect(response.status(), path).toBe(200);
112
+ expect(response.headers()["cache-control"], path).toBe("no-cache");
113
+ expect(response.headers()["etag"], path).toBe(etag);
114
+ }
115
+
116
+ const revalidated = await anon.get("/api/site/favicon?v=stale", { headers: { "if-none-match": etag } });
117
+ expect(revalidated.status()).toBe(304);
118
+ expect(revalidated.headers()["cache-control"]).toBe("no-cache");
119
+ const versioned = await anon.get(`/api/site/favicon?v=${encodeURIComponent(version)}`, {
120
+ headers: { "if-none-match": etag },
121
+ });
122
+ expect(versioned.status()).toBe(304);
123
+ expect(versioned.headers()["cache-control"]).toBe(IMMUTABLE);
124
+ } finally {
125
+ await anon.dispose();
126
+ await admin.dispose();
127
+ }
128
+ });
@@ -63,9 +63,13 @@ test("admin can list and revoke a user's sessions", async ({ playwright, disposa
63
63
  expect((await theirs.post("/api/auth/sign-in/email", { data: { email, password: "Podokit3e-Str0ng!pw" } })).ok()).toBeTruthy();
64
64
  await theirs.dispose();
65
65
 
66
- const listed = await admin.post("/api/auth/admin/list-user-sessions", { data: { userId } });
66
+ // The admin console lists sessions through the app API, which never returns tokens.
67
+ const listed = await admin.get(`/api/admin/sessions?userId=${encodeURIComponent(userId)}`);
67
68
  expect(listed.ok()).toBeTruthy();
68
- expect(((await listed.json())?.sessions ?? []).length).toBeGreaterThan(0);
69
+ const { items } = (await listed.json()) as { items: Array<Record<string, unknown>> };
70
+ expect(items.length).toBeGreaterThan(0);
71
+ expect(items[0]).toMatchObject({ userId, userEmail: email });
72
+ expect(items[0]).not.toHaveProperty("token");
69
73
 
70
74
  const revoked = await admin.post("/api/auth/admin/revoke-user-sessions", { data: { userId } });
71
75
  expect(revoked.ok()).toBeTruthy();
@@ -74,6 +78,35 @@ test("admin can list and revoke a user's sessions", async ({ playwright, disposa
74
78
  await admin.dispose();
75
79
  });
76
80
 
81
+ test("admin lists users one server page at a time", async ({ playwright, disposableUsers }) => {
82
+ const admin = await signedIn(playwright, ADMIN);
83
+ const prefix = `api-page-${Date.now()}`;
84
+ for (let i = 0; i < 3; i += 1) {
85
+ await disposableUsers.create({ email: `${prefix}-${i}@example.com`, name: `Page ${i}` });
86
+ }
87
+ const pendingId = await disposableUsers.create({ email: `${prefix}-pending@example.com`, name: "Page pending" });
88
+ expect((await admin.post("/api/auth/admin/update-user", {
89
+ data: { userId: pendingId, data: { signupApproved: false } },
90
+ })).ok()).toBeTruthy();
91
+
92
+ type UserPage = { users: Array<{ email: string }>; total: number };
93
+ const list = async (query: Record<string, string>): Promise<UserPage> => {
94
+ const params = new URLSearchParams({ searchValue: prefix, searchField: "email", searchOperator: "contains", ...query });
95
+ const response = await admin.get(`/api/auth/admin/list-users?${params.toString()}`);
96
+ expect(response.ok()).toBeTruthy();
97
+ return (await response.json()) as UserPage;
98
+ };
99
+
100
+ const first = await list({ limit: "2", offset: "0", sortBy: "email", sortDirection: "asc" });
101
+ expect(first.total).toBe(4);
102
+ expect(first.users.map((u) => u.email)).toEqual([`${prefix}-0@example.com`, `${prefix}-1@example.com`]);
103
+ const second = await list({ limit: "2", offset: "2", sortBy: "email", sortDirection: "asc" });
104
+ expect(second.users.map((u) => u.email)).toEqual([`${prefix}-2@example.com`, `${prefix}-pending@example.com`]);
105
+ const pending = await list({ filterField: "signupApproved", filterValue: "false", filterOperator: "eq" });
106
+ expect(pending.users.map((u) => u.email)).toEqual([`${prefix}-pending@example.com`]);
107
+ await admin.dispose();
108
+ });
109
+
77
110
  test("revokeOtherSessions keeps the current session valid", async ({ playwright, disposableUsers }) => {
78
111
  // Use a throwaway account so we don't revoke the shared USER's ui storageState.
79
112
  const admin = await signedIn(playwright, ADMIN);
@@ -111,6 +144,7 @@ test("account capabilities reports optional-feature flags", async ({ playwright
111
144
  expect(Array.isArray(caps.providers)).toBe(true);
112
145
  expect(typeof caps.deleteAccount).toBe("boolean");
113
146
  expect(typeof caps.signupApprovalRequired).toBe("boolean");
147
+ expect(typeof caps.require2fa).toBe("boolean");
114
148
  await ctx.dispose();
115
149
  });
116
150
 
@@ -14,3 +14,44 @@ test("admin can view audit entries when the module is installed", async ({ page
14
14
  // seed sign-ins are audited via the better-auth hook, so an action row is present
15
15
  await expect(page.getByText("auth.login").first()).toBeVisible();
16
16
  });
17
+
18
+ test("admin filters the audit log on the server", async ({ page }) => {
19
+ await ready(page, "/admin/audit");
20
+ const table = page.getByRole("table", { name: "Audit log" });
21
+ test.skip((await page.getByRole("link", { name: "Audit log" }).count()) === 0, "audit-log module not installed");
22
+ await expect(table.getByText("auth.login").first()).toBeVisible();
23
+
24
+ await page.locator('button[data-slot="select-trigger"]').first().click(); // Action filter
25
+ await page.getByRole("option", { name: "auth.login", exact: true }).click();
26
+ await page.locator("#toolbar-search").fill("admin@example.com");
27
+ const filtered = page.waitForResponse((response) =>
28
+ response.url().includes("/api/audit-logs?") &&
29
+ response.url().includes("action=auth.login") &&
30
+ response.url().includes("actor=admin%40example.com"),
31
+ );
32
+ await page.getByRole("button", { name: "Search", exact: true }).click();
33
+ expect((await filtered).ok()).toBeTruthy();
34
+
35
+ const actions = table.locator("tbody tr td:nth-child(3)");
36
+ await expect(actions.first()).toHaveText("auth.login");
37
+ for (const text of await actions.allInnerTexts()) expect(text).toBe("auth.login");
38
+ await expect(table.getByText("admin@example.com").first()).toBeVisible();
39
+ });
40
+
41
+ test("admin pages through the audit log", async ({ page }) => {
42
+ await ready(page, "/admin/audit");
43
+ const table = page.getByRole("table", { name: "Audit log" });
44
+ test.skip((await page.getByRole("link", { name: "Audit log" }).count()) === 0, "audit-log module not installed");
45
+ await expect(table.getByRole("row").nth(1)).toBeVisible();
46
+ const next = table.getByRole("button", { name: /next/i });
47
+ test.skip(await next.isDisabled(), "a single page of audit entries");
48
+
49
+ const firstPage = await table.locator("tbody tr").first().innerText();
50
+ const pageTwo = page.waitForResponse((response) =>
51
+ response.url().includes("/api/audit-logs?") && response.url().includes("cursor="),
52
+ );
53
+ await next.click();
54
+ expect((await pageTwo).ok()).toBeTruthy();
55
+ await expect(table.locator('[aria-current="page"]')).toHaveText("2");
56
+ expect(await table.locator("tbody tr").first().innerText()).not.toBe(firstPage);
57
+ });
@@ -1,34 +1,53 @@
1
- import { expect, test } from "@playwright/test";
1
+ import { expect, test, type Response } from "@playwright/test";
2
2
  import { ready } from "../helpers/hydration";
3
3
 
4
- // Sorting via the shared DataTable. Users is server-sorted (manual), audit is
5
- // client-sorted — both drive the same clickable headers + aria-sort.
4
+ // Sorting via the shared DataTable. Users sorts on the server (manual mode
5
+ // refetches through Better Auth listUsers), and the audit log asks the server
6
+ // for the other time order — both drive the same clickable headers + aria-sort.
6
7
  test("users table sorts by a column header (server)", async ({ page }) => {
7
8
  await ready(page, "/admin/users");
8
9
  const header = page.getByRole("columnheader", { name: /Name/ });
9
10
  // Click the sort button *inside* the header — a bare name match collides with
10
11
  // the sidebar user button (a "Renamed" account contains "name").
11
12
  const sortByName = header.getByRole("button");
12
- const firstName = () => page.getByRole("row").nth(1).getByRole("cell").first().innerText();
13
+ const firstName = (): Promise<string> => page.getByRole("row").nth(1).getByRole("cell").first().innerText();
14
+ const sortedBy = (direction: string): Promise<Response> => page.waitForResponse((response) => {
15
+ const url = new URL(response.url());
16
+ return url.pathname === "/api/auth/admin/list-users" &&
17
+ url.searchParams.get("sortBy") === "name" &&
18
+ url.searchParams.get("sortDirection") === direction;
19
+ });
13
20
 
21
+ const ascending = sortedBy("asc");
14
22
  await sortByName.click();
15
23
  await expect(header).toHaveAttribute("aria-sort", "ascending");
16
- await page.waitForTimeout(400); // server refetch
24
+ await (await ascending).finished();
25
+ await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
17
26
  const asc = await firstName();
18
27
 
28
+ const descending = sortedBy("desc");
19
29
  await sortByName.click();
20
30
  await expect(header).toHaveAttribute("aria-sort", "descending");
21
- await page.waitForTimeout(400);
22
- const desc = await firstName();
23
-
24
- expect(asc).not.toBe(desc); // order flipped
31
+ await (await descending).finished();
32
+ // order flipped
33
+ await expect(page.getByRole("row").nth(1).getByRole("cell").first()).not.toHaveText(asc);
25
34
  });
26
35
 
27
- test("audit table sorts client-side", async ({ page }) => {
36
+ test("audit table sorts by time on the server", async ({ page }) => {
28
37
  await ready(page, "/admin/audit");
29
- test.skip((await page.getByRole("row").count()) < 3, "not enough audit entries");
30
- await page.getByRole("button", { name: "When" }).click(); // default desc -> asc
31
- await expect(page.getByRole("columnheader", { name: /When/ })).toHaveAttribute("aria-sort", "ascending");
38
+ const table = page.getByRole("table", { name: "Audit log" });
39
+ await expect(table.getByRole("row").nth(1)).toBeVisible();
40
+ test.skip((await table.getByRole("row").count()) < 4, "not enough audit entries");
41
+ const ascending = page.waitForResponse((response) =>
42
+ response.url().includes("/api/audit-logs?") && response.url().includes("order=asc"),
43
+ );
44
+ await table.getByRole("button", { name: "When" }).click(); // default desc -> asc
45
+ await expect(table.getByRole("columnheader", { name: /When/ })).toHaveAttribute("aria-sort", "ascending");
46
+ const entries = (await (await ascending).json()) as Array<{ createdAt: string }>;
47
+ const times = entries.map((entry) => Date.parse(entry.createdAt));
48
+ expect(times).toEqual([...times].sort((left, right) => left - right));
49
+ // Only time can be sorted: the other headers are plain labels.
50
+ await expect(table.getByRole("columnheader", { name: "Action" }).getByRole("button")).toHaveCount(0);
32
51
  });
33
52
 
34
53
  test("users table keeps responsive headers and cells aligned", async ({ page }) => {
@@ -3,8 +3,8 @@ import { createHash, randomBytes } from "node:crypto";
3
3
 
4
4
  // admin storageState (project default) — the admin is the resource owner.
5
5
  const b64url = (b: Buffer): string => b.toString("base64url");
6
- // This front-channel test stops at consent, so use the HTTPS callback required
7
- // for a web client without depending on a live relying-party callback server.
6
+ // The test intercepts the callback, so an HTTPS URL that a web client requires can
7
+ // be used without a live relying-party server.
8
8
  const REDIRECT = "https://example.com/callback";
9
9
 
10
10
  // Verifies the provider is correctly configured end to end on the front channel:
@@ -61,9 +61,18 @@ test("OIDC provider: discovery, JWKS, client registration, authorize handoff", a
61
61
  expect(consentUrl).toContain("/oauth2/consent");
62
62
  expect(consentUrl).toContain("client_id=");
63
63
 
64
- // The consent page renders (reached in a real browser navigation).
64
+ // The consent page renders (reached in a real browser navigation), and allowing
65
+ // it sends the browser back to the client with an authorization code.
66
+ await page.route(`${REDIRECT}**`, (route) => route.fulfill({ status: 200, body: "callback" }));
65
67
  await page.goto(consentUrl);
66
- await expect(page.getByRole("button", { name: "Allow" })).toBeVisible();
68
+ const allow = page.getByRole("button", { name: "Allow" });
69
+ await expect(allow).toBeVisible();
70
+ const callback = page.waitForURL((url) => url.href.startsWith(REDIRECT));
71
+ await allow.click();
72
+ await callback;
73
+ const returned = new URL(page.url());
74
+ expect(returned.searchParams.get("code")).toBeTruthy();
75
+ expect(returned.searchParams.get("state")).toBe("st");
67
76
  } finally {
68
77
  const disabled = await page.request.put("/api/account/settings", {
69
78
  data: { oidcProvider: false },
@@ -0,0 +1,46 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { ready } from "../helpers/hydration";
3
+
4
+ const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
5
+
6
+ // admin storageState (project default)
7
+ test.beforeEach(async ({ page }) => {
8
+ await page.context().addCookies([{ name: "locale", value: "en", url: base }]);
9
+ });
10
+
11
+ test("the first render does not embed the message catalogs", async ({ page }) => {
12
+ const response = await page.request.get("/admin");
13
+ expect(response.ok()).toBeTruthy();
14
+ const html = await response.text();
15
+ expect(html).toContain("Dashboard");
16
+ // A catalog key name only appears in serialized catalog data, never in markup.
17
+ expect(html).not.toContain("namePlaceholder");
18
+ });
19
+
20
+ test("client navigation keeps the server checks without resending messages", async ({ page }) => {
21
+ await ready(page, "/admin");
22
+ await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
23
+
24
+ const bodies: Promise<string>[] = [];
25
+ page.on("response", (response) => {
26
+ if (new URL(response.url()).pathname.endsWith("/__data.json")) bodies.push(response.text());
27
+ });
28
+ const sidebar = page.locator('[data-sidebar="sidebar"]');
29
+ const stops: Array<[link: string, heading: string]> = [
30
+ ["Users", "Users"],
31
+ ["Sessions", "User sessions"],
32
+ ["Settings", "Settings"],
33
+ ["Overview", "Dashboard"],
34
+ ];
35
+ for (const [link, heading] of stops) {
36
+ await sidebar.getByRole("link", { name: link, exact: true }).click();
37
+ await expect(page.getByRole("heading", { name: heading, exact: true })).toBeVisible();
38
+ }
39
+
40
+ expect(bodies.length).toBeGreaterThanOrEqual(stops.length);
41
+ for (const body of await Promise.all(bodies)) {
42
+ expect(body).not.toContain('"messages"');
43
+ expect(body.length).toBeLessThan(20_000);
44
+ }
45
+ await expect(sidebar.getByRole("link", { name: "Back to home" })).toBeVisible();
46
+ });
@@ -1,22 +1,85 @@
1
- import { expect, test } from "@playwright/test";
1
+ import { expect, test } from "../helpers/disposable-users";
2
2
  import { ready } from "../helpers/hydration";
3
3
 
4
+ const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
5
+
6
+ function isSessionList(url: string): boolean {
7
+ return new URL(url).pathname === "/api/admin/sessions";
8
+ }
9
+
4
10
  // admin storageState (project default) — sessions is an admin-only view
5
11
  test("sessions table shows the expires column", async ({ page }) => {
6
12
  await ready(page, "/admin/sessions");
7
13
  await expect(page.getByRole("columnheader", { name: "Expires" })).toBeVisible();
8
14
  });
9
15
 
16
+ test("sessions page loads one server page without per-user requests or tokens", async ({ page }) => {
17
+ const requested: string[] = [];
18
+ page.on("request", (request) => requested.push(request.url()));
19
+ const listed = page.waitForResponse((response) => isSessionList(response.url()));
20
+ await ready(page, "/admin/sessions");
21
+ const response = await listed;
22
+
23
+ expect(response.status()).toBe(200);
24
+ const body = (await response.json()) as { items: Array<Record<string, unknown>>; total: number };
25
+ expect(body.items.length).toBeLessThanOrEqual(15);
26
+ for (const item of body.items) expect(item).not.toHaveProperty("token");
27
+ await expect(page.getByRole("table", { name: "User sessions" }).getByRole("row").nth(1)).toBeVisible();
28
+ expect(requested.filter(isSessionList)).toHaveLength(1);
29
+ expect(requested.filter((url) => url.includes("/api/auth/admin/list-user-sessions"))).toEqual([]);
30
+ expect(requested.filter((url) => url.includes("/api/auth/admin/list-users"))).toEqual([]);
31
+ });
32
+
10
33
  test("admin sees user sessions across users", async ({ page }) => {
11
34
  await ready(page, "/admin/sessions");
12
35
  await expect(page.getByRole("heading", { name: "User sessions" })).toBeVisible();
13
36
  // Search by exact address — robust to pagination as sessions accumulate (the
14
37
  // admin account alone can fill the first page). This is the across-users view.
38
+ const searched = page.waitForResponse(
39
+ (response) => isSessionList(response.url()) && new URL(response.url()).searchParams.get("search") === "user@example.com",
40
+ );
15
41
  await page.locator("#toolbar-search").fill("user@example.com");
16
42
  await page.getByRole("button", { name: "Search", exact: true }).click();
43
+ expect((await searched).ok()).toBeTruthy();
17
44
  await expect(page.getByRole("main").getByText("user@example.com").first()).toBeVisible();
18
45
  // row actions are available
19
46
  await page.getByRole("row", { name: /user@example.com/ }).first().getByRole("button").click();
20
47
  await expect(page.getByRole("menuitem", { name: "Revoke session" })).toBeVisible();
21
48
  await expect(page.getByRole("menuitem", { name: "Revoke all for user" })).toBeVisible();
22
49
  });
50
+
51
+ test("admin revokes a session by id and only the current page reloads", async ({
52
+ page,
53
+ playwright,
54
+ disposableUsers,
55
+ }) => {
56
+ const email = `ui-sess-revoke-${Date.now()}@example.com`;
57
+ await disposableUsers.create({ email, name: "Session Revoke" });
58
+ // Sign the user in on an isolated context so the shared admin cookie is untouched.
59
+ const theirs = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: { origin: base } });
60
+ expect((await theirs.post("/api/auth/sign-in/email", { data: { email, password: "Podokit3e-Str0ng!pw" } })).ok())
61
+ .toBeTruthy();
62
+
63
+ await ready(page, "/admin/sessions");
64
+ await page.locator("#toolbar-search").fill(email);
65
+ await page.getByRole("button", { name: "Search", exact: true }).click();
66
+ const row = page.getByRole("row", { name: new RegExp(email) });
67
+ await expect(row).toHaveCount(1);
68
+
69
+ const revoked = page.waitForResponse(
70
+ (response) => response.request().method() === "DELETE" &&
71
+ new URL(response.url()).pathname.startsWith("/api/admin/sessions/"),
72
+ );
73
+ const reloaded = page.waitForResponse(
74
+ (response) => isSessionList(response.url()) && new URL(response.url()).searchParams.get("search") === email,
75
+ );
76
+ await row.getByRole("button").click();
77
+ await page.getByRole("menuitem", { name: "Revoke session" }).click();
78
+ expect((await revoked).status()).toBe(204);
79
+ const reload = await reloaded;
80
+ expect(new URL(reload.url()).searchParams.get("cursor")).toBeNull();
81
+ await expect(page.getByText("Session revoked")).toBeVisible();
82
+ await expect(row).toHaveCount(0);
83
+ expect(await (await theirs.get("/api/auth/get-session")).json()).toBeNull();
84
+ await theirs.dispose();
85
+ });