Add the GitHub and Gitea steps and split the script into library files
Dry run by default: the script reads from both hosts (tokens, owners, names, license, SSH) and prints a plan; --apply creates the repositories and the Gitea -> GitHub push mirror after a final yes. Choosing GitHub applies the AGPL-3.0 license to the Gitea repository. A failed step is reported with what exists and how to continue; nothing is ever deleted. create-project.sh is now the entry point; the work lives in src/lib/, one responsibility per file. .gitignore gets !src/lib (the Python template ignores any lib/ folder). Tests grow to 599 checks, with a stub curl and ssh, and guards for the file structure. Task: MIL-002#1 Task: MIL-002#2 Task: MIL-002#3 Task: MIL-002#4 Task: MIL-002#5 Task: MIL-002#6 Refs #9 Refs #10 Refs #11 Refs #12 Refs #13 Refs #14 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
+134
-10
@@ -11,12 +11,18 @@
|
||||
# shellcheck disable=SC2016,SC2034 # stub and snippet text is literal on purpose; OUT, ERR and STATUS are read by the test files
|
||||
REPO_ROOT="$(cd "${BASH_SOURCE[0]%/*}/.." && pwd)"
|
||||
readonly REPO_ROOT
|
||||
readonly SCRIPT="$REPO_ROOT/src/create-project.sh"
|
||||
readonly SRC_DIR="$REPO_ROOT/src"
|
||||
readonly SCRIPT="$SRC_DIR/create-project.sh"
|
||||
|
||||
# Distinctive fake credentials; the tests search all output for them.
|
||||
readonly FAKE_GITEA_TOKEN="giteaFAKEtoken1234567890"
|
||||
readonly FAKE_GITHUB_PAT="ghpFAKEtoken1234567890"
|
||||
|
||||
# Answers to the prompts: name, description, visibility, Gitea owner, GitHub
|
||||
# yes or no, GitHub owner, directory, plan gate.
|
||||
readonly ANSWERS_GITHUB=$'my-app\nA test app\n\nTirSystem\ny\nacme-org\n\nn\n'
|
||||
readonly ANSWERS_GITEA_ONLY=$'my-app\n\n\nTirSystem\nn\n\nn\n'
|
||||
|
||||
TESTS_RUN=0
|
||||
TESTS_FAILED=0
|
||||
CURRENT_TEST=""
|
||||
@@ -59,6 +65,14 @@ assert_not_contains() {
|
||||
fi
|
||||
}
|
||||
|
||||
# assert_before NAME A B: the line numbers A and B are set and A comes first.
|
||||
assert_before() {
|
||||
check
|
||||
if [[ -z $2 || -z $3 ]] || ((10#$2 >= 10#$3)); then
|
||||
fail "$1: expected the step at line '$2' before the step at line '$3'"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_file_exists() {
|
||||
check
|
||||
if [[ ! -e $2 ]]; then
|
||||
@@ -111,25 +125,133 @@ write_stub() {
|
||||
chmod +x "$WORK/bin/$1"
|
||||
}
|
||||
|
||||
# write_curl_stub: a curl that records its arguments and configuration and
|
||||
# answers with STUB_CURL_STATUS (default 200) and body STUB_CURL_BODY.
|
||||
# write_curl_stub: a curl that records every call and answers from the
|
||||
# routes file in the work directory (see write_routes). Without a routes file
|
||||
# it answers STUB_CURL_STATUS (default 200) with the body STUB_CURL_BODY.
|
||||
# Records: curl.args (all arguments), curl.config (the private config file),
|
||||
# curl.calls ("METHOD URL" per call) and curl.bodies (each request body).
|
||||
write_curl_stub() {
|
||||
write_stub curl '
|
||||
printf "%s\n" "$@" >>"$STUB_DIR/curl.args"
|
||||
out="" cfg=""
|
||||
cat >"$WORK/bin/curl" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "$@" >>"$STUB_DIR/curl.args"
|
||||
out="" cfg="" data=""
|
||||
while (($# > 0)); do
|
||||
case "$1" in
|
||||
--output) out="$2"; shift 2 ;;
|
||||
--config) cfg="$2"; shift 2 ;;
|
||||
--data-binary) data="${2#@}"; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
if [[ -n $cfg ]]; then cat "$cfg" >>"$STUB_DIR/curl.config"; fi
|
||||
url="" method=""
|
||||
if [[ -n $cfg ]]; then
|
||||
cat "$cfg" >>"$STUB_DIR/curl.config"
|
||||
url="$(sed -n 's/^url = "\(.*\)"$/\1/p' "$cfg")"
|
||||
method="$(sed -n 's/^request = "\(.*\)"$/\1/p' "$cfg")"
|
||||
fi
|
||||
printf '%s %s\n' "$method" "$url" >>"$STUB_DIR/curl.calls"
|
||||
if [[ -n $data && -f $data ]]; then
|
||||
printf '%s %s\n%s\n' "$method" "$url" "$(cat "$data")" >>"$STUB_DIR/curl.bodies"
|
||||
fi
|
||||
status="${STUB_CURL_STATUS:-200}"
|
||||
body="${STUB_CURL_BODY:-}"
|
||||
if [[ -z $body ]]; then body="{\"ok\":true}"; fi
|
||||
if [[ -n $out ]]; then printf "%s" "$body" >"$out"; fi
|
||||
printf "%s" "${STUB_CURL_STATUS:-200}"
|
||||
exit "${STUB_CURL_EXIT:-0}"'
|
||||
if [[ -f $STUB_DIR/routes ]]; then
|
||||
status=404
|
||||
body="{\"message\":\"Not Found\"}"
|
||||
n=0 first_unused="" last_match=""
|
||||
while IFS='|' read -r m pattern st rest; do
|
||||
n=$((n + 1))
|
||||
if [[ $m == "$method" && $url == *"$pattern" ]]; then
|
||||
last_match=$n
|
||||
if [[ -z $first_unused ]] && ! grep -qx "$n" "$STUB_DIR/routes.used" 2>/dev/null; then
|
||||
first_unused=$n
|
||||
fi
|
||||
fi
|
||||
done <"$STUB_DIR/routes"
|
||||
pick="${first_unused:-$last_match}"
|
||||
if [[ -n $pick ]]; then
|
||||
if [[ -n $first_unused ]]; then printf '%s\n' "$pick" >>"$STUB_DIR/routes.used"; fi
|
||||
IFS='|' read -r _ _ status body <<<"$(sed -n "${pick}p" "$STUB_DIR/routes")"
|
||||
fi
|
||||
fi
|
||||
if [[ $status == exit* ]]; then exit "${status#exit}"; fi
|
||||
if [[ -n $out ]]; then printf '%s' "$body" >"$out"; fi
|
||||
printf '%s' "$status"
|
||||
exit "${STUB_CURL_EXIT:-0}"
|
||||
STUB
|
||||
chmod +x "$WORK/bin/curl"
|
||||
}
|
||||
|
||||
# write_ssh_stub [EXIT]: an ssh that records its arguments and, by default,
|
||||
# answers like a Gitea server that accepted the key.
|
||||
write_ssh_stub() {
|
||||
cat >"$WORK/bin/ssh" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "\$@" >>"\$STUB_DIR/ssh.args"
|
||||
if [[ ${1:-0} == 0 ]]; then
|
||||
echo "Hi there, gitea-user! You've successfully authenticated, but Gitea does not provide shell access."
|
||||
else
|
||||
echo "Permission denied (publickey)."
|
||||
fi
|
||||
exit ${1:-0}
|
||||
STUB
|
||||
chmod +x "$WORK/bin/ssh"
|
||||
}
|
||||
|
||||
# write_routes: read the routes for the stub curl from stdin. One route per
|
||||
# line: METHOD|URL-SUFFIX|STATUS|BODY. A request takes the first matching
|
||||
# route that was not used yet, so repeated calls can get different answers;
|
||||
# when all are used, the last match answers again. STATUS "exitN" makes curl
|
||||
# fail with exit code N.
|
||||
write_routes() {
|
||||
cat >"$WORK/routes"
|
||||
: >"$WORK/routes.used"
|
||||
}
|
||||
|
||||
# prepend_route LINE: answer a request before the routes already written.
|
||||
prepend_route() {
|
||||
local rest
|
||||
rest="$(cat "$WORK/routes")"
|
||||
printf '%s\n%s\n' "$1" "$rest" >"$WORK/routes"
|
||||
# The lines moved, so the record of used routes no longer applies.
|
||||
: >"$WORK/routes.used"
|
||||
}
|
||||
|
||||
# write_happy_routes: both hosts accept the tokens; Gitea owner TirSystem and
|
||||
# GitHub owner acme-org are organizations; nothing exists yet.
|
||||
write_happy_routes() {
|
||||
write_routes <<'ROUTES'
|
||||
GET|/api/v1/user|200|{"login":"gitea-user"}
|
||||
GET|/api/v1/orgs/TirSystem|200|{"username":"TirSystem"}
|
||||
GET|/api/v1/users/gitea-user/orgs/TirSystem/permissions|200|{"can_create_repository":true,"is_owner":true}
|
||||
GET|/api/v1/licenses|200|[{"key":"AGPL-3.0","name":"AGPL-3.0"}]
|
||||
GET|/api/v1/repos/TirSystem/my-app|404|{"message":"not found"}
|
||||
GET|api.github.com/user|200|{"login":"octo-user"}
|
||||
GET|api.github.com/user/memberships/orgs/acme-org|200|{"state":"active","role":"member"}
|
||||
GET|api.github.com/repos/acme-org/my-app|404|{"message":"Not Found"}
|
||||
GET|/api/v1/repos/TirSystem/my-app/push_mirrors|200|[]
|
||||
GET|/api/v1/repos/TirSystem/my-app/push_mirrors|200|[{"remote_address":"https://github.com/acme-org/my-app.git","sync_on_commit":true,"interval":"10m0s","last_error":""}]
|
||||
POST|api.github.com/orgs/acme-org/repos|201|{"html_url":"https://github.com/acme-org/my-app"}
|
||||
POST|/api/v1/orgs/TirSystem/repos|201|{"html_url":"https://git.example.test/TirSystem/my-app"}
|
||||
POST|/api/v1/repos/TirSystem/my-app/push_mirrors|200|{"remote_address":"https://github.com/acme-org/my-app.git"}
|
||||
POST|/api/v1/repos/TirSystem/my-app/push_mirrors-sync|200|{}
|
||||
ROUTES
|
||||
}
|
||||
|
||||
# setup_hosts: fixtures, stub curl and ssh, and the happy routes.
|
||||
setup_hosts() {
|
||||
write_fixtures
|
||||
write_curl_stub
|
||||
write_ssh_stub 0
|
||||
write_happy_routes
|
||||
}
|
||||
|
||||
# calls: the "METHOD URL" lines the stub curl received (empty if none).
|
||||
calls() {
|
||||
if [[ -f $WORK/curl.calls ]]; then
|
||||
cat "$WORK/curl.calls"
|
||||
fi
|
||||
}
|
||||
|
||||
# run_cli STDIN ARGS...: run create-project.sh with answers from STDIN (a
|
||||
@@ -139,6 +261,7 @@ run_cli() {
|
||||
shift
|
||||
STATUS=0
|
||||
PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" \
|
||||
REPOFOUNDRY_SYNC_WAIT=0 \
|
||||
"$BASH" "$SCRIPT" "$@" <<<"$input" >"$WORK/out.txt" 2>"$WORK/err.txt" ||
|
||||
STATUS=$?
|
||||
OUT="$(cat "$WORK/out.txt")"
|
||||
@@ -155,6 +278,7 @@ run_lib() {
|
||||
printf '%s\n' "$2"
|
||||
} >"$WORK/snippet.sh"
|
||||
PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" \
|
||||
REPOFOUNDRY_SYNC_WAIT=0 \
|
||||
"$BASH" "$WORK/snippet.sh" <<<"$input" >"$WORK/out.txt" 2>"$WORK/err.txt" ||
|
||||
STATUS=$?
|
||||
OUT="$(cat "$WORK/out.txt")"
|
||||
|
||||
+8
-3
@@ -23,15 +23,20 @@ failed_checks=0
|
||||
|
||||
run_static_checks() {
|
||||
printf '== static checks\n'
|
||||
bash -n "$SCRIPT" || failed_checks=$((failed_checks + 1))
|
||||
local file
|
||||
for file in "$SCRIPT" "$SRC_DIR"/lib/*.sh; do
|
||||
bash -n "$file" || failed_checks=$((failed_checks + 1))
|
||||
done
|
||||
if command -v shellcheck >/dev/null 2>&1; then
|
||||
shellcheck "$SCRIPT" "$TEST_DIR"/*.sh ||
|
||||
# -x follows the source lines; the library files are named as well,
|
||||
# because shellcheck only reports on the files it is given.
|
||||
shellcheck -x "$SCRIPT" "$SRC_DIR"/lib/*.sh "$TEST_DIR"/*.sh ||
|
||||
failed_checks=$((failed_checks + 1))
|
||||
else
|
||||
printf 'skipped: shellcheck is not installed\n'
|
||||
fi
|
||||
if command -v shfmt >/dev/null 2>&1; then
|
||||
shfmt -i 2 -ci -d "$SCRIPT" "$TEST_DIR"/*.sh ||
|
||||
shfmt -i 2 -ci -d "$SCRIPT" "$SRC_DIR"/lib/*.sh "$TEST_DIR"/*.sh ||
|
||||
failed_checks=$((failed_checks + 1))
|
||||
else
|
||||
printf 'skipped: shfmt is not installed\n'
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-hosts.sh - tests for the GitHub and Gitea steps (MIL-002): preflight
|
||||
# checks, the dry run, creating the repositories and the push mirror, reusing
|
||||
# existing repositories and reporting a partial failure. A stub curl answers
|
||||
# from a routes file and records every call; no real host is contacted.
|
||||
# Sourced by run-tests.sh.
|
||||
|
||||
# shellcheck disable=SC2016 # snippet and fixture text is literal on purpose
|
||||
|
||||
run_dry() {
|
||||
run_cli "$1" --config "$WORK/config.env" --env "$WORK/.env"
|
||||
}
|
||||
|
||||
run_apply() {
|
||||
run_cli "$1" --apply --config "$WORK/config.env" --env "$WORK/.env"
|
||||
}
|
||||
|
||||
# position LINE: the number of the first recorded call equal to LINE.
|
||||
position() {
|
||||
calls | grep -n -x -F "$1" | head -n 1 | cut -d: -f1
|
||||
}
|
||||
|
||||
readonly GITHUB_REPO_CALL="POST https://api.github.com/orgs/acme-org/repos"
|
||||
readonly GITEA_REPO_CALL="POST https://git.example.test/api/v1/orgs/TirSystem/repos"
|
||||
readonly MIRROR_CALL="POST https://git.example.test/api/v1/repos/TirSystem/my-app/push_mirrors"
|
||||
readonly SYNC_CALL="POST https://git.example.test/api/v1/repos/TirSystem/my-app/push_mirrors-sync"
|
||||
|
||||
# ------------------------------------------------------------------ dry run
|
||||
|
||||
test_dry_run_prints_the_plan_and_only_reads() {
|
||||
setup_hosts
|
||||
run_dry "$ANSWERS_GITHUB"
|
||||
assert_status "dry run" 0 "$STATUS"
|
||||
assert_contains "Gitea plan" "$OUT" "Gitea repository : create (private) with the AGPL-3.0 license https://git.example.test/TirSystem/my-app"
|
||||
assert_contains "GitHub plan" "$OUT" "GitHub repository : create (private), empty https://github.com/acme-org/my-app"
|
||||
assert_contains "mirror plan" "$OUT" "Push mirror : Gitea -> GitHub every 10m0s"
|
||||
assert_contains "origin plan" "$OUT" "Local origin : will use SSH (the SSH test passed)"
|
||||
assert_contains "says it is a dry run" "$OUT" "Dry run: nothing was created. Run again with --apply"
|
||||
assert_not_contains "no creation report" "$OUT" "This is what exists now"
|
||||
assert_not_contains "only reads" "$(calls)" "POST"
|
||||
assert_not_contains "never deletes" "$(calls)" "DELETE"
|
||||
assert_not_contains "no PUT or PATCH" "$(calls)" "PATCH"
|
||||
}
|
||||
|
||||
test_dry_run_does_not_ask_to_confirm() {
|
||||
setup_hosts
|
||||
run_dry "$ANSWERS_GITHUB"
|
||||
assert_not_contains "no final question" "$ERR" "Create these now"
|
||||
}
|
||||
|
||||
test_ssh_failure_means_https() {
|
||||
setup_hosts
|
||||
write_ssh_stub 255
|
||||
run_dry "$ANSWERS_GITHUB"
|
||||
assert_status "ssh failure is not fatal" 0 "$STATUS"
|
||||
assert_contains "origin falls back to HTTPS" "$OUT" "will use HTTPS (SSH test: failed"
|
||||
}
|
||||
|
||||
test_ssh_without_the_ssh_tool_is_not_fatal() {
|
||||
setup_hosts
|
||||
# A PATH that has the stubs and the basic tools but no ssh.
|
||||
rm -f "$WORK/bin/ssh"
|
||||
run_lib "" 'command() { if [[ $1 == -v && $2 == ssh ]]; then return 1; fi; builtin command "$@"; }
|
||||
CONFIG[GITEA_URL]=https://git.example.test CONFIG[GITEA_SSH_PORT]=10022
|
||||
test_gitea_ssh
|
||||
echo "${STATE[is_ssh_ok]}|${STATE[ssh_note]}"'
|
||||
assert_eq "no ssh installed" "0|not tested (ssh is not installed)" "$OUT"
|
||||
}
|
||||
|
||||
test_ssh_uses_the_configured_port() {
|
||||
setup_hosts
|
||||
printf 'GITEA_SSH_PORT=2222\n' >>"$WORK/config.env"
|
||||
run_dry "$ANSWERS_GITHUB"
|
||||
assert_status "custom port" 0 "$STATUS"
|
||||
assert_contains "port passed to ssh" "$(cat "$WORK/ssh.args")" "2222"
|
||||
assert_contains "Gitea host" "$(cat "$WORK/ssh.args")" "git@git.example.test"
|
||||
assert_contains "batch mode, no prompts" "$(cat "$WORK/ssh.args")" "BatchMode=yes"
|
||||
assert_contains "unknown host keys are refused" "$(cat "$WORK/ssh.args")" "StrictHostKeyChecking=yes"
|
||||
}
|
||||
|
||||
test_config_validates_ssh_port_and_interval() {
|
||||
local key value expected
|
||||
while IFS='|' read -r key value expected; do
|
||||
printf 'GITEA_URL=https://git.example.test\n%s=%s\n' "$key" "$value" >"$WORK/c.env"
|
||||
run_lib "" "parse_env_file \"$WORK/c.env\" CONFIG_KEYS CONFIG
|
||||
validate_config"
|
||||
assert_status "$key=$value" 1 "$STATUS"
|
||||
assert_contains "$key=$value message" "$ERR" "$expected"
|
||||
done <<'EOF'
|
||||
GITEA_SSH_PORT|abc|port number
|
||||
GITEA_SSH_PORT|0|port number
|
||||
GITEA_SSH_PORT|70000|port number
|
||||
MIRROR_INTERVAL|soon|10m0s or 8h0m0s
|
||||
MIRROR_INTERVAL|10|10m0s or 8h0m0s
|
||||
MIRROR_INTERVAL|10 m|10m0s or 8h0m0s
|
||||
EOF
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------- preflight
|
||||
|
||||
test_gitea_token_rejected() {
|
||||
setup_hosts
|
||||
prepend_route 'GET|/api/v1/user|401|{"message":"token is required"}'
|
||||
run_apply "$ANSWERS_GITHUB"
|
||||
assert_status "bad Gitea token" 1 "$STATUS"
|
||||
assert_contains "says what failed" "$ERR" "Gitea rejected the token: authentication failed"
|
||||
assert_contains "shows the server's words" "$ERR" "token is required"
|
||||
assert_not_contains "nothing created" "$(calls)" "POST"
|
||||
assert_not_contains "no creation report before anything was created" "$OUT" "This is what exists now"
|
||||
assert_not_contains "GitHub not contacted first" "$(calls)" "api.github.com"
|
||||
}
|
||||
|
||||
test_github_token_rejected() {
|
||||
setup_hosts
|
||||
prepend_route 'GET|api.github.com/user|401|{"message":"Bad credentials"}'
|
||||
run_apply "$ANSWERS_GITHUB"
|
||||
assert_status "bad GitHub token" 1 "$STATUS"
|
||||
assert_contains "says what failed" "$ERR" "GitHub rejected the token: authentication failed"
|
||||
assert_not_contains "nothing created" "$(calls)" "POST"
|
||||
}
|
||||
|
||||
test_gitea_owner_must_exist() {
|
||||
setup_hosts
|
||||
prepend_route 'GET|/api/v1/orgs/TirSystem|404|{"message":"not found"}'
|
||||
run_apply "$ANSWERS_GITHUB"
|
||||
assert_status "unknown Gitea owner" 1 "$STATUS"
|
||||
assert_contains "message" "$ERR" "neither your account (gitea-user) nor an organization"
|
||||
assert_not_contains "nothing created" "$(calls)" "POST"
|
||||
}
|
||||
|
||||
test_gitea_organization_needs_create_permission() {
|
||||
setup_hosts
|
||||
prepend_route 'GET|/api/v1/users/gitea-user/orgs/TirSystem/permissions|200|{"can_create_repository":false}'
|
||||
run_apply "$ANSWERS_GITHUB"
|
||||
assert_status "no permission" 1 "$STATUS"
|
||||
assert_contains "message" "$ERR" "you may not create repositories in the Gitea organization 'TirSystem'"
|
||||
}
|
||||
|
||||
test_github_owner_must_be_a_member() {
|
||||
setup_hosts
|
||||
prepend_route 'GET|api.github.com/user/memberships/orgs/acme-org|404|{"message":"Not Found"}'
|
||||
run_apply "$ANSWERS_GITHUB"
|
||||
assert_status "not a member" 1 "$STATUS"
|
||||
assert_contains "message" "$ERR" "neither your account (octo-user) nor an organization you belong to"
|
||||
prepend_route 'GET|api.github.com/user/memberships/orgs/acme-org|200|{"state":"pending"}'
|
||||
run_apply "$ANSWERS_GITHUB"
|
||||
assert_status "pending membership" 1 "$STATUS"
|
||||
assert_contains "pending message" "$ERR" "membership of the GitHub organization 'acme-org' is not active"
|
||||
}
|
||||
|
||||
test_license_must_be_offered_when_github_is_chosen() {
|
||||
setup_hosts
|
||||
prepend_route 'GET|/api/v1/licenses|200|[{"key":"MIT","name":"MIT"}]'
|
||||
run_apply "$ANSWERS_GITHUB"
|
||||
assert_status "no AGPL" 1 "$STATUS"
|
||||
assert_contains "message" "$ERR" "does not offer the AGPL-3.0 license"
|
||||
assert_not_contains "nothing created" "$(calls)" "POST"
|
||||
# Without GitHub no license is needed, so the same server is fine.
|
||||
setup_hosts
|
||||
prepend_route 'GET|/api/v1/licenses|200|[{"key":"MIT","name":"MIT"}]'
|
||||
printf 'GITEA_TOKEN=%s\n' "$FAKE_GITEA_TOKEN" >"$WORK/.env"
|
||||
run_dry "$ANSWERS_GITEA_ONLY"
|
||||
assert_status "Gitea only" 0 "$STATUS"
|
||||
}
|
||||
|
||||
test_existing_repository_with_content_stops_the_run() {
|
||||
setup_hosts
|
||||
prepend_route 'GET|/api/v1/repos/TirSystem/my-app|200|{"empty":false}'
|
||||
prepend_route 'GET|/api/v1/repos/TirSystem/my-app/contents|200|[{"name":"README.md","type":"file"}]'
|
||||
run_apply "$ANSWERS_GITHUB"
|
||||
assert_status "repository with content" 1 "$STATUS"
|
||||
assert_contains "message" "$ERR" "the Gitea repository TirSystem/my-app already exists and has content"
|
||||
assert_not_contains "nothing created" "$(calls)" "POST"
|
||||
}
|
||||
|
||||
test_network_failure_stops_before_creating() {
|
||||
setup_hosts
|
||||
prepend_route 'GET|/api/v1/user|exit7|'
|
||||
run_apply "$ANSWERS_GITHUB"
|
||||
assert_status "no connection" 1 "$STATUS"
|
||||
assert_contains "message" "$ERR" "could not reach git.example.test: could not connect"
|
||||
assert_not_contains "nothing created" "$(calls)" "POST"
|
||||
}
|
||||
|
||||
test_token_for_another_github_account_is_reported() {
|
||||
setup_hosts
|
||||
prepend_route 'GET|api.github.com/user|200|{"login":"someone-else"}'
|
||||
run_apply "$ANSWERS_GITHUB"$'y\n'
|
||||
assert_status "run continues" 0 "$STATUS"
|
||||
assert_contains "warns" "$ERR" "GITHUB_USER is 'octo-user' but the token belongs to 'someone-else'"
|
||||
assert_contains "mirror uses the account the token belongs to" "$(cat "$WORK/curl.bodies")" '"remote_username":"someone-else"'
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------ apply: success
|
||||
|
||||
test_apply_creates_repositories_and_mirror_in_order() {
|
||||
setup_hosts
|
||||
run_apply "$ANSWERS_GITHUB"$'y\n'
|
||||
assert_status "apply" 0 "$STATUS"
|
||||
assert_before "GitHub before Gitea" "$(position "$GITHUB_REPO_CALL")" "$(position "$GITEA_REPO_CALL")"
|
||||
assert_before "Gitea before the mirror" "$(position "$GITEA_REPO_CALL")" "$(position "$MIRROR_CALL")"
|
||||
assert_before "mirror before the sync" "$(position "$MIRROR_CALL")" "$(position "$SYNC_CALL")"
|
||||
assert_contains "final report" "$OUT" "Done. This is what exists now:"
|
||||
assert_contains "GitHub created" "$OUT" "GitHub repository : created https://github.com/acme-org/my-app"
|
||||
assert_contains "Gitea created" "$OUT" "Gitea repository : created https://git.example.test/TirSystem/my-app"
|
||||
assert_contains "mirror created" "$OUT" "Push mirror : created Gitea -> https://github.com/acme-org/my-app.git"
|
||||
assert_not_contains "never deletes" "$(calls)" "DELETE"
|
||||
assert_eq "temporary files removed" "" "$(find "$WORK/tmp" -mindepth 1)"
|
||||
}
|
||||
|
||||
test_apply_sends_the_right_request_bodies() {
|
||||
setup_hosts
|
||||
run_apply "$ANSWERS_GITHUB"$'y\n'
|
||||
local bodies
|
||||
bodies="$(cat "$WORK/curl.bodies")"
|
||||
assert_contains "GitHub name" "$bodies" '"name":"my-app"'
|
||||
assert_contains "GitHub is created empty" "$bodies" '"private":true,"auto_init":false}'
|
||||
assert_contains "Gitea gets the license" "$bodies" '"license":"AGPL-3.0"'
|
||||
assert_contains "Gitea is initialised with it" "$bodies" '"auto_init":true'
|
||||
assert_contains "default branch" "$bodies" '"default_branch":"main"'
|
||||
assert_contains "description" "$bodies" '"description":"A test app"'
|
||||
assert_contains "mirror target without credentials" "$bodies" '"remote_address":"https://github.com/acme-org/my-app.git"'
|
||||
assert_not_contains "no credentials in the address" "$bodies" 'https://octo-user:'
|
||||
assert_not_contains "no credentials in the address (at sign)" "$bodies" '@github.com'
|
||||
assert_contains "mirror account" "$bodies" '"remote_username":"octo-user"'
|
||||
assert_contains "the token is the mirror password" "$bodies" "\"remote_password\":\"$FAKE_GITHUB_PAT\""
|
||||
assert_contains "mirror interval" "$bodies" '"interval":"10m0s"'
|
||||
assert_contains "push on commit asked for" "$bodies" '"sync_on_commit":true'
|
||||
}
|
||||
|
||||
test_apply_never_prints_or_passes_a_token() {
|
||||
setup_hosts
|
||||
run_apply "$ANSWERS_GITHUB"$'y\n'
|
||||
assert_not_contains "no Gitea token in output" "$OUT$ERR" "$FAKE_GITEA_TOKEN"
|
||||
assert_not_contains "no GitHub token in output" "$OUT$ERR" "$FAKE_GITHUB_PAT"
|
||||
assert_not_contains "no token on a command line" "$(cat "$WORK/curl.args")" "$FAKE_GITEA_TOKEN"
|
||||
assert_not_contains "no GitHub token on a command line" "$(cat "$WORK/curl.args")" "$FAKE_GITHUB_PAT"
|
||||
assert_contains "Gitea token in the private config" "$(cat "$WORK/curl.config")" "Authorization: token $FAKE_GITEA_TOKEN"
|
||||
assert_contains "GitHub token in the private config" "$(cat "$WORK/curl.config")" "Authorization: Bearer $FAKE_GITHUB_PAT"
|
||||
}
|
||||
|
||||
test_apply_with_gitea_only() {
|
||||
setup_hosts
|
||||
printf 'GITEA_TOKEN=%s\n' "$FAKE_GITEA_TOKEN" >"$WORK/.env"
|
||||
run_apply "$ANSWERS_GITEA_ONLY"$'y\n'
|
||||
assert_status "Gitea only" 0 "$STATUS"
|
||||
assert_contains "Gitea repository created" "$(calls)" "$GITEA_REPO_CALL"
|
||||
assert_contains "created empty" "$(cat "$WORK/curl.bodies")" '"auto_init":false'
|
||||
assert_not_contains "no license" "$(cat "$WORK/curl.bodies")" "license"
|
||||
assert_not_contains "no GitHub call" "$(calls)" "api.github.com"
|
||||
assert_not_contains "no mirror call" "$(calls)" "push_mirrors"
|
||||
assert_contains "GitHub not used" "$OUT" "GitHub repository : not used"
|
||||
assert_contains "mirror not used" "$OUT" "Push mirror : not used"
|
||||
}
|
||||
|
||||
test_apply_declined_creates_nothing() {
|
||||
setup_hosts
|
||||
run_apply "$ANSWERS_GITHUB"$'n\n'
|
||||
assert_status "answered no" 0 "$STATUS"
|
||||
assert_contains "says so" "$OUT" "Nothing was created."
|
||||
assert_not_contains "no POST" "$(calls)" "POST"
|
||||
assert_not_contains "no report" "$OUT" "This is what exists now"
|
||||
}
|
||||
|
||||
test_apply_for_user_owners_uses_the_user_endpoints() {
|
||||
setup_hosts
|
||||
write_routes <<'ROUTES'
|
||||
GET|/api/v1/user|200|{"login":"gitea-user"}
|
||||
GET|/api/v1/licenses|200|[{"key":"AGPL-3.0"}]
|
||||
GET|/api/v1/repos/gitea-user/my-app|404|{"message":"not found"}
|
||||
GET|api.github.com/user|200|{"login":"octo-user"}
|
||||
GET|api.github.com/repos/octo-user/my-app|404|{"message":"Not Found"}
|
||||
GET|/api/v1/repos/gitea-user/my-app/push_mirrors|200|[]
|
||||
GET|/api/v1/repos/gitea-user/my-app/push_mirrors|200|[{"remote_address":"https://github.com/octo-user/my-app.git","sync_on_commit":true}]
|
||||
POST|api.github.com/user/repos|201|{}
|
||||
POST|/api/v1/user/repos|201|{}
|
||||
POST|/api/v1/repos/gitea-user/my-app/push_mirrors|200|{}
|
||||
POST|/api/v1/repos/gitea-user/my-app/push_mirrors-sync|200|{}
|
||||
ROUTES
|
||||
run_apply $'my-app\n\n\ngitea-user\ny\n\n\n\ny\n'
|
||||
assert_status "user owners" 0 "$STATUS"
|
||||
assert_contains "GitHub user endpoint" "$(calls)" "POST https://api.github.com/user/repos"
|
||||
assert_contains "Gitea user endpoint" "$(calls)" "POST https://git.example.test/api/v1/user/repos"
|
||||
assert_contains "mirror target of the GitHub account" "$(cat "$WORK/curl.bodies")" '"remote_address":"https://github.com/octo-user/my-app.git"'
|
||||
assert_not_contains "no organization endpoint" "$(calls)" "/orgs/"
|
||||
}
|
||||
|
||||
test_public_visibility_and_special_characters_in_the_description() {
|
||||
setup_hosts
|
||||
printf 'GITEA_TOKEN=%s\n' "$FAKE_GITEA_TOKEN" >"$WORK/.env"
|
||||
run_apply $'my-app\nSay "hi" \\ there\npublic\nTirSystem\nn\n\nn\ny\n'
|
||||
assert_status "apply" 0 "$STATUS"
|
||||
assert_contains "public" "$(cat "$WORK/curl.bodies")" '"private":false'
|
||||
assert_contains "description escaped" "$(cat "$WORK/curl.bodies")" '"description":"Say \"hi\" \\ there"'
|
||||
}
|
||||
|
||||
test_mirror_interval_comes_from_the_configuration() {
|
||||
setup_hosts
|
||||
printf 'MIRROR_INTERVAL=1h0m0s\n' >>"$WORK/config.env"
|
||||
run_apply "$ANSWERS_GITHUB"$'y\n'
|
||||
assert_status "apply" 0 "$STATUS"
|
||||
assert_contains "interval sent" "$(cat "$WORK/curl.bodies")" '"interval":"1h0m0s"'
|
||||
assert_contains "interval planned" "$OUT" "every 1h0m0s"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------- reuse
|
||||
|
||||
test_empty_github_repository_can_be_reused() {
|
||||
setup_hosts
|
||||
prepend_route 'GET|api.github.com/repos/acme-org/my-app|200|{"name":"my-app"}'
|
||||
run_dry "$ANSWERS_GITHUB"
|
||||
assert_contains "plan offers reuse" "$OUT" "reuse the existing empty repository (you will be asked to confirm)"
|
||||
assert_not_contains "dry run does not ask" "$ERR" "Reuse it"
|
||||
setup_hosts
|
||||
prepend_route 'GET|api.github.com/repos/acme-org/my-app|200|{"name":"my-app"}'
|
||||
run_apply "$ANSWERS_GITHUB"$'y\ny\n'
|
||||
assert_status "reuse" 0 "$STATUS"
|
||||
assert_contains "asked" "$ERR" "GitHub repository https://github.com/acme-org/my-app already exists"
|
||||
assert_not_contains "GitHub repository not created again" "$(calls)" "$GITHUB_REPO_CALL"
|
||||
assert_contains "reported as reused" "$OUT" "GitHub repository : reused https://github.com/acme-org/my-app"
|
||||
assert_contains "the rest is created" "$(calls)" "$GITEA_REPO_CALL"
|
||||
}
|
||||
|
||||
test_declining_the_reuse_stops_the_run() {
|
||||
setup_hosts
|
||||
prepend_route 'GET|api.github.com/repos/acme-org/my-app|200|{"name":"my-app"}'
|
||||
run_apply "$ANSWERS_GITHUB"$'y\nn\n'
|
||||
assert_status "reuse declined" 1 "$STATUS"
|
||||
assert_contains "message" "$ERR" "stopped: choose another name or remove the existing repository"
|
||||
assert_not_contains "nothing created" "$(calls)" "POST"
|
||||
}
|
||||
|
||||
test_gitea_repository_with_only_the_license_can_be_reused() {
|
||||
setup_hosts
|
||||
prepend_route 'GET|/api/v1/repos/TirSystem/my-app|200|{"empty":false}'
|
||||
prepend_route 'GET|/api/v1/repos/TirSystem/my-app/contents|200|[{"name":"LICENSE","type":"file","path":"LICENSE"}]'
|
||||
run_apply "$ANSWERS_GITHUB"$'y\ny\n'
|
||||
assert_status "license only" 0 "$STATUS"
|
||||
assert_not_contains "Gitea repository not created again" "$(calls)" "$GITEA_REPO_CALL"
|
||||
assert_eq "mirror created once" "1" "$(calls | grep -c -x -F "$MIRROR_CALL")"
|
||||
assert_contains "reported" "$OUT" "Gitea repository : reused https://git.example.test/TirSystem/my-app"
|
||||
# Without GitHub the license is not expected, so the repository has content.
|
||||
setup_hosts
|
||||
printf 'GITEA_TOKEN=%s\n' "$FAKE_GITEA_TOKEN" >"$WORK/.env"
|
||||
prepend_route 'GET|/api/v1/repos/TirSystem/my-app|200|{"empty":false}'
|
||||
prepend_route 'GET|/api/v1/repos/TirSystem/my-app/contents|200|[{"name":"LICENSE","type":"file"}]'
|
||||
run_dry "$ANSWERS_GITEA_ONLY"
|
||||
assert_status "license without GitHub" 1 "$STATUS"
|
||||
assert_contains "has content" "$ERR" "already exists and has content"
|
||||
}
|
||||
|
||||
test_reusing_an_empty_gitea_repository_warns_about_the_license() {
|
||||
setup_hosts
|
||||
prepend_route 'GET|/api/v1/repos/TirSystem/my-app|200|{"empty":true}'
|
||||
run_apply "$ANSWERS_GITHUB"$'y\ny\n'
|
||||
assert_status "reuse empty Gitea repository" 0 "$STATUS"
|
||||
assert_contains "warning" "$ERR" "the AGPL-3.0 license is not added to it"
|
||||
}
|
||||
|
||||
test_an_existing_mirror_is_reused() {
|
||||
setup_hosts
|
||||
prepend_route 'GET|/api/v1/repos/TirSystem/my-app/push_mirrors|200|[{"remote_address":"https://github.com/acme-org/my-app.git","sync_on_commit":true,"last_error":""}]'
|
||||
run_apply "$ANSWERS_GITHUB"$'y\n'
|
||||
assert_status "mirror exists" 0 "$STATUS"
|
||||
assert_eq "mirror not created twice" "" "$(position "$MIRROR_CALL")"
|
||||
assert_contains "first sync still requested" "$(calls)" "$SYNC_CALL"
|
||||
assert_contains "reported" "$OUT" "Push mirror : reused Gitea -> https://github.com/acme-org/my-app.git"
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------- failures
|
||||
|
||||
test_partial_failure_reports_what_exists() {
|
||||
setup_hosts
|
||||
prepend_route 'POST|/api/v1/orgs/TirSystem/repos|422|{"message":"repository already exists"}'
|
||||
run_apply "$ANSWERS_GITHUB"$'y\n'
|
||||
assert_status "Gitea creation fails" 1 "$STATUS"
|
||||
assert_contains "reason" "$ERR" "cannot create the Gitea repository: rejected"
|
||||
assert_contains "server's words" "$ERR" "repository already exists"
|
||||
assert_contains "report header" "$OUT" "The run stopped before it finished. This is what exists now:"
|
||||
assert_contains "GitHub was created" "$OUT" "GitHub repository : created https://github.com/acme-org/my-app"
|
||||
assert_contains "Gitea failed" "$OUT" "Gitea repository : FAILED"
|
||||
assert_contains "mirror not attempted" "$OUT" "Push mirror : not attempted"
|
||||
assert_contains "how to continue" "$OUT" "To continue: fix the problem named above and run the same command again with --apply."
|
||||
assert_contains "nothing deleted" "$OUT" "Nothing is deleted automatically."
|
||||
assert_not_contains "never deletes" "$(calls)" "DELETE"
|
||||
assert_not_contains "no mirror call" "$(calls)" "push_mirrors"
|
||||
assert_eq "temporary files removed" "" "$(find "$WORK/tmp" -mindepth 1)"
|
||||
}
|
||||
|
||||
test_failure_of_the_first_step_leaves_the_rest_not_attempted() {
|
||||
setup_hosts
|
||||
prepend_route 'POST|api.github.com/orgs/acme-org/repos|403|{"message":"Resource not accessible by personal access token"}'
|
||||
run_apply "$ANSWERS_GITHUB"$'y\n'
|
||||
assert_status "GitHub creation fails" 1 "$STATUS"
|
||||
assert_contains "reason" "$ERR" "cannot create the GitHub repository: the token is valid but not allowed to do this"
|
||||
assert_contains "GitHub failed" "$OUT" "GitHub repository : FAILED"
|
||||
assert_contains "Gitea not attempted" "$OUT" "Gitea repository : not attempted"
|
||||
assert_not_contains "no Gitea call" "$(calls)" "$GITEA_REPO_CALL"
|
||||
}
|
||||
|
||||
test_mirror_failure_keeps_the_repositories_and_says_so() {
|
||||
setup_hosts
|
||||
prepend_route 'POST|/api/v1/repos/TirSystem/my-app/push_mirrors|403|{"message":"push mirrors are disabled"}'
|
||||
run_apply "$ANSWERS_GITHUB"$'y\n'
|
||||
assert_status "mirror fails" 1 "$STATUS"
|
||||
assert_contains "reason" "$ERR" "cannot create the push mirror"
|
||||
assert_contains "server's words" "$ERR" "push mirrors are disabled"
|
||||
assert_contains "GitHub kept" "$OUT" "GitHub repository : created"
|
||||
assert_contains "Gitea kept" "$OUT" "Gitea repository : created"
|
||||
assert_contains "mirror failed" "$OUT" "Push mirror : FAILED"
|
||||
}
|
||||
|
||||
test_sync_on_commit_not_applied_is_reported() {
|
||||
setup_hosts
|
||||
sed -i 's/"sync_on_commit":true,"interval"/"sync_on_commit":false,"interval"/' "$WORK/routes"
|
||||
run_apply "$ANSWERS_GITHUB"$'y\n'
|
||||
assert_status "still succeeds" 0 "$STATUS"
|
||||
assert_contains "warning" "$ERR" "Gitea did not apply sync_on_commit (a known server issue)"
|
||||
assert_contains "report mentions the interval" "$OUT" "(syncs every 10m0s, not on every commit)"
|
||||
}
|
||||
|
||||
test_first_sync_error_is_reported() {
|
||||
setup_hosts
|
||||
sed -i 's/"last_error":""/"last_error":"push failed: authentication required"/' "$WORK/routes"
|
||||
run_apply "$ANSWERS_GITHUB"$'y\n'
|
||||
assert_status "still succeeds" 0 "$STATUS"
|
||||
assert_contains "warning" "$ERR" "the first mirror sync reported: push failed: authentication required"
|
||||
}
|
||||
|
||||
test_first_sync_request_failure_is_only_a_warning() {
|
||||
setup_hosts
|
||||
prepend_route 'POST|/api/v1/repos/TirSystem/my-app/push_mirrors-sync|500|{"message":"boom"}'
|
||||
run_apply "$ANSWERS_GITHUB"$'y\n'
|
||||
assert_status "still succeeds" 0 "$STATUS"
|
||||
assert_contains "warning" "$ERR" "could not ask Gitea for the first sync (HTTP 500)"
|
||||
}
|
||||
|
||||
test_a_token_in_a_server_message_is_redacted() {
|
||||
setup_hosts
|
||||
prepend_route "POST|/api/v1/orgs/TirSystem/repos|422|{\"message\":\"bad credentials $FAKE_GITHUB_PAT given\"}"
|
||||
run_apply "$ANSWERS_GITHUB"$'y\n'
|
||||
assert_status "Gitea creation fails" 1 "$STATUS"
|
||||
assert_not_contains "token redacted" "$OUT$ERR" "$FAKE_GITHUB_PAT"
|
||||
assert_contains "redaction visible" "$ERR" "[redacted]"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------ JSON helpers
|
||||
|
||||
test_json_has_value_and_values() {
|
||||
printf '[{"key": "AGPL-3.0","name":"a"},{"key":"MIT","name":"b"}]\n' >"$WORK/l.json"
|
||||
run_lib "" "json_has_value '$WORK/l.json' key AGPL-3.0 && echo yes1
|
||||
json_has_value '$WORK/l.json' key MIT && echo yes2
|
||||
json_has_value '$WORK/l.json' key GPL-3.0 || echo no3
|
||||
json_values '$WORK/l.json' name"
|
||||
assert_eq "pairs and values" $'yes1\nyes2\nno3\na\nb' "$OUT"
|
||||
}
|
||||
|
||||
test_mirror_field_with_and_without_jq() {
|
||||
local mode
|
||||
printf '[{"remote_address":"https://github.com/o/a.git","sync_on_commit":false,"last_error":"x"},{"remote_address":"https://github.com/o/b.git","sync_on_commit":true,"last_error":""}]\n' >"$WORK/m.json"
|
||||
for mode in 0 1; do
|
||||
if ((mode)) && ! command -v jq >/dev/null 2>&1; then
|
||||
continue
|
||||
fi
|
||||
run_lib "" "HAS_JQ=$mode
|
||||
mirror_field '$WORK/m.json' https://github.com/o/a.git sync_on_commit
|
||||
mirror_field '$WORK/m.json' https://github.com/o/a.git last_error"
|
||||
assert_eq "first mirror (HAS_JQ=$mode)" $'false\nx' "$OUT"
|
||||
done
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
run_lib "" "HAS_JQ=1
|
||||
mirror_field '$WORK/m.json' https://github.com/o/b.git sync_on_commit"
|
||||
assert_eq "the right mirror is chosen with jq" "true" "$OUT"
|
||||
fi
|
||||
}
|
||||
+31
-18
@@ -4,43 +4,47 @@
|
||||
# (MIL-001 Go/No-Go criteria 2 to 4). Sourced by run-tests.sh.
|
||||
|
||||
# shellcheck disable=SC2016 # snippet and fixture text is literal on purpose
|
||||
readonly ANSWERS_GITHUB=$'my-app\nA test app\n\nTirSystem\ny\nacme-org\n\nn\n'
|
||||
readonly ANSWERS_GITEA_ONLY=$'my-app\n\n\nTirSystem\nn\n\nn\n'
|
||||
|
||||
# listing: all files under the work directory except the test's own captures.
|
||||
listing() {
|
||||
find "$WORK" -type f ! -name out.txt ! -name err.txt ! -name snippet.sh \
|
||||
! -name 'curl.*' | sort
|
||||
! -name 'curl.*' ! -name 'routes*' ! -name 'ssh.args' | sort
|
||||
}
|
||||
|
||||
test_full_run_with_github() {
|
||||
write_fixtures
|
||||
write_curl_stub
|
||||
setup_hosts
|
||||
local before after
|
||||
before="$(listing)"
|
||||
run_cli "$ANSWERS_GITHUB" --config "$WORK/config.env" --env "$WORK/.env"
|
||||
after="$(listing)"
|
||||
assert_status "full run" 0 "$STATUS"
|
||||
assert_contains "summary" "$OUT" "nothing has been created yet"
|
||||
assert_contains "dry run" "$OUT" "Dry run: nothing was created"
|
||||
assert_contains "plan" "$OUT" "create (private) with the AGPL-3.0 license"
|
||||
assert_contains "Gitea link derived from config" "$OUT" "https://git.example.test/TirSystem/my-app"
|
||||
assert_contains "GitHub link uses the chosen organization" "$OUT" "https://github.com/acme-org/my-app"
|
||||
assert_contains "AGPL noted" "$OUT" "AGPL license applied"
|
||||
assert_contains "credential state" "$OUT" "GITEA_TOKEN set, GITHUB_PAT set"
|
||||
assert_not_contains "no Gitea token in output" "$OUT$ERR" "$FAKE_GITEA_TOKEN"
|
||||
assert_not_contains "no GitHub token in output" "$OUT$ERR" "$FAKE_GITHUB_PAT"
|
||||
assert_file_missing "no network call" "$WORK/curl.args"
|
||||
assert_contains "reads from Gitea" "$(calls)" "GET https://git.example.test/api/v1/user"
|
||||
assert_contains "reads from GitHub" "$(calls)" "GET https://api.github.com/user"
|
||||
assert_not_contains "a dry run only reads" "$(calls)" "POST"
|
||||
assert_not_contains "a dry run never deletes" "$(calls)" "DELETE"
|
||||
assert_eq "no file created or changed" "$before" "$after"
|
||||
assert_eq "temporary files removed" "" "$(find "$WORK/tmp" -mindepth 1)"
|
||||
}
|
||||
|
||||
test_full_run_without_github() {
|
||||
write_fixtures
|
||||
setup_hosts
|
||||
printf 'GITEA_TOKEN=%s\n' "$FAKE_GITEA_TOKEN" >"$WORK/.env"
|
||||
run_cli "$ANSWERS_GITEA_ONLY" --config "$WORK/config.env" --env "$WORK/.env"
|
||||
assert_status "Gitea-only run needs no GitHub credentials" 0 "$STATUS"
|
||||
assert_contains "GitHub not used" "$OUT" "GitHub : not used"
|
||||
assert_not_contains "no AGPL line" "$OUT" "AGPL"
|
||||
assert_contains "GITHUB_PAT not set" "$OUT" "GITHUB_PAT not set"
|
||||
assert_not_contains "no call to GitHub" "$(calls)" "api.github.com"
|
||||
assert_not_contains "no license lookup without GitHub" "$(calls)" "/licenses"
|
||||
assert_contains "plan says GitHub is not used" "$OUT" "GitHub repository : not used"
|
||||
}
|
||||
|
||||
test_github_chosen_without_credentials_fails() {
|
||||
@@ -99,11 +103,11 @@ test_usage_errors() {
|
||||
assert_contains "help shows exit codes" "$OUT" "Exit codes"
|
||||
run_cli "" --version
|
||||
assert_status "version" 0 "$STATUS"
|
||||
assert_contains "version output" "$OUT" "RepoFoundry 0.1.0"
|
||||
assert_contains "version output" "$OUT" "RepoFoundry 0.2.0"
|
||||
}
|
||||
|
||||
test_warns_when_env_is_not_ignored_by_git() {
|
||||
write_fixtures
|
||||
setup_hosts
|
||||
git init -q "$WORK/repo"
|
||||
cp "$WORK/.env" "$WORK/repo/.env"
|
||||
cp "$WORK/config.env" "$WORK/repo/config.env"
|
||||
@@ -117,12 +121,20 @@ test_warns_when_env_is_not_ignored_by_git() {
|
||||
|
||||
test_script_uses_no_unsafe_constructs() {
|
||||
local code
|
||||
code="$(grep -vE '^[[:space:]]*#' "$SCRIPT")"
|
||||
code="$(cat "$SCRIPT" "$SRC_DIR"/lib/*.sh | grep -vE '^[[:space:]]*#')"
|
||||
assert_not_contains "no rm -rf" "$code" "rm -rf"
|
||||
assert_not_contains "no rm -r" "$code" "rm -r "
|
||||
assert_not_contains "no eval" "$code" "eval "
|
||||
assert_not_contains "no source" "$code" "source "
|
||||
assert_not_contains "no dot-source" "$code" $'\n. '
|
||||
# Only the script's own library files are sourced, by a fixed path; a
|
||||
# configuration file never is.
|
||||
local line
|
||||
while IFS= read -r line; do
|
||||
check
|
||||
if [[ $line != 'source "$SCRIPT_DIR/lib/'*'.sh"' ]]; then
|
||||
fail "unexpected source line: $line"
|
||||
fi
|
||||
done < <(grep -hE '(^|[[:space:];])source ' <<<"$code")
|
||||
check
|
||||
if grep -Eq '^[[:space:]]*set -[A-Za-z]*x' <<<"$code"; then
|
||||
fail "the script turns tracing on"
|
||||
@@ -133,9 +145,9 @@ test_script_uses_no_unsafe_constructs() {
|
||||
test_tracing_does_not_leak_secrets() {
|
||||
# bash -x would print every assignment and command, secrets included, so
|
||||
# the script switches tracing off and says so.
|
||||
write_fixtures
|
||||
setup_hosts
|
||||
STATUS=0
|
||||
PATH="$WORK/bin:$PATH" TMPDIR="$WORK/tmp" "$BASH" -x "$SCRIPT" \
|
||||
PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" "$BASH" -x "$SCRIPT" \
|
||||
--config "$WORK/config.env" --env "$WORK/.env" <<<"$ANSWERS_GITHUB" \
|
||||
>"$WORK/out.txt" 2>"$WORK/err.txt" || STATUS=$?
|
||||
assert_status "run under bash -x" 0 "$STATUS"
|
||||
@@ -157,7 +169,7 @@ test_termination_removes_temp_files() {
|
||||
if ! mkfifo "$WORK/in" 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
PATH="$WORK/bin:$PATH" TMPDIR="$WORK/tmp" "$BASH" "$SCRIPT" \
|
||||
PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" "$BASH" "$SCRIPT" \
|
||||
--config "$WORK/config.env" --env "$WORK/.env" <"$WORK/in" \
|
||||
>/dev/null 2>&1 &
|
||||
local pid=$! tries=0
|
||||
@@ -182,19 +194,20 @@ test_script_lives_in_src() {
|
||||
|
||||
test_default_files_are_in_the_project_root() {
|
||||
# A project copy: script in src/, config.env and .env one level up.
|
||||
write_fixtures
|
||||
setup_hosts
|
||||
mkdir -p "$WORK/project/src"
|
||||
cp "$SCRIPT" "$WORK/project/src/create-project.sh"
|
||||
cp -R "$SRC_DIR/lib" "$WORK/project/src/lib"
|
||||
cp "$WORK/config.env" "$WORK/project/config.env"
|
||||
cp "$WORK/.env" "$WORK/project/.env"
|
||||
STATUS=0
|
||||
PATH="$WORK/bin:$PATH" TMPDIR="$WORK/tmp" "$BASH" "$WORK/project/src/create-project.sh" \
|
||||
PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" "$BASH" "$WORK/project/src/create-project.sh" \
|
||||
<<<"$ANSWERS_GITHUB" >"$WORK/out.txt" 2>"$WORK/err.txt" || STATUS=$?
|
||||
assert_status "run with the default files" 0 "$STATUS"
|
||||
assert_contains "found config.env in the project root" "$(cat "$WORK/out.txt")" "https://git.example.test/TirSystem/my-app"
|
||||
# Run from another directory: the defaults follow the script, not the cwd.
|
||||
STATUS=0
|
||||
(cd "$WORK" && PATH="$WORK/bin:$PATH" TMPDIR="$WORK/tmp" "$BASH" "$WORK/project/src/create-project.sh" \
|
||||
(cd "$WORK" && PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" "$BASH" "$WORK/project/src/create-project.sh" \
|
||||
<<<"$ANSWERS_GITEA_ONLY" >"$WORK/out.txt" 2>"$WORK/err.txt") || STATUS=$?
|
||||
assert_status "run from another directory" 0 "$STATUS"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-structure.sh - guards for the split of create-project.sh into files
|
||||
# with one responsibility each. Sourced by run-tests.sh.
|
||||
|
||||
# shellcheck disable=SC2016 # snippet and pattern text is literal on purpose
|
||||
|
||||
# lib_names: the names of the files in src/lib, one per line.
|
||||
lib_names() {
|
||||
local file
|
||||
for file in "$SRC_DIR"/lib/*.sh; do
|
||||
file="${file##*/}"
|
||||
printf '%s\n' "${file%.sh}"
|
||||
done
|
||||
}
|
||||
|
||||
test_every_library_file_is_loaded_and_nothing_else() {
|
||||
local loaded on_disk
|
||||
loaded="$(grep -E '^source "\$SCRIPT_DIR/lib/' "$SCRIPT" | sed -e 's|.*/lib/||' -e 's|\.sh"$||' | sort)"
|
||||
on_disk="$(lib_names | sort)"
|
||||
assert_eq "the files that are loaded are the files in src/lib" "$on_disk" "$loaded"
|
||||
}
|
||||
|
||||
test_every_library_file_states_one_responsibility() {
|
||||
local name line
|
||||
for name in $(lib_names); do
|
||||
line="$(head -n 4 "$SRC_DIR/lib/$name.sh" | grep -m 1 "^# $name.sh - " || true)"
|
||||
check
|
||||
if [[ -z $line ]]; then
|
||||
fail "src/lib/$name.sh must name its responsibility in its first lines ('# $name.sh - ...')"
|
||||
fi
|
||||
check
|
||||
if ! grep -q '^# Provides: ' "$SRC_DIR/lib/$name.sh" && [[ $name != constants ]]; then
|
||||
fail "src/lib/$name.sh does not list what it provides"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
test_no_function_is_defined_twice() {
|
||||
local duplicates
|
||||
duplicates="$(cat "$SCRIPT" "$SRC_DIR"/lib/*.sh | grep -oE '^[a-z_]+\(\) \{' | sort | uniq -d)"
|
||||
assert_eq "each function is defined in exactly one file" "" "$duplicates"
|
||||
}
|
||||
|
||||
test_the_listed_functions_exist_in_their_file() {
|
||||
local name list fn
|
||||
for name in $(lib_names); do
|
||||
list="$(sed -n 's/^# Provides: //p' "$SRC_DIR/lib/$name.sh" | tr -d ',')"
|
||||
for fn in $list; do
|
||||
check
|
||||
if ! grep -q "^$fn() {" "$SRC_DIR/lib/$name.sh"; then
|
||||
fail "src/lib/$name.sh says it provides $fn but does not define it"
|
||||
fi
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
test_library_files_have_no_side_effects_when_sourced_alone() {
|
||||
# A library file only defines functions and constants: running it by itself
|
||||
# (after the constants) prints nothing and makes no request.
|
||||
local name
|
||||
write_curl_stub
|
||||
for name in $(lib_names); do
|
||||
if [[ $name == constants ]]; then
|
||||
continue
|
||||
fi
|
||||
STATUS=0
|
||||
PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" "$BASH" -c \
|
||||
'SCRIPT_DIR="$1"; PROJECT_ROOT="$2"; source "$1/lib/constants.sh"; source "$1/lib/$3.sh"' \
|
||||
_ "$SRC_DIR" "$REPO_ROOT" "$name" >"$WORK/out.txt" 2>"$WORK/err.txt" || STATUS=$?
|
||||
assert_status "sourcing lib/$name.sh" 0 "$STATUS"
|
||||
assert_eq "lib/$name.sh prints nothing" "" "$(cat "$WORK/out.txt" "$WORK/err.txt")"
|
||||
done
|
||||
assert_eq "no request made by sourcing" "" "$(calls)"
|
||||
}
|
||||
|
||||
test_the_entry_point_is_small_and_holds_no_helpers() {
|
||||
local helpers
|
||||
helpers="$(grep -oE '^[a-z_]+\(\) \{' "$SCRIPT" | tr -d '(){ ' | sort | tr '\n' ' ')"
|
||||
assert_eq "only finish and main live in the entry point" "finish main " "$helpers"
|
||||
}
|
||||
|
||||
test_no_library_file_is_ignored_by_git() {
|
||||
# The Python template in .gitignore ignores any folder named lib/; a file
|
||||
# that git ignores would silently be left out of every commit.
|
||||
local name
|
||||
for name in $(lib_names); do
|
||||
check
|
||||
if git -C "$REPO_ROOT" check-ignore -q --no-index "src/lib/$name.sh"; then
|
||||
fail "src/lib/$name.sh is ignored by git; check .gitignore (!src/lib)"
|
||||
fi
|
||||
done
|
||||
}
|
||||
Reference in New Issue
Block a user