#!/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 } 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 write_mock_commands() { local mock_bin=$1 mkdir -p "$mock_bin" cat > "$mock_bin/curl" <<'SH' #!/bin/sh output= url= header_file= printf '%s\n' "$*" >> "${MOCK_CURL_LOG:-/dev/null}" while [ "$#" -gt 0 ]; do case "$1" in -o) shift; output=$1 ;; --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_PUBLIC_INDEX_URL:-http://127.0.0.1/}" ]; 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 SH cat > "$mock_bin/systemctl" <<'SH' #!/bin/sh printf '%s\n' "$*" >> "$MOCK_SYSTEMCTL_LOG" exit 0 SH cat > "$mock_bin/nginx" <<'SH' #!/bin/sh printf '%s\n' "$*" >> "$MOCK_NGINX_LOG" exit 0 SH cat > "$mock_bin/flock" <<'SH' #!/bin/sh exit 0 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/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 stage="$fixture/stage" mkdir -p "$fixture/release" "$stage/public" "$stage/ops" 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" chmod 0755 "$stage/ops/update.sh" for name in kaidi-finance.service kaidi-update.service kaidi-update.path kaidi-finance.conf; do printf 'new %s\n' "$name" > "$stage/ops/$name" done local artifact="kaidi-finance-$version.tar.gz" COPYFILE_DISABLE=1 tar -czf "$fixture/release/$artifact" -C "$stage" . local sha sha=$($REAL_OPENSSL dgst -sha256 "$fixture/release/$artifact" | awk '{print $NF}') jq -n --arg version "$version" --arg artifact "$artifact" --arg sha "$sha" \ '{version:$version,artifact:$artifact,sha256:$sha,publishedAt:"2026-08-16T00:00:00Z",releaseNotes:"fixture"}' \ > "$fixture/release/release-manifest.json" "$REAL_OPENSSL" genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \ -out "$fixture/private.pem" >/dev/null 2>&1 "$REAL_OPENSSL" pkey -in "$fixture/private.pem" -pubout \ -out "$fixture/release-public.pem" >/dev/null 2>&1 "$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 artifact=$(jq -er '.artifact' "$fixture/release/release-manifest.json") jq -n --arg origin "$origin" --arg artifact "$artifact" \ '{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/state/inbox" "$fixture/systemd" "$fixture/nginx" "$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" for name in kaidi-finance.service kaidi-update.service kaidi-update.path; do printf 'old %s\n' "$name" > "$fixture/systemd/$name" done printf 'old kaidi-finance.conf\n' > "$fixture/nginx/kaidi-finance.conf" 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 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' write_request "$fixture" "$version" INSTALL } run_update() { local fixture=$1 local health=$2 local skip_backup=${3:-true} 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_NGINX_LOG="$fixture/nginx.log" \ KAIDI_APP_ROOT="$fixture/app" \ KAIDI_UPDATE_STATE_ROOT="$fixture/state" \ KAIDI_LOG_ROOT="$fixture/log" \ KAIDI_SYSTEMD_ROOT="$fixture/systemd" \ KAIDI_NGINX_CONFIG="$fixture/nginx/kaidi-finance.conf" \ KAIDI_UPDATER_PATH="$fixture/app/bin/update.sh" \ KAIDI_SKIP_DB_BACKUP="$skip_backup" \ 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:18080/actuator/health \ KAIDI_PUBLIC_HEALTH_URL=http://127.0.0.1/actuator/health \ KAIDI_PUBLIC_INDEX_URL=http://127.0.0.1/ \ "$ROOT/deploy/update.sh" } 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_asset_rejected() { 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" if 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 > "$fixture/update.log" 2>&1; then fail 'cross-origin Gitea asset unexpectedly succeeded' fi grep -q 'Release manifest asset is missing' "$fixture/update.log" \ || fail 'cross-origin Gitea asset rejection was not reported' ! grep -Fq 'assets.fixture.invalid' "$fixture/curl.log" \ || fail 'cross-origin Gitea asset 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 'reload nginx' "$fixture/systemctl.log" || fail 'Nginx was not reloaded' } 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 'previous release was restored and verified' "$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' [ "$(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_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_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 'Update request must be a regular file' "$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 'Verified release cache is missing' "$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' } assert_success_case assert_rollback_case assert_download_failure_case assert_database_failure_case assert_symlink_request_rejected assert_install_without_verified_cache_rejected assert_private_gitea_release_case assert_cross_origin_gitea_asset_rejected printf 'Online update download, confirmation, success, rollback, failure, and unsafe-request fixtures passed\n'