Files
accounted/app/api/clients/landing/__tests__/route.test.ts
T
MattssonandClaude Fable 5 a860c690ed feat(white-label): WL-14 cockpit landing for BankID and OAuth/magic-link logins (#1972)
* feat(white-label): WL-14 cockpit landing for BankID and OAuth/magic-link logins

Byra staff logging in via BankID or the Google/magic-link callback on
their brand domain landed on /select-company resp. / instead of the
cockpit, because those two paths bypassed the WL-14 landing rule.

- Extract the rule into resolveLandingDestination
  (lib/company/landing-server.ts) so server code can call it without an
  HTTP round-trip; /api/clients/landing becomes a thin wrapper.
- Auth callback: with no explicit destination, AAL1 sessions resolve the
  landing from the request host, degrading to / on any failure
  (MFA-enrolled users already get the rule via /mfa/verify).
- BankID login: byra staff on their brand host get /clients; everyone
  else keeps the deliberate /select-company picker byte-identically.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): address PR 1972 review findings

- /api/clients/landing: requireAuth() directly instead of
  withRouteContext, which 4xxed byra staff without a company of their
  own (COMPANY_CONTEXT_MISSING) and silently sent the cockpit's primary
  persona to /select-company. MFA enforcement unchanged.
- landing-server: log the byra membership query error before degrading
  to '/' so a persistent failure is distinguishable from no membership.
- Deduplicate the clientWithTeamMembership test mock to file scope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): paginate the byra membership query

fetchAllRows per repo convention: PostgREST silently caps unpaginated
selects at 1000 rows, which could hide a qualifying owner/admin
membership. Errors still degrade to '/' with a log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 13:37:20 +02:00

62 lines
2.1 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
createQueuedMockSupabase,
createMockRequest,
parseJsonResponse,
} from '@/tests/helpers'
const { supabase, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
const resolveLandingDestinationMock = vi.fn()
vi.mock('@/lib/company/landing-server', () => ({
resolveLandingDestination: (...args: unknown[]) => resolveLandingDestinationMock(...args),
}))
import { GET } from '../route'
beforeEach(() => {
vi.clearAllMocks()
reset()
})
// The landing rule itself (role gate, brand/host matching, WL-01 canonical
// fallback, error degradation) is covered where it lives:
// lib/company/__tests__/landing-server.test.ts. This suite covers only the
// HTTP wrapper contract.
describe('GET /api/clients/landing', () => {
it('returns 401 when unauthenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await GET(createMockRequest('/api/clients/landing'))
expect(res.status).toBe(401)
})
it('returns the helper destination, passing the forwarded host and user', async () => {
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
resolveLandingDestinationMock.mockResolvedValue('/clients')
const res = await GET(
createMockRequest('/api/clients/landing', {
headers: { 'x-forwarded-host': 'app.amnas.se' },
})
)
const { status, body } = await parseJsonResponse<{ data: { destination: string } }>(res)
expect(status).toBe(200)
expect(body.data.destination).toBe('/clients')
// No active-company requirement: byrå staff without a company of their
// own (the cockpit's primary persona) must still get a destination.
expect(resolveLandingDestinationMock).toHaveBeenCalledWith(supabase, 'user-1', 'app.amnas.se')
})
})