Files
Joseph MaglyandWibias 916c260fd8 feat: add safe cross-platform upstream updater (#98)
* feat: add safe upstream update scripts

* fix(updater): use portable PowerShell temp path

---------

Co-authored-by: Wibias <37517432+Wibias@users.noreply.github.com>
2026-07-15 19:56:25 -04:00

372 lines
15 KiB
JavaScript

#!/usr/bin/env node
/**
* Integration tests for the T3MP3ST updater.
* Each test runs the updater in a temporary sandbox repo so real repository state
* is never touched.
*/
import { execSync, spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { platform, tmpdir } from 'node:os';
const __dirname = dirname(fileURLToPath(import.meta.url));
const isWin = platform() === 'win32';
let pass = 0;
let fail = 0;
const tests = [];
function test(name, fn) {
tests.push({ name, fn });
}
function assert(cond, msg) {
if (!cond) {
throw new Error(msg || 'assertion failed');
}
}
function ok(label) {
pass++;
console.log(' OK ' + label);
}
function logFail(label, err) {
fail++;
console.log(' FAIL ' + label + ': ' + err.message);
}
function stripAnsi(s) {
return s.replace(/\x1b\[[0-9;]*m/g, '');
}
function psAvailable() {
return spawnSync('powershell', ['-NoProfile', '-Command', 'exit 0'], { stdio: 'pipe' }).status === 0;
}
function bashAvailable() {
return !!bashExe();
}
// On Windows, the bash on PATH is often WSL (can't access Windows temp dirs).
// Prefer Git Bash alongside git if available. Returns null if not found.
function bashExe() {
if (!isWin) {
const r = spawnSync('bash', ['-c', 'exit 0'], { stdio: 'pipe' });
return r.status === 0 ? 'bash' : null;
}
const result = spawnSync('where', ['git'], { shell: true, stdio: 'pipe', encoding: 'utf8' });
if (result.status !== 0 || !result.stdout.trim()) return null;
const gitPath = result.stdout.trim().split(/\r?\n/)[0];
const dir = dirname(gitPath);
const candidates = [join(dir, 'bin', 'bash.exe'), join(dir, '..', 'bin', 'bash.exe')];
for (const c of candidates) {
if (existsSync(c)) {
const r = spawnSync(c, ['-c', 'exit 0'], { stdio: 'pipe' });
if (r.status === 0) return c;
}
}
return null;
}
const _bash = bashExe();
function cleanup(dir) {
for (let i = 0; i < 5; i++) {
try {
rmSync(dir, { recursive: true, force: true });
return;
} catch (err) {
if (i === 4) {
console.warn(' WARN could not clean up temp dir: ' + dir + ' (' + err.message + ')');
return;
}
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200);
}
}
}
function makeSandbox() {
const dir = join(tmpdir(), `t3mp3st-update-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
mkdirSync(dir, { recursive: true });
mkdirSync(join(dir, 'scripts'), { recursive: true });
const files = [
'update.ps1',
'update.sh',
'update-protected.txt',
'update-banner.txt',
'update.mjs',
];
for (const f of files) {
let content = readFileSync(join(__dirname, f), 'utf8');
if (f === 'update.sh') {
content = content.replace(/\r\n/g, '\n');
}
writeFileSync(join(dir, 'scripts', f), content);
}
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 't3mp3st-test-sandbox', version: '0.0.0' }, null, 2));
writeFileSync(join(dir, '.env'), 'SECRET=local-secret\n');
writeFileSync(join(dir, '.env.example'), 'SECRET=your-secret\n');
mkdirSync(join(dir, 'reports'), { recursive: true });
writeFileSync(join(dir, 'reports', 'engagement.md'), '# engagement\n');
mkdirSync(join(dir, 'evidence'), { recursive: true });
writeFileSync(join(dir, 'evidence', 'poc.txt'), 'poc\n');
writeFileSync(join(dir, 'unprotected.txt'), 'I should be replaced\n');
return dir;
}
function runPowerShell(sandbox, args = []) {
const ps1 = join(sandbox, 'scripts', 'update.ps1');
const result = spawnSync(
'powershell',
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ps1, ...args],
{ cwd: sandbox, stdio: 'pipe', encoding: 'utf8' }
);
return {
status: result.status,
stdout: stripAnsi(result.stdout || ''),
stderr: stripAnsi(result.stderr || ''),
};
}
function runBash(sandbox, args = []) {
const sh = 'scripts/update.sh';
const result = spawnSync(_bash, [sh, ...args], { cwd: sandbox, stdio: 'pipe', encoding: 'utf8', shell: false });
return {
status: result.status,
stdout: stripAnsi(result.stdout || ''),
stderr: stripAnsi(result.stderr || ''),
};
}
function makeEmptySandbox() {
// Same as makeSandbox but with NO .env, reports/, or evidence/ -- exercises
// the zero-protected-path path that the reviewer flagged.
const dir = join(tmpdir(), `t3mp3st-update-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
mkdirSync(dir, { recursive: true });
mkdirSync(join(dir, 'scripts'), { recursive: true });
const files = ['update.ps1', 'update.sh', 'update-protected.txt', 'update-banner.txt', 'update.mjs'];
for (const f of files) {
let content = readFileSync(join(__dirname, f), 'utf8');
if (f === 'update.sh') {
content = content.replace(/\r\n/g, '\n');
}
writeFileSync(join(dir, 'scripts', f), content);
}
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 't3mp3st-test-empty', version: '0.0.0' }, null, 2));
writeFileSync(join(dir, 'unprotected.txt'), 'I should be replaced\n');
return dir;
}
// ---------------------------------------------------------------------------
// Static / source-level checks
// ---------------------------------------------------------------------------
test('protected manifest contains reports/ and evidence/', () => {
const manifest = readFileSync(join(__dirname, 'update-protected.txt'), 'utf8');
assert(manifest.includes('reports/'), 'reports/ missing from protected manifest');
assert(manifest.includes('evidence/'), 'evidence/ missing from protected manifest');
assert(manifest.includes('!.env.example'), '!.env.example missing from protected manifest');
});
test('update.mjs maps --dry-run and --hard', () => {
const src = readFileSync(join(__dirname, 'update.mjs'), 'utf8');
assert(src.includes("case '--dry-run'") && src.includes("return ['-DryRun']"), '--dry-run not mapped');
assert(src.includes("case '--hard'") && src.includes("return ['-Hard']"), '--hard not mapped');
});
test('update.ps1 has dry-run guards and hard reset opt-in', () => {
const ps1 = readFileSync(join(__dirname, 'update.ps1'), 'utf8');
assert(ps1.includes('if ($DryRun)'), 'PowerShell dry-run guard missing');
assert(ps1.includes('if ($Hard)'), 'PowerShell hard reset opt-in missing');
assert(ps1.includes('[System.IO.Path]::GetTempPath()'), 'PowerShell backup root must use a portable temp path');
});
test('update.sh has dry-run guard, negation and failure trap', () => {
const sh = readFileSync(join(__dirname, 'update.sh'), 'utf8');
assert(sh.indexOf('if [[ "$DRY_RUN" -eq 1 ]]') < sh.indexOf('git init'), 'bash dry-run guard not before git init');
assert(sh.includes('apply_negation_patterns'), 'bash negation support missing');
assert(sh.includes('trap on_err ERR'), 'bash failure trap missing');
});
// ---------------------------------------------------------------------------
// PowerShell integration tests
// ---------------------------------------------------------------------------
if (psAvailable()) {
test('dry-run is read-only on tarball-like sandbox', () => {
const sb = makeSandbox();
try {
const out = runPowerShell(sb, ['-DryRun', '-Force']);
assert(out.status === 0, 'dry-run failed: ' + out.stderr + out.stdout);
assert(!existsSync(join(sb, '.git')), '.git should not be created in dry-run');
assert(readFileSync(join(sb, '.env'), 'utf8') === 'SECRET=local-secret\n', '.env should not be modified');
assert(out.stdout.includes('First-time sync (upstream snapshot)'), 'expected first-time strategy label');
assert(out.stdout.includes('reports/') || out.stdout.includes('reports'), 'reports/ should be listed as protected');
assert(out.stdout.includes('evidence/') || out.stdout.includes('evidence'), 'evidence/ should be listed as protected');
assert(!out.stdout.includes('.env.example'), '.env.example should NOT be listed as protected');
} finally {
cleanup(sb);
}
});
test('dry-run preserves dirty worktree changes in protected paths', () => {
const sb = makeSandbox();
try {
writeFileSync(join(sb, '.env'), 'SECRET=dirty-secret\n');
writeFileSync(join(sb, 'reports', 'engagement.md'), '# changed\n');
const out = runPowerShell(sb, ['-DryRun', '-Force']);
assert(out.status === 0, 'dry-run failed: ' + out.stderr + out.stdout);
assert(readFileSync(join(sb, '.env'), 'utf8') === 'SECRET=dirty-secret\n', 'dirty .env should survive dry-run');
assert(readFileSync(join(sb, 'reports', 'engagement.md'), 'utf8') === '# changed\n', 'dirty report should survive dry-run');
} finally {
cleanup(sb);
}
});
test('merge strategy shown when local repo has commits', () => {
const sb = makeSandbox();
try {
execSync('git init', { cwd: sb, stdio: 'pipe' });
execSync('git config user.email "test@test"', { cwd: sb, stdio: 'pipe' });
execSync('git config user.name "Test"', { cwd: sb, stdio: 'pipe' });
execSync('git add .', { cwd: sb, stdio: 'pipe' });
execSync('git commit -m initial', { cwd: sb, stdio: 'pipe' });
const out = runPowerShell(sb, ['-DryRun', '-Force']);
assert(out.status === 0, 'dry-run failed: ' + out.stderr + out.stdout);
assert(out.stdout.includes('Merge upstream/main'), 'expected merge strategy label');
assert(!out.stdout.includes('Hard reset'), 'should not show hard reset without -Hard');
} finally {
cleanup(sb);
}
});
test('hard reset strategy is opt-in', () => {
const sb = makeSandbox();
try {
const out = runPowerShell(sb, ['-DryRun', '-Hard', '-Force']);
assert(out.status === 0, 'dry-run failed: ' + out.stderr + out.stdout);
assert(out.stdout.includes('Hard reset to upstream/main'), 'expected hard reset strategy label');
assert(out.stdout.includes('git reset --hard'), 'expected git reset --hard in dry-run plan');
assert(!existsSync(join(sb, '.git')), '.git should not be created in dry-run');
} finally {
cleanup(sb);
}
});
test('non-dry run requires confirmation unless forced', () => {
const sb = makeSandbox();
try {
const result = spawnSync(
'powershell',
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', join(sb, 'scripts', 'update.ps1'), '-DryRun'],
{ cwd: sb, stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf8', input: 'n\n' }
);
const out = stripAnsi((result.stdout || '') + (result.stderr || ''));
assert(out.includes('Cancelled') || out.includes('Cancelled - no changes made'), 'expected cancel message');
assert(!existsSync(join(sb, '.git')), '.git should not be created after cancellation');
} finally {
cleanup(sb);
}
});
} else {
console.log(' SKIP PowerShell integration tests: powershell not available');
}
// ---------------------------------------------------------------------------
// Bash integration tests
// ---------------------------------------------------------------------------
if (bashAvailable()) {
test('bash dry-run is read-only on tarball-like sandbox', () => {
const sb = makeSandbox();
try {
const out = runBash(sb, ['--dry-run', '--force']);
assert(out.status === 0, 'bash dry-run failed: ' + out.stderr + out.stdout);
assert(!existsSync(join(sb, '.git')), '.git should not be created in bash dry-run');
assert(readFileSync(join(sb, '.env'), 'utf8') === 'SECRET=local-secret\n', '.env should not be modified by bash dry-run');
assert(out.stdout.includes('Merge upstream') || out.stdout.includes('First-time sync'), 'expected strategy label in bash output');
} finally {
cleanup(sb);
}
});
test('bash hard reset is opt-in', () => {
const sb = makeSandbox();
try {
const out = runBash(sb, ['--dry-run', '--hard', '--force']);
assert(out.status === 0, 'bash dry-run failed: ' + out.stderr + out.stdout);
assert(out.stdout.includes('git reset --hard'), 'expected git reset --hard in bash dry-run plan');
assert(!existsSync(join(sb, '.git')), '.git should not be created in bash dry-run');
} finally {
cleanup(sb);
}
});
test('bash dry-run exits clean with no protected paths (no --force)', () => {
// Regression: grep -c on an empty file exits nonzero, so || echo 0 produced
// "0\n0" and broke [[ -eq ]] comparisons, tripping the ERR trap.
const sb = makeEmptySandbox();
try {
const result = spawnSync(_bash, ['scripts/update.sh', '--dry-run'], {
cwd: sb, stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf8', input: 'n\n',
});
const out = {
status: result.status,
stdout: stripAnsi(result.stdout || ''),
stderr: stripAnsi(result.stderr || ''),
};
assert(out.status === 0, 'bash dry-run with empty protected set should exit 0, got ' + out.status + '\n' + out.stdout + out.stderr);
assert(!out.stdout.includes('syntax error'), 'unexpected syntax error from broken protected_count:\n' + out.stdout);
assert(out.stdout.includes('No protected project files'), 'expected "No protected project files" message');
assert(out.stdout.includes('Cancelled'), 'expected cancel message when stdin declines');
assert(!existsSync(join(sb, '.git')), '.git should not be created in dry-run');
} finally {
cleanup(sb);
}
});
test('bash dry-run --force exits clean with no protected paths', () => {
const sb = makeEmptySandbox();
try {
const out = runBash(sb, ['--dry-run', '--force']);
assert(out.status === 0, 'bash dry-run --force with empty protected set should exit 0, got ' + out.status + '\n' + out.stdout + out.stderr);
assert(!out.stdout.includes('syntax error'), 'unexpected syntax error:\n' + out.stdout);
assert(out.stdout.includes('No protected project files'), 'expected "No protected project files" message');
assert(out.stdout.includes('Preview only'), 'expected dry-run preview completion message');
assert(!existsSync(join(sb, '.git')), '.git should not be created in dry-run');
} finally {
cleanup(sb);
}
});
} else {
console.log(' SKIP Bash integration tests: bash not available');
}
// ---------------------------------------------------------------------------
// Run all tests
// ---------------------------------------------------------------------------
console.log('');
console.log('======== updater tests ========');
console.log('');
for (const t of tests) {
try {
t.fn();
ok(t.name);
} catch (err) {
logFail(t.name, err);
}
}
console.log('');
console.log('======== ' + (fail === 0 ? 'ALL PASS' : fail + ' FAILED') + ' ========');
console.log('');
process.exit(fail === 0 ? 0 : 1);