diff --git a/.changeset/change-email-enabled-delete-user-debooked.md b/.changeset/change-email-enabled-delete-user-debooked.md new file mode 100644 index 0000000000..0a1d85d09a --- /dev/null +++ b/.changeset/change-email-enabled-delete-user-debooked.md @@ -0,0 +1,44 @@ +--- +"@objectstack/plugin-auth": minor +--- + +feat(plugin-auth): `POST /auth/change-email` works — better-auth's `user.changeEmail` is configured, with verification (#7735) + +`auth.changeEmail()` answered **400 `CHANGE_EMAIL_DISABLED`** on every +deployment. better-auth ships the capability off and `plugin-auth` never +configured it, so there was no product switch to flip — while +`auth-route-ledger.ts` booked the route as a live SDK surface. The route table +was right about the product and wrong about the runtime. + +`user.changeEmail.enabled` is now set, and the change is **confirmed by email** +before it applies: + +1. `POST /api/v1/auth/change-email { newEmail }` mints a verification token and + sends it to the **new** address, through the same + `emailVerification.sendVerificationEmail` callback (and `auth.verify_email` + template) that sign-up verification uses. Nothing is written yet — an + unconfirmed request leaves the identity untouched. +2. `GET /api/v1/auth/verify-email?token=…` applies it: the address changes, + `email_verified` becomes true, and the session cookie is re-issued on the new + identity. + +Two better-auth options are deliberately left at their defaults, because each is +a policy in its own right: `updateEmailWithoutVerification` (would let a user +whose current address is unverified swap emails with no confirmation at all) and +`sendChangeEmailConfirmation` (better-auth's opt-in extra step asking the OLD +address to approve first). + +**A deployment with no email transport** now answers 400 *"Verification email +isn't enabled"* instead of `CHANGE_EMAIL_DISABLED` — a fixable configuration +statement rather than "the platform does not offer this". Wire an email service +(`setEmailService`, or register the kernel `email` service) to enable the flow. + +**Self-service account deletion stays off, and now says so.** +`POST /auth/delete-user` is published by better-auth's catch-all but +`user.deleteUser` is deliberately not configured, so it answers 404 (as does its +`GET /auth/delete-user/callback` half). Its route-ledger row is re-booked from +`sdk` to the new `disabled` disposition carrying that reason, so the ledger no +longer advertises a dead route. `client.auth.deleteUser()` is unchanged and +still reaches the endpoint — it is refused there, as it was before this release. +Self-service deletion in a B2B tenancy touches record ownership and tenant data, +and needs a deliberate design; nothing about its behaviour changes here. diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index c04b6a6219..d93a9512b6 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -922,6 +922,59 @@ export class AuthManager { // the objectql `SysUser` object def (provisioned by boot schema-sync) // and read by a GUARDED system query in resolveCtx (can only no-op, // never break auth). better-auth stays oblivious to the extra column. + + // ── #7735 — self-service email change, ON with verification ──────── + // better-auth ships `changeEmail` OFF, so `POST /change-email` answered + // 400 CHANGE_EMAIL_DISABLED on every deployment while + // `auth-route-ledger.ts` booked the route as a live SDK surface. The + // ledger was right about the product and wrong about the runtime; + // maintainer ruling 2026-08-12 settled it by making the runtime true: + // 「`user.changeEmail` 在 plugin-auth 配置开启,带验证流程(变更需确认, + // 策略按 better-auth 常规)」. + // + // THE FLOW, as better-auth 1.7 actually implements it (read off + // `better-auth/dist/api/routes/update-user.mjs`, not off the docs): + // 1. `POST /change-email {newEmail}` mints a JWT carrying + // `{ email: current, updateTo: newEmail, requestType: + // 'change-email-verification' }` and hands it to + // `emailVerification.sendVerificationEmail` — the same callback + // (and `auth.verify_email` template) sign-up verification uses, + // addressed to the NEW address. Nothing is written yet. + // 2. `GET /verify-email?token=…` applies it: `updateUserByEmail(old, + // { email: newEmail, emailVerified: true })`, then re-issues the + // session cookie on the new identity. + // So the change is confirmed by proving control of the new mailbox — + // 「变更需确认」 — and an unclicked request changes nothing. + // + // TWO OPTIONS DELIBERATELY LEFT AT THEIR DEFAULTS, because each is a + // policy this ruling did not decide: + // • `updateEmailWithoutVerification` — would let a user whose CURRENT + // address is unverified swap emails with no confirmation at all. + // That is the one thing the ruling names; leaving it false keeps + // every path confirmed. + // • `sendChangeEmailConfirmation` — better-auth's opt-in EXTRA step + // that asks the OLD address to approve first (old → new, two + // clicks). Stronger against a hijacked session, and it needs a + // decision about which address is authoritative plus its own + // template; 「策略按 better-auth 常规」 is the single-step default, + // so the two-step variant stays a deliberate future design. + // + // No email transport wired ⇒ the `emailVerification` block below is + // absent ⇒ better-auth answers 400 "Verification email isn't enabled". + // That is the honest answer for a deployment with no mailbox, and a + // different sentence from "the platform does not offer this". + // + // The write itself already worked: `sys_user.email` is schema-`readonly` + // and the readonly-strip drops non-system updates, but the adapter runs + // better-auth's own writes as system (#3164, `withSystemContext` in + // objectql-adapter.ts), so the applied change persists. + // + // ⛔ `deleteUser` is NOT configured here, by the same ruling — see the + // `disabled` row for `POST /api/v1/auth/delete-user` in + // `auth-route-ledger.ts` for why, and for what has to be decided first. + changeEmail: { + enabled: true, + }, }, account: { ...AUTH_ACCOUNT_CONFIG, diff --git a/packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts b/packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts index 613632a648..de51c70b43 100644 --- a/packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts +++ b/packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts @@ -57,6 +57,17 @@ const ENV_KEYS = [ const savedEnv: Record = {}; let live: Set; +/** + * The options object better-auth was actually constructed with — the same one + * its handlers read as `ctx.context.options`, so a capability switch read here + * is the switch the runtime enforces, not a restatement of the ledger. + */ +let liveOptions: { + user?: { + changeEmail?: { enabled?: boolean }; + deleteUser?: { enabled?: boolean }; + }; +}; beforeAll(async () => { for (const k of ENV_KEYS) { savedEnv[k] = process.env[k]; delete process.env[k]; } @@ -71,7 +82,9 @@ beforeAll(async () => { // handling would — so the table enumerates identically. const auth = (await manager.getAuthInstance()) as unknown as { api: Record; + options: typeof liveOptions; }; + liveOptions = auth.options; live = new Set(); for (const endpoint of Object.values(auth.api ?? {})) { @@ -177,3 +190,73 @@ describe('auth route ledger hygiene', () => { expect(AUTH_ROUTE_LEDGER.filter((e) => e.disposition === 'mismatch').length).toBeLessThanOrEqual(0); }); }); + +// ─────────────────────────────────────────────────────────────────────────── +/** + * #7735 — the ledger's disposition for a capability-gated route must agree with + * the SWITCH THE RUNTIME READS. + * + * This is the check whose absence was the whole defect. `change-email` and + * `delete-user` are published by the catch-all unconditionally, so every guard + * that asks "does better-auth serve this path" was green while one route + * answered 400 `CHANGE_EMAIL_DISABLED` and the other 404 — mounted, ledgered as + * live SDK surface, and dead. Path existence cannot see a feature switch. + * + * Both sides here are read independently: the left from `auth.options`, the + * object better-auth's own handlers consult (`ctx.context.options.user + * ?.changeEmail?.enabled`), the right from the ledger row. Deleting the config + * in auth-manager.ts turns this red without touching the ledger, and re-booking + * a `disabled` row as `sdk` turns it red without touching the config — a pin + * that derived both sides from the ledger could do neither. + */ +describe('#7735 — capability switches ↔ ledger disposition', () => { + /** The routes whose liveness is a better-auth `user.*` feature switch. */ + const CAPABILITY_GATED = [ + { + route: 'POST /api/v1/auth/change-email', + switchName: 'user.changeEmail.enabled', + isOn: () => liveOptions?.user?.changeEmail?.enabled === true, + }, + { + route: 'POST /api/v1/auth/delete-user', + switchName: 'user.deleteUser.enabled', + isOn: () => liveOptions?.user?.deleteUser?.enabled === true, + }, + ] as const; + + it.each(CAPABILITY_GATED)( + 'the row for $route matches the live $switchName', + ({ route, switchName, isOn }) => { + const row = AUTH_ROUTE_LEDGER.find((e) => e.route === route); + expect(row, `${route} is missing from AUTH_ROUTE_LEDGER`).toBeDefined(); + + const on = isOn(); + expect( + row!.disposition, + on + ? `${switchName} is ON, so ${route} really is a live SDK surface and the ledger must book it as \`sdk\`.` + : `${switchName} is OFF, so ${route} is refused at runtime. Booking it as \`sdk\` is the #7735 defect: ` + + 'either wire the capability, or leave the row `disabled` with the reason.', + ).toBe(on ? 'sdk' : 'disabled'); + }, + ); + + it('every `disabled` row names its refused capability, and the set is the reviewed one', () => { + const disabled = AUTH_ROUTE_LEDGER.filter((e) => e.disposition === 'disabled'); + + // Pinned, not counted: a second withheld capability is a product decision, + // so it must arrive as a diff someone reads — the same reason + // BETTER_AUTH_MOUNTED_SURFACE is checked for equality rather than growth. + expect( + disabled.map((e) => e.route).sort(), + 'the `disabled` set changed. A route may only sit here with a ruling behind it (#7735); ' + + 'wiring one up means moving it back to `sdk` in the same PR as the config.', + ).toEqual(['POST /api/v1/auth/delete-user']); + + for (const row of disabled) { + // `note` is what makes the state honest rather than merely quiet — the + // hygiene test above requires one; this requires it to say something. + expect(row.note ?? '', `${row.route} must say WHICH switch is off`).toMatch(/deleteUser|changeEmail/); + } + }); +}); diff --git a/packages/plugins/plugin-auth/src/auth-route-ledger.ts b/packages/plugins/plugin-auth/src/auth-route-ledger.ts index 67ef960294..ac21f4737e 100644 --- a/packages/plugins/plugin-auth/src/auth-route-ledger.ts +++ b/packages/plugins/plugin-auth/src/auth-route-ledger.ts @@ -61,7 +61,30 @@ export type AuthRouteDisposition = /** Public, unauthenticated browser-facing route. */ | 'public' /** Server and client disagree on the shape — needs reconciliation. */ - | 'mismatch'; + | 'mismatch' + /** + * PUBLISHED BY THE CATCH-ALL, REFUSED AT RUNTIME (#7735). The path resolves — + * better-auth registers the endpoint unconditionally, so it is in + * `BETTER_AUTH_MOUNTED_SURFACE` and it is NOT a 404-by-absence — but the + * capability behind it is deliberately not configured, so every call is + * refused. `note` MUST say which switch is off and what has to be decided + * before it goes on. + * + * This is `gap`'s mirror image, and the pair is why a separate word was worth + * adding rather than reusing one: `gap` means the server has the capability + * and the SDK does not express it; `disabled` means the SDK expresses it and + * the server refuses. Both are "not a live product surface", and neither is a + * `mismatch` (that word is for a shape disagreement, and its count is + * ratcheted to zero). + * + * ⛔ A `disabled` row is a LEDGER STATE, never a parking space. It exists so + * the ledger can say "we know, and here is the decision it is waiting on" + * instead of booking a dead route as `sdk`; the conformance suite pins the + * set of them against the live better-auth options, so a row cannot sit here + * while the capability is quietly switched on, nor be re-booked as `sdk` + * while it is off. + */ + | 'disabled'; export interface AuthRouteLedgerEntry { /** `VERB /api/v1/auth/...` — full wire path at the default base. */ @@ -75,7 +98,12 @@ export interface AuthRouteLedgerEntry { */ source: 'better-auth' | 'objectstack'; disposition: AuthRouteDisposition; - /** Dotted method path on `ObjectStackClient` — required when disposition is `sdk`. */ + /** + * Dotted method path on `ObjectStackClient` — required when disposition is + * `sdk`, and deliberately KEPT on a `disabled` row (#7735): the SDK method + * still exists and still builds this URL, so erasing the name would hide + * exactly the fact the row is there to record. + */ client?: string; /** Optional better-auth plugin this route needs (absent = always mounted). */ requires?: string; @@ -115,9 +143,18 @@ export interface AuthRouteLedgerEntry { } export const AUTH_ROUTE_LEDGER: readonly AuthRouteLedgerEntry[] = [ - { route: 'POST /api/v1/auth/change-email', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.changeEmail' }, + { route: 'POST /api/v1/auth/change-email', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.changeEmail', note: 'live since #7735: auth-manager.ts sets user.changeEmail.enabled, and the confirmation link rides emailVerification.sendVerificationEmail to the NEW address' }, { route: 'POST /api/v1/auth/change-password', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.changePassword' }, - { route: 'POST /api/v1/auth/delete-user', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.deleteUser' }, + // #7735 — self-service account deletion is NOT wired, and this row says so + // rather than booking it as a live SDK surface. Maintainer ruling + // 2026-08-12, verbatim: 「`delete-user` 从 ledger 摘掉 mounted 记账(记为 + // disabled/未接线,诚实状态)。⛔ 不配置 `user.deleteUser`:B2B 多租户下自助删号 + // 牵连记录归属与租户数据 …… 自助删号需要一次 deliberate 设计,不由一张 QA 卡带 + // 出来」. The ruling's other reason — that the admin-side `/admin/remove-user` + // was itself broken — has since expired (#7724 landed), and the conclusion + // does not move with it: the design question is the standing one, and a + // future design starts from #7724's deletion semantics. + { route: 'POST /api/v1/auth/delete-user', family: 'core-auth', source: 'better-auth', disposition: 'disabled', client: 'auth.deleteUser', note: 'better-auth publishes the endpoint but user.deleteUser is deliberately unconfigured, so it answers 404 (as does its GET /delete-user/callback half); self-service deletion needs a deliberate B2B design first — maintainer ruling 2026-08-12 on #7735' }, { route: 'GET /api/v1/auth/get-session', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.me', note: 'auth.me and auth.refreshToken both target it' }, { route: 'POST /api/v1/auth/link-social', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.accounts.linkSocial' }, { route: 'GET /api/v1/auth/list-accounts', family: 'core-auth', source: 'better-auth', disposition: 'sdk', client: 'auth.accounts.list' }, @@ -179,6 +216,22 @@ export const AUTH_ROUTE_LEDGER: readonly AuthRouteLedgerEntry[] = [ * entry here is a new publicly-mounted auth endpoint, which is a security * surface change even when it is an intended one. * + * ⚠️ PUBLICATION, NOT LIVENESS (#7735). This list answers "what does the + * catch-all expose", and nothing else — better-auth registers several endpoints + * unconditionally and then refuses them at runtime when the feature switch + * behind them is off, so an entry here is NOT evidence that a call succeeds. + * Today that gap is `POST /api/v1/auth/delete-user` and + * `GET /api/v1/auth/delete-user/callback`: both are published, both answer 404, + * because `user.deleteUser` is deliberately unconfigured. The claim about + * whether a route WORKS lives one list up, in `AUTH_ROUTE_LEDGER`, where that + * pair carries the `disabled` disposition and its reason. + * + * ⛔ So do not "reconcile" a disabled route by deleting it from here. This list + * is checked for EXACT equality against the live `auth.api` enumeration in both + * directions; removing a published entry makes the conformance test red and + * would misreport the mounted attack surface, which is the one thing this list + * exists to keep honest. + * * The two `/.well-known/*` entries are not under the base path: `auth-plugin.ts` * mounts those discovery documents at the app root (RFC 8414 / OIDC require it). */ diff --git a/packages/plugins/plugin-auth/src/change-email-delete-user-wiring.test.ts b/packages/plugins/plugin-auth/src/change-email-delete-user-wiring.test.ts new file mode 100644 index 0000000000..ef691eb351 --- /dev/null +++ b/packages/plugins/plugin-auth/src/change-email-delete-user-wiring.test.ts @@ -0,0 +1,344 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #7735 — the two ledgered user-lifecycle routes, driven on the wire. + * + * `auth-route-ledger.ts` booked `POST /change-email` and `POST /delete-user` as + * live SDK surface. Both were dead: better-auth ships `user.changeEmail` and + * `user.deleteUser` OFF, and `plugin-auth` configured neither, so the first + * answered 400 `CHANGE_EMAIL_DISABLED` and the second 404 — on every deployment, + * with no switch to flip. Every existing guard stayed green throughout, because + * better-auth REGISTERS both endpoints unconditionally: the paths resolve, the + * enumeration finds them, and only the request itself can tell you the + * capability behind them is off. + * + * The maintainer ruling of 2026-08-12 resolves the two rows in OPPOSITE + * directions, and that is the point — the ledger's job is to state what is + * mounted, so one row becomes true by wiring the capability and the other by + * withdrawing the claim: + * + * - `user.changeEmail` is configured ON, with better-auth's conventional + * verification flow (「变更需确认,策略按 better-auth 常规」); + * - `user.deleteUser` stays OFF and the row is de-booked to `disabled` — + * self-service account deletion in a B2B tenancy needs a deliberate design, + * not a QA card. + * + * So this file asserts BEHAVIOUR, at the same seam a caller uses: real + * `AuthManager.handleRequest` over a real better-auth pipeline, against a real + * session minted by a real sign-up. `auth-route-ledger.conformance.test.ts` + * holds the other half — that the ledger's disposition agrees with the switch + * the runtime reads — and the two together are what make the ledger checkable + * rather than merely reviewed. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/objectql'; +import type { IEmailService, SendEmailResult, SendTemplateInput } from '@objectstack/spec/contracts'; +import { AuthManager } from './auth-manager'; + +// ─────────────────────────────────────────────────────────────────────────── +// Harness +// ─────────────────────────────────────────────────────────────────────────── + +interface MemoryRow { id: string; [column: string]: unknown } +/** + * The index signature is load-bearing, not decoration: it is what makes this + * type assignable to `EngineDeleteDispatchInput` / `EngineUpdateDispatchInput`, + * so the dispatch predicates below are called with a REAL type rather than + * through an `as any` the query-options rule (#4918) exists to refuse. + */ +interface MemoryQuery { + where?: Record; + fields?: string[]; + limit?: number; + offset?: number; + multi?: boolean; + [option: string]: unknown; +} + +/** + * In-memory `IDataEngine`, the same shape the #4785 session-of-record harness + * uses — with both destructive verbs pinned to ObjectQL's OWN dispatch + * predicates (`assertEngineUpdateDispatch` / `assertEngineDeleteDispatch`) + * rather than a hand-written approximation, so this double cannot accept a call + * the real engine refuses (`pnpm check:engine-double-contract`, #4550/#5480). + */ +function createMemoryEngine() { + const tables = new Map(); + const rows = (name: string): MemoryRow[] => { + if (!tables.has(name)) tables.set(name, []); + return tables.get(name)!; + }; + const eq = (a: unknown, b: unknown): boolean => + a instanceof Date || b instanceof Date + ? new Date(a as string).getTime() === new Date(b as string).getTime() + : a === b; + const matches = (row: MemoryRow, where: Record = {}): boolean => + Object.entries(where).every(([key, expected]) => { + const actual = row[key]; + if (expected && typeof expected === 'object' && !Array.isArray(expected) && !(expected instanceof Date)) { + const operators = expected as Record; + if ('$ne' in operators) return !eq(actual, operators.$ne); + if ('$in' in operators) return (operators.$in as unknown[]).some((v) => eq(actual, v)); + } + return eq(actual, expected); + }); + /** `fields` really projects — `id` always survives, as it does in ObjectQL. */ + const project = (row: MemoryRow, fields?: string[]): MemoryRow => { + if (!Array.isArray(fields) || fields.length === 0) return { ...row }; + const out = { id: row.id } as MemoryRow; + for (const field of fields) if (field in row) out[field] = row[field]; + return out; + }; + let seq = 0; + return { + tables, + async insert(name: string, data: Record): Promise { + const row = { ...data, id: (data.id as string) ?? `row_${++seq}` } as MemoryRow; + rows(name).push(row); + return { ...row }; + }, + async findOne(name: string, query: MemoryQuery = {}): Promise { + const row = rows(name).find((r) => matches(r, query.where)); + return row ? project(row, query.fields) : null; + }, + async find(name: string, query: MemoryQuery = {}): Promise { + let out = rows(name).filter((r) => matches(r, query.where)); + if (query.offset) out = out.slice(query.offset); + if (query.limit) out = out.slice(0, query.limit); + return out.map((r) => project(r, query.fields)); + }, + async count(name: string, query: MemoryQuery = {}): Promise { + return rows(name).filter((r) => matches(r, query.where)).length; + }, + async update(name: string, data: Record, options?: MemoryQuery): Promise { + assertEngineUpdateDispatch(data, options); + const row = rows(name).find((r) => r.id === data.id); + if (!row) return null; + Object.assign(row, data); + return { ...row }; + }, + async delete(name: string, options: MemoryQuery = {}): Promise { + assertEngineDeleteDispatch(options); + const table = rows(name); + const keep = table.filter((r) => !matches(r, options.where)); + tables.set(name, keep); + return table.length - keep.length; + }, + }; +} + +type MemoryEngine = ReturnType; + +/** Recording email transport — every `sendTemplate` call, in order. */ +function createRecordingEmailService() { + const sent: SendTemplateInput[] = []; + const service: IEmailService = { + async send(): Promise { + return { id: 'email_send', status: 'sent' }; + }, + async sendTemplate(input: SendTemplateInput): Promise { + sent.push(input); + return { id: `email_${sent.length}`, status: 'sent' }; + }, + }; + return { service, sent }; +} + +const SECRET = 'test-secret-at-least-32-chars-long!!'; +const PASSWORD = 'S3cure!Passw0rd-7735'; +const ORIGIN = 'http://localhost:3000'; +const AUTH = `${ORIGIN}/api/v1/auth`; + +function makeManager(engine: MemoryEngine, emailService?: IEmailService): AuthManager { + return new AuthManager({ + secret: SECRET, + baseUrl: ORIGIN, + dataEngine: engine, + ...(emailService ? { emailService } : {}), + } as never); +} + +const signUp = (manager: AuthManager, email: string) => + manager.handleRequest( + new Request(`${AUTH}/sign-up/email`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email, password: PASSWORD, name: 'Change Email Subject' }), + }), + ); + +const cookieFrom = (response: Response): string => + (response.headers.getSetCookie?.() ?? [response.headers.get('set-cookie') ?? '']) + .map((c) => c.split(';')[0]) + .filter(Boolean) + .join('; '); + +const post = (manager: AuthManager, path: string, cookie: string, body: unknown) => + manager.handleRequest( + new Request(`${AUTH}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json', cookie, origin: ORIGIN }, + body: JSON.stringify(body), + }), + ); + +const userRows = (engine: MemoryEngine): MemoryRow[] => engine.tables.get('sys_user') ?? []; + +/** The body of a better-auth error response, tolerant of a non-JSON body. */ +const errorBody = async (response: Response): Promise> => { + const text = await response.text(); + try { + return (text ? JSON.parse(text) : {}) as Record; + } catch { + return { raw: text }; + } +}; + +beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'info').mockImplementation(() => {}); +}); +afterEach(() => vi.restoreAllMocks()); + +// ─────────────────────────────────────────────────────────────────────────── +describe('#7735 — POST /change-email is wired, and confirmed by email', () => { + it('accepts the change, mails the NEW address, and changes nothing until the link is followed', async () => { + const engine = createMemoryEngine(); + const email = createRecordingEmailService(); + const manager = makeManager(engine, email.service); + + const cookie = cookieFrom(await signUp(manager, 'before@example.com')); + expect(cookie, 'sign-up must mint a session cookie').not.toBe(''); + email.sent.length = 0; // drop anything sign-up itself sent + + const response = await post(manager, '/change-email', cookie, { + newEmail: 'after@example.com', + callbackURL: '/', + }); + + // The defect was a 400 with `code: 'CHANGE_EMAIL_DISABLED'` here. Assert the + // success envelope rather than `status !== 400`, so a DIFFERENT 400 (a + // missing email transport, say) cannot read as the capability being on. + expect(response.status, await response.clone().text()).toBe(200); + expect(await response.json()).toEqual({ status: true }); + + // Confirmation goes to the NEW address, through the same verification + // callback sign-up uses — better-auth's single-step default, which is the + // 「策略按 better-auth 常规」 the ruling names. + expect(email.sent).toHaveLength(1); + const [confirmation] = email.sent; + expect(confirmation.template).toBe('auth.verify_email'); + expect(confirmation.to).toMatchObject({ address: 'after@example.com' }); + + // …and NOTHING has changed yet. A request nobody confirms must not move the + // identity: this assertion is what separates "verified change" from + // "change, then send a notice about it". + expect(userRows(engine).map((r) => r.email)).toEqual(['before@example.com']); + }); + + it('applies the change when the emailed link is followed, and marks the new address verified', async () => { + const engine = createMemoryEngine(); + const email = createRecordingEmailService(); + const manager = makeManager(engine, email.service); + + const cookie = cookieFrom(await signUp(manager, 'old@example.com')); + email.sent.length = 0; + // The address is unverified at this point, so the transition below is a + // real one. (`email_verified` is 0/1 rather than false/true because the + // ObjectQL adapter declares `supportsBooleans: false` — better-auth encodes + // before the engine sees it, so assert the value's TRUTH, not its spelling.) + expect(userRows(engine)[0]!.email_verified).toBeFalsy(); + + await post(manager, '/change-email', cookie, { newEmail: 'new@example.com' }); + + const lastSent = email.sent[email.sent.length - 1]; + const verificationUrl = (lastSent?.data as { verificationUrl?: string } | undefined)?.verificationUrl; + expect(typeof verificationUrl, 'the change-email mail must carry a verification link').toBe('string'); + + const applied = await manager.handleRequest(new Request(verificationUrl!, { headers: { cookie } })); + expect([200, 302]).toContain(applied.status); + + const user = userRows(engine)[0]!; + expect(user.email).toBe('new@example.com'); + expect(user.email_verified).toBeTruthy(); + }); + + it('without an email transport it refuses for the HONEST reason, not CHANGE_EMAIL_DISABLED', async () => { + // A deployment with no mailbox cannot run a verified change — better-auth + // says so in as many words. The distinction matters: "this deployment has + // no email transport" is a fixable configuration statement, where + // CHANGE_EMAIL_DISABLED said the platform does not offer the capability at + // all, which is the sentence #7735 was filed about. + const engine = createMemoryEngine(); + const manager = makeManager(engine); + + const cookie = cookieFrom(await signUp(manager, 'nomail@example.com')); + const response = await post(manager, '/change-email', cookie, { newEmail: 'elsewhere@example.com' }); + + expect(response.status).toBe(400); + const body = await errorBody(response); + expect(JSON.stringify(body)).not.toContain('CHANGE_EMAIL_DISABLED'); + expect(String(body.message ?? '')).toMatch(/verification email isn't enabled/i); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('#7735 — POST /delete-user stays unwired, and the ledger says so', () => { + it('refuses an authenticated self-delete with 404 NOT_FOUND, deleting nothing', async () => { + const engine = createMemoryEngine(); + const manager = makeManager(engine, createRecordingEmailService().service); + + // TWO accounts, and the SECOND one asks to be deleted. With only one, the + // platform's break-glass guard (auth-manager.ts `before` hook: never remove + // the last local-password login) refuses first with 409 CONFLICT, and the + // request never reaches better-auth's disabled check — a green 409 would + // say nothing about whether `user.deleteUser` is wired. Measured on this + // very test: it read 409 until the second account existed. + await signUp(manager, 'keeper@example.com'); + const cookie = cookieFrom(await signUp(manager, 'leaver@example.com')); + expect(userRows(engine)).toHaveLength(2); + + // Rejection-class, and the discriminator has to be built rather than + // asserted: better-auth's DISABLED branch here is + // `APIError.fromStatus('NOT_FOUND')`, which carries **no body at all** — no + // `code`, no message — so a lone `expect(404)` could equally be a route + // that does not exist. The anonymous call is what separates them: the path + // IS mounted and IS authenticated, so 401-without-a-session next to + // 404-with-one can only be the capability switch. + const anonymous = await manager.handleRequest( + new Request(`${AUTH}/delete-user`, { + method: 'POST', + headers: { 'content-type': 'application/json', origin: ORIGIN }, + body: JSON.stringify({}), + }), + ); + expect(anonymous.status, 'the route is mounted and session-guarded').toBe(401); + + const response = await post(manager, '/delete-user', cookie, { password: PASSWORD }); + expect(response.status).toBe(404); + // Pinned as it really is, not as the envelope convention would like it: + // an upstream version that starts sending a code here should surface as a + // diff someone reads. Its `/delete-user/callback` half DOES carry + // `code: NOT_FOUND` — asserted in the next test. + expect(await errorBody(response)).toEqual({}); + + // The half that matters: the account is still there. + expect(userRows(engine).map((r) => r.email)).toEqual(['keeper@example.com', 'leaver@example.com']); + }); + + it('refuses the /delete-user/callback half too — a token cannot route around the switch', async () => { + const engine = createMemoryEngine(); + const manager = makeManager(engine, createRecordingEmailService().service); + + const cookie = cookieFrom(await signUp(manager, 'callback@example.com')); + const response = await manager.handleRequest( + new Request(`${AUTH}/delete-user/callback?token=whatever`, { headers: { cookie, origin: ORIGIN } }), + ); + + expect(response.status).toBe(404); + expect(JSON.stringify(await errorBody(response))).toContain('NOT_FOUND'); + expect(userRows(engine).map((r) => r.email)).toEqual(['callback@example.com']); + }); +});