* 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>
62 lines
2.1 KiB
TypeScript
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')
|
|
})
|
|
})
|