mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 12:10:49 +00:00
Merge pull request #18 from AikidoSec/e2e-tests-docker
E2e tests using docker
This commit is contained in:
commit
13165b1350
10 changed files with 417 additions and 6 deletions
38
.github/workflows/test-on-pr.yml
vendored
38
.github/workflows/test-on-pr.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
5
.npmignore
Normal file
5
.npmignore
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
|
||||
.github
|
||||
.claude
|
||||
test/e2e
|
||||
|
||||
|
|
@ -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']),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
113
test/e2e/DockerTestContainer.js
Normal file
113
test/e2e/DockerTestContainer.js
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
32
test/e2e/Dockerfile
Normal file
32
test/e2e/Dockerfile
Normal file
|
|
@ -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
|
||||
|
||||
32
test/e2e/package-lock.json
generated
Normal file
32
test/e2e/package-lock.json
generated
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
test/e2e/package.json
Normal file
15
test/e2e/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
104
test/e2e/parseShellOutput.js
Normal file
104
test/e2e/parseShellOutput.js
Normal file
|
|
@ -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;
|
||||
}
|
||||
77
test/e2e/setup.teardown.e2e.spec.js
Normal file
77
test/e2e/setup.teardown.e2e.spec.js
Normal file
|
|
@ -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"
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue