fix(providers): correct Bokio v1 connection validation (#1681)

Fixes #1670
This commit is contained in:
Mattsson
2026-08-18 23:00:44 +02:00
committed by GitHub
parent 9d59e509ab
commit 3ec76d39db
15 changed files with 459 additions and 67 deletions
@@ -44,6 +44,7 @@ describe('structured-errors registry', () => {
expect(codes.length).toBeGreaterThan(20)
expect(codes).toContain('JOURNAL_ENTRY_NOT_BALANCED')
expect(codes).toContain('PROVIDER_AUTH_EXPIRED')
expect(codes).toContain('BOKIO_COMPANY_NOT_FOUND')
expect(codes).toContain('CANNOT_EDIT_NON_DRAFT')
expect(codes).toContain('MANDATORY_DIMENSION_MISSING')
// Node network system codes registered as retryable transients (#337).
+11 -4
View File
@@ -1916,8 +1916,8 @@ const PROVIDER_MIGRATION: Record<string, StructuredErrorEntry> = {
},
PROVIDER_TOKEN_SUBMIT_FAILED: {
httpStatus: 500,
message_sv: 'Tokensubmissionen misslyckades.',
message_en: 'Failed to submit provider token.',
message_sv: 'Kunde inte kontrollera integrationsuppgifterna hos leverantören. Försök igen.',
message_en: 'Could not verify the integration details with the provider. Try again.',
},
PROVIDER_TOKEN_INVALID: {
// 422 (not 401): the UPSTREAM provider rejected the pasted credentials.
@@ -1926,9 +1926,16 @@ const PROVIDER_MIGRATION: Record<string, StructuredErrorEntry> = {
// error code, never on the HTTP status.
httpStatus: 422,
message_sv:
'Leverantören avvisade uppgifterna. Kontrollera att konto-ID och applikationstoken stämmer och försök igen.',
'Leverantören avvisade autentiseringen. Kontrollera integrationsuppgifterna och försök igen.',
message_en:
'The provider rejected the credentials. Check that the account ID and application token are correct and try again.',
'The provider rejected the authentication. Check the integration details and try again.',
},
BOKIO_COMPANY_NOT_FOUND: {
httpStatus: 422,
message_sv:
'Bokio hittade inte företaget. Kontrollera företags-ID:t och att integrationstoken skapades för samma företag.',
message_en:
'Bokio could not find the company. Check the company ID and that the integration token was created for the same company.',
},
PROVIDER_COMPANY_MISMATCH: {
// 422, same reasoning as PROVIDER_TOKEN_INVALID: the credentials are valid,
@@ -0,0 +1,113 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
BokioApiError,
BokioClient,
BokioResponseError,
normalizeBokioAccessToken,
} from '../client';
import { BOKIO_BASE_URL } from '../config';
const COMPANY_ID = '9b408943-7a1e-47ac-85a7-ac52b2c210d3';
describe('BokioClient', () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.stubGlobal('fetch', vi.fn());
});
it('targets the official Bokio API v1 base URL', () => {
expect(BOKIO_BASE_URL).toBe('https://api.bokio.se/v1');
});
it('uses the documented v1 company-information path and unwraps its response', async () => {
vi.mocked(fetch).mockResolvedValueOnce(
Response.json({
companyInformation: {
id: COMPANY_ID,
name: 'Testbolaget AB',
organizationNumber: '556677-8899',
},
}),
);
const result = await new BokioClient().getCompany<Record<string, unknown>>(
'integration-token',
COMPANY_ID,
);
expect(result).toMatchObject({
id: COMPANY_ID,
organizationNumber: '556677-8899',
});
expect(fetch).toHaveBeenCalledWith(
`${BOKIO_BASE_URL}/companies/${COMPANY_ID}/company-information`,
expect.objectContaining({
headers: {
Accept: 'application/json',
Authorization: 'Bearer integration-token',
},
}),
);
});
it('normalizes a pasted Bearer header and surrounding whitespace once', async () => {
vi.mocked(fetch).mockResolvedValueOnce(
Response.json({ companyInformation: { id: COMPANY_ID } }),
);
await new BokioClient().getCompany(' bEaReR copied-token==\r\n', ` ${COMPANY_ID} `);
const [, init] = vi.mocked(fetch).mock.calls[0]!;
expect((init?.headers as Record<string, string>).Authorization).toBe(
'Bearer copied-token==',
);
});
it('returns null for a company-information 404', async () => {
vi.mocked(fetch).mockResolvedValueOnce(
new Response('', { status: 404, statusText: 'Not Found' }),
);
await expect(
new BokioClient().getCompany('integration-token', COMPANY_ID),
).resolves.toBeNull();
});
it.each([400, 401, 403])(
'preserves a company-information HTTP %i as a Bokio API error',
async (statusCode) => {
vi.mocked(fetch).mockResolvedValueOnce(
new Response('', { status: statusCode, statusText: 'Request failed' }),
);
const error = await new BokioClient()
.getCompany('integration-token', COMPANY_ID)
.catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(BokioApiError);
expect((error as BokioApiError).statusCode).toBe(statusCode);
},
);
it('keeps an invalid response envelope distinct from a company 404', async () => {
vi.mocked(fetch).mockResolvedValueOnce(Response.json({ name: 'Unexpected shape' }));
await expect(
new BokioClient().getCompany('integration-token', COMPANY_ID),
).rejects.toBeInstanceOf(BokioResponseError);
});
});
describe('normalizeBokioAccessToken', () => {
it.each([
[' raw-token ', 'raw-token'],
['Bearer copied-token', 'copied-token'],
[' bearer\tsecondary-token\n', 'secondary-token'],
])('normalizes %j', (input, expected) => {
expect(normalizeBokioAccessToken(input)).toBe(expected);
});
it('does not remove internal token characters', () => {
expect(normalizeBokioAccessToken('token with spaces')).toBe('token with spaces');
});
});
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { mapBokioToCompanyInformation } from '../mapper';
describe('mapBokioToCompanyInformation', () => {
it('maps the documented company-information v1 fields', () => {
const result = mapBokioToCompanyInformation({
id: '9b408943-7a1e-47ac-85a7-ac52b2c210d3',
name: 'Testbolaget AB',
organizationNumber: '556677-8899',
companyType: 'limitedCompany',
address: {
line1: 'Testgatan 1',
city: 'Göteborg',
postalCode: '123 45',
country: 'SE',
},
});
expect(result).toMatchObject({
companyName: 'Testbolaget AB',
organizationNumber: '556677-8899',
legalEntity: {
registrationName: 'Testbolaget AB',
companyId: '556677-8899',
},
address: {
streetName: 'Testgatan 1',
cityName: 'Göteborg',
postalZone: '123 45',
countryCode: 'SE',
},
});
});
});
+35 -3
View File
@@ -19,6 +19,26 @@ export class BokioApiError extends Error {
}
}
export class BokioResponseError extends Error {
constructor(message: string) {
super(message);
this.name = 'BokioResponseError';
}
}
/**
* Accept either the integration token itself or a copied Authorization value.
* Only surrounding whitespace and one explicit Bearer scheme are removed:
* internal token characters are left untouched.
*/
export function normalizeBokioAccessToken(accessToken: string): string {
return accessToken.trim().replace(/^Bearer\s+/i, '').trim();
}
function bokioAuthorizationHeader(accessToken: string): string {
return `Bearer ${normalizeBokioAccessToken(accessToken)}`;
}
function isRetryableError(error: unknown): boolean {
if (isTimeoutError(error)) return true;
if (error instanceof BokioApiError) {
@@ -54,7 +74,7 @@ export class BokioClient {
const url = `${this.baseUrl}${path}`;
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
Authorization: bokioAuthorizationHeader(accessToken),
Accept: 'application/json',
},
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
@@ -188,7 +208,7 @@ export class BokioClient {
await this.rateLimiter.acquire();
const url = `${this.baseUrl}/companies/${companyId}${relativePath}`;
const response = await fetch(url, {
headers: { Authorization: `Bearer ${accessToken}` },
headers: { Authorization: bokioAuthorizationHeader(accessToken) },
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
@@ -219,7 +239,19 @@ export class BokioClient {
companyId: string,
): Promise<T | null> {
try {
return await this.get<T>(accessToken, `/companies/${companyId}`);
const normalizedCompanyId = companyId.trim();
const response = await this.get<{ companyInformation?: T }>(
accessToken,
`/companies/${encodeURIComponent(normalizedCompanyId)}/company-information`,
);
if (response.companyInformation == null) {
throw new BokioResponseError(
'Bokio company-information response is missing companyInformation',
);
}
return response.companyInformation;
} catch (err) {
if (err instanceof BokioApiError && err.statusCode === 404) {
return null;
+2 -2
View File
@@ -57,8 +57,8 @@ export const BOKIO_RESOURCE_CONFIGS: Partial<Record<ResourceType, BokioResourceC
paginated: false,
},
[ResourceType.CompanyInformation]: {
listEndpoint: '',
detailEndpoint: '',
listEndpoint: '/company-information',
detailEndpoint: '/company-information',
idField: 'id',
mapper: mapBokioToCompanyInformation,
singleton: true,
+3 -3
View File
@@ -309,7 +309,7 @@ export function mapBokioToSupplierInvoice(raw: Record<string, unknown>): Supplie
* Map Bokio Company to CompanyInformationDto.
*
* Bokio Company fields:
* - id, name, orgNumber, vatNumber, currency, country
* - id, name, organizationNumber, companyType
* - address: { line1, line2, city, postalCode, country }
*/
export function mapBokioToCompanyInformation(raw: Record<string, unknown>): CompanyInformationDto {
@@ -317,10 +317,10 @@ export function mapBokioToCompanyInformation(raw: Record<string, unknown>): Comp
return {
companyName: (raw['name'] as string) ?? '',
organizationNumber: raw['orgNumber'] as string | undefined,
organizationNumber: raw['organizationNumber'] as string | undefined,
legalEntity: {
registrationName: (raw['name'] as string) ?? '',
companyId: raw['orgNumber'] as string | undefined,
companyId: raw['organizationNumber'] as string | undefined,
companyIdSchemeId: 'SE:ORGNR',
},
address: address ? {