Ctrl K
ComunidadGuías…

tests/auth-register.unit.test.ts

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

Archivo original ↗Preguntar a KiBot
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<string, unknown>) {
  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<string, unknown> } }])[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<ReturnType<typeof signUpMock>>)
    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<ReturnType<typeof signUpMock>>)
    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<ReturnType<typeof signUpMock>>)
    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<ReturnType<typeof signUpMock>>)
    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<ReturnType<typeof signUpMock>>)
    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()
  })
})