@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
|
@@ -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
|
});
|
|
@@ -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
|
-
|
|
27
|
-
await
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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();
|
|
@@ -1,53 +1,57 @@
|
|
|
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)
|
|
5
6
|
|
|
6
7
|
test("settings: add and remove a social login provider", async ({ page }) => {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
8
|
+
test.slow();
|
|
9
|
+
await exclusive("social-providers", async () => {
|
|
10
|
+
const dialog = page.getByRole("dialog");
|
|
11
|
+
const addBtn = dialog.getByRole("button", { name: "Add provider" });
|
|
12
|
+
const openConfigure = async (): Promise<void> => {
|
|
13
|
+
await page.waitForLoadState("networkidle");
|
|
14
|
+
await page.getByRole("tab", { name: "Authentication" }).click();
|
|
15
|
+
const socialCard = page
|
|
16
|
+
.getByText("Social login", { exact: true })
|
|
17
|
+
.locator('xpath=ancestor::div[@data-slot="card"]');
|
|
18
|
+
await socialCard.getByRole("button", { name: "Configure" }).click();
|
|
19
|
+
};
|
|
17
20
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
21
|
+
await ready(page, "/admin/settings");
|
|
22
|
+
// The auth-config catalog can be momentarily unavailable right after another
|
|
23
|
+
// spec toggles auth config (the auth instance rebuilds). Reload until the
|
|
24
|
+
// catalog is loaded (Add provider becomes enabled).
|
|
25
|
+
await expect(async () => {
|
|
26
|
+
await page.reload();
|
|
27
|
+
await openConfigure();
|
|
28
|
+
await expect(addBtn).toBeEnabled({ timeout: 3000 });
|
|
29
|
+
}).toPass({ timeout: 25000 });
|
|
27
30
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
31
|
+
await addBtn.click();
|
|
32
|
+
const provider = dialog.locator("#social-provider");
|
|
33
|
+
await expect(provider).toBeVisible();
|
|
34
|
+
const callback = await dialog.locator("code").textContent();
|
|
35
|
+
const providerId = callback?.trim().split("/").pop() ?? ""; // first addable provider (not hard-coded)
|
|
36
|
+
expect(providerId).not.toBe("");
|
|
37
|
+
const expectedCallback = new URL(`/api/auth/callback/${providerId}`, page.url()).toString();
|
|
38
|
+
expect(callback?.trim()).toBe(expectedCallback);
|
|
39
|
+
try {
|
|
40
|
+
await dialog.locator("#social-id").fill("test-client-id");
|
|
41
|
+
await dialog.locator("#social-secret").fill("test-secret");
|
|
42
|
+
await dialog.getByRole("button", { name: "Save", exact: true }).click();
|
|
43
|
+
// Save returns to the list view, where the new provider row has an enable switch.
|
|
44
|
+
await expect(dialog.getByRole("switch").first()).toBeVisible();
|
|
45
|
+
const saved = await (await page.request.get("/api/account/auth-config")).json();
|
|
46
|
+
expect(saved.social[providerId].redirectURI).toBe(expectedCallback);
|
|
47
|
+
} finally {
|
|
48
|
+
if (providerId) {
|
|
49
|
+
await page.request
|
|
50
|
+
.put("/api/account/auth-config", { data: { social: { [providerId]: { delete: true } } } })
|
|
51
|
+
.catch(() => undefined);
|
|
52
|
+
}
|
|
49
53
|
}
|
|
50
|
-
}
|
|
54
|
+
});
|
|
51
55
|
});
|
|
52
56
|
|
|
53
57
|
test("settings: open and save the SMTP dialog", async ({ page }) => {
|
|
@@ -71,31 +75,34 @@ test("settings: open and save the SMTP dialog", async ({ page }) => {
|
|
|
71
75
|
});
|
|
72
76
|
|
|
73
77
|
test("settings: configure automatic logout", async ({ page }) => {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
78
|
+
test.slow();
|
|
79
|
+
await exclusive("session-idle-timeout", async () => {
|
|
80
|
+
await page.request.put("/api/account/auth-config", {
|
|
81
|
+
data: { server: { sessionIdleTimeoutMinutes: null } },
|
|
82
|
+
});
|
|
83
|
+
await ready(page, "/admin/settings");
|
|
84
|
+
await page.getByRole("tab", { name: "Authentication" }).click();
|
|
79
85
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
86
|
+
const toggle = page.getByRole("switch", { name: "Automatic logout" });
|
|
87
|
+
await expect(toggle).not.toBeChecked();
|
|
88
|
+
await toggle.click();
|
|
89
|
+
await expect(toggle).toBeChecked();
|
|
84
90
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
91
|
+
const card = page
|
|
92
|
+
.getByText("Automatic logout", { exact: true })
|
|
93
|
+
.locator('xpath=ancestor::div[@data-slot="card"]');
|
|
94
|
+
await card.getByRole("button", { name: "Configure" }).click();
|
|
95
|
+
const dialog = page.getByRole("dialog");
|
|
96
|
+
await dialog.getByLabel("Idle time (minutes)").fill("45");
|
|
97
|
+
await dialog.getByRole("button", { name: "Save", exact: true }).click();
|
|
98
|
+
await expect(page.getByText("45 min", { exact: true })).toBeVisible();
|
|
93
99
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
100
|
+
const saved = await (await page.request.get("/api/account/auth-config")).json();
|
|
101
|
+
expect(saved.server.sessionIdleTimeoutMinutes).toBe(45);
|
|
102
|
+
const caps = await (await page.request.get("/api/account/capabilities")).json();
|
|
103
|
+
expect(caps.sessionIdleTimeoutMinutes).toBe(45);
|
|
98
104
|
|
|
99
|
-
|
|
100
|
-
|
|
105
|
+
await toggle.click();
|
|
106
|
+
await expect(toggle).not.toBeChecked();
|
|
107
|
+
});
|
|
101
108
|
});
|