Files
accounted/lib/company/__tests__/pending-invites.test.ts
T
Jakob WennbergandClaude Fable 5 15300aa8e2 fix(invites): accept invite on BankID signup, recover missed invites on onboarding surfaces (#1157)
An invited user who registered via BankID was funneled into creating a
company instead of joining the one they were invited to: the register
page's BankID path never processed the gnubok-invite-token cookie
(unlike the login, MFA-verify, and auth-callback paths). Observed in
production 2026-07-24.

- register: BankID signup now accepts the pending invite before routing
  to /select-company, mirroring the login page's BankID path.
- lib/company/pending-invites: acceptPendingInviteByToken retries a
  missed acceptance from the cookie (pending + unexpired + email match,
  same rules as POST /api/team/accept); hasPendingInviteForEmail detects
  a stranded invitee whose cookie is gone.
- /onboarding and /select-company retry acceptance from the cookie and
  redirect to the dashboard on success, making the auth callback's
  long-promised fallback real; with no cookie but a pending invitation,
  both surfaces show a 'join via the link in the invitation email' hint
  instead of silently asking the invitee to create a company.
- No new accept path without the token: the hint deliberately points
  back to the mailed link, so mailbox possession stays required and no
  company name is leaked to unverified emails.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 19:36:09 +02:00

108 lines
3.7 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
const { supabase: serviceSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: () => serviceSupabase,
}))
vi.mock('@/lib/auth/invite-tokens', () => ({
hashInviteToken: (t: string) => `hash-${t}`,
}))
import { acceptPendingInviteByToken, hasPendingInviteForEmail } from '../pending-invites'
const user = { id: 'user-1', email: 'invitee@test.se' }
const futureIso = () => new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()
const pastIso = () => new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()
const pendingInvite = (overrides: Record<string, unknown> = {}) => ({
id: 'inv-1',
company_id: 'co-1',
email: 'invitee@test.se',
role: 'admin',
status: 'pending',
expires_at: futureIso(),
...overrides,
})
beforeEach(() => {
vi.clearAllMocks()
reset()
})
describe('acceptPendingInviteByToken', () => {
it('accepts a valid pending invite', async () => {
enqueue({ data: pendingInvite() }) // invitation lookup
enqueue({}) // company_members insert
enqueue({}) // user_preferences upsert
enqueue({}) // invitation status update
await expect(acceptPendingInviteByToken(user, 'tok')).resolves.toBe(true)
})
it('treats an existing membership (23505) as fulfilled', async () => {
enqueue({ data: pendingInvite() })
enqueue({ error: { code: '23505', message: 'duplicate' } })
enqueue({})
enqueue({})
await expect(acceptPendingInviteByToken(user, 'tok')).resolves.toBe(true)
})
it('rejects when the invitation is not found', async () => {
enqueue({ data: null, error: { message: 'not found' } })
await expect(acceptPendingInviteByToken(user, 'tok')).resolves.toBe(false)
})
it('rejects a non-pending invitation', async () => {
enqueue({ data: pendingInvite({ status: 'accepted' }) })
await expect(acceptPendingInviteByToken(user, 'tok')).resolves.toBe(false)
})
it('rejects an expired invitation', async () => {
enqueue({ data: pendingInvite({ expires_at: pastIso() }) })
await expect(acceptPendingInviteByToken(user, 'tok')).resolves.toBe(false)
})
it('rejects when the email does not match', async () => {
enqueue({ data: pendingInvite({ email: 'other@test.se' }) })
await expect(acceptPendingInviteByToken(user, 'tok')).resolves.toBe(false)
})
it('matches emails case-insensitively', async () => {
enqueue({ data: pendingInvite({ email: 'Invitee@Test.se' }) })
enqueue({})
enqueue({})
enqueue({})
await expect(acceptPendingInviteByToken(user, 'tok')).resolves.toBe(true)
})
it('rejects when the user has no email', async () => {
await expect(acceptPendingInviteByToken({ id: 'user-1' }, 'tok')).resolves.toBe(false)
})
it('returns false on a non-duplicate membership insert error', async () => {
enqueue({ data: pendingInvite() })
enqueue({ error: { code: '42501', message: 'denied' } })
await expect(acceptPendingInviteByToken(user, 'tok')).resolves.toBe(false)
})
})
describe('hasPendingInviteForEmail', () => {
it('returns true when a pending invite row exists', async () => {
enqueue({ data: [{ id: 'inv-1' }] })
await expect(hasPendingInviteForEmail('Invitee@Test.se')).resolves.toBe(true)
})
it('returns false when there are no rows', async () => {
enqueue({ data: [] })
await expect(hasPendingInviteForEmail('invitee@test.se')).resolves.toBe(false)
})
it('returns false when the query errors', async () => {
enqueue({ data: null, error: { message: 'boom' } })
await expect(hasPendingInviteForEmail('invitee@test.se')).resolves.toBe(false)
})
})