logicsrc/plugins/credential-sharing/src/engine.test.ts
Anthony Ettinger cf73fe5af2 feat(credential-sharing): implement the Credential Sharing OpenSpec (M1-M3)
New @logicsrc/plugin-credential-sharing: a provider-neutral secret-sync engine
with env/.env, Doppler, Railway, and GitHub Secrets adapters behind one
CredentialProvider contract.

- engine: inspect -> diff -> plan -> approve -> sync -> rollback -> audit/export
- dry-run is the default for sync; --approve writes; destructive changes gated
- fingerprint-based diffs (salted SHA-256); raw values never printed or stored in
  plans/runs/audit; rollback pre-image kept in a 0600 .logicsrc vault (gitignored)
- github-secrets is write-only for values (sealed-box via libsodium), so it cannot
  be a sync source or value-restoring rollback target
- CLI: real `logicsrc credentials <providers|inspect|diff|plan|approve|sync|
  rollback|audit|export>` (replaces the prior stub)
- 4 JSON schemas registered in @logicsrc/validators
- flip logicsrc.com/credential-sharing band from coming-soon to available
- 37 tests pass; full env->env lifecycle verified; artifacts schema-validate

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 15:24:30 +00:00

145 lines
6.5 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { CredentialEngine } from "./engine.js";
import { createMemoryCredentialStore } from "./store.js";
import { fingerprintValue } from "./fingerprint.js";
import type { CredentialProvider, CredentialValueBag } from "./types.js";
/** In-memory provider backed by a mutable bag — stands in for env/doppler/railway. */
function memoryProvider(id: string, initial: CredentialValueBag, opts: { readValues?: boolean } = {}): CredentialProvider & { store: CredentialValueBag } {
const store: CredentialValueBag = { ...initial };
const readValues = opts.readValues ?? true;
return {
id,
name: id,
description: id,
capabilities: { readValues, readNames: true, write: true, delete: true, rollback: readValues, audit: false },
authRequirements: [],
status: "available",
store,
async inspect(endpoint) {
return {
provider: id,
endpoint,
valuesReadable: readValues,
keys: Object.keys(store)
.sort()
.map((name) => ({ name, present: true, fingerprint: readValues ? fingerprintValue(store[name]) : undefined })),
inspectedAt: new Date().toISOString()
};
},
async readValues(_endpoint, keys) {
return Object.fromEntries(keys.filter((k) => k in store).map((k) => [k, store[k]]));
},
async write({ upserts, deletes, dryRun }) {
const results = [
...Object.keys(upserts).map((key) => ({ key, applied: !dryRun })),
...deletes.map((key) => ({ key, applied: !dryRun }))
];
if (!dryRun) {
Object.assign(store, upserts);
for (const key of deletes) delete store[key];
}
return results;
}
};
}
function makeEngine(providers: CredentialProvider[]) {
let counter = 0;
return new CredentialEngine({
providers: new Map(providers.map((p) => [p.id, p])),
store: createMemoryCredentialStore(),
now: () => new Date("2026-06-27T00:00:00.000Z"),
idFactory: (prefix) => `${prefix}_${++counter}`
});
}
describe("CredentialEngine diff + plan", () => {
it("classifies add / update / unchanged by fingerprint", async () => {
const source = memoryProvider("env", { A: "1", B: "2", SAME: "x" });
const target = memoryProvider("railway", { B: "old", SAME: "x" });
const engine = makeEngine([source, target]);
const diff = await engine.diffCredentialEndpoints({ provider: "env" }, { provider: "railway" });
const byKey = Object.fromEntries(diff.entries.map((e) => [e.key, e.op]));
expect(byKey).toEqual({ A: "add", B: "update", SAME: "unchanged" });
});
it("never includes raw values in a plan", async () => {
const source = memoryProvider("env", { SECRET: "super-secret-value" });
const target = memoryProvider("railway", {});
const engine = makeEngine([source, target]);
const plan = await engine.createCredentialSyncPlan({ from: { provider: "env" }, to: { provider: "railway" } });
expect(JSON.stringify(plan)).not.toContain("super-secret-value");
expect(plan.changes[0]).toMatchObject({ key: "SECRET", op: "add" });
expect(plan.changes[0].sourceFingerprint).toBe(fingerprintValue("super-secret-value"));
});
});
describe("CredentialEngine sync safety", () => {
it("dry-run does not mutate the target", async () => {
const source = memoryProvider("env", { A: "1" });
const target = memoryProvider("railway", {});
const engine = makeEngine([source, target]);
const plan = await engine.createCredentialSyncPlan({ from: { provider: "env" }, to: { provider: "railway" } });
const run = await engine.runCredentialSync(plan.id, { dryRun: true });
expect(run.status).toBe("dry_run");
expect(target.store).toEqual({});
});
it("requires approval before a destructive apply", async () => {
const source = memoryProvider("env", { A: "new" });
const target = memoryProvider("railway", { A: "old" });
const engine = makeEngine([source, target]);
const plan = await engine.createCredentialSyncPlan({ from: { provider: "env" }, to: { provider: "railway" } });
expect(plan.requiresApproval).toBe(true);
await expect(engine.runCredentialSync(plan.id, { dryRun: false })).rejects.toThrow(/requires approval/);
const approval = engine.approveCredentialSync(plan.id);
const run = await engine.runCredentialSync(plan.id, { dryRun: false, approval });
expect(run.status).toBe("applied");
expect(target.store.A).toBe("new");
});
it("refuses a write-only provider as a sync source", async () => {
const source = memoryProvider("github-secrets", { A: "1" }, { readValues: false });
const target = memoryProvider("railway", {});
const engine = makeEngine([source, target]);
await expect(engine.createCredentialSyncPlan({ from: { provider: "github-secrets" }, to: { provider: "railway" } })).rejects.toThrow(/cannot read values/);
});
});
describe("CredentialEngine rollback + audit", () => {
it("rolls back updates to the pre-image and removes newly added keys", async () => {
const source = memoryProvider("env", { A: "new", ADDED: "fresh" });
const target = memoryProvider("railway", { A: "original" });
const engine = makeEngine([source, target]);
const plan = await engine.createCredentialSyncPlan({ from: { provider: "env" }, to: { provider: "railway" } });
const approval = engine.approveCredentialSync(plan.id);
const run = await engine.runCredentialSync(plan.id, { dryRun: false, approval });
expect(target.store).toEqual({ A: "new", ADDED: "fresh" });
const rollbackPlan = await engine.rollbackCredentialSync(run.id);
expect(rollbackPlan.rollbackOfRunId).toBe(run.id);
const rollbackApproval = engine.approveCredentialSync(rollbackPlan.id);
await engine.runCredentialSync(rollbackPlan.id, { dryRun: false, approval: rollbackApproval });
expect(target.store).toEqual({ A: "original" });
});
it("writes audit events with fingerprints, never raw values", async () => {
const source = memoryProvider("env", { TOKEN: "raw-token-abc" });
const target = memoryProvider("railway", {});
const engine = makeEngine([source, target]);
const plan = await engine.createCredentialSyncPlan({ from: { provider: "env" }, to: { provider: "railway" } });
const run = await engine.runCredentialSync(plan.id, { dryRun: false });
const audit = engine.exportCredentialAudit(run.id);
expect(audit).toHaveLength(1);
expect(audit[0]).toMatchObject({ key: "TOKEN", action: "credentials:add", fingerprint: fingerprintValue("raw-token-abc") });
expect(JSON.stringify(audit)).not.toContain("raw-token-abc");
});
});