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:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
41a5728ca7
commit
473b1fd2eb
@@ -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})`,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user