92 lines
8.4 KiB
JavaScript
92 lines
8.4 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import {execFileSync} from 'node:child_process';
|
|
import {parseArgs,run,withLock,redact,redactObject,publicResult} from './core.mjs';
|
|
import {resolveContext,gitState,pull,EXPECTED_REMOTE} from './git.mjs';
|
|
import {freshArtifacts,snapshot,build,sourceFingerprint} from './build.mjs';
|
|
import {execute} from '../ops.mjs';
|
|
async function fixture(t) {
|
|
const root=await fs.mkdtemp(path.join(os.tmpdir(),'ycode-ops-fixture-'));
|
|
t.after(()=>fs.rm(root,{recursive:true,force:true}));
|
|
const git=(...args)=>execFileSync('git',['-C',root,...args],{encoding:'utf8',stdio:['ignore','pipe','pipe']}).trim();
|
|
git('init','-b','master');git('config','user.name','Ops Fixture');git('config','user.email','ops@example.invalid');
|
|
await fs.writeFile(path.join(root,'package.json'),JSON.stringify({name:'ycode'}));git('add','.');git('commit','-m','fixture');
|
|
return {root,git,ctx:await resolveContext({root,timeoutMs:10000})};
|
|
}
|
|
test('strict parser accepts only fixed action and bounded flags',()=>{
|
|
assert.equal(parseArgs(['dev','--json']).action,'dev');assert.equal(parseArgs(['stop-dev']).action,'stop-dev');
|
|
for(const args of [['dev;touch','/tmp/pwn'],['dev','--','echo'],['dev','--root','relative'],['build','--timeout-ms','1'],['status','--json','--json'],['dev','--root','/tmp','--command','x']])assert.throws(()=>parseArgs(args));
|
|
});
|
|
test('shell metacharacters remain literal argv, not commands',async t=>{
|
|
const root=await fs.mkdtemp(path.join(os.tmpdir(),'ops-argv-'));t.after(()=>fs.rm(root,{recursive:true,force:true}));
|
|
const value='; touch '+path.join(root,'injected');
|
|
const result=await run(process.execPath,['-e','process.stdout.write(process.argv[1])',value],{cwd:root});
|
|
assert.equal(result.stdout,value);await assert.rejects(fs.access(path.join(root,'injected')));
|
|
});
|
|
test('explicit root never silently picks parent or another repository',async t=>{
|
|
const {root}=await fixture(t);await fs.mkdir(path.join(root,'nested'));
|
|
await assert.rejects(resolveContext({root:path.join(root,'nested')}),e=>e.code==='ROOT_NOT_TOPLEVEL');
|
|
await fs.writeFile(path.join(root,'package.json'),'{"name":"other"}');await assert.rejects(resolveContext({root}),e=>e.code==='WRONG_REPOSITORY');
|
|
});
|
|
test('dirty checkout blocks pull without discarding changes',async t=>{
|
|
const {root,ctx}=await fixture(t);await fs.writeFile(path.join(root,'local.txt'),'preserve');
|
|
await assert.rejects(pull(ctx),e=>e.code==='DIRTY_WORKTREE');assert.equal(await fs.readFile(path.join(root,'local.txt'),'utf8'),'preserve');assert.equal((await gitState(ctx)).dirty,true);
|
|
});
|
|
test('pull refuses wrong branch and remote before network access',async t=>{
|
|
const {ctx,git}=await fixture(t);git('checkout','-b','other');await assert.rejects(pull(ctx),e=>e.code==='WRONG_BRANCH');
|
|
git('checkout','master');git('remote','add','gitea','https://example.invalid/other.git');await assert.rejects(pull(ctx),e=>e.code==='REMOTE_MISMATCH');
|
|
});
|
|
test('workspace lock excludes concurrent mutations and preserves unknown lock',async t=>{
|
|
const {ctx}=await fixture(t);
|
|
await withLock(ctx,'test',async()=>{await assert.rejects(withLock(ctx,'test',async()=>{}),e=>e.code==='WORKSPACE_LOCKED');});
|
|
await withLock(ctx,'test',async()=>{ctx.keepLock=true;});
|
|
await assert.rejects(withLock(ctx,'test',async()=>{}),e=>e.code==='WORKSPACE_LOCKED');
|
|
});
|
|
test('timeout is bounded and records actual timeout',async()=>{
|
|
const result=await run(process.execPath,['-e','setInterval(()=>{},1000)'],{timeoutMs:50});assert.equal(result.timedOut,true);
|
|
});
|
|
test('artifact freshness rejects stale outputs and accepts rewritten identical bytes',async t=>{
|
|
const {root}=await fixture(t);await fs.writeFile(path.join(root,'artifact'),'data');const before=await snapshot(root,f=>f.endsWith('artifact'));
|
|
assert.equal(freshArtifacts(before,before,Date.now()).length,0);
|
|
const after={artifact:{...before.artifact,mtimeMs:Date.now()+5}};assert.equal(freshArtifacts(before,after,Date.now()).length,1);
|
|
});
|
|
test('logs redact credentials and JSON errors are machine-readable',async t=>{
|
|
assert.equal(redact('https://user:password@example.invalid/token'), 'https://[REDACTED]@example.invalid/token');
|
|
const {root}=await fixture(t);const result=await run(process.execPath,['-e','console.error("token=fixture-secret")'],{logDir:path.join(root,'logs')});
|
|
assert.doesNotMatch(await fs.readFile(result.logPath,'utf8'),/fixture-secret/);
|
|
const failure=await execute(['dev','--command','bad']);assert.equal(failure.exitCode,2);assert.equal(failure.schemaVersion,1);
|
|
});
|
|
test('package never invokes installer on unsupported platforms',async t=>{
|
|
if(process.platform==='win32'&&process.arch==='x64')return t.skip('native packaging covered by Windows smoke');
|
|
const {ctx}=await fixture(t);await assert.rejects(build(ctx,'package'),e=>e.code==='WINDOWS_X64_REQUIRED');
|
|
});
|
|
test('fresh local fetch fast-forwards exactly; ahead/divergent and fetch failure preserve checkout',async t=>{
|
|
if(process.platform==='win32')return t.skip('POSIX git shim fixture; Windows covered by native smoke');
|
|
const {root,ctx,git}=await fixture(t);
|
|
const realGit=execFileSync('which',['git'],{encoding:'utf8'}).trim();
|
|
const remote=path.join(root,'.git','fixture-remote.git');execFileSync(realGit,['clone','--bare',root,remote],{stdio:'ignore'});
|
|
git('remote','add','gitea',EXPECTED_REMOTE);
|
|
// Wrapper substitutes ONLY the fetch transport with a local bare fixture; remote identity remains production contract.
|
|
const bin=path.join(root,'.git','fixture-bin');await fs.mkdir(bin);
|
|
const shim=path.join(bin,'git');await fs.writeFile(shim,`#!/usr/bin/env node\nconst {spawnSync}=require('node:child_process'); const a=process.argv.slice(2);const i=a.indexOf('fetch');if(i>=0){if(process.env.OPS_FIXTURE_FETCH_FAIL==='1')process.exit(8);a[a.indexOf('gitea',i)]=process.env.OPS_FIXTURE_REMOTE;}const r=spawnSync(process.env.OPS_FIXTURE_GIT,a,{stdio:'inherit'});process.exit(r.status??1);\n`,{mode:0o755});
|
|
const saved={PATH:process.env.PATH,OPS_FIXTURE_REMOTE:process.env.OPS_FIXTURE_REMOTE,OPS_FIXTURE_GIT:process.env.OPS_FIXTURE_GIT};
|
|
Object.assign(process.env,{PATH:bin+path.delimiter+process.env.PATH,OPS_FIXTURE_REMOTE:remote,OPS_FIXTURE_GIT:realGit});
|
|
t.after(()=>{for(const [k,v]of Object.entries(saved))if(v===undefined)delete process.env[k];else process.env[k]=v;delete process.env.OPS_FIXTURE_FETCH_FAIL;});
|
|
const old=git('rev-parse','HEAD');await fs.writeFile(path.join(root,'next'),'next');git('add','.');git('commit','-m','remote next');const latest=git('rev-parse','HEAD');git('push',remote,'master');git('checkout','--detach',old);git('branch','-f','master',old);git('checkout','master');
|
|
const synced=await pull(ctx);assert.equal(synced.evidence.fetchedSha,latest);assert.equal(git('rev-parse','HEAD'),latest);
|
|
await fs.writeFile(path.join(root,'ahead'),'ahead');git('add','.');git('commit','-m','local ahead');const ahead=git('rev-parse','HEAD');
|
|
await assert.rejects(pull(ctx),e=>e.code==='NOT_FAST_FORWARD');assert.equal(git('rev-parse','HEAD'),ahead);
|
|
process.env.OPS_FIXTURE_FETCH_FAIL='1';await assert.rejects(pull(ctx),e=>e.code==='FETCH_FAILED');assert.equal(git('rev-parse','HEAD'),ahead);
|
|
});
|
|
|
|
test('source fingerprint notices content changes even when HEAD and dirty boolean do not change',async t=>{const {ctx,root}=await fixture(t);const file=path.join(root,'local');await fs.writeFile(file,'one');const before=await sourceFingerprint(ctx);await fs.writeFile(file,'two');assert.notEqual(await sourceFingerprint(ctx),before);});
|
|
|
|
test('redaction preserves JSON syntax for embedded token error text',()=>{const encoded=JSON.stringify(redactObject({summary:'token=secret-value',next:1}));assert.equal(JSON.parse(encoded).next,1);assert.doesNotMatch(encoded,/secret-value/);});
|
|
|
|
test('dev-latest is unsupported before any Git mutation on non-Windows',async t=>{if(process.platform==='win32')return t.skip('non-Windows contract');const {root,git}=await fixture(t);const before=git('rev-parse','HEAD');const result=await execute(['dev-latest','--root',root]);assert.equal(result.status,'UNSUPPORTED');assert.equal(result.code,'WINDOWS_REQUIRED');assert.equal(git('rev-parse','HEAD'),before);});
|
|
|
|
test('oversized evidence is bounded for small-model JSON consumption',()=>{const result=publicResult({status:'FAIL',summary:'bounded',evidence:{data:'x'.repeat(100000)}});assert.equal(result.evidence.truncated,true);assert.ok(JSON.stringify(result).length<65536);});
|