fix(providers): accept Bokio's flat company-information body (live API differs from spec) (#1735)
* fix(providers): accept Bokio's flat company-information body
Bokio's published v1 spec wraps GET /companies/{id}/company-information in
`{ companyInformation }`, but the live api.bokio.se/v1 returns the company
object flat (`{ id, name, organizationNumber, companyType, address, ... }`).
#1681 moved the connection probe to the spec shape, so a valid integration
token now gets a 200 from Bokio and then fails here with BokioResponseError,
surfaced to the user as "Kunde inte kontrollera integrationsuppgifterna hos
leverantören. Försök igen." (seen in prod on 2026-08-20).
getCompany now unwraps the envelope when present and otherwise accepts the
flat company object; only a body that is neither (empty object, array, null
envelope, paged list) still raises BokioResponseError. Tests cover both
shapes and the rejected ones.
Refs #1670, follow-up to #1681.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(providers): reject malformed Bokio companyInformation envelopes
When the documented envelope key is present, the company must be inside it
and carry an identifying field; `{ companyInformation: {} }` or an envelope
without id/name/organizationNumber now raises BokioResponseError instead of
passing through, and outer fields are never used as a fallback in that case.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1104,3 +1104,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-19] New-customer payment terms fall back to company_settings.invoice_default_days (then 30) in every create path (UI, internal API, v1, bulk, MCP staged op) via lib/customers/resolveDefaultPaymentTerms, rather than adding a separate customer-terms setting: one setting, one meaning, and the invoice flow already reads the same column.
|
||||
|
||||
[2026-08-19] The CI build OOM is the TYPE-CHECK pass, not bundle growth: measured with tsc --extendedDiagnostics the repo needs ~4.19 GB at 506d030b and ~4.19 GB on a branch on top of it, i.e. a steady-state ceiling against Node 20 default old-space (~4 GB), not any one PR's regression. Fixed on main independently by raising the build heap to 8192, which this branch keeps; recording the measurement so the next person does not go hunting in a diff. Vercel builds already run with a larger heap and were never affected.
|
||||
[2026-08-20] Bokio getCompany accepts both the spec envelope and the live flat body: the published v1 spec (bokio/bokio-api company-api.yaml) wraps company-information in `companyInformation`, but api.bokio.se/v1 returned the company object flat on a 200 in prod (BokioResponseError in logs, customer script showed the same). Tolerating both instead of picking one means a spec/live drift in either direction can no longer turn a valid integration token into a connection failure.
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
BokioClient,
|
||||
BokioResponseError,
|
||||
normalizeBokioAccessToken,
|
||||
unwrapBokioCompanyInformation,
|
||||
} from '../client';
|
||||
import { BOKIO_BASE_URL } from '../config';
|
||||
|
||||
@@ -89,8 +90,42 @@ describe('BokioClient', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps an invalid response envelope distinct from a company 404', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(Response.json({ name: 'Unexpected shape' }));
|
||||
it('accepts the flat company object the live v1 API returns (no envelope)', async () => {
|
||||
// Observed on api.bokio.se/v1 in production 2026-08-20: a 200 whose body
|
||||
// is the company itself, not `{ companyInformation }` as the spec says.
|
||||
vi.mocked(fetch).mockResolvedValueOnce(
|
||||
Response.json({
|
||||
id: COMPANY_ID,
|
||||
name: 'Testbolaget AB',
|
||||
companyType: 'limitedCompany',
|
||||
organizationNumber: '5566778899',
|
||||
email: 'ekonomi@example.se',
|
||||
hasBBA: false,
|
||||
address: { line1: 'Testgatan 1', city: 'STOCKHOLM', postalCode: '111 23', country: 'SE' },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await new BokioClient().getCompany<Record<string, unknown>>(
|
||||
'integration-token',
|
||||
COMPANY_ID,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
id: COMPANY_ID,
|
||||
name: 'Testbolaget AB',
|
||||
organizationNumber: '5566778899',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['empty object', {}],
|
||||
['null envelope', { companyInformation: null }],
|
||||
['empty envelope', { companyInformation: {} }],
|
||||
['envelope without identifying fields', { companyInformation: { foo: 'bar' } }],
|
||||
['array body', []],
|
||||
['paged list body', { items: [], totalItems: 0, totalPages: 0, currentPage: 1 }],
|
||||
])('keeps an unusable response body (%s) distinct from a company 404', async (_label, body) => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(Response.json(body));
|
||||
|
||||
await expect(
|
||||
new BokioClient().getCompany('integration-token', COMPANY_ID),
|
||||
@@ -98,6 +133,45 @@ describe('BokioClient', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('unwrapBokioCompanyInformation', () => {
|
||||
it('prefers the documented envelope when present', () => {
|
||||
expect(
|
||||
unwrapBokioCompanyInformation({ companyInformation: { id: COMPANY_ID }, id: 'outer' }),
|
||||
).toEqual({ id: COMPANY_ID });
|
||||
});
|
||||
|
||||
it('falls back to a flat company object', () => {
|
||||
expect(unwrapBokioCompanyInformation({ id: COMPANY_ID, name: 'Testbolaget AB' })).toEqual({
|
||||
id: COMPANY_ID,
|
||||
name: 'Testbolaget AB',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not fall back to outer fields when the envelope is malformed', () => {
|
||||
expect(
|
||||
unwrapBokioCompanyInformation({ companyInformation: { foo: 'bar' }, id: COMPANY_ID }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
unwrapBokioCompanyInformation({ companyInformation: {}, name: 'Outer AB' }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
null,
|
||||
'text',
|
||||
42,
|
||||
[],
|
||||
{},
|
||||
{ companyInformation: 'nope' },
|
||||
{ companyInformation: {} },
|
||||
{ companyInformation: { foo: 'bar' } },
|
||||
{ companyInformation: [{ id: COMPANY_ID }] },
|
||||
{ foo: 'bar' },
|
||||
])('returns null for %j', (body) => {
|
||||
expect(unwrapBokioCompanyInformation(body)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeBokioAccessToken', () => {
|
||||
it.each([
|
||||
[' raw-token ', 'raw-token'],
|
||||
|
||||
@@ -39,6 +39,40 @@ function bokioAuthorizationHeader(accessToken: string): string {
|
||||
return `Bearer ${normalizeBokioAccessToken(accessToken)}`;
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function looksLikeBokioCompany(value: unknown): value is Record<string, unknown> {
|
||||
return (
|
||||
isPlainObject(value) &&
|
||||
(typeof value['id'] === 'string' ||
|
||||
typeof value['name'] === 'string' ||
|
||||
typeof value['organizationNumber'] === 'string')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the company object from a company-information response body.
|
||||
* If the documented `companyInformation` envelope key is present, the company
|
||||
* must be inside it (a malformed envelope is rejected, never bypassed via
|
||||
* outer fields); otherwise the flat object the live API returns is accepted.
|
||||
* Either way the object must carry an identifying field, so `{}` or an
|
||||
* unrelated JSON object is rejected. Returns null when no company is found.
|
||||
*/
|
||||
export function unwrapBokioCompanyInformation(
|
||||
body: unknown,
|
||||
): Record<string, unknown> | null {
|
||||
if (!isPlainObject(body)) return null;
|
||||
|
||||
if ('companyInformation' in body) {
|
||||
const wrapped = body['companyInformation'];
|
||||
return looksLikeBokioCompany(wrapped) ? wrapped : null;
|
||||
}
|
||||
|
||||
return looksLikeBokioCompany(body) ? body : null;
|
||||
}
|
||||
|
||||
function isRetryableError(error: unknown): boolean {
|
||||
if (isTimeoutError(error)) return true;
|
||||
if (error instanceof BokioApiError) {
|
||||
@@ -234,24 +268,34 @@ export class BokioClient {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe one company via the documented v1 company-information endpoint.
|
||||
*
|
||||
* Bokio's published v1 spec wraps the body as `{ companyInformation: {...} }`,
|
||||
* but the live api.bokio.se/v1 returns the company object flat
|
||||
* (`{ id, name, organizationNumber, companyType, address, ... }`). Both are
|
||||
* accepted: a valid token must never be reported as a failure because the
|
||||
* spec and the deployed API disagree on the envelope.
|
||||
*/
|
||||
async getCompany<T>(
|
||||
accessToken: string,
|
||||
companyId: string,
|
||||
): Promise<T | null> {
|
||||
try {
|
||||
const normalizedCompanyId = companyId.trim();
|
||||
const response = await this.get<{ companyInformation?: T }>(
|
||||
const response = await this.get<unknown>(
|
||||
accessToken,
|
||||
`/companies/${encodeURIComponent(normalizedCompanyId)}/company-information`,
|
||||
);
|
||||
|
||||
if (response.companyInformation == null) {
|
||||
const company = unwrapBokioCompanyInformation(response);
|
||||
if (company == null) {
|
||||
throw new BokioResponseError(
|
||||
'Bokio company-information response is missing companyInformation',
|
||||
'Bokio company-information response contains no company object',
|
||||
);
|
||||
}
|
||||
|
||||
return response.companyInformation;
|
||||
return company as T;
|
||||
} catch (err) {
|
||||
if (err instanceof BokioApiError && err.statusCode === 404) {
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user