@podosoft/podokit 1.4.1 → 1.5.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 (97) hide show
  1. package/dist/deploy-compose-profile.js +2 -2
  2. package/dist/deploy-compose-render.js +3 -1
  3. package/dist/deploy-profile.js +2 -2
  4. package/dist/deploy-render.js +16 -8
  5. package/dist/deploy-schema.d.ts +7 -0
  6. package/dist/deploy-schema.js +40 -0
  7. package/dist/lockfile.d.ts +8 -1
  8. package/dist/lockfile.js +23 -4
  9. package/dist/provider.d.ts +1 -0
  10. package/dist/provider.js +5 -2
  11. package/dist/templates/fullstack/AGENTS.md +27 -2
  12. package/dist/templates/fullstack/README.md +1 -1
  13. package/dist/templates/fullstack/apps/api/package.json +1 -1
  14. package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +4 -0
  15. package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -2
  16. package/dist/templates/fullstack/apps/api/src/app.ts +6 -3
  17. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +28 -0
  18. package/dist/templates/fullstack/apps/api/src/config/providers.ts +4 -2
  19. package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +17 -6
  20. package/dist/templates/fullstack/apps/api/src/config/server-options.ts +5 -8
  21. package/dist/templates/fullstack/apps/api/src/core/client-address.spec.ts +82 -0
  22. package/dist/templates/fullstack/apps/api/src/core/client-address.ts +68 -0
  23. package/dist/templates/fullstack/apps/api/src/core/services.ts +30 -0
  24. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +66 -0
  25. package/dist/templates/fullstack/apps/api/src/database/database.ts +34 -11
  26. package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +54 -6
  27. package/dist/templates/fullstack/apps/web/Dockerfile +5 -1
  28. package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +116 -0
  29. package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +34 -4
  30. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +436 -6
  31. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +352 -38
  32. package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +5 -2
  33. package/dist/templates/fullstack/apps/web/src/lib/server/proxy-timeouts.ts +22 -0
  34. package/dist/templates/fullstack/apps/web/src/routes/healthz/+server.ts +13 -0
  35. package/dist/templates/fullstack/dot-env.docker.example +6 -0
  36. package/dist/templates/fullstack/dot-env.example +23 -0
  37. package/dist/templates/fullstack/infra/k3s/configmap.yaml +3 -0
  38. package/dist/templates/fullstack/infra/k3s/web-deployment.yaml +6 -3
  39. package/dist/templates/fullstack/tests/api/health.api.spec.ts +8 -0
  40. package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.spec.ts +152 -0
  41. package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.ts +112 -0
  42. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +24 -0
  43. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +18 -0
  44. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +36 -13
  45. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +13 -1
  46. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +24 -1
  47. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.test.ts +33 -0
  48. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.ts +34 -0
  49. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +62 -38
  50. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-toolbar.svelte +3 -1
  51. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +6 -0
  52. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +5 -0
  53. package/dist/templates/modules/admin-dashboard/files/tests/api/account-sessions.api.spec.ts +55 -0
  54. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +167 -154
  55. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +94 -57
  56. package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +75 -0
  57. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +49 -39
  58. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +26 -0
  59. package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +25 -0
  60. package/dist/templates/modules/admin-dashboard/files/tests/helpers/exclusive.ts +80 -0
  61. package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +3 -10
  62. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-sessions.ui.spec.ts +46 -0
  63. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +54 -47
  64. package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +15 -11
  65. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +35 -31
  66. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +90 -76
  67. package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +19 -15
  68. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +17 -3
  69. package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +15 -6
  70. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +71 -64
  71. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +89 -79
  72. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +25 -0
  73. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +54 -50
  74. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -1
  75. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +16 -0
  76. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +2 -1
  77. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +4 -1
  78. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +54 -6
  79. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -4
  80. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +19 -8
  81. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +83 -0
  82. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +51 -7
  83. package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.spec.ts +56 -0
  84. package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.ts +45 -0
  85. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +2 -59
  86. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +0 -65
  87. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +82 -21
  88. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +54 -15
  89. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +43 -5
  90. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +10 -2
  91. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +61 -3
  92. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +48 -15
  93. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +20 -9
  94. package/dist/templates/modules/rate-limit/module.manifest.json +6 -9
  95. package/dist/templates/todo/AGENTS.md +41 -5
  96. package/dist/templates/todo/README.md +1 -1
  97. package/package.json +2 -2
@@ -1,6 +1,7 @@
1
1
  import { expect, test } from "@playwright/test";
2
2
  import { ready } from "../helpers/hydration";
3
3
  import { ADMIN } from "../helpers/accounts";
4
+ import { exclusive } from "../helpers/exclusive";
4
5
 
5
6
  const PNG = Buffer.from(
6
7
  "iVBORw0KGgoAAAANSUhEUgAAAAIAAAADAQMAAACDJEzCAAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAAGUExURWYzmf///129H+IAAAABYktHRAH/Ai3eAAAAB3RJTUUH6gcVCSUTzXBDTwAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyNi0wNy0yMVQwOTozNzoxOSswMDowMOuehLIAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjYtMDctMjFUMDk6Mzc6MTkrMDA6MDCawzwOAAAAKHRFWHRkYXRlOnRpbWVzdGFtcAAyMDI2LTA3LTIxVDA5OjM3OjE5KzAwOjAwzdYd0QAAAAtJREFUCNdjYAABAAAGAAFm9MlsAAAAAElFTkSuQmCC",
@@ -173,8 +174,8 @@ test("account security and sessions sub-navigation", async ({ page }) => {
173
174
  /**
174
175
  * ⚠ THE SUITE ALWAYS SIGNS IN FIRST, WHICH IS WHY THIS NEEDED A MOCK.
175
176
  *
176
- * better-auth guards `/list-sessions` with its freshness middleware and
177
- * `session.freshAge` defaults to 24 hours, so the endpoint returns 403
177
+ * The sessions list keeps better-auth's freshness rule, and `session.freshAge`
178
+ * defaults to 24 hours, so `GET /api/account/sessions` returns 403
178
179
  * `SESSION_NOT_FRESH` to anyone who signed in yesterday. The seeded session is
179
180
  * minutes old, so the test above passes for every run while the screen is broken for
180
181
  * real users — the tab showed an empty table under an untranslated "Session is not
@@ -182,11 +183,14 @@ test("account security and sessions sub-navigation", async ({ page }) => {
182
183
  * own setup prevents.
183
184
  */
184
185
  test("sessions tab explains a session that is too old to list them", async ({ page }) => {
185
- await page.route("**/api/auth/list-sessions", (route) =>
186
+ await page.route("**/api/account/sessions?*", (route) =>
186
187
  route.fulfill({
187
188
  status: 403,
188
189
  contentType: "application/json",
189
- body: JSON.stringify({ message: "Session is not fresh", code: "SESSION_NOT_FRESH" }),
190
+ body: JSON.stringify({
191
+ success: false,
192
+ error: { code: "SESSION_NOT_FRESH", message: "Session is not fresh", statusCode: 403 },
193
+ }),
190
194
  }),
191
195
  );
192
196
 
@@ -227,54 +231,57 @@ test("account nav shows the core sections", async ({ page }) => {
227
231
  });
228
232
 
229
233
  test("connected account unlink uses the local account row id @smoke", async ({ page }) => {
230
- const provider = "google";
231
- let unlinkBody: unknown;
232
- await page.route("**/api/auth/list-accounts", (route) =>
233
- route.fulfill({
234
- status: 200,
235
- contentType: "application/json",
236
- body: JSON.stringify([
237
- { id: "local-account-id", providerId: provider, accountId: "provider-subject" },
238
- ]),
239
- }),
240
- );
241
- await page.route("**/api/auth/unlink-account", async (route) => {
242
- unlinkBody = route.request().postDataJSON();
243
- await route.fulfill({ status: 200, contentType: "application/json", body: "{}" });
244
- });
234
+ test.slow();
235
+ await exclusive("social-providers", async () => {
236
+ const provider = "google";
237
+ let unlinkBody: unknown;
238
+ await page.route("**/api/auth/list-accounts", (route) =>
239
+ route.fulfill({
240
+ status: 200,
241
+ contentType: "application/json",
242
+ body: JSON.stringify([
243
+ { id: "local-account-id", providerId: provider, accountId: "provider-subject" },
244
+ ]),
245
+ }),
246
+ );
247
+ await page.route("**/api/auth/unlink-account", async (route) => {
248
+ unlinkBody = route.request().postDataJSON();
249
+ await route.fulfill({ status: 200, contentType: "application/json", body: "{}" });
250
+ });
245
251
 
246
- try {
247
- const enabled = await page.request.put("/api/account/auth-config", {
248
- data: {
249
- social: {
250
- [provider]: {
251
- enabled: true,
252
- clientId: "podokit",
253
- clientSecret: "podokit",
252
+ try {
253
+ const enabled = await page.request.put("/api/account/auth-config", {
254
+ data: {
255
+ social: {
256
+ [provider]: {
257
+ enabled: true,
258
+ clientId: "podokit",
259
+ clientSecret: "podokit",
260
+ },
254
261
  },
255
262
  },
256
- },
257
- });
258
- expect(enabled.ok()).toBeTruthy();
259
- await expect
260
- .poll(async () => {
261
- const response = await page.request.get("/api/account/capabilities");
262
- const body = (await response.json()) as { providers?: string[] };
263
- return body.providers ?? [];
264
- }, { timeout: 8_000 })
265
- .toContain(provider);
263
+ });
264
+ expect(enabled.ok()).toBeTruthy();
265
+ await expect
266
+ .poll(async () => {
267
+ const response = await page.request.get("/api/account/capabilities");
268
+ const body = (await response.json()) as { providers?: string[] };
269
+ return body.providers ?? [];
270
+ }, { timeout: 8_000 })
271
+ .toContain(provider);
266
272
 
267
- await ready(page, "/admin/account");
268
- await page.getByRole("button", { name: "Connected", exact: true }).click();
269
- await page.getByRole("button", { name: "Disconnect", exact: true }).click();
273
+ await ready(page, "/admin/account");
274
+ await page.getByRole("button", { name: "Connected", exact: true }).click();
275
+ await page.getByRole("button", { name: "Disconnect", exact: true }).click();
270
276
 
271
- await expect.poll(() => unlinkBody).toEqual({ accountId: "local-account-id" });
272
- await expect(page.getByText("Disconnected", { exact: true })).toBeVisible();
273
- } finally {
274
- await page.request.put("/api/account/auth-config", {
275
- data: { social: { [provider]: { enabled: false } } },
276
- });
277
- }
277
+ await expect.poll(() => unlinkBody).toEqual({ accountId: "local-account-id" });
278
+ await expect(page.getByText("Disconnected", { exact: true })).toBeVisible();
279
+ } finally {
280
+ await page.request.put("/api/account/auth-config", {
281
+ data: { social: { [provider]: { enabled: false } } },
282
+ });
283
+ }
284
+ });
278
285
  });
279
286
 
280
287
  test("two-factor setup shows a scannable QR code when enabled", async ({ page }) => {
@@ -1,5 +1,6 @@
1
1
  import { expect, test } from "@playwright/test";
2
2
  import { ready } from "../helpers/hydration";
3
+ import { exclusive } from "../helpers/exclusive";
3
4
 
4
5
  // admin storageState (project default). The Appearance controls live in their
5
6
  // own "Appearance" settings tab.
@@ -49,16 +50,19 @@ test("appearance: advanced reveals per-token pickers", async ({ page }) => {
49
50
  });
50
51
 
51
52
  test("appearance: pick a preset, save, apply live, then restore @smoke", async ({ page }) => {
52
- await openAppearance(page);
53
- await page.getByRole("button", { name: "Slate" }).click();
54
- await page.getByRole("button", { name: "Medium" }).click();
55
- await page.getByRole("button", { name: "Save changes" }).click();
56
- await expect(page.getByText("General settings saved.").first()).toBeVisible();
57
- // Applied globally via the scoped override stylesheet.
58
- await expect.poll(async () => page.locator("#podokit-theme").count()).toBeGreaterThan(0);
53
+ test.slow();
54
+ await exclusive("site-theme", async () => {
55
+ await openAppearance(page);
56
+ await page.getByRole("button", { name: "Slate" }).click();
57
+ await page.getByRole("button", { name: "Medium" }).click();
58
+ await page.getByRole("button", { name: "Save changes" }).click();
59
+ await expect(page.getByText("General settings saved.").first()).toBeVisible();
60
+ // Applied globally via the scoped override stylesheet.
61
+ await expect.poll(async () => page.locator("#podokit-theme").count()).toBeGreaterThan(0);
59
62
 
60
- // "Restore defaults" reverts everything in one click and persists.
61
- await page.getByRole("button", { name: "Restore defaults" }).click();
62
- await expect(page.getByText("General settings saved.").first()).toBeVisible();
63
- await expect.poll(async () => page.locator("#podokit-theme").count()).toBe(0);
63
+ // "Restore defaults" reverts everything in one click and persists.
64
+ await page.getByRole("button", { name: "Restore defaults" }).click();
65
+ await expect(page.getByText("General settings saved.").first()).toBeVisible();
66
+ await expect.poll(async () => page.locator("#podokit-theme").count()).toBe(0);
67
+ });
64
68
  });
@@ -2,6 +2,7 @@ import { expect, test } from "@playwright/test";
2
2
  import { safeAuthRedirect, withAuthRedirect } from "../../apps/web/src/lib/auth-redirect";
3
3
  import { anonState, ADMIN } from "../helpers/accounts";
4
4
  import { ready } from "../helpers/hydration";
5
+ import { exclusive } from "../helpers/exclusive";
5
6
 
6
7
  test.use({ storageState: anonState });
7
8
 
@@ -66,39 +67,42 @@ test("closed social registration errors explain that existing users may sign in"
66
67
  });
67
68
 
68
69
  test("configured social providers appear on the login page", async ({ page, playwright }) => {
69
- const admin = await playwright.request.newContext({
70
- baseURL: base,
71
- extraHTTPHeaders: { origin: base },
72
- });
73
- await admin.post("/api/auth/sign-in/email", {
74
- data: { email: ADMIN.email, password: ADMIN.password },
75
- });
76
- try {
77
- expect(
78
- (await admin.put("/api/account/auth-config", {
79
- data: {
80
- social: {
81
- google: {
82
- enabled: true,
83
- clientId: "dummy-google-client-id",
84
- clientSecret: "dummy-google-client-secret",
70
+ test.slow();
71
+ await exclusive("social-providers", async () => {
72
+ const admin = await playwright.request.newContext({
73
+ baseURL: base,
74
+ extraHTTPHeaders: { origin: base },
75
+ });
76
+ await admin.post("/api/auth/sign-in/email", {
77
+ data: { email: ADMIN.email, password: ADMIN.password },
78
+ });
79
+ try {
80
+ expect(
81
+ (await admin.put("/api/account/auth-config", {
82
+ data: {
83
+ social: {
84
+ google: {
85
+ enabled: true,
86
+ clientId: "dummy-google-client-id",
87
+ clientSecret: "dummy-google-client-secret",
88
+ },
85
89
  },
86
90
  },
87
- },
88
- })).ok(),
89
- ).toBeTruthy();
90
- await expect
91
- .poll(async () => {
92
- await page.goto("/login");
93
- return page.getByRole("button", { name: "Continue with Google" }).count();
94
- }, { timeout: 8_000 })
95
- .toBe(1);
96
- } finally {
97
- await admin.put("/api/account/auth-config", {
98
- data: { social: { google: { enabled: false } } },
99
- });
100
- await admin.dispose();
101
- }
91
+ })).ok(),
92
+ ).toBeTruthy();
93
+ await expect
94
+ .poll(async () => {
95
+ await page.goto("/login");
96
+ return page.getByRole("button", { name: "Continue with Google" }).count();
97
+ }, { timeout: 8_000 })
98
+ .toBe(1);
99
+ } finally {
100
+ await admin.put("/api/account/auth-config", {
101
+ data: { social: { google: { enabled: false } } },
102
+ });
103
+ await admin.dispose();
104
+ }
105
+ });
102
106
  });
103
107
 
104
108
  test("forgot-password shows a sent confirmation", async ({ page }) => {
@@ -1,12 +1,14 @@
1
1
  import { expect, test, type Page } from "@playwright/test";
2
2
  import { anonState } from "../helpers/accounts";
3
3
  import { ready } from "../helpers/hydration";
4
+ import { exclusive } from "../helpers/exclusive";
4
5
 
5
6
  // Verifies that the General settings actually take effect (not just persist).
6
7
  // The admin storageState (project default) drives the toggles; a fresh anonymous
7
- // context checks the public-facing result. Serial workers (workers:1) make the
8
- // toggle-then-restore pattern safe for the rest of the suite; restores run in a
9
- // `finally` so a mid-test failure still leaves the shared site settings clean.
8
+ // context checks the public-facing result. The site-general lock keeps other
9
+ // specs that save these settings out while one runs, also with --workers above
10
+ // 1; restores run in a `finally` so a mid-test failure still leaves the shared
11
+ // site settings clean.
10
12
 
11
13
  async function saveGeneral(
12
14
  page: Page,
@@ -29,97 +31,109 @@ async function saveGeneral(
29
31
  }
30
32
 
31
33
  test("general: empty locale displays the application default", async ({ page }) => {
32
- await ready(page, "/admin/settings");
33
- const origin = new URL(page.url()).origin;
34
- const settingsUrl = `${origin}/api/site/settings`;
35
- const previous = await (await page.request.get(settingsUrl)).json() as { locale?: string };
36
-
37
- try {
38
- expect((await page.request.put(settingsUrl, { data: { locale: "" } })).ok()).toBeTruthy();
34
+ test.slow();
35
+ await exclusive("site-general", async () => {
39
36
  await ready(page, "/admin/settings");
40
- await expect(page.locator("#locale")).toContainText("English");
41
- } finally {
42
- await page.request.put(settingsUrl, { data: { locale: previous.locale ?? "" } });
43
- }
37
+ const origin = new URL(page.url()).origin;
38
+ const settingsUrl = `${origin}/api/site/settings`;
39
+ const previous = await (await page.request.get(settingsUrl)).json() as { locale?: string };
40
+
41
+ try {
42
+ expect((await page.request.put(settingsUrl, { data: { locale: "" } })).ok()).toBeTruthy();
43
+ await ready(page, "/admin/settings");
44
+ await expect(page.locator("#locale")).toContainText("English");
45
+ } finally {
46
+ await page.request.put(settingsUrl, { data: { locale: previous.locale ?? "" } });
47
+ }
48
+ });
44
49
  });
45
50
 
46
51
  test("general: footer text and links render on public pages", async ({ page, browser }) => {
47
- await ready(page, "/admin/settings");
48
- const origin = new URL(page.url()).origin;
49
- const footer = page.getByLabel("Footer text");
50
- const support = page.getByLabel("Support email");
51
- const origFooter = await footer.inputValue();
52
- const origSupport = await support.inputValue();
53
- await saveGeneral(
54
- page,
55
- async () => {
56
- await footer.fill("© 2026 PodoKit Test");
57
- await support.fill("help@example.com");
58
- },
59
- { footerText: "© 2026 PodoKit Test", supportEmail: "help@example.com" },
60
- );
61
- try {
62
- const ctx = await browser.newContext({ storageState: anonState });
63
- const anon = await ctx.newPage();
64
- await anon.goto(`${origin}/login`);
65
- await expect(anon.getByText("© 2026 PodoKit Test")).toBeVisible();
66
- await expect(anon.getByRole("link", { name: "Support" })).toHaveAttribute("href", "mailto:help@example.com");
67
- await ctx.close();
68
- } finally {
52
+ test.slow();
53
+ await exclusive("site-general", async () => {
69
54
  await ready(page, "/admin/settings");
55
+ const origin = new URL(page.url()).origin;
56
+ const footer = page.getByLabel("Footer text");
57
+ const support = page.getByLabel("Support email");
58
+ const origFooter = await footer.inputValue();
59
+ const origSupport = await support.inputValue();
70
60
  await saveGeneral(
71
61
  page,
72
62
  async () => {
73
- await page.getByLabel("Footer text").fill(origFooter);
74
- await page.getByLabel("Support email").fill(origSupport);
63
+ await footer.fill("© 2026 PodoKit Test");
64
+ await support.fill("help@example.com");
75
65
  },
76
- { footerText: origFooter, supportEmail: origSupport },
66
+ { footerText: "© 2026 PodoKit Test", supportEmail: "help@example.com" },
77
67
  );
78
- }
68
+ try {
69
+ const ctx = await browser.newContext({ storageState: anonState });
70
+ const anon = await ctx.newPage();
71
+ await anon.goto(`${origin}/login`);
72
+ await expect(anon.getByText("© 2026 PodoKit Test")).toBeVisible();
73
+ await expect(anon.getByRole("link", { name: "Support" })).toHaveAttribute("href", "mailto:help@example.com");
74
+ await ctx.close();
75
+ } finally {
76
+ await ready(page, "/admin/settings");
77
+ await saveGeneral(
78
+ page,
79
+ async () => {
80
+ await page.getByLabel("Footer text").fill(origFooter);
81
+ await page.getByLabel("Support email").fill(origSupport);
82
+ },
83
+ { footerText: origFooter, supportEmail: origSupport },
84
+ );
85
+ }
86
+ });
79
87
  });
80
88
 
81
89
  test("general: maintenance mode holds non-admins on the maintenance page", async ({ page, browser }) => {
82
- await ready(page, "/admin/settings");
83
- const origin = new URL(page.url()).origin;
84
- await page.getByRole("switch", { name: "Maintenance mode" }).click();
85
- await saveGeneral(page);
86
- try {
87
- const ctx = await browser.newContext({ storageState: anonState });
88
- const anon = await ctx.newPage();
89
- await anon.goto(`${origin}/`);
90
- await expect(anon).toHaveURL(/\/maintenance$/);
91
- await expect(anon.getByRole("heading", { name: "Under maintenance" })).toBeVisible();
92
- await ctx.close();
93
- // the admin is not held back
94
- await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible();
95
- } finally {
90
+ test.slow();
91
+ await exclusive("site-general", async () => {
96
92
  await ready(page, "/admin/settings");
97
- const t = page.getByRole("switch", { name: "Maintenance mode" });
98
- if (await t.isChecked()) {
99
- await t.click();
100
- await saveGeneral(page);
93
+ const origin = new URL(page.url()).origin;
94
+ await page.getByRole("switch", { name: "Maintenance mode" }).click();
95
+ await saveGeneral(page);
96
+ try {
97
+ const ctx = await browser.newContext({ storageState: anonState });
98
+ const anon = await ctx.newPage();
99
+ await anon.goto(`${origin}/`);
100
+ await expect(anon).toHaveURL(/\/maintenance$/);
101
+ await expect(anon.getByRole("heading", { name: "Under maintenance" })).toBeVisible();
102
+ await ctx.close();
103
+ // the admin is not held back
104
+ await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible();
105
+ } finally {
106
+ await ready(page, "/admin/settings");
107
+ const t = page.getByRole("switch", { name: "Maintenance mode" });
108
+ if (await t.isChecked()) {
109
+ await t.click();
110
+ await saveGeneral(page);
111
+ }
101
112
  }
102
- }
113
+ });
103
114
  });
104
115
 
105
116
  test("general: closing sign-up redirects /signup and hides the link", async ({ page, browser }) => {
106
- await ready(page, "/admin/settings");
107
- const origin = new URL(page.url()).origin;
108
- await page.getByRole("switch", { name: "Allow public sign-up" }).click();
109
- await saveGeneral(page);
110
- try {
111
- const ctx = await browser.newContext({ storageState: anonState });
112
- const anon = await ctx.newPage();
113
- await anon.goto(`${origin}/signup`);
114
- await expect(anon).toHaveURL(/\/login/);
115
- await expect(anon.getByRole("link", { name: "Sign up" })).toHaveCount(0);
116
- await ctx.close();
117
- } finally {
117
+ test.slow();
118
+ await exclusive("site-general", async () => {
118
119
  await ready(page, "/admin/settings");
119
- const t = page.getByRole("switch", { name: "Allow public sign-up" });
120
- if (!(await t.isChecked())) {
121
- await t.click();
122
- await saveGeneral(page);
120
+ const origin = new URL(page.url()).origin;
121
+ await page.getByRole("switch", { name: "Allow public sign-up" }).click();
122
+ await saveGeneral(page);
123
+ try {
124
+ const ctx = await browser.newContext({ storageState: anonState });
125
+ const anon = await ctx.newPage();
126
+ await anon.goto(`${origin}/signup`);
127
+ await expect(anon).toHaveURL(/\/login/);
128
+ await expect(anon.getByRole("link", { name: "Sign up" })).toHaveCount(0);
129
+ await ctx.close();
130
+ } finally {
131
+ await ready(page, "/admin/settings");
132
+ const t = page.getByRole("switch", { name: "Allow public sign-up" });
133
+ if (!(await t.isChecked())) {
134
+ await t.click();
135
+ await saveGeneral(page);
136
+ }
123
137
  }
124
- }
138
+ });
125
139
  });
@@ -1,6 +1,7 @@
1
1
  import { expect, test } from "@playwright/test";
2
2
  import { ADMIN, adminState, anonState } from "../helpers/accounts";
3
3
  import { ready } from "../helpers/hydration";
4
+ import { exclusive } from "../helpers/exclusive";
4
5
 
5
6
  test.use({ storageState: anonState });
6
7
 
@@ -23,23 +24,26 @@ test("language switch updates messages and <html lang>", async ({ page }) => {
23
24
  });
24
25
 
25
26
  test("explicit English overrides a Korean site fallback", async ({ page, playwright }) => {
26
- const admin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
27
- await admin.post("/api/auth/sign-in/email", {
28
- data: { email: ADMIN.email, password: ADMIN.password },
29
- });
30
- const previous = await (await admin.get("/api/site/settings")).json() as { locale?: string };
27
+ test.slow();
28
+ await exclusive("site-general", async () => {
29
+ const admin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
30
+ await admin.post("/api/auth/sign-in/email", {
31
+ data: { email: ADMIN.email, password: ADMIN.password },
32
+ });
33
+ const previous = await (await admin.get("/api/site/settings")).json() as { locale?: string };
31
34
 
32
- try {
33
- expect((await admin.put("/api/site/settings", { data: { locale: "ko" } })).ok()).toBeTruthy();
34
- await page.context().addCookies([{ name: "locale", value: "en", url: base }]);
35
- await ready(page, "/login");
35
+ try {
36
+ expect((await admin.put("/api/site/settings", { data: { locale: "ko" } })).ok()).toBeTruthy();
37
+ await page.context().addCookies([{ name: "locale", value: "en", url: base }]);
38
+ await ready(page, "/login");
36
39
 
37
- await expect(page.locator("html")).toHaveAttribute("lang", "en");
38
- await expect(page.getByRole("button", { name: "Sign in", exact: true })).toBeVisible();
39
- } finally {
40
- await admin.put("/api/site/settings", { data: { locale: previous.locale ?? "en" } });
41
- await admin.dispose();
42
- }
40
+ await expect(page.locator("html")).toHaveAttribute("lang", "en");
41
+ await expect(page.getByRole("button", { name: "Sign in", exact: true })).toBeVisible();
42
+ } finally {
43
+ await admin.put("/api/site/settings", { data: { locale: previous.locale ?? "en" } });
44
+ await admin.dispose();
45
+ }
46
+ });
43
47
  });
44
48
 
45
49
  test("managed admin labels react to locale changes", async ({ browser }) => {
@@ -1,5 +1,6 @@
1
- import { expect, test } from "@playwright/test";
1
+ import { expect, test, type Page } from "@playwright/test";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
+ import { exclusive } from "../helpers/exclusive";
3
4
  import { ready } from "../helpers/hydration";
4
5
 
5
6
  // admin storageState (project default) — the admin is the resource owner.
@@ -14,6 +15,12 @@ const REDIRECT = "https://example.com/callback";
14
15
  // back-channel token exchange is driven by an OIDC client SDK / real RP — see
15
16
  // docs/modules.md for the manual round-trip checklist.
16
17
  test("OIDC provider: discovery, JWKS, client registration, authorize handoff", async ({ page, baseURL }) => {
18
+ // tests/api/auth.api.spec.ts also toggles the provider; the lock keeps the two apart.
19
+ test.slow();
20
+ await exclusive("oidc-provider", () => frontChannel(page, baseURL));
21
+ });
22
+
23
+ async function frontChannel(page: Page, baseURL: string | undefined): Promise<void> {
17
24
  const headers = { origin: baseURL ?? "" };
18
25
  const enabled = await page.request.put("/api/account/settings", {
19
26
  data: { oidcProvider: true },
@@ -30,7 +37,14 @@ test("OIDC provider: discovery, JWKS, client registration, authorize handoff", a
30
37
  expect(typeof disc[field]).toBe("string");
31
38
  }
32
39
  // JWKS exposes at least one signing key (the OAuth provider needs it for id_tokens).
33
- const jwks = await (await page.request.get(disc.jwks_uri)).json();
40
+ // Discovery names BETTER_AUTH_URL's origin, which can be a public tunnel
41
+ // (publicUrl in .podokit/dev.json) that this runner cannot reach, so read the
42
+ // key set's path from the app under test.
43
+ const jwksUri = new URL(disc.jwks_uri);
44
+ expect(jwksUri.origin).toBe(new URL(disc.issuer).origin);
45
+ const jwksResponse = await page.request.get(`${jwksUri.pathname}${jwksUri.search}`);
46
+ expect(jwksResponse.ok(), await jwksResponse.text()).toBeTruthy();
47
+ const jwks = await jwksResponse.json();
34
48
  expect(Array.isArray(jwks.keys) && jwks.keys.length).toBeTruthy();
35
49
 
36
50
  // Dynamic client registration returns usable credentials. Retry: right after
@@ -82,4 +96,4 @@ test("OIDC provider: discovery, JWKS, client registration, authorize handoff", a
82
96
  });
83
97
  expect(disabled.ok(), await disabled.text()).toBeTruthy();
84
98
  }
85
- });
99
+ }
@@ -18,6 +18,19 @@ test("the first render does not embed the message catalogs", async ({ page }) =>
18
18
  });
19
19
 
20
20
  test("client navigation keeps the server checks without resending messages", async ({ page }) => {
21
+ // Read each page-data body on its way to the page. Chromium reports a
22
+ // __data.json fetch as aborted (net::ERR_ABORTED) once SvelteKit has consumed
23
+ // its body, and then keeps no body for response.text() to return.
24
+ const pending = new Map<string, (body: string) => void>();
25
+ await page.route(
26
+ (url) => url.pathname.endsWith("/__data.json"),
27
+ async (route) => {
28
+ const response = await route.fetch();
29
+ const body = await response.text();
30
+ pending.get(new URL(route.request().url()).pathname)?.(body);
31
+ await route.fulfill({ response, body });
32
+ },
33
+ );
21
34
  await ready(page, "/admin");
22
35
  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
23
36
 
@@ -29,13 +42,9 @@ test("client navigation keeps the server checks without resending messages", asy
29
42
  ["Overview", "/admin", "Dashboard"],
30
43
  ];
31
44
  for (const [link, path, heading] of stops) {
32
- // Read each body before the next click: once the page navigates on, the
33
- // previous response body is no longer available.
34
- const data = page.waitForResponse(
35
- (response) => new URL(response.url()).pathname === `${path}/__data.json`,
36
- );
45
+ const data = new Promise<string>((resolve) => pending.set(`${path}/__data.json`, resolve));
37
46
  await sidebar.getByRole("link", { name: link, exact: true }).click();
38
- const body = await (await data).text();
47
+ const body = await data;
39
48
  expect(body).not.toContain('"messages"');
40
49
  expect(body.length).toBeLessThan(20_000);
41
50
  await expect(page.getByRole("heading", { name: heading, exact: true })).toBeVisible();