@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
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
isUserCreationAllowed,
|
|
5
5
|
} from "../../apps/api/src/auth/feature-gate";
|
|
6
6
|
import { ADMIN } from "../helpers/accounts";
|
|
7
|
+
import { exclusive } from "../helpers/exclusive";
|
|
7
8
|
|
|
8
9
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
9
10
|
const origin = { origin: base };
|
|
@@ -17,62 +18,68 @@ async function adminCtx(playwright: import("@playwright/test").PlaywrightWorkerA
|
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
test("admin can enable Google OAuth from the DB and it applies without a restart", async ({ playwright }) => {
|
|
20
|
-
|
|
21
|
-
await
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
21
|
+
test.slow();
|
|
22
|
+
await exclusive("social-providers", async () => {
|
|
23
|
+
const admin = await adminCtx(playwright);
|
|
24
|
+
await admin.put("/api/account/auth-config", { data: { social: { google: { enabled: false } } } }); // clean start
|
|
25
|
+
const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
26
|
+
const social = () => anon.post("/api/auth/sign-in/social", { data: { provider: "google", callbackURL: `${base}/admin` } });
|
|
27
|
+
|
|
28
|
+
// Before: provider not configured.
|
|
29
|
+
await expect.poll(async () => (await social()).status(), { timeout: 8000 }).toBe(404);
|
|
30
|
+
|
|
31
|
+
// Paste credentials via the admin API.
|
|
32
|
+
const put = await admin.put("/api/account/auth-config", {
|
|
33
|
+
data: { social: { google: { enabled: true, clientId: "dummy-google-client-id", clientSecret: "dummy-google-client-secret" } } },
|
|
34
|
+
});
|
|
35
|
+
expect(put.ok()).toBeTruthy();
|
|
36
|
+
const view = await put.json();
|
|
37
|
+
expect(view.social.google.enabled).toBe(true);
|
|
38
|
+
expect(view.social.google.hasSecret).toBe(true);
|
|
39
|
+
expect(view.social.google.clientId).toBe("dummy-google-client-id");
|
|
40
|
+
expect("clientSecret" in view.social.google).toBe(false); // secret is never returned
|
|
41
|
+
|
|
42
|
+
// Applied live (no restart): social sign-in now redirects to Google.
|
|
43
|
+
await expect
|
|
44
|
+
.poll(async () => {
|
|
45
|
+
const r = await social();
|
|
46
|
+
return r.ok() ? (((await r.json())?.url as string) ?? "") : "";
|
|
47
|
+
}, { timeout: 8000 })
|
|
48
|
+
.toContain("accounts.google.com");
|
|
49
|
+
const caps = await (await anon.get("/api/account/capabilities")).json();
|
|
50
|
+
expect(caps.providers).toContain("google");
|
|
51
|
+
|
|
52
|
+
await admin.put("/api/account/auth-config", { data: { social: { google: { enabled: false } } } }); // restore
|
|
53
|
+
await anon.dispose();
|
|
54
|
+
await admin.dispose();
|
|
31
55
|
});
|
|
32
|
-
expect(put.ok()).toBeTruthy();
|
|
33
|
-
const view = await put.json();
|
|
34
|
-
expect(view.social.google.enabled).toBe(true);
|
|
35
|
-
expect(view.social.google.hasSecret).toBe(true);
|
|
36
|
-
expect(view.social.google.clientId).toBe("dummy-google-client-id");
|
|
37
|
-
expect("clientSecret" in view.social.google).toBe(false); // secret is never returned
|
|
38
|
-
|
|
39
|
-
// Applied live (no restart): social sign-in now redirects to Google.
|
|
40
|
-
await expect
|
|
41
|
-
.poll(async () => {
|
|
42
|
-
const r = await social();
|
|
43
|
-
return r.ok() ? (((await r.json())?.url as string) ?? "") : "";
|
|
44
|
-
}, { timeout: 8000 })
|
|
45
|
-
.toContain("accounts.google.com");
|
|
46
|
-
const caps = await (await anon.get("/api/account/capabilities")).json();
|
|
47
|
-
expect(caps.providers).toContain("google");
|
|
48
|
-
|
|
49
|
-
await admin.put("/api/account/auth-config", { data: { social: { google: { enabled: false } } } }); // restore
|
|
50
|
-
await anon.dispose();
|
|
51
|
-
await admin.dispose();
|
|
52
56
|
});
|
|
53
57
|
|
|
54
58
|
test("admin can add and remove a social provider dynamically", async ({ playwright }) => {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
+
test.slow();
|
|
60
|
+
await exclusive("social-providers", async () => {
|
|
61
|
+
const admin = await adminCtx(playwright);
|
|
62
|
+
// The catalog offers providers beyond the two legacy env-backed ones.
|
|
63
|
+
const catalog = (await (await admin.get("/api/account/auth-config")).json()).catalog as Array<{ id: string }>;
|
|
64
|
+
expect(catalog.map((c) => c.id)).toEqual(expect.arrayContaining(["google", "github", "apple"]));
|
|
65
|
+
|
|
66
|
+
const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
67
|
+
const github = () => anon.post("/api/auth/sign-in/social", { data: { provider: "github", callbackURL: `${base}/admin` } });
|
|
68
|
+
|
|
69
|
+
// Add GitHub (a provider with no env fallback) → applies live, no restart.
|
|
70
|
+
await admin.put("/api/account/auth-config", { data: { social: { github: { enabled: true, clientId: "dummy-github-client-id", clientSecret: "dummy-github-client-secret" } } } });
|
|
71
|
+
await expect
|
|
72
|
+
.poll(async () => { const r = await github(); return r.ok() ? (((await r.json())?.url as string) ?? "") : ""; }, { timeout: 8000 })
|
|
73
|
+
.toContain("github.com");
|
|
74
|
+
|
|
75
|
+
// Remove it → the row is deleted and the provider stops working (again live).
|
|
76
|
+
await admin.put("/api/account/auth-config", { data: { social: { github: { delete: true } } } });
|
|
77
|
+
expect("github" in (await (await admin.get("/api/account/auth-config")).json()).social).toBe(false);
|
|
78
|
+
await expect.poll(async () => (await github()).status(), { timeout: 8000 }).toBe(404);
|
|
59
79
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
// Add GitHub (a provider with no env fallback) → applies live, no restart.
|
|
64
|
-
await admin.put("/api/account/auth-config", { data: { social: { github: { enabled: true, clientId: "dummy-github-client-id", clientSecret: "dummy-github-client-secret" } } } });
|
|
65
|
-
await expect
|
|
66
|
-
.poll(async () => { const r = await github(); return r.ok() ? (((await r.json())?.url as string) ?? "") : ""; }, { timeout: 8000 })
|
|
67
|
-
.toContain("github.com");
|
|
68
|
-
|
|
69
|
-
// Remove it → the row is deleted and the provider stops working (again live).
|
|
70
|
-
await admin.put("/api/account/auth-config", { data: { social: { github: { delete: true } } } });
|
|
71
|
-
expect("github" in (await (await admin.get("/api/account/auth-config")).json()).social).toBe(false);
|
|
72
|
-
await expect.poll(async () => (await github()).status(), { timeout: 8000 }).toBe(404);
|
|
73
|
-
|
|
74
|
-
await anon.dispose();
|
|
75
|
-
await admin.dispose();
|
|
80
|
+
await anon.dispose();
|
|
81
|
+
await admin.dispose();
|
|
82
|
+
});
|
|
76
83
|
});
|
|
77
84
|
|
|
78
85
|
test("auth-config is admin-only and never leaks secrets @smoke", async ({ playwright }) => {
|
|
@@ -85,43 +92,46 @@ test("auth-config is admin-only and never leaks secrets @smoke", async ({ playwr
|
|
|
85
92
|
});
|
|
86
93
|
|
|
87
94
|
test("automatic logout validates and applies a sliding session lifetime", async ({ playwright }) => {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
95
|
+
test.slow();
|
|
96
|
+
await exclusive("session-idle-timeout", async () => {
|
|
97
|
+
const admin = await adminCtx(playwright);
|
|
98
|
+
const freshAdmin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
expect(
|
|
102
|
+
(await admin.put("/api/account/auth-config", {
|
|
103
|
+
data: { server: { sessionIdleTimeoutMinutes: 4 } },
|
|
104
|
+
})).status(),
|
|
105
|
+
).toBe(400);
|
|
106
|
+
|
|
107
|
+
const enabled = await admin.put("/api/account/auth-config", {
|
|
108
|
+
data: { server: { sessionIdleTimeoutMinutes: 30 } },
|
|
109
|
+
});
|
|
110
|
+
expect(enabled.ok()).toBeTruthy();
|
|
111
|
+
expect((await enabled.json()).server.sessionIdleTimeoutMinutes).toBe(30);
|
|
112
|
+
|
|
113
|
+
const caps = await (await admin.get("/api/account/capabilities")).json();
|
|
114
|
+
expect(caps.sessionIdleTimeoutMinutes).toBe(30);
|
|
115
|
+
|
|
116
|
+
const signIn = await freshAdmin.post("/api/auth/sign-in/email", {
|
|
117
|
+
data: { email: ADMIN.email, password: ADMIN.password },
|
|
118
|
+
});
|
|
119
|
+
expect(signIn.ok()).toBeTruthy();
|
|
120
|
+
expect(signIn.headers()["set-cookie"]).toMatch(/Max-Age=1800(?:;|,)/i);
|
|
121
|
+
|
|
122
|
+
const sessions = (await (await admin.get("/api/auth/list-sessions")).json()) as Array<{
|
|
123
|
+
expiresAt: string;
|
|
124
|
+
}>;
|
|
125
|
+
const latestExpiry = Math.max(...sessions.map((session) => new Date(session.expiresAt).getTime()));
|
|
126
|
+
expect(latestExpiry).toBeLessThanOrEqual(Date.now() + 30 * 60_000 + 10_000);
|
|
127
|
+
} finally {
|
|
128
|
+
await admin.put("/api/account/auth-config", {
|
|
129
|
+
data: { server: { sessionIdleTimeoutMinutes: null } },
|
|
130
|
+
});
|
|
131
|
+
await freshAdmin.dispose();
|
|
132
|
+
await admin.dispose();
|
|
133
|
+
}
|
|
134
|
+
});
|
|
125
135
|
});
|
|
126
136
|
|
|
127
137
|
test("new registrations require approval when the policy is enabled", async ({ playwright }) => {
|
|
@@ -204,75 +214,78 @@ test("new registrations require approval when the policy is enabled", async ({ p
|
|
|
204
214
|
});
|
|
205
215
|
|
|
206
216
|
test("closed public sign-up blocks new OAuth users but preserves existing sign-in", async ({ playwright }) => {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
217
|
+
test.slow();
|
|
218
|
+
await exclusive(["site-general", "social-providers"], async () => {
|
|
219
|
+
const admin = await adminCtx(playwright);
|
|
220
|
+
const anon = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
221
|
+
const password = "Podokit3e-Str0ng!pw";
|
|
222
|
+
const adminCreatedEmail = `closed-admin-${Date.now()}@example.com`;
|
|
223
|
+
const initialSite = (await (await anon.get("/api/site/settings")).json()) as {
|
|
224
|
+
allowSignup?: string | null;
|
|
225
|
+
};
|
|
226
|
+
const initialAllowSignup = initialSite.allowSignup === "false" ? "false" : "true";
|
|
227
|
+
let adminCreatedId = "";
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
expect(
|
|
231
|
+
(await admin.put("/api/account/auth-config", {
|
|
232
|
+
data: {
|
|
233
|
+
social: {
|
|
234
|
+
google: {
|
|
235
|
+
enabled: true,
|
|
236
|
+
clientId: "dummy-google-client-id",
|
|
237
|
+
clientSecret: "dummy-google-client-secret",
|
|
238
|
+
},
|
|
226
239
|
},
|
|
227
240
|
},
|
|
228
|
-
},
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
expect((await admin.put("/api/site/settings", { data: { allowSignup: "false" } })).ok()).toBeTruthy();
|
|
241
|
+
})).ok(),
|
|
242
|
+
).toBeTruthy();
|
|
243
|
+
expect((await admin.put("/api/site/settings", { data: { allowSignup: "false" } })).ok()).toBeTruthy();
|
|
232
244
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
const signup = await anon.post("/api/auth/sign-up/email", {
|
|
238
|
-
data: {
|
|
239
|
-
email: `closed-${Date.now()}@example.com`,
|
|
240
|
-
password,
|
|
241
|
-
name: "Closed Registration",
|
|
242
|
-
},
|
|
243
|
-
});
|
|
244
|
-
expect(signup.status()).toBe(403);
|
|
245
|
-
expect((await signup.json()).code).toBe(PUBLIC_SIGNUP_DISABLED);
|
|
245
|
+
// The site policy uses a short cache shared by sign-up requests and user
|
|
246
|
+
// creation hooks, so wait for the setting to become live.
|
|
247
|
+
await new Promise((resolve) => setTimeout(resolve, 3_200));
|
|
246
248
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
249
|
+
const signup = await anon.post("/api/auth/sign-up/email", {
|
|
250
|
+
data: {
|
|
251
|
+
email: `closed-${Date.now()}@example.com`,
|
|
252
|
+
password,
|
|
253
|
+
name: "Closed Registration",
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
expect(signup.status()).toBe(403);
|
|
257
|
+
expect((await signup.json()).code).toBe(PUBLIC_SIGNUP_DISABLED);
|
|
258
|
+
|
|
259
|
+
// OAuth initiation must stay available for existing social users. Only a
|
|
260
|
+
// callback that attempts to create a new user is rejected by the DB hook.
|
|
261
|
+
const social = await anon.post("/api/auth/sign-in/social", {
|
|
262
|
+
data: { provider: "google", callbackURL: `${base}/` },
|
|
263
|
+
});
|
|
264
|
+
expect(social.ok()).toBeTruthy();
|
|
265
|
+
expect(((await social.json()).url as string)).toContain("accounts.google.com");
|
|
266
|
+
expect(isUserCreationAllowed(false, "/callback/google")).toBe(false);
|
|
267
|
+
expect(isUserCreationAllowed(false, undefined)).toBe(false);
|
|
268
|
+
|
|
269
|
+
// Deliberate administrator provisioning remains available while the public
|
|
270
|
+
// site is closed.
|
|
271
|
+
expect(isUserCreationAllowed(false, "/admin/create-user")).toBe(true);
|
|
272
|
+
const adminCreated = await admin.post("/api/auth/admin/create-user", {
|
|
273
|
+
data: { email: adminCreatedEmail, password, name: "Admin Created", role: "user" },
|
|
274
|
+
});
|
|
275
|
+
expect(adminCreated.ok()).toBeTruthy();
|
|
276
|
+
adminCreatedId = ((await adminCreated.json()).user as { id: string }).id;
|
|
277
|
+
} finally {
|
|
278
|
+
if (adminCreatedId) {
|
|
279
|
+
await admin.post("/api/auth/admin/remove-user", { data: { userId: adminCreatedId } });
|
|
280
|
+
}
|
|
281
|
+
await admin.put("/api/site/settings", { data: { allowSignup: initialAllowSignup } });
|
|
282
|
+
await admin.put("/api/account/auth-config", {
|
|
283
|
+
data: { social: { google: { enabled: false } } },
|
|
284
|
+
});
|
|
285
|
+
await new Promise((resolve) => setTimeout(resolve, 3_200));
|
|
286
|
+
await anon.get("/api/auth/get-session");
|
|
287
|
+
await anon.dispose();
|
|
288
|
+
await admin.dispose();
|
|
268
289
|
}
|
|
269
|
-
|
|
270
|
-
await admin.put("/api/account/auth-config", {
|
|
271
|
-
data: { social: { google: { enabled: false } } },
|
|
272
|
-
});
|
|
273
|
-
await new Promise((resolve) => setTimeout(resolve, 3_200));
|
|
274
|
-
await anon.get("/api/auth/get-session");
|
|
275
|
-
await anon.dispose();
|
|
276
|
-
await admin.dispose();
|
|
277
|
-
}
|
|
290
|
+
});
|
|
278
291
|
});
|
|
@@ -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
|
+
});
|