mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-19 09:45:07 +00:00
feat+test+docs: D47 — bin/olp-keys.mjs keygen CLI (Phase 2 functional scope closes) (#23)
Fourth Phase 2 implementation D-day. Closes ADR 0007 § 10 acceptance criterion #9 (bootstrap workflow must be reproducible without manual file editing) by shipping a minimal keygen CLI per § 9.1. Phase 2 functional scope is complete with this D-day — remaining work is Phase 2 close → v0.2.0 (maintainer-triggered, explicit per CLAUDE.md release_kit.phase_close_trigger). NEW bin/olp-keys.mjs (~250 lines): subcommand CLI keygen [--owner|--name=X|--tier=guest|owner|--providers=csv|--force] Creates a key + prints plaintext token to stdout ONCE; manifest stores only SHA-256 hash. --force revokes existing owner keys before creating the new owner (ADR § 9.3 recovery flow). list [--owner-only|--include-revoked] Lists keys with token_hash redacted. revoke --id=<key-id> Marks the key's revoked_at; idempotent (already-revoked → no-op + status message); missing id → exit 2. Common flag: --olp-home=<path> overrides ~/.olp/ (defaults to OLP_HOME env then ~/.olp/). package.json bin field "bin": { "olp-keys": "./bin/olp-keys.mjs" } so npx olp-keys ... resolves. Also "scripts": { "olp-keys": "node bin/olp-keys.mjs" } for npm run. Module shape (testability) Exports runCli(argv, { out, err }) so tests invoke with synthetic argv + IO writers (no process spawn). Main guard auto-runs when invoked as entrypoint. Plaintext token discipline (ADR § 5 + § 9.1) Plaintext printed exactly once on stdout. Never logged, never written to manifest, never written to audit. Operators capture immediately; lost → --force revoke + regenerate. --force async correctness cmdKeygen is async and awaits each revokeKey (which is async — acquires per-key write lock per § 6.4). Sequence: revoke each existing owner manifest atomically → then createKey for new owner. Avoids race where create-new runs before revoke-old completes. TESTS — Suite 22, +20 (524 → 544): 22a-1..5: parseArgv unit (--flag=value, --flag value, boolean, mixed positional) 22b-1..5: keygen (owner default, name+providers, missing-name error, invalid-tier error, --force revoke-then-create with isolation tmpdir) 22c-1..3: list (empty, populated with token_hash-redaction check, --owner-only filter) 22d-1..4: revoke (valid id, idempotent re-revoke, missing-id error, nonexistent-id error) 22e-1..3: top-level CLI (--help / no args / unknown subcommand exit codes) DOCUMENTATION: - AGENTS.md: lib/keys.mjs marker promoted to ✅; new bin/olp-keys.mjs entry. Implementation-status-note + shipped-set updated. - README.md: Implementation Status row added for bin/olp-keys.mjs; Known limitations note rewritten to "Phase 2 functional scope complete; close pending"; new Bootstrap workflow section with copy-pasteable npx commands + recovery flow. - CHANGELOG.md: D47 entry under Unreleased per release_kit overlay. AUTHORITY: - ADR 0007 — § 5 token format, § 9.1 minimal keygen command surface, § 9.3 recovery, § 10 acceptance criterion #9 covered. - CLAUDE.md release_kit overlay phase_rolling_mode — under Unreleased. - Standing autopilot grant. Verified: 544/544 pass via npm test (no regression in 524 pre-D47 tests; 20 new Suite 22 tests all green). Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -10657,3 +10657,243 @@ describe('Suite 21 — D46 owner-vs-guest gating (ADR 0007 §§ 7.1, 7.2)', () =
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Suite 22: D47 keygen CLI (bin/olp-keys.mjs, ADR 0007 § 9.1) ───────────
|
||||
//
|
||||
// Tests for the minimal keygen command surface that satisfies ADR § 10
|
||||
// acceptance criterion #9 (bootstrap workflow must be reproducible without
|
||||
// manual file editing).
|
||||
|
||||
import { runCli as runOlpKeysCli, parseArgv as parseOlpKeysArgv } from './bin/olp-keys.mjs';
|
||||
|
||||
describe('Suite 22 — D47 keygen CLI (bin/olp-keys.mjs, ADR 0007 § 9.1)', () => {
|
||||
|
||||
describe('22a — parseArgv unit tests', () => {
|
||||
it('22a-1: empty argv → empty positional + empty flags', () => {
|
||||
const r = parseOlpKeysArgv([]);
|
||||
assert.deepEqual(r.positional, []);
|
||||
assert.deepEqual(r.flags, {});
|
||||
});
|
||||
|
||||
it('22a-2: --flag=value form', () => {
|
||||
const r = parseOlpKeysArgv(['--name=keyA', '--tier=owner']);
|
||||
assert.equal(r.flags.name, 'keyA');
|
||||
assert.equal(r.flags.tier, 'owner');
|
||||
});
|
||||
|
||||
it('22a-3: --flag value form (space-separated)', () => {
|
||||
const r = parseOlpKeysArgv(['--name', 'keyB', '--tier', 'guest']);
|
||||
assert.equal(r.flags.name, 'keyB');
|
||||
assert.equal(r.flags.tier, 'guest');
|
||||
});
|
||||
|
||||
it('22a-4: --flag (boolean, no value) when followed by another --flag', () => {
|
||||
const r = parseOlpKeysArgv(['--owner', '--force']);
|
||||
assert.equal(r.flags.owner, true);
|
||||
assert.equal(r.flags.force, true);
|
||||
});
|
||||
|
||||
it('22a-5: positional args + flags mixed', () => {
|
||||
const r = parseOlpKeysArgv(['keygen', '--owner']);
|
||||
assert.deepEqual(r.positional, ['keygen']);
|
||||
assert.equal(r.flags.owner, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('22b — keygen subcommand', () => {
|
||||
let TMP;
|
||||
before(() => { TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-22b-')); });
|
||||
after(() => { rmSync(TMP, { recursive: true, force: true }); __resetWriteLocks(); });
|
||||
|
||||
it('22b-1: keygen --owner creates owner key + prints plaintext token to stdout', async () => {
|
||||
let out = '';
|
||||
const code = await runOlpKeysCli(['keygen', '--owner', '--olp-home', TMP], {
|
||||
out: s => { out += s; },
|
||||
err: () => {},
|
||||
});
|
||||
assert.equal(code, 0);
|
||||
assert.match(out, /token \(plaintext\):\s+olp_[A-Za-z0-9_-]{43}/);
|
||||
assert.match(out, /owner_tier:\s+owner/);
|
||||
// Verify manifest was actually written
|
||||
const list = listKeys({ olpHome: TMP });
|
||||
assert.equal(list.length, 1);
|
||||
assert.equal(list[0].owner_tier, 'owner');
|
||||
});
|
||||
|
||||
it('22b-2: keygen --name=test --providers=anthropic,openai → guest key with explicit providers', async () => {
|
||||
let out = '';
|
||||
const code = await runOlpKeysCli(
|
||||
['keygen', '--name=test-guest', '--providers=anthropic,openai', '--olp-home', TMP],
|
||||
{ out: s => { out += s; }, err: () => {} },
|
||||
);
|
||||
assert.equal(code, 0);
|
||||
assert.match(out, /owner_tier:\s+guest/);
|
||||
assert.match(out, /providers_enabled:\s+\[anthropic, openai\]/);
|
||||
});
|
||||
|
||||
it('22b-3: keygen without --name AND without --owner → error exit 1', async () => {
|
||||
let err = '';
|
||||
const code = await runOlpKeysCli(['keygen', '--olp-home', TMP], {
|
||||
out: () => {},
|
||||
err: s => { err += s; },
|
||||
});
|
||||
assert.equal(code, 1);
|
||||
assert.match(err, /--name is required/);
|
||||
});
|
||||
|
||||
it('22b-4: keygen --tier=admin → error exit 1 (invalid tier)', async () => {
|
||||
let err = '';
|
||||
const code = await runOlpKeysCli(
|
||||
['keygen', '--name=bad', '--tier=admin', '--olp-home', TMP],
|
||||
{ out: () => {}, err: s => { err += s; } },
|
||||
);
|
||||
assert.equal(code, 1);
|
||||
assert.match(err, /--tier must be "owner" or "guest"/);
|
||||
});
|
||||
|
||||
it('22b-5: keygen --owner --force revokes existing owner + creates new', async () => {
|
||||
// Use a fresh tmpdir for this isolation
|
||||
const TMP2 = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-22b5-'));
|
||||
try {
|
||||
// Create initial owner
|
||||
await runOlpKeysCli(['keygen', '--owner', '--olp-home', TMP2], { out: () => {}, err: () => {} });
|
||||
const list1 = listKeys({ olpHome: TMP2 });
|
||||
assert.equal(list1.length, 1);
|
||||
const originalId = list1[0].id;
|
||||
|
||||
// --force: revoke existing + create new
|
||||
let err = '';
|
||||
const code = await runOlpKeysCli(['keygen', '--owner', '--force', '--olp-home', TMP2], {
|
||||
out: () => {},
|
||||
err: s => { err += s; },
|
||||
});
|
||||
assert.equal(code, 0);
|
||||
assert.match(err, new RegExp(`Revoked existing owner key id=${originalId}`));
|
||||
|
||||
// Listing including revoked must show 2 entries; active-only must show 1
|
||||
const listAll = listKeys({ olpHome: TMP2 });
|
||||
assert.equal(listAll.length, 2);
|
||||
const activeOwners = listAll.filter(k => k.owner_tier === 'owner' && k.revoked_at === null);
|
||||
assert.equal(activeOwners.length, 1, 'exactly one active owner after --force');
|
||||
const revokedOwners = listAll.filter(k => k.owner_tier === 'owner' && k.revoked_at !== null);
|
||||
assert.equal(revokedOwners.length, 1, 'exactly one revoked owner after --force');
|
||||
assert.equal(revokedOwners[0].id, originalId);
|
||||
} finally {
|
||||
rmSync(TMP2, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('22c — list subcommand', () => {
|
||||
let TMP;
|
||||
before(() => { TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-22c-')); });
|
||||
after(() => { rmSync(TMP, { recursive: true, force: true }); __resetWriteLocks(); });
|
||||
|
||||
it('22c-1: list with no keys → "No keys."', async () => {
|
||||
let out = '';
|
||||
const code = await runOlpKeysCli(['list', '--olp-home', TMP], {
|
||||
out: s => { out += s; }, err: () => {},
|
||||
});
|
||||
assert.equal(code, 0);
|
||||
assert.match(out, /No keys\./);
|
||||
});
|
||||
|
||||
it('22c-2: list after 2 keys → both visible with redacted token_hash', async () => {
|
||||
await runOlpKeysCli(['keygen', '--name=alpha', '--olp-home', TMP], { out: () => {}, err: () => {} });
|
||||
await runOlpKeysCli(['keygen', '--name=beta', '--owner', '--olp-home', TMP], { out: () => {}, err: () => {} });
|
||||
let out = '';
|
||||
const code = await runOlpKeysCli(['list', '--olp-home', TMP], { out: s => { out += s; }, err: () => {} });
|
||||
assert.equal(code, 0);
|
||||
assert.match(out, /name:\s+alpha/);
|
||||
assert.match(out, /name:\s+beta/);
|
||||
assert.match(out, /owner_tier:\s+owner/);
|
||||
assert.match(out, /owner_tier:\s+guest/);
|
||||
// token_hash MUST NOT appear in list output (lib/keys.mjs listKeys redacts it)
|
||||
assert.ok(!out.includes('token_hash'), 'list output must not include token_hash field');
|
||||
});
|
||||
|
||||
it('22c-3: list --owner-only filters to owner_tier=owner', async () => {
|
||||
let out = '';
|
||||
const code = await runOlpKeysCli(['list', '--owner-only', '--olp-home', TMP], {
|
||||
out: s => { out += s; }, err: () => {},
|
||||
});
|
||||
assert.equal(code, 0);
|
||||
assert.ok(!out.includes('name: alpha'), 'guest "alpha" must be filtered out with --owner-only');
|
||||
assert.match(out, /name:\s+beta/, 'owner "beta" must remain');
|
||||
});
|
||||
});
|
||||
|
||||
describe('22d — revoke subcommand', () => {
|
||||
let TMP, keyId;
|
||||
before(async () => {
|
||||
TMP = _mkdtempSyncForSetup(_pathJoinForSetup(_tmpdirForSetup(), 'olp-test-22d-'));
|
||||
const r = createKey({ name: '22d-target', owner_tier: 'guest', olpHome: TMP });
|
||||
keyId = r.id;
|
||||
});
|
||||
after(() => { rmSync(TMP, { recursive: true, force: true }); __resetWriteLocks(); });
|
||||
|
||||
it('22d-1: revoke --id=<valid> → exit 0, manifest revoked_at set', async () => {
|
||||
let out = '';
|
||||
const code = await runOlpKeysCli(['revoke', `--id=${keyId}`, '--olp-home', TMP], {
|
||||
out: s => { out += s; }, err: () => {},
|
||||
});
|
||||
assert.equal(code, 0);
|
||||
assert.match(out, new RegExp(`Revoked key id=${keyId}`));
|
||||
const m = readManifest(keyId, { olpHome: TMP });
|
||||
assert.ok(m.revoked_at !== null);
|
||||
});
|
||||
|
||||
it('22d-2: revoke --id=<already-revoked> → exit 0 + "already revoked" message (idempotent)', async () => {
|
||||
let out = '';
|
||||
const code = await runOlpKeysCli(['revoke', `--id=${keyId}`, '--olp-home', TMP], {
|
||||
out: s => { out += s; }, err: () => {},
|
||||
});
|
||||
assert.equal(code, 0);
|
||||
assert.match(out, /already revoked/);
|
||||
});
|
||||
|
||||
it('22d-3: revoke without --id → exit 1', async () => {
|
||||
let err = '';
|
||||
const code = await runOlpKeysCli(['revoke', '--olp-home', TMP], {
|
||||
out: () => {}, err: s => { err += s; },
|
||||
});
|
||||
assert.equal(code, 1);
|
||||
assert.match(err, /--id=<key-id> is required/);
|
||||
});
|
||||
|
||||
it('22d-4: revoke --id=<nonexistent> → exit 2', async () => {
|
||||
let err = '';
|
||||
const code = await runOlpKeysCli(['revoke', '--id=nonexistent-key', '--olp-home', TMP], {
|
||||
out: () => {}, err: s => { err += s; },
|
||||
});
|
||||
assert.equal(code, 2);
|
||||
assert.match(err, /no key with id="nonexistent-key"/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('22e — top-level CLI behaviour', () => {
|
||||
it('22e-1: --help → exit 0 with usage text', async () => {
|
||||
let out = '';
|
||||
const code = await runOlpKeysCli(['--help'], { out: s => { out += s; }, err: () => {} });
|
||||
assert.equal(code, 0);
|
||||
assert.match(out, /OLP key management CLI/);
|
||||
assert.match(out, /keygen/);
|
||||
assert.match(out, /list/);
|
||||
assert.match(out, /revoke/);
|
||||
});
|
||||
|
||||
it('22e-2: no args → exit 1 with usage text', async () => {
|
||||
let out = '';
|
||||
const code = await runOlpKeysCli([], { out: s => { out += s; }, err: () => {} });
|
||||
assert.equal(code, 1);
|
||||
assert.match(out, /OLP key management CLI/);
|
||||
});
|
||||
|
||||
it('22e-3: unknown subcommand → exit 1 with error', async () => {
|
||||
let err = '';
|
||||
const code = await runOlpKeysCli(['frobnicate'], { out: () => {}, err: s => { err += s; } });
|
||||
assert.equal(code, 1);
|
||||
assert.match(err, /unknown subcommand "frobnicate"/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user