@podosoft/podokit 1.6.2 → 1.6.4
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-checks.d.ts +20 -2
- package/dist/deploy-checks.js +83 -4
- package/dist/deploy-compose-render.js +15 -4
- package/dist/deploy-compose.js +211 -27
- package/dist/deploy.js +16 -0
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +50 -4
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +3 -2
- package/dist/templates/fullstack/tests/README.md +11 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +28 -2
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +24 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +5 -5
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +61 -12
- package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +16 -4
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/module.manifest.json +4 -2
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +31 -8
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.spec.ts +531 -29
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +253 -60
- package/dist/templates/modules/auth/files/apps/api/src/auth/admin-emails.spec.ts +232 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/admin-emails.ts +68 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +53 -47
- package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/SKILL.md +1 -1
- package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/bootstrap-admin.md +9 -2
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +60 -15
- package/package.json +1 -1
|
@@ -41,7 +41,7 @@ Review these fields before planning:
|
|
|
41
41
|
- `runtimeConfig`: non-sensitive environment values only
|
|
42
42
|
- `verification`: HTTPS origin and checks with exact statuses and optional JSON fields
|
|
43
43
|
|
|
44
|
-
The cluster namespace and referenced Secrets must already exist. PodoKit asks `kubectl` to output only Secret key names and never prints or stores their values; the Kubernetes client still materializes the Secret while evaluating that output.
|
|
44
|
+
The cluster namespace and referenced Secrets must already exist. PodoKit asks `kubectl` to output only Secret key names and never prints or stores their values; the Kubernetes client still materializes the Secret while evaluating that output. The one exception is `ADMIN_EMAILS` of the API Secret, an allowlist of addresses rather than a credential: with the `auth` module installed, the doctor reads it and fails on a placeholder such as `admin@example.com`.
|
|
45
45
|
|
|
46
46
|
Required keys:
|
|
47
47
|
|
|
@@ -94,7 +94,8 @@ The fields that differ from the Kubernetes driver:
|
|
|
94
94
|
|
|
95
95
|
Credentials live in env files **on the target host**, never in the profile and never
|
|
96
96
|
in the rendered Compose project. The doctor reads only the key names, in a throwaway
|
|
97
|
-
container that mounts the file read-only
|
|
97
|
+
container that mounts the file read-only, plus the `ADMIN_EMAILS` line of the API env
|
|
98
|
+
file when the `auth` module is installed, to fail on a placeholder such as `admin@example.com`. Create those files with the host's own
|
|
98
99
|
secret tooling before deploying; PodoKit does not write them.
|
|
99
100
|
|
|
100
101
|
Required keys:
|
|
@@ -68,3 +68,14 @@ tests not run, and selecting an exclusive test runs every other test first. Use
|
|
|
68
68
|
|
|
69
69
|
Application-owned projects in `playwright.projects.cjs` get twins too. A project
|
|
70
70
|
that another project depends on is a setup project and keeps a single copy.
|
|
71
|
+
|
|
72
|
+
## Email tests
|
|
73
|
+
|
|
74
|
+
The email specs that the `admin-dashboard` module adds read messages from the
|
|
75
|
+
Mailpit at `MAILPIT_URL` (default `http://localhost:8025`). Before the first one,
|
|
76
|
+
each worker has the app send a password reset email to a throwaway account and
|
|
77
|
+
waits up to five seconds for it there. When it does not arrive, because the app
|
|
78
|
+
has no SMTP or sends to a catcher other than the one on that port, the email
|
|
79
|
+
tests skip with that cause. Set `MAILPIT_URL` to the Mailpit that receives the
|
|
80
|
+
app's mail to run them, and `E2E_REQUIRE_MAIL=true` to make a missing probe
|
|
81
|
+
message fail them instead of skipping.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { APIRequestContext } from "@playwright/test";
|
|
2
2
|
import { expect, test } from "../helpers/disposable-users";
|
|
3
3
|
import { ADMIN, USER } from "../helpers/accounts";
|
|
4
|
-
import {
|
|
4
|
+
import { skipUnlessMailReachesMailpit, waitForOtp } from "../helpers/mailpit";
|
|
5
5
|
import { totpCode } from "../helpers/totp";
|
|
6
6
|
|
|
7
7
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
@@ -12,6 +12,7 @@ type Entry = {
|
|
|
12
12
|
id: string;
|
|
13
13
|
action: string;
|
|
14
14
|
actorEmail: string | null;
|
|
15
|
+
targetId: string | null;
|
|
15
16
|
targetLabel: string | null;
|
|
16
17
|
metadata: Record<string, unknown> | null;
|
|
17
18
|
createdAt: string;
|
|
@@ -61,6 +62,31 @@ test("audit log records auth/admin actions", async ({ playwright, disposableUser
|
|
|
61
62
|
await admin.dispose();
|
|
62
63
|
});
|
|
63
64
|
|
|
65
|
+
test("a sign-up and a sign-out are audited with the user", async ({ playwright, disposableUsers }) => {
|
|
66
|
+
const admin = await signedIn(playwright, ADMIN);
|
|
67
|
+
test.skip((await admin.get("/api/audit-logs")).status() === 404, "audit-log module not installed");
|
|
68
|
+
const email = `audit-signup-${Date.now()}@example.com`;
|
|
69
|
+
const visitor = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
70
|
+
const signup = await visitor.post("/api/auth/sign-up/email", { data: { email, password: PASSWORD, name: "Signup" } });
|
|
71
|
+
test.skip(signup.status() === 403, "public sign-up is closed");
|
|
72
|
+
const userId = await disposableUsers.trackResponse(signup);
|
|
73
|
+
const entries = async (action: string): Promise<Entry[]> =>
|
|
74
|
+
(await (await admin.get(auditQuery({ action, target: email }))).json()) as Entry[];
|
|
75
|
+
|
|
76
|
+
expect(await entries("auth.signup")).toEqual([
|
|
77
|
+
expect.objectContaining({ actorEmail: email, targetId: userId, metadata: { method: "password" } }),
|
|
78
|
+
]);
|
|
79
|
+
if (!(await visitor.get("/api/auth/get-session").then((response) => response.json()))?.session) {
|
|
80
|
+
expect((await visitor.post("/api/auth/sign-in/email", { data: { email, password: PASSWORD } })).ok()).toBeTruthy();
|
|
81
|
+
}
|
|
82
|
+
expect((await visitor.post("/api/auth/sign-out", { data: {} })).ok()).toBeTruthy();
|
|
83
|
+
expect(await entries("auth.logout"), "the sign-out names the user whose session ended").toEqual([
|
|
84
|
+
expect.objectContaining({ actorEmail: email, targetId: userId }),
|
|
85
|
+
]);
|
|
86
|
+
await visitor.dispose();
|
|
87
|
+
await admin.dispose();
|
|
88
|
+
});
|
|
89
|
+
|
|
64
90
|
test("the audit log is admin-only", async ({ playwright }) => {
|
|
65
91
|
const admin = await signedIn(playwright, ADMIN);
|
|
66
92
|
test.skip((await admin.get("/api/audit-logs")).status() === 404, "audit-log module not installed");
|
|
@@ -171,7 +197,7 @@ test("a wrong password is not audited as a sign-in", async ({ playwright, dispos
|
|
|
171
197
|
});
|
|
172
198
|
|
|
173
199
|
test("an email code sign-in is audited once", async ({ playwright, disposableUsers }) => {
|
|
174
|
-
|
|
200
|
+
await skipUnlessMailReachesMailpit();
|
|
175
201
|
const admin = await signedIn(playwright, ADMIN);
|
|
176
202
|
test.skip((await admin.get("/api/audit-logs")).status() === 404, "audit-log module not installed");
|
|
177
203
|
const caps = await (await admin.get("/api/account/capabilities")).json();
|
|
@@ -470,6 +470,30 @@ test("ADMIN_EMAILS account has the admin role @smoke", async ({ playwright }) =>
|
|
|
470
470
|
await ctx.dispose();
|
|
471
471
|
});
|
|
472
472
|
|
|
473
|
+
// An ADMIN_EMAILS address with no account yet, such as a placeholder that was
|
|
474
|
+
// never replaced: a sign-up with it must not create an account, let alone an
|
|
475
|
+
// approved administrator.
|
|
476
|
+
test("a sign-up cannot claim an ADMIN_EMAILS address", async ({ playwright }) => {
|
|
477
|
+
const email = process.env.E2E_UNCLAIMED_ADMIN_EMAIL;
|
|
478
|
+
test.skip(!email, "set E2E_UNCLAIMED_ADMIN_EMAIL to an ADMIN_EMAILS address that has no account");
|
|
479
|
+
const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
480
|
+
const password = "Podokit3e-Str0ng!pw";
|
|
481
|
+
const signUp = await ctx.post("/api/auth/sign-up/email", { data: { email, password, name: "Claim" } });
|
|
482
|
+
// Refused, or answered like any sign-up while approval or email verification
|
|
483
|
+
// hides whether an address can register.
|
|
484
|
+
expect([200, 403], await signUp.text()).toContain(signUp.status());
|
|
485
|
+
expect((await ctx.post("/api/auth/sign-in/email", { data: { email, password } })).ok()).toBe(false);
|
|
486
|
+
expect((await (await ctx.get("/api/auth/get-session")).json())?.user ?? null).toBeNull();
|
|
487
|
+
await ctx.dispose();
|
|
488
|
+
|
|
489
|
+
const admin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
490
|
+
await admin.post("/api/auth/sign-in/email", { data: { email: ADMIN.email, password: ADMIN.password } });
|
|
491
|
+
const found = await admin.get(`/api/auth/admin/list-users?searchField=email&searchOperator=contains&searchValue=${encodeURIComponent(email ?? "")}`);
|
|
492
|
+
expect(found.ok()).toBeTruthy();
|
|
493
|
+
expect(((await found.json()) as { users: unknown[] }).users).toEqual([]);
|
|
494
|
+
await admin.dispose();
|
|
495
|
+
});
|
|
496
|
+
|
|
473
497
|
test("admin can list users, a normal user cannot (403)", async ({ playwright }) => {
|
|
474
498
|
const adminCtx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
475
499
|
await adminCtx.post("/api/auth/sign-in/email", { data: { email: ADMIN.email, password: ADMIN.password } });
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { expect, test } from "@playwright/test";
|
|
2
|
-
import {
|
|
2
|
+
import { skipUnlessMailReachesMailpit, waitForLink, waitForOtp } from "../helpers/mailpit";
|
|
3
3
|
|
|
4
4
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
5
5
|
const origin = { origin: base };
|
|
6
6
|
|
|
7
7
|
test("email one-time code signs a user in", async ({ playwright }) => {
|
|
8
|
-
|
|
8
|
+
await skipUnlessMailReachesMailpit();
|
|
9
9
|
const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
10
10
|
const caps = await (await ctx.get("/api/account/capabilities")).json();
|
|
11
11
|
test.skip(!caps?.emailOtp, "email OTP not enabled");
|
|
@@ -49,7 +49,7 @@ test("organization invitation can be sent and accepted", async ({ playwright })
|
|
|
49
49
|
});
|
|
50
50
|
|
|
51
51
|
test("magic link signs a user in", async ({ playwright }) => {
|
|
52
|
-
|
|
52
|
+
await skipUnlessMailReachesMailpit();
|
|
53
53
|
const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
54
54
|
const caps = await (await ctx.get("/api/account/capabilities")).json();
|
|
55
55
|
test.skip(!caps?.magicLink, "magic link not enabled");
|
|
@@ -74,7 +74,7 @@ function tokenFromRedirect(location: string): string | null {
|
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
test("password reset delivers an email and updates the password @smoke", async ({ playwright }) => {
|
|
77
|
-
|
|
77
|
+
await skipUnlessMailReachesMailpit();
|
|
78
78
|
const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
79
79
|
const email = `reset-${Date.now()}@example.com`;
|
|
80
80
|
const signup = await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "Reset" } });
|
|
@@ -99,7 +99,7 @@ test("password reset delivers an email and updates the password @smoke", async (
|
|
|
99
99
|
});
|
|
100
100
|
|
|
101
101
|
test("email verification blocks sign-in until the address is confirmed", async ({ playwright }) => {
|
|
102
|
-
|
|
102
|
+
await skipUnlessMailReachesMailpit();
|
|
103
103
|
const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
104
104
|
const email = `verify-${Date.now()}@example.com`;
|
|
105
105
|
const signup = await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "Verify", callbackURL: `${base}/admin` } });
|
|
@@ -1,33 +1,82 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { request, test } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
// Helpers for the local Mailpit catcher (docker-compose / CI service). Each test
|
|
3
4
|
// mails an address of its own and reads the newest message to it; the inbox is
|
|
4
5
|
// shared with tests on other workers, so no test empties it.
|
|
5
6
|
const MAILPIT = process.env.MAILPIT_URL ?? "http://localhost:8025";
|
|
7
|
+
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
8
|
+
const PROBE_WAIT_MS = 5000;
|
|
9
|
+
|
|
10
|
+
type MailpitList = { messages: Array<{ ID: string; To: Array<{ Address: string }> }> };
|
|
11
|
+
type MailpitMessage = { Text?: string; HTML?: string };
|
|
6
12
|
|
|
7
|
-
|
|
13
|
+
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
14
|
+
|
|
15
|
+
async function newestMessageTo(to: string): Promise<string | undefined> {
|
|
16
|
+
const list = (await (await fetch(`${MAILPIT}/api/v1/messages`)).json()) as MailpitList;
|
|
17
|
+
return list.messages?.find((m) => m.To?.some((t) => t.Address === to))?.ID;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// A Mailpit answering on MAILPIT_URL may belong to another stack while this app
|
|
21
|
+
// sends no mail or sends it elsewhere, so ask the app to mail an address of the
|
|
22
|
+
// probe's own and wait for that message. Returns why email tests cannot run, or
|
|
23
|
+
// null when the message arrives.
|
|
24
|
+
async function mailDeliveryProblem(): Promise<string | null> {
|
|
8
25
|
try {
|
|
9
|
-
|
|
26
|
+
if (!(await fetch(`${MAILPIT}/readyz`)).ok) return `Mailpit at ${MAILPIT} is not ready`;
|
|
10
27
|
} catch {
|
|
11
|
-
return
|
|
28
|
+
return `no Mailpit answers at ${MAILPIT}`;
|
|
12
29
|
}
|
|
30
|
+
const email = `mail-probe-${Date.now()}-${process.pid}@example.com`;
|
|
31
|
+
const app = await request.newContext({ baseURL: base, extraHTTPHeaders: { origin: base } });
|
|
32
|
+
try {
|
|
33
|
+
const signUp = await app.post("/api/auth/sign-up/email", {
|
|
34
|
+
data: { email, password: "Podokit3e-Str0ng!pw", name: "Mail probe" },
|
|
35
|
+
});
|
|
36
|
+
if (!signUp.ok()) return `the mail probe could not sign up (HTTP ${signUp.status()})`;
|
|
37
|
+
const reset = await app.post("/api/auth/request-password-reset", {
|
|
38
|
+
data: { email, redirectTo: `${base}/reset-password` },
|
|
39
|
+
});
|
|
40
|
+
if (!reset.ok()) return `the app could not send the probe email (HTTP ${reset.status()})`;
|
|
41
|
+
} finally {
|
|
42
|
+
await app.dispose();
|
|
43
|
+
}
|
|
44
|
+
const deadline = Date.now() + PROBE_WAIT_MS;
|
|
45
|
+
while (Date.now() < deadline) {
|
|
46
|
+
if (await newestMessageTo(email)) return null;
|
|
47
|
+
await sleep(250);
|
|
48
|
+
}
|
|
49
|
+
return `Mailpit at ${MAILPIT} does not receive this app's mail: a probe email to ${email} did not arrive within ${PROBE_WAIT_MS / 1000} s`;
|
|
13
50
|
}
|
|
14
51
|
|
|
15
|
-
|
|
16
|
-
|
|
52
|
+
let delivery: Promise<string | null> | undefined;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Skip the current test unless the app under test delivers its mail to the
|
|
56
|
+
* Mailpit these helpers read. The probe runs once per worker. With
|
|
57
|
+
* E2E_REQUIRE_MAIL=true, which a harness that wires the app's SMTP to that
|
|
58
|
+
* Mailpit sets, a failed probe fails the test instead.
|
|
59
|
+
*/
|
|
60
|
+
export async function skipUnlessMailReachesMailpit(): Promise<void> {
|
|
61
|
+
delivery ??= mailDeliveryProblem().catch((error: unknown) => `the mail probe failed: ${String(error)}`);
|
|
62
|
+
const problem = await delivery;
|
|
63
|
+
if (problem === null) return;
|
|
64
|
+
if (process.env.E2E_REQUIRE_MAIL === "true") throw new Error(`E2E_REQUIRE_MAIL is set, but ${problem}`);
|
|
65
|
+
test.skip(true, problem);
|
|
66
|
+
}
|
|
17
67
|
|
|
18
68
|
// Poll for the newest message addressed to `to` and return the first match of
|
|
19
69
|
// `pattern` in its text/HTML body.
|
|
20
70
|
async function waitForMatch(to: string, pattern: RegExp, label: string, timeoutMs: number): Promise<string> {
|
|
21
71
|
const deadline = Date.now() + timeoutMs;
|
|
22
72
|
while (Date.now() < deadline) {
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const full = (await (await fetch(`${MAILPIT}/api/v1/message/${msg.ID}`)).json()) as MailpitMessage;
|
|
73
|
+
const id = await newestMessageTo(to);
|
|
74
|
+
if (id) {
|
|
75
|
+
const full = (await (await fetch(`${MAILPIT}/api/v1/message/${id}`)).json()) as MailpitMessage;
|
|
27
76
|
const match = `${full.Text ?? ""} ${full.HTML ?? ""}`.match(pattern);
|
|
28
77
|
if (match) return match[0];
|
|
29
78
|
}
|
|
30
|
-
await
|
|
79
|
+
await sleep(300);
|
|
31
80
|
}
|
|
32
81
|
throw new Error(`no email with ${label} to ${to} within ${timeoutMs}ms`);
|
|
33
82
|
}
|
|
@@ -41,17 +41,29 @@ function withTestLocale(state: StorageState): StorageState {
|
|
|
41
41
|
// cookies as storageState for the browser `ui` project to reuse. Pin the suite
|
|
42
42
|
// locale because UI locators use the generated English catalog and must not
|
|
43
43
|
// inherit an application's configured default language.
|
|
44
|
+
//
|
|
45
|
+
// The user signs up here. The administrator cannot: the API refuses a sign-up
|
|
46
|
+
// with an ADMIN_EMAILS address that has not proven it, so the account comes
|
|
47
|
+
// from admin:bootstrap before the suite runs.
|
|
44
48
|
async function seedSession(
|
|
45
49
|
playwright: import("@playwright/test").PlaywrightWorkerArgs["playwright"],
|
|
46
50
|
account: Account,
|
|
47
51
|
path: string,
|
|
52
|
+
{ signUp }: { signUp: boolean },
|
|
48
53
|
): Promise<void> {
|
|
49
54
|
const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: { origin: base } });
|
|
50
|
-
await ctx.post("/api/auth/sign-up/email", { data: account }).catch(() => undefined); // idempotent
|
|
55
|
+
if (signUp) await ctx.post("/api/auth/sign-up/email", { data: account }).catch(() => undefined); // idempotent
|
|
51
56
|
const res = await ctx.post("/api/auth/sign-in/email", {
|
|
52
57
|
data: { email: account.email, password: account.password },
|
|
53
58
|
});
|
|
54
|
-
expect(
|
|
59
|
+
expect(
|
|
60
|
+
res.ok(),
|
|
61
|
+
signUp
|
|
62
|
+
? `sign-in ${account.email}`
|
|
63
|
+
: `sign-in ${account.email}: create the administrator first with ` +
|
|
64
|
+
`ADMIN_BOOTSTRAP_EMAIL=${account.email} ADMIN_BOOTSTRAP_PASSWORD=<its password> ADMIN_BOOTSTRAP_NAME="${account.name}" {{apiRun}} admin:bootstrap{{apiWorkspaceArg}}, ` +
|
|
65
|
+
"or name an existing administrator as tests/helpers/accounts.ts describes",
|
|
66
|
+
).toBeTruthy();
|
|
55
67
|
// Each run signs in again, so keep a long-lived local database from piling up
|
|
56
68
|
// this account's sessions: the seeded one is the only one left.
|
|
57
69
|
expect((await ctx.post("/api/auth/revoke-other-sessions")).ok(), `clear old sessions for ${account.email}`).toBeTruthy();
|
|
@@ -60,7 +72,7 @@ async function seedSession(
|
|
|
60
72
|
}
|
|
61
73
|
|
|
62
74
|
setup("seed admin session", async ({ playwright }) => {
|
|
63
|
-
await seedSession(playwright, ADMIN, adminState);
|
|
75
|
+
await seedSession(playwright, ADMIN, adminState, { signUp: false });
|
|
64
76
|
});
|
|
65
77
|
|
|
66
78
|
// Feature flags are DB-backed (seeded by the app_setting migration). phoneNumber
|
|
@@ -75,7 +87,7 @@ setup("enable optional features", async ({ playwright }) => {
|
|
|
75
87
|
});
|
|
76
88
|
|
|
77
89
|
setup("seed user session", async ({ playwright }) => {
|
|
78
|
-
await seedSession(playwright, USER, userState);
|
|
90
|
+
await seedSession(playwright, USER, userState, { signUp: true });
|
|
79
91
|
});
|
|
80
92
|
|
|
81
93
|
setup("capture the user cleanup baseline", async ({ playwright }) => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { expect, test } from "../helpers/disposable-users";
|
|
2
2
|
import { ready } from "../helpers/hydration";
|
|
3
|
-
import {
|
|
3
|
+
import { skipUnlessMailReachesMailpit, waitForLink } from "../helpers/mailpit";
|
|
4
4
|
|
|
5
5
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
6
6
|
|
|
@@ -17,7 +17,7 @@ test("verification badges stay hidden when the feature is off", async ({ page })
|
|
|
17
17
|
|
|
18
18
|
// Admin can trigger a password-reset email for a user (not verification-gated).
|
|
19
19
|
test("admin sends a password reset email to a user", async ({ page, disposableUsers }) => {
|
|
20
|
-
|
|
20
|
+
await skipUnlessMailReachesMailpit();
|
|
21
21
|
await ready(page, "/admin/users");
|
|
22
22
|
const email = `admin-reset-${Date.now()}@example.com`;
|
|
23
23
|
await disposableUsers.create({ email, name: "AR" });
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { expect, test } from "@playwright/test";
|
|
2
2
|
import { anonState } from "../helpers/accounts";
|
|
3
3
|
import { ready } from "../helpers/hydration";
|
|
4
|
-
import {
|
|
4
|
+
import { skipUnlessMailReachesMailpit, waitForLink } from "../helpers/mailpit";
|
|
5
5
|
|
|
6
6
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
7
7
|
const origin = { origin: base };
|
|
@@ -10,7 +10,7 @@ const origin = { origin: base };
|
|
|
10
10
|
test.use({ storageState: anonState });
|
|
11
11
|
|
|
12
12
|
test("forgot password: request, open the emailed link, set a new password, sign in", async ({ page, playwright }) => {
|
|
13
|
-
|
|
13
|
+
await skipUnlessMailReachesMailpit();
|
|
14
14
|
const email = `ui-reset-${Date.now()}@example.com`;
|
|
15
15
|
const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
16
16
|
const signup = await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "UIReset" } });
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { expect, test } from "@playwright/test";
|
|
2
2
|
import { anonState } from "../helpers/accounts";
|
|
3
3
|
import { ready } from "../helpers/hydration";
|
|
4
|
-
import {
|
|
4
|
+
import { skipUnlessMailReachesMailpit, waitForOtp } from "../helpers/mailpit";
|
|
5
5
|
|
|
6
6
|
test.use({ storageState: anonState });
|
|
7
7
|
|
|
@@ -24,7 +24,7 @@ test("login: an emailed one-time code signs a user in", async ({ page, playwrigh
|
|
|
24
24
|
const api = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
25
25
|
const caps = await (await api.get("/api/account/capabilities")).json();
|
|
26
26
|
test.skip(!caps?.emailOtp, "email otp disabled");
|
|
27
|
-
|
|
27
|
+
await skipUnlessMailReachesMailpit();
|
|
28
28
|
|
|
29
29
|
const email = `ui-otp-${Date.now()}@example.com`;
|
|
30
30
|
await api.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "OTP" } });
|
|
@@ -57,8 +57,8 @@ test("settings: open and save the SMTP dialog", { tag: "@exclusive" }, async ({
|
|
|
57
57
|
await page.getByRole("button", { name: "SMTP settings" }).click();
|
|
58
58
|
const dialog = page.getByRole("dialog");
|
|
59
59
|
try {
|
|
60
|
-
//
|
|
61
|
-
//
|
|
60
|
+
// SMTP stays switched off, so saving these values can't re-route mail for the
|
|
61
|
+
// other email specs: the mailer keeps the environment's transport.
|
|
62
62
|
await dialog.locator("#smtp-host").fill("localhost");
|
|
63
63
|
await dialog.locator("#smtp-port").fill("1125");
|
|
64
64
|
await dialog.getByRole("button", { name: "Save", exact: true }).click();
|
|
@@ -51,7 +51,9 @@
|
|
|
51
51
|
}
|
|
52
52
|
},
|
|
53
53
|
"env": [
|
|
54
|
-
"# admin-dashboard: emails
|
|
54
|
+
"# admin-dashboard: administrator emails (comma-separated). An account with one becomes an",
|
|
55
|
+
"# admin when admin:bootstrap creates it or a sign-in verifies the address; an email and",
|
|
56
|
+
"# password sign-up with one is refused. A production API refuses placeholders (example.com).",
|
|
55
57
|
"ADMIN_EMAILS=admin@example.com"
|
|
56
58
|
],
|
|
57
59
|
"migrations": [
|
|
@@ -140,7 +142,7 @@
|
|
|
140
142
|
"instructions": [
|
|
141
143
|
"Set ADMIN_EMAILS in .env (comma-separated). After building and migrating, inject ADMIN_BOOTSTRAP_EMAIL and ADMIN_BOOTSTRAP_PASSWORD for one command only, then run {{apiRun}} admin:bootstrap{{apiWorkspaceArg}}. The command creates a verified, approved admin or safely verifies an existing one.",
|
|
142
144
|
"Run all migrations to add the admin and sign-up approval columns: bun run --cwd apps/api migrate:all",
|
|
143
|
-
"Remove ADMIN_BOOTSTRAP_PASSWORD from the environment after bootstrap. Keep ADMIN_EMAILS
|
|
145
|
+
"Remove ADMIN_BOOTSTRAP_PASSWORD from the environment after bootstrap. Keep ADMIN_EMAILS with the administrators' real addresses: a later account with one becomes an administrator only when a sign-in verifies the address as it creates the account, an email and password sign-up with one is refused, and a production API refuses to start with a placeholder such as an example.com address.",
|
|
144
146
|
"Admin pages: /admin/users (list, approve pending registrations, ban, set role, revoke sessions). Signed-in users manage their profile, profile image, and security at /account; /admin/account remains available in the admin shell. Profile images accept PNG, JPEG, and WebP up to 2 MB and 2048 by 2048 pixels. Admin, account, authentication, maintenance, and API responses include X-Robots-Tag: noindex, nofollow by default.",
|
|
145
147
|
"Password reset emails go through the mailer module (installed automatically with auth); in dev without SMTP they are logged to the API console. Configure SMTP in admin settings or via SMTP_* env for production."
|
|
146
148
|
]
|
|
@@ -13,13 +13,33 @@ which the Better Auth plugin in `audit-hook.ts` maps to semantic action codes li
|
|
|
13
13
|
matters.
|
|
14
14
|
|
|
15
15
|
The plugin records an action only when the request succeeded, so a wrong
|
|
16
|
-
password or a forbidden admin call leaves no entry.
|
|
16
|
+
password or a forbidden admin call leaves no entry. The exception is a new
|
|
17
|
+
account: `auth.signup` is recorded when Better Auth stores the user, whichever
|
|
18
|
+
endpoint creates it (email sign-up, a social provider's callback, a magic link,
|
|
19
|
+
an email code, or a plugin the app adds), and even when the request then fails,
|
|
20
|
+
as a social sign-up that waits for approval does when it is refused a session.
|
|
21
|
+
Its `metadata.method` names the endpoint the way `auth.login` does, with the
|
|
22
|
+
provider for a social sign-up, for example
|
|
23
|
+
`{ "method": "social", "provider": "google" }`. A user an administrator creates
|
|
24
|
+
is recorded as `user.create` only. Entries name the user even when the request
|
|
25
|
+
carries no session: `auth.logout` names each account whose session the sign-out
|
|
26
|
+
ended (a sign-out without a session records nothing), a password reset by link
|
|
27
|
+
or code and an email verification name the account they changed,
|
|
28
|
+
`user.session_revoke` names the owner of the revoked session, and
|
|
29
|
+
`user.stop_impersonate` names the administrator. Every sign-in method records
|
|
17
30
|
`auth.login` with the method in `metadata.method` (`password`, `email-otp`,
|
|
18
31
|
`magic-link`, `passkey`, `two-factor-totp`, ...) and, for social sign-in, the
|
|
19
|
-
provider in `metadata.provider`. A
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
32
|
+
provider in `metadata.provider`. A sign-in through an endpoint of a plugin the
|
|
33
|
+
app adds, such as `@better-auth/electron`'s `/electron/token`, is recorded too,
|
|
34
|
+
with the endpoint's path as the method: any request that ends with a new session
|
|
35
|
+
it did not come with is a sign-in, except the endpoints in `NOT_SIGN_INS`
|
|
36
|
+
(sign-up, impersonation, switching between a browser's sessions, and requests
|
|
37
|
+
that replace the signed-in user's own session). Session reads and updates that
|
|
38
|
+
refresh the request's own session, including bearer and API key requests, are
|
|
39
|
+
not. A two-factor sign-in is recorded when the second factor completes it, not
|
|
40
|
+
at the password step. `auth.ts` pushes the plugin after every other plugin:
|
|
41
|
+
Better Auth runs after hooks in plugin order, and two-factor changes the outcome
|
|
42
|
+
of a password sign-in in its own after hook.
|
|
23
43
|
|
|
24
44
|
## Audit your own Elysia routes
|
|
25
45
|
|
|
@@ -96,9 +116,12 @@ survives renames/deletes), `targetType/targetId/targetLabel`, `ip`, `metadata`
|
|
|
96
116
|
## Customize
|
|
97
117
|
|
|
98
118
|
- **Auth actions:** `ACTIONS` in `audit-hook.ts` maps Better Auth endpoints to
|
|
99
|
-
action codes,
|
|
100
|
-
|
|
101
|
-
|
|
119
|
+
action codes, `SIGN_IN_METHODS` names the method of Better Auth's sign-in
|
|
120
|
+
endpoints, and `NOT_SIGN_INS` lists the endpoints that start or swap a session
|
|
121
|
+
without a sign-in. The `audit-hook.spec.ts` test fails when Better Auth or a
|
|
122
|
+
plugin PodoKit configures gains an endpoint that it has not classified; the
|
|
123
|
+
endpoints of plugins the app adds are recorded as sign-ins when they start a
|
|
124
|
+
session, without a change to these lists.
|
|
102
125
|
- **Your routes:** call `AuditService.recordRequest()` after successful operations;
|
|
103
126
|
pass `{ defer: true }` only for high-volume, non-security events.
|
|
104
127
|
- **Schema:** add columns with a migration, and keep the
|