fix(providers): correct Bokio v1 connection validation (#1681)
Fixes #1670
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { createMockRequest, createMockSupabase, parseJsonResponse } from '@/tests/helpers'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import type { ExtensionContext } from '@/lib/extensions/types'
|
||||
|
||||
vi.mock('../lib/import-documents', () => {
|
||||
@@ -17,29 +18,44 @@ vi.mock('../lib/import-documents', () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../lib/provider-client', () => ({
|
||||
createConsent: vi.fn(),
|
||||
getConsent: vi.fn(),
|
||||
listConsents: vi.fn(),
|
||||
generateOtc: vi.fn(),
|
||||
consumeOAuthState: vi.fn(),
|
||||
getAuthUrl: vi.fn(),
|
||||
exchangeAuthToken: vi.fn(),
|
||||
submitProviderToken: vi.fn(),
|
||||
acceptConsent: vi.fn(),
|
||||
deleteConsent: vi.fn(),
|
||||
resolveConsent: vi.fn(),
|
||||
fetchCompanyInfoDirect: vi.fn(),
|
||||
ProviderTokenInvalidError: class ProviderTokenInvalidError extends Error {},
|
||||
ProviderCompanyMismatchError: class ProviderCompanyMismatchError extends Error {},
|
||||
ConsentNotFoundError: class ConsentNotFoundError extends Error {},
|
||||
}))
|
||||
vi.mock('../lib/provider-client', () => {
|
||||
class ProviderTokenInvalidError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly kind: 'credentials' | 'company-not-found' = 'credentials',
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
createConsent: vi.fn(),
|
||||
getConsent: vi.fn(),
|
||||
listConsents: vi.fn(),
|
||||
generateOtc: vi.fn(),
|
||||
consumeOAuthState: vi.fn(),
|
||||
getAuthUrl: vi.fn(),
|
||||
exchangeAuthToken: vi.fn(),
|
||||
submitProviderToken: vi.fn(),
|
||||
acceptConsent: vi.fn(),
|
||||
deleteConsent: vi.fn(),
|
||||
resolveConsent: vi.fn(),
|
||||
fetchCompanyInfoDirect: vi.fn(),
|
||||
ProviderTokenInvalidError,
|
||||
ProviderCompanyMismatchError: class ProviderCompanyMismatchError extends Error {},
|
||||
ConsentNotFoundError: class ConsentNotFoundError extends Error {},
|
||||
}
|
||||
})
|
||||
|
||||
import { arcimMigrationExtension } from '../index'
|
||||
import {
|
||||
FortnoxDocumentScopesRequiredError,
|
||||
importProviderDocuments,
|
||||
} from '../lib/import-documents'
|
||||
import {
|
||||
ProviderTokenInvalidError,
|
||||
submitProviderToken,
|
||||
} from '../lib/provider-client'
|
||||
|
||||
const route = (arcimMigrationExtension.apiRoutes ?? []).find(
|
||||
(candidate) =>
|
||||
@@ -48,6 +64,10 @@ const route = (arcimMigrationExtension.apiRoutes ?? []).find(
|
||||
|
||||
type RouteHandler = (request: Request, ctx?: ExtensionContext) => Promise<Response>
|
||||
const handler = route.handler as RouteHandler
|
||||
const submitTokenRoute = (arcimMigrationExtension.apiRoutes ?? []).find(
|
||||
(candidate) => candidate.method === 'POST' && candidate.path === '/submit-token',
|
||||
)!
|
||||
const submitTokenHandler = submitTokenRoute.handler as RouteHandler
|
||||
|
||||
function buildContext(): ExtensionContext {
|
||||
const { supabase } = createMockSupabase()
|
||||
@@ -67,9 +87,25 @@ function request(dryRun: boolean) {
|
||||
)
|
||||
}
|
||||
|
||||
function submitTokenRequest() {
|
||||
return createMockRequest(
|
||||
'http://localhost/api/extensions/ext/arcim-migration/submit-token',
|
||||
{
|
||||
method: 'POST',
|
||||
body: {
|
||||
consentId: 'consent-1',
|
||||
provider: 'bokio',
|
||||
apiToken: 'not-a-real-token',
|
||||
companyId: '9b408943-7a1e-47ac-85a7-ac52b2c210d3',
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
describe('POST /import-documents', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
})
|
||||
|
||||
it('passes dry-run discovery through without storing documents', async () => {
|
||||
@@ -118,3 +154,61 @@ describe('POST /import-documents', () => {
|
||||
expect(body.error.message_en).toContain('Reconnect Fortnox')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /submit-token Bokio error mapping', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
})
|
||||
|
||||
it('reports a 401/403 authentication verdict as rejected integration details', async () => {
|
||||
;(submitProviderToken as Mock).mockRejectedValue(
|
||||
new ProviderTokenInvalidError('Bokio rejected the integration token (HTTP 403)'),
|
||||
)
|
||||
|
||||
const response = await submitTokenHandler(submitTokenRequest(), buildContext())
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; message: string; message_en?: string }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(422)
|
||||
expect(body.error.code).toBe('PROVIDER_TOKEN_INVALID')
|
||||
expect(body.error.message).toContain('avvisade autentiseringen')
|
||||
expect(body.error.message_en).toContain('rejected the authentication')
|
||||
})
|
||||
|
||||
it('reports a Bokio 404 as a company-ID failure instead of rejected credentials', async () => {
|
||||
;(submitProviderToken as Mock).mockRejectedValue(
|
||||
new ProviderTokenInvalidError(
|
||||
'Bokio does not know that company id',
|
||||
'company-not-found',
|
||||
),
|
||||
)
|
||||
|
||||
const response = await submitTokenHandler(submitTokenRequest(), buildContext())
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; message: string; message_en?: string }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(422)
|
||||
expect(body.error.code).toBe('BOKIO_COMPANY_NOT_FOUND')
|
||||
expect(body.error.message).toContain('företags-ID')
|
||||
expect(body.error.message_en).toContain('company ID')
|
||||
})
|
||||
|
||||
it('keeps an unclassified provider/configuration failure generic', async () => {
|
||||
;(submitProviderToken as Mock).mockRejectedValue(
|
||||
new Error('Bokio company-information response is missing companyInformation'),
|
||||
)
|
||||
|
||||
const response = await submitTokenHandler(submitTokenRequest(), buildContext())
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; message: string; message_en?: string }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect(body.error.code).toBe('PROVIDER_TOKEN_SUBMIT_FAILED')
|
||||
expect(body.error.message).toContain('kontrollera integrationsuppgifterna')
|
||||
expect(body.error.message_en).toContain('verify the integration details')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -470,9 +470,14 @@ export const arcimMigrationExtension: Extension = {
|
||||
details: { consentId },
|
||||
})
|
||||
}
|
||||
// Wrong credentials (provider actively rejected them): tell the
|
||||
// user to re-check the pasted values instead of a generic 500.
|
||||
// Provider rejected authentication or could not resolve the Bokio
|
||||
// company: return the actionable problem instead of a generic 500.
|
||||
if (error instanceof ProviderTokenInvalidError) {
|
||||
if (error.kind === 'company-not-found') {
|
||||
return errorResponseFromCode('BOKIO_COMPANY_NOT_FOUND', moduleLog, {
|
||||
details: { provider, reason: error.message },
|
||||
})
|
||||
}
|
||||
return errorResponseFromCode('PROVIDER_TOKEN_INVALID', moduleLog, {
|
||||
details: { provider, reason: error.message },
|
||||
})
|
||||
|
||||
@@ -111,7 +111,7 @@ describe('submitProviderToken', () => {
|
||||
mock.enqueue({ data: null }) // token upsert
|
||||
mockBokioGetCompany.mockResolvedValueOnce({
|
||||
name: 'Testbolaget AB',
|
||||
orgNumber: '5560125790',
|
||||
organizationNumber: '5560125790',
|
||||
})
|
||||
|
||||
const result = await submitProviderToken('consent-1', 'bokio', 'tok', 'bokio-guid', 'company-A')
|
||||
@@ -136,7 +136,7 @@ describe('submitProviderToken', () => {
|
||||
mock.enqueue({ data: { org_number: '5560125790' } }) // target company
|
||||
mockBokioGetCompany.mockResolvedValueOnce({
|
||||
name: 'Någon Annans Bolag AB',
|
||||
orgNumber: '5566778899', // a different legal entity
|
||||
organizationNumber: '5566778899', // a different legal entity
|
||||
})
|
||||
|
||||
const err: unknown = await submitProviderToken(
|
||||
@@ -168,7 +168,7 @@ describe('submitProviderToken', () => {
|
||||
// Same company, hyphenated and with the century prefix Bokio may return.
|
||||
mockBokioGetCompany.mockResolvedValueOnce({
|
||||
name: 'Testbolaget AB',
|
||||
orgNumber: '556012-5790',
|
||||
organizationNumber: '556012-5790',
|
||||
})
|
||||
|
||||
await expect(
|
||||
@@ -185,7 +185,7 @@ describe('submitProviderToken', () => {
|
||||
mock.enqueue({ data: null }) // token upsert
|
||||
mockBokioGetCompany.mockResolvedValueOnce({
|
||||
name: 'Testbolaget AB',
|
||||
orgNumber: '5560125790',
|
||||
organizationNumber: '5560125790',
|
||||
})
|
||||
|
||||
await expect(
|
||||
@@ -197,25 +197,82 @@ describe('submitProviderToken', () => {
|
||||
expect(tablesTouched()).toContain('provider_consent_tokens')
|
||||
})
|
||||
|
||||
it('maps a 404 from the Bokio probe to invalid credentials', async () => {
|
||||
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)
|
||||
|
||||
await expect(
|
||||
submitProviderToken('consent-1', 'bokio', 'tok', 'bad-guid', 'company-A'),
|
||||
).rejects.toBeInstanceOf(ProviderTokenInvalidError)
|
||||
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('maps a 401 from the Bokio probe to invalid credentials', async () => {
|
||||
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: 401', 401))
|
||||
mockBokioGetCompany.mockRejectedValueOnce(
|
||||
new BokioApiError(`Bokio API error: ${status}`, status),
|
||||
)
|
||||
|
||||
await expect(
|
||||
submitProviderToken('consent-1', 'bokio', 'tok', 'bokio-guid', 'company-A'),
|
||||
).rejects.toBeInstanceOf(ProviderTokenInvalidError)
|
||||
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 () => {
|
||||
|
||||
@@ -17,7 +17,11 @@ import { refreshBjornLundenToken } from '@/lib/providers/bjornlunden/oauth'
|
||||
import { BjornLundenClient, BjornLundenApiError } from '@/lib/providers/bjornlunden/client'
|
||||
import { exchangeBrioxCode } from '@/lib/providers/briox/oauth'
|
||||
import { BrioxApiError } from '@/lib/providers/briox/client'
|
||||
import { BokioClient, BokioApiError } from '@/lib/providers/bokio/client'
|
||||
import {
|
||||
BokioClient,
|
||||
BokioApiError,
|
||||
normalizeBokioAccessToken,
|
||||
} from '@/lib/providers/bokio/client'
|
||||
import { WintClient, WintApiError } from '@/lib/providers/wint/client'
|
||||
import { loginWint, WintLoginRejectedError } from '@/lib/providers/wint/oauth'
|
||||
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
|
||||
@@ -39,7 +43,10 @@ const wintClient = new WintClient()
|
||||
* the user to re-check what they pasted.
|
||||
*/
|
||||
export class ProviderTokenInvalidError extends Error {
|
||||
constructor(message: string) {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly kind: 'credentials' | 'company-not-found' = 'credentials',
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ProviderTokenInvalidError'
|
||||
}
|
||||
@@ -482,31 +489,41 @@ export async function submitProviderToken(
|
||||
// company GUID is typed in by hand. Nothing upstream ties either to the
|
||||
// Accounted company being imported into, so a token/GUID for the user's other
|
||||
// company imports that company's customers, suppliers and invoices here with
|
||||
// no error at all. Probe /companies/{guid} before storing anything: it both
|
||||
// proves the credentials work and returns the orgNumber to compare.
|
||||
// no error at all. Probe the documented company-information endpoint before
|
||||
// storing anything: it proves the credentials work and returns the
|
||||
// organizationNumber to compare.
|
||||
if (provider === 'bokio') {
|
||||
if (!providerCompanyId) {
|
||||
throw new ProviderTokenInvalidError('Bokio requires a company id')
|
||||
const bokioCompanyId = providerCompanyId?.trim() ?? ''
|
||||
accessToken = normalizeBokioAccessToken(apiToken)
|
||||
|
||||
if (!accessToken) {
|
||||
throw new ProviderTokenInvalidError('Bokio requires an integration token')
|
||||
}
|
||||
if (!bokioCompanyId) {
|
||||
throw new ProviderTokenInvalidError(
|
||||
'Bokio requires a company id',
|
||||
'company-not-found',
|
||||
)
|
||||
}
|
||||
storedProviderCompanyId = bokioCompanyId
|
||||
|
||||
let bokioCompany: Record<string, unknown> | null
|
||||
try {
|
||||
bokioCompany = await bokioClient.getCompany<Record<string, unknown>>(
|
||||
accessToken,
|
||||
providerCompanyId,
|
||||
bokioCompanyId,
|
||||
)
|
||||
} catch (error) {
|
||||
if (error instanceof BokioApiError) {
|
||||
// 429/5xx are transient provider failures, not a verdict on the token:
|
||||
// rethrow so the route reports a generic submit failure rather than
|
||||
// telling the user their credentials are wrong. 401/403/404 mean the
|
||||
// token or the GUID genuinely does not open this company.
|
||||
if (error.statusCode === 429 || error.statusCode >= 500) {
|
||||
throw error
|
||||
// Only 401/403 are authentication verdicts. A 404 from the documented
|
||||
// company-information endpoint means the company id is unknown or is
|
||||
// not available to this company-scoped token. Other statuses can be a
|
||||
// provider/API failure and must not be blamed on the pasted token.
|
||||
if (error.statusCode === 401 || error.statusCode === 403) {
|
||||
throw new ProviderTokenInvalidError(
|
||||
`Bokio rejected the integration token (HTTP ${error.statusCode})`,
|
||||
)
|
||||
}
|
||||
throw new ProviderTokenInvalidError(
|
||||
`Bokio rejected the credentials (HTTP ${error.statusCode})`,
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
@@ -514,13 +531,18 @@ export async function submitProviderToken(
|
||||
// getCompany() maps 404 to null: an unknown GUID is a bad company id, not
|
||||
// an outage.
|
||||
if (!bokioCompany) {
|
||||
throw new ProviderTokenInvalidError('Bokio does not know that company id')
|
||||
throw new ProviderTokenInvalidError(
|
||||
'Bokio does not know that company id',
|
||||
'company-not-found',
|
||||
)
|
||||
}
|
||||
|
||||
const bokioName = typeof bokioCompany['name'] === 'string'
|
||||
? (bokioCompany['name'] as string).trim()
|
||||
: ''
|
||||
const bokioOrgNumber = normalizeOrgNumber(bokioCompany['orgNumber'] as string | undefined)
|
||||
const bokioOrgNumber = normalizeOrgNumber(
|
||||
bokioCompany['organizationNumber'] as string | undefined,
|
||||
)
|
||||
|
||||
const { data: targetCompany } = await supabase
|
||||
.from('companies')
|
||||
|
||||
Reference in New Issue
Block a user