diff --git a/.github/workflows/test-on-pr.yml b/.github/workflows/test-on-pr.yml index b726376..e291a50 100644 --- a/.github/workflows/test-on-pr.yml +++ b/.github/workflows/test-on-pr.yml @@ -1,4 +1,4 @@ -name: Run Unit Tests +name: Run tests on: pull_request: @@ -6,7 +6,9 @@ on: - main jobs: - test: + unit-test: + name: Run unit tests and linting + runs-on: ubuntu-latest steps: @@ -21,8 +23,38 @@ jobs: - name: Install dependencies run: npm ci - - name: Run tests + - name: Run unit tests run: npm test - name: Run ESLint run: npm run lint + + e2e-tests: + name: Run E2E tests + + runs-on: ubuntu-latest + defaults: + run: + working-directory: "test/e2e" + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "lts/*" + + - name: Install dependencies + run: npm ci + + - name: Run unit tests + run: npm test + + - name: Clean up Docker resources + if: always() + run: | + # Clean up any remaining containers and images + docker ps -aq --filter "name=safe-chain-e2e-test" | xargs -r docker rm -f + docker images -q safe-chain-e2e-test | xargs -r docker rmi -f diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..72f297f --- /dev/null +++ b/.npmignore @@ -0,0 +1,5 @@ + +.github +.claude +test/e2e + diff --git a/eslint.config.js b/eslint.config.js index 27f6599..b210b69 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,5 +1,5 @@ import js from "@eslint/js"; -import { defineConfig } from "@eslint/config-helpers"; +import { defineConfig, globalIgnores } from "@eslint/config-helpers"; import globals from "globals"; import importPlugin from "eslint-plugin-import"; @@ -22,4 +22,5 @@ export default defineConfig([ }, rules: {}, }, + globalIgnores(['test/e2e']), ]); diff --git a/package.json b/package.json index 4ab4c95..20278fa 100644 --- a/package.json +++ b/package.json @@ -2,8 +2,8 @@ "name": "@aikidosec/safe-chain", "version": "1.0.0", "scripts": { - "test": "node --test --experimental-test-module-mocks **/*.spec.js", - "test:watch": "node --test --watch --experimental-test-module-mocks **/*.spec.js", + "test": "node --test --experimental-test-module-mocks 'src/**/*.spec.js'", + "test:watch": "node --test --watch --experimental-test-module-mocks 'src/**/*.spec.js'", "lint": "eslint ." }, "repository": { diff --git a/test/e2e/DockerTestContainer.js b/test/e2e/DockerTestContainer.js new file mode 100644 index 0000000..3351c08 --- /dev/null +++ b/test/e2e/DockerTestContainer.js @@ -0,0 +1,113 @@ +import { execSync } from "node:child_process"; +import * as pty from "node-pty"; +import { parseShellOutput } from "./parseShellOutput.js"; + +export class DockerTestContainer { + constructor(imageName, containerName) { + this.imageName = imageName; + this.containerName = containerName; + this.isRunning = false; + } + + async start() { + if (this.isRunning) { + throw new Error("Container is already running"); + } + + try { + // Start a long-running container that we can exec commands into + execSync( + `docker run -d --name ${this.containerName} ${this.imageName} sleep infinity`, + { stdio: "ignore" } + ); + this.isRunning = true; + } catch (error) { + throw new Error(`Failed to start container: ${error.message}`); + } + } + + async openShell(shell) { + let ptyProcess = pty.spawn( + "docker", + ["exec", "-it", this.containerName, shell], + { + name: "xterm-color", + cols: 80, + rows: 30, + } + ); + + await new Promise((resolve, reject) => { + ptyProcess.on("data", (data) => { + if (data.includes("\u001b[?2004h")) { + // This indicates that the shell is ready + resolve(); + } + }); + + ptyProcess.on("error", (err) => { + reject(err); + }); + }); + + function runCommand(command) { + if (!ptyProcess) { + throw new Error("Shell is not running"); + } + + return new Promise((resolve) => { + let allData = []; + + ptyProcess.on("data", handleInput); + + const timeout = setTimeout(() => { + // Fallback in case the command doesn't finish in a reasonable time + resolve({ allData, output: parseShellOutput(allData), command }); + ptyProcess.removeListener("data", handleInput); + }, 10000); + + function handleInput(data) { + allData.push(data); + + if (data.includes("\u001b[?2004h")) { + // This indicates that the command has finished executing + resolve({ allData, output: parseShellOutput(allData), command }); + ptyProcess.removeListener("data", handleInput); + clearTimeout(timeout); + } + } + + ptyProcess.write(`${command}\n`); + }); + } + + return { runCommand }; + } + + async stop() { + if (!this.isRunning) { + return; // Already stopped + } + + try { + // Force stop and remove the container + execSync(`docker kill ${this.containerName}`, { + stdio: "ignore", + timeout: 10000, + }); + } catch { + // Container might already be stopped + } + + try { + execSync(`docker rm -f ${this.containerName}`, { + stdio: "ignore", + timeout: 5000, + }); + } catch { + // Container might already be removed + } + + this.isRunning = false; + } +} diff --git a/test/e2e/Dockerfile b/test/e2e/Dockerfile new file mode 100644 index 0000000..4d919ef --- /dev/null +++ b/test/e2e/Dockerfile @@ -0,0 +1,32 @@ +FROM node:24-bookworm as builder + +ENV CI=true + +# Set working directory +WORKDIR /app + +# Copy package files first for better caching +COPY package*.json ./ + +# Install dependencies +RUN npm install + +# Copy the rest of the application +COPY . . + +# Build the application +RUN npm --no-git-tag-version version 1.0.0 --allow-same-version +RUN npm pack + +FROM mcr.microsoft.com/devcontainers/javascript-node:22-bookworm as runner + +WORKDIR /app + +COPY --from=builder /app/*.tgz /app/ + +# # Install the application package globally +RUN npm install -g /app/*.tgz + +RUN mkdir /testapp +RUN cd /testapp && npm init -y + diff --git a/test/e2e/package-lock.json b/test/e2e/package-lock.json new file mode 100644 index 0000000..55aabb7 --- /dev/null +++ b/test/e2e/package-lock.json @@ -0,0 +1,32 @@ +{ + "name": "@aikidosec/safe-chain-e2e-tests", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@aikidosec/safe-chain-e2e-tests", + "version": "1.0.0", + "license": "AGPL-3.0-or-later", + "dependencies": { + "node-pty": "^1.0.0" + } + }, + "node_modules/nan": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz", + "integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==", + "license": "MIT" + }, + "node_modules/node-pty": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz", + "integrity": "sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "nan": "^2.17.0" + } + } + } +} diff --git a/test/e2e/package.json b/test/e2e/package.json new file mode 100644 index 0000000..4748762 --- /dev/null +++ b/test/e2e/package.json @@ -0,0 +1,15 @@ +{ + "name": "@aikidosec/safe-chain-e2e-tests", + "version": "1.0.0", + "description": "End-to-end tests for the Aikido Safe Chain", + "scripts": { + "test": "node --test **/*.spec.js" + }, + "keywords": [], + "author": "Aikido Security", + "license": "AGPL-3.0-or-later", + "type": "module", + "dependencies": { + "node-pty": "^1.0.0" + } +} diff --git a/test/e2e/parseShellOutput.js b/test/e2e/parseShellOutput.js new file mode 100644 index 0000000..4c2d998 --- /dev/null +++ b/test/e2e/parseShellOutput.js @@ -0,0 +1,104 @@ +const escapeChar = "\u001b"; +const startMarker = `${escapeChar}[?2004l`; +const endMarker = `${escapeChar}[?2004h`; + +/* eslint-disable no-control-regex */ +// This module removes control characters and escape sequences from shell output. +// So it is allowed to use control characters in the regex patterns here. + +export function parseShellOutput(rawData) { + const stringData = rawData.join(""); + + let output = getDataBetweenStartAndEndMarkers(stringData); + output = processBackspaces(output); + output = processEraseCommands(output); + output = removeOscSequences(output); + output = removeAnsiSgrSequences(output); + output = removeRemainingEscapeSequences(output); + + return output.trim(); +} + +function getDataBetweenStartAndEndMarkers(data) { + if (!data.includes(startMarker) || !data.includes(endMarker)) { + return data; + } + + const startIndex = data.indexOf(startMarker); + const endIndex = data.indexOf(endMarker, startIndex + startMarker.length); + + if (startIndex === -1 || endIndex === -1) { + return ""; + } + + return data.slice(startIndex + startMarker.length, endIndex); +} + +function processBackspaces(data) { + const result = []; + + for (let i = 0; i < data.length; i++) { + const char = data[i]; + + if (char === "\b") { + // Backspace: remove the previous character if it exists + if (result.length > 0) { + result.pop(); + } + } else { + result.push(char); + } + } + + return result.join(""); +} + +function removeOscSequences(data) { + return data.replace(/\u001b\][0-9]*;[^\u0007\u001b]*(\u0007|\u001b\\)/g, ""); +} + +function removeAnsiSgrSequences(data) { + return data.replace(/\u001b\[[0-9;]*m/g, ""); +} + +function processEraseCommands(data) { + const lines = data.split("\n"); + const result = []; + + for (let line of lines) { + // Process erase in line commands + line = line.replace(/\u001b\[K/g, ""); // Erase to end of line + line = line.replace(/\u001b\[0K/g, ""); // Erase to end of line + line = line.replace(/\u001b\[1K/g, ""); // Erase from start of line to cursor + line = line.replace(/\u001b\[2K/g, ""); // Erase entire line - remove the whole line + + // Skip lines that were completely erased + if (line.includes("\u001b[2K")) { + continue; + } + + result.push(line); + } + + // Process erase in display commands + let output = result.join("\n"); + output = output.replace(/\u001b\[J/g, ""); // Erase to end of display + output = output.replace(/\u001b\[0J/g, ""); // Erase to end of display + output = output.replace(/\u001b\[1J/g, ""); // Erase from start to cursor + output = output.replace(/\u001b\[2J/g, ""); // Erase entire display + + return output; +} + +function removeRemainingEscapeSequences(data) { + // Remove mode setting sequences like \u001b[?1h, \u001b[?1l + data = data.replace(/\u001b\[\?[0-9]+[hl]/g, ""); + + // Remove any other CSI sequences we haven't handled + data = data.replace(/\u001b\[[0-9;?]*[A-Za-z]/g, ""); + + // Remove incomplete or malformed escape sequences + data = data.replace(/\u001b[^\u001b]*/g, ""); + + return data; +} diff --git a/test/e2e/setup.teardown.e2e.spec.js b/test/e2e/setup.teardown.e2e.spec.js new file mode 100644 index 0000000..804bf29 --- /dev/null +++ b/test/e2e/setup.teardown.e2e.spec.js @@ -0,0 +1,77 @@ +import { describe, it, before, beforeEach, afterEach } from "node:test"; +import { execSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { DockerTestContainer } from "./DockerTestContainer.js"; +import assert from "node:assert"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +describe("E2E: safe-chain setup command", () => { + const imageName = "safe-chain-e2e-test"; + const containerName = "safe-chain-e2e-test-container"; + let container; + + before(async () => { + // Build the Docker image for the test environment + try { + const sourceDir = path.join(__dirname, "../.."); + execSync(`docker build -t ${imageName} -f Dockerfile ${sourceDir}`, { + cwd: __dirname, + stdio: "ignore", + }); + } catch (error) { + throw new Error(`Failed to setup test environment: ${error.message}`); + } + }); + + beforeEach(async () => { + // Run a new Docker container for each test + container = new DockerTestContainer(imageName, containerName); + + await container.start(); + }); + + afterEach(async () => { + // Stop and clean up the container after each test + if (container) { + await container.stop(); + container = null; + } + }); + + for (let shell of ["bash", "zsh"]) { + it(`safe-chain setup wraps npm command after installation for ${shell}`, async () => { + // setting up the container + const installationShell = await container.openShell(shell); + await installationShell.runCommand("safe-chain setup"); + + const projectShell = await container.openShell(shell); + await projectShell.runCommand("cd /testapp"); + const result = await projectShell.runCommand("npm i axios"); + + assert.ok( + result.output.includes("Scanning for malicious packages..."), + "Expected npm command to be wrapped by safe-chain" + ); + }); + + it(`safe-chain teardown unwraps npm command after uninstallation for ${shell}`, async () => { + // setting up the container + const installationShell = await container.openShell(shell); + await installationShell.runCommand("safe-chain setup"); + await installationShell.runCommand("safe-chain teardown"); + + const projectShell = await container.openShell(shell); + await projectShell.runCommand("cd /testapp"); + await projectShell.runCommand("npm i axios"); + const result = await projectShell.runCommand("npm i axios"); + + assert.ok( + !result.output.includes("Scanning for malicious packages..."), + "Expected npm command to not be wrapped by safe-chain after teardown" + ); + }); + } +});