@podosoft/podokit 1.4.1 → 1.4.2
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.
- package/dist/deploy-compose-render.js +2 -1
- package/dist/deploy-render.js +14 -8
- package/dist/lockfile.d.ts +8 -1
- package/dist/lockfile.js +20 -3
- package/dist/templates/fullstack/AGENTS.md +12 -0
- package/dist/templates/fullstack/README.md +1 -1
- package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +4 -0
- package/dist/templates/fullstack/apps/api/src/app.extensions.ts +3 -1
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +66 -0
- package/dist/templates/fullstack/apps/api/src/database/database.ts +34 -11
- package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +54 -6
- package/dist/templates/fullstack/apps/web/Dockerfile +1 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +116 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +34 -4
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +175 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +170 -11
- package/dist/templates/fullstack/apps/web/src/routes/healthz/+server.ts +13 -0
- package/dist/templates/fullstack/dot-env.docker.example +4 -0
- package/dist/templates/fullstack/dot-env.example +7 -0
- package/dist/templates/fullstack/infra/k3s/web-deployment.yaml +6 -3
- package/dist/templates/fullstack/tests/api/health.api.spec.ts +8 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +24 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +6 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +5 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +167 -154
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +94 -57
- package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +75 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +49 -39
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/exclusive.ts +80 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +47 -43
- package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +15 -11
- package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +35 -31
- package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +90 -76
- package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +19 -15
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +17 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +15 -6
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +71 -64
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +89 -79
- package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +54 -50
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +11 -5
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +83 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +51 -7
- package/dist/templates/todo/README.md +1 -1
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { expect, test } from "@playwright/test";
|
|
2
2
|
import { ADMIN } from "../helpers/accounts";
|
|
3
3
|
import { expectObjectLength } from "../helpers/object-response";
|
|
4
|
+
import { exclusive } from "../helpers/exclusive";
|
|
4
5
|
|
|
5
6
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
6
7
|
const origin = { origin: base };
|
|
@@ -18,29 +19,35 @@ async function adminCtx(playwright: import("@playwright/test").PlaywrightWorkerA
|
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
test("theme settings: valid preset/radius/accent round-trip", async ({ playwright }) => {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
test.slow();
|
|
23
|
+
await exclusive("site-theme", async () => {
|
|
24
|
+
const admin = await adminCtx(playwright);
|
|
25
|
+
const put = await admin.put("/api/site/settings", {
|
|
26
|
+
data: { themePreset: "slate", themeRadius: "0.75", brandColor: "#2563eb" },
|
|
27
|
+
});
|
|
28
|
+
expect(put.ok()).toBeTruthy();
|
|
26
29
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
31
|
+
const settings = await (await anon.get("/api/site/settings")).json();
|
|
32
|
+
expect(settings.themePreset).toBe("slate");
|
|
33
|
+
expect(settings.themeRadius).toBe("0.75");
|
|
34
|
+
expect(settings.brandColor).toBe("#2563eb");
|
|
32
35
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
+
// restore
|
|
37
|
+
await admin.put("/api/site/settings", { data: { themePreset: "", themeRadius: "", brandColor: "" } });
|
|
38
|
+
await anon.dispose();
|
|
39
|
+
});
|
|
36
40
|
});
|
|
37
41
|
|
|
38
42
|
test("theme settings: valid per-token overrides accepted", async ({ playwright }) => {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
test.slow();
|
|
44
|
+
await exclusive("site-theme", async () => {
|
|
45
|
+
const admin = await adminCtx(playwright);
|
|
46
|
+
const overrides = JSON.stringify({ light: { primary: "#123456" }, dark: { background: "#0a0a0a" } });
|
|
47
|
+
const put = await admin.put("/api/site/settings", { data: { themeOverrides: overrides } });
|
|
48
|
+
expect(put.ok()).toBeTruthy();
|
|
49
|
+
await admin.put("/api/site/settings", { data: { themeOverrides: "" } }); // restore
|
|
50
|
+
});
|
|
44
51
|
});
|
|
45
52
|
|
|
46
53
|
test("theme settings: invalid values are rejected (no CSS injection)", async ({ playwright }) => {
|
|
@@ -62,29 +69,32 @@ test("theme settings: invalid values are rejected (no CSS injection)", async ({
|
|
|
62
69
|
});
|
|
63
70
|
|
|
64
71
|
test("site settings: one response shape and immediate reads after each write", async ({ playwright }) => {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
test.slow();
|
|
73
|
+
await exclusive("site-general", async () => {
|
|
74
|
+
const admin = await adminCtx(playwright);
|
|
75
|
+
const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
76
|
+
const before = await (await anon.get("/api/site/settings")).json() as Record<string, unknown>;
|
|
77
|
+
expect(Object.keys(before)).toEqual([
|
|
78
|
+
"name", "description", "supportEmail", "footerText", "brandColor", "themePreset", "themeRadius",
|
|
79
|
+
"themeOverrides", "termsUrl", "privacyUrl", "locale", "timezone", "maintenanceMode", "allowSignup",
|
|
80
|
+
"hasFavicon", "faviconVersion",
|
|
81
|
+
]);
|
|
82
|
+
expect(typeof before.hasFavicon).toBe("boolean");
|
|
83
|
+
expect(before.faviconVersion === null || typeof before.faviconVersion === "string").toBe(true);
|
|
75
84
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
85
|
+
try {
|
|
86
|
+
// The second write lands while the first read is still cached; it must not be hidden.
|
|
87
|
+
for (const footerText of ["first footer", "second footer"]) {
|
|
88
|
+
expect((await admin.put("/api/site/settings", { data: { footerText } })).ok()).toBeTruthy();
|
|
89
|
+
expect((await (await anon.get("/api/site/settings")).json()).footerText).toBe(footerText);
|
|
90
|
+
}
|
|
91
|
+
} finally {
|
|
92
|
+
const footerText = typeof before.footerText === "string" ? before.footerText : "";
|
|
93
|
+
await admin.put("/api/site/settings", { data: { footerText } });
|
|
94
|
+
await anon.dispose();
|
|
95
|
+
await admin.dispose();
|
|
81
96
|
}
|
|
82
|
-
}
|
|
83
|
-
const footerText = typeof before.footerText === "string" ? before.footerText : "";
|
|
84
|
-
await admin.put("/api/site/settings", { data: { footerText } });
|
|
85
|
-
await anon.dispose();
|
|
86
|
-
await admin.dispose();
|
|
87
|
-
}
|
|
97
|
+
});
|
|
88
98
|
});
|
|
89
99
|
|
|
90
100
|
test("site favicon: the versioned URL is cached for good and other URLs revalidate", async ({ playwright }) => {
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
// Locks for server-wide settings that specs in more than one file change. The
|
|
6
|
+
// suite runs on one worker by default; with --workers above 1, specs of the api
|
|
7
|
+
// and ui projects run at the same time, and two that flip the same setting
|
|
8
|
+
// would undo each other mid-test:
|
|
9
|
+
//
|
|
10
|
+
// - `require2fa`, `magic-link`, `oidc-provider`: feature flags
|
|
11
|
+
// - `session-idle-timeout`: automatic logout in the auth configuration
|
|
12
|
+
// - `social-providers`: any social sign-in provider in the auth configuration
|
|
13
|
+
// - `site-general`: the General settings form, which saves all of its fields
|
|
14
|
+
// (name, footer, locale, maintenance mode, public sign-up, ...) at once
|
|
15
|
+
// - `site-theme`: the Appearance settings
|
|
16
|
+
//
|
|
17
|
+
// A spec that holds a lock calls `test.slow()`, since waiting for another
|
|
18
|
+
// holder counts against its timeout.
|
|
19
|
+
|
|
20
|
+
// Longer than any spec holds a lock. A lock whose holder process has exited is
|
|
21
|
+
// taken over at once; this bounds the wait when the holder cannot be checked.
|
|
22
|
+
const STALE_MS = 120_000;
|
|
23
|
+
const OWNER_FILE = "owner";
|
|
24
|
+
|
|
25
|
+
/** The lock directory for `name`, shared by every worker on this machine. */
|
|
26
|
+
export function lockPath(name: string): string {
|
|
27
|
+
return join(tmpdir(), `podokit-e2e-${name}.lock`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Whether the worker that holds `lock` has exited, such as after Ctrl-C. */
|
|
31
|
+
async function holderExited(lock: string): Promise<boolean> {
|
|
32
|
+
const pid = Number(await readFile(join(lock, OWNER_FILE), "utf8").catch(() => ""));
|
|
33
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
34
|
+
try {
|
|
35
|
+
process.kill(pid, 0);
|
|
36
|
+
return false;
|
|
37
|
+
} catch (error) {
|
|
38
|
+
return (error as NodeJS.ErrnoException).code === "ESRCH";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function acquire(lock: string): Promise<void> {
|
|
43
|
+
for (;;) {
|
|
44
|
+
const created = await mkdir(lock).then(
|
|
45
|
+
() => true,
|
|
46
|
+
(error: NodeJS.ErrnoException) => {
|
|
47
|
+
if (error.code === "EEXIST") return false;
|
|
48
|
+
throw error;
|
|
49
|
+
},
|
|
50
|
+
);
|
|
51
|
+
if (created) {
|
|
52
|
+
await writeFile(join(lock, OWNER_FILE), String(process.pid)).catch(async (error: unknown) => {
|
|
53
|
+
await rm(lock, { recursive: true, force: true });
|
|
54
|
+
throw error;
|
|
55
|
+
});
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const age = await stat(lock).then((info) => Date.now() - info.mtimeMs, () => 0);
|
|
59
|
+
if (age > STALE_MS || (await holderExited(lock))) await rm(lock, { recursive: true, force: true });
|
|
60
|
+
else await new Promise((resolve) => setTimeout(resolve, 200));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Runs `body` while no other Playwright worker on this machine holds any of
|
|
65
|
+
* the named locks, and releases them however `body` ends. */
|
|
66
|
+
export async function exclusive<T>(names: string | string[], body: () => Promise<T>): Promise<T> {
|
|
67
|
+
// One acquisition order for every caller, so two specs that each need
|
|
68
|
+
// several locks cannot wait on each other.
|
|
69
|
+
const locks = [...new Set([names].flat())].sort().map(lockPath);
|
|
70
|
+
const held: string[] = [];
|
|
71
|
+
try {
|
|
72
|
+
for (const lock of locks) {
|
|
73
|
+
await acquire(lock);
|
|
74
|
+
held.push(lock);
|
|
75
|
+
}
|
|
76
|
+
return await body();
|
|
77
|
+
} finally {
|
|
78
|
+
for (const lock of held.reverse()) await rm(lock, { recursive: true, force: true });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -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",
|
|
@@ -227,54 +228,57 @@ test("account nav shows the core sections", async ({ page }) => {
|
|
|
227
228
|
});
|
|
228
229
|
|
|
229
230
|
test("connected account unlink uses the local account row id @smoke", async ({ page }) => {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
await route
|
|
244
|
-
|
|
231
|
+
test.slow();
|
|
232
|
+
await exclusive("social-providers", async () => {
|
|
233
|
+
const provider = "google";
|
|
234
|
+
let unlinkBody: unknown;
|
|
235
|
+
await page.route("**/api/auth/list-accounts", (route) =>
|
|
236
|
+
route.fulfill({
|
|
237
|
+
status: 200,
|
|
238
|
+
contentType: "application/json",
|
|
239
|
+
body: JSON.stringify([
|
|
240
|
+
{ id: "local-account-id", providerId: provider, accountId: "provider-subject" },
|
|
241
|
+
]),
|
|
242
|
+
}),
|
|
243
|
+
);
|
|
244
|
+
await page.route("**/api/auth/unlink-account", async (route) => {
|
|
245
|
+
unlinkBody = route.request().postDataJSON();
|
|
246
|
+
await route.fulfill({ status: 200, contentType: "application/json", body: "{}" });
|
|
247
|
+
});
|
|
245
248
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
249
|
+
try {
|
|
250
|
+
const enabled = await page.request.put("/api/account/auth-config", {
|
|
251
|
+
data: {
|
|
252
|
+
social: {
|
|
253
|
+
[provider]: {
|
|
254
|
+
enabled: true,
|
|
255
|
+
clientId: "podokit",
|
|
256
|
+
clientSecret: "podokit",
|
|
257
|
+
},
|
|
254
258
|
},
|
|
255
259
|
},
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
.toContain(provider);
|
|
260
|
+
});
|
|
261
|
+
expect(enabled.ok()).toBeTruthy();
|
|
262
|
+
await expect
|
|
263
|
+
.poll(async () => {
|
|
264
|
+
const response = await page.request.get("/api/account/capabilities");
|
|
265
|
+
const body = (await response.json()) as { providers?: string[] };
|
|
266
|
+
return body.providers ?? [];
|
|
267
|
+
}, { timeout: 8_000 })
|
|
268
|
+
.toContain(provider);
|
|
266
269
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
+
await ready(page, "/admin/account");
|
|
271
|
+
await page.getByRole("button", { name: "Connected", exact: true }).click();
|
|
272
|
+
await page.getByRole("button", { name: "Disconnect", exact: true }).click();
|
|
270
273
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
274
|
+
await expect.poll(() => unlinkBody).toEqual({ accountId: "local-account-id" });
|
|
275
|
+
await expect(page.getByText("Disconnected", { exact: true })).toBeVisible();
|
|
276
|
+
} finally {
|
|
277
|
+
await page.request.put("/api/account/auth-config", {
|
|
278
|
+
data: { social: { [provider]: { enabled: false } } },
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
});
|
|
278
282
|
});
|
|
279
283
|
|
|
280
284
|
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
|
-
|
|
53
|
-
await
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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.
|
|
8
|
-
//
|
|
9
|
-
// `finally` so a mid-test failure still leaves the shared
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
await page.request.
|
|
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
|
-
|
|
48
|
-
|
|
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
|
|
74
|
-
await
|
|
63
|
+
await footer.fill("© 2026 PodoKit Test");
|
|
64
|
+
await support.fill("help@example.com");
|
|
75
65
|
},
|
|
76
|
-
{ footerText:
|
|
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
|
-
|
|
83
|
-
|
|
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
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
});
|