@podosoft/podokit 1.4.0 → 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/README.md +1 -1
- 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 +22 -3
- package/dist/templates/fullstack/AGENTS.md +22 -3
- package/dist/templates/fullstack/README.md +10 -7
- package/dist/templates/fullstack/apps/api/package.json +1 -1
- package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +29 -0
- package/dist/templates/fullstack/apps/api/src/app.extensions.ts +3 -1
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +84 -0
- package/dist/templates/fullstack/apps/api/src/database/database.ts +50 -5
- package/dist/templates/fullstack/apps/api/src/database/pool-guard.spec.ts +2 -2
- package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +118 -0
- package/dist/templates/fullstack/apps/web/Dockerfile +1 -1
- package/dist/templates/fullstack/apps/web/src/error.html +73 -0
- 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/api/src/admin-sessions/admin-sessions.migration.spec.ts +32 -3
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +19 -1
- 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/audit-log/module.manifest.json +1 -1
- 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/modules/auth/module.manifest.json +0 -1
- package/dist/templates/modules/file-upload/files/tests/api/chunked-uploads.api.spec.ts +12 -6
- package/dist/templates/modules/file-upload/module.manifest.json +2 -2
- package/dist/templates/todo/AGENTS.md +10 -3
- package/dist/templates/todo/README.md +10 -8
- package/package.json +1 -1
- package/dist/templates/modules/auth/files/apps/api/scripts/run-migrations.mjs +0 -14
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { expect, test } from "@playwright/test";
|
|
2
2
|
import { createPublicKey, verify, type JsonWebKey } from "node:crypto";
|
|
3
3
|
import { ADMIN, USER } from "../helpers/accounts";
|
|
4
|
+
import { exclusive } from "../helpers/exclusive";
|
|
4
5
|
import { clearSms, smsSinkReachable, waitForSmsOtp } from "../helpers/sms";
|
|
5
6
|
import { totpCode } from "../helpers/totp";
|
|
6
7
|
|
|
@@ -144,46 +145,49 @@ test("two-factor: regenerating backup codes invalidates the old set", async ({ p
|
|
|
144
145
|
});
|
|
145
146
|
|
|
146
147
|
test("require-2fa: gates an un-enrolled user until they enrol", async ({ playwright }) => {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
await
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
148
|
+
test.slow();
|
|
149
|
+
await exclusive("require2fa", async () => {
|
|
150
|
+
const admin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
151
|
+
const caps = await (await admin.get("/api/account/capabilities")).json();
|
|
152
|
+
test.skip(!caps?.twoFactor, "two-factor not enabled");
|
|
153
|
+
await admin.post("/api/auth/sign-in/email", { data: { email: ADMIN.email, password: ADMIN.password } });
|
|
154
|
+
|
|
155
|
+
const user = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
156
|
+
const email = `rq-${Date.now()}@example.com`;
|
|
157
|
+
const pw = "Podokit3e-Str0ng!pw";
|
|
158
|
+
await user.post("/api/auth/sign-up/email", { data: { email, password: pw, name: "RQ" } });
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
// Turn the policy on (admin; the toggle endpoint is allow-listed by the guard).
|
|
162
|
+
await admin.put("/api/account/settings", { data: { require2fa: true } });
|
|
163
|
+
// Capabilities carry the same policy as the dedicated endpoint.
|
|
164
|
+
await expect(async () => {
|
|
165
|
+
expect((await (await admin.get("/api/account/capabilities")).json()).require2fa).toBe(true);
|
|
166
|
+
expect(await (await admin.get("/api/account/require-2fa")).json()).toEqual({ require2fa: true });
|
|
167
|
+
}).toPass({ timeout: 8000 });
|
|
168
|
+
// The guard caches for a few seconds — poll until the un-enrolled user is gated.
|
|
169
|
+
await expect(async () => {
|
|
170
|
+
expect((await user.get("/api/account/me")).status()).toBe(403);
|
|
171
|
+
}).toPass({ timeout: 8000 });
|
|
172
|
+
|
|
173
|
+
// Enrolling restores access.
|
|
174
|
+
const enable = await (await user.post("/api/auth/two-factor/enable", { data: { password: pw } })).json();
|
|
175
|
+
await user.post("/api/auth/two-factor/verify-totp", { data: { code: totpCode(enable.totpURI) } });
|
|
176
|
+
expect((await user.get("/api/account/me")).status()).toBe(200);
|
|
177
|
+
} finally {
|
|
178
|
+
// Always turn it back off so the shared (un-enrolled) sessions aren't gated.
|
|
179
|
+
await admin.put("/api/account/settings", { data: { require2fa: false } });
|
|
180
|
+
await expect(async () => {
|
|
181
|
+
const probe = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
182
|
+
await probe.post("/api/auth/sign-in/email", { data: { email: USER.email, password: USER.password } });
|
|
183
|
+
const status = (await probe.get("/api/account/me")).status();
|
|
184
|
+
await probe.dispose();
|
|
185
|
+
expect(status).toBe(200);
|
|
186
|
+
}).toPass({ timeout: 8000 });
|
|
187
|
+
await admin.dispose();
|
|
188
|
+
await user.dispose();
|
|
189
|
+
}
|
|
190
|
+
});
|
|
187
191
|
});
|
|
188
192
|
|
|
189
193
|
test("multi-session holds several accounts and switches between them", async ({ playwright }) => {
|
|
@@ -380,9 +384,39 @@ test("an organization can have a parent and manager members", async ({ playwrigh
|
|
|
380
384
|
await ctx.dispose();
|
|
381
385
|
});
|
|
382
386
|
|
|
383
|
-
|
|
384
|
-
//
|
|
385
|
-
//
|
|
387
|
+
test("the OIDC provider's discovery and endpoints follow its setting", async ({ playwright }) => {
|
|
388
|
+
// The provider's metadata is answered before endpoint hooks run, so it needs its
|
|
389
|
+
// own check that "off" hides it. The lock keeps tests/ui/oidc.ui.spec.ts, which
|
|
390
|
+
// also toggles the provider, from running at the same time.
|
|
391
|
+
test.slow();
|
|
392
|
+
const admin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
393
|
+
await admin.post("/api/auth/sign-in/email", { data: { email: ADMIN.email, password: ADMIN.password } });
|
|
394
|
+
const status = (path: string): Promise<number> => admin.get(path).then((r) => r.status());
|
|
395
|
+
const discovery = [
|
|
396
|
+
"/api/auth/.well-known/openid-configuration",
|
|
397
|
+
"/api/auth/.well-known/oauth-authorization-server",
|
|
398
|
+
];
|
|
399
|
+
const allStatuses = (): Promise<number[]> =>
|
|
400
|
+
Promise.all([...discovery, "/api/auth/oauth2/authorize?client_id=probe"].map(status));
|
|
401
|
+
|
|
402
|
+
await exclusive("oidc-provider", async () => {
|
|
403
|
+
try {
|
|
404
|
+
expect((await admin.put("/api/account/settings", { data: { oidcProvider: false } })).ok()).toBeTruthy();
|
|
405
|
+
await expect.poll(allStatuses, { timeout: 8000 }).toEqual([404, 404, 404]);
|
|
406
|
+
// The jwt plugin's keys and session tokens are not part of the provider.
|
|
407
|
+
expect(await status("/api/auth/jwks")).toBe(200);
|
|
408
|
+
|
|
409
|
+
expect((await admin.put("/api/account/settings", { data: { oidcProvider: true } })).ok()).toBeTruthy();
|
|
410
|
+
await expect.poll(() => Promise.all(discovery.map(status)), { timeout: 8000 }).toEqual([200, 200]);
|
|
411
|
+
const metadata = await (await admin.get(discovery[0]!)).json();
|
|
412
|
+
expect(metadata.authorization_endpoint).toContain("/api/auth/oauth2/authorize");
|
|
413
|
+
} finally {
|
|
414
|
+
expect((await admin.put("/api/account/settings", { data: { oidcProvider: false } })).ok()).toBeTruthy();
|
|
415
|
+
}
|
|
416
|
+
await expect.poll(allStatuses, { timeout: 8000 }).toEqual([404, 404, 404]);
|
|
417
|
+
});
|
|
418
|
+
await admin.dispose();
|
|
419
|
+
});
|
|
386
420
|
|
|
387
421
|
test("only admins can change settings @smoke", async ({ playwright }) => {
|
|
388
422
|
const userCtx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
@@ -393,24 +427,27 @@ test("only admins can change settings @smoke", async ({ playwright }) => {
|
|
|
393
427
|
});
|
|
394
428
|
|
|
395
429
|
test("disabling a feature blocks its endpoint server-side and re-enabling restores it @smoke", async ({ playwright }) => {
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
430
|
+
test.slow();
|
|
431
|
+
await exclusive("magic-link", async () => {
|
|
432
|
+
// Uses a fresh admin context (not the shared storageState) so it never rotates
|
|
433
|
+
// the seeded session. Toggles a flag and confirms the feature gate enforces it.
|
|
434
|
+
const admin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
435
|
+
await admin.post("/api/auth/sign-in/email", { data: { email: ADMIN.email, password: ADMIN.password } });
|
|
436
|
+
const caps = await (await admin.get("/api/account/capabilities")).json();
|
|
437
|
+
test.skip(!caps?.magicLink, "magic link not enabled");
|
|
402
438
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
439
|
+
// A disabled feature's endpoint 404s; the gate reads the flag through a short cache.
|
|
440
|
+
const status = (): Promise<number> =>
|
|
441
|
+
admin.post("/api/auth/sign-in/magic-link", { data: { email: "probe@example.com" } }).then((r) => r.status());
|
|
406
442
|
|
|
407
|
-
|
|
408
|
-
|
|
443
|
+
expect((await admin.put("/api/account/settings", { data: { magicLink: false } })).ok()).toBeTruthy();
|
|
444
|
+
await expect.poll(status, { timeout: 8000 }).toBe(404);
|
|
409
445
|
|
|
410
|
-
|
|
411
|
-
|
|
446
|
+
expect((await admin.put("/api/account/settings", { data: { magicLink: true } })).ok()).toBeTruthy();
|
|
447
|
+
await expect.poll(status, { timeout: 8000 }).not.toBe(404);
|
|
412
448
|
|
|
413
|
-
|
|
449
|
+
await admin.dispose();
|
|
450
|
+
});
|
|
414
451
|
});
|
|
415
452
|
|
|
416
453
|
test("sign-in with a wrong password is rejected", async ({ request }) => {
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { existsSync, mkdirSync, utimesSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { rm } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { exclusive, lockPath } from "../helpers/exclusive";
|
|
7
|
+
|
|
8
|
+
// Checks the lock that specs changing shared settings hold. Names are unique per
|
|
9
|
+
// run so these never wait on, or release, a lock a real spec holds.
|
|
10
|
+
const unique = (label: string): string => `self-test-${label}-${process.pid}-${Date.now()}`;
|
|
11
|
+
|
|
12
|
+
test("exclusive releases its locks when the body fails", async () => {
|
|
13
|
+
const first = unique("first");
|
|
14
|
+
const second = unique("second");
|
|
15
|
+
await expect(
|
|
16
|
+
exclusive([second, first], async () => {
|
|
17
|
+
expect(existsSync(lockPath(first))).toBe(true);
|
|
18
|
+
expect(existsSync(lockPath(second))).toBe(true);
|
|
19
|
+
throw new Error("body failed");
|
|
20
|
+
}),
|
|
21
|
+
).rejects.toThrow("body failed");
|
|
22
|
+
expect(existsSync(lockPath(first))).toBe(false);
|
|
23
|
+
expect(existsSync(lockPath(second))).toBe(false);
|
|
24
|
+
|
|
25
|
+
// The next holder gets the lock without waiting.
|
|
26
|
+
const started = Date.now();
|
|
27
|
+
await exclusive(first, async () => undefined);
|
|
28
|
+
expect(Date.now() - started).toBeLessThan(1_000);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("exclusive runs holders of one lock one at a time", async () => {
|
|
32
|
+
const name = unique("serial");
|
|
33
|
+
const events: string[] = [];
|
|
34
|
+
const hold = (id: string): Promise<void> =>
|
|
35
|
+
exclusive(name, async () => {
|
|
36
|
+
events.push(`${id} start`);
|
|
37
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
38
|
+
events.push(`${id} end`);
|
|
39
|
+
});
|
|
40
|
+
await Promise.all([hold("a"), hold("b")]);
|
|
41
|
+
expect(events.map((event) => event.split(" ")[1])).toEqual(["start", "end", "start", "end"]);
|
|
42
|
+
expect(existsSync(lockPath(name))).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("exclusive takes over a lock left by a run that was killed", async () => {
|
|
46
|
+
const name = unique("stale");
|
|
47
|
+
mkdirSync(lockPath(name));
|
|
48
|
+
const longAgo = new Date(Date.now() - 10 * 60_000);
|
|
49
|
+
utimesSync(lockPath(name), longAgo, longAgo);
|
|
50
|
+
await expect(exclusive(name, async () => "acquired")).resolves.toBe("acquired");
|
|
51
|
+
expect(existsSync(lockPath(name))).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("exclusive takes over a lock whose holder exited, such as after Ctrl-C", async () => {
|
|
55
|
+
const name = unique("exited");
|
|
56
|
+
const exited = spawnSync(process.execPath, ["-e", ""]).pid;
|
|
57
|
+
mkdirSync(lockPath(name));
|
|
58
|
+
writeFileSync(join(lockPath(name), "owner"), String(exited));
|
|
59
|
+
const started = Date.now();
|
|
60
|
+
await expect(exclusive(name, async () => "acquired")).resolves.toBe("acquired");
|
|
61
|
+
expect(Date.now() - started).toBeLessThan(1_000);
|
|
62
|
+
expect(existsSync(lockPath(name))).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("exclusive waits for a lock whose holder is still running", async () => {
|
|
66
|
+
const name = unique("alive");
|
|
67
|
+
mkdirSync(lockPath(name));
|
|
68
|
+
writeFileSync(join(lockPath(name), "owner"), String(process.pid));
|
|
69
|
+
const waiting = exclusive(name, async () => "acquired");
|
|
70
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
71
|
+
expect(existsSync(lockPath(name))).toBe(true);
|
|
72
|
+
// The holder finishes; the waiter then gets the lock.
|
|
73
|
+
await rm(lockPath(name), { recursive: true, force: true });
|
|
74
|
+
await expect(waiting).resolves.toBe("acquired");
|
|
75
|
+
});
|
|
@@ -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
|
});
|