fix(providers): name the real Björn Lundén connect failure (integration not activated, not bad credentials) (#2322)

* fix(providers): name the real Björn Lundén connect failure: integration not activated, not bad credentials

Every Björn Lundén connect in prod has failed with "Leverantören avvisade
autentiseringen" (10 consents since June; only BL's own sandbox company ever
received tokens). Live-verified against a real customer User-Key today: BL
answers 403 "<service>:READ is out of allowed scope for service provider
Arcim" on every read endpoint. The key is right and binds the company; the
company has simply never activated our integration, and it cannot until BL
moves the listing out of sandbox. The generic 403 mapping told the user to
re-check what they pasted, which can never help.

- BjornLundenClient: isBjornLundenScopeError / isBjornLundenUnknownKeyError,
  matching the verbatim live 403 and 500 bodies.
- submitProviderToken: 403-with-scope-body -> ProviderTokenInvalidError kind
  'integration-not-activated'; 500/404 -> 'company-key-not-found'; 401 (our
  own client_credentials token refused) rethrows as a generic submit failure
  instead of blaming the pasted key.
- New 422 structured errors BL_INTEGRATION_NOT_ACTIVATED and
  BL_COMPANY_KEY_NOT_FOUND with Swedish/English copy that names the fix
  (activate under Integrationer in Lundify, else SIE) and where the GUID is.
- Wizard copy for BL moved to i18n keys and reordered: activate first, then
  paste the key; the key only works once the integration is activated.
- Tests: route mapping for both kinds, probe classification incl. the
  captured live bodies, registry entries pinned to 422.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQG9jNyxM7mwMUHWBrFUxY

* fix(providers): drop the unknown-key body matcher, the live BL 500 body is not stable

Verifying through BjornLundenClient against apigateway.blinfo.se, a made-up
User-Key answered 500 with a Spring BeanCreationException for
databaseConnector, not the null getCurrentUser() message captured earlier.
The unknown-key verdict already keys on the status alone in
submitProviderToken; keep only the 403 scope matcher, whose body IS stable.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQG9jNyxM7mwMUHWBrFUxY

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-05 17:20:08 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent 41a5728ca7
commit 473b1fd2eb
12 changed files with 264 additions and 12 deletions
@@ -35,7 +35,11 @@ vi.mock('../lib/provider-client', () => {
class ProviderTokenInvalidError extends Error {
constructor(
message: string,
readonly kind: 'credentials' | 'company-not-found' = 'credentials',
readonly kind:
| 'credentials'
| 'company-not-found'
| 'integration-not-activated'
| 'company-key-not-found' = 'credentials',
) {
super(message)
}
@@ -287,3 +291,49 @@ describe('POST /submit-token Bokio error mapping', () => {
expect(body.error.message_en).toContain('verify the integration details')
})
})
describe('POST /submit-token Björn Lundén error mapping', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
it('reports a valid key whose company never activated the integration as an activation problem, not bad credentials', async () => {
;(submitProviderToken as Mock).mockRejectedValue(
new ProviderTokenInvalidError(
'Björn Lundén: the company behind this User-Key has not activated the integration',
'integration-not-activated',
),
)
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('BL_INTEGRATION_NOT_ACTIVATED')
expect(body.error.message).toContain('Aktivera integrationen')
expect(body.error.message).not.toContain('avvisade autentiseringen')
expect(body.error.message_en).toContain('Activate the integration')
})
it('reports an unknown User-Key as a key problem with the place to copy it from', async () => {
;(submitProviderToken as Mock).mockRejectedValue(
new ProviderTokenInvalidError(
'Björn Lundén found no company for the key (HTTP 500)',
'company-key-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('BL_COMPANY_KEY_NOT_FOUND')
expect(body.error.message).toContain('hittade inget företag')
expect(body.error.message_en).toContain('found no company')
})
})
@@ -580,6 +580,18 @@ export const arcimMigrationExtension: Extension = {
details: { provider, reason: error.message },
})
}
// BL: a valid key whose company never activated the integration
// is not a credentials problem; say what actually unblocks it.
if (error.kind === 'integration-not-activated') {
return errorResponseFromCode('BL_INTEGRATION_NOT_ACTIVATED', moduleLog, {
details: { provider, reason: error.message },
})
}
if (error.kind === 'company-key-not-found') {
return errorResponseFromCode('BL_COMPANY_KEY_NOT_FOUND', moduleLog, {
details: { provider, reason: error.message },
})
}
return errorResponseFromCode('PROVIDER_TOKEN_INVALID', moduleLog, {
details: { provider, reason: error.message },
})
@@ -353,6 +353,82 @@ describe('submitProviderToken', () => {
).rejects.toBeInstanceOf(ProviderTokenInvalidError)
})
it('maps a 403 "out of allowed scope" from the BL probe to integration-not-activated (live-verified body) and stores nothing', async () => {
mock.enqueue({ data: [{ id: 'consent-1' }] })
// Verbatim shape of BL's answer for a real customer key whose company
// never activated the integration (2026-09-05).
mockBlGet.mockRejectedValueOnce(
new BjornLundenApiError(
'Björn Lunden API error: 403 Forbidden',
403,
'{"headers":{},"body":{"status":"FORBIDDEN","message":"Calls to details:READ is out of allowed scope for service provider Arcim "},"statusCode":"FORBIDDEN","statusCodeValue":403}',
),
)
const err: unknown = await submitProviderToken(
'consent-1',
'bjornlunden',
'client_credentials',
'user-key-guid',
'company-A',
).catch((e: unknown) => e)
expect(err).toBeInstanceOf(ProviderTokenInvalidError)
expect((err as ProviderTokenInvalidError).kind).toBe('integration-not-activated')
expect(tablesTouched()).not.toContain('provider_consent_tokens')
})
it('keeps a 403 WITHOUT the scope wording as plain rejected credentials', async () => {
mock.enqueue({ data: [{ id: 'consent-1' }] })
mockBlGet.mockRejectedValueOnce(new BjornLundenApiError('Björn Lunden API error: 403', 403, ''))
const err: unknown = await submitProviderToken(
'consent-1',
'bjornlunden',
'client_credentials',
'user-key-guid',
'company-A',
).catch((e: unknown) => e)
expect(err).toBeInstanceOf(ProviderTokenInvalidError)
expect((err as ProviderTokenInvalidError).kind).toBe('credentials')
})
it('reports 500 (unknown key) and 404 as company-key-not-found, not generic bad credentials', async () => {
for (const status of [500, 404]) {
mock.enqueue({ data: [{ id: 'consent-1' }] })
mockBlGet.mockRejectedValueOnce(new BjornLundenApiError(`Björn Lunden API error: ${status}`, status))
const err: unknown = await submitProviderToken(
'consent-1',
'bjornlunden',
'client_credentials',
'user-key-guid',
'company-A',
).catch((e: unknown) => e)
expect(err).toBeInstanceOf(ProviderTokenInvalidError)
expect((err as ProviderTokenInvalidError).kind).toBe('company-key-not-found')
}
})
it('does NOT blame the pasted key for a 401 (that is our own client_credentials token being refused)', async () => {
mock.enqueue({ data: [{ id: 'consent-1' }] })
mockBlGet.mockRejectedValueOnce(new BjornLundenApiError('Björn Lunden API error: 401', 401))
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)
expect(tablesTouched()).not.toContain('provider_consent_tokens')
})
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
@@ -17,7 +17,11 @@ import {
import { exchangeFortnoxCode } from '@/lib/providers/fortnox/oauth'
import { buildVismaAuthUrl, exchangeVismaCode } from '@/lib/providers/visma/oauth'
import { refreshBjornLundenToken } from '@/lib/providers/bjornlunden/oauth'
import { BjornLundenClient, BjornLundenApiError } from '@/lib/providers/bjornlunden/client'
import {
BjornLundenClient,
BjornLundenApiError,
isBjornLundenScopeError,
} from '@/lib/providers/bjornlunden/client'
import { exchangeBrioxCode } from '@/lib/providers/briox/oauth'
import { BrioxApiError } from '@/lib/providers/briox/client'
import {
@@ -54,7 +58,14 @@ const wintClient = new WintClient()
export class ProviderTokenInvalidError extends Error {
constructor(
message: string,
public readonly kind: 'credentials' | 'company-not-found' = 'credentials',
public readonly kind:
| 'credentials'
| 'company-not-found'
// BL: the User-Key opened a company that has not activated our
// integration (no scopes granted to the service provider).
| 'integration-not-activated'
// BL: no company could be bound to the User-Key at all.
| 'company-key-not-found' = 'credentials',
) {
super(message)
this.name = 'ProviderTokenInvalidError'
@@ -613,15 +624,33 @@ export async function submitProviderToken(
}
} catch (error) {
if (error instanceof BjornLundenApiError) {
// 429 and gateway-style 5xx (502/503/504) are transient provider
// failures, not a verdict on the key: rethrow so the route reports a
// generic submit failure instead of "your key is wrong". 500 stays
// mapped to invalid credentials: per the sandbox finding above, 500
// IS the bad-key signal at BL. Tradeoff: a genuine BL 500 outage also
// reads as a rejected key.
if (error.statusCode === 429 || error.statusCode >= 501) {
// Live-verified 2026-09-05 against a real customer key: a company that
// has NOT activated our integration answers 403 "<service>:READ is out
// of allowed scope for service provider <name>". The key is right and
// the grant is missing, so this must not read as "check what you
// pasted": the fix is activating the integration in Lundify.
if (isBjornLundenScopeError(error)) {
throw new ProviderTokenInvalidError(
'Björn Lundén: the company behind this User-Key has not activated the integration (no scopes granted to the service provider)',
'integration-not-activated',
)
}
// 401 is OUR client_credentials token being refused, never the
// customer's key. 429 and gateway-style 5xx (502/503/504) are
// transient. All three rethrow so the route reports a generic submit
// failure instead of blaming the pasted key.
if (error.statusCode === 401 || error.statusCode === 429 || error.statusCode >= 501) {
throw error
}
// Per the sandbox finding above, 500 IS the unknown-key signal at BL
// (404 is the same verdict from the gateway). Tradeoff: a genuine BL
// 500 outage also reads as an unknown key.
if (error.statusCode === 500 || error.statusCode === 404) {
throw new ProviderTokenInvalidError(
`Björn Lundén found no company for the key (HTTP ${error.statusCode})`,
'company-key-not-found',
)
}
throw new ProviderTokenInvalidError(
`Björn Lundén rejected the company key (HTTP ${error.statusCode})`,
)