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>
This commit is contained in:
1 parent
266655abcf
commit
916c260fd8
8 files changed
+1710
-2
No files matched your search
@@ -0,0 +1,371 @@
|
||||
#!/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);
|
||||
Reference in new issue
Block a user