Files
accounted/app/api/team/accept/__tests__/route.test.ts
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

285 lines
10 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextRequest, NextResponse } from 'next/server'
import { parseJsonResponse, createQueuedMockSupabase } from '@/tests/helpers'
// team/accept uses the service client for all DB work (invite acceptance can
// run before the user has any company membership). requireAuth only gates the
// caller's identity + MFA.
const { supabase: serviceSupabase, enqueue, reset, findCalls } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: () => serviceSupabase,
}))
vi.mock('@/lib/auth/invite-tokens', () => ({
hashInviteToken: (t: string) => `hash-${t}`,
}))
import { GET, POST } from '../route'
const mockUser = { id: 'user-1', email: 'invitee@test.se' }
function makeReq(body: unknown) {
return new Request('http://localhost/api/team/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}) as never
}
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: serviceSupabase, error: null })
})
describe('POST /api/team/accept', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: serviceSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await POST(makeReq({ token: 'abc' }))
const { status } = await parseJsonResponse(res)
expect(status).toBe(401)
})
it('returns 400 when the token is missing', async () => {
const res = await POST(makeReq({}))
const { status, body } = await parseJsonResponse<{ error: string }>(res)
expect(status).toBe(400)
expect(body.error).toContain('Token')
})
it('accepts a valid company invite', async () => {
const future = new Date(Date.now() + 86_400_000).toISOString()
// 1. company_invitations lookup
enqueue({
data: {
id: 'inv-1',
company_id: 'company-1',
email: 'invitee@test.se',
role: 'member',
status: 'pending',
expires_at: future,
},
})
// 2. company_members insert
enqueue({ error: null })
// 3. user_preferences upsert
enqueue({ error: null })
// 4. company_invitations update -> accepted
enqueue({ error: null })
const res = await POST(makeReq({ token: 'abc' }))
const { status, body } = await parseJsonResponse<{ data: { type: string; companyId: string } }>(res)
expect(status).toBe(200)
expect(body.data).toEqual({ type: 'company', companyId: 'company-1' })
})
it('returns 403 when the invite email does not match the user', async () => {
const future = new Date(Date.now() + 86_400_000).toISOString()
enqueue({
data: {
id: 'inv-1',
company_id: 'company-1',
email: 'someone-else@test.se',
role: 'member',
status: 'pending',
expires_at: future,
},
})
const res = await POST(makeReq({ token: 'abc' }))
const { status, body } = await parseJsonResponse<{ error: string }>(res)
expect(status).toBe(403)
expect(body.error).toContain('matchar inte')
})
})
// WL-08 invite unfreeze: the same token endpoint accepts byrå-team
// invitations. from() consumption order for the team path:
// 1. company_invitations lookup (miss)
// 2. team_invitations lookup
// 3. team_members insert
// 4. user_preferences read (active company?)
// 5. companies read (first team company) [only when no active company]
// 6. user_preferences upsert [only when a company was found]
// 7. team_invitations update -> accepted
describe('POST /api/team/accept (byrå-team invitations)', () => {
const future = new Date(Date.now() + 86_400_000).toISOString()
const teamInvite = (overrides: Record<string, unknown> = {}) => ({
id: 'tinv-1',
team_id: 'team-byra',
email: 'invitee@test.se',
role: 'member',
status: 'pending',
expires_at: future,
teams: { name: 'Siffran AB', kind: 'byra' },
...overrides,
})
it('accepts a valid byrå invite and points a company-less user at a team company', async () => {
enqueue({ data: null, error: { code: 'PGRST116' } }) // no company invite
enqueue({ data: teamInvite() })
enqueue({ error: null }) // team_members insert
enqueue({ data: null }) // user_preferences read: no active company
enqueue({ data: { id: 'company-9' } }) // first team company
enqueue({ error: null }) // user_preferences upsert
enqueue({ error: null }) // invitation -> accepted
const res = await POST(makeReq({ token: 'abc' }))
const { status, body } = await parseJsonResponse<{
data: { type: string; teamId: string; teamName: string }
}>(res)
expect(status).toBe(200)
expect(body.data).toEqual({ type: 'team', teamId: 'team-byra', teamName: 'Siffran AB' })
// The membership row is what triggers the DB-side company sync.
const inserts = findCalls('team_members', 'insert')
expect(inserts).toHaveLength(1)
expect(inserts[0]![0]).toEqual({
team_id: 'team-byra',
user_id: 'user-1',
role: 'member',
})
expect(findCalls('user_preferences', 'upsert')).toHaveLength(1)
})
it('never hijacks an existing active company', async () => {
enqueue({ data: null, error: { code: 'PGRST116' } })
enqueue({ data: teamInvite() })
enqueue({ error: null }) // team_members insert
enqueue({ data: { active_company_id: 'my-own-firma' } }) // has active company
enqueue({ error: null }) // invitation -> accepted
const res = await POST(makeReq({ token: 'abc' }))
const { status } = await parseJsonResponse(res)
expect(status).toBe(200)
expect(findCalls('user_preferences', 'upsert')).toHaveLength(0)
})
it('never mints a team owner from an invitation row', async () => {
enqueue({ data: null, error: { code: 'PGRST116' } })
enqueue({ data: teamInvite({ role: 'owner' }) }) // hand-edited row
enqueue({ error: null })
enqueue({ data: { active_company_id: 'my-own-firma' } })
enqueue({ error: null })
const res = await POST(makeReq({ token: 'abc' }))
expect(res.status).toBe(200)
const inserts = findCalls('team_members', 'insert')
expect((inserts[0]![0] as { role: string }).role).toBe('member')
})
it('rejects a personal-team invitation token as invalid (kind gate)', async () => {
enqueue({ data: null, error: { code: 'PGRST116' } })
enqueue({ data: teamInvite({ teams: { name: 'Personal', kind: 'personal' } }) })
const res = await POST(makeReq({ token: 'abc' }))
const { status, body } = await parseJsonResponse<{ error: string }>(res)
expect(status).toBe(400)
expect(body.error).toBe('Inbjudan är ogiltig.')
})
it('returns 403 when the team invite email does not match the user', async () => {
enqueue({ data: null, error: { code: 'PGRST116' } })
enqueue({ data: teamInvite({ email: 'someone-else@test.se' }) })
const res = await POST(makeReq({ token: 'abc' }))
const { status } = await parseJsonResponse(res)
expect(status).toBe(403)
})
it('returns 410 and marks an expired team invite', async () => {
const past = new Date(Date.now() - 86_400_000).toISOString()
enqueue({ data: null, error: { code: 'PGRST116' } })
enqueue({ data: teamInvite({ expires_at: past }) })
enqueue({ error: null }) // invitation -> expired
const res = await POST(makeReq({ token: 'abc' }))
const { status } = await parseJsonResponse(res)
expect(status).toBe(410)
const updates = findCalls('team_invitations', 'update')
expect(updates[0]![0]).toEqual({ status: 'expired' })
})
it('returns 409 when already a team member', async () => {
enqueue({ data: null, error: { code: 'PGRST116' } })
enqueue({ data: teamInvite() })
enqueue({ error: { code: '23505' } }) // duplicate membership
const res = await POST(makeReq({ token: 'abc' }))
const { status, body } = await parseJsonResponse<{ error: string }>(res)
expect(status).toBe(409)
expect(body.error).toBe('Du är redan medlem.')
})
})
describe('GET /api/team/accept (byrå-team invitations)', () => {
function makeGetReq(token: string) {
return new NextRequest(`http://localhost/api/team/accept?token=${token}`)
}
it('returns team invite info with companyName doubling as the team name', async () => {
const future = new Date(Date.now() + 86_400_000).toISOString()
enqueue({ data: null, error: { code: 'PGRST116' } }) // no company invite
enqueue({
data: {
id: 'tinv-1',
team_id: 'team-byra',
email: 'invitee@test.se',
role: 'member',
status: 'pending',
expires_at: future,
teams: { name: 'Siffran AB', kind: 'byra' },
},
})
enqueue({ data: true }) // rpc check_email_exists
const res = await GET(makeGetReq('abc'))
const { status, body } = await parseJsonResponse<{
data: {
type: string
companyName: string
teamName: string
email: string
expired: boolean
alreadyHasAccount: boolean
}
}>(res)
expect(status).toBe(200)
expect(body.data.type).toBe('team')
expect(body.data.teamName).toBe('Siffran AB')
// The invite page renders companyName as "what you are joining".
expect(body.data.companyName).toBe('Siffran AB')
expect(body.data.expired).toBe(false)
expect(body.data.alreadyHasAccount).toBe(true)
})
it('treats a personal-team invitation token as not found', async () => {
enqueue({ data: null, error: { code: 'PGRST116' } })
enqueue({
data: {
id: 'tinv-1',
team_id: 'team-p',
email: 'invitee@test.se',
role: 'member',
status: 'pending',
expires_at: new Date(Date.now() + 86_400_000).toISOString(),
teams: { name: 'Personal', kind: 'personal' },
},
})
const res = await GET(makeGetReq('abc'))
const { status } = await parseJsonResponse(res)
expect(status).toBe(404)
})
})