Add the RepoFoundry foundation: config parsing, HTTP helper, prompts, tests

create-project.sh validates config.env and .env (parsed, never sourced),
checks the required tools, asks for the project details and prints a
summary. It makes no network call and no change on disk yet.

- config.env.example and .env.example hold placeholders only
- tokens go through a private curl config file, never the command line
- tests run in private directories with a stub curl; shellcheck and shfmt
  are part of tests/run-tests.sh

Task: MIL-001#1
Task: MIL-001#2
Task: MIL-001#3
Task: MIL-001#4
Task: MIL-001#5
Task: MIL-001#6
Closes #3
Closes #4
Closes #5
Closes #6
Closes #7
Closes #8

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 13:59:08 +08:00
co-authored by Claude Sonnet 5.5
parent d727f7ae60
commit 102dd2473d
12 changed files with 1510 additions and 0 deletions
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env bash
# lib.sh - tiny test helpers for the RepoFoundry tests (sourced, not run).
#
# Each test file defines functions named test_*; run-tests.sh calls them.
# The helpers run create-project.sh in separate bash processes with a private
# work directory and stub tools on PATH, so a test never touches the network,
# the real .env or the repository.
#
# Requires: bash 4.4 or later.
# 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/create-project.sh"
# Distinctive fake credentials; the tests search all output for them.
readonly FAKE_GITEA_TOKEN="giteaFAKEtoken1234567890"
readonly FAKE_GITHUB_PAT="ghpFAKEtoken1234567890"
TESTS_RUN=0
TESTS_FAILED=0
CURRENT_TEST=""
WORK=""
OUT=""
ERR=""
STATUS=0
fail() {
TESTS_FAILED=$((TESTS_FAILED + 1))
printf 'FAIL %s: %s\n' "$CURRENT_TEST" "$1"
}
check() {
TESTS_RUN=$((TESTS_RUN + 1))
}
assert_eq() {
check
if [[ $2 != "$3" ]]; then
fail "$1: expected '$2', got '$3'"
fi
}
assert_status() {
assert_eq "$1 (exit status)" "$2" "$3"
}
assert_contains() {
check
if [[ $2 != *"$3"* ]]; then
fail "$1: output does not contain '$3'"
fi
}
assert_not_contains() {
check
if [[ $2 == *"$3"* ]]; then
fail "$1: output contains '$3' but must not"
fi
}
assert_file_exists() {
check
if [[ ! -e $2 ]]; then
fail "$1: '$2' does not exist"
fi
}
assert_file_missing() {
check
if [[ -e $2 ]]; then
fail "$1: '$2' exists but must not"
fi
}
# new_workdir: create a private work directory with a stub bin directory.
new_workdir() {
WORK="$(mktemp -d "${TMPDIR:-/tmp}/repofoundry-test.XXXXXX")"
mkdir -p "$WORK/bin" "$WORK/tmp"
}
# remove_workdir: delete the work directory without a recursive rm: files
# first, then the now empty directories from the bottom up.
remove_workdir() {
if [[ -n $WORK && -d $WORK ]]; then
find "$WORK" -type f -delete
find "$WORK" -depth -type d -exec rmdir {} +
fi
WORK=""
}
# write_fixtures: valid config.env and .env in the work directory.
write_fixtures() {
cat >"$WORK/config.env" <<EOF
# test configuration
GITHUB_API_URL=https://api.github.com
GITHUB_WEB_URL=https://github.com
GITEA_URL=https://git.example.test/
GITEA_API_URL=https://git.example.test/api/v1
EOF
cat >"$WORK/.env" <<EOF
GITHUB_PAT=$FAKE_GITHUB_PAT
GITHUB_USER=octo-user
GITEA_TOKEN=$FAKE_GITEA_TOKEN
EOF
}
# write_stub NAME BODY: install an executable stub tool in the work bin.
write_stub() {
printf '#!/usr/bin/env bash\n%s\n' "$2" >"$WORK/bin/$1"
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() {
write_stub curl '
printf "%s\n" "$@" >>"$STUB_DIR/curl.args"
out="" cfg=""
while (($# > 0)); do
case "$1" in
--output) out="$2"; shift 2 ;;
--config) cfg="$2"; shift 2 ;;
*) shift ;;
esac
done
if [[ -n $cfg ]]; then cat "$cfg" >>"$STUB_DIR/curl.config"; fi
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}"'
}
# run_cli STDIN ARGS...: run create-project.sh with answers from STDIN (a
# string). Sets OUT, ERR and STATUS.
run_cli() {
local input="$1"
shift
STATUS=0
PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" \
"$BASH" "$SCRIPT" "$@" <<<"$input" >"$WORK/out.txt" 2>"$WORK/err.txt" ||
STATUS=$?
OUT="$(cat "$WORK/out.txt")"
ERR="$(cat "$WORK/err.txt")"
}
# run_lib INPUT CODE: source create-project.sh and run CODE in a fresh bash,
# so that single functions can be tested. Sets OUT, ERR and STATUS.
run_lib() {
local input="$1"
STATUS=0
{
printf '#!/usr/bin/env bash\nsource "%s"\n' "$SCRIPT"
printf '%s\n' "$2"
} >"$WORK/snippet.sh"
PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" \
"$BASH" "$WORK/snippet.sh" <<<"$input" >"$WORK/out.txt" 2>"$WORK/err.txt" ||
STATUS=$?
OUT="$(cat "$WORK/out.txt")"
ERR="$(cat "$WORK/err.txt")"
}
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
# run-tests.sh - run the RepoFoundry checks: bash -n, shellcheck, shfmt (when
# installed) and every test_* function in tests/test-*.sh.
#
# Usage
# tests/run-tests.sh [NAME-PATTERN] run only tests whose name matches
#
# Everything runs in private work directories with stub tools: no network,
# no real .env and no change to the repository.
#
# Requires: bash 4.4 or later, git; shellcheck and shfmt are used if present.
#
# Exit codes: 0 all checks passed, 1 a check failed.
set -Eeuo pipefail
TEST_DIR="$(cd "${BASH_SOURCE[0]%/*}" && pwd)"
readonly TEST_DIR
# shellcheck source=tests/lib.sh
source "$TEST_DIR/lib.sh"
pattern="${1:-}"
failed_checks=0
run_static_checks() {
printf '== static checks\n'
bash -n "$SCRIPT" || failed_checks=$((failed_checks + 1))
if command -v shellcheck >/dev/null 2>&1; then
shellcheck "$SCRIPT" "$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 ||
failed_checks=$((failed_checks + 1))
else
printf 'skipped: shfmt is not installed\n'
fi
}
run_test_file() {
local file="$1" name
# shellcheck source=/dev/null
source "$file"
while read -r name; do
if [[ -n $pattern && $name != *"$pattern"* ]]; then
continue
fi
CURRENT_TEST="$name"
new_workdir
"$name" || fail "the test stopped with an error"
remove_workdir
done < <(declare -F | awk '$3 ~ /^test_/ {print $3}')
}
run_static_checks
for test_file in "$TEST_DIR"/test-*.sh; do
printf '== %s\n' "${test_file##*/}"
run_test_file "$test_file"
# Forget this file's tests so the next file starts clean.
while read -r name; do
unset -f "$name"
done < <(declare -F | awk '$3 ~ /^test_/ {print $3}')
done
printf '\n%d checks, %d failed, %d static check(s) failed\n' \
"$TESTS_RUN" "$TESTS_FAILED" "$failed_checks"
if ((TESTS_FAILED > 0 || failed_checks > 0)); then
exit 1
fi
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env bash
# test-config.sh - tests for the config.env and .env parsing and validation
# (MIL-001 task: safe parser). Sourced by run-tests.sh.
# parse_ok FILE-CONTENT: parse a config file and print the CONFIG entries.
# shellcheck disable=SC2016 # snippet and fixture text is literal on purpose
parse_snippet() {
printf 'parse_env_file "%s" CONFIG_KEYS CONFIG\nfor k in "${!CONFIG[@]}"; do printf "%%s=%%s\\n" "$k" "${CONFIG[$k]}"; done | sort\n' "$WORK/c.env"
}
test_parser_accepts_valid_file() {
printf '# comment\n\nGITEA_URL="https://a.test/"\r\nGITHUB_WEB_URL='"'"'https://b.test'"'"'\nGITEA_API_URL=https://a.test/api/v1 # inline\n' >"$WORK/c.env"
run_lib "" "$(parse_snippet)"
assert_status "valid file" 0 "$STATUS"
assert_contains "quotes stripped" "$OUT" "GITEA_URL=https://a.test/"
assert_contains "single quotes" "$OUT" "GITHUB_WEB_URL=https://b.test"
assert_contains "inline comment dropped" "$OUT" "GITEA_API_URL=https://a.test/api/v1"
}
test_parser_rejects_bad_input() {
local case_name content expected
while IFS='|' read -r case_name content expected; do
printf '%b' "$content" >"$WORK/c.env"
run_lib "" "$(parse_snippet)"
assert_status "$case_name" 1 "$STATUS"
assert_contains "$case_name message" "$ERR" "$expected"
done <<'EOF'
unknown key|OTHER=1\n|unknown key 'OTHER'
credential in config|GITEA_TOKEN=abcdefgh12345\n|is a credential
not KEY=VALUE|just some text\n|line 1: expected KEY=VALUE
lowercase key|gitea_url=https://a.test\n|unknown key 'gitea_url'
duplicate key|GITEA_URL=https://a.test\nGITEA_URL=https://b.test\n|set twice
unbalanced quote|GITEA_URL="https://a.test\n|unbalanced
quote inside|GITEA_URL="https://a"b.test"\n|unbalanced
control character|GITEA_URL=https://a\x01b.test\n|control character
EOF
}
test_parser_missing_file() {
run_lib "" "parse_env_file \"$WORK/none.env\" CONFIG_KEYS CONFIG"
assert_status "missing file" 1 "$STATUS"
assert_contains "missing file message" "$ERR" "cannot read"
}
test_parser_never_executes_values() {
local marker="$WORK/pwned"
printf 'GITEA_URL=$(touch %s)\nGITHUB_WEB_URL=`touch %s`\n' "$marker" "$marker" >"$WORK/c.env"
run_lib "" "parse_env_file \"$WORK/c.env\" CONFIG_KEYS CONFIG
validate_config"
assert_file_missing "command substitution not run" "$marker"
assert_status "bad value rejected" 1 "$STATUS"
printf 'GITEA_TOKEN=$(touch %s)\n' "$marker" >"$WORK/e.env"
run_lib "" "parse_env_file \"$WORK/e.env\" CREDENTIAL_KEYS CREDENTIALS
validate_credentials"
assert_file_missing "token value not run" "$marker"
assert_status "bad token rejected" 1 "$STATUS"
}
test_config_validation() {
local case_name url expected
while IFS='|' read -r case_name url expected; do
printf 'GITEA_URL=%s\n' "$url" >"$WORK/c.env"
run_lib "" "parse_env_file \"$WORK/c.env\" CONFIG_KEYS CONFIG
validate_config"
assert_status "$case_name" 1 "$STATUS"
assert_contains "$case_name message" "$ERR" "$expected"
done <<'EOF'
plain http|http://git.example.test|https URL
user info|https://user:pw@git.example.test|https URL
query string|https://git.example.test/?token=abc|https URL
fragment|https://git.example.test/#x|https URL
spaces|https://git.example.test/a b|https URL
EOF
printf '# nothing\n' >"$WORK/c.env"
run_lib "" "parse_env_file \"$WORK/c.env\" CONFIG_KEYS CONFIG
validate_config"
assert_status "GITEA_URL missing" 1 "$STATUS"
assert_contains "GITEA_URL missing message" "$ERR" "GITEA_URL is missing"
}
test_config_defaults_and_normalizing() {
printf 'GITEA_URL=https://git.example.test///\n' >"$WORK/c.env"
run_lib "" "parse_env_file \"$WORK/c.env\" CONFIG_KEYS CONFIG
validate_config
printf '%s\n' \"\${CONFIG[GITEA_URL]}\" \"\${CONFIG[GITEA_API_URL]}\" \"\${CONFIG[GITHUB_API_URL]}\" \"\${CONFIG[GITHUB_WEB_URL]}\""
assert_status "defaults" 0 "$STATUS"
assert_eq "trailing slashes removed" $'https://git.example.test\nhttps://git.example.test/api/v1\nhttps://api.github.com\nhttps://github.com' "$OUT"
}
test_credentials_validation() {
local case_name content expected
while IFS='|' read -r case_name content expected; do
printf '%b' "$content" >"$WORK/e.env"
run_lib "" "parse_env_file \"$WORK/e.env\" CREDENTIAL_KEYS CREDENTIALS
validate_credentials"
assert_status "$case_name" 1 "$STATUS"
assert_contains "$case_name message" "$ERR" "$expected"
done <<'EOF'
no Gitea token|GITHUB_USER=octo\n|GITEA_TOKEN is missing
token too short|GITEA_TOKEN=short\n|not a valid token
token with a backslash|GITEA_TOKEN=abc\\defgh12345\n|not a valid token
bad GitHub token|GITEA_TOKEN=abcdefgh12345\nGITHUB_PAT=bad token\n|GITHUB_PAT
bad GitHub user|GITEA_TOKEN=abcdefgh12345\nGITHUB_USER=-bad-\n|GITHUB_USER
EOF
}
test_github_credentials_required_only_when_chosen() {
printf 'GITEA_TOKEN=%s\n' "$FAKE_GITEA_TOKEN" >"$WORK/e.env"
run_lib "" "parse_env_file \"$WORK/e.env\" CREDENTIAL_KEYS CREDENTIALS
validate_credentials
echo no-github-ok
ENV_FILE=\"$WORK/e.env\"
require_github_credentials"
assert_contains "Gitea-only .env is valid" "$OUT" "no-github-ok"
assert_status "GitHub credentials missing" 1 "$STATUS"
assert_contains "names the missing key" "$ERR" "GITHUB_PAT is missing"
}
test_validators() {
local fn value expected
while IFS='|' read -r fn value expected; do
run_lib "" "if $fn '$value'; then echo yes; else echo no; fi"
assert_eq "$fn '$value'" "$expected" "$OUT"
done <<'EOF'
is_valid_repo_name|RepoFoundry|yes
is_valid_repo_name|my.repo_1-x|yes
is_valid_repo_name|bad name|no
is_valid_repo_name|..|no
is_valid_repo_name|x.git|no
is_valid_repo_name||no
is_valid_gitea_owner|Tirsvad|yes
is_valid_gitea_owner|-lead|no
is_valid_github_owner|octo-user|yes
is_valid_github_owner|octo_user|no
is_valid_github_owner|-octo|no
is_valid_github_owner|octo-|no
is_valid_directory|./my-project|yes
is_valid_directory|-rf|no
is_valid_directory||no
is_valid_token|abcdefgh12345|yes
is_valid_token|abc|no
is_valid_base_url|https://git.example.test/api/v1|yes
is_valid_base_url|http://git.example.test|no
is_valid_request_url|https://api.github.com/user?per_page=5|yes
is_valid_request_url|https://u:p@api.github.com/user|no
EOF
}
test_example_files_hold_placeholders_only() {
local file line value
for file in "$REPO_ROOT/.env.example" "$REPO_ROOT/config.env.example"; do
assert_file_exists "example file" "$file"
done
while IFS= read -r line; do
value="${line#*=}"
check
if [[ -n $value ]]; then
fail ".env.example has a value for ${line%%=*}; it must be empty"
fi
done < <(grep -E '^[A-Z_]+=' "$REPO_ROOT/.env.example")
run_lib "" "parse_env_file \"$REPO_ROOT/config.env.example\" CONFIG_KEYS CONFIG
validate_config
echo parsed"
assert_contains "config.env.example is valid" "$OUT" "parsed"
run_lib "" "parse_env_file \"$REPO_ROOT/.env.example\" CREDENTIAL_KEYS CREDENTIALS
echo parsed"
assert_contains ".env.example parses" "$OUT" "parsed"
}
test_env_is_ignored_by_git() {
check
if ! git -C "$REPO_ROOT" check-ignore -q .env; then
fail ".env is not ignored by git"
fi
check
if git -C "$REPO_ROOT" check-ignore -q .env.example; then
fail ".env.example must not be ignored"
fi
}
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env bash
# test-http.sh - tests for the HTTP helper, the tool check and the JSON
# helpers (MIL-001 task: tool check and HTTP helper). A stub curl stands in
# for the network. Sourced by run-tests.sh.
# shellcheck disable=SC2016 # snippet and fixture text is literal on purpose
test_http_request_keeps_token_off_the_command_line() {
write_curl_stub
run_lib "" "setup_temp_dir
http_request GET https://git.example.test/api/v1/user token '$FAKE_GITEA_TOKEN'
echo \"status=\$HTTP_STATUS\"
cleanup"
assert_status "request succeeds" 0 "$STATUS"
assert_contains "status captured" "$OUT" "status=200"
assert_file_exists "stub saw the call" "$WORK/curl.args"
assert_not_contains "token not in arguments" "$(cat "$WORK/curl.args")" "$FAKE_GITEA_TOKEN"
assert_contains "token in private config" "$(cat "$WORK/curl.config")" "Authorization: token $FAKE_GITEA_TOKEN"
assert_contains "redirects are not followed" "$(cat "$WORK/curl.args")" "--silent"
assert_not_contains "no redirect flag" "$(cat "$WORK/curl.args")" "--location"
assert_not_contains "no -L flag" "$(cat "$WORK/curl.args")" $'\n-L\n'
assert_not_contains "token not printed" "$OUT$ERR" "$FAKE_GITEA_TOKEN"
}
test_http_request_cleans_up_its_files() {
write_curl_stub
run_lib "" "setup_temp_dir
http_request GET https://git.example.test/api/v1/user token '$FAKE_GITEA_TOKEN'
cleanup"
assert_eq "no temp files left" "" "$(find "$WORK/tmp" -mindepth 1 2>/dev/null)"
}
test_http_request_bearer_scheme_and_body() {
write_curl_stub
run_lib "" "setup_temp_dir
http_request POST https://api.github.com/user/repos bearer '$FAKE_GITHUB_PAT' '{\"name\":\"x\"}'
cleanup"
assert_status "POST succeeds" 0 "$STATUS"
assert_contains "bearer header" "$(cat "$WORK/curl.config")" "Authorization: Bearer $FAKE_GITHUB_PAT"
assert_contains "content type" "$(cat "$WORK/curl.config")" "Content-Type: application/json"
assert_contains "body sent from a file" "$(cat "$WORK/curl.args")" "--data-binary"
assert_not_contains "token not in arguments" "$(cat "$WORK/curl.args")" "$FAKE_GITHUB_PAT"
}
test_http_status_messages() {
local code expected
while IFS='|' read -r code expected; do
run_lib "" "describe_http_status $code"
assert_contains "HTTP $code" "$OUT" "$expected"
done <<'EOF'
401|authentication failed
403|scopes
404|not found
422|already exist
429|rate limited
503|server reported an error
418|unexpected HTTP status 418
EOF
}
test_http_network_failure() {
write_curl_stub
run_lib "" "setup_temp_dir
STUB_CURL_EXIT=7 http_request GET https://git.example.test/api/v1/user token '$FAKE_GITEA_TOKEN' || echo \"failed: \$HTTP_ERROR\"
cleanup"
assert_contains "network error reported" "$OUT" "failed: could not reach git.example.test: could not connect"
assert_not_contains "token not in the error" "$OUT$ERR" "$FAKE_GITEA_TOKEN"
}
test_http_request_refuses_unsafe_input() {
local case_name args expected
write_curl_stub
while IFS='|' read -r case_name args expected; do
run_lib "" "setup_temp_dir
http_request $args
cleanup"
assert_status "$case_name" 1 "$STATUS"
assert_contains "$case_name message" "$ERR" "$expected"
done <<'EOF'
http URL|GET http://git.example.test/x token abcdefgh12345|invalid or non-https URL
user info URL|GET https://u:p@git.example.test/x token abcdefgh12345|invalid or non-https URL
bad method|TRACE https://git.example.test/x token abcdefgh12345|unsupported HTTP method
bad token|GET https://git.example.test/x token 'bad token'|malformed token
bad scheme|GET https://git.example.test/x basic abcdefgh12345|unknown authentication scheme
EOF
assert_file_missing "curl never called" "$WORK/curl.args"
}
test_check_tools_stops_before_any_change() {
# An empty PATH: no git, curl or mktemp, so the check must fail first and
# the script must not create anything, not even a temporary directory.
write_fixtures
mkdir -p "$WORK/emptybin"
STATUS=0
PATH="$WORK/emptybin" TMPDIR="$WORK/tmp" "$BASH" "$SCRIPT" \
--config "$WORK/config.env" --env "$WORK/.env" </dev/null \
>"$WORK/out.txt" 2>"$WORK/err.txt" || STATUS=$?
assert_status "missing tools" 1 "$STATUS"
assert_contains "names the tools" "$(cat "$WORK/err.txt")" "required tool(s) not found: git curl mktemp"
assert_eq "nothing created" "" "$(find "$WORK/tmp" -mindepth 1 2>/dev/null)"
}
test_missing_jq_is_only_a_warning() {
run_lib "" "command() { if [[ \$1 == -v && \$2 == jq ]]; then return 1; fi; builtin command \"\$@\"; }
check_tools
echo \"HAS_JQ=\$HAS_JQ\""
assert_status "jq optional" 0 "$STATUS"
assert_contains "jq flag cleared" "$OUT" "HAS_JQ=0"
assert_contains "warning shown" "$ERR" "jq not found"
}
test_json_escape() {
run_lib "" 'json_escape "say \"hi\" \\ back"; echo; json_escape $'"'"'a\nb\tc'"'"'; echo'
assert_eq "escaped" $'say \\"hi\\" \\\\ back\na\\nb\\tc' "$OUT"
}
test_json_get_with_and_without_jq() {
local mode
printf '{"id": 42, "name": "RepoFoundry", "private": false, "other": {"name": "x"}}\n' >"$WORK/r.json"
for mode in 0 1; do
if ((mode)) && ! command -v jq >/dev/null 2>&1; then
continue
fi
run_lib "" "HAS_JQ=$mode
json_get '$WORK/r.json' id
json_get '$WORK/r.json' name
json_get '$WORK/r.json' private
json_get '$WORK/r.json' missing"
assert_eq "json_get with HAS_JQ=$mode" $'42\nRepoFoundry\nfalse' "$OUT"
done
}
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
# test-prompts.sh - tests for the interactive prompts and their validation
# (MIL-001 task: prompts). Answers are piped to stdin. Sourced by
# run-tests.sh.
# shellcheck disable=SC2016 # snippet and fixture text is literal on purpose
test_prompt_value_asks_again_until_valid() {
run_lib $'bad name\n..\nok-name\n' \
'prompt_value "Repository name" "" is_valid_repo_name "use letters"; echo "[$REPLY]"'
assert_status "retries" 0 "$STATUS"
assert_eq "valid answer returned" "[ok-name]" "$OUT"
assert_contains "told why" "$ERR" "invalid Repository name: use letters"
}
test_prompt_value_uses_the_default() {
run_lib $'\n' \
'prompt_value "Local directory" "./proj" is_valid_directory "x"; echo "[$REPLY]"'
assert_eq "default taken" "[./proj]" "$OUT"
}
test_prompt_stops_when_input_ends() {
run_lib "" 'prompt_value "Repository name" "" is_valid_repo_name "x" </dev/null'
assert_status "end of input" 1 "$STATUS"
assert_contains "message" "$ERR" "no input available for 'Repository name'"
}
test_prompt_choice() {
run_lib $'PUBLIC\n' 'prompt_choice "Visibility" private private public; echo "[$REPLY]"'
assert_eq "case-insensitive choice" "[public]" "$OUT"
run_lib $'\n' 'prompt_choice "Visibility" private private public; echo "[$REPLY]"'
assert_eq "default choice" "[private]" "$OUT"
run_lib $'secret\nprivate\n' 'prompt_choice "Visibility" private private public; echo "[$REPLY]"'
assert_eq "invalid then valid" "[private]" "$OUT"
assert_contains "told the options" "$ERR" "choose one of private public"
}
test_prompt_yes_no() {
local answer expected
while IFS='|' read -r answer expected; do
run_lib "$answer"$'\n' 'prompt_yes_no "Use GitHub" y; echo "[$REPLY]"'
assert_eq "answer '$answer'" "[$expected]" "$OUT"
done <<'EOF'
|1
y|1
YES|1
n|0
No|0
EOF
run_lib $'maybe\nn\n' 'prompt_yes_no "Use GitHub" y; echo "[$REPLY]"'
assert_eq "invalid then valid" "[0]" "$OUT"
assert_contains "told what to answer" "$ERR" "answer y or n"
}
test_collect_details_with_github() {
run_lib $'my-app\nA test app\npublic\nTirSystem\ny\nmy-org\n\ny\n' \
'collect_project_details
for k in name description visibility gitea_owner use_github github_owner directory plan_gate; do
printf "%s=%s\n" "$k" "${PROJECT[$k]}"
done'
assert_status "details collected" 0 "$STATUS"
assert_eq "details" $'name=my-app\ndescription=A test app\nvisibility=public\ngitea_owner=TirSystem\nuse_github=1\ngithub_owner=my-org\ndirectory=./my-app\nplan_gate=1' "$OUT"
}
test_collect_details_without_github() {
run_lib $'my-app\n\n\nTirSystem\nn\n\nn\n' \
'collect_project_details
printf "%s|%s|%s|%s\n" "${PROJECT[visibility]}" "${PROJECT[use_github]}" "[${PROJECT[github_owner]}]" "${PROJECT[plan_gate]}"'
assert_status "GitHub skipped" 0 "$STATUS"
assert_eq "defaults and no GitHub owner" "private|0|[]|0" "$OUT"
assert_not_contains "no GitHub owner prompt" "$ERR" "GitHub owner"
}
test_github_user_is_a_default_not_the_owner() {
# GITHUB_USER only pre-fills the prompt; the Maintainer can pick an
# organization instead.
run_lib $'my-app\n\n\nTirSystem\ny\n\n\nn\n' \
'CREDENTIALS[GITHUB_USER]=octo-user
collect_project_details
echo "${PROJECT[github_owner]}"'
assert_eq "default is the account" "octo-user" "$OUT"
run_lib $'my-app\n\n\nTirSystem\ny\nacme-org\n\nn\n' \
'CREDENTIALS[GITHUB_USER]=octo-user
collect_project_details
echo "${PROJECT[github_owner]}"'
assert_eq "organization chosen" "acme-org" "$OUT"
}
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env bash
# test-security.sh - end-to-end tests: no token in any output, no network
# call, no change on disk, clean temporary files, safe failure paths
# (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
}
test_full_run_with_github() {
write_fixtures
write_curl_stub
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 "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_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
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"
}
test_github_chosen_without_credentials_fails() {
write_fixtures
printf 'GITEA_TOKEN=%s\n' "$FAKE_GITEA_TOKEN" >"$WORK/.env"
run_cli "$ANSWERS_GITHUB" --config "$WORK/config.env" --env "$WORK/.env"
assert_status "missing GitHub credentials" 1 "$STATUS"
assert_contains "names the key" "$ERR" "GITHUB_PAT is missing"
assert_not_contains "no token in the error" "$OUT$ERR" "$FAKE_GITEA_TOKEN"
assert_eq "temporary files removed" "" "$(find "$WORK/tmp" -mindepth 1)"
}
test_error_messages_never_contain_the_value() {
write_fixtures
printf 'GITEA_TOKEN=S3CR3T\nGITHUB_PAT=%s\n' "$FAKE_GITHUB_PAT" >"$WORK/.env"
run_cli "" --config "$WORK/config.env" --env "$WORK/.env"
assert_status "invalid token" 1 "$STATUS"
assert_contains "says what is wrong" "$ERR" "GITEA_TOKEN"
assert_not_contains "invalid value not echoed" "$OUT$ERR" "S3CR3T"
assert_not_contains "valid PAT not echoed" "$OUT$ERR" "$FAKE_GITHUB_PAT"
printf 'this line holds %s as text\n' "$FAKE_GITEA_TOKEN" >"$WORK/.env"
run_cli "" --config "$WORK/config.env" --env "$WORK/.env"
assert_status "malformed line" 1 "$STATUS"
assert_not_contains "malformed line not echoed" "$OUT$ERR" "$FAKE_GITEA_TOKEN"
}
test_unknown_credential_key_in_config_is_rejected() {
write_fixtures
printf 'GITEA_URL=https://git.example.test\nGITHUB_PAT=%s\n' "$FAKE_GITHUB_PAT" >"$WORK/config.env"
run_cli "" --config "$WORK/config.env" --env "$WORK/.env"
assert_status "credential in config.env" 1 "$STATUS"
assert_contains "explains" "$ERR" "keep it in the .env file only"
assert_not_contains "token not echoed" "$OUT$ERR" "$FAKE_GITHUB_PAT"
}
test_redaction() {
run_lib "" "SECRET_VALUES=('$FAKE_GITEA_TOKEN')
say 'token is $FAKE_GITEA_TOKEN here'
warn 'also $FAKE_GITEA_TOKEN'
die 'and $FAKE_GITEA_TOKEN'"
assert_status "die exits 1" 1 "$STATUS"
assert_eq "say redacts" "token is [redacted] here" "$OUT"
assert_not_contains "stderr redacted" "$ERR" "$FAKE_GITEA_TOKEN"
assert_contains "die message" "$ERR" "error: and [redacted]"
}
test_usage_errors() {
run_cli "" --bogus
assert_status "unknown option" 2 "$STATUS"
assert_contains "says so" "$ERR" "unknown option: --bogus"
run_cli "" --config
assert_status "option without value" 2 "$STATUS"
run_cli "" --help
assert_status "help" 0 "$STATUS"
assert_contains "help shows usage" "$OUT" "Usage"
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"
}
test_warns_when_env_is_not_ignored_by_git() {
write_fixtures
git init -q "$WORK/repo"
cp "$WORK/.env" "$WORK/repo/.env"
cp "$WORK/config.env" "$WORK/repo/config.env"
run_cli "$ANSWERS_GITEA_ONLY" --config "$WORK/repo/config.env" --env "$WORK/repo/.env"
assert_status "still runs" 0 "$STATUS"
assert_contains "warns" "$ERR" "is not ignored by git"
assert_not_contains "no token in the warning" "$ERR" "$FAKE_GITEA_TOKEN"
find "$WORK/repo" -type f -delete
find "$WORK/repo" -depth -type d -exec rmdir {} +
}
test_script_uses_no_unsafe_constructs() {
local code
code="$(grep -vE '^[[:space:]]*#' "$SCRIPT")"
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. '
assert_not_contains "no set -x" "$code" "set -x"
assert_not_contains "no fixed /tmp file" "$code" "/tmp/file"
}