@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.
@@ -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(res.ok(), `sign-in ${account.email}`).toBeTruthy();
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 auto-promoted to the admin role on sign-up (comma-separated)",
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 so matching future sign-ups are promoted consistently.",
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. Every sign-in method records
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
- user: { changeEmail: { enabled: true, updateEmailWithoutVerification: true } },
148
- emailAndPassword: { enabled: true },
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: true,
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());