mirror of
https://github.com/EvoMap/evolver.git
synced 2026-09-18 21:47:53 +08:00
433 lines
19 KiB
JavaScript
433 lines
19 KiB
JavaScript
// Tests for issue #111 Phase 1: keychain-backed workspace-id with FS
|
|
// fallback. These tests do NOT depend on `@napi-rs/keyring` being
|
|
// installed — the keychain module is treated as optional and we
|
|
// inject mock implementations through `require.cache` to exercise the
|
|
// hit / miss / migrate / force-throw branches deterministically.
|
|
|
|
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const { canCreateSymlinks } = require('./helpers/symlink');
|
|
|
|
// Symlink-rejection tests need to plant a real symlink before exercising
|
|
// the code under test; on Windows non-admin that fails with EPERM in
|
|
// setup. Route those tests through `symlinkTest` which becomes a no-op
|
|
// skip when symlink creation isn't available.
|
|
const symlinkTest = canCreateSymlinks() ? test : test.skip;
|
|
|
|
const PATHS_PATH = require.resolve('../src/gep/paths.js');
|
|
const KEYCHAIN_PATH = require.resolve('../src/gep/workspaceKeychain.js');
|
|
|
|
function freshRequire(modulePath) {
|
|
delete require.cache[modulePath];
|
|
return require(modulePath);
|
|
}
|
|
|
|
function mkTmpWorkspace(prefix) {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
fs.mkdirSync(path.join(dir, 'workspace'), { recursive: true });
|
|
return path.join(dir, 'workspace');
|
|
}
|
|
|
|
// Replace workspaceKeychain.js in the require cache with a mock that
|
|
// records all calls and lets each test pre-seed its own state.
|
|
function installKeychainMock({
|
|
available = true,
|
|
initialEntries = {},
|
|
readReportsUnavailable = false, // simulate mid-execution unavailability (locked keyring)
|
|
writeFails = false, // simulate keychain write rejection
|
|
} = {}) {
|
|
const store = new Map(Object.entries(initialEntries));
|
|
const calls = { read: [], write: [], loadAddon: 0 };
|
|
const mock = {
|
|
KEYCHAIN_SERVICE: 'evomap.evolver.workspace-id',
|
|
getMode() {
|
|
const raw = String(process.env.EVOLVER_WORKSPACE_KEYCHAIN || 'auto').toLowerCase().trim();
|
|
if (raw === 'force' || raw === 'off' || raw === 'auto') return raw;
|
|
return 'auto';
|
|
},
|
|
loadAddon() {
|
|
calls.loadAddon++;
|
|
return available ? { Entry: function () {} } : null;
|
|
},
|
|
readFromKeychain(account) {
|
|
calls.read.push(account);
|
|
if (!available) return { available: false, id: null };
|
|
if (readReportsUnavailable) return { available: false, id: null };
|
|
const id = store.get(account) || null;
|
|
return { available: true, id };
|
|
},
|
|
writeToKeychain(account, id) {
|
|
calls.write.push({ account, id });
|
|
if (!available) return false;
|
|
if (writeFails) return false;
|
|
store.set(account, id);
|
|
return true;
|
|
},
|
|
};
|
|
require.cache[KEYCHAIN_PATH] = {
|
|
id: KEYCHAIN_PATH,
|
|
filename: KEYCHAIN_PATH,
|
|
loaded: true,
|
|
exports: mock,
|
|
children: [],
|
|
paths: [],
|
|
};
|
|
return { mock, store, calls };
|
|
}
|
|
|
|
function clearKeychainMock() {
|
|
delete require.cache[KEYCHAIN_PATH];
|
|
}
|
|
|
|
describe('workspace-id keychain integration (issue #111 Phase 1)', () => {
|
|
let savedEnv;
|
|
let workspace;
|
|
|
|
beforeEach(() => {
|
|
savedEnv = {
|
|
EVOLVER_WORKSPACE_ID: process.env.EVOLVER_WORKSPACE_ID,
|
|
EVOLVER_WORKSPACE_KEYCHAIN: process.env.EVOLVER_WORKSPACE_KEYCHAIN,
|
|
OPENCLAW_WORKSPACE: process.env.OPENCLAW_WORKSPACE,
|
|
};
|
|
delete process.env.EVOLVER_WORKSPACE_ID;
|
|
delete process.env.EVOLVER_WORKSPACE_KEYCHAIN;
|
|
workspace = mkTmpWorkspace('evolver-111-');
|
|
process.env.OPENCLAW_WORKSPACE = workspace;
|
|
delete require.cache[PATHS_PATH];
|
|
clearKeychainMock();
|
|
});
|
|
|
|
afterEach(() => {
|
|
for (const [k, v] of Object.entries(savedEnv)) {
|
|
if (v === undefined) delete process.env[k];
|
|
else process.env[k] = v;
|
|
}
|
|
try { fs.rmSync(path.dirname(workspace), { recursive: true, force: true }); } catch { /* */ }
|
|
delete require.cache[PATHS_PATH];
|
|
clearKeychainMock();
|
|
});
|
|
|
|
test('EVOLVER_WORKSPACE_ID env always wins, regardless of mode', () => {
|
|
process.env.EVOLVER_WORKSPACE_ID = 'overridden-by-env';
|
|
process.env.EVOLVER_WORKSPACE_KEYCHAIN = 'force';
|
|
installKeychainMock({ available: false });
|
|
const paths = freshRequire(PATHS_PATH);
|
|
assert.equal(paths.getWorkspaceId(), 'overridden-by-env');
|
|
});
|
|
|
|
test('mode=off bypasses keychain entirely (FS only)', () => {
|
|
process.env.EVOLVER_WORKSPACE_KEYCHAIN = 'off';
|
|
const { calls } = installKeychainMock({ available: true });
|
|
const paths = freshRequire(PATHS_PATH);
|
|
const id = paths.getWorkspaceId();
|
|
assert.match(id, /^[a-f0-9]{32}$/);
|
|
assert.equal(calls.read.length, 0, 'mode=off must not read keychain');
|
|
assert.equal(calls.write.length, 0, 'mode=off must not write keychain');
|
|
assert.ok(fs.existsSync(path.join(workspace, '.evolver', 'workspace-id')));
|
|
});
|
|
|
|
test('mode=auto with addon unavailable falls back to FS silently', () => {
|
|
process.env.EVOLVER_WORKSPACE_KEYCHAIN = 'auto';
|
|
const { calls } = installKeychainMock({ available: false });
|
|
const paths = freshRequire(PATHS_PATH);
|
|
const id = paths.getWorkspaceId();
|
|
assert.match(id, /^[a-f0-9]{32}$/);
|
|
assert.equal(calls.read.length, 0, 'unavailable addon must short-circuit before read');
|
|
const onDisk = fs.readFileSync(path.join(workspace, '.evolver', 'workspace-id'), 'utf8').trim();
|
|
assert.equal(onDisk, id, 'FS must hold the same id when addon is unavailable');
|
|
});
|
|
|
|
test('mode=force throws when addon is unavailable', () => {
|
|
process.env.EVOLVER_WORKSPACE_KEYCHAIN = 'force';
|
|
installKeychainMock({ available: false });
|
|
const paths = freshRequire(PATHS_PATH);
|
|
assert.throws(() => paths.getWorkspaceId(), /EVOLVER_WORKSPACE_KEYCHAIN=force/);
|
|
});
|
|
|
|
test('mode=auto with addon hit returns keychain value without touching FS', () => {
|
|
process.env.EVOLVER_WORKSPACE_KEYCHAIN = 'auto';
|
|
const seeded = '0123456789abcdef0123456789abcdef';
|
|
const { calls } = installKeychainMock({
|
|
available: true,
|
|
initialEntries: { [workspace]: seeded },
|
|
});
|
|
const paths = freshRequire(PATHS_PATH);
|
|
const id = paths.getWorkspaceId();
|
|
assert.equal(id, seeded);
|
|
assert.equal(calls.read.length, 1);
|
|
assert.equal(calls.write.length, 0, 'a clean keychain hit must not re-write');
|
|
assert.ok(!fs.existsSync(path.join(workspace, '.evolver', 'workspace-id')),
|
|
'a keychain hit must not lazily create the FS file');
|
|
});
|
|
|
|
test('mode=auto with FS-only secret migrates into keychain and KEEPS FS file', () => {
|
|
process.env.EVOLVER_WORKSPACE_KEYCHAIN = 'auto';
|
|
const fsId = 'fedcba9876543210fedcba9876543210';
|
|
fs.mkdirSync(path.join(workspace, '.evolver'), { recursive: true });
|
|
fs.writeFileSync(path.join(workspace, '.evolver', 'workspace-id'), fsId + '\n', { mode: 0o600 });
|
|
|
|
const { calls, store } = installKeychainMock({ available: true });
|
|
const paths = freshRequire(PATHS_PATH);
|
|
const id = paths.getWorkspaceId();
|
|
|
|
assert.equal(id, fsId, 'returned id must match the pre-existing FS secret');
|
|
assert.equal(calls.read.length, 1, 'keychain miss path triggers one read');
|
|
assert.equal(calls.write.length, 1, 'FS-only secret must be migrated into keychain');
|
|
assert.equal(store.get(workspace), fsId);
|
|
assert.ok(fs.existsSync(path.join(workspace, '.evolver', 'workspace-id')),
|
|
'FS file MUST be retained after migration so bun-compiled binaries (which can\'t require the addon yet — Phase 2) still see the same id');
|
|
});
|
|
|
|
test('mode=auto with empty keychain + empty FS generates id, writes both', () => {
|
|
process.env.EVOLVER_WORKSPACE_KEYCHAIN = 'auto';
|
|
const { calls, store } = installKeychainMock({ available: true });
|
|
const paths = freshRequire(PATHS_PATH);
|
|
const id = paths.getWorkspaceId();
|
|
assert.match(id, /^[a-f0-9]{32}$/);
|
|
assert.equal(store.get(workspace), id, 'new id must be mirrored to keychain');
|
|
const fsContent = fs.readFileSync(path.join(workspace, '.evolver', 'workspace-id'), 'utf8').trim();
|
|
assert.equal(fsContent, id, 'new id must also land on FS for cross-binary handoff');
|
|
assert.equal(calls.write.length, 1);
|
|
});
|
|
|
|
test('writer + reader resolve the same id when only keychain is populated', () => {
|
|
process.env.EVOLVER_WORKSPACE_KEYCHAIN = 'auto';
|
|
installKeychainMock({ available: true });
|
|
const paths = freshRequire(PATHS_PATH);
|
|
const writerId = paths.getWorkspaceId();
|
|
const readerId = paths.getWorkspaceId();
|
|
assert.equal(writerId, readerId);
|
|
assert.match(writerId, /^[a-f0-9]{32}$/);
|
|
});
|
|
|
|
test('default mode (no env set) is auto', () => {
|
|
// No EVOLVER_WORKSPACE_KEYCHAIN set.
|
|
delete process.env.EVOLVER_WORKSPACE_KEYCHAIN;
|
|
const keychain = freshRequire(KEYCHAIN_PATH);
|
|
assert.equal(keychain.getMode(), 'auto');
|
|
});
|
|
|
|
test('unknown EVOLVER_WORKSPACE_KEYCHAIN value normalizes to auto', () => {
|
|
process.env.EVOLVER_WORKSPACE_KEYCHAIN = 'YOLO';
|
|
const keychain = freshRequire(KEYCHAIN_PATH);
|
|
assert.equal(keychain.getMode(), 'auto');
|
|
});
|
|
|
|
test('auto/force/off mode names are case-insensitive', () => {
|
|
process.env.EVOLVER_WORKSPACE_KEYCHAIN = 'FORCE';
|
|
const k1 = freshRequire(KEYCHAIN_PATH);
|
|
assert.equal(k1.getMode(), 'force');
|
|
process.env.EVOLVER_WORKSPACE_KEYCHAIN = ' Off ';
|
|
const k2 = freshRequire(KEYCHAIN_PATH);
|
|
assert.equal(k2.getMode(), 'off');
|
|
});
|
|
|
|
// Regression test for Bugbot PR #121 round-1 HIGH: the refactor split
|
|
// the original monolithic getWorkspaceId() into _readWorkspaceIdFromFs
|
|
// and _writeWorkspaceIdToFs. The read helper retained the directory-
|
|
// symlink guard but the write helper called mkdirSync({recursive:true})
|
|
// through a symlinked `.evolver`, dropping the secret outside the
|
|
// workspace. PR #109 round-2 HIGH originally hardened against this and
|
|
// we must not regress.
|
|
symlinkTest('refuses to write through a symlinked .evolver dir (Bugbot PR#121 round-1 HIGH; original PR#109 round-2 HIGH)', () => {
|
|
process.env.EVOLVER_WORKSPACE_KEYCHAIN = 'off'; // exercise write path directly
|
|
// Pre-place .evolver as a symlink to an attacker-controlled dir.
|
|
const attackerDir = fs.mkdtempSync(path.join(os.tmpdir(), 'evolver-111-attacker-'));
|
|
fs.symlinkSync(attackerDir, path.join(workspace, '.evolver'));
|
|
|
|
const paths = freshRequire(PATHS_PATH);
|
|
const id = paths.getWorkspaceId();
|
|
assert.equal(id, null, 'symlinked .evolver must yield null, never a write through the link');
|
|
|
|
// Critically: NO secret file in the attacker-controlled target.
|
|
assert.ok(!fs.existsSync(path.join(attackerDir, 'workspace-id')),
|
|
'mkdirSync({recursive:true}) must not be allowed to traverse a symlinked .evolver and drop the secret outside the workspace');
|
|
try { fs.rmSync(attackerDir, { recursive: true, force: true }); } catch { /* */ }
|
|
});
|
|
|
|
symlinkTest('mode=auto with addon-hit MISS still refuses symlinked .evolver write', () => {
|
|
process.env.EVOLVER_WORKSPACE_KEYCHAIN = 'auto';
|
|
const attackerDir = fs.mkdtempSync(path.join(os.tmpdir(), 'evolver-111-attacker2-'));
|
|
fs.symlinkSync(attackerDir, path.join(workspace, '.evolver'));
|
|
|
|
installKeychainMock({ available: true }); // keychain miss → falls into write path
|
|
const paths = freshRequire(PATHS_PATH);
|
|
const id = paths.getWorkspaceId();
|
|
assert.equal(id, null);
|
|
assert.ok(!fs.existsSync(path.join(attackerDir, 'workspace-id')),
|
|
'keychain auto path must not write the secret through a symlinked .evolver either');
|
|
try { fs.rmSync(attackerDir, { recursive: true, force: true }); } catch { /* */ }
|
|
});
|
|
|
|
// Bugbot PR #121 round-2 MEDIUM (Agentic Security): `force` mode
|
|
// must NOT silently fall back to FS read/write — that would defeat
|
|
// the entire purpose of opting into keychain-only handling.
|
|
test('mode=force on a fresh workspace mints + writes keychain-only, NEVER touches FS', () => {
|
|
process.env.EVOLVER_WORKSPACE_KEYCHAIN = 'force';
|
|
const { calls, store } = installKeychainMock({ available: true });
|
|
const paths = freshRequire(PATHS_PATH);
|
|
const id = paths.getWorkspaceId();
|
|
assert.match(id, /^[a-f0-9]{32}$/);
|
|
assert.equal(store.get(workspace), id, 'force mode must persist via keychain');
|
|
assert.ok(!fs.existsSync(path.join(workspace, '.evolver', 'workspace-id')),
|
|
'force mode must NEVER create the FS file (would re-introduce same-uid plaintext exposure)');
|
|
assert.equal(calls.write.length, 1);
|
|
});
|
|
|
|
test('mode=force throws when keychain read reports unavailable mid-call (locked keyring)', () => {
|
|
process.env.EVOLVER_WORKSPACE_KEYCHAIN = 'force';
|
|
installKeychainMock({ available: true, readReportsUnavailable: true });
|
|
const paths = freshRequire(PATHS_PATH);
|
|
assert.throws(() => paths.getWorkspaceId(), /keychain reports unavailable/);
|
|
assert.ok(!fs.existsSync(path.join(workspace, '.evolver', 'workspace-id')),
|
|
'force + locked keyring must NOT silently degrade to FS write');
|
|
});
|
|
|
|
test('mode=force throws when keychain write fails (read empty + write rejected)', () => {
|
|
process.env.EVOLVER_WORKSPACE_KEYCHAIN = 'force';
|
|
installKeychainMock({ available: true, writeFails: true });
|
|
const paths = freshRequire(PATHS_PATH);
|
|
assert.throws(() => paths.getWorkspaceId(), /keychain write failed/);
|
|
assert.ok(!fs.existsSync(path.join(workspace, '.evolver', 'workspace-id')),
|
|
'force + keychain-write-failure must throw, never mirror to FS');
|
|
});
|
|
|
|
test('mode=auto with keychain reporting unavailable (locked keyring) DOES fall back to FS', () => {
|
|
process.env.EVOLVER_WORKSPACE_KEYCHAIN = 'auto';
|
|
installKeychainMock({ available: true, readReportsUnavailable: true });
|
|
const paths = freshRequire(PATHS_PATH);
|
|
const id = paths.getWorkspaceId();
|
|
// auto mode degrades gracefully — locked keyring should NOT block
|
|
// the user from getting a workspace-id at all.
|
|
assert.match(id, /^[a-f0-9]{32}$/);
|
|
assert.ok(fs.existsSync(path.join(workspace, '.evolver', 'workspace-id')),
|
|
'auto mode + locked keyring must fall back to FS (it is the documented escape hatch)');
|
|
});
|
|
|
|
test('mode=force does NOT migrate a pre-existing FS secret (refuses to read it)', () => {
|
|
process.env.EVOLVER_WORKSPACE_KEYCHAIN = 'force';
|
|
const fsId = 'aaaabbbbccccdddd0000111122223333';
|
|
fs.mkdirSync(path.join(workspace, '.evolver'), { recursive: true });
|
|
fs.writeFileSync(path.join(workspace, '.evolver', 'workspace-id'), fsId + '\n', { mode: 0o600 });
|
|
|
|
const { store } = installKeychainMock({ available: true });
|
|
const paths = freshRequire(PATHS_PATH);
|
|
const id = paths.getWorkspaceId();
|
|
// force mode generates a fresh id keychain-side, ignoring the FS file.
|
|
assert.match(id, /^[a-f0-9]{32}$/);
|
|
assert.notEqual(id, fsId, 'force mode must not read the FS secret (would defeat keychain-only enforcement)');
|
|
assert.equal(store.get(workspace), id);
|
|
});
|
|
});
|
|
|
|
// Direct unit tests for the readFromKeychain NoEntry/PlatformFailure
|
|
// discrimination (Bugbot PR #121 round-3 MEDIUM). The mock used in the
|
|
// suite above shortcuts loadAddon — these tests exercise the REAL
|
|
// readFromKeychain against a fake `@napi-rs/keyring` addon shape and
|
|
// prove the `available: false` branch is reachable, not dead code.
|
|
describe('readFromKeychain error-class discrimination (real impl)', () => {
|
|
const ADDON_PATH = '@napi-rs/keyring';
|
|
let savedCache;
|
|
|
|
function installFakeAddon(getPasswordImpl) {
|
|
const fake = {
|
|
Entry: function (service, account) {
|
|
this.service = service;
|
|
this.account = account;
|
|
},
|
|
};
|
|
fake.Entry.prototype.getPassword = getPasswordImpl;
|
|
fake.Entry.prototype.setPassword = function () { /* no-op */ };
|
|
// Hijack Module._cache so `require('@napi-rs/keyring')` resolves
|
|
// to our fake even when the real package isn't installed.
|
|
const Module = require('module');
|
|
const fakeId = '__fake_napi_keyring__';
|
|
require.cache[fakeId] = {
|
|
id: fakeId, filename: fakeId, loaded: true, exports: fake,
|
|
children: [], paths: [],
|
|
};
|
|
const orig = Module._resolveFilename;
|
|
savedCache = { orig, fakeId };
|
|
Module._resolveFilename = function (req, ...rest) {
|
|
if (req === ADDON_PATH) return fakeId;
|
|
return orig.call(this, req, ...rest);
|
|
};
|
|
}
|
|
|
|
function uninstallFakeAddon() {
|
|
if (savedCache) {
|
|
const Module = require('module');
|
|
Module._resolveFilename = savedCache.orig;
|
|
delete require.cache[savedCache.fakeId];
|
|
savedCache = null;
|
|
}
|
|
}
|
|
|
|
afterEach(() => {
|
|
uninstallFakeAddon();
|
|
delete require.cache[KEYCHAIN_PATH];
|
|
});
|
|
|
|
test('libsecret-style "No matching entry" → available:true, id:null (clean miss)', () => {
|
|
installFakeAddon(function () {
|
|
throw new Error('No matching entry found in secure storage');
|
|
});
|
|
const keychain = freshRequire(KEYCHAIN_PATH);
|
|
const r = keychain.readFromKeychain('/some/workspace');
|
|
assert.deepEqual(r, { available: true, id: null });
|
|
});
|
|
|
|
test('macOS-style "could not be found" → available:true, id:null (clean miss)', () => {
|
|
installFakeAddon(function () {
|
|
throw new Error('The specified item could not be found in the keychain');
|
|
});
|
|
const keychain = freshRequire(KEYCHAIN_PATH);
|
|
const r = keychain.readFromKeychain('/some/workspace');
|
|
assert.deepEqual(r, { available: true, id: null });
|
|
});
|
|
|
|
test('Windows-style "Element not found" → available:true, id:null (clean miss)', () => {
|
|
installFakeAddon(function () {
|
|
throw new Error('Element not found.');
|
|
});
|
|
const keychain = freshRequire(KEYCHAIN_PATH);
|
|
const r = keychain.readFromKeychain('/some/workspace');
|
|
assert.deepEqual(r, { available: true, id: null });
|
|
});
|
|
|
|
test('locked-keyring failure → available:FALSE (proves force-unavailable path is REACHABLE)', () => {
|
|
installFakeAddon(function () {
|
|
throw new Error('Keyring is locked, cannot read');
|
|
});
|
|
const keychain = freshRequire(KEYCHAIN_PATH);
|
|
const r = keychain.readFromKeychain('/some/workspace');
|
|
assert.deepEqual(r, { available: false, id: null },
|
|
'a locked keyring must surface as unavailable, not silently masquerade as a clean miss');
|
|
});
|
|
|
|
test('platform-binding failure → available:FALSE', () => {
|
|
installFakeAddon(function () {
|
|
throw new Error('Platform credential storage failure: D-Bus session not available');
|
|
});
|
|
const keychain = freshRequire(KEYCHAIN_PATH);
|
|
const r = keychain.readFromKeychain('/some/workspace');
|
|
assert.deepEqual(r, { available: false, id: null });
|
|
});
|
|
|
|
test('_isNoEntryError correctly classifies the documented platform messages', () => {
|
|
const keychain = freshRequire(KEYCHAIN_PATH);
|
|
assert.equal(keychain._isNoEntryError(new Error('No matching entry found in secure storage')), true);
|
|
assert.equal(keychain._isNoEntryError(new Error('The specified item could not be found in the keychain')), true);
|
|
assert.equal(keychain._isNoEntryError(new Error('Element not found.')), true);
|
|
assert.equal(keychain._isNoEntryError(new Error('NoEntry')), true);
|
|
assert.equal(keychain._isNoEntryError(new Error('Keyring locked')), false);
|
|
assert.equal(keychain._isNoEntryError(new Error('D-Bus session unavailable')), false);
|
|
assert.equal(keychain._isNoEntryError(new Error('Ambiguous credential')), false);
|
|
assert.equal(keychain._isNoEntryError(null), false);
|
|
assert.equal(keychain._isNoEntryError(undefined), false);
|
|
});
|
|
});
|