feat(openbao): wizard-provisioning + daily token rotation
Some checks failed
CI/CD / typecheck (pull_request) Successful in 55s
CI/CD / test (pull_request) Successful in 1m4s
CI/CD / lint (pull_request) Successful in 2m2s
CI/CD / smoke (pull_request) Failing after 1m36s
CI/CD / build (pull_request) Successful in 4m13s
CI/CD / publish (pull_request) Has been skipped
Some checks failed
CI/CD / typecheck (pull_request) Successful in 55s
CI/CD / test (pull_request) Successful in 1m4s
CI/CD / lint (pull_request) Successful in 2m2s
CI/CD / smoke (pull_request) Failing after 1m36s
CI/CD / build (pull_request) Successful in 4m13s
CI/CD / publish (pull_request) Has been skipped
One-command setup replaces the 6-step manual flow — `mcpctl create
secretbackend bao --type openbao --wizard` takes the OpenBao admin token
once, provisions a narrow policy + token role, mints the first periodic
token, stores it on mcpd, verifies end-to-end, and prints the migration
command. The admin token is NEVER persisted.
The stored credential auto-rotates daily: mcpd mints a successor via the
token role (self-rotation capability is part of the policy it was issued
with), verifies the successor, writes it over the backing Secret, then
revokes the predecessor by accessor. TTL 720h means a week of rotation
failures still leaves 20+ days of runway.
Shared:
- New `@mcpctl/shared/vault` — pure HTTP wrappers (verifyHealth,
ensureKvV2, writePolicy, ensureTokenRole, mintRoleToken, revokeAccessor,
lookupSelf, testWriteReadDelete) and policy HCL builder.
mcpd:
- `tokenMeta Json @default("{}")` on SecretBackend. Self-healing schema
migration — empty default lets `prisma db push` add the column cleanly.
- SecretBackendRotator.rotateOne: mint → verify → persist → revoke-old →
update tokenMeta. Failures surface via `lastRotationError` on the row;
the old token keeps working.
- SecretBackendRotatorLoop: on startup rotates overdue backends, schedules
per-backend timers with ±10min jitter. Stops cleanly on shutdown.
- New `POST /api/v1/secretbackends/:id/rotate` (operation
`rotate-secretbackend` — added to bootstrap-admin's auto-migrated ops
alongside migrate-secrets, which was previously missing too).
CLI:
- `--wizard` on `create secretbackend` delegates to the interactive flow.
All prompts can be pre-answered via flags (--url, --admin-token,
--mount, --path-prefix, --policy-name, --token-role,
--no-promote-default) for CI.
- `mcpctl rotate secretbackend <name>` — convenience verb; hits the new
rotate endpoint.
- `describe secretbackend` renders a Token health section (healthy /
STALE / WARNING / ERROR) with generated/renewal/expiry timestamps and
last rotation error. Only shown when tokenMeta.rotatable is true — the
existing k8s-auth + static-token backends don't surface it.
Tests: 15 vault-client unit tests (shared), 8 rotator unit tests (mcpd),
3 wizard flow tests (cli, including a regression test that the admin
token never appears in stdout). Full suite 1885/1885 (+32). Completions
regenerated for the new flags.
Out of scope (explicit): kubernetes-auth wizard, Vault Enterprise
namespaces in the wizard path, rotation for non-wizard static-token
backends. See plan file for details.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,3 +5,4 @@ export * from './utils/index.js';
|
||||
export * from './secrets/index.js';
|
||||
export * from './tokens/index.js';
|
||||
export * from './mcp-http/index.js';
|
||||
export * from './vault/index.js';
|
||||
|
||||
308
src/shared/src/vault/client.ts
Normal file
308
src/shared/src/vault/client.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* Thin HTTP wrappers around the OpenBao / Vault REST API.
|
||||
*
|
||||
* Used by:
|
||||
* - the CLI wizard (admin-token-scoped calls: enable engine, write policy,
|
||||
* create role, mint first token, smoke-test write/read)
|
||||
* - the mcpd rotator (caller-token-scoped calls: mint successor, revoke
|
||||
* predecessor, lookup-self for verification)
|
||||
*
|
||||
* Plain `fetch()` — no SDK dep, consistent with the OpenBaoDriver. All
|
||||
* functions accept an injectable `fetch` in a deps arg so tests can mock.
|
||||
*/
|
||||
|
||||
export interface VaultDeps {
|
||||
fetch?: typeof globalThis.fetch;
|
||||
/** Optional Vault Enterprise namespace (X-Vault-Namespace header). */
|
||||
namespace?: string;
|
||||
}
|
||||
|
||||
export interface VaultHealth {
|
||||
initialized: boolean;
|
||||
sealed: boolean;
|
||||
standby: boolean;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface MintedToken {
|
||||
/** The raw client token (treat as secret — surface to user only in wizard transcript). */
|
||||
clientToken: string;
|
||||
/** Accessor used to revoke without knowing the token value. */
|
||||
accessor: string;
|
||||
/** TTL in seconds reported by Vault. For periodic tokens this is the period. */
|
||||
leaseDuration: number;
|
||||
/** True iff Vault said the token is renewable. The wizard bails if false. */
|
||||
renewable: boolean;
|
||||
policies: string[];
|
||||
}
|
||||
|
||||
function baseUrl(url: string): string {
|
||||
return url.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function headers(token: string | undefined, ns: string | undefined, withBody: boolean): Record<string, string> {
|
||||
const h: Record<string, string> = {};
|
||||
if (token !== undefined && token !== '') h['X-Vault-Token'] = token;
|
||||
if (ns !== undefined && ns !== '') h['X-Vault-Namespace'] = ns;
|
||||
if (withBody) h['Content-Type'] = 'application/json';
|
||||
return h;
|
||||
}
|
||||
|
||||
async function readError(res: Response): Promise<string> {
|
||||
const text = await res.text().catch(() => '');
|
||||
try {
|
||||
const parsed = JSON.parse(text) as { errors?: string[] };
|
||||
if (Array.isArray(parsed.errors) && parsed.errors.length > 0) return parsed.errors.join('; ');
|
||||
} catch { /* fall through */ }
|
||||
return text;
|
||||
}
|
||||
|
||||
/** GET /v1/sys/health. Returns a normalised shape; throws on network error. */
|
||||
export async function verifyHealth(
|
||||
url: string,
|
||||
adminToken: string,
|
||||
deps: VaultDeps = {},
|
||||
): Promise<VaultHealth> {
|
||||
const fetchImpl = deps.fetch ?? globalThis.fetch;
|
||||
// /sys/health returns 200/429/472/473/501/503 depending on state. All are
|
||||
// valid responses to parse; anything else is a hard error.
|
||||
const res = await fetchImpl(`${baseUrl(url)}/v1/sys/health`, {
|
||||
method: 'GET',
|
||||
headers: headers(adminToken, deps.namespace, false),
|
||||
});
|
||||
if (res.status >= 500 && res.status !== 501 && res.status !== 503) {
|
||||
throw new Error(`vault health: HTTP ${String(res.status)} ${await readError(res)}`);
|
||||
}
|
||||
const body = await res.json() as Partial<VaultHealth> & { version?: string };
|
||||
return {
|
||||
initialized: body.initialized ?? false,
|
||||
sealed: body.sealed ?? false,
|
||||
standby: body.standby ?? false,
|
||||
version: body.version ?? 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable KV v2 at `mount` if not already mounted there. Idempotent.
|
||||
* Returns `true` if a mount was created, `false` if it was already present.
|
||||
*/
|
||||
export async function ensureKvV2(
|
||||
url: string,
|
||||
adminToken: string,
|
||||
mount: string,
|
||||
deps: VaultDeps = {},
|
||||
): Promise<boolean> {
|
||||
const fetchImpl = deps.fetch ?? globalThis.fetch;
|
||||
const clean = mount.replace(/^\/|\/$/g, '');
|
||||
// Check existing mounts
|
||||
const listRes = await fetchImpl(`${baseUrl(url)}/v1/sys/mounts`, {
|
||||
method: 'GET',
|
||||
headers: headers(adminToken, deps.namespace, false),
|
||||
});
|
||||
if (!listRes.ok) {
|
||||
throw new Error(`vault list mounts: HTTP ${String(listRes.status)} ${await readError(listRes)}`);
|
||||
}
|
||||
const mounts = await listRes.json() as Record<string, { type?: string; options?: { version?: string } }>;
|
||||
const key = `${clean}/`;
|
||||
const existing = mounts[key];
|
||||
if (existing !== undefined) {
|
||||
if (existing.type !== 'kv') {
|
||||
throw new Error(`mount at '${clean}/' exists but is type '${String(existing.type)}', not kv`);
|
||||
}
|
||||
// Accept either v2 or unspecified (older Vault treats kv without options as v1 — surface a clear error).
|
||||
if (existing.options?.version !== '2') {
|
||||
throw new Error(`mount '${clean}/' is KV but not v2 (version='${String(existing.options?.version)}'). Use a different mount.`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Mount it
|
||||
const mountRes = await fetchImpl(`${baseUrl(url)}/v1/sys/mounts/${clean}`, {
|
||||
method: 'POST',
|
||||
headers: headers(adminToken, deps.namespace, true),
|
||||
body: JSON.stringify({ type: 'kv', options: { version: '2' } }),
|
||||
});
|
||||
if (!mountRes.ok) {
|
||||
throw new Error(`vault mount ${clean}: HTTP ${String(mountRes.status)} ${await readError(mountRes)}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** PUT /v1/sys/policies/acl/<name> with the provided HCL. Idempotent. */
|
||||
export async function writePolicy(
|
||||
url: string,
|
||||
adminToken: string,
|
||||
name: string,
|
||||
hcl: string,
|
||||
deps: VaultDeps = {},
|
||||
): Promise<void> {
|
||||
const fetchImpl = deps.fetch ?? globalThis.fetch;
|
||||
const res = await fetchImpl(`${baseUrl(url)}/v1/sys/policies/acl/${encodeURIComponent(name)}`, {
|
||||
method: 'PUT',
|
||||
headers: headers(adminToken, deps.namespace, true),
|
||||
body: JSON.stringify({ policy: hcl }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`vault write policy ${name}: HTTP ${String(res.status)} ${await readError(res)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export interface TokenRoleConfig {
|
||||
allowedPolicies: string[];
|
||||
/** Seconds. For `period`, pass 0 to omit. */
|
||||
period?: number;
|
||||
renewable?: boolean;
|
||||
orphan?: boolean;
|
||||
}
|
||||
|
||||
/** POST /v1/auth/token/roles/<role>. Idempotent: upserts the role config. */
|
||||
export async function ensureTokenRole(
|
||||
url: string,
|
||||
adminToken: string,
|
||||
role: string,
|
||||
cfg: TokenRoleConfig,
|
||||
deps: VaultDeps = {},
|
||||
): Promise<void> {
|
||||
const fetchImpl = deps.fetch ?? globalThis.fetch;
|
||||
const body: Record<string, unknown> = {
|
||||
allowed_policies: cfg.allowedPolicies,
|
||||
renewable: cfg.renewable ?? true,
|
||||
orphan: cfg.orphan ?? false,
|
||||
};
|
||||
if (cfg.period !== undefined && cfg.period > 0) body.period = cfg.period;
|
||||
const res = await fetchImpl(`${baseUrl(url)}/v1/auth/token/roles/${encodeURIComponent(role)}`, {
|
||||
method: 'POST',
|
||||
headers: headers(adminToken, deps.namespace, true),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`vault ensure role ${role}: HTTP ${String(res.status)} ${await readError(res)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /v1/auth/token/create/<role>. Caller must hold a token with
|
||||
* `create` on that path (admin, or a previously-minted successor).
|
||||
*/
|
||||
export async function mintRoleToken(
|
||||
url: string,
|
||||
callerToken: string,
|
||||
role: string,
|
||||
deps: VaultDeps = {},
|
||||
): Promise<MintedToken> {
|
||||
const fetchImpl = deps.fetch ?? globalThis.fetch;
|
||||
const res = await fetchImpl(`${baseUrl(url)}/v1/auth/token/create/${encodeURIComponent(role)}`, {
|
||||
method: 'POST',
|
||||
headers: headers(callerToken, deps.namespace, true),
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`vault mint role-token ${role}: HTTP ${String(res.status)} ${await readError(res)}`);
|
||||
}
|
||||
const body = await res.json() as {
|
||||
auth?: {
|
||||
client_token?: string;
|
||||
accessor?: string;
|
||||
lease_duration?: number;
|
||||
renewable?: boolean;
|
||||
policies?: string[];
|
||||
};
|
||||
};
|
||||
const a = body.auth;
|
||||
if (a?.client_token === undefined || a?.accessor === undefined) {
|
||||
throw new Error(`vault mint role-token ${role}: response missing auth.client_token or accessor`);
|
||||
}
|
||||
return {
|
||||
clientToken: a.client_token,
|
||||
accessor: a.accessor,
|
||||
leaseDuration: a.lease_duration ?? 0,
|
||||
renewable: a.renewable ?? false,
|
||||
policies: a.policies ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/** POST /v1/auth/token/revoke-accessor. Idempotent — revoking an unknown accessor returns 204. */
|
||||
export async function revokeAccessor(
|
||||
url: string,
|
||||
callerToken: string,
|
||||
accessor: string,
|
||||
deps: VaultDeps = {},
|
||||
): Promise<void> {
|
||||
const fetchImpl = deps.fetch ?? globalThis.fetch;
|
||||
const res = await fetchImpl(`${baseUrl(url)}/v1/auth/token/revoke-accessor`, {
|
||||
method: 'POST',
|
||||
headers: headers(callerToken, deps.namespace, true),
|
||||
body: JSON.stringify({ accessor }),
|
||||
});
|
||||
// 204 = revoked, 400 = already revoked/unknown (treat as noop)
|
||||
if (!res.ok && res.status !== 400) {
|
||||
throw new Error(`vault revoke-accessor: HTTP ${String(res.status)} ${await readError(res)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /v1/auth/token/lookup-self. Returns accessor + remaining TTL on the caller's token. */
|
||||
export async function lookupSelf(
|
||||
url: string,
|
||||
callerToken: string,
|
||||
deps: VaultDeps = {},
|
||||
): Promise<{ accessor: string; ttl: number; policies: string[] }> {
|
||||
const fetchImpl = deps.fetch ?? globalThis.fetch;
|
||||
const res = await fetchImpl(`${baseUrl(url)}/v1/auth/token/lookup-self`, {
|
||||
method: 'GET',
|
||||
headers: headers(callerToken, deps.namespace, false),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`vault lookup-self: HTTP ${String(res.status)} ${await readError(res)}`);
|
||||
}
|
||||
const body = await res.json() as { data?: { accessor?: string; ttl?: number; policies?: string[] } };
|
||||
return {
|
||||
accessor: body.data?.accessor ?? '',
|
||||
ttl: body.data?.ttl ?? 0,
|
||||
policies: body.data?.policies ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Round-trip smoke test: write a marker secret, read it back, delete metadata.
|
||||
* Used by the wizard to prove the minted token's policy is wired correctly
|
||||
* before reporting success to the user.
|
||||
*/
|
||||
export async function testWriteReadDelete(
|
||||
url: string,
|
||||
callerToken: string,
|
||||
mount: string,
|
||||
relPath: string,
|
||||
deps: VaultDeps = {},
|
||||
): Promise<void> {
|
||||
const fetchImpl = deps.fetch ?? globalThis.fetch;
|
||||
const dataUrl = `${baseUrl(url)}/v1/${mount}/data/${relPath.replace(/^\//, '')}`;
|
||||
const metaUrl = `${baseUrl(url)}/v1/${mount}/metadata/${relPath.replace(/^\//, '')}`;
|
||||
|
||||
const writeRes = await fetchImpl(dataUrl, {
|
||||
method: 'POST',
|
||||
headers: headers(callerToken, deps.namespace, true),
|
||||
body: JSON.stringify({ data: { marker: 'mcpctl-smoke', at: new Date().toISOString() } }),
|
||||
});
|
||||
if (!writeRes.ok) {
|
||||
throw new Error(`vault smoke write ${relPath}: HTTP ${String(writeRes.status)} ${await readError(writeRes)}`);
|
||||
}
|
||||
|
||||
const readRes = await fetchImpl(dataUrl, {
|
||||
method: 'GET',
|
||||
headers: headers(callerToken, deps.namespace, false),
|
||||
});
|
||||
if (!readRes.ok) {
|
||||
throw new Error(`vault smoke read ${relPath}: HTTP ${String(readRes.status)} ${await readError(readRes)}`);
|
||||
}
|
||||
const readBody = await readRes.json() as { data?: { data?: { marker?: string } } };
|
||||
if (readBody.data?.data?.marker !== 'mcpctl-smoke') {
|
||||
throw new Error(`vault smoke: read-back didn't match written marker`);
|
||||
}
|
||||
|
||||
const delRes = await fetchImpl(metaUrl, {
|
||||
method: 'DELETE',
|
||||
headers: headers(callerToken, deps.namespace, false),
|
||||
});
|
||||
if (!delRes.ok && delRes.status !== 404) {
|
||||
throw new Error(`vault smoke delete ${relPath}: HTTP ${String(delRes.status)} ${await readError(delRes)}`);
|
||||
}
|
||||
}
|
||||
2
src/shared/src/vault/index.ts
Normal file
2
src/shared/src/vault/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './client.js';
|
||||
export * from './policy.js';
|
||||
35
src/shared/src/vault/policy.ts
Normal file
35
src/shared/src/vault/policy.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* OpenBao / Vault policy template for mcpd's wizard-provisioned backend.
|
||||
*
|
||||
* The policy is deliberately narrow:
|
||||
* - Read/write/list/delete under `<mount>/{data,metadata}/<pathPrefix>/*`
|
||||
* - Self-rotation: mcpd can mint its successor via the dedicated token role
|
||||
* and revoke its predecessor by accessor.
|
||||
*
|
||||
* Keeping the paths in one place lets the wizard and the rotator agree on
|
||||
* exactly which capabilities the stored token has, and lets tests assert the
|
||||
* generated HCL is stable.
|
||||
*/
|
||||
|
||||
export interface AppMcpdPolicyConfig {
|
||||
/** KV v2 mount name. Default: 'secret'. */
|
||||
mount: string;
|
||||
/** Path prefix under the mount (the directory mcpd is confined to). Default: 'mcpd'. */
|
||||
pathPrefix: string;
|
||||
/** Token role name the policy allows self-rotation against. Default: 'app-mcpd-role'. */
|
||||
tokenRole: string;
|
||||
}
|
||||
|
||||
export function buildAppMcpdPolicyHcl(cfg: AppMcpdPolicyConfig): string {
|
||||
const { mount, pathPrefix, tokenRole } = cfg;
|
||||
const prefix = pathPrefix.replace(/^\/|\/$/g, '');
|
||||
return [
|
||||
`path "${mount}/data/${prefix}/*" { capabilities = ["create", "read", "update"] }`,
|
||||
`path "${mount}/metadata/${prefix}/*" { capabilities = ["list", "delete"] }`,
|
||||
`path "${mount}/metadata/${prefix}/" { capabilities = ["list"] }`,
|
||||
`path "auth/token/create/${tokenRole}" { capabilities = ["create", "update"] }`,
|
||||
`path "auth/token/revoke-accessor" { capabilities = ["update"] }`,
|
||||
`path "auth/token/lookup-self" { capabilities = ["read"] }`,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
183
src/shared/tests/vault-client.test.ts
Normal file
183
src/shared/tests/vault-client.test.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
buildAppMcpdPolicyHcl,
|
||||
verifyHealth,
|
||||
ensureKvV2,
|
||||
writePolicy,
|
||||
ensureTokenRole,
|
||||
mintRoleToken,
|
||||
revokeAccessor,
|
||||
lookupSelf,
|
||||
testWriteReadDelete,
|
||||
} from '../src/vault/index.js';
|
||||
|
||||
function mockFetch(responses: Array<{ match: RegExp; status: number; body?: unknown; text?: string }>): ReturnType<typeof vi.fn> {
|
||||
return vi.fn(async (url: string | URL, init?: RequestInit) => {
|
||||
const u = String(url);
|
||||
const method = init?.method ?? 'GET';
|
||||
const match = responses.find((r) => r.match.test(`${method} ${u}`) || r.match.test(u));
|
||||
if (!match) throw new Error(`unexpected fetch: ${method} ${u}`);
|
||||
const body = match.body !== undefined ? JSON.stringify(match.body) : (match.text ?? '');
|
||||
return new Response(body, { status: match.status, headers: { 'Content-Type': 'application/json' } });
|
||||
});
|
||||
}
|
||||
|
||||
describe('buildAppMcpdPolicyHcl', () => {
|
||||
it('emits stable HCL for the documented default', () => {
|
||||
const hcl = buildAppMcpdPolicyHcl({ mount: 'secret', pathPrefix: 'mcpd', tokenRole: 'app-mcpd-role' });
|
||||
expect(hcl).toContain('path "secret/data/mcpd/*"');
|
||||
expect(hcl).toContain('path "secret/metadata/mcpd/*"');
|
||||
expect(hcl).toContain('path "auth/token/create/app-mcpd-role"');
|
||||
expect(hcl).toContain('path "auth/token/revoke-accessor"');
|
||||
expect(hcl).toContain('capabilities = ["read"]');
|
||||
});
|
||||
|
||||
it('normalises leading/trailing slashes in pathPrefix', () => {
|
||||
const hcl = buildAppMcpdPolicyHcl({ mount: 'secret', pathPrefix: '/mcpd/', tokenRole: 'r' });
|
||||
expect(hcl).not.toContain('//');
|
||||
expect(hcl).toContain('path "secret/data/mcpd/*"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyHealth', () => {
|
||||
it('returns normalised shape for a healthy unsealed vault', async () => {
|
||||
const fetchFn = mockFetch([{ match: /\/v1\/sys\/health$/, status: 200, body: { initialized: true, sealed: false, standby: false, version: '2.5.2' } }]);
|
||||
const h = await verifyHealth('http://bao.example:8200', 'root', { fetch: fetchFn as unknown as typeof fetch });
|
||||
expect(h).toEqual({ initialized: true, sealed: false, standby: false, version: '2.5.2' });
|
||||
});
|
||||
|
||||
it('throws on non-standard 5xx', async () => {
|
||||
const fetchFn = vi.fn(async () => new Response('boom', { status: 502 }));
|
||||
await expect(verifyHealth('http://x', 'root', { fetch: fetchFn as unknown as typeof fetch })).rejects.toThrow(/HTTP 502/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureKvV2', () => {
|
||||
it('returns false when mount already exists as kv v2', async () => {
|
||||
const fetchFn = mockFetch([
|
||||
{ match: /GET .*\/v1\/sys\/mounts$/, status: 200, body: { 'secret/': { type: 'kv', options: { version: '2' } } } },
|
||||
]);
|
||||
const created = await ensureKvV2('http://x', 'root', 'secret', { fetch: fetchFn as unknown as typeof fetch });
|
||||
expect(created).toBe(false);
|
||||
});
|
||||
|
||||
it('mounts KV v2 when mount is missing', async () => {
|
||||
const fetchFn = mockFetch([
|
||||
{ match: /GET .*\/v1\/sys\/mounts$/, status: 200, body: {} },
|
||||
{ match: /POST .*\/v1\/sys\/mounts\/secret$/, status: 200 },
|
||||
]);
|
||||
const created = await ensureKvV2('http://x', 'root', 'secret', { fetch: fetchFn as unknown as typeof fetch });
|
||||
expect(created).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects when mount exists but is kv v1', async () => {
|
||||
const fetchFn = mockFetch([
|
||||
{ match: /GET .*\/v1\/sys\/mounts$/, status: 200, body: { 'secret/': { type: 'kv', options: { version: '1' } } } },
|
||||
]);
|
||||
await expect(ensureKvV2('http://x', 'root', 'secret', { fetch: fetchFn as unknown as typeof fetch }))
|
||||
.rejects.toThrow(/not v2/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('writePolicy', () => {
|
||||
it('PUTs the HCL to /v1/sys/policies/acl/<name>', async () => {
|
||||
const fetchFn = mockFetch([{ match: /PUT .*\/v1\/sys\/policies\/acl\/app-mcpd$/, status: 200 }]);
|
||||
await writePolicy('http://x', 'root', 'app-mcpd', 'path "x" {}', { fetch: fetchFn as unknown as typeof fetch });
|
||||
const [, init] = fetchFn.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.method).toBe('PUT');
|
||||
const body = JSON.parse(init.body as string) as { policy: string };
|
||||
expect(body.policy).toContain('path "x"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureTokenRole', () => {
|
||||
it('POSTs the role config with period + renewable', async () => {
|
||||
const fetchFn = mockFetch([{ match: /POST .*\/v1\/auth\/token\/roles\/app-mcpd-role$/, status: 200 }]);
|
||||
await ensureTokenRole('http://x', 'root', 'app-mcpd-role', {
|
||||
allowedPolicies: ['app-mcpd'],
|
||||
period: 720 * 3600,
|
||||
renewable: true,
|
||||
}, { fetch: fetchFn as unknown as typeof fetch });
|
||||
const [, init] = fetchFn.mock.calls[0] as [string, RequestInit];
|
||||
const sent = JSON.parse(init.body as string) as Record<string, unknown>;
|
||||
expect(sent.allowed_policies).toEqual(['app-mcpd']);
|
||||
expect(sent.period).toBe(720 * 3600);
|
||||
expect(sent.renewable).toBe(true);
|
||||
expect(sent.orphan).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mintRoleToken', () => {
|
||||
it('parses the auth block into a MintedToken', async () => {
|
||||
const fetchFn = mockFetch([{
|
||||
match: /POST .*\/v1\/auth\/token\/create\/app-mcpd-role$/,
|
||||
status: 200,
|
||||
body: { auth: { client_token: 'hvs.CAE.xyz', accessor: 'acc-1', lease_duration: 2592000, renewable: true, policies: ['app-mcpd', 'default'] } },
|
||||
}]);
|
||||
const m = await mintRoleToken('http://x', 'caller', 'app-mcpd-role', { fetch: fetchFn as unknown as typeof fetch });
|
||||
expect(m.clientToken).toBe('hvs.CAE.xyz');
|
||||
expect(m.accessor).toBe('acc-1');
|
||||
expect(m.leaseDuration).toBe(2592000);
|
||||
expect(m.renewable).toBe(true);
|
||||
expect(m.policies).toEqual(['app-mcpd', 'default']);
|
||||
});
|
||||
|
||||
it('throws when the response is missing auth.client_token', async () => {
|
||||
const fetchFn = mockFetch([{ match: /create\/r$/, status: 200, body: { auth: { accessor: 'acc' } } }]);
|
||||
await expect(mintRoleToken('http://x', 'caller', 'r', { fetch: fetchFn as unknown as typeof fetch }))
|
||||
.rejects.toThrow(/missing auth.client_token/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('revokeAccessor', () => {
|
||||
it('swallows 400 (already revoked/unknown)', async () => {
|
||||
const fetchFn = vi.fn(async () => new Response('{}', { status: 400 }));
|
||||
await expect(revokeAccessor('http://x', 'caller', 'acc', { fetch: fetchFn as unknown as typeof fetch }))
|
||||
.resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('lookupSelf', () => {
|
||||
it('extracts accessor + ttl from data block', async () => {
|
||||
const fetchFn = mockFetch([{
|
||||
match: /lookup-self$/,
|
||||
status: 200,
|
||||
body: { data: { accessor: 'acc-7', ttl: 2591000, policies: ['app-mcpd'] } },
|
||||
}]);
|
||||
const r = await lookupSelf('http://x', 'caller', { fetch: fetchFn as unknown as typeof fetch });
|
||||
expect(r).toEqual({ accessor: 'acc-7', ttl: 2591000, policies: ['app-mcpd'] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('testWriteReadDelete', () => {
|
||||
it('runs write→read→delete and succeeds on round-trip match', async () => {
|
||||
const calls: string[] = [];
|
||||
const fetchFn = vi.fn(async (url: string | URL, init?: RequestInit) => {
|
||||
const u = String(url);
|
||||
const m = init?.method ?? 'GET';
|
||||
calls.push(`${m} ${u}`);
|
||||
if (m === 'POST') return new Response('{}', { status: 200 });
|
||||
if (m === 'GET') {
|
||||
return new Response(JSON.stringify({ data: { data: { marker: 'mcpctl-smoke' } } }), { status: 200 });
|
||||
}
|
||||
// DELETE
|
||||
return new Response(null, { status: 200 });
|
||||
});
|
||||
await testWriteReadDelete('http://x', 'caller', 'secret', 'mcpd/smoke', { fetch: fetchFn as unknown as typeof fetch });
|
||||
expect(calls).toHaveLength(3);
|
||||
expect(calls[0]).toMatch(/POST .*\/v1\/secret\/data\/mcpd\/smoke$/);
|
||||
expect(calls[1]).toMatch(/GET .*\/v1\/secret\/data\/mcpd\/smoke$/);
|
||||
expect(calls[2]).toMatch(/DELETE .*\/v1\/secret\/metadata\/mcpd\/smoke$/);
|
||||
});
|
||||
|
||||
it('throws when read-back marker does not match', async () => {
|
||||
const fetchFn = vi.fn(async (_u: string | URL, init?: RequestInit) => {
|
||||
if ((init?.method ?? 'GET') === 'GET') {
|
||||
return new Response(JSON.stringify({ data: { data: { marker: 'wrong' } } }), { status: 200 });
|
||||
}
|
||||
return new Response('{}', { status: 200 });
|
||||
});
|
||||
await expect(testWriteReadDelete('http://x', 'c', 'secret', 'p', { fetch: fetchFn as unknown as typeof fetch }))
|
||||
.rejects.toThrow(/didn't match written marker/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user