@podosoft/podokit 1.6.3 → 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/dot-agents/skills/podokit-deploy/references/profile.md +3 -2
- package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +26 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +24 -0
- package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +16 -4
- package/dist/templates/modules/admin-dashboard/module.manifest.json +4 -2
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +14 -1
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.spec.ts +284 -6
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +184 -38
- 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/package.json +1 -1
|
@@ -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 } });
|
|
@@ -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 }) => {
|
|
@@ -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,7 +13,20 @@ 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
32
|
provider in `metadata.provider`. A sign-in through an endpoint of a plugin the
|
|
@@ -8,7 +8,7 @@ import { memoryAdapter } from "better-auth/adapters/memory";
|
|
|
8
8
|
import { APIError, createAuthEndpoint } from "better-auth/api";
|
|
9
9
|
import { setSessionCookie } from "better-auth/cookies";
|
|
10
10
|
import { getAuthTables } from "better-auth/db";
|
|
11
|
-
import { admin, bearer, emailOTP, jwt, magicLink, multiSession, twoFactor } from "better-auth/plugins";
|
|
11
|
+
import { admin, bearer, emailOTP, jwt, magicLink, multiSession, phoneNumber, twoFactor } from "better-auth/plugins";
|
|
12
12
|
import { buildAuth } from "../auth/auth";
|
|
13
13
|
import { setAuditRecorder, type AuditEntry } from "./audit-events";
|
|
14
14
|
import { auditPlugin, NOT_SIGN_INS, SIGN_IN_METHODS } from "./audit-hook";
|
|
@@ -83,6 +83,8 @@ interface Mailbox {
|
|
|
83
83
|
magicLink: string;
|
|
84
84
|
otp: string;
|
|
85
85
|
verification: string;
|
|
86
|
+
reset: string;
|
|
87
|
+
sms: string;
|
|
86
88
|
}
|
|
87
89
|
|
|
88
90
|
interface AuthHandler {
|
|
@@ -131,23 +133,55 @@ function desktopSignIn(): BetterAuthPlugin {
|
|
|
131
133
|
interface StartOptions {
|
|
132
134
|
enabled?: () => Promise<boolean>;
|
|
133
135
|
session?: BetterAuthOptions["session"];
|
|
136
|
+
/** Self-registered accounts wait for approval without a session, as auth.ts does. */
|
|
137
|
+
signupApproval?: boolean;
|
|
138
|
+
/** Whether an email verification link signs its user in. */
|
|
139
|
+
verificationSignIn?: boolean;
|
|
134
140
|
}
|
|
135
141
|
|
|
142
|
+
// Like auth.ts with sign-up approval on: a self-registered account starts
|
|
143
|
+
// pending and gets no session until an administrator approves it.
|
|
144
|
+
const signupApprovalHooks: BetterAuthOptions["databaseHooks"] = {
|
|
145
|
+
user: {
|
|
146
|
+
create: {
|
|
147
|
+
before: async (user, context) => ({ data: { ...user, signupApproved: context?.path === "/admin/create-user" } }),
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
session: {
|
|
151
|
+
create: {
|
|
152
|
+
before: async (session, context) => {
|
|
153
|
+
const user = (await context?.context.internalAdapter.findUserById(session.userId)) as { signupApproved?: boolean } | null;
|
|
154
|
+
if (user?.signupApproved === false) throw new APIError("FORBIDDEN", { message: "Waiting for approval." });
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
|
|
136
160
|
// The sign-in methods PodoKit's auth.ts enables and plugins that set or swap
|
|
137
161
|
// sessions without a sign-in, on an in-memory database, with the audit plugin
|
|
138
162
|
// pushed last as the audit-log module does.
|
|
139
|
-
function startAuth({ enabled = async () => true, session }: StartOptions = {}) {
|
|
140
|
-
const mail: Mailbox = { magicLink: "", otp: "", verification: "" };
|
|
163
|
+
function startAuth({ enabled = async () => true, session, signupApproval = false, verificationSignIn = true }: StartOptions = {}) {
|
|
164
|
+
const mail: Mailbox = { magicLink: "", otp: "", verification: "", reset: "", sms: "" };
|
|
141
165
|
const db: Record<string, Record<string, unknown>[]> = {};
|
|
142
166
|
const options = {
|
|
143
167
|
baseURL: BASE_URL,
|
|
144
168
|
secret: "audit-hook-test-secret-0123456789abcdef",
|
|
145
169
|
database: memoryAdapter(db),
|
|
146
170
|
...(session ? { session } : {}),
|
|
147
|
-
|
|
148
|
-
|
|
171
|
+
...(signupApproval ? { databaseHooks: signupApprovalHooks } : {}),
|
|
172
|
+
user: {
|
|
173
|
+
changeEmail: { enabled: true, updateEmailWithoutVerification: true },
|
|
174
|
+
additionalFields: { signupApproved: { type: "boolean" as const, required: false, defaultValue: true, input: false } },
|
|
175
|
+
},
|
|
176
|
+
emailAndPassword: {
|
|
177
|
+
enabled: true,
|
|
178
|
+
autoSignIn: !signupApproval,
|
|
179
|
+
sendResetPassword: async ({ token }: { token: string }) => {
|
|
180
|
+
mail.reset = token;
|
|
181
|
+
},
|
|
182
|
+
},
|
|
149
183
|
emailVerification: {
|
|
150
|
-
autoSignInAfterVerification:
|
|
184
|
+
autoSignInAfterVerification: verificationSignIn,
|
|
151
185
|
sendVerificationEmail: async ({ url }: { url: string }) => {
|
|
152
186
|
mail.verification = url;
|
|
153
187
|
},
|
|
@@ -183,10 +217,19 @@ function startAuth({ enabled = async () => true, session }: StartOptions = {}) {
|
|
|
183
217
|
},
|
|
184
218
|
}),
|
|
185
219
|
emailOTP({
|
|
220
|
+
changeEmail: { enabled: true },
|
|
186
221
|
sendVerificationOTP: async ({ otp }) => {
|
|
187
222
|
mail.otp = otp;
|
|
188
223
|
},
|
|
189
224
|
}),
|
|
225
|
+
phoneNumber({
|
|
226
|
+
sendOTP: async ({ code }) => {
|
|
227
|
+
mail.sms = code;
|
|
228
|
+
},
|
|
229
|
+
sendPasswordResetOTP: async ({ code }) => {
|
|
230
|
+
mail.sms = code;
|
|
231
|
+
},
|
|
232
|
+
}),
|
|
190
233
|
multiSession(),
|
|
191
234
|
apiKey({ enableSessionForAPIKeys: true, rateLimit: { enabled: false } }),
|
|
192
235
|
bearer({ requireSignature: true }),
|
|
@@ -272,6 +315,33 @@ function totp(uri: string): string {
|
|
|
272
315
|
|
|
273
316
|
const otherCode = (code: string): string => String((Number(code) + 1) % 1_000_000).padStart(6, "0");
|
|
274
317
|
|
|
318
|
+
// A Google sign-in through the provider's callback, with the token endpoint
|
|
319
|
+
// stubbed; the provider reports social@example.com, verified.
|
|
320
|
+
async function googleSignIn(browser: Browser): Promise<Response> {
|
|
321
|
+
const realFetch = globalThis.fetch;
|
|
322
|
+
const tokenEndpoint = spyOn(globalThis, "fetch").mockImplementation(((input: RequestInfo | URL, init?: RequestInit) => {
|
|
323
|
+
const url = input instanceof Request ? input.url : String(input);
|
|
324
|
+
if (url.startsWith("https://oauth2.googleapis.com/token")) {
|
|
325
|
+
return Promise.resolve(Response.json({ access_token: "access", expires_in: 3600, token_type: "Bearer" }));
|
|
326
|
+
}
|
|
327
|
+
return realFetch(input, init);
|
|
328
|
+
}) as typeof fetch);
|
|
329
|
+
try {
|
|
330
|
+
const started = await browser.post("/sign-in/social", { provider: "google", callbackURL: `${BASE_URL}/` });
|
|
331
|
+
const state = new URL(((await started.json()) as { url: string }).url).searchParams.get("state") ?? "";
|
|
332
|
+
return await browser.get(`/callback/google?code=code&state=${encodeURIComponent(state)}`);
|
|
333
|
+
} finally {
|
|
334
|
+
tokenEndpoint.mockRestore();
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function makeAdmin(db: Record<string, Record<string, unknown>[]>, browser: Browser): Promise<string> {
|
|
339
|
+
const id = await userId(browser);
|
|
340
|
+
const row = db.user?.find((user) => user.id === id);
|
|
341
|
+
if (row) row.role = "admin";
|
|
342
|
+
return id;
|
|
343
|
+
}
|
|
344
|
+
|
|
275
345
|
describe("audit plugin", () => {
|
|
276
346
|
test("records a password sign-in once and a wrong password not at all", async () => {
|
|
277
347
|
const { auth } = startAuth();
|
|
@@ -583,6 +653,214 @@ describe("audit plugin", () => {
|
|
|
583
653
|
});
|
|
584
654
|
});
|
|
585
655
|
|
|
656
|
+
describe("audit plugin accounts and sessions", () => {
|
|
657
|
+
test("records an email sign-up once, and not a duplicate answered as if it succeeded", async () => {
|
|
658
|
+
const { auth } = startAuth({ signupApproval: true });
|
|
659
|
+
const first = await new Browser(auth).post("/sign-up/email", { email: "pending@example.com", password: PASSWORD, name: "P" });
|
|
660
|
+
expect(first.status).toBe(200);
|
|
661
|
+
const id = (await first.json()) as { user: { id: string } };
|
|
662
|
+
// With auto sign-in off, Better Auth answers a taken address with the same 200.
|
|
663
|
+
const again = await new Browser(auth).post("/sign-up/email", { email: "pending@example.com", password: PASSWORD, name: "P" });
|
|
664
|
+
expect(again.status).toBe(200);
|
|
665
|
+
expect(entries).toEqual([
|
|
666
|
+
expect.objectContaining({
|
|
667
|
+
action: "auth.signup",
|
|
668
|
+
actorEmail: "pending@example.com",
|
|
669
|
+
targetId: id.user.id,
|
|
670
|
+
targetLabel: "pending@example.com",
|
|
671
|
+
metadata: { method: "password" },
|
|
672
|
+
}),
|
|
673
|
+
]);
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
test("records a social sign-up that waits for approval, without a sign-in", async () => {
|
|
677
|
+
const { auth, db } = startAuth({ signupApproval: true });
|
|
678
|
+
// The approval hook refuses the session, so the callback fails after the
|
|
679
|
+
// account is stored.
|
|
680
|
+
expect((await googleSignIn(new Browser(auth))).status).toBe(403);
|
|
681
|
+
expect(db.session).toEqual([]);
|
|
682
|
+
expect(db.user).toHaveLength(1);
|
|
683
|
+
expect(entries).toEqual([
|
|
684
|
+
expect.objectContaining({
|
|
685
|
+
action: "auth.signup",
|
|
686
|
+
actorEmail: "social@example.com",
|
|
687
|
+
targetId: db.user?.[0]?.id,
|
|
688
|
+
metadata: { method: "social", provider: "google" },
|
|
689
|
+
}),
|
|
690
|
+
]);
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
test("records an approved social sign-up and its sign-in, and a later sign-in alone", async () => {
|
|
694
|
+
const { auth } = startAuth();
|
|
695
|
+
expect((await googleSignIn(new Browser(auth))).status).toBe(302);
|
|
696
|
+
expect(entries.map((entry) => [entry.action, entry.metadata])).toEqual([
|
|
697
|
+
["auth.signup", { method: "social", provider: "google" }],
|
|
698
|
+
["auth.login", { method: "social", provider: "google" }],
|
|
699
|
+
]);
|
|
700
|
+
expect((await googleSignIn(new Browser(auth))).status).toBe(302);
|
|
701
|
+
expect(entries.map((entry) => entry.action)).toEqual(["auth.signup", "auth.login", "auth.login"]);
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
test("records the account a magic link or an email code creates as a sign-up", async () => {
|
|
705
|
+
const { auth, mail } = startAuth();
|
|
706
|
+
const linked = new Browser(auth);
|
|
707
|
+
await linked.post("/sign-in/magic-link", { email: "new-magic@example.com", callbackURL: `${BASE_URL}/` });
|
|
708
|
+
expect((await linked.get(mail.magicLink)).status).toBe(302);
|
|
709
|
+
const coded = new Browser(auth);
|
|
710
|
+
await coded.post("/email-otp/send-verification-otp", { email: "new-code@example.com", type: "sign-in" });
|
|
711
|
+
expect((await coded.post("/sign-in/email-otp", { email: "new-code@example.com", otp: mail.otp })).status).toBe(200);
|
|
712
|
+
expect(entries.map((entry) => [entry.action, entry.actorEmail, entry.metadata])).toEqual([
|
|
713
|
+
["auth.signup", "new-magic@example.com", { method: "magic-link" }],
|
|
714
|
+
["auth.login", "new-magic@example.com", { method: "magic-link" }],
|
|
715
|
+
["auth.signup", "new-code@example.com", { method: "email-otp" }],
|
|
716
|
+
["auth.login", "new-code@example.com", { method: "email-otp" }],
|
|
717
|
+
]);
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
test("records an administrator's new user as user.create only, with its id", async () => {
|
|
721
|
+
const { auth, db } = startAuth({ signupApproval: true });
|
|
722
|
+
await new Browser(auth).post("/sign-up/email", { email: "creator@example.com", password: PASSWORD, name: "C" });
|
|
723
|
+
const creator = db.user?.[0];
|
|
724
|
+
if (creator) Object.assign(creator, { role: "admin", signupApproved: true });
|
|
725
|
+
const operator = new Browser(auth);
|
|
726
|
+
expect((await operator.post("/sign-in/email", { email: "creator@example.com", password: PASSWORD })).status).toBe(200);
|
|
727
|
+
entries = [];
|
|
728
|
+
|
|
729
|
+
const created = await operator.post("/admin/create-user", { email: "made@example.com", password: PASSWORD, name: "M" });
|
|
730
|
+
expect(created.status).toBe(200);
|
|
731
|
+
const { user } = (await created.json()) as { user: { id: string } };
|
|
732
|
+
expect(entries).toEqual([
|
|
733
|
+
expect.objectContaining({ action: "user.create", actorEmail: "creator@example.com", targetId: user.id, targetLabel: "made@example.com" }),
|
|
734
|
+
]);
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
test("records an account created outside a request without an actor", async () => {
|
|
738
|
+
const { auth } = startAuth();
|
|
739
|
+
const context = await auth.$context;
|
|
740
|
+
const user = await context.internalAdapter.createUser(
|
|
741
|
+
{ email: "seeded@example.com", name: "Seeded", emailVerified: true },
|
|
742
|
+
{ method: "seed" },
|
|
743
|
+
);
|
|
744
|
+
expect(entries).toEqual([
|
|
745
|
+
{ action: "auth.signup", targetId: user.id, targetLabel: "seeded@example.com", ip: null },
|
|
746
|
+
]);
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
test("names the user who signs out, and records nothing for a sign-out without a session", async () => {
|
|
750
|
+
const { auth } = startAuth();
|
|
751
|
+
const owner = await signUp(auth, "leaving@example.com");
|
|
752
|
+
const id = await userId(owner);
|
|
753
|
+
entries = [];
|
|
754
|
+
expect((await new Browser(auth).post("/sign-out", {})).status).toBe(200);
|
|
755
|
+
const forged = new Browser(auth, { cookie: "better-auth.session_token=forged.signature" });
|
|
756
|
+
expect((await forged.post("/sign-out", {})).status).toBe(200);
|
|
757
|
+
expect(entries).toEqual([]);
|
|
758
|
+
|
|
759
|
+
expect((await owner.post("/sign-out", {})).status).toBe(200);
|
|
760
|
+
expect(entries).toEqual([
|
|
761
|
+
expect.objectContaining({ action: "auth.logout", actorId: id, actorEmail: "leaving@example.com", targetId: id }),
|
|
762
|
+
]);
|
|
763
|
+
// The session is gone, so signing out again ends nothing.
|
|
764
|
+
expect((await owner.post("/sign-out", {})).status).toBe(200);
|
|
765
|
+
expect(actions("auth.logout")).toHaveLength(1);
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
test("records a sign-out of every account a browser holds", async () => {
|
|
769
|
+
const { auth } = startAuth();
|
|
770
|
+
await signUp(auth, "first@example.com");
|
|
771
|
+
await signUp(auth, "second@example.com");
|
|
772
|
+
const browser = new Browser(auth);
|
|
773
|
+
await browser.post("/sign-in/email", { email: "first@example.com", password: PASSWORD });
|
|
774
|
+
await browser.post("/sign-in/email", { email: "second@example.com", password: PASSWORD });
|
|
775
|
+
entries = [];
|
|
776
|
+
expect((await browser.post("/sign-out", {})).status).toBe(200);
|
|
777
|
+
expect(actions("auth.logout").map((entry) => entry.actorEmail).sort()).toEqual(["first@example.com", "second@example.com"]);
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
test("names the owner of a session an administrator revokes, and the administrator who stops impersonating", async () => {
|
|
781
|
+
const { auth, db } = startAuth();
|
|
782
|
+
const operator = await signUp(auth, "revoker@example.com");
|
|
783
|
+
await makeAdmin(db, operator);
|
|
784
|
+
const target = await signUp(auth, "revoked@example.com");
|
|
785
|
+
const targetId = await userId(target);
|
|
786
|
+
const { session } = (await (await target.get("/get-session")).json()) as { session: { token: string } };
|
|
787
|
+
entries = [];
|
|
788
|
+
|
|
789
|
+
expect((await operator.post("/admin/revoke-user-session", { sessionToken: session.token })).status).toBe(200);
|
|
790
|
+
expect(actions("user.session_revoke")).toEqual([
|
|
791
|
+
expect.objectContaining({ actorEmail: "revoker@example.com", targetId, targetLabel: "revoked@example.com" }),
|
|
792
|
+
]);
|
|
793
|
+
|
|
794
|
+
expect((await operator.post("/admin/impersonate-user", { userId: targetId })).status).toBe(200);
|
|
795
|
+
expect((await operator.post("/admin/stop-impersonating", {})).status).toBe(200);
|
|
796
|
+
expect(actions("user.stop_impersonate")).toEqual([
|
|
797
|
+
expect.objectContaining({ actorEmail: "revoker@example.com", targetId, targetLabel: "revoked@example.com" }),
|
|
798
|
+
]);
|
|
799
|
+
// Ending the impersonation session is not a sign-out.
|
|
800
|
+
expect(actions("auth.logout")).toEqual([]);
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
test("names the user a password reset link, email code, or text message code resets", async () => {
|
|
804
|
+
const { auth, mail, db } = startAuth();
|
|
805
|
+
await signUp(auth, "reset@example.com");
|
|
806
|
+
const id = db.user?.[0]?.id;
|
|
807
|
+
entries = [];
|
|
808
|
+
|
|
809
|
+
const anonymous = new Browser(auth);
|
|
810
|
+
await anonymous.post("/request-password-reset", { email: "reset@example.com" });
|
|
811
|
+
const wrong = await anonymous.post("/reset-password", { token: "forged", newPassword: "N3w-Str0ng!password" });
|
|
812
|
+
expect(wrong.status).toBe(400);
|
|
813
|
+
expect((await anonymous.post("/reset-password", { token: mail.reset, newPassword: "N3w-Str0ng!password" })).status).toBe(200);
|
|
814
|
+
|
|
815
|
+
await anonymous.post("/email-otp/request-password-reset", { email: "reset@example.com" });
|
|
816
|
+
const coded = await anonymous.post("/email-otp/reset-password", { email: "reset@example.com", otp: mail.otp, password: PASSWORD });
|
|
817
|
+
expect(coded.status).toBe(200);
|
|
818
|
+
|
|
819
|
+
const row = db.user?.[0];
|
|
820
|
+
if (row) Object.assign(row, { phoneNumber: "+15550100", phoneNumberVerified: true });
|
|
821
|
+
await anonymous.post("/phone-number/request-password-reset", { phoneNumber: "+15550100" });
|
|
822
|
+
const texted = await anonymous.post("/phone-number/reset-password", {
|
|
823
|
+
phoneNumber: "+15550100",
|
|
824
|
+
otp: mail.sms,
|
|
825
|
+
newPassword: "Th1rd-Str0ng!password",
|
|
826
|
+
});
|
|
827
|
+
expect(texted.status).toBe(200);
|
|
828
|
+
|
|
829
|
+
expect(actions("auth.password_reset")).toEqual([
|
|
830
|
+
expect.objectContaining({ actorId: id, actorEmail: "reset@example.com", targetId: id, targetLabel: "reset@example.com" }),
|
|
831
|
+
expect.objectContaining({ actorId: id, actorEmail: "reset@example.com", targetId: id, targetLabel: "reset@example.com" }),
|
|
832
|
+
expect.objectContaining({ actorId: id, actorEmail: "reset@example.com", targetId: id, targetLabel: "reset@example.com" }),
|
|
833
|
+
]);
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
test("names the user an email verification link or code verifies", async () => {
|
|
837
|
+
const { auth, mail } = startAuth({ verificationSignIn: false });
|
|
838
|
+
const owner = await signUp(auth, "verify-me@example.com");
|
|
839
|
+
const id = await userId(owner);
|
|
840
|
+
await owner.post("/send-verification-email", { email: "verify-me@example.com", callbackURL: `${BASE_URL}/` });
|
|
841
|
+
expect((await new Browser(auth).get(mail.verification)).status).toBe(302);
|
|
842
|
+
|
|
843
|
+
const coded = await signUp(auth, "verify-code@example.com");
|
|
844
|
+
const codedId = await userId(coded);
|
|
845
|
+
const anonymous = new Browser(auth);
|
|
846
|
+
await anonymous.post("/email-otp/send-verification-otp", { email: "verify-code@example.com", type: "email-verification" });
|
|
847
|
+
expect((await anonymous.post("/email-otp/verify-email", { email: "verify-code@example.com", otp: mail.otp })).status).toBe(200);
|
|
848
|
+
|
|
849
|
+
expect(actions("auth.email_verified")).toEqual([
|
|
850
|
+
expect.objectContaining({ actorId: id, actorEmail: "verify-me@example.com", targetId: id, targetLabel: "verify-me@example.com" }),
|
|
851
|
+
expect.objectContaining({ actorId: codedId, targetId: codedId, targetLabel: "verify-code@example.com" }),
|
|
852
|
+
]);
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
test("records an email change by code", async () => {
|
|
856
|
+
const { auth, mail } = startAuth();
|
|
857
|
+
const owner = await signUp(auth, "old-address@example.com");
|
|
858
|
+
expect((await owner.post("/email-otp/request-email-change", { newEmail: "new-address@example.com" })).status).toBe(200);
|
|
859
|
+
expect((await owner.post("/email-otp/change-email", { newEmail: "new-address@example.com", otp: mail.otp })).status).toBe(200);
|
|
860
|
+
expect(actions("auth.email_change")).toEqual([expect.objectContaining({ actorEmail: "old-address@example.com" })]);
|
|
861
|
+
});
|
|
862
|
+
});
|
|
863
|
+
|
|
586
864
|
describe("the app's Better Auth", () => {
|
|
587
865
|
test("runs the audit hook after every plugin's after hooks", () => {
|
|
588
866
|
const instance = buildAuth(envAuthConfig());
|