diff --git a/.env.example b/.env.example index 56ec999..096e1ee 100644 --- a/.env.example +++ b/.env.example @@ -1,36 +1,32 @@ -# Assemblrr Environment Configuration -# This file documents all available environment variables. -# Actual values are generated by setup.sh using envsubst. +# Generated into INSTALL_DIR/.env by setup (envsubst). +# Credentials go under secrets/, not here. -# User/Group IDs — must match the user that owns the media files -PUID=1000 -PGID=1000 +# Host user/group that owns media files +PUID=${PUID} +PGID=${PGID} -# Paths (defaults are home-based; set during setup) -MEDIA_DIRECTORY=/home//assemblrr-media -INSTALL_DIRECTORY=/home//assemblrr +MEDIA_DIRECTORY=${MEDIA_DIRECTORY} +INSTALL_DIRECTORY=${INSTALL_DIRECTORY} -# Media service: jellyfin, emby, or plex -MEDIA_SERVICE=jellyfin +# jellyfin | emby | plex +MEDIA_SERVICE=${MEDIA_SERVICE} -# Timezone (IANA format) -TZ=UTC +# IANA timezone (e.g. Europe/Athens) +TZ=${TZ} -# VPN Configuration -VPN_ENABLED=n -VPN_SERVICE=protonvpn -VPN_TYPE=openvpn +# y/n — secrets under secrets/ when enabled +VPN_ENABLED=${VPN_ENABLED} +# Gluetun provider id (e.g. protonvpn) +VPN_SERVICE=${VPN_SERVICE} +# openvpn | wireguard +VPN_TYPE=${VPN_TYPE} -# OpenVPN credentials are stored in secrets/openvpn_user.txt and secrets/openvpn_password.txt -# WireGuard private key is stored in secrets/wireguard_private_key.txt -# These are NOT in .env — they are mounted as Docker secrets so docker inspect won't expose them. +# WireGuard only (not secret) +WIREGUARD_ADDRESSES=${WIREGUARD_ADDRESSES} -# WireGuard addresses (not secret, just an internal IP) -WIREGUARD_ADDRESSES= +# on/off — only if your provider supports it +PORT_FORWARD_ONLY=${PORT_FORWARD_ONLY} +VPN_PORT_FORWARDING=${VPN_PORT_FORWARDING} -# Port forwarding (only supported by some VPN providers) -PORT_FORWARD_ONLY=off -VPN_PORT_FORWARDING=off - -# API Host for configuration script (advanced users) -API_HOST=127.0.0.1 +# configure.sh API host +API_HOST=${API_HOST} diff --git a/Makefile b/Makefile index cb021f6..d7c6fcc 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,3 @@ -# Assemblrr developer targets .PHONY: test test-unit test-compose test-integration lint help help: diff --git a/README.md b/README.md index 41320e6..2b12a0d 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ A lightweight media server stack via Docker. Cross-platform, VPN-aware, and buil - **Subtitles:** Bazarr - **Update Notifications:** Watchtower -These are deployed only — no auto-configuration like the core stack. Wire them up in each service’s UI after enabling. +Deployed only — not auto-configured. Set them up in each service’s UI. ## Installation @@ -90,10 +90,11 @@ See [docs/uninstall.md](docs/uninstall.md) for the complete removal guide, inclu Configuration lives in your installation directory (default: `~/assemblrr`). -- `.env`: Environment overrides (ASSEMBLRR_HOST for remote proxying, PUID/PGID, API variables). -- `compose/base.yaml`: Immutable core services. -- `compose/vpn.yaml`: Gluetun routing overlays. -- `config/`: Persistent data safe from container teardowns. +- `.env`: Generated at install from [`.env.example`](.env.example) (all variables are documented there). Credentials live under `secrets/`, not in `.env`. +- `compose/base.yaml`: Core services. +- `compose/vpn.yaml` / `compose/direct-access.yaml`: Networking overlays. +- `compose/custom.yaml`: Optional services (from the example under `compose/examples/`). +- `config/`: Persistent service data. ## Development diff --git a/bin/setup.sh b/bin/setup.sh index c1f15d5..2d35bdf 100644 --- a/bin/setup.sh +++ b/bin/setup.sh @@ -238,7 +238,7 @@ copy_configuration_files() { ["compose/direct-access.yaml"]="compose/direct-access.yaml" ["compose/vpn.yaml"]="compose/vpn.yaml" ["compose/examples/custom.yaml.example"]="compose/examples/custom.yaml.example" - ["templates/envsubst.env"]="templates/envsubst.env" + [".env.example"]=".env.example" ["templates/recyclarr-ultra_hd.yml"]="templates/recyclarr-ultra_hd.yml" ["templates/recyclarr-full_hd.yml"]="templates/recyclarr-full_hd.yml" ["branding.conf"]="branding.conf" @@ -322,8 +322,13 @@ generate_env_file() { export PORT_FORWARD_ONLY VPN_PORT_FORWARDING export API_HOST - # Generate .env from template using envsubst - envsubst < "$APP_ROOT/templates/envsubst.env" > "$env_file" || \ + local env_template + if ! env_template=$(resolve_project_file ".env.example"); then + log_error "Failed to locate .env.example" + fi + # Explicit key list so envsubst does not pull in unrelated shell variables + envsubst '${PUID} ${PGID} ${MEDIA_DIRECTORY} ${INSTALL_DIRECTORY} ${MEDIA_SERVICE} ${TZ} ${VPN_ENABLED} ${VPN_SERVICE} ${VPN_TYPE} ${WIREGUARD_ADDRESSES} ${PORT_FORWARD_ONLY} ${VPN_PORT_FORWARDING} ${API_HOST}' \ + < "$env_template" > "$env_file" || \ log_error "Failed to generate .env file" chmod 600 "$env_file" diff --git a/platform/windows/bootstrap-dev.ps1 b/platform/windows/bootstrap-dev.ps1 index 02bb03c..4f373a6 100644 --- a/platform/windows/bootstrap-dev.ps1 +++ b/platform/windows/bootstrap-dev.ps1 @@ -249,7 +249,8 @@ cp /tmp/$AppName/lib/*.sh "`$INSTALL_DIR/lib/" cp /tmp/$AppName/compose/base.yaml /tmp/$AppName/compose/vpn.yaml /tmp/$AppName/compose/direct-access.yaml "`$INSTALL_DIR/compose/" mkdir -p "`$INSTALL_DIR/compose/examples" cp /tmp/$AppName/compose/examples/custom.yaml.example "`$INSTALL_DIR/compose/examples/" 2>/dev/null || true -cp /tmp/$AppName/templates/envsubst.env /tmp/$AppName/templates/recyclarr-full_hd.yml /tmp/$AppName/templates/recyclarr-ultra_hd.yml "`$INSTALL_DIR/templates/" +cp /tmp/$AppName/.env.example "`$INSTALL_DIR/" 2>/dev/null || true +cp /tmp/$AppName/templates/recyclarr-full_hd.yml /tmp/$AppName/templates/recyclarr-ultra_hd.yml "`$INSTALL_DIR/templates/" mkdir -p "`$INSTALL_DIR/scripts" cp /tmp/$AppName/scripts/* "`$INSTALL_DIR/scripts/" echo "Files synced to install directory" diff --git a/templates/envsubst.env b/templates/envsubst.env deleted file mode 100644 index 2c8ab12..0000000 --- a/templates/envsubst.env +++ /dev/null @@ -1,19 +0,0 @@ -# Base configuration -PUID=${PUID} -PGID=${PGID} -MEDIA_DIRECTORY=${MEDIA_DIRECTORY} -INSTALL_DIRECTORY=${INSTALL_DIRECTORY} -MEDIA_SERVICE=${MEDIA_SERVICE} -TZ=${TZ} - -# VPN configuration -VPN_ENABLED=${VPN_ENABLED} -VPN_SERVICE=${VPN_SERVICE} -VPN_TYPE=${VPN_TYPE} -WIREGUARD_ADDRESSES=${WIREGUARD_ADDRESSES} -PORT_FORWARD_ONLY=${PORT_FORWARD_ONLY} -VPN_PORT_FORWARDING=${VPN_PORT_FORWARDING} - -# API Host for configuration script -API_HOST=${API_HOST} - diff --git a/tests/README.md b/tests/README.md index f5a40e5..515c826 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,55 +1,20 @@ # Tests -Runtime scripts live in `scripts/` (installed with the stack). **All tests live here.** - -## Layout - -```text -tests/ - helpers.sh # assertions, install discovery, qBittorrent helpers - run.sh # entrypoint - unit/ - test_compose.sh # lib/compose.sh - test_core.sh # expand_path, safe_source, rm guards - test_vpn_secrets.sh # validate_vpn_secrets - test_compose_config.sh # docker compose config (needs Docker) - integration/ - watchdog_stall.sh # live qBittorrent + vpn-watchdog (opt-in) -``` - -## Quick start +Runtime hooks stay in `scripts/`. Tests live here. ```bash -# From repo root -./tests/run.sh # unit tests (no Docker required) -./tests/run.sh compose # docker compose config validation +./tests/run.sh # unit +./tests/run.sh compose # docker compose config ./tests/run.sh all # unit + compose + shellcheck (if installed) -make test # same as ./tests/run.sh all +make test ``` -## Live integration (watchdog) +## Live integration -Mutates a **running** qBittorrent (sets `max_connecs=0`, adds a public Ubuntu magnet, runs `vpn-watchdog.sh`). Always restores settings on exit. +Hits a running qBittorrent (stalls connections, runs `vpn-watchdog`). Opt-in only: ```bash ASSEMBLRR_ALLOW_LIVE_TEST=1 ./tests/run.sh integration - -# Or point at a non-default install: -ASSEMBLRR_DIR=~/assemblrr ASSEMBLRR_ALLOW_LIVE_TEST=1 \ - ./tests/integration/watchdog_stall.sh ``` -Without `ASSEMBLRR_ALLOW_LIVE_TEST=1` the script exits with code 2 and does nothing. - -Config resolution order: - -1. `ASSEMBLRR_DIR` -2. `ASSEMBLRR_CONFIG` (path to `.assemblrr-config`) -3. `find_install_directory` from `lib/core.sh` -4. `$HOME/assemblrr` - -## Notes - -- Unit tests use a tiny bash assertion helper (no BATS dependency). -- Compose checks create a temporary fixture with dummy secret files so `docker compose config` succeeds. -- Install [shellcheck](https://www.shellcheck.net/) for `./tests/run.sh lint`. +Install dir: `ASSEMBLRR_DIR`, `ASSEMBLRR_CONFIG`, then `~/assemblrr`. diff --git a/tests/helpers.sh b/tests/helpers.sh index 4db3eb9..a76e503 100644 --- a/tests/helpers.sh +++ b/tests/helpers.sh @@ -1,13 +1,9 @@ #!/bin/bash -# Shared helpers for Assemblrr unit and integration tests. # shellcheck shell=bash -# Repo root (parent of tests/) TESTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$TESTS_DIR/.." && pwd)" -# --- Assertion helpers --- - _TEST_FAILURES=0 _TEST_PASSES=0 _TEST_CURRENT="" @@ -124,8 +120,7 @@ assert_false() { return 0 } -# Soft log_error for unit tests — core.sh's log_error exits the process. -# Call after sourcing lib/core.sh so we can exercise guard paths. +# core.sh log_error exits; unit tests need a non-fatal stub stub_log_error_no_exit() { log_error() { echo -e "${RED:-}$1${NC:-}" >&2 @@ -145,10 +140,6 @@ test_summary() { return 0 } -# --- Install / config discovery (integration) --- - -# Resolve Assemblrr install directory. -# Priority: ASSEMBLRR_DIR, ASSEMBLRR_CONFIG parent, find_install_directory, ~/assemblrr resolve_install_dir() { if [ -n "${ASSEMBLRR_DIR:-}" ]; then echo "$ASSEMBLRR_DIR" @@ -195,8 +186,6 @@ load_assemblrr_config() { source "$config_file" } -# --- qBittorrent API helpers (integration) --- - QBIT_HOST="${QBIT_HOST:-127.0.0.1:8081}" QBIT_API="http://${QBIT_HOST}/api/v2" @@ -219,7 +208,6 @@ qbit_api() { curl -s -b "$cookie_jar" -H "Referer: http://${QBIT_HOST}" "$@" } -# Create a temporary install-like tree for compose validation. make_compose_fixture() { local dest="$1" mkdir -p "$dest/compose" "$dest/config" "$dest/scripts" "$dest/secrets" "$dest/media" @@ -228,7 +216,7 @@ make_compose_fixture() { mkdir -p "$dest/compose/examples" cp "$REPO_ROOT/compose/examples/"* "$dest/compose/examples/" 2>/dev/null || true fi - # Secret files must exist for `docker compose config` with vpn overlay + # vpn overlay expects these files to exist : >"$dest/secrets/openvpn_user.txt" : >"$dest/secrets/openvpn_password.txt" : >"$dest/secrets/wireguard_private_key.txt" diff --git a/tests/integration/watchdog_stall.sh b/tests/integration/watchdog_stall.sh index eab952d..28546e8 100755 --- a/tests/integration/watchdog_stall.sh +++ b/tests/integration/watchdog_stall.sh @@ -1,20 +1,13 @@ #!/bin/bash -# Live integration test: stall qBittorrent connections and verify vpn-watchdog reacts. -# -# SAFETY: mutates a running qBittorrent instance (max connections → 0, test torrent). -# Requires ASSEMBLRR_ALLOW_LIVE_TEST=1 and a configured Assemblrr install. -# -# Usage: -# ASSEMBLRR_ALLOW_LIVE_TEST=1 ./tests/integration/watchdog_stall.sh -# ASSEMBLRR_DIR=~/assemblrr ASSEMBLRR_ALLOW_LIVE_TEST=1 ./tests/integration/watchdog_stall.sh -# +# Live test: stall qBittorrent and check vpn-watchdog. +# Mutates a real install — requires ASSEMBLRR_ALLOW_LIVE_TEST=1. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck disable=SC1091 source "$SCRIPT_DIR/../helpers.sh" -# Ubuntu 22.04.4 desktop ISO infohash + public trackers (no content hosted by us) +# Ubuntu 22.04 desktop infohash + public trackers TARGET_HASH="3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0" MAGNET="magnet:?xt=urn:btih:${TARGET_HASH}&dn=ubuntu-22.04.4-desktop-amd64.iso&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337%2Fannounce&tr=udp%3A%2F%2Ftracker.openbittorrent.com%3A6969%2Fannounce" @@ -46,7 +39,6 @@ EOF fi } -# Fail fast before any trap/cleanup noise require_live_opt_in cleanup() { @@ -78,7 +70,6 @@ resolve_watchdog_script() { return 1 } -# Watchdog in containers reads /run/secrets; for host runs, stage secrets or env. prepare_watchdog_env() { local install_dir="$1" local secrets="$install_dir/secrets" @@ -87,7 +78,6 @@ prepare_watchdog_env() { export AUTH_PASSWORD_FILE="$secrets/auth_password.txt" return 0 fi - # Fallback: materialize from config credentials into temp secret files if [ -n "${AUTH_USERNAME:-}" ] && [ -n "${AUTH_PASSWORD:-}" ]; then local staged staged=$(mktemp -d) @@ -117,7 +107,6 @@ install_dir=$(resolve_install_dir) || die "Could not resolve install directory. load_assemblrr_config "$install_dir" || die "Could not load config from $install_dir" if [ -z "${AUTH_USERNAME:-}" ] || [ -z "${AUTH_PASSWORD:-}" ]; then - # Try secret files if [ -f "$install_dir/secrets/auth_username.txt" ]; then AUTH_USERNAME=$(cat "$install_dir/secrets/auth_username.txt") AUTH_PASSWORD=$(cat "$install_dir/secrets/auth_password.txt") @@ -152,7 +141,6 @@ echo " $(torrent_summary || echo 'torrent not listed yet')" echo "[4] Sabotaging max_connecs=0..." qbit_api "$COOKIE_JAR" -d 'json={"max_connecs": 0}' "${QBIT_API}/app/setPreferences" >/dev/null -# Bounce torrent to drop existing peer connections quickly qbit_api "$COOKIE_JAR" -d "hashes=${TARGET_HASH}" "${QBIT_API}/torrents/stop" >/dev/null 2>&1 || true sleep 2 qbit_api "$COOKIE_JAR" -d "hashes=${TARGET_HASH}" "${QBIT_API}/torrents/start" >/dev/null 2>&1 || true @@ -176,19 +164,15 @@ failures=0 if [ "$watchdog_rc" -ne 0 ]; then echo " PASS: watchdog exited non-zero (rc=$watchdog_rc) — expected under stall" else - # Watchdog only restarts gluetun when specific tracker-failure symptoms match. - # If symptoms are not severe enough yet, surface a soft failure with diagnostics. if echo "$watchdog_out" | grep -qiE 'DETECTED|routing failure|VPN routing'; then echo " PASS: watchdog reported routing failure" else echo " FAIL: watchdog exited 0 without a detection message" - echo " Symptoms may not have matched (needs has_metadata=false, 0 connections," - echo " and ≥3 failed trackers with Operation not permitted / timed out / Host not found)." + echo " (needs stalled torrent + failed trackers — see vpn-watchdog.sh)" failures=$((failures + 1)) fi fi -# API still reachable after watchdog if qbit_api "$COOKIE_JAR" "${QBIT_API}/app/version" >/dev/null 2>&1 \ || qbit_login "$COOKIE_JAR" "$AUTH_USERNAME" "$AUTH_PASSWORD"; then echo " PASS: qBittorrent API still reachable after watchdog" diff --git a/tests/run.sh b/tests/run.sh index 3f4b149..b121cba 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -1,14 +1,5 @@ #!/bin/bash -# Assemblrr test runner -# -# Usage: -# ./tests/run.sh # unit tests only -# ./tests/run.sh unit -# ./tests/run.sh compose # docker compose config checks -# ./tests/run.sh integration # live stack (requires ASSEMBLRR_ALLOW_LIVE_TEST=1) -# ./tests/run.sh all # unit + compose (not live integration) -# ./tests/run.sh lint # shellcheck if installed -# +# Usage: ./tests/run.sh [unit|compose|integration|lint|all] set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -44,10 +35,10 @@ run_scripts() { run_unit() { echo "Running unit tests..." - # Pure unit tests first (no docker required) for f in \ "$SCRIPT_DIR/unit/test_compose.sh" \ "$SCRIPT_DIR/unit/test_core.sh" \ + "$SCRIPT_DIR/unit/test_env_example.sh" \ "$SCRIPT_DIR/unit/test_vpn_secrets.sh" do echo "" @@ -101,7 +92,6 @@ run_lint() { return 0 fi - # SC1091: sourced paths resolved at runtime; SC2034: libs export for callers if shellcheck -x -e SC1091,SC2034 "${files[@]}"; then echo "shellcheck: OK" else diff --git a/tests/unit/test_compose_config.sh b/tests/unit/test_compose_config.sh index 5228cad..718cbb4 100755 --- a/tests/unit/test_compose_config.sh +++ b/tests/unit/test_compose_config.sh @@ -44,10 +44,8 @@ run_config "base + vpn" \ -f "$tmp/compose/base.yaml" \ -f "$tmp/compose/vpn.yaml" -# examples/custom.yaml.example is documentation (all services commented), so -# `services:` is empty and invalid for compose. Test a minimal real overlay instead. +# example custom.yaml has only commented services; use a valid empty overlay cat >"$tmp/compose/custom.yaml" <<'EOF' -# Minimal custom overlay for config validation services: {} EOF run_config "base + direct-access + custom overlay" \ diff --git a/tests/unit/test_env_example.sh b/tests/unit/test_env_example.sh new file mode 100755 index 0000000..c5c47f6 --- /dev/null +++ b/tests/unit/test_env_example.sh @@ -0,0 +1,111 @@ +#!/bin/bash +# .env.example stays aligned with setup's envsubst key list and actually substitutes +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/../helpers.sh" + +example="$REPO_ROOT/.env.example" +setup="$REPO_ROOT/bin/setup.sh" + +test_suite ".env.example exists" +assert_true "repo has .env.example" "[ -f \"$example\" ]" +assert_true "setup.sh exists" "[ -f \"$setup\" ]" + +mapfile -t template_keys < <( + grep -E '^[A-Za-z_][A-Za-z0-9_]*=\$\{[A-Za-z_][A-Za-z0-9_]*\}$' "$example" \ + | sed 's/=.*//' \ + | sort -u +) + +test_suite "template has expected keys" +if [ "${#template_keys[@]}" -gt 0 ]; then + _TEST_PASSES=$((_TEST_PASSES + 1)) + echo " PASS: found ${#template_keys[@]} KEY=\${KEY} lines" +else + _TEST_FAILURES=$((_TEST_FAILURES + 1)) + echo " FAIL: no KEY=\${KEY} lines in .env.example" +fi + +# First envsubst '...' argument in setup (literal key list) +envsubst_arg=$( + grep -E "envsubst '" "$setup" | head -1 \ + | sed -n "s/.*envsubst '\\([^']*\\)'.*/\\1/p" +) + +test_suite "setup envsubst list" +if [ -n "$envsubst_arg" ]; then + _TEST_PASSES=$((_TEST_PASSES + 1)) + echo " PASS: found envsubst key list in setup.sh" +else + _TEST_FAILURES=$((_TEST_FAILURES + 1)) + echo " FAIL: no envsubst '...' list in setup.sh" + test_summary + exit 1 +fi + +mapfile -t envsubst_keys < <( + # Parse ${NAME} tokens without expanding them + printf '%s\n' "$envsubst_arg" \ + | grep -oE '\$\{[A-Za-z_][A-Za-z0-9_]*\}' \ + | sed 's/^\${//;s/}$//' \ + | sort -u +) + +test_suite "template keys match envsubst list" +for key in "${template_keys[@]}"; do + if printf '%s\n' "${envsubst_keys[@]}" | grep -qx "$key"; then + _TEST_PASSES=$((_TEST_PASSES + 1)) + echo " PASS: $key in envsubst list" + else + _TEST_FAILURES=$((_TEST_FAILURES + 1)) + echo " FAIL: $key missing from setup envsubst list" + fi +done + +for key in "${envsubst_keys[@]}"; do + if printf '%s\n' "${template_keys[@]}" | grep -qx "$key"; then + _TEST_PASSES=$((_TEST_PASSES + 1)) + echo " PASS: $key present in .env.example" + else + _TEST_FAILURES=$((_TEST_FAILURES + 1)) + echo " FAIL: envsubst lists $key but .env.example has no matching assignment" + fi +done + +test_suite "envsubst leaves no placeholders" +tmp=$(mktemp) +trap 'rm -f "$tmp"' EXIT + +export PUID=1000 PGID=1000 \ + MEDIA_DIRECTORY=/data/media INSTALL_DIRECTORY=/opt/assemblrr \ + MEDIA_SERVICE=jellyfin TZ=UTC \ + VPN_ENABLED=n VPN_SERVICE= VPN_TYPE=openvpn \ + WIREGUARD_ADDRESSES= \ + PORT_FORWARD_ONLY=off VPN_PORT_FORWARDING=off \ + API_HOST=127.0.0.1 + +envsubst "$envsubst_arg" < "$example" > "$tmp" + +if grep -E '\$\{[A-Za-z_][A-Za-z0-9_]*\}' "$tmp" >/dev/null; then + _TEST_FAILURES=$((_TEST_FAILURES + 1)) + echo " FAIL: unsubstituted placeholders remain:" + grep -E '\$\{[A-Za-z_][A-Za-z0-9_]*\}' "$tmp" | sed 's/^/ /' || true +else + _TEST_PASSES=$((_TEST_PASSES + 1)) + echo " PASS: no leftover placeholders" +fi + +for key in PUID INSTALL_DIRECTORY MEDIA_SERVICE API_HOST; do + val=$(grep -E "^${key}=" "$tmp" | head -1 | cut -d= -f2- || true) + if [ -n "$val" ]; then + _TEST_PASSES=$((_TEST_PASSES + 1)) + echo " PASS: $key set ($val)" + else + _TEST_FAILURES=$((_TEST_FAILURES + 1)) + echo " FAIL: $key empty or missing in generated .env" + fi +done + +test_summary