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: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,47 +1,49 @@
|
||||
/**
|
||||
* KMS (Key Management / Secrets) client — shared across all browser targets.
|
||||
*
|
||||
* Lightweight fetch-based client for Hanzo KMS (kms.hanzo.ai).
|
||||
* Uses IAM bearer tokens for auth — no separate KMS credentials needed.
|
||||
* Lightweight fetch-based client for Hanzo KMS. Uses IAM bearer tokens for
|
||||
* auth — no separate KMS credentials needed.
|
||||
*
|
||||
* API path. Hanzo convention is `/v1/<service>/<endpoint>` — but the KMS
|
||||
* upstream publishes its v3 API under the root
|
||||
* `${KMS_PATH_PREFIX}/secrets/raw`. The kms.hanzo.ai gateway currently passes that
|
||||
* through unchanged (verified: `kms.hanzo.ai/api/v3/secrets/raw` → 401 JSON,
|
||||
* `/v1/kms/...` → SPA HTML). We centralise the path in `KMS_PATH_PREFIX`
|
||||
* so the day the gateway adds the rewrite we update one constant.
|
||||
* Every path is `/v1/kms/...`, which is the Hanzo `/v1/<service>/<endpoint>`
|
||||
* convention and, more importantly, what the server actually serves:
|
||||
*
|
||||
* GET {KMS_PATH_PREFIX}/secrets/raw → list
|
||||
* GET {KMS_PATH_PREFIX}/secrets/raw/:name → get
|
||||
* POST {KMS_PATH_PREFIX}/secrets/raw/:name → create
|
||||
* PATCH {KMS_PATH_PREFIX}/secrets/raw/:name → update
|
||||
* DELETE {KMS_PATH_PREFIX}/secrets/raw/:name → delete
|
||||
* GET /v1/kms/orgs/{org}/secrets?path=&env= → list names
|
||||
* GET /v1/kms/orgs/{org}/secrets/{path}/{name}?env= → read one value
|
||||
* POST /v1/kms/orgs/{org}/secrets → create or replace
|
||||
* DELETE /v1/kms/orgs/{org}/secrets/{path}/{name}?env= → delete
|
||||
*
|
||||
* This file used to speak Infisical's shape under an `/api/v3` prefix, with a
|
||||
* header comment reporting that `/v1/kms/...` had been *verified* to return SPA
|
||||
* HTML while `/api/v3/secrets/raw` returned JSON — so `/api/v3` was pinned as
|
||||
* the working path and a rewrite was awaited. The observation was real and the
|
||||
* conclusion was backwards: the KMS binary embedded a console SPA under a root
|
||||
* catch-all, so it answered EVERY unmatched path with 200 text/html. The HTML
|
||||
* was the 404. luxfi/kms 1.12.8 removed the catch-all, and the same probe now
|
||||
* reads the other way round — `/v1/kms/...` answers JSON, `/api/v3/...` is a
|
||||
* JSON 404. There is no gateway rewrite to wait for.
|
||||
*
|
||||
* Create and update are ONE call because the server has one upsert, not two
|
||||
* endpoints. Listing arrived in luxfi/kms 1.12.9; before that the HTTP surface
|
||||
* had no list at all, which is what made the Infisical prefix look necessary.
|
||||
*/
|
||||
|
||||
import { KMS_API_URL } from './config.js';
|
||||
|
||||
/** Prefix for every KMS request. See file header. */
|
||||
const KMS_PATH_PREFIX = '/api/v3';
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface KmsSecret {
|
||||
secretKey: string;
|
||||
secretValue?: string;
|
||||
version?: number;
|
||||
type?: string;
|
||||
environment?: string;
|
||||
secretPath?: string;
|
||||
/** Where a secret lives. `path` groups secrets; `name` identifies one. */
|
||||
export interface KmsRef {
|
||||
/** Org scope — also the scope of the bearer token that reads it. */
|
||||
org: string;
|
||||
/** Environment slug (`dev` / `test` / `main`). Defaults to `default`. */
|
||||
env?: string;
|
||||
/** Grouping path, e.g. `gateway`. Omit to address the org root. */
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface KmsListParams {
|
||||
workspaceId: string;
|
||||
environment: string;
|
||||
secretPath?: string;
|
||||
}
|
||||
|
||||
export interface KmsSecretParams extends KmsListParams {
|
||||
secretName: string;
|
||||
/** A ref plus the name of a single secret. */
|
||||
export interface KmsSecretRef extends KmsRef {
|
||||
name: string;
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
@@ -57,121 +59,94 @@ function kmsUrl(path: string, baseUrl?: string): string {
|
||||
return `${baseUrl || KMS_API_URL}${path}`;
|
||||
}
|
||||
|
||||
function buildQuery(params: Record<string, string | undefined>): string {
|
||||
const entries = Object.entries(params).filter(([, v]) => v !== undefined) as [string, string][];
|
||||
if (!entries.length) return '';
|
||||
return '?' + new URLSearchParams(entries).toString();
|
||||
/** The collection URL for an org, with `path`/`env` as query. */
|
||||
function collectionUrl(ref: KmsRef, baseUrl?: string): string {
|
||||
const q = new URLSearchParams();
|
||||
if (ref.path) q.set('path', ref.path);
|
||||
q.set('env', ref.env || 'default');
|
||||
return kmsUrl(`/v1/kms/orgs/${encodeURIComponent(ref.org)}/secrets?${q}`, baseUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* The URL of one secret. The server splits the trailing `{rest...}` at its LAST
|
||||
* slash into (path, name), so each segment is escaped on its own — escaping the
|
||||
* joined string would encode the separators and the server would read one long
|
||||
* name.
|
||||
*/
|
||||
function secretUrl(ref: KmsSecretRef, baseUrl?: string): string {
|
||||
const segs = [...(ref.path ? ref.path.split('/') : []), ref.name]
|
||||
.filter((s) => s.length > 0)
|
||||
.map(encodeURIComponent)
|
||||
.join('/');
|
||||
const q = new URLSearchParams({ env: ref.env || 'default' });
|
||||
return kmsUrl(`/v1/kms/orgs/${encodeURIComponent(ref.org)}/secrets/${segs}?${q}`, baseUrl);
|
||||
}
|
||||
|
||||
async function fail(op: string, resp: Response): Promise<never> {
|
||||
throw new Error(`KMS ${op} failed: ${resp.status} ${await resp.text()}`);
|
||||
}
|
||||
|
||||
// ── API Functions ───────────────────────────────────────────────────────
|
||||
|
||||
/** List secrets in a workspace/environment */
|
||||
/** List the names of the secrets under a path. */
|
||||
export async function kmsListSecrets(
|
||||
token: string,
|
||||
params: KmsListParams,
|
||||
ref: KmsRef,
|
||||
baseUrl?: string,
|
||||
): Promise<KmsSecret[]> {
|
||||
const query = buildQuery({
|
||||
workspaceId: params.workspaceId,
|
||||
environment: params.environment,
|
||||
secretPath: params.secretPath,
|
||||
});
|
||||
const resp = await fetch(kmsUrl(`${KMS_PATH_PREFIX}/secrets/raw${query}`, baseUrl), {
|
||||
headers: kmsHeaders(token),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`KMS list failed: ${resp.status} ${await resp.text()}`);
|
||||
): Promise<string[]> {
|
||||
const resp = await fetch(collectionUrl(ref, baseUrl), { headers: kmsHeaders(token) });
|
||||
if (!resp.ok) return fail('list', resp);
|
||||
const data = await resp.json();
|
||||
return data.secrets || data;
|
||||
return data.names ?? [];
|
||||
}
|
||||
|
||||
/** Get a single secret by name */
|
||||
/** Read one secret's value. */
|
||||
export async function kmsGetSecret(
|
||||
token: string,
|
||||
params: KmsSecretParams,
|
||||
ref: KmsSecretRef,
|
||||
baseUrl?: string,
|
||||
): Promise<KmsSecret> {
|
||||
const query = buildQuery({
|
||||
workspaceId: params.workspaceId,
|
||||
environment: params.environment,
|
||||
secretPath: params.secretPath,
|
||||
});
|
||||
const resp = await fetch(
|
||||
kmsUrl(`${KMS_PATH_PREFIX}/secrets/raw/${encodeURIComponent(params.secretName)}${query}`, baseUrl),
|
||||
{ headers: kmsHeaders(token) },
|
||||
);
|
||||
if (!resp.ok) throw new Error(`KMS get failed: ${resp.status} ${await resp.text()}`);
|
||||
): Promise<string> {
|
||||
const resp = await fetch(secretUrl(ref, baseUrl), { headers: kmsHeaders(token) });
|
||||
if (!resp.ok) return fail('get', resp);
|
||||
const data = await resp.json();
|
||||
return data.secret || data;
|
||||
return data.secret?.value ?? '';
|
||||
}
|
||||
|
||||
/** Create a new secret */
|
||||
export async function kmsCreateSecret(
|
||||
/**
|
||||
* Create a secret, or replace it if the name already exists. One call, because
|
||||
* the server has one upsert — there is no separate create and update.
|
||||
*/
|
||||
export async function kmsPutSecret(
|
||||
token: string,
|
||||
params: KmsSecretParams,
|
||||
ref: KmsSecretRef,
|
||||
value: string,
|
||||
baseUrl?: string,
|
||||
): Promise<KmsSecret> {
|
||||
): Promise<void> {
|
||||
const resp = await fetch(
|
||||
kmsUrl(`${KMS_PATH_PREFIX}/secrets/raw/${encodeURIComponent(params.secretName)}`, baseUrl),
|
||||
kmsUrl(`/v1/kms/orgs/${encodeURIComponent(ref.org)}/secrets`, baseUrl),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: kmsHeaders(token),
|
||||
body: JSON.stringify({
|
||||
workspaceId: params.workspaceId,
|
||||
environment: params.environment,
|
||||
secretPath: params.secretPath,
|
||||
secretValue: value,
|
||||
type: 'shared',
|
||||
path: ref.path ?? '',
|
||||
name: ref.name,
|
||||
env: ref.env || 'default',
|
||||
value,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!resp.ok) throw new Error(`KMS create failed: ${resp.status} ${await resp.text()}`);
|
||||
const data = await resp.json();
|
||||
return data.secret || data;
|
||||
if (!resp.ok) return fail('put', resp);
|
||||
}
|
||||
|
||||
/** Update an existing secret */
|
||||
export async function kmsUpdateSecret(
|
||||
token: string,
|
||||
params: KmsSecretParams,
|
||||
value: string,
|
||||
baseUrl?: string,
|
||||
): Promise<KmsSecret> {
|
||||
const resp = await fetch(
|
||||
kmsUrl(`${KMS_PATH_PREFIX}/secrets/raw/${encodeURIComponent(params.secretName)}`, baseUrl),
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: kmsHeaders(token),
|
||||
body: JSON.stringify({
|
||||
workspaceId: params.workspaceId,
|
||||
environment: params.environment,
|
||||
secretPath: params.secretPath,
|
||||
secretValue: value,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!resp.ok) throw new Error(`KMS update failed: ${resp.status} ${await resp.text()}`);
|
||||
const data = await resp.json();
|
||||
return data.secret || data;
|
||||
}
|
||||
|
||||
/** Delete a secret */
|
||||
/** Delete a secret. */
|
||||
export async function kmsDeleteSecret(
|
||||
token: string,
|
||||
params: KmsSecretParams,
|
||||
ref: KmsSecretRef,
|
||||
baseUrl?: string,
|
||||
): Promise<void> {
|
||||
const resp = await fetch(
|
||||
kmsUrl(`${KMS_PATH_PREFIX}/secrets/raw/${encodeURIComponent(params.secretName)}`, baseUrl),
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: kmsHeaders(token),
|
||||
body: JSON.stringify({
|
||||
workspaceId: params.workspaceId,
|
||||
environment: params.environment,
|
||||
secretPath: params.secretPath,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!resp.ok) throw new Error(`KMS delete failed: ${resp.status} ${await resp.text()}`);
|
||||
const resp = await fetch(secretUrl(ref, baseUrl), {
|
||||
method: 'DELETE',
|
||||
headers: kmsHeaders(token),
|
||||
});
|
||||
if (!resp.ok) return fail('delete', resp);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
kmsListSecrets,
|
||||
kmsGetSecret,
|
||||
kmsCreateSecret,
|
||||
kmsUpdateSecret,
|
||||
kmsPutSecret,
|
||||
kmsDeleteSecret,
|
||||
} from '../src/shared/kms';
|
||||
|
||||
@@ -24,49 +23,95 @@ function mockResponse(data: any, ok = true, status = 200) {
|
||||
}
|
||||
|
||||
const TOKEN = 'test-bearer-token';
|
||||
const PARAMS = {
|
||||
workspaceId: 'ws-123',
|
||||
environment: 'production',
|
||||
secretPath: '/app',
|
||||
};
|
||||
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('sends GET with correct URL and query params', async () => {
|
||||
mockFetch.mockResolvedValue(mockResponse({ secrets: [{ secretKey: 'API_KEY' }] }));
|
||||
|
||||
const result = await kmsListSecrets(TOKEN, PARAMS);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
const [url, opts] = mockFetch.mock.calls[0];
|
||||
expect(url).toContain('kms.hanzo.ai/api/v3/secrets/raw?');
|
||||
expect(url).toContain('workspaceId=ws-123');
|
||||
expect(url).toContain('environment=production');
|
||||
expect(url).toContain('secretPath=%2Fapp');
|
||||
expect(opts.headers.Authorization).toBe(`Bearer ${TOKEN}`);
|
||||
expect(result).toEqual([{ secretKey: 'API_KEY' }]);
|
||||
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('uses custom baseUrl when provided', async () => {
|
||||
mockFetch.mockResolvedValue(mockResponse({ secrets: [] }));
|
||||
|
||||
await kmsListSecrets(TOKEN, PARAMS, 'https://custom-kms.example.com');
|
||||
|
||||
const [url] = mockFetch.mock.calls[0];
|
||||
expect(url).toContain('custom-kms.example.com/api/v3/secrets/raw');
|
||||
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('throws on non-ok response', async () => {
|
||||
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, PARAMS)).rejects.toThrow('KMS list failed: 404');
|
||||
await expect(kmsListSecrets(TOKEN, REF)).rejects.toThrow('KMS list failed: 404');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,78 +120,47 @@ describe('kmsListSecrets', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('kmsGetSecret', () => {
|
||||
it('sends GET with encoded secret name', async () => {
|
||||
mockFetch.mockResolvedValue(mockResponse({ secret: { secretKey: 'DB_URL', secretValue: 'postgres://...' } }));
|
||||
|
||||
const result = await kmsGetSecret(TOKEN, { ...PARAMS, secretName: 'DB_URL' });
|
||||
|
||||
const [url] = mockFetch.mock.calls[0];
|
||||
expect(url).toContain('/api/v3/secrets/raw/DB_URL');
|
||||
expect(result.secretKey).toBe('DB_URL');
|
||||
it('unwraps secret.value', async () => {
|
||||
mockFetch.mockResolvedValue(mockResponse({ secret: { value: 'postgres://...' } }));
|
||||
await expect(kmsGetSecret(TOKEN, SECRET)).resolves.toBe('postgres://...');
|
||||
});
|
||||
|
||||
it('URL-encodes special characters in secret name', async () => {
|
||||
mockFetch.mockResolvedValue(mockResponse({ secret: { secretKey: 'app/key' } }));
|
||||
|
||||
await kmsGetSecret(TOKEN, { ...PARAMS, secretName: 'app/key' });
|
||||
|
||||
const [url] = mockFetch.mock.calls[0];
|
||||
expect(url).toContain('app%2Fkey');
|
||||
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 on non-ok response', async () => {
|
||||
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(kmsGetSecret(TOKEN, { ...PARAMS, secretName: 'X' })).rejects.toThrow('KMS get failed: 403');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// kmsCreateSecret
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('kmsCreateSecret', () => {
|
||||
it('sends POST with correct body', async () => {
|
||||
mockFetch.mockResolvedValue(mockResponse({ secret: { secretKey: 'NEW_KEY' } }));
|
||||
|
||||
await kmsCreateSecret(TOKEN, { ...PARAMS, secretName: 'NEW_KEY' }, 'secret-value');
|
||||
|
||||
const [url, opts] = mockFetch.mock.calls[0];
|
||||
expect(url).toContain('/api/v3/secrets/raw/NEW_KEY');
|
||||
expect(opts.method).toBe('POST');
|
||||
const body = JSON.parse(opts.body);
|
||||
expect(body.secretValue).toBe('secret-value');
|
||||
expect(body.workspaceId).toBe('ws-123');
|
||||
expect(body.environment).toBe('production');
|
||||
expect(body.type).toBe('shared');
|
||||
});
|
||||
|
||||
it('includes Authorization header', async () => {
|
||||
mockFetch.mockResolvedValue(mockResponse({ secret: {} }));
|
||||
|
||||
await kmsCreateSecret(TOKEN, { ...PARAMS, secretName: 'X' }, 'val');
|
||||
|
||||
const [, opts] = mockFetch.mock.calls[0];
|
||||
expect(opts.headers.Authorization).toBe(`Bearer ${TOKEN}`);
|
||||
expect(opts.headers['Content-Type']).toBe('application/json');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// kmsUpdateSecret
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('kmsUpdateSecret', () => {
|
||||
it('sends PATCH with correct body', async () => {
|
||||
mockFetch.mockResolvedValue(mockResponse({ secret: { secretKey: 'KEY' } }));
|
||||
|
||||
await kmsUpdateSecret(TOKEN, { ...PARAMS, secretName: 'KEY' }, 'new-value');
|
||||
|
||||
const [url, opts] = mockFetch.mock.calls[0];
|
||||
expect(url).toContain('/api/v3/secrets/raw/KEY');
|
||||
expect(opts.method).toBe('PATCH');
|
||||
const body = JSON.parse(opts.body);
|
||||
expect(body.secretValue).toBe('new-value');
|
||||
await expect(kmsPutSecret(TOKEN, SECRET, 'v')).rejects.toThrow('KMS put failed: 403');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -155,21 +169,16 @@ describe('kmsUpdateSecret', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('kmsDeleteSecret', () => {
|
||||
it('sends DELETE with correct body', async () => {
|
||||
it('DELETEs the secret URL', async () => {
|
||||
mockFetch.mockResolvedValue(mockResponse({}));
|
||||
|
||||
await kmsDeleteSecret(TOKEN, { ...PARAMS, secretName: 'OLD_KEY' });
|
||||
|
||||
const [url, opts] = mockFetch.mock.calls[0];
|
||||
expect(url).toContain('/api/v3/secrets/raw/OLD_KEY');
|
||||
expect(opts.method).toBe('DELETE');
|
||||
const body = JSON.parse(opts.body);
|
||||
expect(body.workspaceId).toBe('ws-123');
|
||||
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 on non-ok response', async () => {
|
||||
mockFetch.mockResolvedValue(mockResponse('Server Error', false, 500));
|
||||
|
||||
await expect(kmsDeleteSecret(TOKEN, { ...PARAMS, secretName: 'X' })).rejects.toThrow('KMS delete failed: 500');
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user