@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, 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
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { expect, test, type Page } 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
|
|
|
@@ -30,16 +31,19 @@ test("general settings: shows the favicon uploader", async ({ page }) => {
|
|
|
30
31
|
});
|
|
31
32
|
|
|
32
33
|
test("general settings: edit the site name, save, and apply live", async ({ page }) => {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
34
|
+
test.slow();
|
|
35
|
+
await exclusive("site-general", async () => {
|
|
36
|
+
await ready(page, "/admin/settings");
|
|
37
|
+
const name = page.getByLabel("Site name");
|
|
38
|
+
const original = await name.inputValue();
|
|
39
|
+
await name.fill("PodoKit Test Site");
|
|
40
|
+
await saveGeneral(page);
|
|
41
|
+
// the browser tab title reflects the new name live (no reload)
|
|
42
|
+
await expect(page).toHaveTitle("PodoKit Test Site");
|
|
43
|
+
// restore so other specs start clean
|
|
44
|
+
await name.fill(original);
|
|
45
|
+
await saveGeneral(page);
|
|
46
|
+
});
|
|
43
47
|
});
|
|
44
48
|
|
|
45
49
|
test("settings lists the auth features under the Authentication tab", async ({ page }) => {
|
|
@@ -69,18 +73,21 @@ test("settings is reachable from the sidebar", async ({ page }) => {
|
|
|
69
73
|
});
|
|
70
74
|
|
|
71
75
|
test("admin can toggle a feature flag and it applies live @smoke", async ({ page }) => {
|
|
72
|
-
|
|
73
|
-
await
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
76
|
+
test.slow();
|
|
77
|
+
await exclusive("magic-link", async () => {
|
|
78
|
+
await ready(page, "/admin/settings");
|
|
79
|
+
await page.getByRole("tab", { name: "Authentication" }).click();
|
|
80
|
+
const toggle = page.getByRole("switch", { name: "Magic link" });
|
|
81
|
+
test.skip((await toggle.count()) === 0, "magic link toggle not present");
|
|
82
|
+
// The magic-link lock keeps the API spec that toggles the same flag out.
|
|
83
|
+
await expect(toggle).toBeChecked();
|
|
84
|
+
await toggle.click();
|
|
85
|
+
await expect(page.getByText("Setting updated")).toBeVisible();
|
|
86
|
+
await expect(toggle).not.toBeChecked(); // capabilities refreshed live
|
|
87
|
+
// restore so the shared magic-link/2FA specs keep their expected state
|
|
88
|
+
await toggle.click();
|
|
89
|
+
await expect(toggle).toBeChecked();
|
|
90
|
+
});
|
|
84
91
|
});
|
|
85
92
|
|
|
86
93
|
test("settings: the require-two-factor toggle is available", async ({ page }) => {
|
|
@@ -95,68 +102,71 @@ test("settings: the require-two-factor toggle is available", async ({ page }) =>
|
|
|
95
102
|
});
|
|
96
103
|
|
|
97
104
|
test("inactive browser sessions are signed out automatically @smoke", async ({ browser, page: adminPage }) => {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
headers: { origin: base },
|
|
108
|
-
data: { email, password: "Podokit3e-Str0ng!pw", name: "Idle Admin", role: "admin" },
|
|
109
|
-
});
|
|
110
|
-
expect(created.ok()).toBeTruthy();
|
|
111
|
-
userId = ((await created.json()).user?.id ?? "") as string;
|
|
112
|
-
expect(userId).toBeTruthy();
|
|
113
|
-
|
|
114
|
-
const signIn = await context.request.post("/api/auth/sign-in/email", {
|
|
115
|
-
headers: { origin: base },
|
|
116
|
-
data: { email, password: "Podokit3e-Str0ng!pw" },
|
|
117
|
-
});
|
|
118
|
-
expect(signIn.ok()).toBeTruthy();
|
|
119
|
-
|
|
120
|
-
const configured = await context.request.put("/api/account/auth-config", {
|
|
121
|
-
data: { server: { sessionIdleTimeoutMinutes: 5 } },
|
|
122
|
-
});
|
|
123
|
-
expect(configured.ok()).toBeTruthy();
|
|
124
|
-
await page.route("**/api/auth/sign-out", async (route) => {
|
|
125
|
-
signOutRequests += 1;
|
|
126
|
-
await route.fulfill({ status: 200, contentType: "application/json", body: '{"success":true}' });
|
|
127
|
-
});
|
|
128
|
-
await page.clock.install();
|
|
129
|
-
|
|
105
|
+
test.slow();
|
|
106
|
+
await exclusive("session-idle-timeout", async () => {
|
|
107
|
+
// Idle sign-out revokes the active server session. Use a dedicated login so
|
|
108
|
+
// this test cannot invalidate the shared admin storageState used by later specs.
|
|
109
|
+
const context = await browser.newContext({ baseURL: base });
|
|
110
|
+
const page = await context.newPage();
|
|
111
|
+
const email = `idle-admin-${Date.now()}@example.com`;
|
|
112
|
+
let signOutRequests = 0;
|
|
113
|
+
let userId = "";
|
|
130
114
|
try {
|
|
131
|
-
await
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
// Keep the loaded page policy at five minutes while restoring the shared
|
|
135
|
-
// DB policy before the isolated session is revoked.
|
|
136
|
-
const restored = await context.request.put("/api/account/auth-config", {
|
|
137
|
-
data: { server: { sessionIdleTimeoutMinutes: null } },
|
|
115
|
+
const created = await adminPage.request.post("/api/auth/admin/create-user", {
|
|
116
|
+
headers: { origin: base },
|
|
117
|
+
data: { email, password: "Podokit3e-Str0ng!pw", name: "Idle Admin", role: "admin" },
|
|
138
118
|
});
|
|
139
|
-
expect(
|
|
140
|
-
|
|
119
|
+
expect(created.ok()).toBeTruthy();
|
|
120
|
+
userId = ((await created.json()).user?.id ?? "") as string;
|
|
121
|
+
expect(userId).toBeTruthy();
|
|
141
122
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
123
|
+
const signIn = await context.request.post("/api/auth/sign-in/email", {
|
|
124
|
+
headers: { origin: base },
|
|
125
|
+
data: { email, password: "Podokit3e-Str0ng!pw" },
|
|
126
|
+
});
|
|
127
|
+
expect(signIn.ok()).toBeTruthy();
|
|
128
|
+
|
|
129
|
+
const configured = await context.request.put("/api/account/auth-config", {
|
|
130
|
+
data: { server: { sessionIdleTimeoutMinutes: 5 } },
|
|
131
|
+
});
|
|
132
|
+
expect(configured.ok()).toBeTruthy();
|
|
133
|
+
await page.route("**/api/auth/sign-out", async (route) => {
|
|
134
|
+
signOutRequests += 1;
|
|
135
|
+
await route.fulfill({ status: 200, contentType: "application/json", body: '{"success":true}' });
|
|
136
|
+
});
|
|
137
|
+
await page.clock.install();
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
await ready(page, "/admin");
|
|
141
|
+
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
|
|
142
|
+
} finally {
|
|
143
|
+
// Keep the loaded page policy at five minutes while restoring the shared
|
|
144
|
+
// DB policy before the isolated session is revoked.
|
|
145
|
+
const restored = await context.request.put("/api/account/auth-config", {
|
|
146
|
+
data: { server: { sessionIdleTimeoutMinutes: null } },
|
|
147
|
+
});
|
|
148
|
+
expect(restored.ok()).toBeTruthy();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const removed = await adminPage.request.post("/api/auth/admin/remove-user", {
|
|
156
152
|
headers: { origin: base },
|
|
157
153
|
data: { userId },
|
|
158
154
|
});
|
|
155
|
+
expect(removed.ok()).toBeTruthy();
|
|
156
|
+
userId = "";
|
|
157
|
+
|
|
158
|
+
await page.clock.fastForward(5 * 60_000 + 1_000);
|
|
159
|
+
await expect.poll(() => signOutRequests).toBe(1);
|
|
160
|
+
await expect(page).toHaveURL(/\/login\?reason=idle$/);
|
|
161
|
+
await expect(page.getByTestId("session-timeout-message")).toBeVisible();
|
|
162
|
+
} finally {
|
|
163
|
+
if (userId) {
|
|
164
|
+
await adminPage.request.post("/api/auth/admin/remove-user", {
|
|
165
|
+
headers: { origin: base },
|
|
166
|
+
data: { userId },
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
await context.close();
|
|
159
170
|
}
|
|
160
|
-
|
|
161
|
-
}
|
|
171
|
+
});
|
|
162
172
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { expect, test, type Page } from "@playwright/test";
|
|
2
2
|
import { ADMIN, USER, anonState } from "../helpers/accounts";
|
|
3
3
|
import { totpCode } from "../helpers/totp";
|
|
4
|
+
import { exclusive } from "../helpers/exclusive";
|
|
4
5
|
|
|
5
6
|
test.use({ storageState: anonState });
|
|
6
7
|
|
|
@@ -85,58 +86,61 @@ test("two-factor: sign in with an authenticator code from the login page", async
|
|
|
85
86
|
});
|
|
86
87
|
|
|
87
88
|
test("require-2fa: a new sign-up is forced through the enrolment page", async ({ page, playwright }) => {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
await expect(async () => {
|
|
101
|
-
expect((await probe.get("/api/account/me")).status()).toBe(403);
|
|
102
|
-
}).toPass({ timeout: 8000 });
|
|
103
|
-
await probe.dispose();
|
|
104
|
-
|
|
105
|
-
// Signing in toward a protected route lands on the mandatory enrolment page.
|
|
106
|
-
await openHydratedLogin(page, "/account");
|
|
107
|
-
await page.getByLabel("Email").fill(email);
|
|
108
|
-
await page.getByLabel("Password").fill(pw);
|
|
109
|
-
await page.getByRole("button", { name: "Sign in", exact: true }).click();
|
|
110
|
-
await expect(page).toHaveURL(/\/setup-2fa/);
|
|
111
|
-
|
|
112
|
-
// Complete enrolment entirely through the UI: password → QR + backup codes →
|
|
113
|
-
// authenticator code → activate. The code is computed from the on-page URI.
|
|
114
|
-
await page.getByLabel("Confirm your password").fill(pw);
|
|
115
|
-
await page.getByRole("button", { name: "Continue" }).click();
|
|
116
|
-
await expect(page.getByTestId("backup-codes")).toBeVisible();
|
|
117
|
-
await expect(page.getByRole("button", { name: "Download" })).toBeVisible();
|
|
118
|
-
const uri = (await page.locator("code").first().innerText()).trim();
|
|
119
|
-
await page.getByRole("checkbox").click();
|
|
120
|
-
await page.getByLabel("3. Enter the 6-digit code").fill(totpCode(uri));
|
|
121
|
-
await page.getByRole("button", { name: "Activate and continue" }).click();
|
|
122
|
-
|
|
123
|
-
// Enrolled → the gate lets the regular user into their account page.
|
|
124
|
-
await expectAccountPage(page);
|
|
125
|
-
|
|
126
|
-
// An enrolled user who revisits setup is returned to the same non-admin page.
|
|
127
|
-
await page.goto("/setup-2fa");
|
|
128
|
-
await expectAccountPage(page);
|
|
129
|
-
} finally {
|
|
130
|
-
await admin.put("/api/account/settings", { data: { require2fa: false } });
|
|
131
|
-
await expect(async () => {
|
|
89
|
+
test.slow();
|
|
90
|
+
await exclusive("require2fa", async () => {
|
|
91
|
+
const admin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
92
|
+
const caps = await (await admin.get("/api/account/capabilities")).json();
|
|
93
|
+
test.skip(!caps?.twoFactor, "two-factor not enabled");
|
|
94
|
+
await admin.post("/api/auth/sign-in/email", { data: { email: ADMIN.email, password: ADMIN.password } });
|
|
95
|
+
|
|
96
|
+
const email = `rq-ui-${Date.now()}@example.com`;
|
|
97
|
+
const pw = "Podokit3e-Str0ng!pw";
|
|
98
|
+
try {
|
|
99
|
+
await admin.put("/api/account/settings", { data: { require2fa: true } });
|
|
100
|
+
// A fresh un-enrolled user; poll (via API) until the guard cache picks up the policy.
|
|
132
101
|
const probe = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
133
|
-
await probe.post("/api/auth/sign-
|
|
134
|
-
|
|
102
|
+
await probe.post("/api/auth/sign-up/email", { data: { email, password: pw, name: "RQUI" } });
|
|
103
|
+
await expect(async () => {
|
|
104
|
+
expect((await probe.get("/api/account/me")).status()).toBe(403);
|
|
105
|
+
}).toPass({ timeout: 8000 });
|
|
135
106
|
await probe.dispose();
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
107
|
+
|
|
108
|
+
// Signing in toward a protected route lands on the mandatory enrolment page.
|
|
109
|
+
await openHydratedLogin(page, "/account");
|
|
110
|
+
await page.getByLabel("Email").fill(email);
|
|
111
|
+
await page.getByLabel("Password").fill(pw);
|
|
112
|
+
await page.getByRole("button", { name: "Sign in", exact: true }).click();
|
|
113
|
+
await expect(page).toHaveURL(/\/setup-2fa/);
|
|
114
|
+
|
|
115
|
+
// Complete enrolment entirely through the UI: password → QR + backup codes →
|
|
116
|
+
// authenticator code → activate. The code is computed from the on-page URI.
|
|
117
|
+
await page.getByLabel("Confirm your password").fill(pw);
|
|
118
|
+
await page.getByRole("button", { name: "Continue" }).click();
|
|
119
|
+
await expect(page.getByTestId("backup-codes")).toBeVisible();
|
|
120
|
+
await expect(page.getByRole("button", { name: "Download" })).toBeVisible();
|
|
121
|
+
const uri = (await page.locator("code").first().innerText()).trim();
|
|
122
|
+
await page.getByRole("checkbox").click();
|
|
123
|
+
await page.getByLabel("3. Enter the 6-digit code").fill(totpCode(uri));
|
|
124
|
+
await page.getByRole("button", { name: "Activate and continue" }).click();
|
|
125
|
+
|
|
126
|
+
// Enrolled → the gate lets the regular user into their account page.
|
|
127
|
+
await expectAccountPage(page);
|
|
128
|
+
|
|
129
|
+
// An enrolled user who revisits setup is returned to the same non-admin page.
|
|
130
|
+
await page.goto("/setup-2fa");
|
|
131
|
+
await expectAccountPage(page);
|
|
132
|
+
} finally {
|
|
133
|
+
await admin.put("/api/account/settings", { data: { require2fa: false } });
|
|
134
|
+
await expect(async () => {
|
|
135
|
+
const probe = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
136
|
+
await probe.post("/api/auth/sign-in/email", { data: { email: USER.email, password: USER.password } });
|
|
137
|
+
const status = (await probe.get("/api/account/me")).status();
|
|
138
|
+
await probe.dispose();
|
|
139
|
+
expect(status).toBe(200);
|
|
140
|
+
}).toPass({ timeout: 8000 });
|
|
141
|
+
await admin.dispose();
|
|
142
|
+
}
|
|
143
|
+
});
|
|
140
144
|
});
|
|
141
145
|
|
|
142
146
|
test("account: regenerate backup codes shows a fresh set", async ({ page, playwright }) => {
|