lib/auth/__tests__/better-auth-provider.test.ts
Rama main · réplica de lectura del repositorio original.
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()
})
})
})