466 lines
14 KiB
Bash
Executable File
466 lines
14 KiB
Bash
Executable File
#!/bin/bash
|
|
set -euo pipefail
|
|
|
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
ENTRYPOINT="${ROOT_DIR}/entrypoint.sh"
|
|
TEST_TMPDIR="$(mktemp -d)"
|
|
TEST_BIN_DIR="${TEST_TMPDIR}/bin"
|
|
CURL_MOCK_ROUTES="${TEST_TMPDIR}/curl.routes"
|
|
|
|
trap 'rm -rf -- "${TEST_TMPDIR}"' EXIT
|
|
|
|
mkdir -p "${TEST_BIN_DIR}"
|
|
: > "${CURL_MOCK_ROUTES}"
|
|
|
|
export PATH="${TEST_BIN_DIR}:${PATH}"
|
|
export CURL_MOCK_ROUTES
|
|
|
|
cat > "${TEST_BIN_DIR}/jq" <<'EOF'
|
|
#!/usr/bin/env python3
|
|
import json
|
|
import sys
|
|
import urllib.parse
|
|
|
|
|
|
def load_json_text(text):
|
|
text = text.strip()
|
|
if not text:
|
|
return None
|
|
return json.loads(text)
|
|
|
|
|
|
def read_json_file(path):
|
|
if path == "-":
|
|
return load_json_text(sys.stdin.read())
|
|
|
|
with open(path, "r", encoding="utf-8") as fh:
|
|
return load_json_text(fh.read())
|
|
|
|
|
|
args = sys.argv[1:]
|
|
expr = None
|
|
files = []
|
|
variables = {}
|
|
raw = False
|
|
compact = False
|
|
slurp = False
|
|
null_input = False
|
|
i = 0
|
|
|
|
while i < len(args):
|
|
arg = args[i]
|
|
if arg.startswith("-") and len(arg) > 1 and set(arg[1:]).issubset({"r", "n", "c", "s"}):
|
|
raw = raw or ("r" in arg[1:])
|
|
null_input = null_input or ("n" in arg[1:])
|
|
slurp = slurp or ("s" in arg[1:])
|
|
compact = compact or ("c" in arg[1:])
|
|
i += 1
|
|
continue
|
|
if arg == "--arg":
|
|
variables[args[i + 1]] = args[i + 2]
|
|
i += 3
|
|
continue
|
|
if arg == "--argjson":
|
|
variables[args[i + 1]] = json.loads(args[i + 2])
|
|
i += 3
|
|
continue
|
|
if arg.startswith("-"):
|
|
i += 1
|
|
continue
|
|
|
|
expr = arg
|
|
files = args[i + 1 :]
|
|
break
|
|
|
|
if expr is None:
|
|
raise SystemExit("jq mock requires an expression")
|
|
|
|
if slurp:
|
|
if files:
|
|
docs = [read_json_file(path) for path in files]
|
|
elif null_input:
|
|
docs = []
|
|
else:
|
|
docs = [load_json_text(sys.stdin.read())]
|
|
doc = docs
|
|
elif files:
|
|
doc = read_json_file(files[0])
|
|
else:
|
|
doc = None if null_input else load_json_text(sys.stdin.read())
|
|
|
|
|
|
def emit(text):
|
|
sys.stdout.write(text)
|
|
|
|
|
|
def emit_json(value):
|
|
sys.stdout.write(json.dumps(value, separators=(",", ":")))
|
|
|
|
|
|
if expr == "$value|@uri":
|
|
emit(urllib.parse.quote(str(variables["value"]), safe="-._~"))
|
|
elif expr == "length":
|
|
emit(str(len(doc)))
|
|
elif expr == "sort_by(.created_at, .version)[] | [.version, .created_at] | @tsv":
|
|
for item in sorted(doc, key=lambda entry: (entry.get("created_at", ""), entry.get("version", ""))):
|
|
emit(f"{item.get('version', '')}\t{item.get('created_at', '')}\n")
|
|
elif expr == "sort_by(.created_at, .version) | .[0:(length - $keep)]":
|
|
keep = int(variables["keep"])
|
|
items = sorted(doc, key=lambda entry: (entry.get("created_at", ""), entry.get("version", "")))
|
|
emit_json(items[: max(len(items) - keep, 0)])
|
|
elif expr == ".[] | [.name, .version, .created_at] | @tsv":
|
|
for item in doc:
|
|
emit(f"{item.get('name', '')}\t{item.get('version', '')}\t{item.get('created_at', '')}\n")
|
|
elif expr == ".[0] + .[1]":
|
|
if len(doc) < 2:
|
|
raise SystemExit("jq mock expected two inputs for slurp merge")
|
|
emit_json(doc[0] + doc[1])
|
|
else:
|
|
raise SystemExit(f"jq mock does not support expression: {expr}")
|
|
EOF
|
|
|
|
cat > "${TEST_BIN_DIR}/curl" <<'EOF'
|
|
#!/bin/bash
|
|
set -euo pipefail
|
|
|
|
method="GET"
|
|
headers_file=""
|
|
body_file=""
|
|
url=""
|
|
|
|
while (($#)); do
|
|
case "$1" in
|
|
-s|-S|-sS)
|
|
shift
|
|
;;
|
|
-H)
|
|
shift 2
|
|
;;
|
|
-X)
|
|
method="$2"
|
|
shift 2
|
|
;;
|
|
-D)
|
|
headers_file="$2"
|
|
shift 2
|
|
;;
|
|
-o)
|
|
body_file="$2"
|
|
shift 2
|
|
;;
|
|
-w)
|
|
shift 2
|
|
;;
|
|
--)
|
|
shift
|
|
break
|
|
;;
|
|
-*)
|
|
shift
|
|
;;
|
|
*)
|
|
url="$1"
|
|
shift
|
|
;;
|
|
esac
|
|
done
|
|
|
|
if [[ -z "${headers_file}" || -z "${body_file}" || -z "${url}" ]]; then
|
|
echo "curl mock missing required arguments" >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [[ -n "${CURL_FAIL_MATCH:-}" && "${method}|${url}" == "${CURL_FAIL_MATCH}" ]]; then
|
|
echo "curl mock forced failure for ${method} ${url}" >&2
|
|
exit 7
|
|
fi
|
|
|
|
route_line="$(
|
|
awk -F $'\t' -v method="${method}" -v url="${url}" '$1 == method && $2 == url { print; exit }' "${CURL_MOCK_ROUTES}"
|
|
)"
|
|
|
|
if [[ -z "${route_line}" ]]; then
|
|
echo "curl mock missing route for ${method} ${url}" >&2
|
|
exit 1
|
|
fi
|
|
|
|
IFS=$'\t' read -r _ _ code status request_id body <<< "${route_line}"
|
|
|
|
printf 'HTTP/1.1 %s %s\r\n' "${code}" "${status}" > "${headers_file}"
|
|
if [[ -n "${request_id}" ]]; then
|
|
printf 'x-gitea-request-id: %s\r\n' "${request_id}" >> "${headers_file}"
|
|
fi
|
|
printf '\r\n' >> "${headers_file}"
|
|
printf '%s' "${body}" > "${body_file}"
|
|
printf '%s' "${code}"
|
|
EOF
|
|
|
|
chmod +x "${TEST_BIN_DIR}/jq" "${TEST_BIN_DIR}/curl"
|
|
|
|
source "${ENTRYPOINT}"
|
|
|
|
CAPTURE_STDOUT=""
|
|
CAPTURE_STDERR=""
|
|
CAPTURE_STATUS=0
|
|
|
|
assert_eq() {
|
|
local expected="$1"
|
|
local actual="$2"
|
|
local message="${3:-values differ}"
|
|
|
|
if [[ "${expected}" != "${actual}" ]]; then
|
|
printf 'ASSERTION FAILED: %s\nexpected: %q\nactual: %q\n' "${message}" "${expected}" "${actual}" >&2
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
assert_contains() {
|
|
local haystack="$1"
|
|
local needle="$2"
|
|
local message="${3:-substring missing}"
|
|
|
|
if [[ "${haystack}" != *"${needle}"* ]]; then
|
|
printf 'ASSERTION FAILED: %s\nmissing: %s\ntext: %q\n' "${message}" "${needle}" "${haystack}" >&2
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
capture_call() {
|
|
local stdout_file stderr_file
|
|
stdout_file="$(mktemp)"
|
|
stderr_file="$(mktemp)"
|
|
|
|
set +e
|
|
( "$@" ) > "${stdout_file}" 2> "${stderr_file}"
|
|
CAPTURE_STATUS=$?
|
|
set -e
|
|
|
|
CAPTURE_STDOUT="$(<"${stdout_file}")"
|
|
CAPTURE_STDERR="$(<"${stderr_file}")"
|
|
rm -f -- "${stdout_file}" "${stderr_file}"
|
|
}
|
|
|
|
reset_env() {
|
|
unset INPUT_KEEP_COUNT INPUT_PACKAGE_NAMES GITEA_SERVER_URL GITEA_REPOSITORY RUNNER_TOKEN PAGE_LIMIT CURL_FAIL_MATCH
|
|
: > "${CURL_MOCK_ROUTES}"
|
|
}
|
|
|
|
add_route() {
|
|
printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$4" "$5" "$6" >> "${CURL_MOCK_ROUTES}"
|
|
}
|
|
|
|
run_test() {
|
|
local name="$1"
|
|
shift
|
|
|
|
if "$@"; then
|
|
printf 'ok - %s\n' "${name}"
|
|
else
|
|
printf 'not ok - %s\n' "${name}" >&2
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
test_trim() {
|
|
capture_call trim ""
|
|
assert_eq "" "${CAPTURE_STDOUT}" "trim empty string"
|
|
|
|
capture_call trim " "
|
|
assert_eq "" "${CAPTURE_STDOUT}" "trim whitespace only"
|
|
|
|
capture_call trim " abc "
|
|
assert_eq "abc" "${CAPTURE_STDOUT}" "trim trims edges"
|
|
|
|
capture_call trim "a b"
|
|
assert_eq "a b" "${CAPTURE_STDOUT}" "trim keeps inner whitespace"
|
|
|
|
capture_call trim "abc"
|
|
assert_eq "abc" "${CAPTURE_STDOUT}" "trim keeps plain values"
|
|
}
|
|
|
|
test_url_encode() {
|
|
capture_call url_encode "a b/c?x=1&y=two"
|
|
assert_eq "a%20b%2Fc%3Fx%3D1%26y%3Dtwo" "${CAPTURE_STDOUT}" "url_encode encodes reserved characters"
|
|
|
|
capture_call url_encode "already%20encoded"
|
|
assert_eq "already%2520encoded" "${CAPTURE_STDOUT}" "url_encode encodes percent signs"
|
|
}
|
|
|
|
test_resolve_keep_count() {
|
|
reset_env
|
|
capture_call resolve_keep_count
|
|
assert_eq "2" "${CAPTURE_STDOUT}" "resolve_keep_count default"
|
|
|
|
INPUT_KEEP_COUNT=" 5 "
|
|
capture_call resolve_keep_count
|
|
assert_eq "5" "${CAPTURE_STDOUT}" "resolve_keep_count trims input"
|
|
|
|
INPUT_KEEP_COUNT="0"
|
|
capture_call resolve_keep_count
|
|
assert_eq "0" "${CAPTURE_STDOUT}" "resolve_keep_count accepts zero"
|
|
}
|
|
|
|
test_resolve_keep_count_invalid() {
|
|
local value
|
|
for value in abc -1 1.5; do
|
|
reset_env
|
|
INPUT_KEEP_COUNT="${value}"
|
|
capture_call resolve_keep_count
|
|
assert_eq "1" "${CAPTURE_STATUS}" "resolve_keep_count fails for ${value}"
|
|
assert_contains "${CAPTURE_STDERR}" "ERROR: Invalid keep_count: ${value}" "resolve_keep_count error message"
|
|
done
|
|
}
|
|
|
|
test_resolve_package_names() {
|
|
reset_env
|
|
INPUT_PACKAGE_NAMES=$' pkg-a , pkg-b\npkg-a,,pkg-c '
|
|
capture_call resolve_package_names
|
|
assert_eq $'pkg-a\npkg-b\npkg-c' "${CAPTURE_STDOUT}" "resolve_package_names trims, dedupes, and keeps order"
|
|
}
|
|
|
|
test_resolve_package_names_missing() {
|
|
reset_env
|
|
capture_call resolve_package_names
|
|
assert_eq "1" "${CAPTURE_STATUS}" "resolve_package_names fails when empty"
|
|
assert_contains "${CAPTURE_STDERR}" "ERROR: Missing PACKAGE_NAMES" "resolve_package_names missing message"
|
|
}
|
|
|
|
test_parse_repo_context() {
|
|
capture_call parse_repo_context " org/project-name "
|
|
assert_eq $'org\tproject-name' "${CAPTURE_STDOUT}" "parse_repo_context trims inputs"
|
|
|
|
capture_call parse_repo_context "owner/repo/sub"
|
|
assert_eq "1" "${CAPTURE_STATUS}" "parse_repo_context rejects nested paths"
|
|
assert_contains "${CAPTURE_STDERR}" "ERROR: Invalid GITEA_REPOSITORY: owner/repo/sub" "parse_repo_context error message"
|
|
}
|
|
|
|
test_api_request() {
|
|
reset_env
|
|
GITEA_SERVER_URL="https://gitea.example"
|
|
add_route GET "https://gitea.example/api/v1/test" 200 OK req-123 '{"ok":true}'
|
|
|
|
local body_file headers_file
|
|
body_file="$(mktemp)"
|
|
headers_file="$(mktemp)"
|
|
capture_call api_request token GET "/api/v1/test" "${body_file}" "${headers_file}"
|
|
|
|
assert_eq $'200\t200 OK\treq-123' "${CAPTURE_STDOUT}" "api_request metadata"
|
|
assert_eq '{"ok":true}' "$(cat "${body_file}")" "api_request writes body"
|
|
assert_contains "$(cat "${headers_file}")" "x-gitea-request-id: req-123" "api_request writes headers"
|
|
|
|
export CURL_FAIL_MATCH="GET|https://gitea.example/api/v1/test"
|
|
capture_call api_request token GET "/api/v1/test" "${body_file}" "${headers_file}"
|
|
assert_eq "1" "${CAPTURE_STATUS}" "api_request propagates curl failure"
|
|
assert_contains "${CAPTURE_STDERR}" "ERROR: Request failed: GET /api/v1/test" "api_request failure message"
|
|
unset CURL_FAIL_MATCH
|
|
}
|
|
|
|
test_fetch_package_versions_404() {
|
|
reset_env
|
|
GITEA_SERVER_URL="https://gitea.example"
|
|
add_route GET "https://gitea.example/api/v1/packages/acme/nuget/missing?page=1&limit=100" 404 "Not Found" req-404 '[]'
|
|
|
|
capture_call fetch_package_versions acme missing token
|
|
assert_eq "[]" "${CAPTURE_STDOUT}" "fetch_package_versions returns empty array for 404"
|
|
}
|
|
|
|
test_fetch_package_versions_paginated() {
|
|
reset_env
|
|
GITEA_SERVER_URL="https://gitea.example"
|
|
PAGE_LIMIT=2
|
|
add_route GET "https://gitea.example/api/v1/packages/acme/nuget/pkg-a?page=1&limit=2" 200 OK req-1 '[{"name":"pkg-a","version":"1.0.0","created_at":"2024-01-01T00:00:00Z"},{"name":"pkg-a","version":"1.1.0","created_at":"2024-02-01T00:00:00Z"}]'
|
|
add_route GET "https://gitea.example/api/v1/packages/acme/nuget/pkg-a?page=2&limit=2" 200 OK req-2 '[{"name":"pkg-a","version":"1.2.0","created_at":"2024-03-01T00:00:00Z"}]'
|
|
|
|
capture_call fetch_package_versions acme pkg-a token
|
|
assert_eq '[{"name":"pkg-a","version":"1.0.0","created_at":"2024-01-01T00:00:00Z"},{"name":"pkg-a","version":"1.1.0","created_at":"2024-02-01T00:00:00Z"},{"name":"pkg-a","version":"1.2.0","created_at":"2024-03-01T00:00:00Z"}]' "${CAPTURE_STDOUT}" "fetch_package_versions paginates and merges"
|
|
}
|
|
|
|
test_collect_package_candidates() {
|
|
reset_env
|
|
GITEA_SERVER_URL="https://gitea.example"
|
|
local candidate_file
|
|
candidate_file="$(mktemp)"
|
|
|
|
add_route GET "https://gitea.example/api/v1/packages/acme/nuget/pkg-a?page=1&limit=100" 200 OK req-1 '[{"name":"pkg-a","version":"1.0.0","created_at":"2024-01-01T00:00:00Z"},{"name":"pkg-a","version":"2.0.0","created_at":"2024-02-01T00:00:00Z"},{"name":"pkg-a","version":"3.0.0","created_at":"2024-03-01T00:00:00Z"}]'
|
|
add_route GET "https://gitea.example/api/v1/packages/acme/nuget/pkg-b?page=1&limit=100" 200 OK req-2 '[{"name":"pkg-b","version":"9.9.9","created_at":"2024-04-01T00:00:00Z"}]'
|
|
add_route GET "https://gitea.example/api/v1/packages/acme/nuget/pkg-missing?page=1&limit=100" 404 "Not Found" req-3 '[]'
|
|
|
|
capture_call collect_package_candidates acme 2 "${candidate_file}" token pkg-a pkg-b pkg-missing
|
|
assert_eq $'2\t4\t3\t1' "${CAPTURE_STDOUT}" "collect_package_candidates summary"
|
|
assert_eq $'pkg-a\t1.0.0\t2024-01-01T00:00:00Z' "$(cat "${candidate_file}")" "collect_package_candidates chooses oldest version"
|
|
}
|
|
|
|
test_process_candidates_empty() {
|
|
reset_env
|
|
local candidate_file
|
|
candidate_file="$(mktemp)"
|
|
|
|
capture_call process_candidates acme "${candidate_file}" 0 0 0 0 token
|
|
assert_contains "${CAPTURE_STDERR}" "Summary: packages=0 versions=0 kept=0 candidates=0 deleted=0 errors=0" "process_candidates empty summary"
|
|
}
|
|
|
|
test_process_candidates() {
|
|
reset_env
|
|
GITEA_SERVER_URL="https://gitea.example"
|
|
local candidate_file
|
|
candidate_file="$(mktemp)"
|
|
printf 'pkg-a\t1.0.0\t2024-01-01T00:00:00Z\npkg-b\t2.0.0\t2024-02-01T00:00:00Z\n' > "${candidate_file}"
|
|
|
|
add_route DELETE "https://gitea.example/api/v1/packages/acme/nuget/pkg-a/1.0.0" 204 "No Content" del-1 ''
|
|
add_route DELETE "https://gitea.example/api/v1/packages/acme/nuget/pkg-b/2.0.0" 500 "Internal Server Error" del-2 '{"error":"boom"}'
|
|
|
|
capture_call process_candidates acme "${candidate_file}" 2 4 3 2 token
|
|
assert_contains "${CAPTURE_STDERR}" "ERROR: DELETE package pkg-b version 2.0.0 -> 500 Internal Server Error request_id=del-2" "process_candidates failure path"
|
|
assert_contains "${CAPTURE_STDERR}" "Summary: packages=2 versions=4 kept=3 candidates=2 deleted=1 errors=1" "process_candidates summary"
|
|
}
|
|
|
|
test_main_integration() {
|
|
reset_env
|
|
GITEA_SERVER_URL="https://gitea.example"
|
|
GITEA_REPOSITORY="acme/repo"
|
|
RUNNER_TOKEN="token"
|
|
INPUT_KEEP_COUNT="2"
|
|
INPUT_PACKAGE_NAMES="pkg-a,pkg-b"
|
|
|
|
add_route GET "https://gitea.example/api/v1/packages/acme/nuget/pkg-a?page=1&limit=100" 200 OK req-a '[{"name":"pkg-a","version":"1.0.0","created_at":"2024-01-01T00:00:00Z"},{"name":"pkg-a","version":"2.0.0","created_at":"2024-02-01T00:00:00Z"},{"name":"pkg-a","version":"3.0.0","created_at":"2024-03-01T00:00:00Z"}]'
|
|
add_route GET "https://gitea.example/api/v1/packages/acme/nuget/pkg-b?page=1&limit=100" 200 OK req-b '[{"name":"pkg-b","version":"9.9.9","created_at":"2024-04-01T00:00:00Z"}]'
|
|
add_route DELETE "https://gitea.example/api/v1/packages/acme/nuget/pkg-a/1.0.0" 204 "No Content" del-a ''
|
|
|
|
capture_call main
|
|
assert_contains "${CAPTURE_STDERR}" "Summary: packages=2 versions=4 kept=3 candidates=1 deleted=1 errors=0" "main summary"
|
|
}
|
|
|
|
tests=(
|
|
test_trim
|
|
test_url_encode
|
|
test_resolve_keep_count
|
|
test_resolve_keep_count_invalid
|
|
test_resolve_package_names
|
|
test_resolve_package_names_missing
|
|
test_parse_repo_context
|
|
test_api_request
|
|
test_fetch_package_versions_404
|
|
test_fetch_package_versions_paginated
|
|
test_collect_package_candidates
|
|
test_process_candidates_empty
|
|
test_process_candidates
|
|
test_main_integration
|
|
)
|
|
|
|
failures=0
|
|
|
|
for test_name in "${tests[@]}"; do
|
|
if run_test "${test_name}" "${test_name}"; then
|
|
:
|
|
else
|
|
failures=$((failures + 1))
|
|
fi
|
|
done
|
|
|
|
if (( failures > 0 )); then
|
|
printf '%s test(s) failed\n' "${failures}" >&2
|
|
exit 1
|
|
fi
|
|
|
|
printf 'all tests passed\n'
|