@podosoft/podokit 0.14.0 → 0.16.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 (122) hide show
  1. package/README.md +20 -0
  2. package/dist/add.d.ts +4 -0
  3. package/dist/add.js +59 -1
  4. package/dist/create.js +8 -1
  5. package/dist/deploy-profile.d.ts +92 -0
  6. package/dist/deploy-profile.js +603 -0
  7. package/dist/deploy-render.d.ts +18 -0
  8. package/dist/deploy-render.js +651 -0
  9. package/dist/deploy.d.ts +65 -0
  10. package/dist/deploy.js +851 -0
  11. package/dist/dev.d.ts +1 -0
  12. package/dist/dev.js +29 -5
  13. package/dist/index.d.ts +8 -0
  14. package/dist/index.js +147 -1
  15. package/dist/lib.d.ts +6 -0
  16. package/dist/lib.js +18 -1
  17. package/dist/lockfile.js +3 -0
  18. package/dist/remove.js +7 -0
  19. package/dist/templates/fullstack-nest-svelte/AGENTS.md +1 -0
  20. package/dist/templates/fullstack-nest-svelte/README.md +19 -8
  21. package/dist/templates/fullstack-nest-svelte/apps/api/Dockerfile +2 -2
  22. package/dist/templates/fullstack-nest-svelte/apps/api/package.json +1 -0
  23. package/dist/templates/fullstack-nest-svelte/apps/api/src/config/redis.connection.spec.ts +53 -0
  24. package/dist/templates/fullstack-nest-svelte/apps/api/src/config/redis.connection.ts +69 -0
  25. package/dist/templates/fullstack-nest-svelte/apps/api/src/database/migrate.ts +16 -0
  26. package/dist/templates/fullstack-nest-svelte/apps/api/src/health/health.controller.ts +19 -4
  27. package/dist/templates/fullstack-nest-svelte/apps/api/src/health/health.module.ts +5 -1
  28. package/dist/templates/fullstack-nest-svelte/apps/api/src/health/readiness.service.spec.ts +22 -0
  29. package/dist/templates/fullstack-nest-svelte/apps/api/src/health/readiness.service.ts +33 -0
  30. package/dist/templates/fullstack-nest-svelte/apps/web/Dockerfile +2 -2
  31. package/dist/templates/fullstack-nest-svelte/apps/web/src/app.html +2 -0
  32. package/dist/templates/fullstack-nest-svelte/compose.dev.yaml +3 -5
  33. package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/SKILL.md +20 -0
  34. package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/agents/openai.yaml +4 -0
  35. package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/profile.md +37 -0
  36. package/dist/templates/fullstack-nest-svelte/dot-agents/skills/podokit-deploy/references/rollback.md +18 -0
  37. package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-deploy/SKILL.md +8 -0
  38. package/dist/templates/fullstack-nest-svelte/dot-env.example +6 -0
  39. package/dist/templates/fullstack-nest-svelte/infra/k3s/api-deployment.yaml +1 -1
  40. package/dist/templates/fullstack-nest-svelte/infra/k3s/ingress.yaml +0 -4
  41. package/dist/templates/fullstack-nest-svelte/tests/playwright.config.ts +6 -4
  42. package/dist/templates/fullstack-nest-svelte/tests/playwright.extensions.ts +44 -0
  43. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-menu.svelte +35 -4
  44. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +33 -4
  45. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +6 -0
  46. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +18 -10
  47. package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +11 -6
  48. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +18 -8
  49. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +15 -9
  50. package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +1 -0
  51. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +83 -0
  52. package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +13 -10
  53. package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +69 -3
  54. package/dist/templates/modules/admin-dashboard/files/tests/seed.teardown.ts +46 -0
  55. package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +13 -0
  56. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +3 -6
  57. package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +34 -28
  58. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +29 -39
  59. package/dist/templates/modules/admin-dashboard/module.manifest.json +1 -1
  60. package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key-verifier.spec.ts +36 -0
  61. package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key-verifier.ts +20 -0
  62. package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key.guard.spec.ts +49 -0
  63. package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key.guard.ts +3 -11
  64. package/dist/templates/modules/api-key-auth/files/apps/api/src/api-key/api-key.module.ts +3 -1
  65. package/dist/templates/modules/api-key-auth/module.manifest.json +1 -1
  66. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-environment.ts +32 -0
  67. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.runtime.spec.ts +35 -0
  68. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +3 -2
  69. package/dist/templates/modules/auth/files/apps/api/src/health/health.controller.ts +19 -4
  70. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/queue.ts +4 -5
  71. package/dist/templates/modules/bullmq/module.manifest.json +4 -1
  72. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +36 -0
  73. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.ts +26 -5
  74. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.spec.ts +35 -0
  75. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +21 -2
  76. package/dist/templates/modules/object-storage-s3/module.manifest.json +3 -0
  77. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +85 -0
  78. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +110 -0
  79. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +132 -0
  80. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +100 -0
  81. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.integration.spec.ts +91 -0
  82. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +228 -0
  83. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +97 -38
  84. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +11 -6
  85. package/dist/templates/modules/rate-limit/module.manifest.json +23 -9
  86. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.spec.ts +28 -0
  87. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.ts +52 -11
  88. package/dist/templates/modules/redis/module.manifest.json +16 -1
  89. package/dist/templates/modules/sse/files/apps/api/src/events/events.controller.ts +17 -3
  90. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +9 -2
  91. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +61 -0
  92. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +44 -4
  93. package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.redis.spec.ts +42 -0
  94. package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.spec.ts +97 -0
  95. package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.ts +165 -0
  96. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +41 -0
  97. package/dist/templates/modules/sse/module.manifest.json +16 -3
  98. package/dist/templates/todo/AGENTS.md +1 -0
  99. package/dist/templates/todo/README.md +12 -8
  100. package/dist/templates/todo/apps/api/Dockerfile +2 -2
  101. package/dist/templates/todo/apps/api/package.json +1 -0
  102. package/dist/templates/todo/apps/api/src/config/redis.connection.spec.ts +53 -0
  103. package/dist/templates/todo/apps/api/src/config/redis.connection.ts +69 -0
  104. package/dist/templates/todo/apps/api/src/database/migrate.ts +16 -0
  105. package/dist/templates/todo/apps/api/src/health/health.controller.ts +19 -4
  106. package/dist/templates/todo/apps/api/src/health/health.module.ts +5 -1
  107. package/dist/templates/todo/apps/api/src/health/readiness.service.spec.ts +22 -0
  108. package/dist/templates/todo/apps/api/src/health/readiness.service.ts +33 -0
  109. package/dist/templates/todo/apps/web/Dockerfile +2 -2
  110. package/dist/templates/todo/compose.dev.yaml +3 -5
  111. package/dist/templates/todo/dot-agents/skills/podokit-deploy/SKILL.md +20 -0
  112. package/dist/templates/todo/dot-agents/skills/podokit-deploy/agents/openai.yaml +4 -0
  113. package/dist/templates/todo/dot-agents/skills/podokit-deploy/references/profile.md +28 -0
  114. package/dist/templates/todo/dot-agents/skills/podokit-deploy/references/rollback.md +5 -0
  115. package/dist/templates/todo/dot-claude/skills/podokit-deploy/SKILL.md +8 -0
  116. package/dist/templates/todo/dot-env.example +6 -0
  117. package/dist/templates/todo/infra/k3s/api-deployment.yaml +1 -1
  118. package/dist/templates/todo/infra/k3s/ingress.yaml +0 -4
  119. package/dist/templates/todo/tests/playwright.config.ts +6 -4
  120. package/dist/templates/todo/tests/playwright.extensions.ts +44 -0
  121. package/dist/update.js +17 -13
  122. package/package.json +2 -2
@@ -1,4 +1,4 @@
1
- import { expect, test } from "@playwright/test";
1
+ import { expect, test } from "../helpers/disposable-users";
2
2
  import { ADMIN, USER } from "../helpers/accounts";
3
3
 
4
4
  const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
@@ -13,7 +13,7 @@ async function signedIn(
13
13
  return ctx;
14
14
  }
15
15
 
16
- test("audit log records auth/admin actions", async ({ playwright }) => {
16
+ test("audit log records auth/admin actions", async ({ playwright, disposableUsers }) => {
17
17
  const admin = await signedIn(playwright, ADMIN);
18
18
  const probe = await admin.get("/api/audit-logs");
19
19
  test.skip(probe.status() === 404, "audit-log module not installed");
@@ -24,7 +24,7 @@ test("audit log records auth/admin actions", async ({ playwright }) => {
24
24
  data: { email, password: "Podokit3e-Str0ng!pw", name: "Audit", role: "user" },
25
25
  });
26
26
  expect(created.ok()).toBeTruthy();
27
- const userId = (await created.json())?.user?.id as string;
27
+ const userId = await disposableUsers.trackResponse(created);
28
28
 
29
29
  const res = await admin.get("/api/audit-logs");
30
30
  expect(res.ok()).toBeTruthy();
@@ -34,6 +34,7 @@ test("audit log records auth/admin actions", async ({ playwright }) => {
34
34
  expect(entry?.actorEmail, "audit entry records the acting admin").toBe(ADMIN.email);
35
35
 
36
36
  await admin.post("/api/auth/admin/remove-user", { data: { userId } });
37
+ disposableUsers.forget(userId);
37
38
  await admin.dispose();
38
39
  });
39
40
 
@@ -47,11 +48,14 @@ test("the audit log is admin-only", async ({ playwright }) => {
47
48
  await user.dispose();
48
49
  });
49
50
 
50
- test("sending a verification email is audited", async ({ playwright }) => {
51
+ test("sending a verification email is audited", async ({ playwright, disposableUsers }) => {
51
52
  const admin = await signedIn(playwright, ADMIN);
52
53
  test.skip((await admin.get("/api/audit-logs")).status() === 404, "audit-log module not installed");
53
54
  const email = `verif-audit-${Date.now()}@example.com`;
54
- await admin.post("/api/auth/admin/create-user", { data: { email, password: "Podokit3e-Str0ng!pw", name: "VA", role: "user" } });
55
+ const created = await admin.post("/api/auth/admin/create-user", {
56
+ data: { email, password: "Podokit3e-Str0ng!pw", name: "VA", role: "user" },
57
+ });
58
+ await disposableUsers.trackResponse(created);
55
59
  await admin.post("/api/auth/send-verification-email", { data: { email, callbackURL: `${base}/admin` } });
56
60
  const entries = (await (await admin.get("/api/audit-logs")).json()) as Array<{ action: string; targetLabel: string | null }>;
57
61
  expect(entries.some((e) => e.action === "auth.verification_sent"), "verification send should be audited").toBeTruthy();
@@ -60,7 +64,7 @@ test("sending a verification email is audited", async ({ playwright }) => {
60
64
 
61
65
  // Audit logging is a DB-backed on/off toggle (auth_config `server`, applied live).
62
66
  // Runs after the recording tests and restores the flag so nothing else is affected.
63
- test("audit logging honours the DB on/off toggle @smoke", async ({ playwright }) => {
67
+ test("audit logging honours the DB on/off toggle @smoke", async ({ playwright, disposableUsers }) => {
64
68
  const admin = await signedIn(playwright, ADMIN);
65
69
  test.skip((await admin.get("/api/audit-logs")).status() === 404, "audit-log module not installed");
66
70
  const auditLog = async () => (await (await admin.get("/api/account/capabilities")).json()).auditLog as boolean;
@@ -68,8 +72,12 @@ test("audit logging honours the DB on/off toggle @smoke", async ({ playwright })
68
72
  ((await (await admin.get("/api/audit-logs")).json()) as Array<{ action: string; targetLabel: string | null }>).some(
69
73
  (e) => e.action === "user.create" && e.targetLabel === label,
70
74
  );
71
- const createUser = async (email: string) =>
72
- (await (await admin.post("/api/auth/admin/create-user", { data: { email, password: "Podokit3e-Str0ng!pw", name: "T", role: "user" } })).json())?.user?.id as string;
75
+ const createUser = async (email: string): Promise<string> => {
76
+ const response = await admin.post("/api/auth/admin/create-user", {
77
+ data: { email, password: "Podokit3e-Str0ng!pw", name: "T", role: "user" },
78
+ });
79
+ return disposableUsers.trackResponse(response);
80
+ };
73
81
 
74
82
  try {
75
83
  // OFF → the action is not recorded. Wait past the config-store cache TTL so the
@@ -81,6 +89,7 @@ test("audit logging honours the DB on/off toggle @smoke", async ({ playwright })
81
89
  const offId = await createUser(offEmail);
82
90
  expect(await recorded(offEmail), "no audit entry while disabled").toBe(false);
83
91
  await admin.post("/api/auth/admin/remove-user", { data: { userId: offId } });
92
+ disposableUsers.forget(offId);
84
93
 
85
94
  // ON → recording resumes.
86
95
  await admin.put("/api/account/auth-config", { data: { server: { auditLog: true } } });
@@ -90,6 +99,7 @@ test("audit logging honours the DB on/off toggle @smoke", async ({ playwright })
90
99
  const onId = await createUser(onEmail);
91
100
  expect(await recorded(onEmail), "audit entry recorded once re-enabled").toBe(true);
92
101
  await admin.post("/api/auth/admin/remove-user", { data: { userId: onId } });
102
+ disposableUsers.forget(onId);
93
103
  } finally {
94
104
  await admin.put("/api/account/auth-config", { data: { server: { auditLog: true } } });
95
105
  await admin.dispose();
@@ -1,4 +1,4 @@
1
- import { expect, test } from "@playwright/test";
1
+ import { expect, test } from "../helpers/disposable-users";
2
2
  import { ADMIN, USER } from "../helpers/accounts";
3
3
 
4
4
  const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
@@ -13,7 +13,7 @@ async function signedIn(
13
13
  return ctx;
14
14
  }
15
15
 
16
- test("admin can create a user, set its password, and remove it", async ({ playwright }) => {
16
+ test("admin can create a user, set its password, and remove it", async ({ playwright, disposableUsers }) => {
17
17
  const admin = await signedIn(playwright, ADMIN);
18
18
  const email = `api-crud-${Date.now()}@example.com`;
19
19
 
@@ -21,7 +21,7 @@ test("admin can create a user, set its password, and remove it", async ({ playwr
21
21
  data: { email, password: "Podokit3e-Str0ng!pw", name: "API CRUD", role: "user" },
22
22
  });
23
23
  expect(created.ok()).toBeTruthy();
24
- const id = (await created.json())?.user?.id as string;
24
+ const id = await disposableUsers.trackResponse(created);
25
25
  expect(id).toBeTruthy();
26
26
 
27
27
  const setPw = await admin.post("/api/auth/admin/set-user-password", {
@@ -37,6 +37,7 @@ test("admin can create a user, set its password, and remove it", async ({ playwr
37
37
 
38
38
  const removed = await admin.post("/api/auth/admin/remove-user", { data: { userId: id } });
39
39
  expect(removed.ok()).toBeTruthy();
40
+ disposableUsers.forget(id);
40
41
  await admin.dispose();
41
42
  });
42
43
 
@@ -49,13 +50,13 @@ test("a normal user cannot create users (403)", async ({ playwright }) => {
49
50
  await user.dispose();
50
51
  });
51
52
 
52
- test("admin can list and revoke a user's sessions", async ({ playwright }) => {
53
+ test("admin can list and revoke a user's sessions", async ({ playwright, disposableUsers }) => {
53
54
  const admin = await signedIn(playwright, ADMIN);
54
55
  const email = `api-sess-${Date.now()}@example.com`;
55
56
  const created = await admin.post("/api/auth/admin/create-user", {
56
57
  data: { email, password: "Podokit3e-Str0ng!pw", name: "API Sess", role: "user" },
57
58
  });
58
- const userId = (await created.json())?.user?.id as string;
59
+ const userId = await disposableUsers.trackResponse(created);
59
60
 
60
61
  // the user signs in -> creates a session
61
62
  const theirs = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
@@ -69,14 +70,18 @@ test("admin can list and revoke a user's sessions", async ({ playwright }) => {
69
70
  const revoked = await admin.post("/api/auth/admin/revoke-user-sessions", { data: { userId } });
70
71
  expect(revoked.ok()).toBeTruthy();
71
72
  await admin.post("/api/auth/admin/remove-user", { data: { userId } });
73
+ disposableUsers.forget(userId);
72
74
  await admin.dispose();
73
75
  });
74
76
 
75
- test("revokeOtherSessions keeps the current session valid", async ({ playwright }) => {
77
+ test("revokeOtherSessions keeps the current session valid", async ({ playwright, disposableUsers }) => {
76
78
  // Use a throwaway account so we don't revoke the shared USER's ui storageState.
77
79
  const admin = await signedIn(playwright, ADMIN);
78
80
  const email = `api-revoke-others-${Date.now()}@example.com`;
79
- await admin.post("/api/auth/admin/create-user", { data: { email, password: "Podokit3e-Str0ng!pw", name: "RO", role: "user" } });
81
+ const created = await admin.post("/api/auth/admin/create-user", {
82
+ data: { email, password: "Podokit3e-Str0ng!pw", name: "RO", role: "user" },
83
+ });
84
+ await disposableUsers.trackResponse(created);
80
85
  await admin.dispose();
81
86
 
82
87
  const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
@@ -109,13 +114,13 @@ test("account capabilities reports optional-feature flags", async ({ playwright
109
114
  await ctx.dispose();
110
115
  });
111
116
 
112
- test("admin can update a user and impersonate them", async ({ playwright }) => {
117
+ test("admin can update a user and impersonate them", async ({ playwright, disposableUsers }) => {
113
118
  const admin = await signedIn(playwright, ADMIN);
114
119
  const email = `api-edit-${Date.now()}@example.com`;
115
120
  const created = await admin.post("/api/auth/admin/create-user", {
116
121
  data: { email, password: "Podokit3e-Str0ng!pw", name: "Edit Me", role: "user" },
117
122
  });
118
- const userId = (await created.json())?.user?.id as string;
123
+ const userId = await disposableUsers.trackResponse(created);
119
124
 
120
125
  const updated = await admin.post("/api/auth/admin/update-user", { data: { userId, data: { name: "Updated" } } });
121
126
  expect(updated.ok()).toBeTruthy();
@@ -131,5 +136,6 @@ test("admin can update a user and impersonate them", async ({ playwright }) => {
131
136
  await imp.dispose();
132
137
 
133
138
  await admin.post("/api/auth/admin/remove-user", { data: { userId } });
139
+ disposableUsers.forget(userId);
134
140
  await admin.dispose();
135
141
  });
@@ -3,4 +3,5 @@ export const ADMIN = { name: "Admin", email: "admin@example.com", password: "Pod
3
3
  export const USER = { name: "Normal User", email: "user@example.com", password: "Podokit3e-Str0ng!pw" };
4
4
  export const adminState = "playwright/.auth/admin.json";
5
5
  export const userState = "playwright/.auth/user.json";
6
+ export const userBaselineState = "playwright/.auth/user-baseline.json";
6
7
  export const anonState = { cookies: [], origins: [] } as const;
@@ -0,0 +1,83 @@
1
+ import {
2
+ expect,
3
+ test as base,
4
+ type APIResponse,
5
+ } from "@playwright/test";
6
+
7
+ const baseURL = process.env.E2E_BASE_URL ?? "http://localhost:5001";
8
+ const origin = { origin: baseURL };
9
+
10
+ type CreateUserInput = {
11
+ email: string;
12
+ password?: string;
13
+ name?: string;
14
+ role?: string;
15
+ };
16
+
17
+ export type DisposableUsers = {
18
+ create(input: CreateUserInput): Promise<string>;
19
+ trackResponse(response: APIResponse): Promise<string>;
20
+ track(userId: string): void;
21
+ forget(userId: string): void;
22
+ };
23
+
24
+ async function readCreatedUserId(response: APIResponse): Promise<string> {
25
+ expect(response.ok()).toBeTruthy();
26
+ const body = (await response.json()) as { user?: { id?: unknown } };
27
+ expect(typeof body.user?.id).toBe("string");
28
+ return body.user?.id as string;
29
+ }
30
+
31
+ export const test = base.extend<{ disposableUsers: DisposableUsers }>({
32
+ disposableUsers: async ({ context }, use) => {
33
+ // Reuse the browser context's storageState-backed request client. Creating
34
+ // a second admin login here can revoke the browser session in applications
35
+ // that enforce a single concurrent session.
36
+ const request = context.request;
37
+ const userIds = new Set<string>();
38
+
39
+ const disposableUsers: DisposableUsers = {
40
+ async create(input): Promise<string> {
41
+ const response = await request.post("/api/auth/admin/create-user", {
42
+ headers: origin,
43
+ data: {
44
+ password: "Podokit3e-Str0ng!pw",
45
+ name: "Throwaway",
46
+ role: "user",
47
+ ...input,
48
+ },
49
+ });
50
+ const userId = await readCreatedUserId(response);
51
+ userIds.add(userId);
52
+ return userId;
53
+ },
54
+ async trackResponse(response): Promise<string> {
55
+ const userId = await readCreatedUserId(response);
56
+ userIds.add(userId);
57
+ return userId;
58
+ },
59
+ track(userId): void {
60
+ userIds.add(userId);
61
+ },
62
+ forget(userId): void {
63
+ userIds.delete(userId);
64
+ },
65
+ };
66
+
67
+ try {
68
+ await use(disposableUsers);
69
+ } finally {
70
+ for (const userId of userIds) {
71
+ const response = await request.post("/api/auth/admin/remove-user", {
72
+ headers: origin,
73
+ data: { userId },
74
+ });
75
+ if (!response.ok() && response.status() !== 404) {
76
+ throw new Error(`Failed to remove disposable user ${userId}: HTTP ${response.status()}`);
77
+ }
78
+ }
79
+ }
80
+ },
81
+ });
82
+
83
+ export { expect };
@@ -1,9 +1,21 @@
1
1
  import { defineConfig, devices } from "@playwright/test";
2
+ import { loadPlaywrightProjects } from "./playwright.extensions";
2
3
 
3
4
  // admin-dashboard overlay: adds a `setup` project that seeds admin/user sessions
4
5
  // (storageState) which the `ui` project reuses. Serial + single worker because
5
6
  // tests share one backend/DB. Runs against a live stack on E2E_BASE_URL.
6
7
  const baseURL = process.env.E2E_BASE_URL ?? "http://localhost:5001";
8
+ const coreProjects = [
9
+ { name: "api", testMatch: /.*\.api\.spec\.ts/, dependencies: ["setup"] },
10
+ { name: "setup", testMatch: /.*\.setup\.ts/, teardown: "cleanup" },
11
+ { name: "cleanup", testMatch: /.*\.teardown\.ts/ },
12
+ {
13
+ name: "ui",
14
+ testMatch: /.*\.ui\.spec\.ts/,
15
+ dependencies: ["setup"],
16
+ use: { ...devices["Desktop Chrome"], storageState: "playwright/.auth/admin.json" },
17
+ },
18
+ ];
7
19
 
8
20
  export default defineConfig({
9
21
  testDir: ".",
@@ -13,14 +25,5 @@ export default defineConfig({
13
25
  workers: 1,
14
26
  reporter: [["list"], ["html", { open: "never" }]],
15
27
  use: { baseURL, trace: "on-first-retry", screenshot: "only-on-failure" },
16
- projects: [
17
- { name: "api", testMatch: /.*\.api\.spec\.ts/, dependencies: ["setup"] },
18
- { name: "setup", testMatch: /.*\.setup\.ts/ },
19
- {
20
- name: "ui",
21
- testMatch: /.*\.ui\.spec\.ts/,
22
- dependencies: ["setup"],
23
- use: { ...devices["Desktop Chrome"], storageState: "playwright/.auth/admin.json" },
24
- },
25
- ],
28
+ projects: [...coreProjects, ...loadPlaywrightProjects(coreProjects)],
26
29
  });
@@ -1,10 +1,46 @@
1
1
  import { test as setup, expect } from "@playwright/test";
2
- import { ADMIN, USER, adminState, userState, type Account } from "./helpers/accounts";
2
+ import { mkdirSync, writeFileSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ import {
5
+ ADMIN,
6
+ USER,
7
+ adminState,
8
+ userState,
9
+ userBaselineState,
10
+ type Account,
11
+ } from "./helpers/accounts";
3
12
 
4
13
  const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
14
+ mkdirSync(dirname(adminState), { recursive: true });
15
+
16
+ type StorageState = Awaited<
17
+ ReturnType<import("@playwright/test").APIRequestContext["storageState"]>
18
+ >;
19
+
20
+ function withTestLocale(state: StorageState): StorageState {
21
+ const url = new URL(base);
22
+ return {
23
+ ...state,
24
+ cookies: [
25
+ ...state.cookies.filter((cookie) => cookie.name !== "locale"),
26
+ {
27
+ name: "locale",
28
+ value: "en",
29
+ domain: url.hostname,
30
+ path: "/",
31
+ expires: -1,
32
+ httpOnly: false,
33
+ secure: url.protocol === "https:",
34
+ sameSite: "Lax",
35
+ },
36
+ ],
37
+ };
38
+ }
5
39
 
6
40
  // Seed a session via the API (reliable, no UI hydration races) and save its
7
- // cookies as storageState for the browser `ui` project to reuse.
41
+ // cookies as storageState for the browser `ui` project to reuse. Pin the suite
42
+ // locale because UI locators use the generated English catalog and must not
43
+ // inherit an application's configured default language.
8
44
  async function seedSession(
9
45
  playwright: import("@playwright/test").PlaywrightWorkerArgs["playwright"],
10
46
  account: Account,
@@ -26,7 +62,7 @@ async function seedSession(
26
62
  if (!Array.isArray(sessions) || sessions.length <= 1) break;
27
63
  expect((await ctx.post("/api/auth/revoke-other-sessions")).ok(), `clear old sessions for ${account.email}`).toBeTruthy();
28
64
  }
29
- await ctx.storageState({ path });
65
+ writeFileSync(path, `${JSON.stringify(withTestLocale(await ctx.storageState()), null, 2)}\n`);
30
66
  await ctx.dispose();
31
67
  }
32
68
 
@@ -48,3 +84,33 @@ setup("enable optional features", async ({ playwright }) => {
48
84
  setup("seed user session", async ({ playwright }) => {
49
85
  await seedSession(playwright, USER, userState);
50
86
  });
87
+
88
+ setup("capture the user cleanup baseline", async ({ playwright }) => {
89
+ const ctx = await playwright.request.newContext({
90
+ baseURL: base,
91
+ extraHTTPHeaders: { origin: base },
92
+ });
93
+ try {
94
+ const signIn = await ctx.post("/api/auth/sign-in/email", {
95
+ data: { email: ADMIN.email, password: ADMIN.password },
96
+ });
97
+ expect(signIn.ok(), "sign in before capturing the user baseline").toBeTruthy();
98
+ const ids: string[] = [];
99
+ const limit = 100;
100
+ for (let offset = 0; ; offset += limit) {
101
+ const response = await ctx.get(`/api/auth/admin/list-users?limit=${limit}&offset=${offset}`);
102
+ expect(response.ok(), "list users before the suite").toBeTruthy();
103
+ const body = (await response.json()) as { users?: Array<{ id?: unknown }> };
104
+ const page = body.users ?? [];
105
+ ids.push(
106
+ ...page
107
+ .map((user) => user.id)
108
+ .filter((id): id is string => typeof id === "string"),
109
+ );
110
+ if (page.length < limit) break;
111
+ }
112
+ writeFileSync(userBaselineState, `${JSON.stringify(ids, null, 2)}\n`);
113
+ } finally {
114
+ await ctx.dispose();
115
+ }
116
+ });
@@ -0,0 +1,46 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { expect, test as cleanup } from "@playwright/test";
3
+ import { ADMIN, userBaselineState } from "./helpers/accounts";
4
+
5
+ const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
6
+
7
+ type ListedUser = {
8
+ id: string;
9
+ };
10
+
11
+ cleanup("remove users created by the suite", async ({ playwright }) => {
12
+ if (!existsSync(userBaselineState)) return;
13
+ const baseline = new Set(JSON.parse(readFileSync(userBaselineState, "utf8")) as string[]);
14
+ const ctx = await playwright.request.newContext({
15
+ baseURL: base,
16
+ extraHTTPHeaders: { origin: base },
17
+ });
18
+
19
+ try {
20
+ const signIn = await ctx.post("/api/auth/sign-in/email", {
21
+ data: { email: ADMIN.email, password: ADMIN.password },
22
+ });
23
+ expect(signIn.ok(), "sign in before cleaning users").toBeTruthy();
24
+
25
+ const users: ListedUser[] = [];
26
+ const limit = 100;
27
+ for (let offset = 0; ; offset += limit) {
28
+ const response = await ctx.get(`/api/auth/admin/list-users?limit=${limit}&offset=${offset}`);
29
+ expect(response.ok(), "list users during cleanup").toBeTruthy();
30
+ const body = (await response.json()) as { users?: ListedUser[] };
31
+ const page = body.users ?? [];
32
+ users.push(...page);
33
+ if (page.length < limit) break;
34
+ }
35
+
36
+ for (const user of users) {
37
+ if (baseline.has(user.id)) continue;
38
+ const response = await ctx.post("/api/auth/admin/remove-user", {
39
+ data: { userId: user.id },
40
+ });
41
+ expect(response.ok(), `remove disposable user ${user.id}`).toBeTruthy();
42
+ }
43
+ } finally {
44
+ await ctx.dispose();
45
+ }
46
+ });
@@ -30,3 +30,16 @@ test("audit table sorts client-side", async ({ page }) => {
30
30
  await page.getByRole("button", { name: "When" }).click(); // default desc -> asc
31
31
  await expect(page.getByRole("columnheader", { name: /When/ })).toHaveAttribute("aria-sort", "ascending");
32
32
  });
33
+
34
+ test("users table keeps responsive headers and cells aligned", async ({ page }) => {
35
+ await page.setViewportSize({ width: 390, height: 844 });
36
+ await ready(page, "/admin/users");
37
+ const table = page.getByRole("table", { name: "Users" });
38
+ const visibleHeaders = table.locator("thead th:visible");
39
+ const visibleCells = table.locator("tbody tr").first().locator("td:visible");
40
+
41
+ await expect(table.getByRole("columnheader", { name: "Role" })).toBeHidden();
42
+ await expect(table.getByRole("columnheader", { name: "Status" })).toBeHidden();
43
+ await expect(table.getByRole("columnheader", { name: "Joined" })).toBeHidden();
44
+ expect(await visibleHeaders.count()).toBe(await visibleCells.count());
45
+ });
@@ -1,4 +1,4 @@
1
- import { expect, test } from "@playwright/test";
1
+ import { expect, test } from "../helpers/disposable-users";
2
2
  import { ready } from "../helpers/hydration";
3
3
  import { clearMailpit, mailpitReachable, waitForLink } from "../helpers/mailpit";
4
4
 
@@ -17,14 +17,11 @@ test("verification badges stay hidden when the feature is off", async ({ page })
17
17
  });
18
18
 
19
19
  // Admin can trigger a password-reset email for a user (not verification-gated).
20
- test("admin sends a password reset email to a user", async ({ page }) => {
20
+ test("admin sends a password reset email to a user", async ({ page, disposableUsers }) => {
21
21
  test.skip(!(await mailpitReachable()), "Mailpit not available");
22
22
  await ready(page, "/admin/users");
23
23
  const email = `admin-reset-${Date.now()}@example.com`;
24
- await page.request.post("/api/auth/admin/create-user", {
25
- headers: { origin: base },
26
- data: { email, password: "Podokit3e-Str0ng!pw", name: "AR", role: "user" },
27
- });
24
+ await disposableUsers.create({ email, name: "AR" });
28
25
  await clearMailpit();
29
26
  await page.locator("#toolbar-search").fill(email);
30
27
  await page.getByRole("button", { name: "Search", exact: true }).click();
@@ -1,16 +1,13 @@
1
- import { expect, test, type Page } from "@playwright/test";
1
+ import { type Page } from "@playwright/test";
2
+ import { expect, test, type DisposableUsers } from "../helpers/disposable-users";
2
3
  import { ready } from "../helpers/hydration";
3
4
 
4
5
  const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
5
6
 
6
7
  // Seed a throwaway user via the API (admin cookies come from storageState) so
7
8
  // the mutating UI tests don't touch the shared admin/user seed accounts.
8
- async function seedUser(page: Page, email: string): Promise<void> {
9
- const res = await page.request.post("/api/auth/admin/create-user", {
10
- headers: { origin: base },
11
- data: { email, password: "Podokit3e-Str0ng!pw", name: "Throwaway", role: "user" },
12
- });
13
- expect(res.ok()).toBeTruthy();
9
+ async function seedUser(disposableUsers: DisposableUsers, email: string): Promise<string> {
10
+ return disposableUsers.create({ email });
14
11
  }
15
12
 
16
13
  // Open the Manage two-pane modal for a seeded user.
@@ -21,7 +18,7 @@ async function openManage(page: Page, email: string): Promise<void> {
21
18
  await page.getByRole("menuitem", { name: "Manage" }).click();
22
19
  }
23
20
 
24
- test("admin can create a user via the dialog @smoke", async ({ page }) => {
21
+ test("admin can create a user via the dialog @smoke", async ({ page, disposableUsers }) => {
25
22
  await ready(page, "/admin/users");
26
23
  const email = `ui-create-${Date.now()}@example.com`;
27
24
  await page.getByRole("button", { name: "Add user" }).click();
@@ -30,7 +27,13 @@ test("admin can create a user via the dialog @smoke", async ({ page }) => {
30
27
  await dialog.getByLabel("Email").fill(email);
31
28
  await dialog.getByLabel("Password", { exact: true }).fill("Podokit3e-Str0ng!pw");
32
29
  await dialog.getByLabel("Confirm password").fill("Podokit3e-Str0ng!pw");
30
+ const created = page.waitForResponse(
31
+ (response) =>
32
+ response.url().endsWith("/api/auth/admin/create-user") &&
33
+ response.request().method() === "POST",
34
+ );
33
35
  await dialog.getByRole("button", { name: "Create" }).click();
36
+ await disposableUsers.trackResponse(await created);
34
37
  // Wait for the create to settle (dialog closes + list reloads) before searching,
35
38
  // otherwise the search input can be refilled by the post-create reload.
36
39
  await expect(page.getByRole("dialog")).toBeHidden();
@@ -51,10 +54,10 @@ test("create rejects mismatched passwords", async ({ page }) => {
51
54
  await expect(dialog.getByText("Passwords do not match")).toBeVisible();
52
55
  });
53
56
 
54
- test("manage: edit profile name", async ({ page }) => {
57
+ test("manage: edit profile name", async ({ page, disposableUsers }) => {
55
58
  await ready(page, "/admin/users");
56
59
  const email = `ui-edit-${Date.now()}@example.com`;
57
- await seedUser(page, email);
60
+ await seedUser(disposableUsers, email);
58
61
  await openManage(page, email);
59
62
  const dialog = page.getByRole("dialog");
60
63
  await dialog.getByLabel("Name").fill("Edited Name");
@@ -62,10 +65,10 @@ test("manage: edit profile name", async ({ page }) => {
62
65
  await expect(page.getByText("User updated")).toBeVisible();
63
66
  });
64
67
 
65
- test("manage: set a password (and reject mismatch)", async ({ page }) => {
68
+ test("manage: set a password (and reject mismatch)", async ({ page, disposableUsers }) => {
66
69
  await ready(page, "/admin/users");
67
70
  const email = `ui-setpw-${Date.now()}@example.com`;
68
- await seedUser(page, email);
71
+ await seedUser(disposableUsers, email);
69
72
  await openManage(page, email);
70
73
  const dialog = page.getByRole("dialog");
71
74
  await dialog.getByRole("button", { name: "Security" }).click();
@@ -78,10 +81,10 @@ test("manage: set a password (and reject mismatch)", async ({ page }) => {
78
81
  await expect(page.getByText("Password updated")).toBeVisible();
79
82
  });
80
83
 
81
- test("manage: ban a user with a reason", async ({ page }) => {
84
+ test("manage: ban a user with a reason", async ({ page, disposableUsers }) => {
82
85
  await ready(page, "/admin/users");
83
86
  const email = `ui-ban-${Date.now()}@example.com`;
84
- await seedUser(page, email);
87
+ await seedUser(disposableUsers, email);
85
88
  await openManage(page, email);
86
89
  const dialog = page.getByRole("dialog");
87
90
  await dialog.getByRole("button", { name: "Security" }).click();
@@ -90,10 +93,10 @@ test("manage: ban a user with a reason", async ({ page }) => {
90
93
  await expect(page.getByText("User banned", { exact: true })).toBeVisible();
91
94
  });
92
95
 
93
- test("manage: revoke a user's session", async ({ page, playwright }) => {
96
+ test("manage: revoke a user's session", async ({ page, playwright, disposableUsers }) => {
94
97
  await ready(page, "/admin/users");
95
98
  const email = `ui-sess-${Date.now()}@example.com`;
96
- await seedUser(page, email);
99
+ await seedUser(disposableUsers, email);
97
100
  // create a session for the user in an isolated context (don't touch the admin cookie)
98
101
  const uctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: { origin: base } });
99
102
  expect((await uctx.post("/api/auth/sign-in/email", { data: { email, password: "Podokit3e-Str0ng!pw" } })).ok()).toBeTruthy();
@@ -105,39 +108,42 @@ test("manage: revoke a user's session", async ({ page, playwright }) => {
105
108
  await expect(page.getByText("Session revoked")).toBeVisible();
106
109
  });
107
110
 
108
- test("manage: delete a user from the danger zone", async ({ page }) => {
111
+ test("manage: delete a user from the danger zone", async ({ page, disposableUsers }) => {
109
112
  await ready(page, "/admin/users");
110
113
  const email = `ui-delete-${Date.now()}@example.com`;
111
- await seedUser(page, email);
114
+ const userId = await seedUser(disposableUsers, email);
112
115
  await openManage(page, email);
113
116
  const dialog = page.getByRole("dialog");
114
117
  await dialog.getByRole("button", { name: "Danger zone" }).click();
115
118
  await dialog.getByRole("button", { name: "Delete" }).click(); // arm
116
119
  await dialog.getByRole("button", { name: "Delete" }).click(); // confirm
117
120
  await expect(page.getByText("User deleted")).toBeVisible();
121
+ disposableUsers.forget(userId);
118
122
  await page.locator("#toolbar-search").fill(email);
119
123
  await page.getByRole("button", { name: "Search", exact: true }).click();
120
124
  await expect(page.getByRole("cell", { name: email })).toHaveCount(0);
121
125
  });
122
126
 
123
- test("users list shows pagination beyond one page", async ({ page }) => {
127
+ test("users list shows pagination beyond one page", async ({ page, disposableUsers }) => {
124
128
  await ready(page, "/admin/users");
125
- for (let i = 0; i < 6; i += 1) {
126
- await page.request.post("/api/auth/admin/create-user", {
127
- headers: { origin: base },
128
- data: { email: `pg-${Date.now()}-${i}@example.com`, password: "Podokit3e-Str0ng!pw", name: `Pg${i}`, role: "user" },
129
+ const prefix = `pg-${Date.now()}`;
130
+ for (let i = 0; i < 16; i += 1) {
131
+ await disposableUsers.create({
132
+ email: `${prefix}-${i}@example.com`,
133
+ name: `Page user ${i}`,
129
134
  });
130
135
  }
131
- await page.reload();
132
- const nav = page.getByRole("navigation", { name: "pagination" });
136
+ await page.locator("#toolbar-search").fill(prefix);
137
+ await page.getByRole("button", { name: "Search", exact: true }).click();
138
+ const nav = page.getByRole("navigation", { name: "pagination" }).first();
133
139
  await expect(nav).toBeVisible();
134
- await expect(nav.getByRole("button", { name: "Page 2" })).toBeVisible();
140
+ await expect(nav.getByRole("button", { name: "Page 2", exact: true }).first()).toBeVisible();
135
141
  });
136
142
 
137
- test("manage modal closes from its footer", async ({ page }) => {
143
+ test("manage modal closes from its footer", async ({ page, disposableUsers }) => {
138
144
  await ready(page, "/admin/users");
139
145
  const email = `ui-close-${Date.now()}@example.com`;
140
- await seedUser(page, email);
146
+ await seedUser(disposableUsers, email);
141
147
  await openManage(page, email);
142
148
  // footer Close (a visible button, distinct from the header X) dismisses the modal
143
149
  await page.getByRole("dialog").getByRole("button", { name: "Close", exact: true }).last().click();