fix(import): refuse a Bokio connection that opens a different company (#1315)
A Bokio integration token is scoped to one Bokio company and the company id is typed in by hand, so credentials for the user's other company imported that company's customers, suppliers and invoices with no error at all. Probe /companies/{id} before storing, mirroring the Bjorn Lunden /details probe, and refuse on a confident org-number mismatch.
Also surface the inbox mail body when nothing was attached: it was captured in email_body_text and never read back, which made Gmail's forwarding-confirmation mail unreadable and the forward impossible to complete.
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
resolveConsent,
|
||||
fetchCompanyInfoDirect,
|
||||
ProviderTokenInvalidError,
|
||||
ProviderCompanyMismatchError,
|
||||
ConsentNotFoundError,
|
||||
} from './lib/provider-client'
|
||||
import { providerSupportsSie, fetchProviderSieFiles, getAllowedFiscalYears } from './lib/sie-fetcher'
|
||||
@@ -437,6 +438,18 @@ export const arcimMigrationExtension: Extension = {
|
||||
details: { provider, reason: error.message },
|
||||
})
|
||||
}
|
||||
// Valid credentials, wrong company: name both org numbers so the user
|
||||
// can see at a glance which company the token actually opened.
|
||||
if (error instanceof ProviderCompanyMismatchError) {
|
||||
return errorResponseFromCode('PROVIDER_COMPANY_MISMATCH', moduleLog, {
|
||||
details: {
|
||||
provider,
|
||||
expectedOrgNumber: error.expectedOrgNumber,
|
||||
actualOrgNumber: error.actualOrgNumber,
|
||||
actualCompanyName: error.actualCompanyName,
|
||||
},
|
||||
})
|
||||
}
|
||||
return errorResponseFromCode('PROVIDER_TOKEN_SUBMIT_FAILED', moduleLog, {
|
||||
details: { reason: error instanceof Error ? error.message : 'unknown' },
|
||||
})
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
|
||||
const { mockBlGet } = vi.hoisted(() => ({ mockBlGet: vi.fn() }))
|
||||
const { mockBlGet, mockBokioGetCompany } = vi.hoisted(() => ({
|
||||
mockBlGet: vi.fn(),
|
||||
mockBokioGetCompany: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createServiceClient: vi.fn(),
|
||||
@@ -29,11 +32,25 @@ vi.mock('@/lib/providers/bjornlunden/client', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 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 }
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { BjornLundenApiError } from '@/lib/providers/bjornlunden/client'
|
||||
import { BokioApiError } from '@/lib/providers/bokio/client'
|
||||
import {
|
||||
submitProviderToken,
|
||||
ProviderTokenInvalidError,
|
||||
ProviderCompanyMismatchError,
|
||||
ConsentNotFoundError,
|
||||
} from '../provider-client'
|
||||
|
||||
@@ -65,12 +82,133 @@ describe('submitProviderToken', () => {
|
||||
|
||||
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',
|
||||
orgNumber: '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', 'provider_consent_tokens'])
|
||||
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',
|
||||
orgNumber: '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',
|
||||
orgNumber: '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',
|
||||
orgNumber: '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 invalid credentials', 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)
|
||||
|
||||
expect(tablesTouched()).not.toContain('provider_consent_tokens')
|
||||
})
|
||||
|
||||
it('maps a 401 from the Bokio probe to invalid credentials', async () => {
|
||||
mock.enqueue({ data: [{ id: 'consent-1' }] })
|
||||
mockBokioGetCompany.mockRejectedValueOnce(new BokioApiError('Bokio API error: 401', 401))
|
||||
|
||||
await expect(
|
||||
submitProviderToken('consent-1', 'bokio', 'tok', 'bokio-guid', 'company-A'),
|
||||
).rejects.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 ────────────────────────
|
||||
|
||||
@@ -17,11 +17,16 @@ 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 { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
|
||||
import type { ConsentRecord, OtcResponse } from '../types'
|
||||
|
||||
// Singleton (holds the rate limiter): used to validate BL User-Keys at submit
|
||||
const bjornLundenClient = new BjornLundenClient()
|
||||
|
||||
// Singleton (holds the rate limiter): used to verify Bokio company identity
|
||||
const bokioClient = new BokioClient()
|
||||
|
||||
/**
|
||||
* Thrown by submitProviderToken when the provider actively rejects the
|
||||
* submitted credentials (as opposed to a transient failure). The route maps
|
||||
@@ -48,6 +53,30 @@ export class ConsentNotFoundError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when the credentials are valid but open a company whose org number is
|
||||
* not the one being imported into. Reported as PROVIDER_COMPANY_MISMATCH (422).
|
||||
*
|
||||
* The failure this prevents is silent and expensive: a token that works plus a
|
||||
* company id for the wrong company imports a FOREIGN legal entity's customers,
|
||||
* suppliers and invoices into this ledger. Nothing errors, so the first signal
|
||||
* is the user noticing their books are full of a stranger's data. Both org
|
||||
* numbers are carried on the error so the wizard can name the two companies.
|
||||
*/
|
||||
export class ProviderCompanyMismatchError extends Error {
|
||||
constructor(
|
||||
public readonly expectedOrgNumber: string,
|
||||
public readonly actualOrgNumber: string,
|
||||
public readonly actualCompanyName: string | null,
|
||||
) {
|
||||
super(
|
||||
`Provider company mismatch: credentials open ${actualOrgNumber}, ` +
|
||||
`but the target company is ${expectedOrgNumber}`,
|
||||
)
|
||||
this.name = 'ProviderCompanyMismatchError'
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export data fetching functions from the provider layer
|
||||
export { resolveConsent } from '@/lib/providers/resolve-consent'
|
||||
export {
|
||||
@@ -439,6 +468,86 @@ export async function submitProviderToken(
|
||||
}
|
||||
}
|
||||
|
||||
// Bokio: the pasted integration token is scoped to ONE Bokio company, and the
|
||||
// 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.
|
||||
if (provider === 'bokio') {
|
||||
if (!providerCompanyId) {
|
||||
throw new ProviderTokenInvalidError('Bokio requires a company id')
|
||||
}
|
||||
|
||||
let bokioCompany: Record<string, unknown> | null
|
||||
try {
|
||||
bokioCompany = await bokioClient.getCompany<Record<string, unknown>>(
|
||||
accessToken,
|
||||
providerCompanyId,
|
||||
)
|
||||
} 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
|
||||
}
|
||||
throw new ProviderTokenInvalidError(
|
||||
`Bokio rejected the credentials (HTTP ${error.statusCode})`,
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
// 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')
|
||||
}
|
||||
|
||||
const bokioName = typeof bokioCompany['name'] === 'string'
|
||||
? (bokioCompany['name'] as string).trim()
|
||||
: ''
|
||||
const bokioOrgNumber = normalizeOrgNumber(bokioCompany['orgNumber'] as string | undefined)
|
||||
|
||||
const { data: targetCompany } = await supabase
|
||||
.from('companies')
|
||||
.select('org_number')
|
||||
.eq('id', ownerCompanyId)
|
||||
.maybeSingle()
|
||||
const targetOrgNumber = normalizeOrgNumber(targetCompany?.org_number)
|
||||
|
||||
// Only a confident mismatch blocks. A missing org number on either side is
|
||||
// not evidence of anything (Accounted allows companies without one, and a
|
||||
// Bokio response could omit it), so those fall through to the labelling
|
||||
// below: the wizard still shows WHICH Bokio company was linked, which is
|
||||
// what lets the user catch it themselves.
|
||||
if (bokioOrgNumber && targetOrgNumber && bokioOrgNumber !== targetOrgNumber) {
|
||||
throw new ProviderCompanyMismatchError(
|
||||
targetOrgNumber,
|
||||
bokioOrgNumber,
|
||||
bokioName || null,
|
||||
)
|
||||
}
|
||||
|
||||
// Label the consent with what the credentials actually opened. Written as
|
||||
// an object literal (not a conditional spread) so the phantom-column guard
|
||||
// can see which columns this touches. `undefined` is dropped by the JSON
|
||||
// serialisation, so a field Bokio did not return is left alone rather than
|
||||
// overwriting a value the user typed at connect time with null.
|
||||
if (bokioName || bokioOrgNumber) {
|
||||
await supabase
|
||||
.from('provider_consents')
|
||||
.update({
|
||||
company_name: bokioName || undefined,
|
||||
org_number: bokioOrgNumber || undefined,
|
||||
})
|
||||
.eq('id', consentId)
|
||||
}
|
||||
}
|
||||
|
||||
// Store tokens: consent stays at status 0 until migration/SIE import completes
|
||||
await supabase
|
||||
.from('provider_consent_tokens')
|
||||
|
||||
@@ -515,7 +515,8 @@ export const invoiceInboxExtension: Extension = {
|
||||
.select(`
|
||||
id, status, source, created_at, extracted_data,
|
||||
matched_supplier_id, document_id, email_from, email_subject,
|
||||
email_received_at, error_message, created_supplier_invoice_id,
|
||||
email_received_at, email_body_text, error_message,
|
||||
created_supplier_invoice_id,
|
||||
matched_transaction_id, created_journal_entry_id,
|
||||
resend_email_id, extraction_skipped
|
||||
`)
|
||||
|
||||
Reference in New Issue
Block a user