#!/usr/bin/env bash # Exercises the allow/deny list helpers and the google-auth:allow / :unallow / # :deny / :undeny subcommands against a fake dokku layout — both the global # scope and per-app scopes — and checks that global lists reach the env file # while per-app lists land where the container's bind mount expects them. set -eo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" WORK="$(mktemp -d)" trap 'rm -rf "$WORK"' EXIT # Fake dokku host layout, with two apps that exist as far as dokku is concerned. export DOKKU_ROOT="$WORK/dokku-root" export DOKKU_LIB_ROOT="$WORK/dokku-lib" export PLUGIN_CORE_AVAILABLE_PATH="$WORK/nonexistent" # force built-in fallbacks DATA="$DOKKU_LIB_ROOT/data/google-auth" GLOBAL="$DATA/global" mkdir -p "$GLOBAL" "$DOKKU_ROOT/my-app" "$DOKKU_ROOT/other-app" # Stub docker so the subcommands see the service as not running and never touch # a real container. This test is about list handling, not container management. mkdir -p "$WORK/bin" printf '#!/bin/sh\nexit 1\n' >"$WORK/bin/docker" chmod +x "$WORK/bin/docker" export PATH="$WORK/bin:$PATH" # shellcheck disable=SC1091 source "$ROOT/functions" fail() { echo "FAIL: $*" 1>&2 exit 1 } ga() { local sub="$1" shift "$ROOT/subcommands/$sub" "google-auth:$sub" "$@" } # expect_output [args...] — asserts the subcommand's # output (stdout + stderr) contains . Collects the output first rather # than piping into grep, which would SIGPIPE the writer under pipefail. expect_output() { local needle="$1" out shift out="$(ga "$@" 2>&1 || true)" grep -qF "$needle" <<<"$out" || fail "expected '$needle' in google-auth:$1 output, got: $out" } expect_fails() { local why="$1" shift ga "$@" >/dev/null 2>&1 && fail "$why" return 0 } # --- entry validation and classification --- fn-ga-validate-list-entry "guest@partner.com" || fail "address should be a valid entry" fn-ga-validate-list-entry "signal.org" || fail "domain should be a valid entry" fn-ga-validate-list-entry "a@b.com,c@d.com" && fail "comma should be rejected (it splits the env var)" fn-ga-validate-list-entry "not a domain" && fail "whitespace should be rejected" fn-ga-validate-list-entry "" && fail "empty entry should be rejected" [[ "$(fn-ga-allow-entry-kind "signal.org")" == "domain" ]] || fail "bare domain misclassified" [[ "$(fn-ga-allow-entry-kind "@signal.org")" == "domain" ]] || fail "@domain misclassified" [[ "$(fn-ga-allow-entry-kind "guest@partner.com")" == "email" ]] || fail "address misclassified" echo "ok: entry validation and classification" # --- list helpers, in both scopes --- for scope in global my-app; do fn-ga-list-add "$scope" allowed-emails "a@x.com" fn-ga-list-add "$scope" allowed-emails "a@x.com" # idempotent [[ "$(fn-ga-list-count "$scope" allowed-emails)" -eq 1 ]] || fail "$scope: duplicate add should be a no-op" fn-ga-list-contains "$scope" allowed-emails "a@x.com" || fail "$scope: contains should find the entry" fn-ga-list-contains "$scope" allowed-emails "a@x.co" && fail "$scope: contains should match whole lines only" fn-ga-list-remove "$scope" allowed-emails "a@x.com" [[ "$(fn-ga-list-count "$scope" allowed-emails)" -eq 0 ]] || fail "$scope: remove should empty the list" fn-ga-list-remove "$scope" allowed-emails "nope@x.com" # missing entry is not an error done [[ "$(fn-ga-list-file global allowed-emails)" == "$GLOBAL/allowed-emails" ]] || fail "global list path wrong" [[ "$(fn-ga-list-file my-app allowed-emails)" == "$DATA/apps/my-app/allowed-emails" ]] || fail "per-app list path wrong" echo "ok: list helpers in both scopes" # --- scope resolution --- expect_fails "a missing scope should be rejected" allow expect_fails "an unknown flag should be rejected" allow --oops x expect_fails "an app that does not exist should be rejected" allow ghost-app a@b.com expect_output "unknown flag" allow --oops x expect_output "does not exist" allow ghost-app a@b.com echo "ok: scope resolution" # --- global allow --- ga allow --global Signal.org @Example.com Guest@Partner.com >/dev/null grep -qx "signal.org" "$GLOBAL/allowed-domains" || fail "bare domain should be stored lowercased" grep -qx "example.com" "$GLOBAL/allowed-domains" || fail "@domain should be stored without the @" grep -qx "guest@partner.com" "$GLOBAL/allowed-emails" || fail "address should be stored lowercased" expect_fails "allow should reject an entry with a comma" allow --global "a@b.com,c@d.com" expect_fails "allow should reject a domain with no dot" allow --global localhost [[ "$(fn-ga-list-count global allowed-domains)" -eq 2 ]] || fail "rejected entries should not be stored" echo "ok: global allow" # --- per-app allow lives in the app's own directory and warns about the switch --- expect_output "no longer uses the global allow list" allow my-app ceo@signal.org grep -qx "ceo@signal.org" "$DATA/apps/my-app/allowed-emails" || fail "per-app entry should be stored under apps/" grep -qx "ceo@signal.org" "$GLOBAL/allowed-emails" && fail "a per-app entry must not touch the global list" expect_output "(none — other-app uses the global allow list)" allow other-app # The warning is only for the first entry, when the app stops inheriting. out="$(ga allow my-app cto@signal.org 2>&1)" grep -qF "no longer uses the global allow list" <<<"$out" && fail "the inheritance warning should only fire on the first entry" echo "ok: per-app allow" # --- the service must be able to read per-app lists through its bind mount --- [[ "$(stat -c '%a' "$DATA/apps")" == "711" ]] || fail "apps/ must be traversable by the container uid" [[ "$(stat -c '%a' "$DATA/apps/my-app")" == "711" ]] || fail "apps// must be traversable by the container uid" [[ "$(stat -c '%a' "$DATA/apps/my-app/allowed-emails")" == "644" ]] || fail "per-app lists must be readable by the container uid" [[ "$(stat -c '%a' "$GLOBAL/allowed-emails")" == "600" ]] || fail "global lists should stay 0600" echo "ok: per-app file modes" # --- deny is per scope, and a global denial cannot be lifted by an app --- ga deny --global former@signal.org >/dev/null ga deny my-app bob@signal.org >/dev/null grep -qx "bob@signal.org" "$DATA/apps/my-app/denied-emails" || fail "per-app denial should be stored under apps/" grep -qx "bob@signal.org" "$GLOBAL/denied-emails" && fail "a per-app denial must not touch the global list" expect_fails "deny should reject a bare domain" deny my-app signal.org # Listing an app's deny list also shows the global entries that apply to it. expect_output "former@signal.org (global)" deny my-app ga deny my-app former@signal.org >/dev/null expect_output "still denied globally" undeny my-app former@signal.org expect_output "denied globally, which no app can override" undeny other-app former@signal.org echo "ok: deny scoping" # --- allow warns when a deny list (either scope) will win --- expect_output "on a deny list, which wins" allow my-app bob@signal.org expect_output "on a deny list, which wins" allow --global former@signal.org echo "ok: deny-wins warnings" # --- undeny --- ga undeny my-app bob@signal.org >/dev/null fn-ga-list-contains my-app denied-emails "bob@signal.org" && fail "undeny should remove the address" expect_output "was not on the deny list" undeny my-app never@denied.com ga deny other-app stranger@elsewhere.com >/dev/null expect_output "still cannot sign in" undeny other-app stranger@elsewhere.com echo "ok: undeny" # --- unallow: the lockout guardrail is global-only --- ga unallow --global @Example.com >/dev/null fn-ga-list-contains global allowed-domains "example.com" && fail "unallow should remove the domain" expect_output "was not on the allow list" unallow --global absent@nowhere.com # Removing every remaining global entry (with a duplicate, to check # de-duplication) must be refused and change nothing. before="$(cat "$GLOBAL/allowed-domains" "$GLOBAL/allowed-emails")" expect_fails "unallow should refuse to empty the global allow list" \ unallow --global signal.org guest@partner.com former@signal.org SIGNAL.ORG [[ "$(cat "$GLOBAL/allowed-domains" "$GLOBAL/allowed-emails")" == "$before" ]] || fail "a refused unallow must leave the lists untouched" ga unallow --global guest@partner.com >/dev/null || fail "unallow should still remove a non-final entry" [[ "$(fn-ga-list-count global allowed-domains)" -eq 1 ]] || fail "signal.org should remain allowed" # Emptying an app's list is allowed: it falls back to the global one. expect_output "now uses the global allow list" \ unallow my-app ceo@signal.org cto@signal.org bob@signal.org [[ "$(fn-ga-list-count my-app allowed-emails)" -eq 0 ]] || fail "the app's allow list should be empty" [[ "$(fn-ga-list-count global allowed-domains)" -eq 1 ]] || fail "the global list must survive an app's unallow" echo "ok: unallow and lockout guardrail" # --- configure's replace-the-list flags still drive the GLOBAL lists --- "$ROOT/subcommands/configure" google-auth:configure \ --allow-domain signal.org --deny-email one@signal.org --deny-email two@signal.org >/dev/null 2>&1 || true [[ "$(fn-ga-list-count global denied-emails)" -eq 2 ]] || fail "--deny-email should replace the global deny list" "$ROOT/subcommands/configure" google-auth:configure --clear-deny-emails >/dev/null 2>&1 || true [[ "$(fn-ga-list-count global denied-emails)" -eq 0 ]] || fail "--clear-deny-emails should empty the global deny list" [[ "$(fn-ga-list-count my-app denied-emails)" -eq 0 ]] || fail "configure should not touch per-app lists" echo "ok: configure flags" # --- global lists reach the env file; per-app lists reach the mount --- # Start from a known set so the env file can be asserted exactly. ga unallow --global former@signal.org >/dev/null 2>&1 || true ga deny --global former@signal.org >/dev/null ga allow --global guest@partner.com >/dev/null ga allow my-app ceo@signal.org >/dev/null 2>&1 fn-ga-write-env-file ENV_FILE="$DATA/service.env" grep -qx "GOOGLE_AUTH_ALLOWED_DOMAINS=signal.org" "$ENV_FILE" || fail "env file missing global allowed domains" grep -qx "GOOGLE_AUTH_ALLOWED_EMAILS=guest@partner.com" "$ENV_FILE" || fail "env file missing global allowed emails" grep -qx "GOOGLE_AUTH_DENIED_EMAILS=former@signal.org" "$ENV_FILE" || fail "env file missing global denied emails" grep -qx "GOOGLE_AUTH_APP_CONFIG_DIR=/data/apps" "$ENV_FILE" || fail "env file must point the service at the mount" grep -q "ceo@signal.org" "$ENV_FILE" && fail "per-app entries must not be baked into the env file" echo "ok: service env file" # --- report shows both scopes, enabled or not --- out="$("$ROOT/subcommands/report" google-auth:report my-app)" grep -qF "ceo@signal.org" <<<"$out" || fail "report should show the app's allow entries: $out" grep -qF "replaces the global allow list" <<<"$out" || fail "report should say the app's list replaces global: $out" out="$("$ROOT/subcommands/report" google-auth:report other-app)" grep -qF "inherits the global allow list" <<<"$out" || fail "report should say an app inherits: $out" echo "ok: report" # --- setting an app's list refreshes its nginx config --- # Per-app lists depend on nginx stamping the app name, so a config written # before that header existed has to be rewritten; otherwise the app would # silently fall back to the global lists. cat >"$DOKKU_ROOT/my-app/nginx.conf" <<'EOF' upstream my-app-5000 { server 172.17.0.3:5000; } EOF fn-ga-app-set-enabled my-app true APP_CONF="$DOKKU_ROOT/my-app/nginx.conf.d/google-auth.conf" mkdir -p "$(dirname "$APP_CONF")" echo "# stale config from an older plugin version" >"$APP_CONF" ga allow my-app auditor@signal.org >/dev/null 2>&1 grep -q 'proxy_set_header X-Google-Auth-App "my-app";' "$APP_CONF" || fail "changing an app's list should rewrite its nginx config to stamp the app name" echo "ok: per-app list change refreshes the nginx config" # --- a running service only counts if it can really read per-app lists --- # It takes both the bind mount and the env var naming it. A container recreated # from a service.env written before per-app lists existed has the mount but not # the variable, ignores every per-app list, and still looks healthy — so the # check must not be satisfied by the mount alone. mkdir -p "$WORK/bin-docker" cat >"$WORK/bin-docker/docker" <<'EOF' #!/bin/sh # Stands in for `docker container inspect`, replaying a canned inspection. if [ "$1" = "container" ] && [ "$2" = "inspect" ]; then cat "$DOCKER_INSPECT_FIXTURE" exit 0 fi exit 0 EOF chmod +x "$WORK/bin-docker/docker" # Runs the check against one canned inspection, in a subshell so the stub and # its fixture do not leak into the rest of the file. reads_app_lists() ( export DOCKER_INSPECT_FIXTURE="$WORK/inspect-fixture" printf '%s\n' "$@" >"$DOCKER_INSPECT_FIXTURE" PATH="$WORK/bin-docker:$PATH" fn-ga-service-reads-app-lists ) reads_app_lists "mount=/data/apps" "env=GOOGLE_AUTH_APP_CONFIG_DIR=/data/apps" "env=GOOGLE_AUTH_CLIENT_ID=x" || fail "a container with both the mount and the env var should read per-app lists" reads_app_lists "mount=/data/apps" "env=GOOGLE_AUTH_CLIENT_ID=x" && fail "a container with the mount but no GOOGLE_AUTH_APP_CONFIG_DIR ignores per-app lists" reads_app_lists "env=GOOGLE_AUTH_APP_CONFIG_DIR=/data/apps" && fail "a container with the env var but no mount has nothing to read" reads_app_lists "" && fail "a container with neither should not count" echo "ok: per-app list readiness check" # --- lifecycle triggers carry per-app lists --- "$ROOT/post-app-rename" my-app renamed-app [[ ! -d "$DATA/apps/my-app" ]] || fail "rename should move the app's directory" fn-ga-list-contains renamed-app allowed-emails "ceo@signal.org" || fail "rename should keep the app's allow list" "$ROOT/post-app-clone" renamed-app clone-app fn-ga-list-contains clone-app allowed-emails "ceo@signal.org" || fail "clone should copy the app's allow list" "$ROOT/post-delete" clone-app [[ ! -d "$DATA/apps/clone-app" ]] || fail "delete should remove the app's directory" echo "ok: lifecycle triggers" echo "ALL ACCESS LIST TESTS PASSED"