Compare commits
2
Commits
5ac230df7e
...
4b6e5b6c67
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b6e5b6c67 | ||
|
|
102dd2473d |
@@ -0,0 +1,9 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
@@ -0,0 +1,22 @@
|
||||
# RepoFoundry credentials. Placeholders only: never put a real value in this
|
||||
# file or commit one.
|
||||
#
|
||||
# Copy this file to .env, fill in the values and keep it private
|
||||
# (chmod 600 .env on Linux and macOS). .env is ignored by git. The file is
|
||||
# read as plain KEY=VALUE lines and never executed. Values may be wrapped in
|
||||
# single or double quotes.
|
||||
|
||||
# GitHub personal access token. Needed only when you choose to create a
|
||||
# GitHub repository. It creates the repository and is also the password of the
|
||||
# Gitea push mirror, so it needs permission to create repositories for the
|
||||
# chosen owner and to push to the new one. Prefer a fine-grained token.
|
||||
GITHUB_PAT=
|
||||
|
||||
# GitHub account the token belongs to. It identifies who authenticates; it is
|
||||
# only a default suggestion for the owner prompt, because the repository can
|
||||
# belong to an organization.
|
||||
GITHUB_USER=
|
||||
|
||||
# Gitea access token (required). It needs permission to create repositories
|
||||
# for the chosen owner and to manage the repository's push mirror.
|
||||
GITEA_TOKEN=
|
||||
+10
@@ -174,3 +174,13 @@ cython_debug/
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
|
||||
# RepoFoundry: credentials and temporary files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.tmp
|
||||
*.swp
|
||||
*~
|
||||
tmp/
|
||||
repofoundry.*/
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# shellcheck configuration for the RepoFoundry scripts
|
||||
shell=bash
|
||||
@@ -0,0 +1,23 @@
|
||||
# RepoFoundry service addresses (not secret).
|
||||
#
|
||||
# Copy this file to config.env and adjust it. create-project.sh reads it as
|
||||
# plain KEY=VALUE lines; it is never executed, so no shell syntax, $variables
|
||||
# or command substitution works here. Credentials do NOT belong in this file:
|
||||
# put them in .env (see .env.example). A credential key in this file is
|
||||
# rejected.
|
||||
#
|
||||
# Every URL must start with https:// and must not contain a user name, a
|
||||
# password, a query string or a fragment.
|
||||
|
||||
# GitHub REST API base URL.
|
||||
GITHUB_API_URL=https://api.github.com
|
||||
|
||||
# GitHub web base URL, used for the repository links the script prints and
|
||||
# for the push-mirror address.
|
||||
GITHUB_WEB_URL=https://github.com
|
||||
|
||||
# Gitea instance base URL. Repository links are derived from it.
|
||||
GITEA_URL=https://git.tirsystem.com/
|
||||
|
||||
# Gitea REST API base URL. If you leave this out it is GITEA_URL + /api/v1.
|
||||
GITEA_API_URL=https://git.tirsystem.com/api/v1
|
||||
@@ -0,0 +1,689 @@
|
||||
#!/usr/bin/env bash
|
||||
# create-project.sh - set up a new project on Gitea (and optionally GitHub).
|
||||
#
|
||||
# Purpose
|
||||
# RepoFoundry creates a Gitea repository, optionally an empty GitHub
|
||||
# repository with a Gitea -> GitHub push mirror, and a local project with
|
||||
# the SQA-QC-Framework. This version (MIL-001) validates the configuration
|
||||
# and credentials, checks the required tools and asks for the project
|
||||
# details. It does NOT contact GitHub or Gitea and changes nothing on disk;
|
||||
# it only prints a summary of what it collected.
|
||||
#
|
||||
# Usage
|
||||
# create-project.sh [--config FILE] [--env FILE]
|
||||
# create-project.sh --help | --version
|
||||
#
|
||||
# Options
|
||||
# --config FILE service addresses (default: config.env next to the script)
|
||||
# --env FILE credentials (default: .env next to the script)
|
||||
# -h, --help show this help
|
||||
# --version show the version
|
||||
#
|
||||
# Files (parsed, never sourced)
|
||||
# config.env GITHUB_API_URL, GITHUB_WEB_URL, GITEA_URL, GITEA_API_URL
|
||||
# .env GITHUB_PAT, GITHUB_USER, GITEA_TOKEN
|
||||
#
|
||||
# Environment
|
||||
# REPOFOUNDRY_NAME project name used in messages (default: RepoFoundry)
|
||||
# TMPDIR where the private temporary directory is created
|
||||
#
|
||||
# Requires
|
||||
# bash 4.4 or later, git, curl, mktemp; jq is optional (used when present).
|
||||
#
|
||||
# Exit codes
|
||||
# 0 success, 1 a failed check or bad input, 2 a usage error.
|
||||
set -Eeuo pipefail
|
||||
|
||||
if ((BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 4))); then
|
||||
printf 'error: bash 4.4 or later is required (found %s)\n' "$BASH_VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
readonly PROJECT_NAME="${REPOFOUNDRY_NAME:-RepoFoundry}"
|
||||
readonly VERSION="0.1.0"
|
||||
readonly EXIT_FAILURE=1
|
||||
readonly EXIT_USAGE=2
|
||||
readonly MAX_VALUE_LENGTH=2048
|
||||
readonly MAX_DESCRIPTION_LENGTH=350
|
||||
readonly HTTP_TIMEOUT_SECONDS=30
|
||||
# shellcheck disable=SC2034 # read through namerefs (parse_env_file)
|
||||
readonly CONFIG_KEYS=(GITHUB_API_URL GITHUB_WEB_URL GITEA_URL GITEA_API_URL)
|
||||
readonly CREDENTIAL_KEYS=(GITHUB_PAT GITHUB_USER GITEA_TOKEN)
|
||||
|
||||
case "${BASH_SOURCE[0]}" in
|
||||
*/*) script_path_dir="${BASH_SOURCE[0]%/*}" ;;
|
||||
*) script_path_dir="." ;;
|
||||
esac
|
||||
SCRIPT_DIR="$(cd "$script_path_dir" && pwd)"
|
||||
readonly SCRIPT_DIR
|
||||
unset script_path_dir
|
||||
|
||||
CONFIG_FILE="$SCRIPT_DIR/config.env"
|
||||
ENV_FILE="$SCRIPT_DIR/.env"
|
||||
TMP_DIR=""
|
||||
HAS_JQ=0
|
||||
HTTP_STATUS=0
|
||||
HTTP_BODY_FILE=""
|
||||
HTTP_ERROR=""
|
||||
REPLY=""
|
||||
SECRET_VALUES=()
|
||||
TEMP_FILES=()
|
||||
declare -A CONFIG=()
|
||||
declare -A CREDENTIALS=()
|
||||
declare -A PROJECT=()
|
||||
|
||||
# ---------------------------------------------------------------- output
|
||||
|
||||
# redact TEXT: print TEXT with every known secret value replaced.
|
||||
redact() {
|
||||
local text="$1" secret
|
||||
for secret in "${SECRET_VALUES[@]}"; do
|
||||
if [[ -n $secret ]]; then
|
||||
text="${text//"$secret"/[redacted]}"
|
||||
fi
|
||||
done
|
||||
printf '%s' "$text"
|
||||
}
|
||||
|
||||
say() {
|
||||
printf '%s\n' "$(redact "$*")"
|
||||
}
|
||||
|
||||
warn() {
|
||||
printf 'warning: %s\n' "$(redact "$*")" >&2
|
||||
}
|
||||
|
||||
# die [--code N] MESSAGE: print "error: MESSAGE" and exit (default code 1).
|
||||
die() {
|
||||
local code=$EXIT_FAILURE
|
||||
if [[ ${1:-} == --code ]]; then
|
||||
code="$2"
|
||||
shift 2
|
||||
fi
|
||||
printf 'error: %s\n' "$(redact "$*")" >&2
|
||||
exit "$code"
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: ${0##*/} [--config FILE] [--env FILE]
|
||||
${0##*/} --help | --version
|
||||
EOF
|
||||
}
|
||||
|
||||
usage_error() {
|
||||
printf 'error: %s\n' "$1" >&2
|
||||
usage >&2
|
||||
exit "$EXIT_USAGE"
|
||||
}
|
||||
|
||||
# on_error LINE: report an unexpected failure without echoing the command,
|
||||
# because a command line could contain a value that must stay private.
|
||||
on_error() {
|
||||
printf 'error: unexpected failure near line %s of %s\n' "$1" "${0##*/}" >&2
|
||||
}
|
||||
|
||||
# ------------------------------------------------------- temporary files
|
||||
|
||||
# Files are removed one by one and the directory with rmdir: a recursive
|
||||
# delete is never needed and never used.
|
||||
cleanup() {
|
||||
local file
|
||||
for file in "${TEMP_FILES[@]}"; do
|
||||
rm -f -- "$file"
|
||||
done
|
||||
if [[ -n $TMP_DIR && -d $TMP_DIR ]]; then
|
||||
# rmdir fails only if something unexpected is left inside; leave it
|
||||
# rather than delete files this script did not create.
|
||||
rmdir -- "$TMP_DIR" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
setup_temp_dir() {
|
||||
TMP_DIR="$(umask 077 && mktemp -d "${TMPDIR:-/tmp}/repofoundry.XXXXXX")"
|
||||
}
|
||||
|
||||
# make_temp_file: create a private file in TMP_DIR and return it in REPLY.
|
||||
make_temp_file() {
|
||||
REPLY="$(umask 077 && mktemp "$TMP_DIR/file.XXXXXX")"
|
||||
TEMP_FILES+=("$REPLY")
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------ small helpers
|
||||
|
||||
trim() {
|
||||
local text="$1"
|
||||
text="${text#"${text%%[![:space:]]*}"}"
|
||||
text="${text%"${text##*[![:space:]]}"}"
|
||||
printf '%s' "$text"
|
||||
}
|
||||
|
||||
# in_list NEEDLE ITEM...: succeed if NEEDLE equals one of the items.
|
||||
in_list() {
|
||||
local needle="$1" item
|
||||
shift
|
||||
for item in "$@"; do
|
||||
if [[ $item == "$needle" ]]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
has_control_character() {
|
||||
[[ $1 == *[[:cntrl:]]* ]]
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------- validators
|
||||
|
||||
is_valid_repo_name() {
|
||||
local name="$1"
|
||||
[[ $name =~ ^[A-Za-z0-9._-]{1,100}$ ]] || return 1
|
||||
[[ $name != . && $name != .. && $name != *.git ]]
|
||||
}
|
||||
|
||||
is_valid_gitea_owner() {
|
||||
[[ $1 =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,38}$ ]]
|
||||
}
|
||||
|
||||
is_valid_github_owner() {
|
||||
[[ $1 =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,37}[A-Za-z0-9])?$ ]]
|
||||
}
|
||||
|
||||
is_valid_description() {
|
||||
((${#1} <= MAX_DESCRIPTION_LENGTH)) && ! has_control_character "$1"
|
||||
}
|
||||
|
||||
is_valid_directory() {
|
||||
local path="$1"
|
||||
[[ -n $path && ${#path} -le 4096 && $path != -* ]] &&
|
||||
! has_control_character "$path"
|
||||
}
|
||||
|
||||
# https URL without user info, query or fragment, so it can never carry a
|
||||
# credential.
|
||||
is_valid_base_url() {
|
||||
local pattern='^https://[A-Za-z0-9.-]+(:[0-9]{1,5})?(/[A-Za-z0-9._~%+/-]*)?$'
|
||||
[[ $1 =~ $pattern ]]
|
||||
}
|
||||
|
||||
# Like is_valid_base_url but a query string is allowed (for API requests).
|
||||
is_valid_request_url() {
|
||||
local pattern='^https://[A-Za-z0-9.-]+(:[0-9]{1,5})?(/[A-Za-z0-9._~%+/-]*)?(\?[A-Za-z0-9._~%+=&,-]*)?$'
|
||||
[[ $1 =~ $pattern ]]
|
||||
}
|
||||
|
||||
# Access tokens: no quotes, backslashes or whitespace, so a token cannot
|
||||
# break out of the curl configuration it is written to.
|
||||
is_valid_token() {
|
||||
[[ $1 =~ ^[A-Za-z0-9_.~+/=-]{8,255}$ ]]
|
||||
}
|
||||
|
||||
normalize_url() {
|
||||
local url="$1"
|
||||
while [[ $url == */ ]]; do
|
||||
url="${url%/}"
|
||||
done
|
||||
printf '%s' "$url"
|
||||
}
|
||||
|
||||
# --------------------------------------------------- config file parsing
|
||||
|
||||
# unquote_value RAW: strip matching quotes (or a trailing " # comment" on an
|
||||
# unquoted value) and return the value in REPLY. Fails on unbalanced quotes.
|
||||
unquote_value() {
|
||||
local raw quote
|
||||
raw="$(trim "$1")"
|
||||
quote="${raw:0:1}"
|
||||
if [[ $quote == '"' || $quote == "'" ]]; then
|
||||
[[ ${#raw} -ge 2 && ${raw: -1} == "$quote" ]] || return 1
|
||||
raw="${raw:1:${#raw}-2}"
|
||||
[[ $raw != *"$quote"* ]] || return 1
|
||||
else
|
||||
raw="${raw%%[[:space:]]#*}"
|
||||
raw="$(trim "$raw")"
|
||||
[[ $raw != *'"'* && $raw != *"'"* ]] || return 1
|
||||
fi
|
||||
REPLY="$raw"
|
||||
}
|
||||
|
||||
# parse_env_file FILE ALLOWED_ARRAY TARGET_ARRAY
|
||||
# Read KEY=VALUE lines without source or eval. Only keys named in
|
||||
# ALLOWED_ARRAY are accepted; they are stored in the associative array
|
||||
# TARGET_ARRAY. Messages name the key and the line, never the value.
|
||||
parse_env_file() {
|
||||
local file="$1" line key line_number=0
|
||||
local pattern='^([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*=(.*)$'
|
||||
[[ -f $file && -r $file ]] || die "cannot read '$file'"
|
||||
# shellcheck disable=SC2094 # the loop body only uses $file in messages
|
||||
while IFS= read -r line || [[ -n $line ]]; do
|
||||
line_number=$((line_number + 1))
|
||||
line="$(trim "${line%$'\r'}")"
|
||||
if [[ -z $line || $line == \#* ]]; then
|
||||
continue
|
||||
fi
|
||||
[[ $line =~ $pattern ]] ||
|
||||
die "$file line $line_number: expected KEY=VALUE"
|
||||
key="${BASH_REMATCH[1]}"
|
||||
parse_env_entry "$file" "$line_number" "$key" "${BASH_REMATCH[2]}" \
|
||||
"$2" "$3"
|
||||
done <"$file"
|
||||
}
|
||||
|
||||
# parse_env_entry FILE LINE KEY RAW_VALUE ALLOWED_ARRAY TARGET_ARRAY
|
||||
parse_env_entry() {
|
||||
local file="$1" line_number="$2" key="$3" raw="$4"
|
||||
local -n allowed_keys="$5"
|
||||
local -n target_map="$6"
|
||||
local value
|
||||
if ! in_list "$key" "${allowed_keys[@]}"; then
|
||||
if in_list "$key" "${CREDENTIAL_KEYS[@]}"; then
|
||||
die "$file line $line_number: '$key' is a credential; keep it in the .env file only"
|
||||
fi
|
||||
die "$file line $line_number: unknown key '$key'"
|
||||
fi
|
||||
if [[ -n ${target_map[$key]+set} ]]; then
|
||||
die "$file line $line_number: '$key' is set twice"
|
||||
fi
|
||||
unquote_value "$raw" ||
|
||||
die "$file line $line_number: unbalanced or misplaced quotes"
|
||||
value="$REPLY"
|
||||
if has_control_character "$value"; then
|
||||
die "$file line $line_number: '$key' contains a control character"
|
||||
fi
|
||||
if ((${#value} > MAX_VALUE_LENGTH)); then
|
||||
die "$file line $line_number: '$key' is too long"
|
||||
fi
|
||||
# shellcheck disable=SC2004 # target_map is an associative array: $key is a string
|
||||
target_map[$key]="$value"
|
||||
}
|
||||
|
||||
# ------------------------------------------------- configuration checks
|
||||
|
||||
validate_config() {
|
||||
local key url
|
||||
if [[ -z ${CONFIG[GITEA_URL]:-} ]]; then
|
||||
die "GITEA_URL is missing in $CONFIG_FILE (see config.env.example)"
|
||||
fi
|
||||
CONFIG[GITHUB_API_URL]="${CONFIG[GITHUB_API_URL]:-https://api.github.com}"
|
||||
CONFIG[GITHUB_WEB_URL]="${CONFIG[GITHUB_WEB_URL]:-https://github.com}"
|
||||
for key in GITHUB_API_URL GITHUB_WEB_URL GITEA_URL; do
|
||||
url="$(normalize_url "${CONFIG[$key]}")"
|
||||
is_valid_base_url "$url" ||
|
||||
die "$key in $CONFIG_FILE must be an https URL without credentials, query or fragment"
|
||||
CONFIG[$key]="$url"
|
||||
done
|
||||
CONFIG[GITEA_API_URL]="$(normalize_url "${CONFIG[GITEA_API_URL]:-${CONFIG[GITEA_URL]}/api/v1}")"
|
||||
is_valid_base_url "${CONFIG[GITEA_API_URL]}" ||
|
||||
die "GITEA_API_URL in $CONFIG_FILE must be an https URL without credentials, query or fragment"
|
||||
}
|
||||
|
||||
validate_credentials() {
|
||||
if [[ -z ${CREDENTIALS[GITEA_TOKEN]:-} ]]; then
|
||||
die "GITEA_TOKEN is missing in $ENV_FILE (see .env.example)"
|
||||
fi
|
||||
# Register secrets first so that no later message can show them.
|
||||
SECRET_VALUES+=("${CREDENTIALS[GITEA_TOKEN]}")
|
||||
if [[ -n ${CREDENTIALS[GITHUB_PAT]:-} ]]; then
|
||||
SECRET_VALUES+=("${CREDENTIALS[GITHUB_PAT]}")
|
||||
fi
|
||||
is_valid_token "${CREDENTIALS[GITEA_TOKEN]}" ||
|
||||
die "GITEA_TOKEN in $ENV_FILE is not a valid token (8 to 255 letters, digits or _ . ~ + / = -)"
|
||||
if [[ -n ${CREDENTIALS[GITHUB_PAT]:-} ]] &&
|
||||
! is_valid_token "${CREDENTIALS[GITHUB_PAT]}"; then
|
||||
die "GITHUB_PAT in $ENV_FILE is not a valid token (8 to 255 letters, digits or _ . ~ + / = -)"
|
||||
fi
|
||||
if [[ -n ${CREDENTIALS[GITHUB_USER]:-} ]] &&
|
||||
! is_valid_github_owner "${CREDENTIALS[GITHUB_USER]}"; then
|
||||
die "GITHUB_USER in $ENV_FILE is not a valid GitHub account name"
|
||||
fi
|
||||
}
|
||||
|
||||
# GitHub credentials are only needed when the Maintainer chose GitHub.
|
||||
require_github_credentials() {
|
||||
local key
|
||||
for key in GITHUB_PAT GITHUB_USER; do
|
||||
if [[ -z ${CREDENTIALS[$key]:-} ]]; then
|
||||
die "GitHub was chosen but $key is missing in $ENV_FILE (see .env.example)"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
warn_if_env_unsafe() {
|
||||
local file="$1" dir mode
|
||||
case "$(uname -s 2>/dev/null || true)" in
|
||||
MINGW* | MSYS* | CYGWIN*) ;;
|
||||
*)
|
||||
mode="$(stat -c '%a' -- "$file" 2>/dev/null ||
|
||||
stat -f '%Lp' -- "$file" 2>/dev/null || true)"
|
||||
if [[ -n $mode ]] && (((8#$mode & 8#077) != 0)); then
|
||||
warn "$file is readable by other users (mode $mode); run: chmod 600 $file"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
dir="."
|
||||
if [[ $file == */* ]]; then
|
||||
dir="${file%/*}"
|
||||
fi
|
||||
if git -C "$dir" rev-parse --is-inside-work-tree >/dev/null 2>&1 &&
|
||||
! git -C "$dir" check-ignore -q -- "$file"; then
|
||||
warn "$file is not ignored by git; add it to .gitignore before committing"
|
||||
fi
|
||||
}
|
||||
|
||||
load_configuration() {
|
||||
parse_env_file "$CONFIG_FILE" CONFIG_KEYS CONFIG
|
||||
validate_config
|
||||
parse_env_file "$ENV_FILE" CREDENTIAL_KEYS CREDENTIALS
|
||||
validate_credentials
|
||||
warn_if_env_unsafe "$ENV_FILE"
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------- tool check
|
||||
|
||||
check_tools() {
|
||||
local tool
|
||||
local missing=()
|
||||
for tool in git curl mktemp; do
|
||||
if ! command -v "$tool" >/dev/null 2>&1; then
|
||||
missing+=("$tool")
|
||||
fi
|
||||
done
|
||||
if ((${#missing[@]} > 0)); then
|
||||
die "required tool(s) not found: ${missing[*]}. Install them and try again."
|
||||
fi
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
HAS_JQ=1
|
||||
else
|
||||
HAS_JQ=0
|
||||
warn "jq not found; using the built-in JSON reader (install jq for stricter parsing)"
|
||||
fi
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- JSON
|
||||
|
||||
# json_escape TEXT: escape TEXT for use inside a JSON string.
|
||||
json_escape() {
|
||||
local text="$1"
|
||||
text="${text//\\/\\\\}"
|
||||
text="${text//\"/\\\"}"
|
||||
text="${text//$'\n'/\\n}"
|
||||
text="${text//$'\r'/\\r}"
|
||||
text="${text//$'\t'/\\t}"
|
||||
printf '%s' "$text"
|
||||
}
|
||||
|
||||
# json_get FILE KEY: print the string, number or boolean value of KEY.
|
||||
# With jq only the top-level key is read. Without jq the first occurrence of
|
||||
# the key anywhere in the file is used, which is enough for the flat fields
|
||||
# the GitHub and Gitea APIs return (name, id, html_url, ...).
|
||||
json_get() {
|
||||
local file="$1" key="$2"
|
||||
[[ $key =~ ^[A-Za-z0-9_]+$ ]] || die "internal error: invalid JSON key"
|
||||
if ((HAS_JQ)); then
|
||||
# jq on Windows ends lines with CRLF; strip the CR so values stay clean.
|
||||
jq -r --arg key "$key" \
|
||||
'if has($key) and .[$key] != null then .[$key] | tostring else empty end' \
|
||||
"$file" | tr -d '\r'
|
||||
else
|
||||
# grep exits 1 when the key is absent; that is not an error here.
|
||||
{ grep -o "\"$key\"[[:space:]]*:[[:space:]]*\(\"[^\"]*\"\|[0-9][0-9]*\|true\|false\)" "$file" || true; } |
|
||||
head -n 1 |
|
||||
sed -e 's/^[^:]*:[[:space:]]*//' -e 's/^"\(.*\)"$/\1/'
|
||||
fi
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- HTTP
|
||||
|
||||
describe_http_status() {
|
||||
case "$1" in
|
||||
401) printf 'authentication failed: the token is missing, expired or invalid' ;;
|
||||
403) printf 'the token is valid but not allowed to do this (check its scopes)' ;;
|
||||
404) printf 'not found (check the name, the owner and the token access)' ;;
|
||||
409 | 422) printf 'rejected (the name may already exist or be invalid)' ;;
|
||||
429) printf 'rate limited; wait and try again' ;;
|
||||
5??) printf 'the server reported an error; try again later' ;;
|
||||
*) printf 'unexpected HTTP status %s' "$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
describe_curl_error() {
|
||||
case "$1" in
|
||||
6) printf 'could not resolve the host name' ;;
|
||||
7) printf 'could not connect' ;;
|
||||
28) printf 'the request timed out' ;;
|
||||
35 | 51 | 58 | 60) printf 'the TLS connection failed' ;;
|
||||
*) printf 'curl failed with exit code %s' "$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# http_request METHOD URL SCHEME TOKEN [BODY]
|
||||
# SCHEME is "token" (Gitea) or "bearer" (GitHub). The token goes into a
|
||||
# private curl config file, never onto the command line where other users
|
||||
# could see it. Redirects are not followed, so the token is only ever sent
|
||||
# to the host named in URL. On success HTTP_STATUS and HTTP_BODY_FILE are
|
||||
# set; on a network failure the function returns 1 with HTTP_ERROR set.
|
||||
# shellcheck disable=SC2034 # HTTP_* are results read by the callers
|
||||
http_request() {
|
||||
local method="$1" url="$2" scheme="$3" token="$4" body="${5:-}"
|
||||
local header config_file body_file out_file host curl_status=0
|
||||
local data_args=()
|
||||
[[ $method =~ ^(GET|POST|PUT|PATCH|DELETE)$ ]] ||
|
||||
die "internal error: unsupported HTTP method"
|
||||
is_valid_request_url "$url" ||
|
||||
die "refusing to call an invalid or non-https URL"
|
||||
is_valid_token "$token" || die "refusing to send a malformed token"
|
||||
case "$scheme" in
|
||||
token) header="Authorization: token $token" ;;
|
||||
bearer) header="Authorization: Bearer $token" ;;
|
||||
*) die "internal error: unknown authentication scheme" ;;
|
||||
esac
|
||||
make_temp_file
|
||||
config_file="$REPLY"
|
||||
make_temp_file
|
||||
out_file="$REPLY"
|
||||
{
|
||||
printf 'url = "%s"\n' "$url"
|
||||
printf 'request = "%s"\n' "$method"
|
||||
printf 'header = "%s"\n' "$header"
|
||||
printf 'header = "Accept: application/json"\n'
|
||||
printf 'header = "User-Agent: %s/%s"\n' "$PROJECT_NAME" "$VERSION"
|
||||
} >"$config_file"
|
||||
if [[ -n $body ]]; then
|
||||
make_temp_file
|
||||
body_file="$REPLY"
|
||||
printf '%s' "$body" >"$body_file"
|
||||
printf 'header = "Content-Type: application/json"\n' >>"$config_file"
|
||||
data_args=(--data-binary "@$body_file")
|
||||
fi
|
||||
# curl's own error text is dropped: the exit code is mapped to a message
|
||||
# that never contains the request.
|
||||
HTTP_STATUS="$(curl --silent --max-time "$HTTP_TIMEOUT_SECONDS" \
|
||||
--connect-timeout 10 --output "$out_file" --write-out '%{http_code}' \
|
||||
--config "$config_file" "${data_args[@]}" 2>/dev/null)" || curl_status=$?
|
||||
if ((curl_status != 0)); then
|
||||
host="${url#https://}"
|
||||
host="${host%%/*}"
|
||||
HTTP_STATUS=0
|
||||
HTTP_ERROR="could not reach $host: $(describe_curl_error "$curl_status")"
|
||||
return 1
|
||||
fi
|
||||
HTTP_BODY_FILE="$out_file"
|
||||
HTTP_ERROR=""
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------- prompts
|
||||
|
||||
# prompt_value LABEL DEFAULT VALIDATOR HINT: ask until VALIDATOR accepts the
|
||||
# answer; the accepted answer is returned in REPLY.
|
||||
prompt_value() {
|
||||
local label="$1" default="$2" validator="$3" hint="$4" answer
|
||||
while true; do
|
||||
if [[ -n $default ]]; then
|
||||
printf '%s [%s]: ' "$label" "$default" >&2
|
||||
else
|
||||
printf '%s: ' "$label" >&2
|
||||
fi
|
||||
IFS= read -r answer || die "no input available for '$label'"
|
||||
answer="$(trim "$answer")"
|
||||
answer="${answer:-$default}"
|
||||
if "$validator" "$answer"; then
|
||||
REPLY="$answer"
|
||||
return 0
|
||||
fi
|
||||
warn "invalid $label: $hint"
|
||||
done
|
||||
}
|
||||
|
||||
# prompt_choice LABEL DEFAULT CHOICE...: the answer is returned in REPLY.
|
||||
prompt_choice() {
|
||||
local label="$1" default="$2" answer
|
||||
shift 2
|
||||
while true; do
|
||||
printf '%s (%s) [%s]: ' "$label" "$(IFS=/ && echo "$*")" "$default" >&2
|
||||
IFS= read -r answer || die "no input available for '$label'"
|
||||
answer="$(trim "$answer")"
|
||||
answer="${answer:-$default}"
|
||||
answer="${answer,,}"
|
||||
if in_list "$answer" "$@"; then
|
||||
REPLY="$answer"
|
||||
return 0
|
||||
fi
|
||||
warn "invalid $label: choose one of $*"
|
||||
done
|
||||
}
|
||||
|
||||
# prompt_yes_no LABEL DEFAULT: DEFAULT is y or n; REPLY is 1 (yes) or 0 (no).
|
||||
prompt_yes_no() {
|
||||
local label="$1" default="$2" answer
|
||||
while true; do
|
||||
printf '%s (y/n) [%s]: ' "$label" "$default" >&2
|
||||
IFS= read -r answer || die "no input available for '$label'"
|
||||
answer="$(trim "$answer")"
|
||||
answer="${answer:-$default}"
|
||||
case "${answer,,}" in
|
||||
y | yes)
|
||||
REPLY=1
|
||||
return 0
|
||||
;;
|
||||
n | no)
|
||||
REPLY=0
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
warn "invalid $label: answer y or n"
|
||||
done
|
||||
}
|
||||
|
||||
collect_project_details() {
|
||||
prompt_value "Repository name" "" is_valid_repo_name \
|
||||
"use letters, digits, '.', '_' or '-' (at most 100), not ending in .git"
|
||||
PROJECT[name]="$REPLY"
|
||||
prompt_value "Description (optional)" "" is_valid_description \
|
||||
"at most $MAX_DESCRIPTION_LENGTH characters and no control characters"
|
||||
PROJECT[description]="$REPLY"
|
||||
prompt_choice "Visibility" private private public
|
||||
PROJECT[visibility]="$REPLY"
|
||||
prompt_value "Gitea owner (user or organization)" "" is_valid_gitea_owner \
|
||||
"use letters, digits, '.', '_' or '-' (at most 39)"
|
||||
PROJECT[gitea_owner]="$REPLY"
|
||||
prompt_yes_no "Also create a GitHub repository (applies the AGPL license)" y
|
||||
PROJECT[use_github]="$REPLY"
|
||||
PROJECT[github_owner]=""
|
||||
if ((PROJECT[use_github])); then
|
||||
prompt_value "GitHub owner (user or organization)" \
|
||||
"${CREDENTIALS[GITHUB_USER]:-}" is_valid_github_owner \
|
||||
"use letters, digits or '-' (at most 39)"
|
||||
PROJECT[github_owner]="$REPLY"
|
||||
fi
|
||||
prompt_value "Local directory" "./${PROJECT[name]}" is_valid_directory \
|
||||
"must not be empty, start with '-' or contain control characters"
|
||||
PROJECT[directory]="$REPLY"
|
||||
prompt_yes_no "Enable the plan gate" n
|
||||
PROJECT[plan_gate]="$REPLY"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------- summary
|
||||
|
||||
yes_no() {
|
||||
if (($1)); then
|
||||
printf 'yes'
|
||||
else
|
||||
printf 'no'
|
||||
fi
|
||||
}
|
||||
|
||||
credential_state() {
|
||||
if [[ -n ${CREDENTIALS[$1]:-} ]]; then
|
||||
printf 'set'
|
||||
else
|
||||
printf 'not set'
|
||||
fi
|
||||
}
|
||||
|
||||
print_summary() {
|
||||
say ""
|
||||
say "$PROJECT_NAME $VERSION: nothing has been created yet."
|
||||
say "Collected details:"
|
||||
say " Repository : ${PROJECT[name]} (${PROJECT[visibility]})"
|
||||
say " Description : ${PROJECT[description]:-(none)}"
|
||||
say " Gitea : ${CONFIG[GITEA_URL]}/${PROJECT[gitea_owner]}/${PROJECT[name]}"
|
||||
if ((PROJECT[use_github])); then
|
||||
say " GitHub : ${CONFIG[GITHUB_WEB_URL]}/${PROJECT[github_owner]}/${PROJECT[name]} (AGPL license applied)"
|
||||
else
|
||||
say " GitHub : not used"
|
||||
fi
|
||||
say " Directory : ${PROJECT[directory]}"
|
||||
say " Plan gate : $(yes_no "${PROJECT[plan_gate]}")"
|
||||
say "Credentials : GITEA_TOKEN $(credential_state GITEA_TOKEN)," \
|
||||
"GITHUB_PAT $(credential_state GITHUB_PAT)"
|
||||
say "Creating the repositories and the project comes in later phases."
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- main
|
||||
|
||||
parse_args() {
|
||||
while (($# > 0)); do
|
||||
case "$1" in
|
||||
--config)
|
||||
(($# >= 2)) || usage_error "--config needs a file"
|
||||
CONFIG_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--env)
|
||||
(($# >= 2)) || usage_error "--env needs a file"
|
||||
ENV_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h | --help)
|
||||
sed -n '2,/^set -Eeuo/p' "${BASH_SOURCE[0]}" | sed -e '$d' -e 's/^# \{0,1\}//'
|
||||
exit 0
|
||||
;;
|
||||
--version)
|
||||
say "$PROJECT_NAME $VERSION"
|
||||
exit 0
|
||||
;;
|
||||
*) usage_error "unknown option: $1" ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
main() {
|
||||
trap 'on_error "$LINENO"' ERR
|
||||
trap cleanup EXIT
|
||||
is_valid_repo_name "$PROJECT_NAME" ||
|
||||
die "REPOFOUNDRY_NAME is not a valid project name"
|
||||
parse_args "$@"
|
||||
check_tools
|
||||
setup_temp_dir
|
||||
load_configuration
|
||||
collect_project_details
|
||||
if ((PROJECT[use_github])); then
|
||||
require_github_credentials
|
||||
fi
|
||||
print_summary
|
||||
}
|
||||
|
||||
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
|
||||
main "$@"
|
||||
fi
|
||||
+162
@@ -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")"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user