Ask for missing credentials and create the project's own .env

.env becomes optional. A credential it does not provide (an absent file, an
absent or empty key) is asked, without echo: GITEA_TOKEN at the start,
GITHUB_PAT and GITHUB_USER once GitHub is chosen. An invalid value is asked
again and never shown; when input ends the run stops before any request.
Asked tokens are registered for redaction at once.

After the local project exists the script asks (default no) whether to
create a .env in it. On a yes it holds only the needed keys, is created
private (mode 600) from the start, is excluded from git through
.git/info/exclude (no tracked file changes), is never replaced without a
second yes and is never written when git tracks it. The summary names the
keys, never the values.

New library files credentials.sh and envfile.sh; README, .env.example and
the security decisions updated; tests cover every case.

Task: MIL-005#1
Task: MIL-005#2
Task: MIL-005#3
Task: MIL-005#4
Task: MIL-005#5
Closes #35
Closes #36
Closes #37
Closes #38
Closes #39

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 13:29:01 +08:00
co-authored by Claude Sonnet 5.5
parent 3804ef7556
commit 05a7159c18
13 changed files with 544 additions and 50 deletions
-13
View File
@@ -96,7 +96,6 @@ 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
@@ -104,18 +103,6 @@ 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
+305
View File
@@ -0,0 +1,305 @@
#!/usr/bin/env bash
# test-credentials.sh - tests for the credentials that .env does not provide
# and for the .env file of the new project (MIL-005): they are asked without
# echo, the project .env is only written after a yes, owner-only and ignored
# by git, and no token appears anywhere else. Sourced by run-tests.sh.
# shellcheck disable=SC2016 # snippet and fixture text is literal on purpose
readonly NL=$'\n'
# credentials_input: the answers of a run with no .env at all and GitHub
# chosen: the Gitea token, the details, then the GitHub token and account.
credentials_input() {
printf '%s' "$FAKE_GITEA_TOKEN$NL$ANSWERS_GITHUB$FAKE_GITHUB_PAT${NL}octo-user$NL"
}
run_dry_cred() {
run_cli "$1" --config "$WORK/config.env" --env "$WORK/.env"
}
run_apply_cred() {
run_cli "$1" --apply --config "$WORK/config.env" --env "$WORK/.env"
}
# without_env: remove the .env of the fixtures.
without_env() {
rm -f -- "$WORK/.env"
}
# env_mode FILE: the permission bits, empty where the platform has none.
env_mode() {
case "$(uname -s 2>/dev/null || true)" in
MINGW* | MSYS* | CYGWIN*) ;;
*) stat -c '%a' -- "$1" 2>/dev/null || stat -f '%Lp' -- "$1" 2>/dev/null || true ;;
esac
}
# ------------------------------------------------------------ prompt_secret
test_prompt_secret_asks_again_and_never_repeats_the_answer() {
run_lib $'short\n\nlongenoughtoken1\n' \
'prompt_secret "Gitea access token" is_valid_token "needs 8 characters"; echo "[$REPLY]"'
assert_status "valid answer found" 0 "$STATUS"
assert_eq "valid answer returned" "[longenoughtoken1]" "$OUT"
assert_contains "told why" "$ERR" "invalid Gitea access token: needs 8 characters"
assert_not_contains "refused answer not repeated" "$ERR" "short"
assert_contains "says the input is hidden" "$ERR" "(input is hidden)"
}
test_prompt_secret_stops_when_input_ends() {
run_lib "" 'prompt_secret "Gitea access token" is_valid_token "x" </dev/null'
assert_status "end of input" 1 "$STATUS"
assert_contains "message names the credential" "$ERR" "no input available for 'Gitea access token'"
}
# ------------------------------------------------------ collect_credentials
test_collect_credentials_asks_only_what_is_missing() {
run_lib "$FAKE_GITHUB_PAT${NL}octo-user$NL" \
'CREDENTIALS[GITEA_TOKEN]=giteaFAKEtoken1234567890
collect_credentials GITEA_TOKEN GITHUB_PAT GITHUB_USER
printf "%s|%s\n" "${CREDENTIALS[GITHUB_USER]}" "${#SECRET_VALUES[@]}"'
assert_status "asked" 0 "$STATUS"
assert_eq "account name kept, one secret registered" "octo-user|1" "$OUT"
assert_contains "GitHub token asked" "$ERR" "GitHub personal access token"
assert_contains "account asked" "$ERR" "GitHub account name"
assert_not_contains "Gitea token not asked" "$ERR" "Gitea access token"
assert_not_contains "no token shown" "$ERR$OUT" "$FAKE_GITHUB_PAT"
}
test_a_token_that_is_asked_is_registered_as_a_secret() {
run_lib "$FAKE_GITEA_TOKEN$NL" \
'collect_credentials GITEA_TOKEN
warn "the token is giteaFAKEtoken1234567890 here"'
assert_status "asked" 0 "$STATUS"
assert_not_contains "redacted in later messages" "$ERR" "$FAKE_GITEA_TOKEN"
}
test_an_empty_value_in_env_counts_as_not_provided() {
printf 'GITEA_TOKEN=\nGITHUB_USER=\n' >"$WORK/e.env"
run_lib "$FAKE_GITEA_TOKEN$NL" \
'parse_env_file "'"$WORK"'/e.env" CREDENTIAL_KEYS CREDENTIALS
validate_credentials
collect_credentials GITEA_TOKEN
echo asked-ok'
assert_status "empty value tolerated" 0 "$STATUS"
assert_contains "asked instead" "$ERR" "Gitea access token"
}
test_validate_credentials_no_longer_requires_any() {
printf '# nothing\n' >"$WORK/e.env"
run_lib "" 'parse_env_file "'"$WORK"'/e.env" CREDENTIAL_KEYS CREDENTIALS
validate_credentials
echo fine'
assert_status "no credential required" 0 "$STATUS"
assert_eq "no error" "fine" "$OUT"
printf 'GITEA_TOKEN=short\n' >"$WORK/e.env"
run_lib "" 'parse_env_file "'"$WORK"'/e.env" CREDENTIAL_KEYS CREDENTIALS
validate_credentials'
assert_status "a provided bad token is still refused" 1 "$STATUS"
assert_contains "named" "$ERR" "GITEA_TOKEN"
}
# --------------------------------------------------------------- the run
test_env_is_optional_and_the_token_is_asked_without_echo() {
setup_hosts
without_env
run_dry_cred "$FAKE_GITEA_TOKEN$NL$ANSWERS_GITEA_ONLY"
assert_status "dry run without .env" 0 "$STATUS"
assert_contains "token asked" "$ERR" "Gitea access token (input is hidden)"
assert_contains "plan printed" "$OUT" "Plan:"
assert_not_contains "token not shown" "$OUT$ERR" "$FAKE_GITEA_TOKEN"
assert_not_contains "only reads" "$(calls)" "POST"
}
test_a_provided_credential_is_not_asked() {
setup_hosts
run_dry_cred "$ANSWERS_GITHUB"
assert_status "all provided" 0 "$STATUS"
assert_not_contains "no token prompt" "$ERR" "(input is hidden)"
assert_not_contains "no account prompt" "$ERR" "GitHub account name"
}
test_github_credentials_are_asked_only_when_github_is_chosen() {
setup_hosts
printf 'GITEA_TOKEN=%s\n' "$FAKE_GITEA_TOKEN" >"$WORK/.env"
run_dry_cred "$ANSWERS_GITEA_ONLY"
assert_status "Gitea only" 0 "$STATUS"
assert_not_contains "no GitHub token asked" "$ERR" "GitHub personal access token"
assert_not_contains "no GitHub account asked" "$ERR" "GitHub account name"
run_dry_cred "$ANSWERS_GITHUB$FAKE_GITHUB_PAT${NL}octo-user$NL"
assert_status "GitHub chosen" 0 "$STATUS"
assert_contains "GitHub token asked" "$ERR" "GitHub personal access token (input is hidden)"
assert_contains "GitHub account asked" "$ERR" "GitHub account name"
assert_not_contains "token not shown" "$OUT$ERR" "$FAKE_GITHUB_PAT"
}
test_an_invalid_asked_value_is_asked_again_and_never_shown() {
setup_hosts
without_env
run_dry_cred "bad token${NL}$FAKE_GITEA_TOKEN$NL$ANSWERS_GITEA_ONLY"
assert_status "second answer accepted" 0 "$STATUS"
assert_contains "told it is invalid" "$ERR" "invalid Gitea access token"
assert_not_contains "refused value not shown" "$ERR$OUT" "bad token"
}
test_input_that_ends_stops_before_any_request() {
setup_hosts
without_env
run_dry_cred ""
assert_status "stopped" 1 "$STATUS"
assert_contains "key named" "$ERR" "no input available for 'Gitea access token'"
assert_eq "no request made" "" "$(calls)"
assert_not_contains "no creation report" "$OUT" "This is what exists now"
}
test_asked_tokens_do_not_leak_under_bash_x() {
setup_hosts
without_env
STATUS=0
PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" "$BASH" -x "$SCRIPT" \
--config "$WORK/config.env" --env "$WORK/.env" <<<"$(credentials_input)" \
>"$WORK/out.txt" 2>"$WORK/err.txt" || STATUS=$?
assert_status "run under bash -x" 0 "$STATUS"
assert_not_contains "no Gitea token in the trace" "$(cat "$WORK/err.txt" "$WORK/out.txt")" "$FAKE_GITEA_TOKEN"
assert_not_contains "no GitHub token in the trace" "$(cat "$WORK/err.txt" "$WORK/out.txt")" "$FAKE_GITHUB_PAT"
}
# ---------------------------------------------------------- the project .env
test_the_dry_run_names_the_env_step_and_writes_nothing() {
setup_hosts
run_dry_cred "$ANSWERS_GITHUB"
assert_contains "plan line" "$OUT" "Project .env : you are asked whether to create it (GITEA_TOKEN GITHUB_PAT GITHUB_USER)"
assert_file_missing "no .env" "$WORK/my-app/.env"
}
test_the_project_env_is_not_created_without_a_yes() {
setup_hosts
run_apply_cred "${ANSWERS_GITHUB}y$NL$NL"
assert_status "run" 0 "$STATUS"
assert_file_missing "default is no" "$WORK/my-app/.env"
assert_contains "reported" "$OUT" "Project .env : skipped (you declined)"
setup_hosts
run_apply_cred "${ANSWERS_GITHUB}y${NL}n$NL"
assert_file_missing "explicit no" "$WORK/my-app/.env"
}
test_the_project_env_holds_only_the_needed_keys_and_is_private() {
setup_hosts
run_apply_cred "${ANSWERS_GITHUB}y${NL}y$NL"
assert_status "run" 0 "$STATUS"
assert_file_exists ".env created" "$WORK/my-app/.env"
assert_eq "exactly the needed keys" "GITEA_TOKEN=$FAKE_GITEA_TOKEN${NL}GITHUB_PAT=$FAKE_GITHUB_PAT${NL}GITHUB_USER=octo-user" "$(cat "$WORK/my-app/.env")"
assert_eq "owner-only" "$(env_mode "$WORK/my-app/.env")" "$([[ -z "$(env_mode "$WORK/my-app/.env")" ]] || echo 600)"
assert_contains "reported with the keys, not the values" "$OUT" "Project .env : created (GITEA_TOKEN GITHUB_PAT GITHUB_USER;"
assert_not_contains "no token in the output" "$OUT$ERR" "$FAKE_GITEA_TOKEN"
assert_not_contains "no GitHub token in the output" "$OUT$ERR" "$FAKE_GITHUB_PAT"
# Git ignores it without any tracked file changing.
check
if ! git -C "$WORK/my-app" check-ignore -q -- .env; then
fail ".env is not ignored by git"
fi
assert_not_contains "not listed by git status" "$(git -C "$WORK/my-app" status --porcelain)" ".env"
assert_contains "excluded locally" "$(cat "$WORK/my-app/.git/info/exclude")" ".env"
check
if [[ -e $WORK/my-app/.gitignore ]]; then
fail "a .gitignore was written; only .git/info/exclude may change"
fi
# No temporary file is left behind.
assert_eq "no leftover file" "" "$(find "$WORK/my-app" -maxdepth 1 -name '.env.*' -print)"
}
test_the_project_env_without_github_holds_only_the_gitea_token() {
setup_hosts
run_apply_cred "${ANSWERS_GITEA_ONLY}y${NL}y$NL"
assert_status "run" 0 "$STATUS"
assert_eq "one key" "GITEA_TOKEN=$FAKE_GITEA_TOKEN" "$(cat "$WORK/my-app/.env")"
assert_contains "reported" "$OUT" "Project .env : created (GITEA_TOKEN;"
}
test_asked_credentials_are_what_the_project_env_holds() {
setup_hosts
without_env
run_apply_cred "$(credentials_input)${NL}y${NL}y$NL"
assert_status "run" 0 "$STATUS"
assert_eq "the asked values" "GITEA_TOKEN=$FAKE_GITEA_TOKEN${NL}GITHUB_PAT=$FAKE_GITHUB_PAT${NL}GITHUB_USER=octo-user" "$(cat "$WORK/my-app/.env")"
assert_not_contains "no token in the output" "$OUT$ERR" "$FAKE_GITEA_TOKEN"
}
test_no_token_is_in_any_file_but_the_project_env() {
setup_hosts
without_env
run_apply_cred "$(credentials_input)${NL}y${NL}y$NL"
assert_status "run" 0 "$STATUS"
local hits
# The new project (with its .git folder), the script's temporary directory
# and its output; the stub curl's own request log is not part of the product.
hits="$(grep -rIl -F -e "$FAKE_GITEA_TOKEN" -e "$FAKE_GITHUB_PAT" "$WORK/my-app" "$WORK/tmp" "$WORK/out.txt" "$WORK/err.txt" 2>/dev/null |
grep -v -e '/my-app/\.env$' || true)"
assert_eq "only the project .env holds a token" "" "$hits"
}
test_an_existing_env_is_kept_unless_the_maintainer_says_replace() {
setup_hosts
mkdir -p "$WORK/my-app"
printf 'keep\n' >"$WORK/my-app/.env"
run_apply_cred "${ANSWERS_GITHUB}y${NL}y${NL}y${NL}n$NL"
assert_status "declined replacing" 0 "$STATUS"
assert_eq "unchanged" "keep" "$(cat "$WORK/my-app/.env")"
assert_contains "reported" "$OUT" "Project .env : kept (the existing .env was left as it was)"
remove_workdir
new_workdir
setup_hosts
mkdir -p "$WORK/my-app"
printf 'keep\n' >"$WORK/my-app/.env"
run_apply_cred "${ANSWERS_GITHUB}y${NL}y${NL}y${NL}y$NL"
assert_status "agreed to replace" 0 "$STATUS"
assert_contains "replaced" "$(cat "$WORK/my-app/.env")" "GITEA_TOKEN=$FAKE_GITEA_TOKEN"
assert_eq "private after replacing" "$(env_mode "$WORK/my-app/.env")" "$([[ -z "$(env_mode "$WORK/my-app/.env")" ]] || echo 600)"
}
test_a_tracked_env_is_never_written() {
mkdir -p "$WORK/p"
git -C "$WORK/p" init -q
printf 'tracked\n' >"$WORK/p/.env"
git -C "$WORK/p" add .env
git -C "$WORK/p" -c user.name=t -c user.email=t@example.test commit -q -m init
run_lib "y$NL" \
'PROJECT[directory]="'"$WORK"'/p"; PROJECT[has_github]=0
CREDENTIALS[GITEA_TOKEN]=giteaFAKEtoken1234567890
init_steps
create_env_file
echo "${STEP_STATUS["Project .env"]}"'
assert_status "run" 0 "$STATUS"
assert_eq "skipped" "skipped" "$OUT"
assert_eq "unchanged" "tracked" "$(cat "$WORK/p/.env")"
assert_contains "says why" "$(cat "$WORK/err.txt" "$WORK/out.txt")" "skipped"
}
test_exclude_is_added_once_and_keeps_the_existing_entries() {
mkdir -p "$WORK/p"
git -C "$WORK/p" init -q
printf 'build/' >"$WORK/p/.git/info/exclude" # no trailing newline
run_lib "" \
'exclude_env_file "'"$WORK"'/p"
exclude_env_file "'"$WORK"'/p"
echo done'
assert_status "run" 0 "$STATUS"
local exclude
exclude="$(cat "$WORK/p/.git/info/exclude")"
assert_contains "old entry kept" "$exclude" "build/"
assert_eq "entry added once" "1" "$(grep -c '^\.env$' "$WORK/p/.git/info/exclude")"
assert_eq "old entry still on its own line" "1" "$(grep -c '^build/$' "$WORK/p/.git/info/exclude")"
}
test_exclude_does_nothing_when_env_is_already_ignored() {
mkdir -p "$WORK/p"
git -C "$WORK/p" init -q
printf '.env\n' >"$WORK/p/.gitignore"
run_lib "" 'exclude_env_file "'"$WORK"'/p"; echo done'
assert_status "run" 0 "$STATUS"
assert_eq "exclude file untouched" "0" "$(grep -c '^\.env$' "$WORK/p/.git/info/exclude" || true)"
}
+3 -3
View File
@@ -47,12 +47,12 @@ test_full_run_without_github() {
assert_contains "plan says GitHub is not used" "$OUT" "GitHub repository : not used"
}
test_github_chosen_without_credentials_fails() {
test_github_chosen_without_credentials_stops_when_input_ends() {
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_status "missing GitHub credentials, no answer" 1 "$STATUS"
assert_contains "names the credential" "$ERR" "no input available for 'GitHub personal access token'"
assert_not_contains "no token in the error" "$OUT$ERR" "$FAKE_GITEA_TOKEN"
assert_eq "temporary files removed" "" "$(find "$WORK/tmp" -mindepth 1)"
}