Ctrl K
ComunidadGuías…

lib/auth/__tests__/better-auth-provider.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { headers } from 'next/headers'
import type { NextRequest } from 'next/server'

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { getAuth, reElectBootstrapAdmin } from '@/lib/auth/better-auth/config'
import {
  consumeInvitation,
  createInvitation,
  hasRecentBootstrapInvitation,
  releaseInvitation,
  revokeInvitation,
  validateInvitation
} from '@/lib/auth/better-auth/invitations'
import { sendSmtpMail } from '@/lib/auth/better-auth/mailer'
import { betterAuthProvider } from '@/lib/auth/providers/better-auth'
import { resetRateLimits } from '@/lib/auth/rate-limit'

const { mockAuth, mockSetCookie } = vi.hoisted(() => ({
  mockAuth: {
    api: {
      getSession: vi.fn(),
      signInEmail: vi.fn(),
      signUpEmail: vi.fn(),
      signOut: vi.fn(),
      requestPasswordReset: vi.fn(),
      resetPassword: vi.fn()
    }
  },
  mockSetCookie: vi.fn()
}))

vi.mock('@/lib/auth/better-auth/config', async importOriginal => {
  const actual =
    await importOriginal<typeof import('@/lib/auth/better-auth/config')>()
  return {
    ...actual,
    getAuth: () => mockAuth,
    reElectBootstrapAdmin: vi.fn()
  }
})

vi.mock('@/lib/auth/better-auth/invitations', () => ({
  validateInvitation: vi.fn(),
  consumeInvitation: vi.fn(),
  createInvitation: vi.fn(),
  hasRecentBootstrapInvitation: vi.fn(async () => false),
  releaseInvitation: vi.fn(async () => {}),
  revokeInvitation: vi.fn(async () => true)
}))

vi.mock('@/lib/auth/better-auth/mailer', async importOriginal => ({
  ...(await importOriginal<typeof import('@/lib/auth/better-auth/mailer')>()),
  sendSmtpMail: vi.fn()
}))

const { state } = vi.hoisted(() => ({
  state: {
    accounts: [] as unknown[],
    adminCount: 1,
    userCount: 2,
    targetLocked: false
  }
}))

vi.mock('@/lib/db', () => {
  const db: Record<string, unknown> = {
    delete: vi.fn(() => ({
      where: vi.fn(async () => undefined)
    })),
    select: vi.fn(() => ({
      // Awaitable (user-count and admin-count queries) and chainable
      // (.where().limit() for the account-exists and role lookups,
      // .where().for() for the locked admin listing) so all query
      // shapes resolve.
      from: vi.fn(() =>
        Object.assign(Promise.resolve([{ total: 0, n: state.userCount }]), {
          // Awaited directly it is the admin listing (one row per admin);
          // chained it serves the role lookup (.limit) and the locked
          // admin listing (.for) inside deletionGuard.
          where: vi.fn(() =>
            Object.assign(
              Promise.resolve(
                Array.from({ length: state.adminCount }, (_, i) => ({
                  id: `admin-${i}`,
                  n: state.adminCount
                }))
              ),
              {
                limit: vi.fn(() =>
                  Object.assign(Promise.resolve(state.accounts), {
                    // The role lookup goes through FOR UPDATE when the
                    // guard runs inside a transaction.
                    for: vi.fn(async () => {
                      state.targetLocked = true
                      return state.accounts
                    })
                  })
                ),
                for: vi.fn(async () =>
                  Array.from({ length: state.adminCount }, (_, i) => ({
                    id: `admin-${i}`
                  }))
                )
              }
            )
          )
        })
      )
    }))
  }
  // Transactions receive the same mock object, so assertions on db.delete
  // also cover deletes issued through tx.
  db.transaction = vi.fn((cb: (tx: unknown) => unknown) => cb(db))
  return { db }
})

vi.mock('next/headers', () => ({
  headers: vi.fn(async () => new Headers({ origin: 'http://localhost:3000' })),
  cookies: vi.fn(async () => ({ set: mockSetCookie }))
}))

function makeRequest(pathname: string): NextRequest {
  const href = `http://localhost:3000${pathname}`
  const url = new URL(href)
  return {
    nextUrl: {
      href,
      pathname: url.pathname,
      search: url.search,
      clone: () => new URL(href)
    },
    headers: new Headers()
  } as unknown as NextRequest
}

const sessionUser = {
  id: 'user-1',
  email: 'admin@example.com',
  name: 'Admin',
  image: null,
  createdAt: new Date('2026-01-01'),
  role: 'admin'
}

describe('better-auth provider', () => {
  const originalEnv: Record<string, string | undefined> = {}

  beforeEach(() => {
    resetRateLimits()
    state.accounts = []
    state.adminCount = 1
    state.userCount = 2
    state.targetLocked = false
    for (const key of [
      'SMTP_HOST',
      'SMTP_USER',
      'SMTP_PASSWORD',
      'AUTH_SIGNUP_MODE',
      'BOOTSTRAP_ADMIN_EMAIL',
      'BETTER_AUTH_URL',
      'OIDC_ISSUER',
      'OIDC_CLIENT_ID',
      'OIDC_CLIENT_SECRET',
      'OIDC_PROVIDER_NAME'
    ]) {
      originalEnv[key] = process.env[key]
      delete process.env[key]
    }
    vi.clearAllMocks()
  })

  afterEach(() => {
    for (const [key, value] of Object.entries(originalEnv)) {
      if (value === undefined) {
        delete process.env[key]
      } else {
        process.env[key] = value
      }
    }
  })

  describe('capabilities', () => {
    it('gates passwordReset on SMTP configuration', () => {
      expect(betterAuthProvider.capabilities.passwordReset).toBe(false)

      process.env.SMTP_HOST = 'smtp.example.com'
      process.env.SMTP_USER = 'user'
      process.env.SMTP_PASSWORD = 'secret'

      // Emailed links need a canonical origin, never request headers
      expect(betterAuthProvider.capabilities.passwordReset).toBe(false)
      process.env.BETTER_AUTH_URL = 'http://localhost:3000'

      expect(betterAuthProvider.capabilities.passwordReset).toBe(true)
      expect(betterAuthProvider.capabilities.signUp).toBe(true)
      expect(betterAuthProvider.capabilities.deleteUser).toBe(true)
      expect(betterAuthProvider.capabilities.share).toBe(true)
    })

    it('hides the OAuth button until BETTER_AUTH_URL provides the callback origin', () => {
      process.env.OIDC_ISSUER = 'https://idp.example.com'
      process.env.OIDC_CLIENT_ID = 'morphic-client'
      process.env.OIDC_CLIENT_SECRET = 'secret'

      // The IdP redirect URI and the post-sign-in redirect derive from the
      // canonical origin: with OIDC credentials alone, rendering the button
      // would only ever produce "BETTER_AUTH_URL must be set" on click.
      expect(betterAuthProvider.capabilities.oauth).toBe(false)
      expect(betterAuthProvider.capabilities.oauthProvider).toBeNull()

      process.env.BETTER_AUTH_URL = 'http://localhost:3000'
      expect(betterAuthProvider.capabilities.oauth).toBe(true)
      expect(betterAuthProvider.capabilities.oauthProvider).toBe('OIDC')
    })
  })

  describe('getCurrentUser', () => {
    it('resolves the session user', async () => {
      vi.mocked(mockAuth.api.getSession).mockResolvedValue({
        user: sessionUser
      } as never)

      const user = await betterAuthProvider.getCurrentUser()

      expect(user).toMatchObject({
        id: 'user-1',
        email: 'admin@example.com',
        name: 'Admin',
        role: 'admin'
      })
    })

    it('returns null without a session', async () => {
      vi.mocked(mockAuth.api.getSession).mockResolvedValue(null)

      await expect(betterAuthProvider.getCurrentUser()).resolves.toBeNull()
    })
  })

  describe('handleSession', () => {
    it('redirects unauthenticated requests to protected paths', async () => {
      vi.mocked(mockAuth.api.getSession).mockResolvedValue({
        response: null,
        headers: new Headers()
      } as never)

      const response = await betterAuthProvider.handleSession!(
        makeRequest('/some-protected-page')
      )

      expect(response.status).toBe(307)
      expect(response.headers.get('location')).toBe(
        'http://localhost:3000/auth/login?next=%2Fsome-protected-page'
      )
    })

    it('carries the query string through the login redirect', async () => {
      vi.mocked(mockAuth.api.getSession).mockResolvedValue({
        response: null,
        headers: new Headers()
      } as never)

      const response = await betterAuthProvider.handleSession!(
        makeRequest('/some-protected-page?q=hello%20world')
      )

      expect(response.status).toBe(307)
      expect(response.headers.get('location')).toBe(
        'http://localhost:3000/auth/login?next=%2Fsome-protected-page%3Fq%3Dhello%2520world'
      )
    })

    it('lets unauthenticated requests through on public paths', async () => {
      vi.mocked(mockAuth.api.getSession).mockResolvedValue({
        response: null,
        headers: new Headers()
      } as never)

      const response = await betterAuthProvider.handleSession!(
        makeRequest('/auth/login')
      )

      expect(response.status).toBe(200)
    })

    it('lets unauthenticated analytics through the posthog relay', async () => {
      // Guest pageviews from the login and sign-up pages must reach PostHog
      // instead of bouncing to /auth/login.
      vi.mocked(mockAuth.api.getSession).mockResolvedValue({
        response: null,
        headers: new Headers()
      } as never)

      const response = await betterAuthProvider.handleSession!(
        makeRequest('/relay/e/?ip=1')
      )

      expect(response.status).toBe(200)
    })

    it('lets logged-out users open shared chat links', async () => {
      // /search/<id> is the share URL: app/search/[id]/page.tsx enforces
      // visibility (public chats viewable, private rejected), so the proxy
      // must not pre-empt that check with a login redirect.
      vi.mocked(mockAuth.api.getSession).mockResolvedValue({
        response: null,
        headers: new Headers()
      } as never)

      const response = await betterAuthProvider.handleSession!(
        makeRequest('/search/abc123')
      )

      expect(response.status).toBe(200)
    })

    it('lets authenticated requests through', async () => {
      vi.mocked(mockAuth.api.getSession).mockResolvedValue({
        response: { user: sessionUser },
        headers: new Headers()
      } as never)

      const response = await betterAuthProvider.handleSession!(
        makeRequest('/some-protected-page')
      )

      expect(response.status).toBe(200)
    })

    it('forwards refreshed session cookies to the response', async () => {
      // better-auth renews the cookie past half the session lifetime; the
      // middleware must pass it on or active users expire at the old time.
      const responseHeaders = new Headers()
      responseHeaders.append(
        'set-cookie',
        'better-auth.session_token=refreshed; Path=/; HttpOnly'
      )
      vi.mocked(mockAuth.api.getSession).mockResolvedValue({
        response: { user: sessionUser },
        headers: responseHeaders
      } as never)

      const response = await betterAuthProvider.handleSession!(
        makeRequest('/some-protected-page')
      )

      expect(response.headers.get('set-cookie')).toContain('refreshed')
    })
  })

  describe('signIn', () => {
    it('signs in without a manual set-cookie copy', async () => {
      vi.mocked(mockAuth.api.signInEmail).mockResolvedValue({
        session: {},
        user: sessionUser
      } as never)

      const result = await betterAuthProvider.signIn!({
        email: 'admin@example.com',
        password: 'secret'
      })

      expect(result).toEqual({ success: true })
      // The nextCookies plugin (registered on the real auth instance)
      // stores the cookie via next/headers; the action must not ask for
      // raw headers and re-apply them, which would double-encode the
      // signed value.
      expect(mockAuth.api.signInEmail).toHaveBeenCalledWith(
        expect.not.objectContaining({ returnHeaders: true })
      )
      expect(mockSetCookie).not.toHaveBeenCalled()
    })

    it('reports invalid credentials', async () => {
      vi.mocked(mockAuth.api.signInEmail).mockRejectedValue(
        new Error('Invalid email or password')
      )

      const result = await betterAuthProvider.signIn!({
        email: 'admin@example.com',
        password: 'wrong'
      })

      expect(result).toEqual({
        success: false,
        error: 'Invalid email or password'
      })
      expect(mockSetCookie).not.toHaveBeenCalled()
    })

    it('blocks repeated attempts for the same ip and email', async () => {
      // better-auth's own limiter only runs in its HTTP handler; the
      // direct action needs the in-process limiter.
      vi.mocked(mockAuth.api.signInEmail).mockResolvedValue({} as never)

      for (let attempt = 0; attempt < 10; attempt++) {
        await betterAuthProvider.signIn!({
          email: 'a@b.co',
          password: 'x'
        })
      }

      const result = await betterAuthProvider.signIn!({
        email: 'a@b.co',
        password: 'x'
      })

      expect(result).toEqual({
        success: false,
        error: expect.stringContaining('Too many attempts')
      })
    })

    it('blocks repeated attempts for one email across rotating ips', async () => {
      // x-forwarded-for is caller-supplied, so a fresh value per request
      // must not buy a fresh budget against the same account.
      vi.mocked(mockAuth.api.signInEmail).mockResolvedValue({} as never)
      let attempt = 0
      vi.mocked(headers).mockImplementation(
        async () =>
          new Headers({ 'x-forwarded-for': `10.0.0.${attempt}` }) as never
      )

      try {
        for (; attempt < 10; attempt++) {
          expect(
            await betterAuthProvider.signIn!({ email: 'a@b.co', password: 'x' })
          ).toEqual({ success: true })
        }

        expect(
          await betterAuthProvider.signIn!({ email: 'A@b.co ', password: 'x' })
        ).toEqual({
          success: false,
          error: expect.stringContaining('Too many attempts')
        })
        expect(
          await betterAuthProvider.signIn!({
            email: 'other@b.co',
            password: 'x'
          })
        ).toEqual({ success: true })
      } finally {
        vi.mocked(headers).mockImplementation(
          async () => new Headers({ origin: 'http://localhost:3000' }) as never
        )
      }
    })
  })

  describe('signUp', () => {
    const signUpResult = { token: 'session-token', user: sessionUser }

    it('signs up in open mode without a token', async () => {
      vi.mocked(mockAuth.api.signUpEmail).mockResolvedValue(
        signUpResult as never
      )

      const result = await betterAuthProvider.signUp!({
        email: 'new@example.com',
        password: 'secret'
      })

      expect(result).toEqual({ success: true })
      expect(validateInvitation).not.toHaveBeenCalled()
    })

    it('reports failure when the response carries no session token', async () => {
      // Regression: signUpEmail resolves to `{ token, user }`; a response
      // without a token means the account was not created.
      vi.mocked(mockAuth.api.signUpEmail).mockResolvedValue({
        user: sessionUser
      } as never)

      const result = await betterAuthProvider.signUp!({
        email: 'new@example.com',
        password: 'secret'
      })

      expect(result.success).toBe(false)
    })

    it('requires a valid invitation in invite mode', async () => {
      process.env.AUTH_SIGNUP_MODE = 'invite'
      vi.mocked(validateInvitation).mockResolvedValue(null)

      const result = await betterAuthProvider.signUp!({
        email: 'new@example.com',
        password: 'secret'
      })

      expect(result).toEqual({
        success: false,
        error: 'Sign-up requires a valid invitation token.'
      })
      expect(mockAuth.api.signUpEmail).not.toHaveBeenCalled()
    })

    it('lets the BOOTSTRAP_ADMIN_EMAIL account sign up without an invitation', async () => {
      // Fresh invite-only instance: invitations require an admin, so the
      // gated seed address must be able to create the first account.
      process.env.AUTH_SIGNUP_MODE = 'invite'
      process.env.BOOTSTRAP_ADMIN_EMAIL = 'Admin@Corp.local'
      vi.mocked(mockAuth.api.signUpEmail).mockResolvedValue(
        signUpResult as never
      )

      const result = await betterAuthProvider.signUp!({
        email: 'admin@corp.local',
        password: 'secret'
      })

      expect(result).toEqual({ success: true })
      expect(validateInvitation).not.toHaveBeenCalled()
    })

    it('emails a bootstrap invitation instead of creating the gated account when SMTP is set', async () => {
      // The gate only compares caller-supplied text; with SMTP available the
      // gated address must prove mailbox control before the admin account
      // is created.
      process.env.AUTH_SIGNUP_MODE = 'invite'
      process.env.BOOTSTRAP_ADMIN_EMAIL = 'admin@corp.local'
      process.env.SMTP_HOST = 'smtp.example.com'
      process.env.SMTP_USER = 'user'
      process.env.SMTP_PASSWORD = 'pass'
      process.env.BETTER_AUTH_URL = 'http://localhost:3000'
      vi.mocked(createInvitation).mockResolvedValue({
        invitation: {} as never,
        token: 'boot-token'
      })

      const result = await betterAuthProvider.signUp!({
        email: 'admin@corp.local',
        password: 'secret'
      })

      expect(result.success).toBe(true)
      expect(result.notice).toContain('bootstrap link')
      expect(createInvitation).toHaveBeenCalledWith({
        invitedBy: 'bootstrap',
        email: 'admin@corp.local'
      })
      expect(sendSmtpMail).toHaveBeenCalledWith(
        expect.objectContaining({
          to: 'admin@corp.local',
          text: expect.stringContaining('/auth/sign-up?token=boot-token')
        })
      )
      expect(mockAuth.api.signUpEmail).not.toHaveBeenCalled()
    })

    it('still validates the emailed bootstrap invitation through the normal token path', async () => {
      // Following the emailed link must go through invitation validation,
      // not the mailbox-proof branch (which would loop forever).
      process.env.AUTH_SIGNUP_MODE = 'invite'
      process.env.BOOTSTRAP_ADMIN_EMAIL = 'admin@corp.local'
      process.env.SMTP_HOST = 'smtp.example.com'
      process.env.SMTP_USER = 'user'
      process.env.SMTP_PASSWORD = 'pass'
      process.env.BETTER_AUTH_URL = 'http://localhost:3000'
      vi.mocked(validateInvitation).mockResolvedValue({
        id: 'inv-1',
        email: 'admin@corp.local',
        invitedBy: 'bootstrap'
      } as never)
      vi.mocked(consumeInvitation).mockResolvedValue(true)
      vi.mocked(mockAuth.api.signUpEmail).mockResolvedValue(
        signUpResult as never
      )

      const result = await betterAuthProvider.signUp!({
        email: 'admin@corp.local',
        password: 'secret',
        token: 'boot-token'
      })

      expect(result).toEqual({ success: true })
      expect(createInvitation).not.toHaveBeenCalled()
      expect(consumeInvitation).toHaveBeenCalledWith('inv-1')
    })

    it('releases the invitation when sign-up is rejected', async () => {
      // better-auth validates the password after the claim: a rejected
      // credential (e.g. below the minimum length) must not burn the link,
      // or the invitee needs a fresh invitation for a typo.
      process.env.AUTH_SIGNUP_MODE = 'invite'
      vi.mocked(validateInvitation).mockResolvedValue({
        id: 'inv-9',
        email: 'member@example.com'
      } as never)
      vi.mocked(consumeInvitation).mockResolvedValue(true)
      vi.mocked(mockAuth.api.signUpEmail).mockResolvedValue({
        response: null,
        headers: new Headers()
      } as never)

      const result = await betterAuthProvider.signUp!({
        email: 'member@example.com',
        password: 'short',
        token: 'invite-token'
      })

      expect(result.success).toBe(false)
      expect(releaseInvitation).toHaveBeenCalledWith('inv-9')
    })

    it('releases the invitation when sign-up throws', async () => {
      process.env.AUTH_SIGNUP_MODE = 'invite'
      vi.mocked(validateInvitation).mockResolvedValue({
        id: 'inv-9',
        email: 'member@example.com'
      } as never)
      vi.mocked(consumeInvitation).mockResolvedValue(true)
      vi.mocked(mockAuth.api.signUpEmail).mockRejectedValue(
        new Error('Password must be at least 8 characters')
      )

      const result = await betterAuthProvider.signUp!({
        email: 'member@example.com',
        password: 'short',
        token: 'invite-token'
      })

      expect(result.success).toBe(false)
      expect(result.error).toContain('at least 8 characters')
      expect(releaseInvitation).toHaveBeenCalledWith('inv-9')
    })

    it('keeps the invitation consumed when the account exists despite a throw', async () => {
      // The throw may have happened after better-auth committed the user:
      // releasing the claim would re-enable a link whose address is taken.
      process.env.AUTH_SIGNUP_MODE = 'invite'
      state.accounts = [{ id: 'user-1' }]
      vi.mocked(validateInvitation).mockResolvedValue({
        id: 'inv-9',
        email: 'member@example.com'
      } as never)
      vi.mocked(consumeInvitation).mockResolvedValue(true)
      vi.mocked(mockAuth.api.signUpEmail).mockRejectedValue(
        new Error('connection dropped')
      )

      const result = await betterAuthProvider.signUp!({
        email: 'member@example.com',
        password: 'secret',
        token: 'invite-token'
      })

      expect(result.success).toBe(false)
      expect(releaseInvitation).not.toHaveBeenCalled()
    })

    it('rejects an arbitrary token on the gated address in open mode', async () => {
      // Open mode ignores tokens for regular sign-ups, so the bootstrap
      // proof must validate them itself: any non-empty token must not be
      // enough to skip mailbox control.
      process.env.BOOTSTRAP_ADMIN_EMAIL = 'admin@corp.local'
      process.env.SMTP_HOST = 'smtp.example.com'
      process.env.SMTP_USER = 'user'
      process.env.SMTP_PASSWORD = 'pass'
      process.env.BETTER_AUTH_URL = 'http://localhost:3000'
      vi.mocked(validateInvitation).mockResolvedValue(null)

      const result = await betterAuthProvider.signUp!({
        email: 'admin@corp.local',
        password: 'secret',
        token: 'arbitrary-token'
      })

      expect(result.success).toBe(false)
      expect(result.error).toContain('link sent to your email')
      expect(mockAuth.api.signUpEmail).not.toHaveBeenCalled()
      expect(createInvitation).not.toHaveBeenCalled()
    })

    it('redeems the emailed bootstrap invitation in open mode too', async () => {
      process.env.BOOTSTRAP_ADMIN_EMAIL = 'admin@corp.local'
      process.env.SMTP_HOST = 'smtp.example.com'
      process.env.SMTP_USER = 'user'
      process.env.SMTP_PASSWORD = 'pass'
      process.env.BETTER_AUTH_URL = 'http://localhost:3000'
      vi.mocked(validateInvitation).mockResolvedValue({
        id: 'inv-2',
        email: 'admin@corp.local',
        invitedBy: 'bootstrap'
      } as never)
      vi.mocked(consumeInvitation).mockResolvedValue(true)
      vi.mocked(mockAuth.api.signUpEmail).mockResolvedValue(
        signUpResult as never
      )

      const result = await betterAuthProvider.signUp!({
        email: 'admin@corp.local',
        password: 'secret',
        token: 'boot-token'
      })

      expect(result).toEqual({ success: true })
      expect(consumeInvitation).toHaveBeenCalledWith('inv-2')
    })

    it('rejects a bootstrap token not bound to the gated address', async () => {
      process.env.BOOTSTRAP_ADMIN_EMAIL = 'admin@corp.local'
      process.env.SMTP_HOST = 'smtp.example.com'
      process.env.SMTP_USER = 'user'
      process.env.SMTP_PASSWORD = 'pass'
      process.env.BETTER_AUTH_URL = 'http://localhost:3000'
      vi.mocked(validateInvitation).mockResolvedValue({
        id: 'inv-3',
        email: null
      } as never)

      const result = await betterAuthProvider.signUp!({
        email: 'admin@corp.local',
        password: 'secret',
        token: 'unbound-token'
      })

      expect(result.success).toBe(false)
      expect(mockAuth.api.signUpEmail).not.toHaveBeenCalled()
      expect(consumeInvitation).not.toHaveBeenCalled()
    })

    it('rejects an ordinary invitation used for bootstrap sign-up', async () => {
      // In a reopened bootstrap window, an ordinary invitation (from an
      // inviting admin) for the same address must not claim admin:
      // only the invitation this branch mailed itself (invitedBy =
      // 'bootstrap') proves mailbox control of the gated address.
      process.env.BOOTSTRAP_ADMIN_EMAIL = 'admin@corp.local'
      process.env.SMTP_HOST = 'smtp.example.com'
      process.env.SMTP_USER = 'user'
      process.env.SMTP_PASSWORD = 'pass'
      process.env.BETTER_AUTH_URL = 'http://localhost:3000'
      vi.mocked(validateInvitation).mockResolvedValue({
        id: 'inv-5',
        email: 'admin@corp.local',
        invitedBy: 'some-admin-id'
      } as never)

      const result = await betterAuthProvider.signUp!({
        email: 'admin@corp.local',
        password: 'secret',
        token: 'ordinary-invitation-token'
      })

      expect(result.success).toBe(false)
      expect(result.error).toContain('link sent to your email')
      expect(mockAuth.api.signUpEmail).not.toHaveBeenCalled()
      expect(consumeInvitation).not.toHaveBeenCalled()
    })

    it('does not send a second bootstrap link within the cooldown', async () => {
      // The branch runs before better-auth's rate limiter: a caller who
      // knows the gated address must not be able to flood the mailbox.
      process.env.BOOTSTRAP_ADMIN_EMAIL = 'admin@corp.local'
      process.env.SMTP_HOST = 'smtp.example.com'
      process.env.SMTP_USER = 'user'
      process.env.SMTP_PASSWORD = 'pass'
      process.env.BETTER_AUTH_URL = 'http://localhost:3000'
      vi.mocked(hasRecentBootstrapInvitation).mockResolvedValue(true)

      const result = await betterAuthProvider.signUp!({
        email: 'admin@corp.local',
        password: 'secret'
      })

      expect(result.success).toBe(true)
      expect(result.notice).toContain('bootstrap link')
      expect(createInvitation).not.toHaveBeenCalled()
      expect(sendSmtpMail).not.toHaveBeenCalled()
      expect(mockAuth.api.signUpEmail).not.toHaveBeenCalled()
    })

    it('refuses the bootstrap flow when SMTP is set without a canonical origin', async () => {
      // Fail closed: silently falling back to first-come sign-up would
      // let anyone who guesses the gated address claim admin while the
      // operator believes mailbox proof is active.
      process.env.BOOTSTRAP_ADMIN_EMAIL = 'admin@corp.local'
      process.env.SMTP_HOST = 'smtp.example.com'
      process.env.SMTP_USER = 'user'
      process.env.SMTP_PASSWORD = 'pass'

      const result = await betterAuthProvider.signUp!({
        email: 'admin@corp.local',
        password: 'secret'
      })

      expect(result.success).toBe(false)
      expect(result.error).toContain('BETTER_AUTH_URL')
      expect(createInvitation).not.toHaveBeenCalled()
      expect(sendSmtpMail).not.toHaveBeenCalled()
      expect(mockAuth.api.signUpEmail).not.toHaveBeenCalled()
    })

    it('releases the invitation when the bootstrap email fails to send', async () => {
      // Otherwise the cooldown would treat the unsent invitation as
      // delivered and block retries for 15 minutes.
      process.env.BOOTSTRAP_ADMIN_EMAIL = 'admin@corp.local'
      process.env.SMTP_HOST = 'smtp.example.com'
      process.env.SMTP_USER = 'user'
      process.env.SMTP_PASSWORD = 'pass'
      process.env.BETTER_AUTH_URL = 'http://localhost:3000'
      vi.mocked(hasRecentBootstrapInvitation).mockResolvedValue(false)
      vi.mocked(createInvitation).mockResolvedValue({
        invitation: { id: 'inv-boot' } as never,
        token: 'boot-token'
      })
      vi.mocked(sendSmtpMail).mockRejectedValue(new Error('smtp down'))

      const result = await betterAuthProvider.signUp!({
        email: 'admin@corp.local',
        password: 'secret'
      })

      expect(result.success).toBe(false)
      expect(result.error).toContain('smtp down')
      expect(revokeInvitation).toHaveBeenCalledWith('inv-boot')
    })

    it('serializes concurrent bootstrap requests per address', async () => {
      // The cooldown check and the insert are separate queries: without the
      // per-address lock, two concurrent calls would both pass the check
      // before either insert landed and each would mail a live admin link.
      process.env.BOOTSTRAP_ADMIN_EMAIL = 'admin@corp.local'
      process.env.SMTP_HOST = 'smtp.example.com'
      process.env.SMTP_USER = 'user'
      process.env.SMTP_PASSWORD = 'pass'
      process.env.BETTER_AUTH_URL = 'http://localhost:3000'
      let created = false
      vi.mocked(hasRecentBootstrapInvitation).mockImplementation(
        async () => created
      )
      vi.mocked(createInvitation).mockImplementation(async () => {
        created = true
        return { invitation: { id: 'inv-boot' } as never, token: 'boot-token' }
      })
      vi.mocked(sendSmtpMail).mockResolvedValue(undefined)

      const [first, second] = await Promise.all([
        betterAuthProvider.signUp!({
          email: 'admin@corp.local',
          password: 'secret'
        }),
        betterAuthProvider.signUp!({
          email: 'admin@corp.local',
          password: 'secret'
        })
      ])

      expect(first.success).toBe(true)
      expect(second.success).toBe(true)
      expect(createInvitation).toHaveBeenCalledTimes(1)
      expect(sendSmtpMail).toHaveBeenCalledTimes(1)
    })

    it('claims the invitation before creating the account', async () => {
      process.env.AUTH_SIGNUP_MODE = 'invite'
      vi.mocked(validateInvitation).mockResolvedValue({
        id: 'inv-1',
        token: 'tok'
      } as never)
      vi.mocked(consumeInvitation).mockResolvedValue(true)
      vi.mocked(mockAuth.api.signUpEmail).mockResolvedValue(
        signUpResult as never
      )

      const result = await betterAuthProvider.signUp!({
        email: 'new@example.com',
        password: 'secret',
        token: 'tok'
      })

      expect(result).toEqual({ success: true })
      expect(consumeInvitation).toHaveBeenCalledWith('inv-1')
    })

    it('rejects a sign-up whose email does not match the invitation', async () => {
      process.env.AUTH_SIGNUP_MODE = 'invite'
      vi.mocked(validateInvitation).mockResolvedValue({
        id: 'inv-1',
        email: 'friend@example.com'
      } as never)

      const result = await betterAuthProvider.signUp!({
        email: 'someone-else@example.com',
        password: 'secret',
        token: 'tok'
      })

      expect(result).toEqual({
        success: false,
        error: 'This invitation was issued for a different email address.'
      })
      // A mismatched attempt must not burn the invitation for its recipient
      expect(consumeInvitation).not.toHaveBeenCalled()
      expect(mockAuth.api.signUpEmail).not.toHaveBeenCalled()
    })

    it('accepts a case-insensitive match with the invited email', async () => {
      process.env.AUTH_SIGNUP_MODE = 'invite'
      vi.mocked(validateInvitation).mockResolvedValue({
        id: 'inv-1',
        email: 'Friend@Example.com'
      } as never)
      vi.mocked(consumeInvitation).mockResolvedValue(true)
      vi.mocked(mockAuth.api.signUpEmail).mockResolvedValue(
        signUpResult as never
      )

      const result = await betterAuthProvider.signUp!({
        email: 'friend@example.com',
        password: 'secret',
        token: 'tok'
      })

      expect(result).toEqual({ success: true })
    })

    it('rejects when the invitation was already claimed concurrently', async () => {
      process.env.AUTH_SIGNUP_MODE = 'invite'
      vi.mocked(validateInvitation).mockResolvedValue({
        id: 'inv-1',
        token: 'tok'
      } as never)
      vi.mocked(consumeInvitation).mockResolvedValue(false)

      const result = await betterAuthProvider.signUp!({
        email: 'new@example.com',
        password: 'secret',
        token: 'tok'
      })

      expect(result).toEqual({
        success: false,
        error: 'Sign-up requires a valid invitation token.'
      })
      expect(mockAuth.api.signUpEmail).not.toHaveBeenCalled()
    })
  })

  describe('requestPasswordReset', () => {
    it('fails without SMTP configuration', async () => {
      const result =
        await betterAuthProvider.requestPasswordReset!('admin@example.com')

      expect(result.success).toBe(false)
      expect(mockAuth.api.requestPasswordReset).not.toHaveBeenCalled()
    })

    it('sends a reset email when SMTP is configured', async () => {
      process.env.SMTP_HOST = 'smtp.example.com'
      process.env.SMTP_USER = 'user'
      process.env.SMTP_PASSWORD = 'secret'
      vi.mocked(mockAuth.api.requestPasswordReset).mockResolvedValue(
        {} as never
      )

      const result =
        await betterAuthProvider.requestPasswordReset!('admin@example.com')

      expect(result).toEqual({ success: true })
      expect(mockAuth.api.requestPasswordReset).toHaveBeenCalledWith(
        expect.objectContaining({
          body: {
            email: 'admin@example.com',
            redirectTo: 'http://localhost:3000/auth/update-password'
          }
        })
      )
    })
  })

  describe('updatePassword', () => {
    it('routes token resets to the sign-in page', async () => {
      // A token reset changes the credential only: no session cookie is
      // issued, so landing on the app root would show the anonymous UI.
      vi.mocked(mockAuth.api.resetPassword).mockResolvedValue({} as never)

      const result = await betterAuthProvider.updatePassword!(
        'new-password',
        'reset-token'
      )

      expect(result).toEqual({
        success: true,
        redirectTo: '/auth/login'
      })
    })

    it('keeps the session-based flow on the app root', async () => {
      vi.mocked(mockAuth.api.resetPassword).mockResolvedValue({} as never)

      const result = await betterAuthProvider.updatePassword!('new-password')

      expect(result).toEqual({ success: true })
    })
  })

  describe('canDeleteUser', () => {
    it('returns null for non-admin accounts', async () => {
      state.accounts = [{ id: 'user-1', role: 'user' }]

      await expect(
        betterAuthProvider.canDeleteUser!('user-1')
      ).resolves.toBeNull()
    })

    it('refuses for the only remaining admin', async () => {
      state.accounts = [{ id: 'user-1', role: 'admin' }]
      state.adminCount = 1

      await expect(
        betterAuthProvider.canDeleteUser!('user-1')
      ).resolves.toMatch(/only admin/i)
    })

    it('allows an admin while another remains', async () => {
      state.accounts = [{ id: 'user-1', role: 'admin' }]
      state.adminCount = 2

      await expect(
        betterAuthProvider.canDeleteUser!('user-1')
      ).resolves.toBeNull()
    })

    it('allows the sole account of a single-user instance', async () => {
      state.accounts = [{ id: 'user-1', role: 'admin' }]
      state.adminCount = 1
      state.userCount = 1

      await expect(
        betterAuthProvider.canDeleteUser!('user-1')
      ).resolves.toBeNull()
    })

    it('does not take row locks from the unlocked pre-check', async () => {
      state.accounts = [{ id: 'user-1', role: 'admin' }]
      state.adminCount = 2

      await expect(
        betterAuthProvider.canDeleteUser!('user-1')
      ).resolves.toBeNull()

      // The pre-check runs outside any transaction; FOR UPDATE there
      // would be pointless contention for a UX-only answer.
      expect(state.targetLocked).toBe(false)
    })
  })

  describe('deleteUser', () => {
    it('deletes the auth user row', async () => {
      const { db } = await import('@/lib/db')

      const result = await betterAuthProvider.deleteUser!('user-1')

      expect(result).toEqual({ success: true })
      expect(db.delete).toHaveBeenCalled()
    })

    it('refuses to delete the only remaining admin', async () => {
      state.accounts = [{ id: 'user-1', role: 'admin' }]
      state.adminCount = 1
      const { db } = await import('@/lib/db')

      const result = await betterAuthProvider.deleteUser!('user-1')

      expect(result.success).toBe(false)
      expect(result.error).toMatch(/only admin/i)
      expect(db.delete).not.toHaveBeenCalled()
    })

    it('deletes an admin account while another admin remains', async () => {
      state.accounts = [{ id: 'user-1', role: 'admin' }]
      state.adminCount = 2
      const { db } = await import('@/lib/db')

      const result = await betterAuthProvider.deleteUser!('user-1')

      expect(result).toEqual({ success: true })
      expect(db.delete).toHaveBeenCalled()
    })

    it('deletes the last remaining account even when it is the only admin', async () => {
      state.accounts = [{ id: 'user-1', role: 'admin' }]
      state.adminCount = 1
      state.userCount = 1
      const { db } = await import('@/lib/db')

      const result = await betterAuthProvider.deleteUser!('user-1')

      expect(result).toEqual({ success: true })
      expect(db.delete).toHaveBeenCalled()
    })

    it('deletes through a caller-supplied transaction without re-guarding', async () => {
      // The caller (deleteAccount) already validated the guard under this
      // transaction's locks; deleteUser must not refuse here even though
      // the state alone (last admin with members) would.
      state.accounts = [{ id: 'user-1', role: 'admin' }]
      state.adminCount = 1
      state.userCount = 2
      const { db } = await import('@/lib/db')

      const result = await betterAuthProvider.deleteUser!('user-1', db as never)

      expect(result).toEqual({ success: true })
      expect(db.delete).toHaveBeenCalled()
    })
  })

  describe('validateDeletion', () => {
    it('refuses the last admin while members remain and reports wasAdmin', async () => {
      state.accounts = [{ id: 'user-1', role: 'admin' }]
      state.adminCount = 1
      state.userCount = 2
      const { db } = await import('@/lib/db')

      await expect(
        betterAuthProvider.validateDeletion!('user-1', db as never)
      ).resolves.toEqual({
        error: expect.stringMatching(/only admin/i),
        wasAdmin: true
      })
    })

    it('allows the sole account of a single-user instance', async () => {
      state.accounts = [{ id: 'user-1', role: 'admin' }]
      state.adminCount = 1
      state.userCount = 1
      const { db } = await import('@/lib/db')

      await expect(
        betterAuthProvider.validateDeletion!('user-1', db as never)
      ).resolves.toEqual({ error: null, wasAdmin: true })
    })

    it('reports wasAdmin false for regular members', async () => {
      state.accounts = [{ id: 'user-1', role: 'user' }]
      const { db } = await import('@/lib/db')

      await expect(
        betterAuthProvider.validateDeletion!('user-1', db as never)
      ).resolves.toEqual({ error: null, wasAdmin: false })
    })

    it('locks the target row before classifying its role', async () => {
      // A concurrent promotion must not slip a soon-to-be-admin past
      // the last-admin guard mid-deletion, so the role read happens
      // under a row lock when the guard runs in a transaction.
      state.accounts = [{ id: 'user-1', role: 'user' }]
      const { db } = await import('@/lib/db')

      await betterAuthProvider.validateDeletion!('user-1', db as never)

      expect(state.targetLocked).toBe(true)
    })
  })

  describe('postDeletion', () => {
    it('re-runs the bootstrap election when the deleted account was admin', async () => {
      await betterAuthProvider.postDeletion!(true)

      expect(reElectBootstrapAdmin).toHaveBeenCalledTimes(1)
    })

    it('is a no-op for member deletions', async () => {
      await betterAuthProvider.postDeletion!(false)

      expect(reElectBootstrapAdmin).not.toHaveBeenCalled()
    })
  })
})