Files
zeekayandHanzo Dev ed33bdd420 browser/kms: talk to /v1/kms — the SPA had been lying about the path
This client spoke Infisical under an /api/v3 prefix, and its header comment
explained why: "verified: kms.hanzo.ai/api/v3/secrets/raw -> 401 JSON,
/v1/kms/... -> SPA HTML", so /api/v3 was pinned as the live path and a gateway
rewrite was awaited.

The observation was real and the conclusion was backwards. The KMS binary
embedded a console SPA under a root catch-all and answered EVERY unmatched path
with 200 text/html — the HTML was the 404. So the path that worked looked broken
and the path that did not looked fine. luxfi/kms 1.12.8 removed the catch-all;
the same probe now reads the other way round (/v1/kms/... -> JSON,
/api/v3/... -> JSON 404). There is no rewrite to wait for.

  GET    /v1/kms/orgs/{org}/secrets?path=&env=          -> {"names": []}
  GET    /v1/kms/orgs/{org}/secrets/{path}/{name}?env=  -> {"secret":{"value"}}
  POST   /v1/kms/orgs/{org}/secrets                     {path,name,env,value}
  DELETE /v1/kms/orgs/{org}/secrets/{path}/{name}?env=

Create and update collapse into one kmsPutSecret, because the server has one
upsert rather than two endpoints. Listing is a real call now — the HTTP surface
had no list until luxfi/kms 1.12.9, which is a large part of why the Infisical
prefix looked necessary in the first place.

Params move from Infisical vocabulary (workspaceId/environment/secretPath/
secretName) to the server-s own: KmsRef {org, env, path} and KmsSecretRef adding
name. Only the tests consumed this module, so nothing downstream breaks.

Path segments are escaped one at a time: the server splits {rest...} at its LAST
slash into (path, name), and escaping the joined string would encode the
separators into a single long name. A test pins that, and another asserts no
request may contain /api/ again.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-26 01:07:35 -07:00

185 lines
7.0 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
kmsListSecrets,
kmsGetSecret,
kmsPutSecret,
kmsDeleteSecret,
} from '../src/shared/kms';
// ---------------------------------------------------------------------------
// Mock fetch
// ---------------------------------------------------------------------------
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
function mockResponse(data: any, ok = true, status = 200) {
return {
ok,
status,
json: async () => data,
text: async () => JSON.stringify(data),
};
}
const TOKEN = 'test-bearer-token';
const REF = { org: 'hanzo', env: 'production', path: 'app' };
const SECRET = { ...REF, name: 'API_KEY' };
function calledUrl(): string {
return mockFetch.mock.calls[0][0] as string;
}
function calledInit(): any {
return mockFetch.mock.calls[0][1];
}
beforeEach(() => {
mockFetch.mockReset();
});
// ---------------------------------------------------------------------------
// Path shape — the regression that actually happened
// ---------------------------------------------------------------------------
describe('KMS paths', () => {
it('never emits an /api/ prefix', async () => {
// This client used to speak /api/v3/secrets/raw, because the KMS binary
// embedded a console SPA under a root catch-all and answered every
// unmatched path with 200 text/html — so the wrong path looked alive and
// the right one looked broken. Nothing may drift back to it.
mockFetch.mockResolvedValue(mockResponse({ names: [] }));
await kmsListSecrets(TOKEN, REF);
mockFetch.mockResolvedValue(mockResponse({ secret: { value: 'v' } }));
await kmsGetSecret(TOKEN, SECRET);
mockFetch.mockResolvedValue(mockResponse({}));
await kmsPutSecret(TOKEN, SECRET, 'v');
mockFetch.mockResolvedValue(mockResponse({}));
await kmsDeleteSecret(TOKEN, SECRET);
expect(mockFetch.mock.calls).toHaveLength(4);
for (const [url] of mockFetch.mock.calls) {
expect(url).not.toContain('/api/');
expect(url).toContain('/v1/kms/');
}
});
it('escapes path segments individually so separators survive', async () => {
// The server splits {rest...} at its LAST slash into (path, name). Escaping
// the joined string would encode the separators into one long name.
mockFetch.mockResolvedValue(mockResponse({ secret: { value: 'v' } }));
await kmsGetSecret(TOKEN, { org: 'hanzo', env: 'main', path: 'a/b', name: 'C D' });
expect(calledUrl()).toContain('/v1/kms/orgs/hanzo/secrets/a/b/C%20D');
});
});
// ---------------------------------------------------------------------------
// kmsListSecrets
// ---------------------------------------------------------------------------
describe('kmsListSecrets', () => {
it('returns the names array', async () => {
mockFetch.mockResolvedValue(mockResponse({ names: ['API_KEY', 'DB_URL'] }));
await expect(kmsListSecrets(TOKEN, REF)).resolves.toEqual(['API_KEY', 'DB_URL']);
});
it('addresses the collection with path and env as query, and sends the bearer', async () => {
mockFetch.mockResolvedValue(mockResponse({ names: [] }));
await kmsListSecrets(TOKEN, REF);
expect(calledUrl()).toContain('/v1/kms/orgs/hanzo/secrets?');
expect(calledUrl()).toContain('path=app');
expect(calledUrl()).toContain('env=production');
expect(calledInit().headers.Authorization).toBe(`Bearer ${TOKEN}`);
});
it('defaults env when the ref omits it', async () => {
mockFetch.mockResolvedValue(mockResponse({ names: [] }));
await kmsListSecrets(TOKEN, { org: 'hanzo' });
expect(calledUrl()).toContain('env=default');
});
it('yields an empty array when the server omits names', async () => {
mockFetch.mockResolvedValue(mockResponse({}));
await expect(kmsListSecrets(TOKEN, REF)).resolves.toEqual([]);
});
it('uses a custom baseUrl when provided', async () => {
mockFetch.mockResolvedValue(mockResponse({ names: [] }));
await kmsListSecrets(TOKEN, REF, 'https://custom-kms.example.com');
expect(calledUrl()).toContain('custom-kms.example.com/v1/kms/orgs/hanzo/secrets');
});
it('throws with the status on failure', async () => {
mockFetch.mockResolvedValue(mockResponse('Not found', false, 404));
await expect(kmsListSecrets(TOKEN, REF)).rejects.toThrow('KMS list failed: 404');
});
});
// ---------------------------------------------------------------------------
// kmsGetSecret
// ---------------------------------------------------------------------------
describe('kmsGetSecret', () => {
it('unwraps secret.value', async () => {
mockFetch.mockResolvedValue(mockResponse({ secret: { value: 'postgres://...' } }));
await expect(kmsGetSecret(TOKEN, SECRET)).resolves.toBe('postgres://...');
});
it('addresses the secret and carries env', async () => {
mockFetch.mockResolvedValue(mockResponse({ secret: { value: 'v' } }));
await kmsGetSecret(TOKEN, SECRET);
expect(calledUrl()).toContain('/v1/kms/orgs/hanzo/secrets/app/API_KEY');
expect(calledUrl()).toContain('env=production');
});
it('throws with the status on failure', async () => {
mockFetch.mockResolvedValue(mockResponse('Not found', false, 404));
await expect(kmsGetSecret(TOKEN, SECRET)).rejects.toThrow('KMS get failed: 404');
});
});
// ---------------------------------------------------------------------------
// kmsPutSecret — create and replace are one call, because the server has one
// ---------------------------------------------------------------------------
describe('kmsPutSecret', () => {
it('POSTs to the collection with the flat body the server takes', async () => {
mockFetch.mockResolvedValue(mockResponse({}));
await kmsPutSecret(TOKEN, SECRET, 'sk-live-123');
expect(calledUrl()).toContain('/v1/kms/orgs/hanzo/secrets');
expect(calledUrl()).not.toContain('API_KEY'); // name is in the body, not the URL
expect(calledInit().method).toBe('POST');
expect(JSON.parse(calledInit().body)).toEqual({
path: 'app',
name: 'API_KEY',
env: 'production',
value: 'sk-live-123',
});
});
it('throws with the status on failure', async () => {
mockFetch.mockResolvedValue(mockResponse('Forbidden', false, 403));
await expect(kmsPutSecret(TOKEN, SECRET, 'v')).rejects.toThrow('KMS put failed: 403');
});
});
// ---------------------------------------------------------------------------
// kmsDeleteSecret
// ---------------------------------------------------------------------------
describe('kmsDeleteSecret', () => {
it('DELETEs the secret URL', async () => {
mockFetch.mockResolvedValue(mockResponse({}));
await kmsDeleteSecret(TOKEN, SECRET);
expect(calledInit().method).toBe('DELETE');
expect(calledUrl()).toContain('/v1/kms/orgs/hanzo/secrets/app/API_KEY');
expect(calledUrl()).toContain('env=production');
});
it('throws with the status on failure', async () => {
mockFetch.mockResolvedValue(mockResponse('Forbidden', false, 403));
await expect(kmsDeleteSecret(TOKEN, SECRET)).rejects.toThrow('KMS delete failed: 403');
});
});