#!/usr/bin/env bash set -uo pipefail PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" PASS=0 FAIL=0 run_test() { local name="$1" shift if ("$@"); then printf 'PASS %s\n' "$name" PASS=$((PASS + 1)) else printf 'FAIL %s\n' "$name" FAIL=$((FAIL + 1)) fi } checksum_file() { local file="$1" if command -v sha256sum >/dev/null 2>&1; then sha256sum "$file" else shasum -a 256 "$file" fi } test_erp_run_preserves_java_option_arguments() ( local tmp tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-run-test.XXXXXX")" || return 1 trap 'rm -rf "$tmp"' EXIT mkdir -p "$tmp/current/app" "$tmp/run" "$tmp/state" : > "$tmp/current/app/kaidi-erp.jar" : > "$tmp/state/install.lock" : > "$tmp/-Dpattern=expanded" printf '%s\n' \ '#!/usr/bin/env bash' \ 'if [[ "${1:-}" == "-version" ]]; then' \ ' printf '\''openjdk version "17.0.12"\n'\'' >&2' \ ' exit 0' \ 'fi' \ 'printf '\''%s\n'\'' "$@"' > "$tmp/java" chmod +x "$tmp/java" { printf 'ERP_JAVA_BIN=%q\n' "$tmp/java" printf 'ERP_JAVA_OPTS=%q\n' '-Dpattern=* -Xmx1g' printf 'ERP_RUN_DIR=%q\n' "$tmp/run" } > "$tmp/erp.env" local output output="$(cd "$tmp" && ERP_INSTALL_ROOT="$tmp" ERP_CONFIG_FILE="$tmp/erp.env" \ "$PROJECT_ROOT/distribution/bin/erp-run")" || return 1 grep -Fqx -- '-Dpattern=*' <<< "$output" || return 1 grep -Fqx -- '-Xmx1g' <<< "$output" || return 1 grep -Fqx -- "$tmp/current/app/kaidi-erp.jar" <<< "$output" ) prepare_signed_archive() { local tmp="$1" unsafe="${2:-0}" mkdir -p "$tmp/package/kaidi-erp-1.2.3/app" printf 'jar\n' > "$tmp/package/kaidi-erp-1.2.3/app/kaidi-erp.jar" if [[ "$unsafe" == "1" ]]; then ln -s /etc/passwd "$tmp/package/kaidi-erp-1.2.3/unsafe-link" fi tar -czf "$tmp/kaidi-erp-1.2.3.tar.gz" -C "$tmp/package" kaidi-erp-1.2.3 printf 'installer jar\n' > "$tmp/kaidi-erp-installer-1.2.3.jar" (cd "$tmp" && checksum_file kaidi-erp-1.2.3.tar.gz && checksum_file kaidi-erp-installer-1.2.3.jar) > "$tmp/SHA256SUMS" openssl pkeyutl -sign -rawin -inkey "$tmp/private.pem" \ -in "$tmp/SHA256SUMS" -out "$tmp/SHA256SUMS.sig" } test_installer_verifies_signed_safe_archive() ( local tmp tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-signature-test.XXXXXX")" || return 1 trap 'rm -rf "$tmp"' EXIT openssl genpkey -algorithm ED25519 -out "$tmp/private.pem" >/dev/null 2>&1 || return 1 openssl pkey -in "$tmp/private.pem" -pubout -out "$tmp/public.pem" >/dev/null 2>&1 || return 1 prepare_signed_archive "$tmp" || return 1 source "$PROJECT_ROOT/install.sh" trap 'rm -rf "$tmp"' EXIT TMP_DIR="$tmp" PUBLIC_KEY="$(<"$tmp/public.pem")" ARCHIVE_NAME=kaidi-erp-1.2.3.tar.gz ARCHIVE_PATH="$tmp/$ARCHIVE_NAME" INSTALLER_NAME=kaidi-erp-installer-1.2.3.jar INSTALLER_PATH="$tmp/$INSTALLER_NAME" verify_release ) test_installer_rejects_archive_symlinks() ( local tmp tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-unsafe-test.XXXXXX")" || return 1 trap 'rm -rf "$tmp"' EXIT openssl genpkey -algorithm ED25519 -out "$tmp/private.pem" >/dev/null 2>&1 || return 1 openssl pkey -in "$tmp/private.pem" -pubout -out "$tmp/public.pem" >/dev/null 2>&1 || return 1 prepare_signed_archive "$tmp" 1 || return 1 source "$PROJECT_ROOT/install.sh" trap 'rm -rf "$tmp"' EXIT TMP_DIR="$tmp" PUBLIC_KEY="$(<"$tmp/public.pem")" ARCHIVE_NAME=kaidi-erp-1.2.3.tar.gz ARCHIVE_PATH="$tmp/$ARCHIVE_NAME" INSTALLER_NAME=kaidi-erp-installer-1.2.3.jar INSTALLER_PATH="$tmp/$INSTALLER_NAME" ! (verify_release) >/dev/null 2>&1 ) test_stable_channel_rejects_prerelease_tag() ( local tmp tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-prerelease-test.XXXXXX")" || return 1 trap 'rm -rf "$tmp"' EXIT printf 'OA_UPDATE_GITEA_BASE_URL=https://example.test\n' > "$tmp/erp.env" ERP_INSTALL_ROOT="$tmp" ERP_CONFIG_FILE="$tmp/erp.env" source "$PROJECT_ROOT/distribution/bin/erp-update" trap 'rm -rf "$tmp"' EXIT printf '%s\n' \ '{"tag_name":"v2.0.0-rc.1","draft":false,"prerelease":false,"assets":[' \ '{"name":"kaidi-erp-2.0.0-rc.1.tar.gz","browser_download_url":"https://example.test/app"},' \ '{"name":"SHA256SUMS","browser_download_url":"https://example.test/sums"},' \ '{"name":"SHA256SUMS.sig","browser_download_url":"https://example.test/sig"}]}' \ > "$tmp/release.json" CHANNEL=stable ! select_release "$tmp/release.json" '' "$tmp/selection" >/dev/null 2>&1 ) prepare_update_release_archive() { local tmp="$1" version="$2" local root="$tmp/package/kaidi-erp-$version" mkdir -p "$root/app" "$root/bin" "$root/config" "$tmp/assets" printf 'mock jar %s\n' "$version" > "$root/app/kaidi-erp.jar" cp "$PROJECT_ROOT/distribution/bin/erp-run" "$root/bin/erp-run" cp "$PROJECT_ROOT/distribution/bin/erp-update" "$root/bin/erp-update" chmod +x "$root/bin/erp-run" "$root/bin/erp-update" printf '%s\n' "$version" > "$root/VERSION" printf '{"version":"%s","database":"postgresql","rollbackCompatible":true}\n' \ "$version" > "$root/manifest.json" printf 'installer jar %s\n' "$version" > "$tmp/assets/kaidi-erp-installer-$version.jar" local archive="kaidi-erp-$version.tar.gz" tar -czf "$tmp/assets/$archive" -C "$tmp/package" "kaidi-erp-$version" (cd "$tmp/assets" && checksum_file "$archive" && checksum_file "kaidi-erp-installer-$version.jar") > "$tmp/assets/SHA256SUMS" : > "$tmp/assets/SHA256SUMS.sig" } test_installer_downloads_and_installs_signed_release() ( local version=3.0.0 tmp server_pid="" tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-install-e2e.XXXXXX")" || return 1 cleanup_install_scenario() { [[ -z "$server_pid" ]] || kill "$server_pid" 2>/dev/null || true [[ -z "$server_pid" ]] || wait "$server_pid" 2>/dev/null || true rm -rf "$tmp" } trap cleanup_install_scenario EXIT prepare_update_release_archive "$tmp" "$version" || return 1 openssl genpkey -algorithm ED25519 -out "$tmp/private.pem" >/dev/null 2>&1 || return 1 openssl pkey -in "$tmp/private.pem" -pubout -out "$tmp/public.pem" >/dev/null 2>&1 || return 1 openssl pkeyutl -sign -rawin -inkey "$tmp/private.pem" \ -in "$tmp/assets/SHA256SUMS" -out "$tmp/assets/SHA256SUMS.sig" || return 1 python3 - "$tmp" "$version" <<'PY' > "$tmp/server.log" 2>&1 & import json import os import sys from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer root, version = sys.argv[1:] assets = os.path.join(root, "assets") archive = f"kaidi-erp-{version}.tar.gz" class Handler(BaseHTTPRequestHandler): def send_bytes(self, status, body, content_type="application/octet-stream"): self.send_response(status) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def do_GET(self): if self.path == "/api/v1/repos/awaioi/ERP/releases/latest": base = f"http://127.0.0.1:{self.server.server_port}/assets" payload = { "tag_name": "v" + version, "draft": False, "prerelease": False, "assets": [ {"name": archive, "browser_download_url": base + "/" + archive}, {"name": f"kaidi-erp-installer-{version}.jar", "browser_download_url": base + f"/kaidi-erp-installer-{version}.jar"}, {"name": "SHA256SUMS", "browser_download_url": base + "/SHA256SUMS"}, {"name": "SHA256SUMS.sig", "browser_download_url": base + "/SHA256SUMS.sig"}, ], } self.send_bytes(200, json.dumps(payload).encode(), "application/json") return if self.path.startswith("/assets/"): name = self.path.removeprefix("/assets/") if name not in {archive, f"kaidi-erp-installer-{version}.jar", "SHA256SUMS", "SHA256SUMS.sig"}: self.send_bytes(404, b"not found", "text/plain") return with open(os.path.join(assets, name), "rb") as handle: self.send_bytes(200, handle.read()) return self.send_bytes(404, b"not found", "text/plain") def log_message(self, *_): pass server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) with open(os.path.join(root, "server.port"), "w", encoding="ascii") as handle: handle.write(str(server.server_port)) server.serve_forever() PY server_pid=$! local attempt=0 while [[ ! -s "$tmp/server.port" && "$attempt" -lt 100 ]]; do sleep 0.02 attempt=$((attempt + 1)) done [[ -s "$tmp/server.port" ]] || return 1 local port port="$(<"$tmp/server.port")" source "$PROJECT_ROOT/install.sh" trap cleanup_install_scenario EXIT GITEA_BASE_URL="http://127.0.0.1:$port" REPOSITORY=awaioi/ERP ALLOW_INSECURE=1 INSTALL_ROOT="$tmp/install" ERP_CONFIG_ROOT="$tmp/config" ERP_STATE_ROOT="$tmp/state" ERP_LOG_ROOT="$tmp/log" NO_SERVICE=0 PUBLIC_KEY="$(<"$tmp/public.pem")" detect_platform() { PLATFORM=darwin; } check_and_install_dependencies() { JAVA_BIN="$(command -v java)"; return 0; } start_service() { return 0; } wait_for_installer() { return 0; } main > "$tmp/install.log" 2>&1 || return 1 [[ "$(readlink "$INSTALL_ROOT/current")" == "releases/$version" ]] || return 1 [[ -r "$INSTALL_ROOT/current/app/kaidi-erp.jar" ]] || return 1 ( source "$ERP_CONFIG_ROOT/erp.env" [[ "$SPRING_PROFILES_ACTIVE" == "postgres" ]] [[ "$OA_UPDATE_ENABLED" == "true" ]] [[ "$OA_UPDATE_GITEA_BASE_URL" == "http://127.0.0.1:$port" ]] [[ "$ERP_SETUP_TOKEN" =~ ^[0-9a-f]{64}$ ]] [[ -z "${OA_DB_URL:-}" ]] ) ) run_update_scenario() ( local mode="$1" version=2.0.0 local tmp server_pid="" supervisor_pid="" tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-update-e2e.XXXXXX")" || return 1 cleanup_scenario() { [[ -z "$supervisor_pid" ]] || kill "$supervisor_pid" 2>/dev/null || true [[ -z "$server_pid" ]] || kill "$server_pid" 2>/dev/null || true [[ -z "$supervisor_pid" ]] || wait "$supervisor_pid" 2>/dev/null || true [[ -z "$server_pid" ]] || wait "$server_pid" 2>/dev/null || true rm -rf "$tmp" } trap cleanup_scenario EXIT prepare_update_release_archive "$tmp" "$version" || return 1 mkdir -p "$tmp/releases/1.0.0" "$tmp/run" "$tmp/state" printf '1.0.0\n' > "$tmp/releases/1.0.0/VERSION" ln -s releases/1.0.0 "$tmp/current" python3 - "$tmp" "$mode" <<'PY' > "$tmp/server.log" 2>&1 & import json import os import sys from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer root, mode = sys.argv[1:] assets = os.path.join(root, "assets") class Handler(BaseHTTPRequestHandler): def send_bytes(self, status, body, content_type="application/octet-stream"): self.send_response(status) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def do_GET(self): if self.path == "/api/v1/repos/awaioi/ERP/releases/latest": base = f"http://127.0.0.1:{self.server.server_port}" payload = { "tag_name": "v2.0.0", "draft": False, "prerelease": False, "assets": [ {"name": "kaidi-erp-2.0.0.tar.gz", "browser_download_url": base + "/assets/kaidi-erp-2.0.0.tar.gz"}, {"name": "SHA256SUMS", "browser_download_url": base + "/assets/SHA256SUMS"}, {"name": "SHA256SUMS.sig", "browser_download_url": base + "/assets/SHA256SUMS.sig"}, ], } self.send_bytes(200, json.dumps(payload).encode(), "application/json") return if self.path == "/health": current = os.path.basename(os.path.realpath(os.path.join(root, "current"))) healthy = current == "2.0.0" if mode == "success" else current == "1.0.0" self.send_bytes(200 if healthy else 503, b'{"status":"UP"}' if healthy else b'{"status":"DOWN"}', "application/json") return if self.path.startswith("/assets/"): name = self.path.removeprefix("/assets/") if name not in {"kaidi-erp-2.0.0.tar.gz", "SHA256SUMS", "SHA256SUMS.sig"}: self.send_bytes(404, b"not found", "text/plain") return with open(os.path.join(assets, name), "rb") as handle: self.send_bytes(200, handle.read()) return self.send_bytes(404, b"not found", "text/plain") def log_message(self, *_): pass server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) with open(os.path.join(root, "server.port"), "w", encoding="ascii") as handle: handle.write(str(server.server_port)) server.serve_forever() PY server_pid=$! local attempt=0 while [[ ! -s "$tmp/server.port" && "$attempt" -lt 100 ]]; do sleep 0.02 attempt=$((attempt + 1)) done [[ -s "$tmp/server.port" ]] || return 1 local port port="$(<"$tmp/server.port")" ( child="" trap '[[ -z "$child" ]] || kill "$child" 2>/dev/null || true' EXIT trap 'exit 0' TERM INT while :; do sleep 300 & child=$! printf '%s\n' "$child" > "$tmp/run/app.pid" wait "$child" 2>/dev/null || true child="" sleep 0.05 done ) & supervisor_pid=$! attempt=0 while [[ ! -s "$tmp/run/app.pid" && "$attempt" -lt 100 ]]; do sleep 0.02 attempt=$((attempt + 1)) done [[ -s "$tmp/run/app.pid" ]] || return 1 { printf 'ERP_RUN_DIR=%q\n' "$tmp/run" printf 'OA_UPDATE_STATE_FILE=%q\n' "$tmp/state/update.json" printf 'OA_UPDATE_GITEA_BASE_URL=http://127.0.0.1:%s\n' "$port" printf 'OA_UPDATE_ALLOW_INSECURE_HTTP=true\n' printf 'OA_UPDATE_REPOSITORY=awaioi/ERP\n' printf 'ERP_HEALTH_URL=http://127.0.0.1:%s/health\n' "$port" printf 'ERP_UPDATE_REQUIRE_SIGNATURE=false\n' printf 'ERP_UPDATE_BACKUP_MODE=none\n' printf 'ERP_UPDATE_HEALTH_TIMEOUT_SECONDS=3\n' printf 'ERP_UPDATE_HEALTH_POLL_SECONDS=1\n' } > "$tmp/erp.env" local status=0 phase current ERP_INSTALL_ROOT="$tmp" ERP_CONFIG_FILE="$tmp/erp.env" \ "$PROJECT_ROOT/distribution/bin/erp-update" install "$version" \ > "$tmp/update.log" 2>&1 || status=$? phase="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["phase"])' "$tmp/state/update.json")" \ || return 1 current="$(readlink "$tmp/current")" if [[ "$mode" == "success" ]]; then [[ "$status" == "0" && "$phase" == "SUCCEEDED" && "$current" == "releases/2.0.0" ]] else [[ "$status" != "0" && "$phase" == "ROLLED_BACK" && "$current" == "releases/1.0.0" ]] fi ) test_update_helper_installs_healthy_release() ( run_update_scenario success ) test_update_helper_rolls_back_unhealthy_release() ( run_update_scenario rollback ) test_update_lock_rejects_concurrent_process() ( local tmp tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-lock-test.XXXXXX")" || return 1 trap 'rm -rf "$tmp"' EXIT mkdir -p "$tmp/bin" "$tmp/run" "$tmp/state" printf '%s\n' \ '#!/usr/bin/env bash' \ 'printf '\''%s\n'\'' "$$" > "$MOCK_CURL_PID_FILE"' \ 'trap '\''exit 1'\'' TERM INT' \ 'sleep 30' \ 'exit 1' > "$tmp/bin/curl" chmod +x "$tmp/bin/curl" { printf 'ERP_RUN_DIR=%q\n' "$tmp/run" printf 'OA_UPDATE_STATE_FILE=%q\n' "$tmp/state/update.json" printf 'OA_UPDATE_GITEA_BASE_URL=%q\n' 'http://127.0.0.1:1' printf 'OA_UPDATE_ALLOW_INSECURE_HTTP=true\n' printf 'OA_UPDATE_REPOSITORY=awaioi/ERP\n' } > "$tmp/erp.env" PATH="$tmp/bin:$PATH" MOCK_CURL_PID_FILE="$tmp/curl.pid" \ ERP_INSTALL_ROOT="$tmp" ERP_CONFIG_FILE="$tmp/erp.env" \ "$PROJECT_ROOT/distribution/bin/erp-update" install > "$tmp/first.log" 2>&1 & local first=$! attempt=0 while [[ ! -s "$tmp/run/update.lock" && "$attempt" -lt 100 ]]; do sleep 0.02 attempt=$((attempt + 1)) done [[ -s "$tmp/run/update.lock" ]] || { kill "$first" 2>/dev/null || true; return 1; } local output status=0 rejected=0 output="$(PATH="$tmp/bin:$PATH" MOCK_CURL_PID_FILE="$tmp/curl-2.pid" \ ERP_INSTALL_ROOT="$tmp" ERP_CONFIG_FILE="$tmp/erp.env" \ "$PROJECT_ROOT/distribution/bin/erp-update" install 2>&1)" || status=$? [[ "$status" -ne 0 && "$output" == *'已有更新任务正在执行'* ]] && rejected=1 [[ ! -r "$tmp/curl.pid" ]] || kill "$(<"$tmp/curl.pid")" 2>/dev/null || true kill "$first" 2>/dev/null || true wait "$first" 2>/dev/null || true [[ "$rejected" == "1" ]] ) test_installer_moves_database_setup_to_web_wizard() ( local output status=0 output="$(bash "$PROJECT_ROOT/install.sh" --db-mode existing 2>&1)" || status=$? [[ "$status" -ne 0 && "$output" == *'unknown option: --db-mode'* ]] ) test_installer_requires_explicit_gitea_url() ( local output status=0 output="$(ERP_GITEA_BASE_URL= bash -c ' root="$1" set -- source "$root/install.sh" GITEA_BASE_URL= main ' _ "$PROJECT_ROOT" 2>&1)" || status=$? [[ "$status" -ne 0 && "$output" == *'Gitea URL is required'* ]] ) test_linux_service_preflight_requires_systemd() ( local tmp output status=0 tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-systemd-test.XXXXXX")" || return 1 trap 'rm -rf "$tmp"' EXIT mkdir -p "$tmp/bin" source "$PROJECT_ROOT/install.sh" PLATFORM=linux NO_SERVICE=0 output="$(PATH="$tmp/bin" validate_service_manager 2>&1)" || status=$? [[ "$status" -ne 0 && "$output" == *'systemd'* ]] || return 1 NO_SERVICE=1 PATH="$tmp/bin" validate_service_manager ) test_no_service_install_disables_online_update() ( local tmp tmp="$(mktemp -d "${TMPDIR:-/tmp}/erp-no-service-test.XXXXXX")" || return 1 trap 'rm -rf "$tmp"' EXIT source "$PROJECT_ROOT/install.sh" trap 'rm -rf "$tmp"' EXIT CONFIG_ROOT="$tmp/config" STATE_ROOT="$tmp/state" INSTALL_ROOT="$tmp/install" PLATFORM=darwin NO_SERVICE=1 VERSION=1.0.0 JAVA_BIN="$(command -v java)" GITEA_BASE_URL=https://example.test REPOSITORY=awaioi/ERP mkdir -p "$CONFIG_ROOT" "$STATE_ROOT" "$INSTALL_ROOT" write_bootstrap_configuration || return 1 grep -Fqx 'OA_UPDATE_ENABLED=false' "$CONFIG_ROOT/erp.env" ) test_release_workflow_uses_scoped_job_token() ( local workflow="$PROJECT_ROOT/.gitea/workflows/release.yml" grep -Fqx ' contents: read' "$workflow" grep -Fqx ' releases: write' "$workflow" grep -Fq 'GITEA_BASE_URL: ${{ github.server_url }}' "$workflow" grep -Fq 'GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}' "$workflow" ! grep -Fq 'RELEASE_GITEA_TOKEN' "$workflow" ! grep -Fq 'vars.GITEA_BASE_URL' "$workflow" ) test_installer_runs_when_piped_to_bash() ( local output status=0 output="$(ERP_GITEA_BASE_URL= bash -s -- --no-service < "$PROJECT_ROOT/install.sh" 2>&1)" || status=$? [[ "$status" -ne 0 ]] [[ "$output" == *'[ERP Install] ERROR:'* ]] [[ "$output" != *'BASH_SOURCE'* ]] ) run_test 'erp-run preserves Java option arguments' test_erp_run_preserves_java_option_arguments run_test 'installer accepts a correctly signed archive' test_installer_verifies_signed_safe_archive run_test 'installer rejects symlinks in release archives' test_installer_rejects_archive_symlinks run_test 'stable update channel rejects prerelease tags' test_stable_channel_rejects_prerelease_tag run_test 'installer downloads and installs a signed release end to end' test_installer_downloads_and_installs_signed_release run_test 'update helper installs a healthy release end to end' test_update_helper_installs_healthy_release run_test 'update helper rolls back an unhealthy release end to end' test_update_helper_rolls_back_unhealthy_release run_test 'update helper rejects a concurrent process' test_update_lock_rejects_concurrent_process run_test 'installer moves database setup to the web wizard' test_installer_moves_database_setup_to_web_wizard run_test 'installer requires an explicit Gitea URL' test_installer_requires_explicit_gitea_url run_test 'Linux production install requires systemd' test_linux_service_preflight_requires_systemd run_test 'no-service install disables online update' test_no_service_install_disables_online_update run_test 'release workflow uses the scoped Gitea job token' test_release_workflow_uses_scoped_job_token run_test 'installer starts correctly when piped to bash' test_installer_runs_when_piped_to_bash printf 'RESULT pass=%s fail=%s\n' "$PASS" "$FAIL" [[ "$FAIL" -eq 0 ]]