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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
e6c4fe2cf8
commit
9a7db6bbdf
@@ -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] 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-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,
|
BokioClient,
|
||||||
BokioResponseError,
|
BokioResponseError,
|
||||||
normalizeBokioAccessToken,
|
normalizeBokioAccessToken,
|
||||||
|
unwrapBokioCompanyInformation,
|
||||||
} from '../client';
|
} from '../client';
|
||||||
import { BOKIO_BASE_URL } from '../config';
|
import { BOKIO_BASE_URL } from '../config';
|
||||||
|
|
||||||
@@ -89,8 +90,42 @@ describe('BokioClient', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it('keeps an invalid response envelope distinct from a company 404', async () => {
|
it('accepts the flat company object the live v1 API returns (no envelope)', async () => {
|
||||||
vi.mocked(fetch).mockResolvedValueOnce(Response.json({ name: 'Unexpected shape' }));
|
// 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(
|
await expect(
|
||||||
new BokioClient().getCompany('integration-token', COMPANY_ID),
|
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', () => {
|
describe('normalizeBokioAccessToken', () => {
|
||||||
it.each([
|
it.each([
|
||||||
[' raw-token ', 'raw-token'],
|
[' raw-token ', 'raw-token'],
|
||||||
|
|||||||
@@ -39,6 +39,40 @@ function bokioAuthorizationHeader(accessToken: string): string {
|
|||||||
return `Bearer ${normalizeBokioAccessToken(accessToken)}`;
|
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 {
|
function isRetryableError(error: unknown): boolean {
|
||||||
if (isTimeoutError(error)) return true;
|
if (isTimeoutError(error)) return true;
|
||||||
if (error instanceof BokioApiError) {
|
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>(
|
async getCompany<T>(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
companyId: string,
|
companyId: string,
|
||||||
): Promise<T | null> {
|
): Promise<T | null> {
|
||||||
try {
|
try {
|
||||||
const normalizedCompanyId = companyId.trim();
|
const normalizedCompanyId = companyId.trim();
|
||||||
const response = await this.get<{ companyInformation?: T }>(
|
const response = await this.get<unknown>(
|
||||||
accessToken,
|
accessToken,
|
||||||
`/companies/${encodeURIComponent(normalizedCompanyId)}/company-information`,
|
`/companies/${encodeURIComponent(normalizedCompanyId)}/company-information`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.companyInformation == null) {
|
const company = unwrapBokioCompanyInformation(response);
|
||||||
|
if (company == null) {
|
||||||
throw new BokioResponseError(
|
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) {
|
} catch (err) {
|
||||||
if (err instanceof BokioApiError && err.statusCode === 404) {
|
if (err instanceof BokioApiError && err.statusCode === 404) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user