|
| 1 | +import { betterAuth } from 'better-auth' |
| 2 | +import { memoryAdapter } from 'better-auth/adapters/memory' |
| 3 | +import { admin } from 'better-auth/plugins' |
| 4 | +import { describe, expect, it } from 'vitest' |
| 5 | +import { DISPOSABLE_EMAIL_ERROR_CODE } from '@/lib/auth/constants' |
| 6 | +import { refuseDisposableAccountEmail } from '@/lib/auth/disposable-email-gate' |
| 7 | +import { configuredSsoPlugin } from '@/lib/auth/sso/plugin' |
| 8 | + |
| 9 | +/** |
| 10 | + * A real Better Auth instance wired the way `auth.ts` wires the gate, so the endpoint path each |
| 11 | + * creation path reports to `databaseHooks.user.create.before` is Better Auth's, not an assumption. |
| 12 | + */ |
| 13 | +function createAuth() { |
| 14 | + return betterAuth({ |
| 15 | + baseURL: 'http://localhost:3000', |
| 16 | + secret: 'disposable-email-gate-test-secret-0123456789', |
| 17 | + database: memoryAdapter({ user: [], session: [], account: [], verification: [] }), |
| 18 | + emailAndPassword: { enabled: true }, |
| 19 | + plugins: [admin()], |
| 20 | + databaseHooks: { |
| 21 | + user: { |
| 22 | + create: { |
| 23 | + before: async (user, context) => { |
| 24 | + await refuseDisposableAccountEmail(user.email, context?.path) |
| 25 | + return { data: user } |
| 26 | + }, |
| 27 | + }, |
| 28 | + }, |
| 29 | + }, |
| 30 | + }) |
| 31 | +} |
| 32 | + |
| 33 | +const password = 'correct-horse-battery-staple' |
| 34 | + |
| 35 | +describe('refuseDisposableAccountEmail', () => { |
| 36 | + it('refuses a self-service email signup from a disposable domain', async () => { |
| 37 | + const auth = createAuth() |
| 38 | + await expect( |
| 39 | + auth.api.signUpEmail({ body: { email: 'jane@mailinator.com', password, name: 'Jane' } }) |
| 40 | + ).rejects.toMatchObject({ body: { code: DISPOSABLE_EMAIL_ERROR_CODE } }) |
| 41 | + }) |
| 42 | + |
| 43 | + it('admits a self-service email signup from a company under a public suffix', async () => { |
| 44 | + const auth = createAuth() |
| 45 | + const result = await auth.api.signUpEmail({ |
| 46 | + body: { email: 'jane@company.msk.ru', password, name: 'Jane' }, |
| 47 | + }) |
| 48 | + expect(result.user.email).toBe('jane@company.msk.ru') |
| 49 | + }) |
| 50 | + |
| 51 | + /** SCIM provisioning creates accounts through the admin plugin's server-side `createUser`. */ |
| 52 | + it('admits a directory-provisioned account whatever its domain', async () => { |
| 53 | + const auth = createAuth() |
| 54 | + const result = await auth.api.createUser({ |
| 55 | + body: { email: 'jane@mailinator.com', name: 'Jane' }, |
| 56 | + }) |
| 57 | + expect(result.user.email).toBe('jane@mailinator.com') |
| 58 | + }) |
| 59 | + |
| 60 | + /** |
| 61 | + * Better Auth dispatches with an endpoint's declared path, so the SSO plugin's own callback |
| 62 | + * endpoints name the paths a just-in-time creation reports. No IdP is driven here. |
| 63 | + */ |
| 64 | + const { callbackSSO, callbackSSOShared, callbackSSOSAML, acsEndpoint } = |
| 65 | + configuredSsoPlugin.endpoints |
| 66 | + it.each([callbackSSO.path, callbackSSOShared.path, callbackSSOSAML.path, acsEndpoint.path])( |
| 67 | + 'admits any domain at the SSO plugin callback path %s', |
| 68 | + async (path) => { |
| 69 | + await expect( |
| 70 | + refuseDisposableAccountEmail('jane@mailinator.com', path) |
| 71 | + ).resolves.toBeUndefined() |
| 72 | + } |
| 73 | + ) |
| 74 | + |
| 75 | + it.each([undefined, '/sign-up/email', '/callback/:id', '/oauth2/callback/:providerId'])( |
| 76 | + 'refuses a disposable domain created at %s', |
| 77 | + async (path) => { |
| 78 | + await expect(refuseDisposableAccountEmail('jane@mailinator.com', path)).rejects.toMatchObject( |
| 79 | + { body: { code: DISPOSABLE_EMAIL_ERROR_CODE } } |
| 80 | + ) |
| 81 | + } |
| 82 | + ) |
| 83 | +}) |
0 commit comments