#!/usr/bin/env bash set -Eeuo pipefail ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) WORK=$(mktemp -d) trap 'rm -rf "$WORK"' EXIT REAL_OPENSSL=$(command -v openssl) fail() { printf 'Update fixture failed: %s\n' "$1" >&2 exit 1 } mode_of() { stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1" } sed -n '/^is_semver()/,/^}/p' "$ROOT/deploy/update.sh" > "$WORK/update-semver.sh" # shellcheck disable=SC1090,SC1091 source "$WORK/update-semver.sh" for version in 0.0.0 1.2.3-alpha- 1.2.3--alpha 1.2.3-alpha+build.07; do is_semver "$version" || fail "updater rejected valid SemVer $version" done for version in 01.2.3 1.02.3 1.2.03 1.2.3-01 1.2.3-alpha..1; do ! is_semver "$version" || fail "updater accepted invalid SemVer $version" done missing_service_user="kaidi-fixture-missing-$$" mkdir -p "$WORK/bootstrap/app" "$WORK/bootstrap/state/inbox" \ "$WORK/bootstrap/state/processing" "$WORK/bootstrap/state/failed" "$WORK/bootstrap/log" \ "$WORK/bootstrap/bin" cat > "$WORK/bootstrap/bin/flock" <<'SH' #!/bin/sh exit 0 SH chmod 0755 "$WORK/bootstrap/bin/flock" jq -n \ '{action:"DOWNLOAD",version:"1.0.0-preview.2",reason:"bootstrap fixture"}' \ > "$WORK/bootstrap/state/inbox/request.json" if KAIDI_APP_ROOT="$WORK/bootstrap/app" \ KAIDI_UPDATE_STATE_ROOT="$WORK/bootstrap/state" \ KAIDI_LOG_ROOT="$WORK/bootstrap/log" \ KAIDI_SERVICE_USER="$missing_service_user" \ KAIDI_SERVICE_GROUP="$missing_service_user" \ PATH="$WORK/bootstrap/bin:$PATH" \ sh "$ROOT/deploy/update.sh" > "$WORK/bootstrap.log" 2>&1; then fail 'updater accepted a missing service identity during bootstrap' fi grep -Fq '缺少服务用户' "$WORK/bootstrap.log" \ || fail 'updater bootstrap failure did not preserve its diagnostic' [ "$(jq -r '.state' "$WORK/bootstrap/state/status.json")" = FAILED ] \ || fail 'updater bootstrap failure did not persist FAILED' [ ! -e "$WORK/bootstrap/state/inbox/request.json" ] \ && [ ! -e "$WORK/bootstrap/state/processing/request.json" ] \ || fail 'updater bootstrap failure left a request permanently queued' find "$WORK/bootstrap/state/failed" -type f -name 'request-*.json' -print -quit | grep -q . \ || fail 'updater bootstrap failure did not archive the claimed request' write_mock_commands() { local mock_bin=$1 mkdir -p "$mock_bin" cat > "$mock_bin/curl" <<'SH' #!/bin/sh output= url= header_file= write_out= http_status=200 printf '%s\n' "$*" >> "${MOCK_CURL_LOG:-/dev/null}" while [ "$#" -gt 0 ]; do case "$1" in -o|--output) shift; output=$1 ;; --write-out) shift; write_out=$1 ;; --connect-timeout|--max-time) shift ;; --header|-H) shift case "${1:-}" in @*) header_file=${1#@} ;; esac ;; -*) ;; *) url=$1 ;; esac shift done case "$url" in "${MOCK_RELEASE_ORIGIN:-https://release.fixture.invalid}"/*) if [ -n "${MOCK_EXPECT_RELEASE_TOKEN:-}" ]; then [ -r "$header_file" ] || exit 90 grep -Fqx "Authorization: token $MOCK_EXPECT_RELEASE_TOKEN" "$header_file" || exit 91 printf 'AUTH_FILE_OK\n' >> "${MOCK_CURL_LOG:-/dev/null}" fi ;; esac if [ -n "$output" ]; then if [ -n "${MOCK_RELEASE_API_URL:-}" ] && [ "$url" = "$MOCK_RELEASE_API_URL" ]; then cp "$FIXTURE_RELEASE_ROOT/release-api.json" "$output" elif [ "$url" = "${KAIDI_HEALTH_URL:-http://127.0.0.1:18080/actuator/health}" ]; then health_ok=false case "${MOCK_HEALTH:-success}" in success) health_ok=true ;; fail-new) [ "$(cat "$MOCK_APP_ROOT/current/VERSION" 2>/dev/null)" = '1.0.0-preview.1' ] && health_ok=true ;; fail-after-first) health_count=$(cat "$MOCK_APP_ROOT/health-count" 2>/dev/null || printf 0) health_count=$((health_count + 1)) printf '%s\n' "$health_count" > "$MOCK_APP_ROOT/health-count" [ "$health_count" -eq 1 ] && health_ok=true ;; esac if [ "$health_ok" = true ]; then printf '{"status":"UP"}\n' > "$output" else printf '{"status":"DOWN"}\n' > "$output" http_status=503 fi elif [ "$url" = "${KAIDI_APP_INDEX_URL:-http://127.0.0.1:18080/}" ]; then cp "$MOCK_APP_ROOT/current/public/index.html" "$output" else [ "${MOCK_DOWNLOAD_FAILURE:-}" != "${url##*/}" ] || exit 22 cp "$FIXTURE_RELEASE_ROOT/${url##*/}" "$output" fi elif [ "${MOCK_HEALTH:-success}" = success ]; then printf '{"status":"UP"}\n' elif [ "${MOCK_HEALTH:-success}" = fail-new ] \ && [ "$(cat "$MOCK_APP_ROOT/current/VERSION" 2>/dev/null)" = '1.0.0-preview.1' ]; then printf '{"status":"UP"}\n' else exit 22 fi [ -z "$write_out" ] || printf '%s' "$http_status" SH cat > "$mock_bin/systemctl" <<'SH' #!/bin/sh printf '%s\n' "$*" >> "$MOCK_SYSTEMCTL_LOG" [ "${MOCK_UPDATE_PATH_START:-success}:$*" != 'fail:start kaidi-update.path' ] || exit 1 exit 0 SH cat > "$mock_bin/flock" <<'SH' #!/bin/sh exit 0 SH cat > "$mock_bin/setsid" <<'SH' #!/bin/sh exec "$@" SH cat > "$mock_bin/systemd-analyze" <<'SH' #!/bin/sh exit 0 SH cat > "$mock_bin/mysqldump" <<'SH' #!/bin/sh if [ "${MOCK_MYSQLDUMP:-success}" = fail ]; then printf 'partial dump\n' exit 9 fi printf '%s\n' '-- valid fixture dump' 'CREATE TABLE fixture (id INT);' SH cat > "$mock_bin/chown" <<'SH' #!/bin/sh exit 0 SH cat > "$mock_bin/runuser" <<'SH' #!/bin/sh [ "${1:-}" = -u ] || exit 2 shift 2 [ "${1:-}" = -- ] && shift exec "$@" SH cat > "$mock_bin/install" <<'SH' #!/usr/bin/env bash if [[ " $* " != *" -d "* ]]; then exec /usr/bin/install "$@" fi mode=0755 paths=() while [ "$#" -gt 0 ]; do case "$1" in -d) shift ;; -o|-g) shift 2 ;; -m) mode=$2; shift 2 ;; *) paths+=("$1"); shift ;; esac done mkdir -p "${paths[@]}" chmod "$mode" "${paths[@]}" SH cat > "$mock_bin/mv" <<'SH' #!/bin/sh case "${1:-}" in -Tf | -fT) source=$2 destination=$3 /bin/rm -f "$destination" /bin/mv "$source" "$destination" ;; *) /bin/mv "$@" ;; esac SH cat > "$mock_bin/sha256sum" <<'SH' #!/bin/sh hash=$($REAL_OPENSSL dgst -sha256 "$1" | awk '{print $NF}') printf '%s %s\n' "$hash" "$1" SH chmod 0755 "$mock_bin"/* } build_release() { local fixture=$1 local version=$2 local unit_prefix=${3:-new} local stage="$fixture/stage" mkdir -p "$fixture/release" "$stage/public" "$stage/ops" if [ ! -s "$WORK/fixture-private.pem" ]; then "$REAL_OPENSSL" genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \ -out "$WORK/fixture-private.pem" >/dev/null 2>&1 "$REAL_OPENSSL" pkey -in "$WORK/fixture-private.pem" -pubout \ -out "$WORK/fixture-public.pem" >/dev/null 2>&1 fi cp "$WORK/fixture-private.pem" "$fixture/private.pem" cp "$WORK/fixture-public.pem" "$fixture/release-public.pem" printf 'new application\n' > "$stage/app.jar" printf 'new\n' > "$stage/public/index.html" printf '%s\n' "$version" > "$stage/VERSION" printf '#!/bin/sh\nprintf "new updater\\n"\n' > "$stage/ops/update.sh" printf '#!/usr/bin/env bash\nexit 0\n' > "$stage/ops/baota-start.sh" printf '#!/usr/bin/env bash\nexit 0\n' > "$stage/ops/baota-init.sh" cp "$fixture/release-public.pem" "$stage/ops/release-public.pem" chmod 0755 "$stage/ops/update.sh" chmod 0755 "$stage/ops/baota-start.sh" chmod 0755 "$stage/ops/baota-init.sh" for name in kaidi-finance.service kaidi-update.service kaidi-update.path; do printf '%s %s\n' "$unit_prefix" "$name" > "$stage/ops/$name" done local artifact="kaidi-finance-$version.tar.gz" COPYFILE_DISABLE=1 tar -czf "$fixture/release/$artifact" -C "$stage" . local sha size sha=$($REAL_OPENSSL dgst -sha256 "$fixture/release/$artifact" | awk '{print $NF}') size=$(wc -c < "$fixture/release/$artifact" | tr -d '[:space:]') jq -n --arg version "$version" --arg artifact "$artifact" --arg sha "$sha" --argjson size "$size" \ '{version:$version,artifact:$artifact,sha256:$sha,artifactSizeBytes:$size, publishedAt:"2026-08-16T00:00:00Z",releaseNotes:"fixture"}' \ > "$fixture/release/release-manifest.json" "$REAL_OPENSSL" dgst -sha256 -sign "$fixture/private.pem" \ -out "$fixture/release/release-manifest.sig" "$fixture/release/release-manifest.json" } build_gitea_release_index() { local fixture=$1 local origin=${2:-https://gitea.fixture.invalid} local artifact version tag artifact=$(jq -er '.artifact' "$fixture/release/release-manifest.json") version=$(jq -er '.version' "$fixture/release/release-manifest.json") tag="v$version" jq -n --arg origin "$origin" --arg artifact "$artifact" --arg tag "$tag" \ '{tag_name:$tag,assets:[ {name:"release-manifest.json",browser_download_url:($origin + "/assets/release-manifest.json")}, {name:"release-manifest.sig",browser_download_url:($origin + "/assets/release-manifest.sig")}, {name:$artifact,browser_download_url:($origin + "/assets/" + $artifact)} ]}' > "$fixture/release/release-api.json" } prepare_installation() { local fixture=$1 local version=$2 mkdir -p "$fixture/app/releases/1.0.0-preview.1/public" "$fixture/app/bin" \ "$fixture/app/runtime/java/bin" "$fixture/state/inbox" "$fixture/systemd" "$fixture/log" printf 'old application\n' > "$fixture/app/releases/1.0.0-preview.1/app.jar" printf 'old\n' > "$fixture/app/releases/1.0.0-preview.1/public/index.html" printf '1.0.0-preview.1\n' > "$fixture/app/releases/1.0.0-preview.1/VERSION" ln -s "$fixture/app/releases/1.0.0-preview.1" "$fixture/app/current" printf 'old update.sh\n' > "$fixture/app/bin/update.sh" chmod 0755 "$fixture/app/bin/update.sh" cat > "$fixture/app/runtime/java/bin/java" <<'SH' #!/bin/sh exit 0 SH chmod 0755 "$fixture/app/runtime/java/bin/java" for name in kaidi-finance.service kaidi-update.service kaidi-update.path; do printf 'old %s\n' "$name" > "$fixture/systemd/$name" done write_request "$fixture" "$version" DOWNLOAD } write_request() { local fixture=$1 local version=$2 local action=$3 mkdir -p "$fixture/state/inbox" jq -n --arg action "$action" --arg version "$version" \ '{action:$action,version:$version,reason:"fixture"}' > "$fixture/state/inbox/request.json" } download_and_prepare_install() { local fixture=$1 local version=$2 truncate -s 0 "$fixture/systemctl.log" run_update "$fixture" success [ "$(jq -r '.state' "$fixture/state/status.json")" = READY ] \ || fail 'download phase did not persist READY' [ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \ || fail 'download phase changed the active application' [ -s "$fixture/state/cache/$version/release.tar.gz" ] \ || fail 'download phase did not persist the verified artifact cache' [ "$(jq -r '.downloadPercent' "$fixture/state/status.json")" -eq 100 ] \ || fail 'download phase did not persist 100 percent progress' [ "$(jq -r '.totalBytes' "$fixture/state/status.json")" -gt 0 ] \ || fail 'download phase did not persist artifact bytes' [ "$(jq -r '.bytesPerSecond' "$fixture/state/status.json")" -gt 0 ] \ || fail 'download phase did not persist a measured transfer speed' grep -Fq '"stage":"DOWNLOADING"' "$fixture/state/events.jsonl" \ || fail 'download phase did not persist structured runtime events' ! grep -Eq '^(start|restart|stop) kaidi-finance.service$' "$fixture/systemctl.log" \ || fail 'download phase changed the application service' write_request "$fixture" "$version" INSTALL } run_update() { local fixture=$1 local health=$2 local skip_backup=${3:-true} local backup_mode=${4:-} env \ PATH="$fixture/mock-bin:$PATH" \ REAL_OPENSSL="$REAL_OPENSSL" \ FIXTURE_RELEASE_ROOT="$fixture/release" \ MOCK_APP_ROOT="$fixture/app" \ MOCK_HEALTH="$health" \ MOCK_DOWNLOAD_FAILURE="${MOCK_DOWNLOAD_FAILURE:-}" \ MOCK_MYSQLDUMP="${MOCK_MYSQLDUMP:-success}" \ MOCK_CURL_LOG="${MOCK_CURL_LOG:-$fixture/curl.log}" \ MOCK_RELEASE_API_URL="${FIXTURE_RELEASE_API_URL-}" \ MOCK_RELEASE_ORIGIN="${FIXTURE_RELEASE_ORIGIN:-https://release.fixture.invalid}" \ MOCK_EXPECT_RELEASE_TOKEN="${FIXTURE_RELEASE_TOKEN-}" \ MOCK_SYSTEMCTL_LOG="$fixture/systemctl.log" \ MOCK_UPDATE_PATH_START="${MOCK_UPDATE_PATH_START:-success}" \ KAIDI_SERVICE_USER="$(id -un)" \ KAIDI_SERVICE_GROUP="$(id -gn)" \ KAIDI_APP_ROOT="$fixture/app" \ KAIDI_UPDATE_STATE_ROOT="$fixture/state" \ KAIDI_LOG_ROOT="$fixture/log" \ KAIDI_SYSTEMD_ROOT="$fixture/systemd" \ KAIDI_UPDATER_PATH="$fixture/app/bin/update.sh" \ KAIDI_RUNTIME_ENV_FILE="$fixture/runtime.env" \ KAIDI_SKIP_DB_BACKUP="$skip_backup" \ KAIDI_DB_BACKUP_MODE="$backup_mode" \ KAIDI_UPDATE_HEALTH_ATTEMPTS=1 \ KAIDI_UPDATE_HEALTH_INTERVAL_SECONDS=0 \ UPDATE_RELEASE_BASE_URL="${FIXTURE_RELEASE_BASE_URL-https://release.fixture.invalid}" \ UPDATE_RELEASE_API_URL="${FIXTURE_RELEASE_API_URL-}" \ UPDATE_RELEASE_TOKEN="${FIXTURE_RELEASE_TOKEN-}" \ UPDATE_PUBLIC_KEY="$fixture/release-public.pem" \ UPDATE_REQUEST_FILE="$fixture/state/inbox/request.json" \ UPDATE_STATUS_FILE="$fixture/state/status.json" \ KAIDI_HEALTH_URL=http://127.0.0.1:19090/actuator/health \ KAIDI_APP_INDEX_URL=http://127.0.0.1:19090/ \ "$ROOT/deploy/update.sh" } assert_database_backup_opt_in_case() { local fixture="$WORK/database-backup-opt-in" local version='1.0.0-preview.2' mkdir -p "$fixture" write_mock_commands "$fixture/mock-bin" build_release "$fixture" "$version" prepare_installation "$fixture" "$version" download_and_prepare_install "$fixture" "$version" run_update "$fixture" success false mysqldump find "$fixture/state/backups" -type f -name 'mysql-*.sql.gz' -print -quit | grep -q . \ || fail 'explicit mysqldump mode did not create a database backup' } assert_private_gitea_release_case() { local fixture="$WORK/private-gitea" local version='1.0.0-preview.2' local api_url='https://gitea.fixture.invalid/api/v1/repos/ERP-Team/kaidi/releases/latest' local token='fixture-read-only-token' mkdir -p "$fixture" write_mock_commands "$fixture/mock-bin" build_release "$fixture" "$version" build_gitea_release_index "$fixture" prepare_installation "$fixture" "$version" FIXTURE_RELEASE_BASE_URL='' \ FIXTURE_RELEASE_API_URL="$api_url" \ FIXTURE_RELEASE_ORIGIN=https://gitea.fixture.invalid \ FIXTURE_RELEASE_TOKEN="$token" \ MOCK_CURL_LOG="$fixture/curl.log" \ run_update "$fixture" success [ "$(jq -r '.state' "$fixture/state/status.json")" = READY ] \ || fail 'private Gitea download did not persist READY' ! grep -Fq "$token" "$fixture/curl.log" \ || fail 'private Gitea token leaked into curl process arguments' [ "$(grep -c '^AUTH_FILE_OK$' "$fixture/curl.log")" -eq 4 ] \ || fail 'private Gitea requests did not use the protected header file' ! grep -Fq -- '--location' "$fixture/curl.log" \ || fail 'authenticated private Gitea requests unexpectedly enabled redirects' } assert_cross_origin_gitea_browser_url_ignored() { local fixture="$WORK/cross-origin-gitea" local version='1.0.0-preview.2' mkdir -p "$fixture" write_mock_commands "$fixture/mock-bin" build_release "$fixture" "$version" build_gitea_release_index "$fixture" https://assets.fixture.invalid prepare_installation "$fixture" "$version" FIXTURE_RELEASE_BASE_URL='' \ FIXTURE_RELEASE_API_URL=https://gitea.fixture.invalid/api/v1/repos/ERP-Team/kaidi/releases/latest \ FIXTURE_RELEASE_ORIGIN=https://gitea.fixture.invalid \ FIXTURE_RELEASE_TOKEN=fixture-read-only-token \ MOCK_CURL_LOG="$fixture/curl.log" \ run_update "$fixture" success [ "$(jq -r '.state' "$fixture/state/status.json")" = READY ] \ || fail 'misconfigured browser download URL prevented trusted same-origin download' ! grep -Fq 'assets.fixture.invalid' "$fixture/curl.log" \ || fail 'cross-origin browser download URL was requested' } assert_success_case() { local fixture="$WORK/success" local version='1.0.0-preview.2+build.7' mkdir -p "$fixture" write_mock_commands "$fixture/mock-bin" build_release "$fixture" "$version" prepare_installation "$fixture" "$version" download_and_prepare_install "$fixture" "$version" run_update "$fixture" success [ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/$version" ] \ || fail 'success case did not activate the new application' cmp -s "$fixture/app/bin/update.sh" "$fixture/app/current/ops/update.sh" \ || fail 'success case did not activate the signed updater' [ "$(jq -r '.state' "$fixture/state/status.json")" = SUCCEEDED ] \ || fail 'success case did not persist SUCCEEDED' [ ! -e "$fixture/state/processing/request.json" ] \ || fail 'success case left a claimed request behind' grep -qx 'daemon-reload' "$fixture/systemctl.log" || fail 'systemd units were not reloaded' grep -qx 'restart kaidi-finance.service' "$fixture/systemctl.log" \ || fail 'success case did not restart the application after the atomic switch' ! grep -qx 'stop kaidi-finance.service' "$fixture/systemctl.log" \ || fail 'success case stopped the application before switching releases' ! grep -qi nginx "$fixture/systemctl.log" || fail 'updater unexpectedly managed Nginx' [ "$(mode_of "$fixture/app")" = 750 ] || fail 'application root is not traversable by the service group' [ "$(mode_of "$fixture/app/releases/$version")" = 750 ] || fail 'release root mode is not 0750' [ "$(mode_of "$fixture/app/current/app.jar")" = 640 ] || fail 'release file mode is not 0640' [ "$(mode_of "$fixture/app/current/ops/update.sh")" = 750 ] || fail 'release updater mode is not 0750' } assert_identical_systemd_operations_case() { local fixture="$WORK/identical-systemd" local version='1.0.0-preview.2' mkdir -p "$fixture" write_mock_commands "$fixture/mock-bin" build_release "$fixture" "$version" old prepare_installation "$fixture" "$version" download_and_prepare_install "$fixture" "$version" truncate -s 0 "$fixture/systemctl.log" run_update "$fixture" success [ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/$version" ] \ || fail 'identical systemd case did not activate the new application' ! grep -qx 'daemon-reload' "$fixture/systemctl.log" \ || fail 'identical systemd units triggered an unnecessary daemon reload' } assert_update_path_failure_keeps_application_case() { local fixture="$WORK/path-watcher-failure" local version='1.0.0-preview.2' mkdir -p "$fixture" write_mock_commands "$fixture/mock-bin" build_release "$fixture" "$version" prepare_installation "$fixture" "$version" download_and_prepare_install "$fixture" "$version" MOCK_UPDATE_PATH_START=fail run_update "$fixture" success [ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/$version" ] \ || fail 'path watcher failure rolled back a healthy application' [ "$(jq -r '.state' "$fixture/state/status.json")" = SUCCEEDED ] \ || fail 'path watcher failure did not preserve successful application state' grep -Fq '自动更新监听器未能启动' "$fixture/state/status.json" \ || fail 'path watcher failure did not preserve its diagnostic' } assert_rollback_case() { local fixture="$WORK/rollback" local version='1.0.0-preview.2' mkdir -p "$fixture" write_mock_commands "$fixture/mock-bin" build_release "$fixture" "$version" prepare_installation "$fixture" "$version" download_and_prepare_install "$fixture" "$version" if run_update "$fixture" fail-new > "$fixture/update.log" 2>&1; then fail 'rollback case unexpectedly succeeded' fi grep -q '已恢复并验证旧版本' "$fixture/update.log" \ || fail 'rollback case did not report a complete restoration' [ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \ || fail 'rollback case did not restore the previous application' grep -qx 'old update.sh' "$fixture/app/bin/update.sh" \ || fail 'rollback case did not restore the previous updater' grep -qx 'old kaidi-update.path' "$fixture/systemd/kaidi-update.path" \ || fail 'rollback case did not restore the previous path unit' ! grep -qx 'stop kaidi-finance.service' "$fixture/systemctl.log" \ || fail 'rollback case stopped the application before restoring the previous release' [ "$(grep -c '^restart kaidi-finance.service$' "$fixture/systemctl.log")" -ge 2 ] \ || fail 'rollback case did not restart both the candidate and restored releases' [ "$(jq -r '.state' "$fixture/state/status.json")" = FAILED ] \ || fail 'rollback case did not persist FAILED' [ ! -e "$fixture/app/releases/$version" ] \ || fail 'rollback case left the failed release installed' find "$fixture/state/failed" -type f -name 'request-*.json' -print -quit | grep -q . \ || fail 'rollback case did not archive the failed request' } assert_incomplete_rollback_requires_manual_recovery_case() { local fixture="$WORK/incomplete-rollback" local version='1.0.0-preview.2' mkdir -p "$fixture" write_mock_commands "$fixture/mock-bin" build_release "$fixture" "$version" prepare_installation "$fixture" "$version" download_and_prepare_install "$fixture" "$version" if run_update "$fixture" fail-after-first > "$fixture/update.log" 2>&1; then fail 'incomplete rollback case unexpectedly succeeded' fi grep -Fq '需要人工恢复' "$fixture/update.log" \ || fail 'incomplete rollback case did not require explicit recovery' [ ! -e "$fixture/state/processing/request.json" ] \ || fail 'incomplete rollback case left an automatically retriggered processing request' [ ! -e "$fixture/state/transactions/active" ] \ || fail 'incomplete rollback case left transaction evidence on the automatic recovery path' [ -d "$fixture/state/transactions/recovery-required" ] \ || fail 'incomplete rollback case did not quarantine transaction evidence' [ "$(jq -r '.state' "$fixture/state/status.json")" = RECOVERY_REQUIRED ] \ || fail 'incomplete rollback case did not lock the updater for recovery' find "$fixture/state/failed" -type f -name 'request-*.json' -print -quit | grep -q . \ || fail 'incomplete rollback case did not archive its claimed request' truncate -s 0 "$fixture/systemctl.log" write_request "$fixture" "$version" DOWNLOAD if run_update "$fixture" success > "$fixture/retry.log" 2>&1; then fail 'recovery-locked updater accepted a new download request' fi [ "$(jq -r '.state' "$fixture/state/status.json")" = RECOVERY_REQUIRED ] \ || fail 'recovery-locked updater replaced the manual recovery status' ! grep -Eq '^(start|restart|stop) kaidi-finance.service$' "$fixture/systemctl.log" \ || fail 'recovery-locked download request changed the application service' [ ! -e "$fixture/state/inbox/request.json" ] && [ ! -e "$fixture/state/processing/request.json" ] \ || fail 'recovery-locked updater left a request on an automatic trigger path' } assert_download_failure_case() { local fixture="$WORK/download-failure" local version='1.0.0-preview.2' mkdir -p "$fixture" write_mock_commands "$fixture/mock-bin" build_release "$fixture" "$version" prepare_installation "$fixture" "$version" if MOCK_DOWNLOAD_FAILURE=release-manifest.json run_update "$fixture" success > "$fixture/update.log" 2>&1; then fail 'download failure case unexpectedly succeeded' fi [ "$(jq -r '.state' "$fixture/state/status.json")" = FAILED ] \ || fail 'download failure did not persist FAILED' [ ! -e "$fixture/state/processing/request.json" ] \ || fail 'download failure left a busy processing request' find "$fixture/state/failed" -type f -name 'request-*.json' -print -quit | grep -q . \ || fail 'download failure did not archive the request' } assert_database_failure_case() { local fixture="$WORK/database-failure" local version='1.0.0-preview.2' mkdir -p "$fixture" write_mock_commands "$fixture/mock-bin" build_release "$fixture" "$version" prepare_installation "$fixture" "$version" download_and_prepare_install "$fixture" "$version" if MOCK_MYSQLDUMP=fail run_update "$fixture" success false > "$fixture/update.log" 2>&1; then fail 'database failure case unexpectedly succeeded' fi [ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \ || fail 'database failure changed the active application' find "$fixture/state/backups" -type f -name 'mysql-*.sql.gz' -print -quit | grep -q . \ && fail 'database failure produced a trusted backup artifact' [ "$(jq -r '.state' "$fixture/state/status.json")" = FAILED ] \ || fail 'database failure did not persist FAILED' } assert_unhealthy_baseline_blocks_restart_case() { local fixture="$WORK/unhealthy-baseline" local version='1.0.0-preview.2' mkdir -p "$fixture" write_mock_commands "$fixture/mock-bin" build_release "$fixture" "$version" prepare_installation "$fixture" "$version" download_and_prepare_install "$fixture" "$version" truncate -s 0 "$fixture/systemctl.log" if run_update "$fixture" fail > "$fixture/update.log" 2>&1; then fail 'unhealthy baseline unexpectedly reached installation' fi grep -Fq '当前版本预检查失败' "$fixture/update.log" \ || fail 'unhealthy baseline did not preserve its preflight diagnostic' ! grep -Eq '^(start|restart|stop) kaidi-finance.service$' "$fixture/systemctl.log" \ || fail 'unhealthy baseline restarted the application' [ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \ || fail 'unhealthy baseline changed the active release' [ ! -e "$fixture/state/transactions/active" ] \ || fail 'unhealthy baseline created a switching transaction' } assert_invalid_database_url_blocks_restart_case() { local fixture="$WORK/invalid-database-url" local version='1.0.0-preview.2' mkdir -p "$fixture" write_mock_commands "$fixture/mock-bin" build_release "$fixture" "$version" prepare_installation "$fixture" "$version" download_and_prepare_install "$fixture" "$version" cat > "$fixture/runtime.env" <<'EOF' DB_URL="jdbc:mysql://" KAIDI_DB_HOST="127.0.0.1" KAIDI_DB_PORT="3306" KAIDI_DB_NAME="kaidi_finance" KAIDI_DB_USERNAME="kaidi" KAIDI_DB_PASSWORD="fixture" EOF truncate -s 0 "$fixture/systemctl.log" if run_update "$fixture" success > "$fixture/update.log" 2>&1; then fail 'invalid database URL unexpectedly reached installation' fi grep -Fq 'DB_URL 与主机、端口、库名不一致' "$fixture/update.log" \ || fail 'structurally invalid JDBC URL did not preserve its diagnostic' ! grep -q '^restart kaidi-finance.service$' "$fixture/systemctl.log" \ || fail 'invalid database URL restarted the application' [ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \ || fail 'invalid database URL changed the active release' } assert_symlink_request_rejected() { local fixture="$WORK/symlink-request" local version='1.0.0-preview.2' mkdir -p "$fixture" write_mock_commands "$fixture/mock-bin" build_release "$fixture" "$version" prepare_installation "$fixture" "$version" printf '%s\n' 'operator-owned sentinel' > "$fixture/sentinel" rm "$fixture/state/inbox/request.json" ln -s "$fixture/sentinel" "$fixture/state/inbox/request.json" if run_update "$fixture" success > "$fixture/update.log" 2>&1; then fail 'symlink request case unexpectedly succeeded' fi grep -q '更新请求必须是普通文件' "$fixture/update.log" \ || fail 'symlink request case did not report the unsafe request' [ "$(cat "$fixture/sentinel")" = 'operator-owned sentinel' ] \ || fail 'symlink request case changed the link target' [ ! -e "$fixture/state/inbox/request.json" ] && [ ! -L "$fixture/state/inbox/request.json" ] \ || fail 'symlink request case left the unsafe request in the inbox' local actual_state actual_state=$(jq -r '.state // "MISSING"' "$fixture/state/status.json") [ "$actual_state" = FAILED ] \ || fail "symlink request case persisted state $actual_state instead of FAILED" } assert_install_without_verified_cache_rejected() { local fixture="$WORK/install-without-cache" local version='1.0.0-preview.2' mkdir -p "$fixture" write_mock_commands "$fixture/mock-bin" build_release "$fixture" "$version" prepare_installation "$fixture" "$version" write_request "$fixture" "$version" INSTALL if run_update "$fixture" success > "$fixture/update.log" 2>&1; then fail 'install without cache unexpectedly succeeded' fi grep -q '缺少已校验的发布缓存' "$fixture/update.log" \ || fail 'install without cache did not report the missing verified cache' [ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \ || fail 'install without cache changed the active application' } printf '[update-fixture] success\n' assert_success_case printf '[update-fixture] identical-systemd-operations\n' assert_identical_systemd_operations_case printf '[update-fixture] path-watcher-failure\n' assert_update_path_failure_keeps_application_case printf '[update-fixture] rollback\n' assert_rollback_case printf '[update-fixture] incomplete-rollback-recovery-lock\n' assert_incomplete_rollback_requires_manual_recovery_case printf '[update-fixture] download-failure\n' assert_download_failure_case printf '[update-fixture] database-backup-failure\n' assert_database_failure_case printf '[update-fixture] database-backup-opt-in\n' assert_database_backup_opt_in_case printf '[update-fixture] unhealthy-baseline\n' assert_unhealthy_baseline_blocks_restart_case printf '[update-fixture] invalid-database-url\n' assert_invalid_database_url_blocks_restart_case printf '[update-fixture] symlink-request\n' assert_symlink_request_rejected printf '[update-fixture] missing-verified-cache\n' assert_install_without_verified_cache_rejected printf '[update-fixture] private-gitea\n' assert_private_gitea_release_case printf '[update-fixture] cross-origin-gitea\n' assert_cross_origin_gitea_browser_url_ignored printf 'Online update download, confirmation, success, rollback, failure, and unsafe-request fixtures passed\n'