Ctrl K
ComunidadGuías…

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

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

Archivo original ↗Preguntar a KiBot
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import {
  applyBootstrapAdminGate,
  claimBootstrapAdmin,
  getAuth,
  getSignUpMode,
  isBootstrapAccount,
  isBootstrapWindowOpen,
  reElectBootstrapAdmin,
  resetAuthInstance
} from '@/lib/auth/better-auth/config'
import { sendSmtpMail } from '@/lib/auth/better-auth/mailer'
import { db } from '@/lib/db'

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

vi.mock('@/lib/db', () => ({
  db: {
    select: vi.fn(() => ({
      from: vi.fn()
    })),
    transaction: vi.fn()
  }
}))

function mockUserCount(total: number) {
  vi.mocked(db.select).mockReturnValue({
    from: vi.fn().mockResolvedValue([{ total }])
  } as never)
}

function mockClaim(opts: {
  admins?: Array<{ id: string }>
  earliest?: string
}) {
  const set = vi.fn(() => ({ where: vi.fn() }))
  const update = vi.fn(() => ({ set }))
  const tx = {
    execute: vi.fn(),
    select: vi.fn(() => ({
      from: vi.fn(() => ({
        where: vi.fn(() => ({
          limit: vi.fn().mockResolvedValue(opts.admins ?? [])
        })),
        orderBy: vi.fn(() => ({
          limit: vi
            .fn()
            .mockResolvedValue(opts.earliest ? [{ id: opts.earliest }] : [])
        }))
      }))
    })),
    update
  }
  vi.mocked(db.transaction).mockImplementation(
    async callback => (callback as (tx: unknown) => Promise<void>)(tx) as never
  )
  return { tx, update, set }
}

describe('bootstrap admin gate', () => {
  const originalEnv: Record<string, string | undefined> = {}

  beforeEach(() => {
    for (const key of ['BOOTSTRAP_ADMIN_EMAIL', 'NODE_ENV']) {
      originalEnv[key] = process.env[key]
    }
    delete process.env.BOOTSTRAP_ADMIN_EMAIL
    vi.clearAllMocks()
  })

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

  it('allows any email through while no gate is configured', async () => {
    mockUserCount(0)

    await expect(
      applyBootstrapAdminGate({ email: 'first@example.com' })
    ).resolves.toBeUndefined()
  })

  it('leaves later sign-ups untouched once the window closed', async () => {
    mockUserCount(1)

    await expect(
      applyBootstrapAdminGate({ email: 'second@example.com' })
    ).resolves.toBeUndefined()
  })

  it('rejects other emails while BOOTSTRAP_ADMIN_EMAIL is set', async () => {
    process.env.BOOTSTRAP_ADMIN_EMAIL = 'admin@example.com'
    mockUserCount(0)

    await expect(
      applyBootstrapAdminGate({ email: 'intruder@example.com' })
    ).rejects.toThrow(/BOOTSTRAP_ADMIN_EMAIL/)
  })

  it('accepts the gated email case-insensitively', async () => {
    process.env.BOOTSTRAP_ADMIN_EMAIL = 'Admin@Example.com'
    mockUserCount(0)

    await expect(
      applyBootstrapAdminGate({ email: 'admin@example.com' })
    ).resolves.toBeUndefined()
  })

  it('does not gate sign-ups after the window closed even with a gate set', async () => {
    process.env.BOOTSTRAP_ADMIN_EMAIL = 'admin@example.com'
    mockUserCount(3)

    await expect(
      applyBootstrapAdminGate({ email: 'someone-else@example.com' })
    ).resolves.toBeUndefined()
  })
})

describe('claimBootstrapAdmin', () => {
  it('grants the admin role to the first account', async () => {
    const { tx, update, set } = mockClaim({ earliest: 'user-1' })

    await claimBootstrapAdmin('user-1')

    expect(tx.execute).toHaveBeenCalled()
    expect(update).toHaveBeenCalled()
    expect(set).toHaveBeenCalledWith({ role: 'admin' })
  })

  it('does nothing when an admin already exists', async () => {
    const { update } = mockClaim({
      admins: [{ id: 'existing-admin' }],
      earliest: 'user-1'
    })

    await claimBootstrapAdmin('user-1')

    expect(update).not.toHaveBeenCalled()
  })

  it('does nothing when other users remain (no re-bootstrap after the first account)', async () => {
    // Sole admin deleted their account while members remained: the next
    // registrant is neither an admin nor the earliest account, so it must
    // not inherit the role.
    const { update } = mockClaim({ earliest: 'older-user' })

    await claimBootstrapAdmin('user-1')

    expect(update).not.toHaveBeenCalled()
  })

  it('elects exactly one admin when concurrent first sign-ups both observe each other', async () => {
    // Alice and Bob commit before either after-hook claims; each claim sees
    // both rows. Only the earliest account may claim, so exactly one wins
    // regardless of interleaving.
    const claims: string[] = []
    for (const userId of ['alice', 'bob']) {
      const { update } = mockClaim({ earliest: 'alice' })
      await claimBootstrapAdmin(userId)
      if (update.mock.calls.length > 0) {
        claims.push(userId)
      }
    }

    expect(claims).toEqual(['alice'])
  })

  it('retries serialization failures and completes the claim when no admin materialized', async () => {
    // A 40001 abort does not prove a concurrent claimant committed:
    // both claimants can abort. The retry must re-run the election
    // instead of giving up and stranding the instance admin-less.
    const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
    const { update } = mockClaim({ earliest: 'user-1' })
    vi.mocked(db.transaction).mockRejectedValueOnce(
      Object.assign(new Error('could not serialize access'), {
        code: '40001'
      })
    )

    await expect(claimBootstrapAdmin('user-1')).resolves.toBeUndefined()

    expect(update).toHaveBeenCalled()
    expect(errorSpy).not.toHaveBeenCalled()

    errorSpy.mockRestore()
  })

  it('logs unexpected claim failures', async () => {
    const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
    vi.mocked(db.transaction).mockRejectedValue(new Error('connection lost'))

    await expect(claimBootstrapAdmin('user-1')).resolves.toBeUndefined()
    expect(errorSpy).toHaveBeenCalledWith(
      expect.stringContaining('Bootstrap admin claim failed'),
      expect.any(Error)
    )
    // Exhaustion must read as a repair-needed condition with the
    // remediation, not a silent success.
    expect(errorSpy.mock.calls[0][0]).toContain('repair needed')
    expect(errorSpy.mock.calls[0][0]).toContain("SET role = 'admin'")

    errorSpy.mockRestore()
  })

  it('retries a transient claim failure until it succeeds', async () => {
    // The user insert is already committed at this point and the
    // earliest-account guard means no later sign-up can take the claim
    // over, so a transient blip must be retried rather than logged away.
    const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
    const { update } = mockClaim({ earliest: 'user-1' })
    vi.mocked(db.transaction).mockRejectedValueOnce(
      Object.assign(new Error('connection lost'), { code: '08006' })
    )

    await expect(claimBootstrapAdmin('user-1')).resolves.toBeUndefined()

    expect(update).toHaveBeenCalled()
    expect(errorSpy).not.toHaveBeenCalled()

    errorSpy.mockRestore()
  })
})

describe('reElectBootstrapAdmin', () => {
  it('promotes the earliest account when the deletion left no admin', async () => {
    // A sign-up raced the sole admin's deletion: its own claim declined
    // because the admin row still existed. The re-election completes the
    // election for the earliest remaining account.
    const { update, set } = mockClaim({ earliest: 'raced-signup' })

    await reElectBootstrapAdmin()

    expect(update).toHaveBeenCalled()
    expect(set).toHaveBeenCalledWith({ role: 'admin' })
  })

  it('does nothing when an admin exists', async () => {
    const { update } = mockClaim({
      admins: [{ id: 'existing-admin' }],
      earliest: 'user-1'
    })

    await reElectBootstrapAdmin()

    expect(update).not.toHaveBeenCalled()
  })

  it('does nothing on an empty table', async () => {
    const { update } = mockClaim({})

    await reElectBootstrapAdmin()

    expect(update).not.toHaveBeenCalled()
  })

  it('retries serialization failures and logs after exhausting attempts', async () => {
    const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
    const { update } = mockClaim({ earliest: 'raced-signup' })
    vi.mocked(db.transaction).mockRejectedValueOnce(
      Object.assign(new Error('could not serialize'), { code: '40001' })
    )
    await expect(reElectBootstrapAdmin()).resolves.toBeUndefined()
    expect(update).toHaveBeenCalled()
    expect(errorSpy).not.toHaveBeenCalled()

    vi.mocked(db.transaction).mockRejectedValue(new Error('boom'))
    await expect(reElectBootstrapAdmin()).resolves.toBeUndefined()
    expect(errorSpy).toHaveBeenCalledWith(
      expect.stringContaining('Bootstrap admin re-election failed'),
      expect.any(Error)
    )

    errorSpy.mockRestore()
  })
})

describe('getSignUpMode', () => {
  const original = process.env.AUTH_SIGNUP_MODE

  afterEach(() => {
    if (original === undefined) {
      delete process.env.AUTH_SIGNUP_MODE
    } else {
      process.env.AUTH_SIGNUP_MODE = original
    }
  })

  it('defaults to open when unset', () => {
    delete process.env.AUTH_SIGNUP_MODE
    expect(getSignUpMode()).toBe('open')
  })

  it('accepts open and invite (case-insensitive, trimmed)', () => {
    process.env.AUTH_SIGNUP_MODE = ' invite '
    expect(getSignUpMode()).toBe('invite')
    process.env.AUTH_SIGNUP_MODE = 'Open'
    expect(getSignUpMode()).toBe('open')
  })

  it('throws on an unsupported value instead of silently opening sign-ups', () => {
    process.env.AUTH_SIGNUP_MODE = 'invte'
    expect(() => getSignUpMode()).toThrow(/Invalid AUTH_SIGNUP_MODE/)
  })
})

describe('isBootstrapAccount', () => {
  const original = process.env.BOOTSTRAP_ADMIN_EMAIL

  afterEach(() => {
    if (original === undefined) {
      delete process.env.BOOTSTRAP_ADMIN_EMAIL
    } else {
      process.env.BOOTSTRAP_ADMIN_EMAIL = original
    }
  })

  it('is false when no gate is configured', async () => {
    delete process.env.BOOTSTRAP_ADMIN_EMAIL
    mockUserCount(0)
    await expect(isBootstrapAccount('anyone@example.com')).resolves.toBe(false)
  })

  it('accepts the gated address while the table is empty', async () => {
    process.env.BOOTSTRAP_ADMIN_EMAIL = 'admin@corp.local'
    mockUserCount(0)
    await expect(isBootstrapAccount('admin@corp.local')).resolves.toBe(true)
  })

  it('matches the gate case-insensitively and trimmed', async () => {
    process.env.BOOTSTRAP_ADMIN_EMAIL = ' Admin@Corp.local '
    mockUserCount(0)
    await expect(isBootstrapAccount('ADMIN@CORP.LOCAL')).resolves.toBe(true)
  })

  it('rejects other addresses', async () => {
    process.env.BOOTSTRAP_ADMIN_EMAIL = 'admin@corp.local'
    mockUserCount(0)
    await expect(isBootstrapAccount('intruder@example.com')).resolves.toBe(
      false
    )
  })

  it('is false once the bootstrap window closed', async () => {
    process.env.BOOTSTRAP_ADMIN_EMAIL = 'admin@corp.local'
    mockUserCount(1)
    await expect(isBootstrapAccount('admin@corp.local')).resolves.toBe(false)
  })
})

describe('isBootstrapWindowOpen', () => {
  const original = process.env.BOOTSTRAP_ADMIN_EMAIL

  afterEach(() => {
    if (original === undefined) {
      delete process.env.BOOTSTRAP_ADMIN_EMAIL
    } else {
      process.env.BOOTSTRAP_ADMIN_EMAIL = original
    }
  })

  it('is false when no gate is configured', async () => {
    delete process.env.BOOTSTRAP_ADMIN_EMAIL
    mockUserCount(0)
    await expect(isBootstrapWindowOpen()).resolves.toBe(false)
  })

  it('is true while the gate is set and the table is empty', async () => {
    process.env.BOOTSTRAP_ADMIN_EMAIL = 'admin@corp.local'
    mockUserCount(0)
    await expect(isBootstrapWindowOpen()).resolves.toBe(true)
  })

  it('is false once the first account exists', async () => {
    process.env.BOOTSTRAP_ADMIN_EMAIL = 'admin@corp.local'
    mockUserCount(1)
    await expect(isBootstrapWindowOpen()).resolves.toBe(false)
  })
})

describe('reset password mailer wiring', () => {
  const original: Record<string, string | undefined> = {}

  beforeEach(() => {
    resetAuthInstance()
    for (const key of [
      'SMTP_HOST',
      'SMTP_USER',
      'SMTP_PASSWORD',
      'BETTER_AUTH_URL'
    ]) {
      original[key] = process.env[key]
      delete process.env[key]
    }
  })

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

  it('wires the reset mailer through the shared SMTP transport', () => {
    process.env.SMTP_HOST = 'smtp.example.com'
    process.env.SMTP_USER = 'user'
    process.env.SMTP_PASSWORD = 'pass'

    const auth = getAuth()

    expect(typeof auth.options.emailAndPassword?.sendResetPassword).toBe(
      'function'
    )
  })

  it('omits the reset mailer when SMTP is not configured', () => {
    const auth = getAuth()

    expect(auth.options.emailAndPassword?.sendResetPassword).toBeUndefined()
  })

  it('refuses to send a reset email without a canonical origin', async () => {
    // Without BETTER_AUTH_URL the link could only be built from
    // caller-controlled headers: the mailer must fail instead of
    // delivering a live credential to a spoofed host.
    process.env.SMTP_HOST = 'smtp.example.com'
    process.env.SMTP_USER = 'user'
    process.env.SMTP_PASSWORD = 'pass'

    const auth = getAuth()
    const sendResetPassword = auth.options.emailAndPassword?.sendResetPassword
    expect(sendResetPassword).toBeDefined()

    await expect(
      sendResetPassword!({
        user: { email: 'admin@corp.local' },
        url: 'http://localhost:3000/api/auth/reset-password/tok',
        token: 'tok'
      } as never)
    ).rejects.toThrow('BETTER_AUTH_URL')
    expect(sendSmtpMail).not.toHaveBeenCalled()
  })

  it('links the in-app reset page instead of better-auth unmounted endpoint', async () => {
    // better-auth's own /api/auth/reset-password/:token handler is not
    // mounted, so its generated url would 404; the email must carry the
    // raw token to the in-app page instead.
    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/'

    const auth = getAuth()
    const sendResetPassword = auth.options.emailAndPassword?.sendResetPassword
    expect(sendResetPassword).toBeDefined()

    await sendResetPassword!({
      user: { email: 'admin@corp.local' },
      url: 'http://localhost:3000/api/auth/reset-password/tok?callbackURL=x',
      token: 'tok'
    } as never)

    expect(sendSmtpMail).toHaveBeenCalledWith(
      expect.objectContaining({
        to: 'admin@corp.local',
        text: expect.stringContaining(
          'http://localhost:3000/auth/update-password?token=tok'
        )
      })
    )
  })
})

describe('auth secret', () => {
  beforeEach(() => {
    resetAuthInstance()
  })

  afterEach(() => {
    resetAuthInstance()
    vi.unstubAllEnvs()
  })

  it('throws in production when BETTER_AUTH_SECRET is missing', () => {
    vi.stubEnv('NODE_ENV', 'production')
    vi.stubEnv('BETTER_AUTH_SECRET', undefined)

    expect(() => getAuth()).toThrow(/BETTER_AUTH_SECRET/)
  })

  it('uses the configured secret in production', () => {
    vi.stubEnv('NODE_ENV', 'production')
    vi.stubEnv('BETTER_AUTH_SECRET', 'a'.repeat(32))

    expect(() => getAuth()).not.toThrow()
  })

  it('falls back to the development secret outside production', () => {
    vi.stubEnv('NODE_ENV', 'test')
    vi.stubEnv('BETTER_AUTH_SECRET', undefined)

    expect(() => getAuth()).not.toThrow()
  })
})