mirror of https://github.com/garrytan/gstack.git
721 lines
26 KiB
TypeScript
721 lines
26 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
import {
|
|
chmodSync,
|
|
cpSync,
|
|
existsSync,
|
|
mkdtempSync,
|
|
mkdirSync,
|
|
readFileSync,
|
|
realpathSync,
|
|
rmSync,
|
|
symlinkSync,
|
|
writeFileSync,
|
|
} from 'fs';
|
|
import { spawn, spawnSync } from 'child_process';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
|
|
const REPO_ROOT = process.cwd();
|
|
const HELPER = join(REPO_ROOT, 'bin', 'gstack-gbrain-capability-check');
|
|
|
|
let root: string;
|
|
let fixtureRepo: string;
|
|
let fakeGbrain: string;
|
|
let slugFile: string;
|
|
let callLog: string;
|
|
let sourceIdFile: string;
|
|
let sourcePathFile: string;
|
|
let putReadyFile: string;
|
|
let autopilotActiveFile: string;
|
|
let versionCountFile: string;
|
|
let markerFile: string;
|
|
let fixtureHelper: string;
|
|
|
|
function git(args: string[]): string {
|
|
const result = spawnSync('git', args, {
|
|
cwd: fixtureRepo,
|
|
encoding: 'utf8',
|
|
});
|
|
if (result.status !== 0) {
|
|
throw new Error(result.stderr || `git ${args.join(' ')} failed`);
|
|
}
|
|
return result.stdout;
|
|
}
|
|
|
|
function status(): string {
|
|
return git(['status', '--short']);
|
|
}
|
|
|
|
function helperEnv(mode: string): NodeJS.ProcessEnv {
|
|
return {
|
|
...process.env,
|
|
GBRAIN_BIN: fakeGbrain,
|
|
BUN_BIN: process.execPath,
|
|
GBRAIN_HOME: root,
|
|
GSTACK_HOME: join(root, 'gstack-state'),
|
|
GSTACK_GBRAIN_CAPABILITY_RETRY_DELAY_SECONDS: '0',
|
|
FIXTURE_MODE: mode,
|
|
FIXTURE_REPO: fixtureRepo,
|
|
FIXTURE_SLUG_FILE: slugFile,
|
|
FIXTURE_CALL_LOG: callLog,
|
|
FIXTURE_SOURCE_ID_FILE: sourceIdFile,
|
|
FIXTURE_SOURCE_PATH_FILE: sourcePathFile,
|
|
FIXTURE_PUT_READY_FILE: putReadyFile,
|
|
FIXTURE_AUTOPILOT_ACTIVE_FILE: autopilotActiveFile,
|
|
FIXTURE_VERSION_COUNT_FILE: versionCountFile,
|
|
FIXTURE_MARKER_FILE: markerFile,
|
|
FIXTURE_EXPECT_DATABASE_ROUTING_CLEARED: '1',
|
|
FIXTURE_EXPECT_OUTSIDE_PROJECT_CWD: '1',
|
|
};
|
|
}
|
|
|
|
function runHelper(mode: string) {
|
|
return spawnSync(fixtureHelper, [], {
|
|
cwd: fixtureRepo,
|
|
encoding: 'utf8',
|
|
env: helperEnv(mode),
|
|
});
|
|
}
|
|
|
|
function sourcePath(): string {
|
|
return readFileSync(sourcePathFile, 'utf8').trim();
|
|
}
|
|
|
|
function generatedPath(): string {
|
|
const slug = readFileSync(slugFile, 'utf8').trim();
|
|
return join(sourcePath(), `${slug}.md`);
|
|
}
|
|
|
|
function writeThinClientConfig(layout: 'current' | 'legacy' = 'current'): void {
|
|
if (layout === 'legacy') {
|
|
rmSync(join(root, '.gbrain', 'config.json'), { force: true });
|
|
}
|
|
const dir = layout === 'current' ? join(root, '.gbrain') : root;
|
|
mkdirSync(dir, { recursive: true });
|
|
writeFileSync(
|
|
join(dir, 'config.json'),
|
|
JSON.stringify({
|
|
engine: 'postgres',
|
|
remote_mcp: {
|
|
mcp_url: 'https://brain.example.invalid/mcp',
|
|
issuer_url: 'https://brain.example.invalid',
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
async function waitForFile(path: string, timeoutMs = 5_000): Promise<void> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
if (existsSync(path)) return;
|
|
await Bun.sleep(20);
|
|
}
|
|
throw new Error(`timed out waiting for ${path}`);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
root = mkdtempSync(join(tmpdir(), 'gstack-gbrain-capability-test-'));
|
|
fixtureRepo = join(root, 'repo');
|
|
mkdirSync(fixtureRepo);
|
|
slugFile = join(root, 'slug');
|
|
callLog = join(root, 'calls.log');
|
|
sourceIdFile = join(root, 'source-id');
|
|
sourcePathFile = join(root, 'source-path');
|
|
putReadyFile = join(root, 'put-ready');
|
|
autopilotActiveFile = join(root, 'autopilot-active');
|
|
versionCountFile = join(root, 'version-count');
|
|
markerFile = join(root, 'put-marker');
|
|
fakeGbrain = join(root, 'fake-gbrain');
|
|
mkdirSync(join(root, '.gbrain'), { recursive: true });
|
|
writeFileSync(
|
|
join(root, '.gbrain', 'config.json'),
|
|
JSON.stringify({ engine: 'pglite', database_path: join(root, '.gbrain', 'brain.pglite') }),
|
|
);
|
|
const fixtureInstall = join(root, 'gstack-install');
|
|
const fixtureBin = join(fixtureInstall, 'bin');
|
|
const fixtureLib = join(fixtureInstall, 'lib');
|
|
mkdirSync(fixtureBin, { recursive: true });
|
|
cpSync(join(REPO_ROOT, 'lib'), fixtureLib, { recursive: true });
|
|
fixtureHelper = join(fixtureBin, 'gstack-gbrain-capability-check');
|
|
writeFileSync(fixtureHelper, readFileSync(HELPER));
|
|
chmodSync(fixtureHelper, 0o755);
|
|
writeFileSync(
|
|
join(fixtureLib, 'gbrain-guards.ts'),
|
|
`import { existsSync } from 'fs';
|
|
export function detectAutopilot(env: NodeJS.ProcessEnv) {
|
|
return existsSync(env.FIXTURE_AUTOPILOT_ACTIVE_FILE ?? '')
|
|
? { active: true, signal: 'fixture:autopilot' }
|
|
: { active: false, signal: null };
|
|
}
|
|
export function gbrainSourceRemoveConfirmationArgsForIdentity(identity: string) {
|
|
const match = identity.match(/\\b(\\d+)\\.(\\d+)\\.(\\d+)/);
|
|
if (!match) return ['--confirm-destructive'];
|
|
const version = match.slice(1, 4).map(Number);
|
|
return version[0] === 0 && (version[1] < 26 || (version[1] === 26 && version[2] < 5))
|
|
? ['--yes']
|
|
: ['--confirm-destructive'];
|
|
}
|
|
`,
|
|
);
|
|
writeFileSync(
|
|
fakeGbrain,
|
|
`#!/usr/bin/env bash
|
|
set -u
|
|
if [ "\${FIXTURE_EXPECT_DATABASE_ROUTING_CLEARED:-0}" = "1" ] && \
|
|
{ [ -n "\${DATABASE_URL:-}" ] || [ -n "\${GBRAIN_DATABASE_URL:-}" ]; }; then
|
|
printf 'database-routing-not-cleared\n' >&2
|
|
exit 15
|
|
fi
|
|
if [ -n "\${FIXTURE_EXPECTED_DATABASE_URL:-}" ] && \
|
|
{ [ "\${DATABASE_URL:-}" != "$FIXTURE_EXPECTED_DATABASE_URL" ] || \
|
|
[ "\${GBRAIN_DATABASE_URL:-}" != "$FIXTURE_EXPECTED_DATABASE_URL" ]; }; then
|
|
printf 'wrong-database-url\n' >&2
|
|
exit 15
|
|
fi
|
|
if [ "\${FIXTURE_EXPECT_OUTSIDE_PROJECT_CWD:-0}" = "1" ]; then
|
|
case "$PWD" in
|
|
"$FIXTURE_REPO"|"$FIXTURE_REPO"/*)
|
|
printf 'project-cwd-not-isolated\n' >&2
|
|
exit 16
|
|
;;
|
|
esac
|
|
fi
|
|
cmd="\${1:-}"
|
|
if [ "$cmd" = "--version" ]; then
|
|
if [ "\${FIXTURE_MODE:-}" = "autopilot-confirmation-race" ]; then
|
|
count=0
|
|
[ -f "$FIXTURE_VERSION_COUNT_FILE" ] && count=$(cat "$FIXTURE_VERSION_COUNT_FILE")
|
|
count=$((count + 1))
|
|
printf '%s\n' "$count" > "$FIXTURE_VERSION_COUNT_FILE"
|
|
if [ "$count" -eq 2 ]; then : > "$FIXTURE_AUTOPILOT_ACTIVE_FILE"; fi
|
|
fi
|
|
if [ "\${FIXTURE_MODE:-}" = "legacy-confirm-unsupported" ]; then
|
|
echo "gbrain 0.25.9"
|
|
elif [ "\${FIXTURE_MODE:-}" = "unknown-version" ]; then
|
|
echo "gbrain development-build"
|
|
else
|
|
echo "gbrain 0.42.56.0"
|
|
fi
|
|
exit 0
|
|
fi
|
|
case "$cmd" in
|
|
doctor)
|
|
printf 'doctor\n' >> "$FIXTURE_CALL_LOG"
|
|
if [ "\${FIXTURE_MODE:-}" = "remote-missing-write" ]; then
|
|
printf '%s\n' '{"mode":"thin-client","status":"ok","oauth_scope":"read","checks":[{"name":"oauth_client_scopes_probe","status":"ok","detail":{"granted":"read","read_ok":true,"admin_ok":false}}]}'
|
|
elif [ "\${FIXTURE_MODE:-}" = "remote-admin-only" ]; then
|
|
printf '%s\n' '{"mode":"thin-client","status":"ok","oauth_scope":"admin","checks":[{"name":"oauth_client_scopes_probe","status":"ok","detail":{"granted":"admin","read_ok":true,"admin_ok":true}}]}'
|
|
else
|
|
printf '%s\n' '{"mode":"thin-client","status":"ok","oauth_scope":"read,write,admin","checks":[{"name":"oauth_client_scopes_probe","status":"ok","detail":{"granted":"read,write,admin","read_ok":true,"admin_ok":true}}]}'
|
|
fi
|
|
;;
|
|
sources)
|
|
sub="\${2:-}"
|
|
case "$sub" in
|
|
add)
|
|
id="$3"
|
|
path=""
|
|
shift 3
|
|
while [ "$#" -gt 0 ]; do
|
|
if [ "$1" = "--path" ]; then path="$2"; shift 2; else shift; fi
|
|
done
|
|
printf 'sources-add:%s\n' "$id" >> "$FIXTURE_CALL_LOG"
|
|
if [ "\${FIXTURE_MODE:-}" = "source-collision" ]; then
|
|
printf '%s\n' "$id" > "$FIXTURE_SOURCE_ID_FILE"
|
|
printf '%s\n' "$FIXTURE_REPO/pre-existing-source" > "$FIXTURE_SOURCE_PATH_FILE"
|
|
exit 6
|
|
fi
|
|
printf '%s\n' "$id" > "$FIXTURE_SOURCE_ID_FILE"
|
|
printf '%s\n' "$path" > "$FIXTURE_SOURCE_PATH_FILE"
|
|
if [ "\${FIXTURE_MODE:-}" = "source-add-late-failure" ]; then exit 7; fi
|
|
;;
|
|
list)
|
|
if [ "\${FIXTURE_MODE:-}" = "source-list-failure" ]; then exit 13; fi
|
|
if [ "\${FIXTURE_MODE:-}" = "source-list-empty-object" ]; then printf '%s\n' '{}'; exit 0; fi
|
|
if [ "\${FIXTURE_MODE:-}" = "source-list-null" ]; then printf '%s\n' '{"sources":null}'; exit 0; fi
|
|
if [ "\${FIXTURE_MODE:-}" = "source-list-error-envelope" ]; then printf '%s\n' '{"error":"denied"}'; exit 0; fi
|
|
if [ -f "$FIXTURE_SOURCE_ID_FILE" ] && [ -f "$FIXTURE_SOURCE_PATH_FILE" ]; then
|
|
id=$(cat "$FIXTURE_SOURCE_ID_FILE")
|
|
path=$(cat "$FIXTURE_SOURCE_PATH_FILE")
|
|
if [ "\${FIXTURE_MODE:-}" = "bare-array-list" ]; then
|
|
printf '[{"id":"%s","local_path":"%s"}]\n' "$id" "$path"
|
|
else
|
|
printf '{"sources":[{"id":"%s","local_path":"%s"}]}\n' "$id" "$path"
|
|
fi
|
|
else
|
|
if [ "\${FIXTURE_MODE:-}" = "bare-array-list" ]; then
|
|
printf '%s\n' '[]'
|
|
else
|
|
printf '%s\n' '{"sources":[]}'
|
|
fi
|
|
fi
|
|
;;
|
|
remove)
|
|
id="$3"
|
|
printf 'sources-remove:%s:%s\n' "$id" "\${4:-}" >> "$FIXTURE_CALL_LOG"
|
|
if [ "\${FIXTURE_MODE:-}" = "legacy-confirm-unsupported" ] && [ "\${4:-}" != "--yes" ]; then exit 2; fi
|
|
if [ "\${FIXTURE_MODE:-}" = "unknown-version" ] && [ "\${4:-}" != "--confirm-destructive" ]; then exit 2; fi
|
|
if [ "\${FIXTURE_MODE:-}" = "source-remove-failure" ]; then exit 12; fi
|
|
rm -f "$FIXTURE_SOURCE_ID_FILE"
|
|
;;
|
|
*) exit 9 ;;
|
|
esac
|
|
;;
|
|
put)
|
|
slug="$2"
|
|
path=$(cat "$FIXTURE_SOURCE_PATH_FILE")
|
|
marker=$(cat)
|
|
printf '%s\n' "$marker" > "$FIXTURE_MARKER_FILE"
|
|
printf '%s\n' "$slug" > "$FIXTURE_SLUG_FILE"
|
|
printf '%s\n' "$marker" > "$path/$slug.md"
|
|
printf 'put:%s\n' "$slug" >> "$FIXTURE_CALL_LOG"
|
|
if [ "\${FIXTURE_MODE:-}" = "put-block" ]; then
|
|
: > "$FIXTURE_PUT_READY_FILE"
|
|
while :; do sleep 1; done
|
|
fi
|
|
if [ "\${FIXTURE_MODE:-}" = "put-failure-no-json" ]; then exit 7; fi
|
|
printf '{"slug":"%s","write_through":{"written":true,"path":"%s"}}\n' "$slug" "$path/$slug.md"
|
|
;;
|
|
search)
|
|
if [ "\${FIXTURE_MODE:-}" = "remote-success" ] || [ "\${FIXTURE_MODE:-}" = "remote-missing-write" ] || [ "\${FIXTURE_MODE:-}" = "remote-admin-only" ]; then
|
|
printf 'remote-search\n' >> "$FIXTURE_CALL_LOG"
|
|
exit 0
|
|
fi
|
|
slug=$(cat "$FIXTURE_SLUG_FILE")
|
|
printf 'search:%s\n' "$slug" >> "$FIXTURE_CALL_LOG"
|
|
if [ "\${FIXTURE_MODE:-}" = "search-failure" ]; then exit 8; fi
|
|
if [ "\${FIXTURE_MODE:-}" = "autopilot-race" ]; then : > "$FIXTURE_AUTOPILOT_ACTIVE_FILE"; fi
|
|
printf '[1.0] %s -- probe\n' "$slug"
|
|
;;
|
|
*)
|
|
exit 9
|
|
;;
|
|
esac
|
|
`,
|
|
);
|
|
chmodSync(fakeGbrain, 0o755);
|
|
|
|
git(['init', '-q']);
|
|
git(['config', 'user.email', 'gstack-test@example.invalid']);
|
|
git(['config', 'user.name', 'gstack test']);
|
|
writeFileSync(join(fixtureRepo, 'tracked.txt'), 'baseline\n');
|
|
git(['add', 'tracked.txt']);
|
|
git(['commit', '-qm', 'fixture']);
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('gstack-gbrain-capability-check', () => {
|
|
test('sync-gbrain generated contract delegates to the isolated helper', () => {
|
|
const template = readFileSync(join(REPO_ROOT, 'sync-gbrain', 'SKILL.md.tmpl'), 'utf8');
|
|
const generated = readFileSync(join(REPO_ROOT, 'sync-gbrain', 'SKILL.md'), 'utf8');
|
|
|
|
expect(template).toContain('{{BIN_DIR}}/gstack-gbrain-capability-check');
|
|
expect(generated).toContain('bin/gstack-gbrain-capability-check');
|
|
for (const content of [template, generated]) {
|
|
expect(content).not.toContain('gbrain delete "$SLUG"');
|
|
expect(content).not.toContain('gbrain put "$SLUG"');
|
|
}
|
|
expect(readFileSync(HELPER, 'utf8')).toContain('detectAutopilot');
|
|
});
|
|
|
|
test('successful local probe uses a private source and restores exact git status', () => {
|
|
writeFileSync(join(fixtureRepo, 'tracked.txt'), 'baseline\npre-existing dirty change\n');
|
|
const before = status();
|
|
|
|
const result = runHelper('success');
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(status()).toBe(before);
|
|
expect(readFileSync(join(fixtureRepo, 'tracked.txt'), 'utf8')).toContain('pre-existing dirty change');
|
|
expect(readFileSync(callLog, 'utf8')).toContain('sources-add:');
|
|
expect(readFileSync(callLog, 'utf8')).toContain('sources-remove:');
|
|
expect(readFileSync(markerFile, 'utf8')).toContain('gstack-capability-probe:_capability_check_');
|
|
expect(existsSync(sourcePath())).toBe(false);
|
|
});
|
|
|
|
test('every gbrain command uses the configured database despite inherited and project dotenv conflicts', () => {
|
|
const configuredDatabase = 'postgresql://gbrain.invalid:5432/canonical';
|
|
mkdirSync(join(root, '.gbrain'), { recursive: true });
|
|
writeFileSync(
|
|
join(root, '.gbrain', 'config.json'),
|
|
JSON.stringify({ engine: 'postgres', database_url: configuredDatabase }),
|
|
);
|
|
writeFileSync(
|
|
join(fixtureRepo, '.env.local'),
|
|
'DATABASE_URL=postgresql://project.invalid:5432/wrong-project\n',
|
|
);
|
|
const before = status();
|
|
|
|
const result = spawnSync(fixtureHelper, [], {
|
|
cwd: fixtureRepo,
|
|
encoding: 'utf8',
|
|
env: {
|
|
...helperEnv('success'),
|
|
DATABASE_URL: 'postgresql://caller.invalid:5432/wrong-caller',
|
|
GBRAIN_DATABASE_URL: 'postgresql://caller.invalid:5432/also-wrong',
|
|
FIXTURE_EXPECTED_DATABASE_URL: configuredDatabase,
|
|
FIXTURE_EXPECT_DATABASE_ROUTING_CLEARED: '0',
|
|
},
|
|
});
|
|
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(readFileSync(callLog, 'utf8')).toContain('sources-remove:');
|
|
expect(status()).toBe(before);
|
|
});
|
|
|
|
test('project dotenv cannot inject GBRAIN_HOME or GSTACK_HOME before isolation', () => {
|
|
const actualHome = join(root, 'actual-home');
|
|
const actualState = join(actualHome, '.gbrain');
|
|
const foreignHome = join(root, 'foreign-brain-parent');
|
|
const foreignState = join(foreignHome, '.gbrain');
|
|
mkdirSync(actualState, { recursive: true });
|
|
mkdirSync(foreignState, { recursive: true });
|
|
writeFileSync(
|
|
join(actualState, 'config.json'),
|
|
JSON.stringify({ engine: 'pglite', database_path: join(actualState, 'brain.pglite') }),
|
|
);
|
|
writeFileSync(
|
|
join(foreignState, 'config.json'),
|
|
JSON.stringify({ engine: 'postgres', database_url: 'postgresql://foreign.invalid/wrong-brain' }),
|
|
);
|
|
writeFileSync(
|
|
join(fixtureRepo, '.env.local'),
|
|
`GBRAIN_HOME=${foreignHome}\nGSTACK_HOME=${join(fixtureRepo, 'dotenv-gstack-state')}\n`,
|
|
);
|
|
|
|
const env = {
|
|
...helperEnv('success'),
|
|
HOME: actualHome,
|
|
};
|
|
delete env.GBRAIN_HOME;
|
|
delete env.GSTACK_HOME;
|
|
const result = spawnSync(fixtureHelper, [], {
|
|
cwd: fixtureRepo,
|
|
encoding: 'utf8',
|
|
env,
|
|
});
|
|
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(readFileSync(callLog, 'utf8')).toContain('sources-remove:');
|
|
expect(existsSync(join(fixtureRepo, 'dotenv-gstack-state'))).toBe(false);
|
|
});
|
|
|
|
test('PGLite probe clears hostile caller routing and leaves project dotenv scope', () => {
|
|
writeFileSync(
|
|
join(fixtureRepo, '.env.local'),
|
|
'DATABASE_URL=postgresql://project.invalid:5432/wrong-project\n',
|
|
);
|
|
const before = status();
|
|
|
|
const result = spawnSync(fixtureHelper, [], {
|
|
cwd: fixtureRepo,
|
|
encoding: 'utf8',
|
|
env: {
|
|
...helperEnv('success'),
|
|
DATABASE_URL: 'postgresql://caller.invalid:5432/wrong-caller',
|
|
GBRAIN_DATABASE_URL: 'postgresql://caller.invalid:5432/also-wrong',
|
|
},
|
|
});
|
|
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(readFileSync(callLog, 'utf8')).toContain('sources-remove:');
|
|
expect(status()).toBe(before);
|
|
});
|
|
|
|
test('missing and malformed active configs fail before any gbrain mutation', () => {
|
|
const configPath = join(root, '.gbrain', 'config.json');
|
|
for (const setup of [
|
|
() => rmSync(configPath, { force: true }),
|
|
() => writeFileSync(configPath, '{broken'),
|
|
]) {
|
|
setup();
|
|
rmSync(callLog, { force: true });
|
|
const result = spawnSync(fixtureHelper, [], {
|
|
cwd: fixtureRepo,
|
|
encoding: 'utf8',
|
|
env: {
|
|
...helperEnv('success'),
|
|
DATABASE_URL: 'postgresql://caller.invalid:5432/wrong-caller',
|
|
GBRAIN_DATABASE_URL: 'postgresql://caller.invalid:5432/also-wrong',
|
|
},
|
|
});
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.stderr).toContain('active gbrain config is missing or malformed');
|
|
expect(existsSync(callLog)).toBe(false);
|
|
}
|
|
});
|
|
|
|
test('external-host bin symlink resolves the physical sibling guard module', () => {
|
|
const runtimeRoot = join(root, 'external-runtime');
|
|
mkdirSync(runtimeRoot);
|
|
symlinkSync(join(root, 'gstack-install', 'bin'), join(runtimeRoot, 'bin'));
|
|
|
|
const result = spawnSync(join(runtimeRoot, 'bin', 'gstack-gbrain-capability-check'), [], {
|
|
cwd: fixtureRepo,
|
|
encoding: 'utf8',
|
|
env: helperEnv('success'),
|
|
});
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(readFileSync(callLog, 'utf8')).toContain('sources-remove:');
|
|
});
|
|
|
|
test('caller TMPDIR inside the checkout cannot receive probe files', () => {
|
|
const result = spawnSync(fixtureHelper, [], {
|
|
cwd: fixtureRepo,
|
|
encoding: 'utf8',
|
|
env: { ...helperEnv('success'), TMPDIR: fixtureRepo },
|
|
});
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(sourcePath()).toStartWith(realpathSync(join(root, '.gstack-capability-tmp')));
|
|
expect(sourcePath()).not.toStartWith(fixtureRepo);
|
|
expect(status()).toBe('');
|
|
});
|
|
|
|
test('Git-backed GSTACK_HOME keeps probe scratch space outside that repository', () => {
|
|
const stateRoot = join(root, 'gstack-state');
|
|
mkdirSync(stateRoot, { recursive: true });
|
|
const init = spawnSync('git', ['init', '-q'], { cwd: stateRoot, encoding: 'utf8' });
|
|
expect(init.status, init.stderr).toBe(0);
|
|
|
|
const result = runHelper('success');
|
|
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(sourcePath()).toStartWith(realpathSync(join(root, '.gstack-capability-tmp')));
|
|
expect(sourcePath()).not.toStartWith(realpathSync(stateRoot));
|
|
expect(spawnSync('git', ['status', '--short'], { cwd: stateRoot, encoding: 'utf8' }).stdout).toBe('');
|
|
});
|
|
|
|
test('GSTACK_HOME inside the checkout is rejected before creating directories', () => {
|
|
const nestedState = join(fixtureRepo, 'would-dirty-checkout');
|
|
const before = status();
|
|
const result = spawnSync(fixtureHelper, [], {
|
|
cwd: fixtureRepo,
|
|
encoding: 'utf8',
|
|
env: { ...helperEnv('success'), GSTACK_HOME: nestedState },
|
|
});
|
|
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.stderr).toContain('refusing temp root inside Git worktree');
|
|
expect(existsSync(nestedState)).toBe(false);
|
|
expect(status()).toBe(before);
|
|
});
|
|
|
|
test('failed search removes the temporary source and preserves dirty files', () => {
|
|
writeFileSync(join(fixtureRepo, 'untracked-user-file.txt'), 'keep me\n');
|
|
const before = status();
|
|
|
|
const result = runHelper('search-failure');
|
|
|
|
expect(result.status).not.toBe(0);
|
|
expect(status()).toBe(before);
|
|
expect(readFileSync(join(fixtureRepo, 'untracked-user-file.txt'), 'utf8')).toBe('keep me\n');
|
|
expect(readFileSync(callLog, 'utf8').match(/search:/g)?.length).toBe(3);
|
|
expect(readFileSync(callLog, 'utf8')).toContain('sources-remove:');
|
|
expect(existsSync(sourcePath())).toBe(false);
|
|
});
|
|
|
|
test('put failure before JSON still cleans its page, source, and temp directory', () => {
|
|
const before = status();
|
|
|
|
const result = runHelper('put-failure-no-json');
|
|
const path = generatedPath();
|
|
|
|
expect(result.status).not.toBe(0);
|
|
expect(status()).toBe(before);
|
|
expect(existsSync(path)).toBe(false);
|
|
expect(readFileSync(callLog, 'utf8')).toContain('sources-remove:');
|
|
});
|
|
|
|
test('late source-add failure is recovered only when id and path match', () => {
|
|
const result = runHelper('source-add-late-failure');
|
|
const path = sourcePath();
|
|
|
|
expect(result.status).not.toBe(0);
|
|
expect(readFileSync(callLog, 'utf8')).toContain('sources-remove:');
|
|
expect(existsSync(path)).toBe(false);
|
|
});
|
|
|
|
test('bare-array source readback still removes the owned source and directory', () => {
|
|
const result = runHelper('bare-array-list');
|
|
const path = sourcePath();
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(readFileSync(callLog, 'utf8')).toContain('sources-remove:');
|
|
expect(existsSync(path)).toBe(false);
|
|
});
|
|
|
|
test('cleanup uses the v0.20 --yes contract only for a positively identified legacy CLI', () => {
|
|
const result = runHelper('legacy-confirm-unsupported');
|
|
const path = sourcePath();
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(readFileSync(callLog, 'utf8').match(/sources-remove:/g)?.length).toBe(1);
|
|
expect(readFileSync(callLog, 'utf8')).toContain(':--yes');
|
|
expect(existsSync(path)).toBe(false);
|
|
});
|
|
|
|
test('unknown gbrain identity keeps the current fail-closed remove contract', () => {
|
|
const result = runHelper('unknown-version');
|
|
const path = sourcePath();
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(readFileSync(callLog, 'utf8')).toContain(':--confirm-destructive');
|
|
expect(existsSync(path)).toBe(false);
|
|
});
|
|
|
|
test('active autopilot refuses before any temporary source is created', () => {
|
|
writeFileSync(autopilotActiveFile, 'active\n');
|
|
const before = status();
|
|
|
|
const result = runHelper('success');
|
|
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.stderr).toContain('autopilot active');
|
|
expect(status()).toBe(before);
|
|
expect(existsSync(callLog)).toBe(false);
|
|
});
|
|
|
|
test('autopilot starting during the probe blocks remove and preserves recovery state', () => {
|
|
const result = runHelper('autopilot-race');
|
|
const path = sourcePath();
|
|
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.stderr).toContain('autopilot active');
|
|
expect(readFileSync(callLog, 'utf8')).not.toContain('sources-remove:');
|
|
expect(existsSync(path)).toBe(true);
|
|
});
|
|
|
|
test('autopilot starting during the confirmation probe blocks remove', () => {
|
|
const result = runHelper('autopilot-confirmation-race');
|
|
const path = sourcePath();
|
|
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.stderr).toContain('autopilot active');
|
|
expect(readFileSync(callLog, 'utf8')).not.toContain('sources-remove:');
|
|
expect(existsSync(path)).toBe(true);
|
|
});
|
|
|
|
test('source id collision never removes the pre-existing source', () => {
|
|
const result = runHelper('source-collision');
|
|
|
|
expect(result.status).not.toBe(0);
|
|
expect(readFileSync(callLog, 'utf8')).not.toContain('sources-remove:');
|
|
expect(readFileSync(sourcePathFile, 'utf8')).toContain('pre-existing-source');
|
|
});
|
|
|
|
test('parent-only SIGTERM terminates the active child before isolated cleanup', async () => {
|
|
const before = status();
|
|
const child = spawn(fixtureHelper, [], {
|
|
cwd: fixtureRepo,
|
|
env: helperEnv('put-block'),
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
|
|
await waitForFile(putReadyFile);
|
|
const path = sourcePath();
|
|
process.kill(child.pid ?? 0, 'SIGTERM');
|
|
const exit = await new Promise<number | null>((resolve) => child.once('exit', resolve));
|
|
|
|
expect(exit).not.toBe(0);
|
|
expect(status()).toBe(before);
|
|
expect(readFileSync(callLog, 'utf8')).toContain('sources-remove:');
|
|
expect(existsSync(path)).toBe(false);
|
|
});
|
|
|
|
test('thin-client probe is read-only and accepts live read plus granted write scope', () => {
|
|
writeThinClientConfig();
|
|
const before = status();
|
|
|
|
const result = runHelper('remote-success');
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(status()).toBe(before);
|
|
const calls = readFileSync(callLog, 'utf8');
|
|
expect(calls).toContain('doctor');
|
|
expect(calls).toContain('remote-search');
|
|
expect(calls).not.toContain('sources-add:');
|
|
expect(calls).not.toContain('put:');
|
|
});
|
|
|
|
test('stale direct-layout thin-client config is rejected before any remote call', () => {
|
|
writeThinClientConfig('legacy');
|
|
|
|
const result = runHelper('remote-success');
|
|
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.stderr).toContain(join(root, '.gbrain', 'config.json'));
|
|
expect(existsSync(callLog)).toBe(false);
|
|
});
|
|
|
|
test('thin-client probe accepts admin because the gbrain scope lattice implies write', () => {
|
|
writeThinClientConfig();
|
|
|
|
const result = runHelper('remote-admin-only');
|
|
|
|
expect(result.status).toBe(0);
|
|
const calls = readFileSync(callLog, 'utf8');
|
|
expect(calls).toContain('doctor');
|
|
expect(calls).toContain('remote-search');
|
|
expect(calls).not.toContain('sources-add:');
|
|
expect(calls).not.toContain('put:');
|
|
});
|
|
|
|
test('thin-client probe fails closed when granted write scope is absent', () => {
|
|
writeThinClientConfig();
|
|
const before = status();
|
|
|
|
const result = runHelper('remote-missing-write');
|
|
|
|
expect(result.status).not.toBe(0);
|
|
expect(status()).toBe(before);
|
|
const calls = readFileSync(callLog, 'utf8');
|
|
expect(calls).toContain('doctor');
|
|
expect(calls).not.toContain('remote-search');
|
|
expect(calls).not.toContain('sources-add:');
|
|
expect(calls).not.toContain('put:');
|
|
});
|
|
|
|
test('cleanup failure is surfaced and preserves the recovery directory', () => {
|
|
const result = runHelper('source-remove-failure');
|
|
const path = sourcePath();
|
|
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.stderr).toContain('failed to remove owned temporary source');
|
|
expect(existsSync(path)).toBe(true);
|
|
});
|
|
|
|
test('source readback failure fails closed and preserves the recovery directory', () => {
|
|
const result = runHelper('source-list-failure');
|
|
const path = sourcePath();
|
|
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.stderr).toContain('could not verify temporary source cleanup');
|
|
expect(readFileSync(callLog, 'utf8')).not.toContain('sources-remove:');
|
|
expect(existsSync(path)).toBe(true);
|
|
});
|
|
|
|
test('unsupported successful source-list JSON fails closed and preserves recovery state', () => {
|
|
for (const mode of [
|
|
'source-list-empty-object',
|
|
'source-list-null',
|
|
'source-list-error-envelope',
|
|
]) {
|
|
rmSync(callLog, { force: true });
|
|
rmSync(sourceIdFile, { force: true });
|
|
rmSync(sourcePathFile, { force: true });
|
|
const result = runHelper(mode);
|
|
const path = sourcePath();
|
|
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.stderr).toContain('could not verify temporary source cleanup');
|
|
expect(readFileSync(callLog, 'utf8')).not.toContain('sources-remove:');
|
|
expect(existsSync(path)).toBe(true);
|
|
}
|
|
});
|
|
});
|