diff --git a/test/containers/Containerfile.alpine b/test/containers/Containerfile.alpine new file mode 100644 index 0000000..9e073d3 --- /dev/null +++ b/test/containers/Containerfile.alpine @@ -0,0 +1,25 @@ +FROM alpine:3.21 + +RUN apk add --no-cache \ + bash \ + git \ + openssh-client \ + openssh-keygen \ + tmux \ + coreutils \ + grep \ + sed + +RUN adduser -D -s /bin/bash testuser + +COPY git-harden.sh /home/testuser/git-harden.sh +COPY test/ /home/testuser/test/ +RUN chown -R testuser:testuser /home/testuser + +USER testuser +WORKDIR /home/testuser + +RUN git config --global user.name "Test User" \ + && git config --global user.email "test@example.com" + +CMD ["bash", "test/run.sh"] diff --git a/test/containers/Containerfile.arch b/test/containers/Containerfile.arch new file mode 100644 index 0000000..4f7d6a4 --- /dev/null +++ b/test/containers/Containerfile.arch @@ -0,0 +1,22 @@ +FROM archlinux:base + +RUN pacman -Syu --noconfirm \ + bash \ + git \ + openssh \ + tmux \ + && pacman -Scc --noconfirm + +RUN useradd -m -s /bin/bash testuser + +COPY git-harden.sh /home/testuser/git-harden.sh +COPY test/ /home/testuser/test/ +RUN chown -R testuser:testuser /home/testuser + +USER testuser +WORKDIR /home/testuser + +RUN git config --global user.name "Test User" \ + && git config --global user.email "test@example.com" + +CMD ["bash", "test/run.sh"] diff --git a/test/containers/Containerfile.debian b/test/containers/Containerfile.debian new file mode 100644 index 0000000..7341d60 --- /dev/null +++ b/test/containers/Containerfile.debian @@ -0,0 +1,22 @@ +FROM debian:trixie + +RUN apt-get update && apt-get install -y --no-install-recommends \ + bash \ + git \ + openssh-client \ + tmux \ + && rm -rf /var/lib/apt/lists/* + +RUN useradd -m -s /bin/bash testuser + +COPY git-harden.sh /home/testuser/git-harden.sh +COPY test/ /home/testuser/test/ +RUN chown -R testuser:testuser /home/testuser + +USER testuser +WORKDIR /home/testuser + +RUN git config --global user.name "Test User" \ + && git config --global user.email "test@example.com" + +CMD ["bash", "test/run.sh"] diff --git a/test/containers/Containerfile.fedora b/test/containers/Containerfile.fedora new file mode 100644 index 0000000..38ff80b --- /dev/null +++ b/test/containers/Containerfile.fedora @@ -0,0 +1,22 @@ +FROM fedora:42 + +RUN dnf install -y \ + bash \ + git \ + openssh-clients \ + tmux \ + && dnf clean all + +RUN useradd -m -s /bin/bash testuser + +COPY git-harden.sh /home/testuser/git-harden.sh +COPY test/ /home/testuser/test/ +RUN chown -R testuser:testuser /home/testuser + +USER testuser +WORKDIR /home/testuser + +RUN git config --global user.name "Test User" \ + && git config --global user.email "test@example.com" + +CMD ["bash", "test/run.sh"] diff --git a/test/containers/Containerfile.ubuntu b/test/containers/Containerfile.ubuntu new file mode 100644 index 0000000..5908dfe --- /dev/null +++ b/test/containers/Containerfile.ubuntu @@ -0,0 +1,22 @@ +FROM ubuntu:24.04 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + bash \ + git \ + openssh-client \ + tmux \ + && rm -rf /var/lib/apt/lists/* + +RUN useradd -m -s /bin/bash testuser + +COPY git-harden.sh /home/testuser/git-harden.sh +COPY test/ /home/testuser/test/ +RUN chown -R testuser:testuser /home/testuser + +USER testuser +WORKDIR /home/testuser + +RUN git config --global user.name "Test User" \ + && git config --global user.email "test@example.com" + +CMD ["bash", "test/run.sh"] diff --git a/test/e2e.sh b/test/e2e.sh new file mode 100755 index 0000000..4c71e31 --- /dev/null +++ b/test/e2e.sh @@ -0,0 +1,292 @@ +#!/usr/bin/env bash +# test/e2e.sh — Run BATS tests inside containers across Linux distros +# Usage: test/e2e.sh [--runtime docker|podman] [--rebuild] [distro] + +set -o errexit +set -o nounset +set -o pipefail +IFS=$'\n\t' + +# ------------------------------------------------------------------------------ +# Constants +# ------------------------------------------------------------------------------ + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +readonly SCRIPT_DIR +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +readonly REPO_ROOT +readonly CONTAINER_DIR="${SCRIPT_DIR}/containers" +readonly IMAGE_PREFIX="git-harden-test" + +# Distro matrix: name=image +readonly DISTROS="ubuntu debian fedora alpine arch" + +# Colors (empty if not a terminal) +if [ -t 2 ]; then + readonly C_RED='\033[0;31m' + readonly C_GREEN='\033[0;32m' + readonly C_YELLOW='\033[0;33m' + readonly C_BOLD='\033[1m' + readonly C_RESET='\033[0m' +else + readonly C_RED='' + readonly C_GREEN='' + readonly C_YELLOW='' + readonly C_BOLD='' + readonly C_RESET='' +fi + +# Mutable state +RUNTIME="" +REBUILD=false +TARGET_DISTRO="" + +# Results tracking +RESULTS_DISTROS="" +RESULTS_STATUS="" +RESULTS_DURATION="" + +# ------------------------------------------------------------------------------ +# Helpers +# ------------------------------------------------------------------------------ + +die() { + printf '%bError:%b %s\n' "$C_RED" "$C_RESET" "$1" >&2 + exit 1 +} + +info() { + printf '%b[INFO]%b %s\n' "$C_YELLOW" "$C_RESET" "$1" >&2 +} + +# ------------------------------------------------------------------------------ +# Argument parsing +# ------------------------------------------------------------------------------ + +parse_args() { + while [ $# -gt 0 ]; do + case "$1" in + --runtime) + [ $# -ge 2 ] || die "--runtime requires an argument (docker or podman)" + RUNTIME="$2" + shift 2 + ;; + --rebuild) + REBUILD=true + shift + ;; + --help|-h) + usage + exit 0 + ;; + -*) + die "Unknown option: $1" + ;; + *) + TARGET_DISTRO="$1" + shift + ;; + esac + done +} + +usage() { + cat >&2 <<'EOF' +Usage: test/e2e.sh [OPTIONS] [DISTRO] + +Run BATS tests inside containers across Linux distributions. + +Options: + --runtime Container runtime (auto-detected if omitted) + --rebuild Force image rebuild ignoring cache + --help, -h Show this help + +Distros: ubuntu, debian, fedora, alpine, arch + +Examples: + test/e2e.sh # Run all distros + test/e2e.sh alpine # Run Alpine only + test/e2e.sh --runtime podman fedora # Use Podman, Fedora only +EOF +} + +# ------------------------------------------------------------------------------ +# Runtime detection +# ------------------------------------------------------------------------------ + +detect_runtime() { + if [ -n "$RUNTIME" ]; then + if ! command -v "$RUNTIME" >/dev/null 2>&1; then + die "${RUNTIME} is not installed" + fi + return + fi + + local has_docker=false + local has_podman=false + + if command -v docker >/dev/null 2>&1; then + has_docker=true + fi + if command -v podman >/dev/null 2>&1; then + has_podman=true + fi + + if [ "$has_docker" = true ]; then + RUNTIME="docker" + elif [ "$has_podman" = true ]; then + RUNTIME="podman" + else + die "Neither docker nor podman found. Install one of them:\n brew install docker\n brew install podman" + fi + + # Verify the daemon is running + if ! "$RUNTIME" info >/dev/null 2>&1; then + die "${RUNTIME} daemon is not running. Is the ${RUNTIME} service started?" + fi +} + +# ------------------------------------------------------------------------------ +# Build & run +# ------------------------------------------------------------------------------ + +build_image() { + local distro="$1" + local containerfile="${CONTAINER_DIR}/Containerfile.${distro}" + local image_name="${IMAGE_PREFIX}:${distro}" + + if [ ! -f "$containerfile" ]; then + die "Containerfile not found: $containerfile" + fi + + local build_args=() + if [ "$REBUILD" = true ]; then + build_args+=("--no-cache") + fi + + info "Building ${image_name}..." + if ! "$RUNTIME" build \ + "${build_args[@]}" \ + -f "$containerfile" \ + -t "$image_name" \ + "$REPO_ROOT" 2>&1; then + return 1 + fi +} + +run_tests() { + local distro="$1" + local image_name="${IMAGE_PREFIX}:${distro}" + + info "Running tests on ${distro}..." + if ! "$RUNTIME" run \ + --rm \ + --network=none \ + "$image_name" \ + bash test/run.sh 2>&1; then + return 1 + fi +} + +run_distro() { + local distro="$1" + local start_time + start_time="$(date +%s)" + + printf '\n%b══ %s ══%b\n' "$C_BOLD" "$distro" "$C_RESET" >&2 + + local status="PASS" + + if ! build_image "$distro"; then + status="FAIL (build)" + elif ! run_tests "$distro"; then + status="FAIL (tests)" + fi + + local end_time + end_time="$(date +%s)" + local duration=$(( end_time - start_time )) + + # Append to results + RESULTS_DISTROS="${RESULTS_DISTROS}${distro}\n" + RESULTS_STATUS="${RESULTS_STATUS}${status}\n" + RESULTS_DURATION="${RESULTS_DURATION}${duration}s\n" + + if [ "$status" = "PASS" ]; then + printf '%b ✓ %s passed (%ds)%b\n' "$C_GREEN" "$distro" "$duration" "$C_RESET" >&2 + else + printf '%b ✗ %s %s (%ds)%b\n' "$C_RED" "$distro" "$status" "$duration" "$C_RESET" >&2 + fi +} + +# ------------------------------------------------------------------------------ +# Summary +# ------------------------------------------------------------------------------ + +print_summary() { + printf '\n%b══ Summary ══%b\n' "$C_BOLD" "$C_RESET" >&2 + printf '%-12s %-20s %s\n' "DISTRO" "STATUS" "DURATION" >&2 + printf '%-12s %-20s %s\n' "------" "------" "--------" >&2 + + local distros_arr status_arr duration_arr + IFS=$'\n' read -r -d '' -a distros_arr <<< "$(printf '%b' "$RESULTS_DISTROS")" || true + IFS=$'\n' read -r -d '' -a status_arr <<< "$(printf '%b' "$RESULTS_STATUS")" || true + IFS=$'\n' read -r -d '' -a duration_arr <<< "$(printf '%b' "$RESULTS_DURATION")" || true + + local i=0 + local any_failed=false + while [ "$i" -lt "${#distros_arr[@]}" ]; do + local d="${distros_arr[$i]}" + local s="${status_arr[$i]}" + local t="${duration_arr[$i]}" + [ -z "$d" ] && { i=$((i + 1)); continue; } + + local color="$C_GREEN" + if [ "$s" != "PASS" ]; then + color="$C_RED" + any_failed=true + fi + printf '%b%-12s %-20s %s%b\n' "$color" "$d" "$s" "$t" "$C_RESET" >&2 + i=$((i + 1)) + done + + if [ "$any_failed" = true ]; then + return 1 + fi + return 0 +} + +# ------------------------------------------------------------------------------ +# Main +# ------------------------------------------------------------------------------ + +main() { + parse_args "$@" + detect_runtime + + info "Using runtime: ${RUNTIME}" + + if [ -n "$TARGET_DISTRO" ]; then + # Validate distro name + local valid=false + for d in $DISTROS; do + if [ "$d" = "$TARGET_DISTRO" ]; then + valid=true + break + fi + done + if [ "$valid" = false ]; then + die "Unknown distro: ${TARGET_DISTRO}. Available: ${DISTROS}" + fi + + run_distro "$TARGET_DISTRO" + else + for d in $DISTROS; do + run_distro "$d" + done + fi + + print_summary +} + +main "$@" diff --git a/test/interactive/helpers.sh b/test/interactive/helpers.sh new file mode 100755 index 0000000..8a4f2c0 --- /dev/null +++ b/test/interactive/helpers.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Shared helpers for interactive tmux-driven tests + +set -o errexit +set -o nounset +set -o pipefail +IFS=$'\n\t' + +readonly TMUX_SESSION="test" +readonly SCRIPT_PATH="${HOME}/git-harden.sh" + +# Colors +if [ -t 2 ]; then + readonly C_RED='\033[0;31m' + readonly C_GREEN='\033[0;32m' + readonly C_RESET='\033[0m' +else + readonly C_RED='' + readonly C_GREEN='' + readonly C_RESET='' +fi + +# Wait for a string to appear in the tmux pane. +# Polls every 0.2s, times out after $2 seconds (default 10). +wait_for() { + local pattern="$1" + local timeout="${2:-10}" + local elapsed=0 + while ! tmux capture-pane -t "$TMUX_SESSION" -p | grep -qF "$pattern"; do + sleep 0.2 + elapsed=$(( elapsed + 1 )) + if (( elapsed > timeout * 5 )); then + printf 'TIMEOUT waiting for: %s\n' "$pattern" >&2 + printf 'Current pane content:\n' >&2 + tmux capture-pane -t "$TMUX_SESSION" -p >&2 + return 1 + fi + done +} + +# Send keys to the tmux session +send() { + tmux send-keys -t "$TMUX_SESSION" "$@" +} + +# Start git-harden.sh in a tmux session +start_session() { + tmux kill-session -t "$TMUX_SESSION" 2>/dev/null || true + tmux new-session -d -s "$TMUX_SESSION" "bash ${SCRIPT_PATH}" +} + +# Wait for the script to exit and capture final output +capture_output() { + # Wait for the shell to become available (script exited) + local timeout=30 + local elapsed=0 + while tmux list-panes -t "$TMUX_SESSION" -F '#{pane_dead}' 2>/dev/null | grep -q '^0$'; do + sleep 0.5 + elapsed=$(( elapsed + 1 )) + if (( elapsed > timeout * 2 )); then + printf 'TIMEOUT waiting for script to exit\n' >&2 + tmux capture-pane -t "$TMUX_SESSION" -p >&2 + return 1 + fi + done + tmux capture-pane -t "$TMUX_SESSION" -p +} + +# Clean up +cleanup() { + tmux kill-session -t "$TMUX_SESSION" 2>/dev/null || true +} + +# Assert helper +assert_contains() { + local haystack="$1" + local needle="$2" + if printf '%s' "$haystack" | grep -qF "$needle"; then + return 0 + fi + printf '%bFAIL:%b expected output to contain: %s\n' "$C_RED" "$C_RESET" "$needle" >&2 + return 1 +} + +pass() { + printf '%b PASS:%b %s\n' "$C_GREEN" "$C_RESET" "$1" >&2 +} + +fail() { + printf '%b FAIL:%b %s\n' "$C_RED" "$C_RESET" "$1" >&2 +} diff --git a/test/interactive/run-all.sh b/test/interactive/run-all.sh new file mode 100755 index 0000000..ddccf9d --- /dev/null +++ b/test/interactive/run-all.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Run all interactive tmux-driven tests +# Intended to be run inside a container (with tmux installed) + +set -o nounset +set -o pipefail +IFS=$'\n\t' + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +passed=0 +failed=0 +total=0 + +for test_script in "${SCRIPT_DIR}"/test-*.sh; do + [ -f "$test_script" ] || continue + total=$((total + 1)) + printf '\n── %s ──\n' "$(basename "$test_script")" >&2 + if bash "$test_script"; then + passed=$((passed + 1)) + else + failed=$((failed + 1)) + fi +done + +printf '\n── Interactive tests: %d passed, %d failed, %d total ──\n' "$passed" "$failed" "$total" >&2 + +if [ "$failed" -gt 0 ]; then + exit 1 +fi diff --git a/test/interactive/test-full-accept.sh b/test/interactive/test-full-accept.sh new file mode 100755 index 0000000..00cc909 --- /dev/null +++ b/test/interactive/test-full-accept.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Interactive test: accept all prompts (safety gate + hardening + signing skip) +# Verifies: all settings applied, re-audit exits 0 + +set -o errexit +set -o nounset +set -o pipefail +IFS=$'\n\t' + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=helpers.sh +source "${SCRIPT_DIR}/helpers.sh" + +main() { + trap cleanup EXIT + + printf 'Test: Full interactive apply (accept all)\n' >&2 + + start_session + + # Safety review gate — answer yes + wait_for "reviewed this script" + send "y" Enter + + # Proceed with hardening — answer yes + wait_for "Proceed with hardening" + send "y" Enter + + # Accept each setting prompt by sending "y" + Enter repeatedly + local pane_content + for _ in $(seq 1 30); do + sleep 0.3 + pane_content="$(tmux capture-pane -t "$TMUX_SESSION" -p 2>/dev/null || true)" + if printf '%s' "$pane_content" | grep -qF "Signing key options"; then + break + fi + if printf '%s' "$pane_content" | grep -qF "Hardening complete"; then + break + fi + send "y" Enter + done + + # Signing wizard — skip + wait_for "Signing key options" 15 + send "s" Enter + + # Wait for completion + sleep 2 + capture_output >/dev/null 2>&1 || true + + # Verify: re-run audit — signing won't pass (skipped) but git config should + if git config --global --get transfer.fsckObjects | grep -q true; then + pass "Full accept: git config settings applied (signing skipped as expected)" + else + fail "Full accept: settings not applied" + exit 1 + fi +} + +main diff --git a/test/interactive/test-safety-gate-decline.sh b/test/interactive/test-safety-gate-decline.sh new file mode 100755 index 0000000..b8c8678 --- /dev/null +++ b/test/interactive/test-safety-gate-decline.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Interactive test: decline safety review gate +# Verifies: script exits 0, prints AI review instructions, no config changes + +set -o errexit +set -o nounset +set -o pipefail +IFS=$'\n\t' + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=helpers.sh +source "${SCRIPT_DIR}/helpers.sh" + +main() { + trap cleanup EXIT + + printf 'Test: Safety gate decline\n' >&2 + + # Snapshot current config + local config_before + config_before="$(git config --global --list 2>/dev/null || true)" + + start_session + + # Safety review gate — answer no (default) + wait_for "reviewed this script" + send "n" Enter + + # Wait for exit + sleep 2 + local output + output="$(capture_output)" + + # Verify: output contains AI review instructions + assert_contains "$output" "claude" + assert_contains "$output" "gemini" + + # Verify: no config changes + local config_after + config_after="$(git config --global --list 2>/dev/null || true)" + if [ "$config_before" = "$config_after" ]; then + pass "Safety gate decline: no config changes, instructions shown" + else + fail "Safety gate decline: config was modified" + exit 1 + fi +} + +main diff --git a/test/interactive/test-signing-generate.sh b/test/interactive/test-signing-generate.sh new file mode 100755 index 0000000..f2a48b0 --- /dev/null +++ b/test/interactive/test-signing-generate.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# Interactive test: generate ed25519 key via signing wizard +# Verifies: key created, user.signingkey configured, commit.gpgsign=true + +set -o errexit +set -o nounset +set -o pipefail +IFS=$'\n\t' + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=helpers.sh +source "${SCRIPT_DIR}/helpers.sh" + +main() { + trap cleanup EXIT + + printf 'Test: Signing wizard - generate ed25519 key\n' >&2 + + # Ensure no existing keys + rm -f "${HOME}/.ssh/id_ed25519" "${HOME}/.ssh/id_ed25519.pub" + + start_session + + # Safety review gate + wait_for "reviewed this script" + send "y" Enter + + # Proceed with hardening + wait_for "Proceed with hardening" + send "y" Enter + + # Accept settings until signing wizard + local pane_content + for _ in $(seq 1 30); do + sleep 0.3 + pane_content="$(tmux capture-pane -t "$TMUX_SESSION" -p 2>/dev/null || true)" + if printf '%s' "$pane_content" | grep -qF "Signing key options"; then + break + fi + if printf '%s' "$pane_content" | grep -qF "Hardening complete"; then + break + fi + send "y" Enter + done + + # Signing wizard — option 1: generate ed25519 + wait_for "Signing key options" 15 + send "1" Enter + + # ssh-keygen prompts for passphrase — enter empty twice + wait_for "Enter passphrase" 10 + send "" Enter + wait_for "Enter same passphrase" 10 + send "" Enter + + # Wait for completion + sleep 3 + capture_output >/dev/null 2>&1 || true + + # Verify key exists + if [ -f "${HOME}/.ssh/id_ed25519.pub" ]; then + pass "Key generated: ~/.ssh/id_ed25519.pub exists" + else + fail "Key not generated" + exit 1 + fi + + # Verify signing key configured + local signing_key + signing_key="$(git config --global --get user.signingkey 2>/dev/null || true)" + if [ -n "$signing_key" ]; then + pass "user.signingkey configured: ${signing_key}" + else + fail "user.signingkey not configured" + exit 1 + fi + + # Verify gpgsign enabled + local gpgsign + gpgsign="$(git config --global --get commit.gpgsign 2>/dev/null || true)" + if [ "$gpgsign" = "true" ]; then + pass "commit.gpgsign=true" + else + fail "commit.gpgsign not set" + exit 1 + fi +} + +main diff --git a/test/interactive/test-signing-skip.sh b/test/interactive/test-signing-skip.sh new file mode 100755 index 0000000..10159a6 --- /dev/null +++ b/test/interactive/test-signing-skip.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Interactive test: skip signing wizard +# Verifies: no signing key configured, commit.gpgsign not set + +set -o errexit +set -o nounset +set -o pipefail +IFS=$'\n\t' + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=helpers.sh +source "${SCRIPT_DIR}/helpers.sh" + +main() { + trap cleanup EXIT + + printf 'Test: Signing wizard - skip\n' >&2 + + start_session + + # Safety review gate + wait_for "reviewed this script" + send "y" Enter + + # Proceed with hardening + wait_for "Proceed with hardening" + send "y" Enter + + # Accept settings until signing wizard + local pane_content + for _ in $(seq 1 30); do + sleep 0.3 + pane_content="$(tmux capture-pane -t "$TMUX_SESSION" -p 2>/dev/null || true)" + if printf '%s' "$pane_content" | grep -qF "Signing key options"; then + break + fi + if printf '%s' "$pane_content" | grep -qF "Hardening complete"; then + break + fi + send "y" Enter + done + + # Signing wizard — skip + wait_for "Signing key options" 15 + send "s" Enter + + # Wait for completion + sleep 2 + capture_output >/dev/null 2>&1 || true + + # Verify: no signing key + local signing_key + signing_key="$(git config --global --get user.signingkey 2>/dev/null || true)" + if [ -z "$signing_key" ]; then + pass "Signing skip: user.signingkey not set" + else + fail "Signing skip: user.signingkey was set unexpectedly: ${signing_key}" + exit 1 + fi + + # Verify: commit.gpgsign not set + local gpgsign + gpgsign="$(git config --global --get commit.gpgsign 2>/dev/null || true)" + if [ -z "$gpgsign" ]; then + pass "Signing skip: commit.gpgsign not set" + else + fail "Signing skip: commit.gpgsign was set unexpectedly: ${gpgsign}" + exit 1 + fi +} + +main