Files
chat/api/server/services/CommerceClient.spec.js
zooqueenandHanzo Dev 09610dc91d chat: ask Commerce for the balance with the header it authorizes on
The credits row was missing for a funded account. The balance is real --
Commerce holds $149,863.17 for the hanzo org -- and chat could never read it:
CommerceClient scoped its service-token call with `X-Hanzo-Org`, which
Commerce does not read on that path. Every call answered 401 "sign in to view
billing", byte-identical to sending no credential at all. The header is
`X-Org-Id`, as `api/billing/me.go` documents and as the LLM gate in
hanzoai/ai has always sent.

Proven against the live service from inside the pod, same token, same URL:
X-Hanzo-Org -> 401, X-Org-Id -> 200 {"available":14986317}.

Nothing caught this because three deliberate fallbacks sit behind it, each
doing exactly what it was written to do: checkBalance fails CLOSED and throws,
the balance controller falls through to the local tokenCredits ledger,
production does not fund that ledger (balance.enabled=false), the read 404s,
and the client renders no balance rather than a wrong one. A wrong header name
came out the far end as silence.

So the header is pinned where it is written, including the spelling that
failed, and the refusal case asserts a THROW rather than a zero -- a 401 must
never read as "this account has no money". Reverting the header turns 3 of the
5 red.

`ask.js` and CloudAgentsClient already sent `X-Org-Id`; this was the one
caller that did not.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-08 05:27:31 -07:00

88 lines
3.3 KiB
JavaScript

jest.mock('@hanzochat/data-schemas', () => ({
logger: { debug: jest.fn(), error: jest.fn(), info: jest.fn(), warn: jest.fn() },
}));
const { CommerceClient } = require('./CommerceClient');
/** Capture the request Commerce would receive. */
function mockFetch({ ok = true, status = 200, body = {} } = {}) {
const calls = [];
const fn = jest.fn(async (url, opts) => {
calls.push({ url, opts });
return { ok, status, text: async () => JSON.stringify(body), json: async () => body };
});
fn.calls = calls;
return fn;
}
const client = () =>
new CommerceClient({ endpoint: 'http://commerce.hanzo.svc:8001', token: 'svc-token' });
/**
* The org header is the whole read.
*
* Commerce authorizes a service-token call by `X-Org-Id` and does not read
* `X-Hanzo-Org` at all, so the wrong spelling answered 401 "sign in to view
* billing" — identically to sending no credential. Nothing caught it because
* two deliberate fallbacks sit behind it: `checkBalance` throws, the balance
* controller falls through to the local ledger, production does not fund that
* ledger, the read 404s, and the client renders no balance rather than a wrong
* one. Every layer did what it was written to do and a funded account showed
* nothing. So the header name is pinned here, at the one place it is written.
*/
describe('CommerceClient', () => {
afterEach(() => {
delete global.fetch;
});
it('scopes the read with X-Org-Id, the header Commerce authorizes on', async () => {
const fetch = mockFetch({ body: { available: 14986317 } });
global.fetch = fetch;
await client().checkBalance('hanzo');
const { url, opts } = fetch.calls[0];
expect(url).toBe(
'http://commerce.hanzo.svc:8001/v1/billing/balance?user=hanzo&currency=usd',
);
expect(opts.headers['X-Org-Id']).toBe('hanzo');
expect(opts.headers.Authorization).toBe('Bearer svc-token');
// The spelling that silently 401'd. Naming it keeps the regression legible.
expect(opts.headers['X-Hanzo-Org']).toBeUndefined();
});
it('sends the org, not the per-user subject, as the scope', async () => {
const fetch = mockFetch({ body: { available: 1 } });
global.fetch = fetch;
// A per-user subject is "owner/name"; the namespace is the owner alone.
await client().checkBalance('hanzo/[email protected]');
expect(fetch.calls[0].opts.headers['X-Org-Id']).toBe('hanzo');
expect(fetch.calls[0].url).toContain('user=hanzo%2Falice%40example.com');
});
it('reports the balance Commerce returns', async () => {
global.fetch = mockFetch({ body: { available: 14986317 } });
await expect(client().checkBalance('hanzo')).resolves.toEqual({
sufficient: true,
available: 14986317,
});
});
it('throws on refusal rather than reporting an empty wallet', async () => {
// Fail CLOSED: a 401 must not read as "this account has no money".
global.fetch = mockFetch({ ok: false, status: 401, body: { error: 'unauthorized' } });
await expect(client().checkBalance('hanzo')).rejects.toThrow('401');
});
it('carries the same scope on the credit breakdown', async () => {
const fetch = mockFetch({ body: { breakdown: {}, total: { cents: 0 } } });
global.fetch = fetch;
await client().getCreditBreakdown('hanzo/[email protected]');
expect(fetch.calls[0].opts.headers['X-Org-Id']).toBe('hanzo');
});
});