fix(providers): correct Bokio v1 connection validation (#1681)
Fixes #1670
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ? {
|
||||
|
||||
Reference in New Issue
Block a user