#!/usr/bin/env bash
# Regression harness for `git-credential-mosaic` — per-agent Gitea identity
# resolution (Gate-16 author≠reviewer separation) and fail-closed refusal.
#
# Covers:
#   1. Identity resolution priority: MOSAIC_GIT_IDENTITY env > git config
#      mosaic.gitIdentity (per-worktree) > git-supplied username.
#   2. Correct token file path chosen per host
#      (gitea-usc-<id>.token vs gitea-mosaicstack-<id>.token).
#   3. Credential store selection: an identity with a directory under
#      <brain>/fleet/agents/ is a SEAT and is read ONLY from its own secrets/
#      slot; any other identity is a SERVICE and is read from the framework
#      store. No precedence between them and NO fallback from one to the other.
#   4. Fail-closed: an identity that resolves but has no credential is REFUSED —
#      no output, nonzero exit, a stderr diagnostic, and a durable spool record.
#      The shared account is never emitted in its place.
#   5. Fail-closed: no identity resolvable on a host that runs a fleet is also
#      REFUSED, because records made there must name the agent that made them.
#   6. Backward compatibility, the one surviving fallback: no identity AND no
#      fleet -> shared account, unchanged. On such a host the shared account is
#      the operator's own and there is no attribution to lose.
#   7. Unknown/unrelated host -> exits 0 with no output (passthrough).
#   8. Non-"get" verb -> exits 0 with no output.
#
# Uses stubbed token files under a fake HOME + a real (throwaway) git repo.
# NEVER reads real secrets or touches the real ~/.config/mosaic/secrets.

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/git-credential-mosaic}"
FAKE_HOME="$WORK_DIR/home"
REPO_DIR="$WORK_DIR/repo"
BRAIN_DIR="$WORK_DIR/brain"
SPOOL_DIR="$WORK_DIR/spool"
SVC_STORE="$FAKE_HOME/.config/mosaic/secrets/gitea-tokens"
# Mirror the real deployed layout (~/.config/mosaic/tools/{git,_lib}/) under the
# fake HOME: git-credential-mosaic resolves its credentials.sh sibling via a
# script-relative path (BASH_SOURCE), so the copy must live next to a stubbed
# _lib/credentials.sh, not the real one, to keep this test hermetic.
HELPER="$FAKE_HOME/.config/mosaic/tools/git/git-credential-mosaic"

rm -rf "$WORK_DIR"
mkdir -p "$SVC_STORE" \
         "$FAKE_HOME/.config/mosaic/tools/git" \
         "$FAKE_HOME/.config/mosaic/tools/_lib" \
         "$REPO_DIR" "$BRAIN_DIR"

cp "$SCRIPT_DIR/git-credential-mosaic" "$HELPER"
chmod +x "$HELPER"

git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" config user.email "test@example.invalid"
git -C "$REPO_DIR" config user.name "Test"

# Fake shared-account credential loader — stands in for
# tools/_lib/credentials.sh's load_credentials(), scoped to this test only.
cat > "$FAKE_HOME/.config/mosaic/tools/_lib/credentials.sh" <<'SH'
load_credentials() {
  case "$1" in
    gitea-mosaicstack) GITEA_URL="https://git.mosaicstack.dev"; GITEA_TOKEN="shared-mosaicstack-token"; export GITEA_URL GITEA_TOKEN; return 0 ;;
    gitea-usc)         GITEA_URL="https://git.uscllc.com"; GITEA_TOKEN="shared-usc-token"; export GITEA_URL GITEA_TOKEN; return 0 ;;
    *) return 1 ;;
  esac
}
SH

fail=0
assert_eq() {
  local desc="$1" expected="$2" actual="$3"
  if [[ "$expected" != "$actual" ]]; then
    echo "FAIL: $desc — expected '$expected', got '$actual'" >&2
    fail=1
  fi
}

# Feed "host=<h>\nusername=<u>\n\n" on stdin (mirrors git's credential protocol)
# and run the helper with the fake HOME, inside REPO_DIR (so `git config
# mosaic.gitIdentity` resolves per-worktree), plus any extra env passed in $@.
run_helper() {
  local host="$1" username_in="$2"; shift 2
  (
    cd "$REPO_DIR"
    env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIAL_SPOOL="$SPOOL_DIR" "$@" \
      bash "$HELPER" get <<EOF
host=$host
username=$username_in

EOF
  )
}

# A refusal must be observable in four independent ways: nonzero exit, EMPTY
# stdout, a stderr diagnostic naming the identity and host, and — the assertion
# that actually catches a regression to the old behavior — NO shared token value
# anywhere in the output. Checking only the exit code would pass against a helper
# that emitted the shared credential and then exited 1.
assert_fail_closed() {
  local desc="$1" host="$2" username_in="$3" want_in_stderr="$4"; shift 4
  local stderr_file="$WORK_DIR/stderr.tmp"
  : > "$stderr_file"
  set +e
  local stdout
  stdout=$(run_helper "$host" "$username_in" "$@" 2>"$stderr_file")
  local rc=$?
  set -e
  local stderr
  stderr=$(cat "$stderr_file")
  if [[ "$rc" -eq 0 ]]; then
    echo "FAIL: $desc — expected nonzero exit, got 0 (stdout='$stdout')" >&2
    fail=1
  fi
  if [[ -n "$stdout" ]]; then
    echo "FAIL: $desc — expected empty stdout (nothing emitted), got '$stdout'" >&2
    fail=1
  fi
  if [[ "$stdout$stderr" == *"shared-mosaicstack-token"* || "$stdout$stderr" == *"shared-usc-token"* ]]; then
    echo "FAIL: $desc — a SHARED token value appeared in the output. The shared-account fallback must be gone:" >&2
    echo "$stdout$stderr" >&2
    fail=1
  fi
  if [[ -n "$want_in_stderr" && "$stderr" != *"$want_in_stderr"* ]]; then
    echo "FAIL: $desc — stderr does not contain '$want_in_stderr':" >&2
    echo "$stderr" >&2
    fail=1
  fi
  if [[ "$stderr" != *"$host"* ]]; then
    echo "FAIL: $desc — stderr does not name the host '$host':" >&2
    echo "$stderr" >&2
    fail=1
  fi
}

# ---------------------------------------------------------------------------
# 1. Backward compatibility: nothing resolvable, and NO fleet on this host ->
#    shared account, unchanged. This is the only surviving fallback.
# ---------------------------------------------------------------------------
git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
out=$(run_helper "git.mosaicstack.dev" "")
assert_eq "no identity + no fleet: username" "username=git" "$(echo "$out" | grep '^username=')"
assert_eq "no identity + no fleet: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"

# ---------------------------------------------------------------------------
# 2. git-supplied username resolves to a SERVICE identity WITH a token in the
#    framework store -> that identity + token wins over the shared account.
# ---------------------------------------------------------------------------
echo -n "agentA-mosaicstack-token" > "$SVC_STORE/gitea-mosaicstack-agentA.token"
out=$(run_helper "git.mosaicstack.dev" "agentA")
assert_eq "username-resolved identity: username" "username=agentA" "$(echo "$out" | grep '^username=')"
assert_eq "username-resolved identity: password" "password=agentA-mosaicstack-token" "$(echo "$out" | grep '^password=')"

# ---------------------------------------------------------------------------
# 3. git config mosaic.gitIdentity (per-worktree) beats git-supplied username.
# ---------------------------------------------------------------------------
echo -n "agentB-mosaicstack-token" > "$SVC_STORE/gitea-mosaicstack-agentB.token"
git -C "$REPO_DIR" config mosaic.gitIdentity agentB
out=$(run_helper "git.mosaicstack.dev" "agentA")
assert_eq "git-config beats username: username" "username=agentB" "$(echo "$out" | grep '^username=')"
assert_eq "git-config beats username: password" "password=agentB-mosaicstack-token" "$(echo "$out" | grep '^password=')"

# ---------------------------------------------------------------------------
# 4. MOSAIC_GIT_IDENTITY env beats git config mosaic.gitIdentity.
# ---------------------------------------------------------------------------
echo -n "agentC-mosaicstack-token" > "$SVC_STORE/gitea-mosaicstack-agentC.token"
out=$(run_helper "git.mosaicstack.dev" "agentA" MOSAIC_GIT_IDENTITY=agentC)
assert_eq "env beats git-config: username" "username=agentC" "$(echo "$out" | grep '^username=')"
assert_eq "env beats git-config: password" "password=agentC-mosaicstack-token" "$(echo "$out" | grep '^password=')"
git -C "$REPO_DIR" config --unset mosaic.gitIdentity

# ---------------------------------------------------------------------------
# 5. Correct token PATH is chosen per host: same agent id, different host
#    prefix (gitea-usc- vs gitea-mosaicstack-).
# ---------------------------------------------------------------------------
echo -n "agentD-usc-token" > "$SVC_STORE/gitea-usc-agentD.token"
out=$(run_helper "git.uscllc.com" "agentD")
assert_eq "host-scoped token path (usc): username" "username=agentD" "$(echo "$out" | grep '^username=')"
assert_eq "host-scoped token path (usc): password" "password=agentD-usc-token" "$(echo "$out" | grep '^password=')"

# ---------------------------------------------------------------------------
# 6. FAIL CLOSED — identity resolves, no credential for it on this host. Must
#    NOT borrow the shared account, and must NOT leak the same agent's token
#    for a DIFFERENT host (agentD holds a usc token and no mosaicstack one).
# ---------------------------------------------------------------------------
assert_fail_closed "cross-host absence refuses (no shared fallback, no cross-host leak)" \
  "git.mosaicstack.dev" "agentD" "gitea-mosaicstack-agentD.token"
# The agent's own usc token must not appear either.
: > "$WORK_DIR/stderr.tmp"
set +e
leak_out=$(run_helper "git.mosaicstack.dev" "agentD" 2>"$WORK_DIR/stderr.tmp")
set -e
if [[ "$leak_out$(cat "$WORK_DIR/stderr.tmp")" == *"agentD-usc-token"* ]]; then
  echo "FAIL: cross-host leak — the usc token value appeared on a mosaicstack request" >&2
  fail=1
fi

assert_fail_closed "unknown identity refuses (shared account never substituted)" \
  "git.mosaicstack.dev" "no-such-agent" "no-token-for-identity"

# ---------------------------------------------------------------------------
# 7. A refusal leaves a durable spool record, and that record contains no token.
#    The stderr diagnostic is transient; the record is what an operator reads
#    afterwards, so it must exist independently of anyone watching the terminal.
# ---------------------------------------------------------------------------
spool_file=$(find "$SPOOL_DIR" -maxdepth 1 -name '*.jsonl' | head -n 1)
if [[ -z "$spool_file" ]]; then
  echo "FAIL: fail-closed left no spool record under $SPOOL_DIR" >&2
  fail=1
else
  spool_body=$(cat "$spool_file")
  assert_eq "spool record names the refused identity" "1" \
    "$(grep -c '"identity":"no-such-agent"' "$spool_file" | head -n 1)"
  if [[ "$spool_body" == *"shared-"*"-token"* || "$spool_body" == *"agentD-usc-token"* ]]; then
    echo "FAIL: spool record contains a token value:" >&2
    echo "$spool_body" >&2
    fail=1
  fi
fi

# ---------------------------------------------------------------------------
# 8. SEAT identity: an id with a directory under <brain>/fleet/agents/ is read
#    from its OWN secrets/ slot, not from the framework store.
# ---------------------------------------------------------------------------
mkdir -p "$BRAIN_DIR/fleet/agents/seatE/secrets"
echo -n "seatE-slot-token" > "$BRAIN_DIR/fleet/agents/seatE/secrets/gitea-mosaicstack-seatE.token"
out=$(run_helper "git.mosaicstack.dev" "seatE" MOSAIC_BRAIN_HOME="$BRAIN_DIR")
assert_eq "seat reads its own slot: username" "username=seatE" "$(echo "$out" | grep '^username=')"
assert_eq "seat reads its own slot: password" "password=seatE-slot-token" "$(echo "$out" | grep '^password=')"

# ---------------------------------------------------------------------------
# 9. NO CROSS-STORE FALLBACK — the assertion this whole store-selection design
#    exists for. seatF is a seat (it has a directory) with an EMPTY slot, while
#    a framework-store token of the identical name is present and readable.
#    The helper must refuse rather than read it: one credential, one location,
#    and a seat that reads a same-named service credential is exactly the
#    silent-substitution failure the fail-closed rule removes.
# ---------------------------------------------------------------------------
mkdir -p "$BRAIN_DIR/fleet/agents/seatF/secrets"
echo -n "seatF-SERVICE-STORE-token" > "$SVC_STORE/gitea-mosaicstack-seatF.token"
assert_fail_closed "seat with empty slot does NOT fall back to the framework store" \
  "git.mosaicstack.dev" "seatF" "fleet/agents/seatF/secrets" MOSAIC_BRAIN_HOME="$BRAIN_DIR"
: > "$WORK_DIR/stderr.tmp"
set +e
xstore_out=$(run_helper "git.mosaicstack.dev" "seatF" MOSAIC_BRAIN_HOME="$BRAIN_DIR" 2>"$WORK_DIR/stderr.tmp")
set -e
if [[ "$xstore_out$(cat "$WORK_DIR/stderr.tmp")" == *"seatF-SERVICE-STORE-token"* ]]; then
  echo "FAIL: cross-store fallback — a seat read the framework store's same-named token" >&2
  fail=1
fi
# Control: that framework-store token IS readable, so the refusal above is the
# store rule firing and not an unreadable file. A non-seat identity pointed at
# the same file gets it.
out=$(run_helper "git.mosaicstack.dev" "seatF" MOSAIC_BRAIN_HOME="$WORK_DIR/no-such-brain")
assert_eq "control — same file IS readable for a non-seat identity" \
  "password=seatF-SERVICE-STORE-token" "$(echo "$out" | grep '^password=')"

# ---------------------------------------------------------------------------
# 10. FAIL CLOSED — no identity resolvable, but this host runs a fleet. Where
#     seats exist, an unattributable request is refused instead of receiving
#     the shared account. Contrast with case 1, which is the same request on a
#     host with no fleet and still returns the shared account.
# ---------------------------------------------------------------------------
git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
assert_fail_closed "no identity on a fleet host refuses" \
  "git.mosaicstack.dev" "" "no-identity" MOSAIC_BRAIN_HOME="$BRAIN_DIR"

# ---------------------------------------------------------------------------
# 11. The brain home defaults to ~/.mosaic when MOSAIC_BRAIN_HOME is unset —
#     the fleet gate must fire on the default path too, not only on an
#     explicitly injected one. Case 1 ran before this directory existed; the
#     same call now refuses, which also proves case 1 was measuring the
#     no-fleet branch rather than passing for an unrelated reason.
# ---------------------------------------------------------------------------
mkdir -p "$FAKE_HOME/.mosaic/fleet/agents"
assert_fail_closed "fleet gate fires on the default ~/.mosaic brain home" \
  "git.mosaicstack.dev" "" "no-identity"

# ---------------------------------------------------------------------------
# 12. Unrelated/unknown host -> exit 0, no output (passthrough for non-Gitea
#     remotes, e.g. github.com via a different credential helper). A fleet host
#     must not refuse a host this helper does not own.
# ---------------------------------------------------------------------------
out=$(run_helper "github.com" "agentA")
assert_eq "unknown host: no output" "" "$out"
out=$(run_helper "github.com" "" MOSAIC_BRAIN_HOME="$BRAIN_DIR")
assert_eq "unknown host on a fleet host: still passthrough, not a refusal" "" "$out"

# ---------------------------------------------------------------------------
# 13. Non-"get" verb (store/erase) -> exit 0, no output (git-credential
#     protocol: this helper only implements get).
# ---------------------------------------------------------------------------
store_out=$(cd "$REPO_DIR" && env -i HOME="$FAKE_HOME" PATH="$PATH" bash "$HELPER" store <<EOF
host=git.mosaicstack.dev
username=no-such-agent
password=whatever

EOF
)
assert_eq "store verb: no output" "" "$store_out"

# ---------------------------------------------------------------------------
# 14. The escalation record is machine-readable even when a field carries a
#     quote or a backslash. A cwd is arbitrary operator text; an unescaped one
#     silently turns the spool into unparseable JSONL, and the operator only
#     finds out while reading the record that explains an outage.
# ---------------------------------------------------------------------------
hostile_dir="$WORK_DIR/we\"ird\\dir"
mkdir -p "$hostile_dir"
hostile_spool="$WORK_DIR/spool-hostile"
(
  cd "$hostile_dir"
  env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIAL_SPOOL="$hostile_spool" \
    MOSAIC_GIT_IDENTITY=no-such-agent \
    bash "$HELPER" get <<EOF >/dev/null 2>&1
host=git.mosaicstack.dev
username=no-such-agent

EOF
) || true
# Deliberately not `ls ... | head -1`: under `set -o pipefail` a missed glob
# makes ls exit 2, the pipeline inherits it, and `set -e` kills this suite with
# zero output — the same silent-nonzero failure this file exists to catch.
record_file=""
for candidate in "$hostile_spool"/*.jsonl; do
  if [[ -e "$candidate" ]]; then
    record_file="$candidate"
    break
  fi
done
if [[ -z "$record_file" ]]; then
  echo "FAIL: hostile cwd — no escalation record was written at all" >&2
  fail=1
elif ! python3 -c 'import json,sys
for line in open(sys.argv[1]):
    line = line.strip()
    if line:
        json.loads(line)' "$record_file" 2>/dev/null; then
  echo "FAIL: hostile cwd — escalation record is not parseable JSONL:" >&2
  cat "$record_file" >&2
  fail=1
fi

# ---------------------------------------------------------------------------
# 15. When the spool cannot be created, the diagnostic must NOT name a record
#     path. Naming a file that was never written sends the operator to an
#     empty path on exactly the hosts where the escalation was lost.
# ---------------------------------------------------------------------------
unwritable_spool="/proc/mosaic-credential-spool-cannot-exist"
nospool_err=$(
  cd "$REPO_DIR"
  env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIAL_SPOOL="$unwritable_spool" \
    MOSAIC_GIT_IDENTITY=no-such-agent \
    bash "$HELPER" get <<EOF 2>&1 >/dev/null
host=git.mosaicstack.dev
username=no-such-agent

EOF
) || true
if [[ "$nospool_err" == *"record: $unwritable_spool/"* ]]; then
  echo "FAIL: unwritable spool — diagnostic names a record file that was never written" >&2
  fail=1
fi
if [[ "$nospool_err" != *"NOT WRITTEN"* ]]; then
  echo "FAIL: unwritable spool — diagnostic does not say the record was not written" >&2
  echo "$nospool_err" >&2
  fail=1
fi

if [[ "$fail" -eq 0 ]]; then
  echo "git-credential-mosaic identity resolution regression passed"
fi

exit "$fail"
