import { beforeEach, describe, expect, it, vi } from 'vitest' const { getEnvMock, getClientIPMock, rpcMock, signUpMock, usersUpsertMock, deleteUserMock, updateUserByIdMock, usersDeleteMock, } = vi.hoisted(() => ({ getEnvMock: vi.fn((_c: unknown, key: string) => { if (key === 'CAPTCHA_SECRET_KEY') return 'turnstile-secret' if (key === 'SUPABASE_URL') return 'https://example.supabase.co' if (key === 'SUPABASE_ANON_KEY') return 'anon-key' return '' }), getClientIPMock: vi.fn(() => '203.0.113.10'), rpcMock: vi.fn(async () => ({ data: true, error: null })), signUpMock: vi.fn(async () => ({ data: { user: { id: '11111111-1111-1111-1111-111111111111', email_confirmed_at: '2026-01-01T00:00:00.000Z' }, session: { access_token: 'access-token', refresh_token: 'refresh-token', }, }, error: null, })), usersUpsertMock: vi.fn(async (_row: unknown) => ({ error: null })), deleteUserMock: vi.fn(async () => ({ error: null })), updateUserByIdMock: vi.fn<(id: string, attributes: unknown) => Promise<{ error: Error | null }>>(async () => ({ error: null })), usersDeleteMock: vi.fn(async (_id: unknown) => ({ error: null })), })) vi.mock('../supabase/functions/_backend/utils/utils.ts', () => ({ getEnv: getEnvMock, })) vi.mock('../supabase/functions/_backend/utils/rate_limit.ts', () => ({ getClientIP: getClientIPMock, })) vi.mock('../supabase/functions/_backend/utils/supabase.ts', () => ({ emptySupabaseWithClientIP: () => ({ auth: { signUp: signUpMock, }, }), supabaseAdmin: () => ({ auth: { admin: { deleteUser: deleteUserMock, updateUserById: updateUserByIdMock, }, }, }), })) // Route the direct-Postgres statements back to the same mocks the supabase-js calls used. async function unwrap(result: Promise<{ data?: unknown, error: unknown }>) { const { data, error } = await result if (error) throw error return data } vi.mock('../supabase/functions/_backend/utils/pg.ts', () => ({ getPgClient: async () => ({ async query(text: string, values: unknown[] = []) { if (text.includes('public.is_not_deleted')) { const data = await unwrap(rpcMock()) return { rows: [{ not_deleted: data }] } } if (text.includes('INSERT INTO public.users')) { expect(text).toContain('ON CONFLICT (id) DO UPDATE') const [id, email, first_name, last_name, opt_for_newsletters] = values await unwrap(usersUpsertMock({ id, email, first_name, last_name, enable_notifications: true, opt_for_newsletters })) return { rows: [] } } if (text.includes('DELETE FROM public.users')) { await unwrap(usersDeleteMock(values[0])) return { rows: [] } } throw new Error(`unexpected query ${text}`) }, }), closeClient: () => {}, })) const { app } = await import('../supabase/functions/_backend/auth/register.ts') function postRegister(body: Record) { return app.request('/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) } const validBody = { email: 'new-user@example.com', password: 'Password123!', first_name: 'Jane', last_name: 'Doe', captcha_token: 'captcha-token', } describe('pOST /auth/register unit', () => { beforeEach(() => { vi.clearAllMocks() }) it.each([ { label: 'names supplied', names: {} }, { label: 'both names omitted', names: { first_name: undefined, last_name: undefined } }, ])('requires captcha when CAPTCHA_SECRET_KEY is configured ($label)', async ({ names }) => { const response = await postRegister({ ...validBody, ...names, captcha_token: undefined, }) expect(response.status).toBe(422) expect(await response.json()).toMatchObject({ error: 'captcha_failed' }) expect(rpcMock).not.toHaveBeenCalled() expect(signUpMock).not.toHaveBeenCalled() }) it('forwards captcha_token to GoTrue signUp without local siteverify', async () => { const response = await postRegister(validBody) expect(response.status).toBe(200) expect(signUpMock).toHaveBeenCalledWith({ email: validBody.email, password: validBody.password, options: { captchaToken: validBody.captcha_token, data: { first_name: validBody.first_name, last_name: validBody.last_name, }, }, }) }) it('stores offer=direct in server-only app metadata before creating the profile', async () => { const response = await postRegister({ ...validBody, offer: 'direct' }) expect(response.status).toBe(200) expect(updateUserByIdMock).toHaveBeenCalledWith('11111111-1111-1111-1111-111111111111', { app_metadata: { offer: 'direct' } }) expect(updateUserByIdMock.mock.invocationCallOrder[0]).toBeLessThan(usersUpsertMock.mock.invocationCallOrder[0]) const signUpOptions = (signUpMock.mock.calls[0] as unknown as [{ options: { data: Record } }])[0].options expect(signUpOptions.data).not.toHaveProperty('offer') }) it('ignores any other offer value', async () => { const response = await postRegister({ ...validBody, offer: 'Direct' }) expect(response.status).toBe(200) expect(updateUserByIdMock).not.toHaveBeenCalled() }) it('rolls back the signup when the direct offer cannot be stored', async () => { updateUserByIdMock.mockResolvedValueOnce({ error: new Error('auth unavailable') }) const response = await postRegister({ ...validBody, offer: 'direct' }) expect(response.status).toBe(500) expect(await response.json()).toMatchObject({ error: 'registration_failed' }) expect(usersUpsertMock).not.toHaveBeenCalled() expect(deleteUserMock).toHaveBeenCalledWith('11111111-1111-1111-1111-111111111111') }) it.each([ { label: 'both names omitted', firstName: undefined, lastName: undefined }, { label: 'first name omitted', firstName: undefined, lastName: 'Doe' }, { label: 'last name omitted', firstName: 'Jane', lastName: undefined }, ])('creates a profile with $label', async ({ firstName, lastName }) => { const response = await postRegister({ ...validBody, first_name: firstName, last_name: lastName, }) expect(response.status).toBe(200) expect(await response.json()).toEqual({ user: { id: '11111111-1111-1111-1111-111111111111' }, session: { access_token: 'access-token', refresh_token: 'refresh-token' }, }) expect(signUpMock).toHaveBeenCalledWith({ email: validBody.email, password: validBody.password, options: { captchaToken: validBody.captcha_token, data: { ...(firstName ? { first_name: firstName } : {}), ...(lastName ? { last_name: lastName } : {}), }, }, }) expect(usersUpsertMock).toHaveBeenCalledWith({ id: '11111111-1111-1111-1111-111111111111', email: validBody.email, first_name: firstName ?? '', last_name: lastName ?? '', enable_notifications: true, opt_for_newsletters: false, }) }) it.each([ { first_name: '' }, { first_name: ' ' }, { first_name: null }, { first_name: 123 }, { last_name: '' }, { last_name: ' ' }, { last_name: null }, { last_name: false }, ])('rejects invalid supplied names: %j', async (names) => { const response = await postRegister({ ...validBody, ...names }) expect(response.status).toBe(400) expect(await response.json()).toMatchObject({ error: 'invalid_request' }) expect(rpcMock).not.toHaveBeenCalled() expect(signUpMock).not.toHaveBeenCalled() expect(usersUpsertMock).not.toHaveBeenCalled() }) it.each(['password', 'Password!', 'Password123', 'PASSWORD123!', ''])('still rejects weak passwords without names: %j', async (password) => { const response = await postRegister({ ...validBody, first_name: undefined, last_name: undefined, password, }) expect(response.status).toBe(400) expect(await response.json()).toMatchObject({ error: 'invalid_request' }) expect(rpcMock).not.toHaveBeenCalled() expect(signUpMock).not.toHaveBeenCalled() }) it.each([true, false])('preserves signup attribution and explicit newsletter preference %s without forcing an onboarding version', async (newsletter) => { const attribution = { ref: 'readme-example', signup_source: 'plugin_example', signup_plugin: 'example', first_touch_type: 'plugin', first_touch_path: '/plugins/example/', utm_source: 'example-source', utm_medium: 'readme', utm_campaign: 'example-campaign', } const response = await postRegister({ ...validBody, ...attribution, first_name: undefined, last_name: undefined, opt_for_newsletters: newsletter, }) expect(response.status).toBe(200) expect(signUpMock).toHaveBeenCalledWith({ email: validBody.email, password: validBody.password, options: { captchaToken: validBody.captcha_token, data: attribution }, }) expect(usersUpsertMock).toHaveBeenCalledWith({ id: '11111111-1111-1111-1111-111111111111', email: validBody.email, first_name: '', last_name: '', enable_notifications: true, opt_for_newsletters: newsletter, }) }) it('rejects non-boolean newsletter preferences before signup', async () => { const response = await postRegister({ ...validBody, opt_for_newsletters: 'true' }) expect(response.status).toBe(400) expect(signUpMock).not.toHaveBeenCalled() }) it('returns captcha_failed when GoTrue rejects the captcha token', async () => { signUpMock.mockResolvedValueOnce({ data: { user: null, session: null }, error: { code: 'captcha_failed', message: 'Captcha verification failed' }, } as unknown as Awaited>) const response = await postRegister(validBody) expect(response.status).toBe(422) const body = await response.json() as { error: string } expect(body.error).toBe('captcha_failed') }) it('returns weak_password with the GoTrue reason instead of a generic 500', async () => { signUpMock.mockResolvedValueOnce({ data: { user: null, session: null }, error: { code: 'weak_password', message: 'Password is too weak', status: 422 }, } as unknown as Awaited>) const response = await postRegister(validBody) expect(response.status).toBe(422) const body = await response.json() as { error: string, message: string } expect(body.error).toBe('weak_password') expect(body.message).toBe('Password is too weak') }) it('tells the user to pick another password when GoTrue finds it in a breach list', async () => { signUpMock.mockResolvedValueOnce({ data: { user: null, session: null }, error: { code: 'weak_password', message: 'Password is known to be weak and easy to guess, please choose a different one.', status: 422, reasons: ['pwned'] }, } as unknown as Awaited>) const response = await postRegister(validBody) expect(response.status).toBe(422) const body = await response.json() as { error: string, message: string } expect(body.error).toBe('weak_password') expect(body.message).toBe('This password appeared in a data breach. Choose a different one.') }) it('returns 429 when GoTrue reports a rate limit', async () => { signUpMock.mockResolvedValueOnce({ data: { user: null, session: null }, error: { code: 'over_request_rate_limit', message: 'Request rate limit reached', status: 429 }, } as unknown as Awaited>) const response = await postRegister(validBody) expect(response.status).toBe(429) const body = await response.json() as { error: string } expect(body.error).toBe('too_many_requests') }) it('does not rollback when signup succeeded but email confirmation is pending', async () => { signUpMock.mockResolvedValueOnce({ data: { user: { id: '22222222-2222-2222-2222-222222222222', email_confirmed_at: undefined }, session: null, }, error: null, } as unknown as Awaited>) const response = await postRegister(validBody) expect(response.status).toBe(500) expect(usersDeleteMock).not.toHaveBeenCalled() expect(deleteUserMock).not.toHaveBeenCalled() }) it('returns account_deleted when is_not_deleted is false', async () => { rpcMock.mockResolvedValueOnce({ data: false, error: null }) const response = await postRegister(validBody) expect(response.status).toBe(403) const body = await response.json() as { error: string } expect(body.error).toBe('account_deleted') expect(signUpMock).not.toHaveBeenCalled() }) })