* 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>
149 lines
5.3 KiB
TypeScript
149 lines
5.3 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { createQueuedMockSupabase } from '@/tests/helpers'
|
|
|
|
const resolveBrandByHostMock = vi.fn()
|
|
vi.mock('@/lib/branding/resolve', () => ({
|
|
resolveBrandByHost: (...args: unknown[]) => resolveBrandByHostMock(...args),
|
|
}))
|
|
|
|
const resolveBrandsForTeamsMock = vi.fn()
|
|
vi.mock('@/lib/branding/team-brands', () => ({
|
|
resolveBrandsForTeams: (...args: unknown[]) => resolveBrandsForTeamsMock(...args),
|
|
}))
|
|
|
|
import { resolveLandingDestination } from '../landing-server'
|
|
|
|
const { supabase, enqueue, reset, findCall } = createQueuedMockSupabase()
|
|
const client = supabase as unknown as SupabaseClient
|
|
|
|
const byraMembership = { team_id: 'team-1', role: 'owner', teams: { kind: 'byra' } }
|
|
|
|
function membership(role: string, teamId = 'team-1') {
|
|
return { team_id: teamId, role, teams: { kind: 'byra' } }
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
reset()
|
|
resolveBrandByHostMock.mockResolvedValue(null)
|
|
resolveBrandsForTeamsMock.mockResolvedValue(new Map())
|
|
})
|
|
|
|
describe('resolveLandingDestination', () => {
|
|
it('returns / for a user with no byrå membership, without resolving brands', async () => {
|
|
enqueue({ data: [] })
|
|
|
|
const dest = await resolveLandingDestination(client, 'user-1', 'app.accounted.se')
|
|
|
|
expect(dest).toBe('/')
|
|
expect(resolveBrandsForTeamsMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns /clients for byrå staff on their own brand host', async () => {
|
|
resolveBrandByHostMock.mockResolvedValue({ teamId: 'team-1' })
|
|
resolveBrandsForTeamsMock.mockResolvedValue(
|
|
new Map([['team-1', { domain: 'app.amnas.se', appName: 'Amnas' }]]),
|
|
)
|
|
enqueue({ data: [byraMembership] })
|
|
|
|
const dest = await resolveLandingDestination(client, 'user-1', 'app.amnas.se')
|
|
|
|
expect(dest).toBe('/clients')
|
|
expect(resolveBrandByHostMock).toHaveBeenCalledWith('app.amnas.se')
|
|
})
|
|
|
|
it('returns / for byrå staff on a foreign brand host', async () => {
|
|
resolveBrandByHostMock.mockResolvedValue({ teamId: 'team-other' })
|
|
resolveBrandsForTeamsMock.mockResolvedValue(
|
|
new Map([['team-1', { domain: 'app.amnas.se', appName: 'Amnas' }]]),
|
|
)
|
|
enqueue({ data: [byraMembership] })
|
|
|
|
const dest = await resolveLandingDestination(client, 'user-1', 'app.ziffr.se')
|
|
|
|
expect(dest).toBe('/')
|
|
})
|
|
|
|
it('returns /clients for a brandless byrå on the canonical host (WL-01)', async () => {
|
|
resolveBrandByHostMock.mockResolvedValue(null)
|
|
resolveBrandsForTeamsMock.mockResolvedValue(new Map())
|
|
enqueue({ data: [byraMembership] })
|
|
|
|
const dest = await resolveLandingDestination(client, 'user-1', 'app.accounted.se')
|
|
|
|
expect(dest).toBe('/clients')
|
|
})
|
|
|
|
it('returns / for a branded byrå landing on the canonical host', async () => {
|
|
resolveBrandByHostMock.mockResolvedValue(null)
|
|
resolveBrandsForTeamsMock.mockResolvedValue(
|
|
new Map([['team-1', { domain: 'app.amnas.se', appName: 'Amnas' }]]),
|
|
)
|
|
enqueue({ data: [byraMembership] })
|
|
|
|
const dest = await resolveLandingDestination(client, 'user-1', 'app.accounted.se')
|
|
|
|
expect(dest).toBe('/')
|
|
})
|
|
|
|
it('skips the host-brand lookup when host is empty', async () => {
|
|
enqueue({ data: [] })
|
|
|
|
const dest = await resolveLandingDestination(client, 'user-1', '')
|
|
|
|
expect(dest).toBe('/')
|
|
expect(resolveBrandByHostMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('degrades to / when the membership query errors', async () => {
|
|
enqueue({ data: null, error: { message: 'boom' } })
|
|
|
|
const dest = await resolveLandingDestination(client, 'user-1', 'app.amnas.se')
|
|
|
|
expect(dest).toBe('/')
|
|
})
|
|
|
|
// Role gate (2026-08-27): only owner/admin get the automatic cockpit
|
|
// landing. Ported from the pre-extraction route tests (PR #1970).
|
|
it('byrå owner on the canonical host lands in the cockpit', async () => {
|
|
enqueue({ data: [membership('owner')] })
|
|
|
|
const dest = await resolveLandingDestination(client, 'user-1', 'app.accounted.se')
|
|
|
|
expect(dest).toBe('/clients')
|
|
// The mock returns fixtures regardless of the select string, so pin the
|
|
// role column into the query: dropping it would send every owner to '/'
|
|
// while these tests stayed green.
|
|
expect(findCall('team_members', 'select')?.[0]).toContain('role')
|
|
})
|
|
|
|
it('byrå admin on the canonical host lands in the cockpit', async () => {
|
|
enqueue({ data: [membership('admin')] })
|
|
|
|
const dest = await resolveLandingDestination(client, 'user-1', 'app.accounted.se')
|
|
|
|
expect(dest).toBe('/clients')
|
|
})
|
|
|
|
it('plain byrå member lands on / like a regular user (role gate)', async () => {
|
|
enqueue({ data: [membership('member')] })
|
|
|
|
const dest = await resolveLandingDestination(client, 'user-1', 'app.accounted.se')
|
|
|
|
expect(dest).toBe('/')
|
|
// No qualifying teams: the brand lookup must not run.
|
|
expect(resolveBrandsForTeamsMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('mixed roles: an admin membership still wins the cockpit landing', async () => {
|
|
enqueue({ data: [membership('member', 'byra-1'), membership('admin', 'byra-2')] })
|
|
|
|
const dest = await resolveLandingDestination(client, 'user-1', 'app.accounted.se')
|
|
|
|
expect(dest).toBe('/clients')
|
|
// Only the qualifying team reaches the brand lookup.
|
|
expect(resolveBrandsForTeamsMock).toHaveBeenCalledWith(['byra-2'])
|
|
})
|
|
})
|