483 lines
18 KiB
TypeScript
483 lines
18 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { createQueuedMockSupabase } from '@/tests/helpers'
|
|
|
|
const { mockBlGet, mockBokioGetCompany, mockWintGet, mockLoginWint } = vi.hoisted(() => ({
|
|
mockBlGet: vi.fn(),
|
|
mockBokioGetCompany: vi.fn(),
|
|
mockWintGet: vi.fn(),
|
|
mockLoginWint: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('@/lib/supabase/server', () => ({
|
|
createServiceClient: vi.fn(),
|
|
createClient: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('@/lib/providers/bjornlunden/oauth', () => ({
|
|
refreshBjornLundenToken: vi.fn().mockResolvedValue({
|
|
access_token: 'bl-app-token',
|
|
token_type: 'Bearer',
|
|
expires_in: 3600,
|
|
}),
|
|
}))
|
|
|
|
// Keep the real BjornLundenApiError (instanceof checks in provider-client)
|
|
// but replace the client so the /details probe is controllable per test.
|
|
vi.mock('@/lib/providers/bjornlunden/client', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@/lib/providers/bjornlunden/client')>()
|
|
return {
|
|
...actual,
|
|
// Must be a `function` (not an arrow) so `new BjornLundenClient()` works.
|
|
BjornLundenClient: vi.fn().mockImplementation(function mockClient() {
|
|
return { get: mockBlGet }
|
|
}),
|
|
}
|
|
})
|
|
|
|
// Same shape as the BL mock above: keep the real BokioApiError for the
|
|
// instanceof checks, swap the client so the /companies probe is controllable.
|
|
vi.mock('@/lib/providers/bokio/client', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@/lib/providers/bokio/client')>()
|
|
return {
|
|
...actual,
|
|
BokioClient: vi.fn().mockImplementation(function mockClient() {
|
|
return { getCompany: mockBokioGetCompany }
|
|
}),
|
|
}
|
|
})
|
|
|
|
// WINT: keep the real WintApiError / WintLoginRejectedError classes (the
|
|
// instanceof branches in provider-client depend on them), swap the network.
|
|
vi.mock('@/lib/providers/wint/client', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@/lib/providers/wint/client')>()
|
|
return {
|
|
...actual,
|
|
WintClient: vi.fn().mockImplementation(function mockClient() {
|
|
return { get: mockWintGet }
|
|
}),
|
|
}
|
|
})
|
|
|
|
vi.mock('@/lib/providers/wint/oauth', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@/lib/providers/wint/oauth')>()
|
|
return {
|
|
...actual,
|
|
loginWint: mockLoginWint,
|
|
}
|
|
})
|
|
|
|
import { createServiceClient } from '@/lib/supabase/server'
|
|
import { BjornLundenApiError } from '@/lib/providers/bjornlunden/client'
|
|
import { BokioApiError } from '@/lib/providers/bokio/client'
|
|
import { WintApiError } from '@/lib/providers/wint/client'
|
|
import { WintLoginRejectedError } from '@/lib/providers/wint/oauth'
|
|
import {
|
|
submitProviderToken,
|
|
ProviderTokenInvalidError,
|
|
ProviderCompanyMismatchError,
|
|
ConsentNotFoundError,
|
|
} from '../provider-client'
|
|
|
|
describe('submitProviderToken', () => {
|
|
let mock: ReturnType<typeof createQueuedMockSupabase>
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mock = createQueuedMockSupabase()
|
|
vi.mocked(createServiceClient).mockReturnValue(mock.supabase as never)
|
|
})
|
|
|
|
const tablesTouched = () => vi.mocked(mock.supabase.from).mock.calls.map((c) => c[0])
|
|
|
|
// ── Consent ownership (IDOR guard) ────────────────────────────────
|
|
|
|
it('throws ConsentNotFoundError and writes NOTHING when the consent belongs to another company', async () => {
|
|
// Ownership check finds no row for (consentId, ownerCompanyId): the same
|
|
// result whether the consent does not exist or belongs to another tenant.
|
|
mock.enqueue({ data: [] })
|
|
|
|
await expect(
|
|
submitProviderToken('consent-other-tenant', 'bokio', 'tok', 'bokio-guid', 'company-A'),
|
|
).rejects.toBeInstanceOf(ConsentNotFoundError)
|
|
|
|
// Only the ownership read happened: no token upsert, no consent update.
|
|
expect(tablesTouched()).toEqual(['provider_consents'])
|
|
})
|
|
|
|
it('stores tokens when the consent belongs to the caller company', async () => {
|
|
mock.enqueue({ data: [{ id: 'consent-1' }] }) // ownership check
|
|
mock.enqueue({ data: { org_number: '5560125790' } }) // target company lookup
|
|
mock.enqueue({ data: null }) // consent label update
|
|
mock.enqueue({ data: null }) // token upsert
|
|
mockBokioGetCompany.mockResolvedValueOnce({
|
|
name: 'Testbolaget AB',
|
|
organizationNumber: '5560125790',
|
|
})
|
|
|
|
const result = await submitProviderToken('consent-1', 'bokio', 'tok', 'bokio-guid', 'company-A')
|
|
|
|
expect(result).toEqual({ success: true, consentId: 'consent-1' })
|
|
expect(tablesTouched()).toEqual([
|
|
'provider_consents',
|
|
'companies',
|
|
'provider_consents',
|
|
'provider_consent_tokens',
|
|
])
|
|
})
|
|
|
|
// ── Bokio company-identity guard ──────────────────────────────────
|
|
//
|
|
// The failure being prevented: a valid Bokio token plus a company id for the
|
|
// WRONG company imports a foreign legal entity's customers, suppliers and
|
|
// invoices into this ledger with no error at all.
|
|
|
|
it('refuses to store the token when the Bokio company org number differs from the target company', async () => {
|
|
mock.enqueue({ data: [{ id: 'consent-1' }] }) // ownership check
|
|
mock.enqueue({ data: { org_number: '5560125790' } }) // target company
|
|
mockBokioGetCompany.mockResolvedValueOnce({
|
|
name: 'Någon Annans Bolag AB',
|
|
organizationNumber: '5566778899', // a different legal entity
|
|
})
|
|
|
|
const err: unknown = await submitProviderToken(
|
|
'consent-1',
|
|
'bokio',
|
|
'tok',
|
|
'bokio-guid',
|
|
'company-A',
|
|
).catch((e: unknown) => e)
|
|
|
|
expect(err).toBeInstanceOf(ProviderCompanyMismatchError)
|
|
expect(err).toMatchObject({
|
|
expectedOrgNumber: '5560125790',
|
|
actualOrgNumber: '5566778899',
|
|
actualCompanyName: 'Någon Annans Bolag AB',
|
|
})
|
|
|
|
// Nothing was stored and the consent was NOT labelled: the connection must
|
|
// not exist in any form, or the next step would import from it.
|
|
expect(tablesTouched()).not.toContain('provider_consent_tokens')
|
|
expect(tablesTouched()).toEqual(['provider_consents', 'companies'])
|
|
})
|
|
|
|
it('compares org numbers canonically, so formatting differences are not a mismatch', async () => {
|
|
mock.enqueue({ data: [{ id: 'consent-1' }] })
|
|
mock.enqueue({ data: { org_number: '5560125790' } }) // stored 10-digit
|
|
mock.enqueue({ data: null }) // consent label update
|
|
mock.enqueue({ data: null }) // token upsert
|
|
// Same company, hyphenated and with the century prefix Bokio may return.
|
|
mockBokioGetCompany.mockResolvedValueOnce({
|
|
name: 'Testbolaget AB',
|
|
organizationNumber: '556012-5790',
|
|
})
|
|
|
|
await expect(
|
|
submitProviderToken('consent-1', 'bokio', 'tok', 'bokio-guid', 'company-A'),
|
|
).resolves.toEqual({ success: true, consentId: 'consent-1' })
|
|
|
|
expect(tablesTouched()).toContain('provider_consent_tokens')
|
|
})
|
|
|
|
it('still connects when an org number is missing on either side (absence is not evidence of mismatch)', async () => {
|
|
mock.enqueue({ data: [{ id: 'consent-1' }] })
|
|
mock.enqueue({ data: { org_number: null } }) // company has no org number
|
|
mock.enqueue({ data: null }) // consent label update
|
|
mock.enqueue({ data: null }) // token upsert
|
|
mockBokioGetCompany.mockResolvedValueOnce({
|
|
name: 'Testbolaget AB',
|
|
organizationNumber: '5560125790',
|
|
})
|
|
|
|
await expect(
|
|
submitProviderToken('consent-1', 'bokio', 'tok', 'bokio-guid', 'company-A'),
|
|
).resolves.toEqual({ success: true, consentId: 'consent-1' })
|
|
|
|
// Labelled anyway: the wizard showing WHICH company was linked is what
|
|
// lets the user catch the mistake themselves in this case.
|
|
expect(tablesTouched()).toContain('provider_consent_tokens')
|
|
})
|
|
|
|
it('maps a 404 from the Bokio probe to a company-specific failure', async () => {
|
|
mock.enqueue({ data: [{ id: 'consent-1' }] })
|
|
// getCompany() maps 404 to null: an unknown GUID, not an outage.
|
|
mockBokioGetCompany.mockResolvedValueOnce(null)
|
|
|
|
const err: unknown = await submitProviderToken(
|
|
'consent-1',
|
|
'bokio',
|
|
'tok',
|
|
'bad-guid',
|
|
'company-A',
|
|
).catch((e: unknown) => e)
|
|
|
|
expect(err).toBeInstanceOf(ProviderTokenInvalidError)
|
|
expect(err).toMatchObject({ kind: 'company-not-found' })
|
|
|
|
expect(tablesTouched()).not.toContain('provider_consent_tokens')
|
|
})
|
|
|
|
it.each([401, 403])('maps a %s from the Bokio probe to invalid credentials', async (status) => {
|
|
mock.enqueue({ data: [{ id: 'consent-1' }] })
|
|
mockBokioGetCompany.mockRejectedValueOnce(
|
|
new BokioApiError(`Bokio API error: ${status}`, status),
|
|
)
|
|
|
|
const err: unknown = await submitProviderToken(
|
|
'consent-1',
|
|
'bokio',
|
|
'tok',
|
|
'bokio-guid',
|
|
'company-A',
|
|
).catch((e: unknown) => e)
|
|
|
|
expect(err).toBeInstanceOf(ProviderTokenInvalidError)
|
|
expect(err).toMatchObject({ kind: 'credentials' })
|
|
})
|
|
|
|
it('trims the token and company id and removes a pasted Bearer prefix before probing or storing', async () => {
|
|
mock.enqueue({ data: [{ id: 'consent-1' }] })
|
|
mock.enqueue({ data: { org_number: '5560125790' } })
|
|
mock.enqueue({ data: null })
|
|
mock.enqueue({ data: null })
|
|
mockBokioGetCompany.mockResolvedValueOnce({
|
|
name: 'Testbolaget AB',
|
|
organizationNumber: '5560125790',
|
|
})
|
|
|
|
await submitProviderToken(
|
|
'consent-1',
|
|
'bokio',
|
|
' Bearer copied-token==\r\n',
|
|
' bokio-guid ',
|
|
'company-A',
|
|
)
|
|
|
|
expect(mockBokioGetCompany).toHaveBeenCalledWith('copied-token==', 'bokio-guid')
|
|
expect(mock.findCall('provider_consent_tokens', 'upsert')?.[0]).toMatchObject({
|
|
access_token: 'copied-token==',
|
|
provider_company_id: 'bokio-guid',
|
|
})
|
|
})
|
|
|
|
it('does NOT map another Bokio 4xx to invalid credentials', async () => {
|
|
mock.enqueue({ data: [{ id: 'consent-1' }] })
|
|
mockBokioGetCompany.mockRejectedValueOnce(new BokioApiError('Bokio API error: 400', 400))
|
|
|
|
const err: unknown = await submitProviderToken(
|
|
'consent-1',
|
|
'bokio',
|
|
'tok',
|
|
'bokio-guid',
|
|
'company-A',
|
|
).catch((e: unknown) => e)
|
|
|
|
expect(err).toBeInstanceOf(BokioApiError)
|
|
expect(err).not.toBeInstanceOf(ProviderTokenInvalidError)
|
|
})
|
|
|
|
it('does NOT map a transient 503 from the Bokio probe to invalid credentials', async () => {
|
|
mock.enqueue({ data: [{ id: 'consent-1' }] })
|
|
mockBokioGetCompany.mockRejectedValueOnce(new BokioApiError('Bokio API error: 503', 503))
|
|
|
|
const err: unknown = await submitProviderToken(
|
|
'consent-1',
|
|
'bokio',
|
|
'tok',
|
|
'bokio-guid',
|
|
'company-A',
|
|
).catch((e: unknown) => e)
|
|
|
|
expect(err).toBeInstanceOf(BokioApiError)
|
|
expect(err).not.toBeInstanceOf(ProviderTokenInvalidError)
|
|
expect(tablesTouched()).not.toContain('provider_consent_tokens')
|
|
})
|
|
|
|
// ── BL /details probe error classification ────────────────────────
|
|
|
|
it('does NOT map a 429 from the BL probe to ProviderTokenInvalidError', async () => {
|
|
mock.enqueue({ data: [{ id: 'consent-1' }] }) // ownership check
|
|
mockBlGet.mockRejectedValueOnce(new BjornLundenApiError('Björn Lunden API error: 429', 429))
|
|
|
|
const err: unknown = await submitProviderToken(
|
|
'consent-1',
|
|
'bjornlunden',
|
|
'client_credentials',
|
|
'user-key-guid',
|
|
'company-A',
|
|
).catch((e: unknown) => e)
|
|
|
|
expect(err).toBeInstanceOf(BjornLundenApiError)
|
|
expect(err).not.toBeInstanceOf(ProviderTokenInvalidError)
|
|
// The transient failure must not store the unverified key either.
|
|
expect(tablesTouched()).not.toContain('provider_consent_tokens')
|
|
})
|
|
|
|
it('does NOT map gateway-style 5xx (503) from the BL probe to ProviderTokenInvalidError', async () => {
|
|
mock.enqueue({ data: [{ id: 'consent-1' }] })
|
|
mockBlGet.mockRejectedValueOnce(new BjornLundenApiError('Björn Lunden API error: 503', 503))
|
|
|
|
const err: unknown = await submitProviderToken(
|
|
'consent-1',
|
|
'bjornlunden',
|
|
'client_credentials',
|
|
'user-key-guid',
|
|
'company-A',
|
|
).catch((e: unknown) => e)
|
|
|
|
expect(err).toBeInstanceOf(BjornLundenApiError)
|
|
expect(err).not.toBeInstanceOf(ProviderTokenInvalidError)
|
|
})
|
|
|
|
it('maps 500 from the BL probe to invalid credentials (sandbox-verified bad-key signal) and disables probe retries', async () => {
|
|
mock.enqueue({ data: [{ id: 'consent-1' }] })
|
|
mockBlGet.mockRejectedValueOnce(new BjornLundenApiError('Björn Lunden API error: 500', 500))
|
|
|
|
await expect(
|
|
submitProviderToken('consent-1', 'bjornlunden', 'client_credentials', 'user-key-guid', 'company-A'),
|
|
).rejects.toBeInstanceOf(ProviderTokenInvalidError)
|
|
|
|
// The probe must fail fast: a typo'd key answers 500, which the client's
|
|
// retry policy treats as retryable: retry is disabled per call.
|
|
expect(mockBlGet).toHaveBeenCalledTimes(1)
|
|
expect(mockBlGet).toHaveBeenCalledWith('bl-app-token', 'user-key-guid', '/details', {
|
|
retry: false,
|
|
})
|
|
})
|
|
|
|
it('maps 404 from the BL probe to invalid credentials', async () => {
|
|
mock.enqueue({ data: [{ id: 'consent-1' }] })
|
|
mockBlGet.mockRejectedValueOnce(new BjornLundenApiError('Björn Lunden API error: 404', 404))
|
|
|
|
await expect(
|
|
submitProviderToken('consent-1', 'bjornlunden', 'client_credentials', 'user-key-guid', 'company-A'),
|
|
).rejects.toBeInstanceOf(ProviderTokenInvalidError)
|
|
})
|
|
|
|
it('stores BL tokens (and labels the consent) when the probe succeeds', async () => {
|
|
mock.enqueue({ data: [{ id: 'consent-1' }] }) // ownership check
|
|
mock.enqueue({ data: null }) // consent company_name update
|
|
mock.enqueue({ data: null }) // token upsert
|
|
mockBlGet.mockResolvedValueOnce({ name: 'Testbolaget AB' })
|
|
|
|
const result = await submitProviderToken(
|
|
'consent-1',
|
|
'bjornlunden',
|
|
'client_credentials',
|
|
'user-key-guid',
|
|
'company-A',
|
|
)
|
|
|
|
expect(result).toEqual({ success: true, consentId: 'consent-1' })
|
|
expect(tablesTouched()).toEqual([
|
|
'provider_consents',
|
|
'provider_consents',
|
|
'provider_consent_tokens',
|
|
])
|
|
})
|
|
|
|
// ── WINT login exchange ───────────────────────────────────────────
|
|
//
|
|
// WINT has no API keys: the wizard sends mail (as providerCompanyId) +
|
|
// password (as apiToken). The password is exchanged for a token pair and
|
|
// must never reach the database.
|
|
|
|
describe('wint', () => {
|
|
const wintCompany = {
|
|
Id: 4711,
|
|
Name: 'Wintbolaget AB',
|
|
Org: '556012-5790',
|
|
}
|
|
|
|
const loginOk = () =>
|
|
mockLoginWint.mockResolvedValueOnce({
|
|
access_token: 'wint-jwt',
|
|
refresh_token: 'wint-refresh',
|
|
token_type: 'Bearer',
|
|
expires_in: 900,
|
|
})
|
|
|
|
it('exchanges the login, labels the consent and stores the token pair with the WINT company id', async () => {
|
|
mock.enqueue({ data: [{ id: 'consent-1' }] }) // ownership check
|
|
mock.enqueue({ data: { org_number: '5560125790' } }) // target company
|
|
mock.enqueue({ data: null }) // consent label update
|
|
mock.enqueue({ data: null }) // token upsert
|
|
loginOk()
|
|
mockWintGet.mockResolvedValueOnce(wintCompany)
|
|
|
|
const result = await submitProviderToken('consent-1', 'wint', 'hemligt', 'user@bolag.se', 'company-A')
|
|
|
|
expect(result).toEqual({ success: true, consentId: 'consent-1' })
|
|
expect(mockLoginWint).toHaveBeenCalledWith('user@bolag.se', 'hemligt')
|
|
expect(tablesTouched()).toEqual([
|
|
'provider_consents',
|
|
'companies',
|
|
'provider_consents',
|
|
'provider_consent_tokens',
|
|
])
|
|
// The stored row carries the token pair and the WINT company id; the
|
|
// login mail and password must never reach the database.
|
|
const upsert = mock.findCall('provider_consent_tokens', 'upsert')?.[0] as Record<string, unknown>
|
|
expect(upsert).toMatchObject({
|
|
provider: 'wint',
|
|
access_token: 'wint-jwt',
|
|
refresh_token: 'wint-refresh',
|
|
provider_company_id: '4711',
|
|
})
|
|
expect(JSON.stringify(upsert)).not.toContain('user@bolag.se')
|
|
expect(JSON.stringify(upsert)).not.toContain('hemligt')
|
|
})
|
|
|
|
it('rejects a login WINT refused (WrongUsernameOrPassword) without writing tokens', async () => {
|
|
mock.enqueue({ data: [{ id: 'consent-1' }] }) // ownership check
|
|
mockLoginWint.mockRejectedValueOnce(new WintLoginRejectedError('WrongUsernameOrPassword'))
|
|
|
|
const err: unknown = await submitProviderToken(
|
|
'consent-1', 'wint', 'fel-lösenord', 'user@bolag.se', 'company-A',
|
|
).catch((e: unknown) => e)
|
|
|
|
expect(err).toBeInstanceOf(ProviderTokenInvalidError)
|
|
expect((err as Error).message).toContain('WrongUsernameOrPassword')
|
|
expect(tablesTouched()).toEqual(['provider_consents'])
|
|
})
|
|
|
|
it('requires a login e-mail in the companyId field', async () => {
|
|
mock.enqueue({ data: [{ id: 'consent-1' }] }) // ownership check
|
|
|
|
const err: unknown = await submitProviderToken(
|
|
'consent-1', 'wint', 'lösenord', undefined, 'company-A',
|
|
).catch((e: unknown) => e)
|
|
|
|
expect(err).toBeInstanceOf(ProviderTokenInvalidError)
|
|
expect(mockLoginWint).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('refuses to store tokens when the WINT company org number differs from the target company', async () => {
|
|
mock.enqueue({ data: [{ id: 'consent-1' }] }) // ownership check
|
|
mock.enqueue({ data: { org_number: '5566778899' } }) // a DIFFERENT target company
|
|
loginOk()
|
|
mockWintGet.mockResolvedValueOnce(wintCompany)
|
|
|
|
const err: unknown = await submitProviderToken(
|
|
'consent-1', 'wint', 'hemligt', 'user@bolag.se', 'company-A',
|
|
).catch((e: unknown) => e)
|
|
|
|
expect(err).toBeInstanceOf(ProviderCompanyMismatchError)
|
|
expect((err as ProviderCompanyMismatchError).actualOrgNumber).toBe('5560125790')
|
|
// No token upsert, no consent label write
|
|
expect(tablesTouched()).toEqual(['provider_consents', 'companies'])
|
|
})
|
|
|
|
it('rethrows transient login failures (5xx) as generic errors, not invalid credentials', async () => {
|
|
mock.enqueue({ data: [{ id: 'consent-1' }] }) // ownership check
|
|
mockLoginWint.mockRejectedValueOnce(new WintApiError('WINT login failed: 503', 503))
|
|
|
|
const err: unknown = await submitProviderToken(
|
|
'consent-1', 'wint', 'hemligt', 'user@bolag.se', 'company-A',
|
|
).catch((e: unknown) => e)
|
|
|
|
expect(err).toBeInstanceOf(WintApiError)
|
|
expect(err).not.toBeInstanceOf(ProviderTokenInvalidError)
|
|
})
|
|
})
|
|
})
|