#!/usr/bin/env bash
# Tests lib/session-class.sh: the launch ancestry's cgroups decide human vs agent, and an
# inherited env marker can never demote (or promote) a classification.
#
# The ancestry walk is stubbed with cgroup paths READ from live processes on the workstation,
# so a fixture cannot drift into a shape the kernel never produces. The last case runs a real
# transient unit in agent.slice to prove the walk itself reads the kernel correctly.
#
# Run: bash session-class-cgroup.test.sh (exit 0 = all pass).
set -uo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
LIB="$ROOT/lib/session-class.sh"
PASS=0; FAIL=0
ok()  { PASS=$((PASS+1)); printf 'PASS %s\n' "$1"; }
bad() { FAIL=$((FAIL+1)); printf 'FAIL %s\n     %s\n' "$1" "$2"; }

TMP=$(mktemp -d "${TMPDIR:-/tmp}/session-class-test-XXXX")
trap 'rm -rf "$TMP"' EXIT

# shellcheck source=../lib/session-class.sh
source "$LIB"
# The suite itself usually runs inside an agent; `container` is the one env value the
# classifier still reads (container detection), so it is stripped per case.
UNSET_ARGS=(-u container)

U="/user.slice/user-1000.slice/user@1000.service"
CG_AGENT="$U/agent.slice/confine-agent-3659732-10458.scope"
CG_DISPATCH="$U/app.slice/v2-child-dispatch-t1-3105035-4.scope"
CG_HUMAN="$U/human.slice/rescue.scope-like"
CG_TERM="$U/app.slice/app-org.gnome.Terminal.slice/vte-spawn-96d28260-5090-422e-ab41-2bdbe4efb231.scope"
CG_LOGIN="/user.slice/user-1000.slice/session-364.scope"
CG_INIT="$U/init.scope"

# Runs session_is_human against a stubbed ancestry. $1=newline-separated cgroups (self first),
# $2=tty|notty, rest=env assignments. Prints "<rc> <reason>".
classify() {
  local ancestry="$1" mode="$2"; shift 2
  local script="$TMP/case.$$.$RANDOM.sh"
  {
    printf '#!/usr/bin/env bash\n'
    printf 'source %q\n' "$LIB"
    printf 'session_cgroup_verdict() { session_cgroup_verdict_from_paths %q "${1:-}"; }\n' "$ancestry"
    printf 'session_container_signal() { [[ -n "${container:-}" ]] && { printf "env:container=%%s" "$container"; return 0; }; return 1; }\n'
    printf 'r="$(session_is_human)"; printf "%%s %%s" "$?" "$r"\n'
  } >"$script"
  # script(1) gives the case a real pty only when its own stdout is not a pipe, so the output
  # is captured rather than piped.
  if [[ "$mode" == tty ]]; then
    local out
    out=$(script -qec "env $(printf '%q ' "${UNSET_ARGS[@]}" "$@") bash $script" /dev/null </dev/null)
    printf '%s' "${out//$'\r'/}"
  else
    # setsid drops the controlling terminal, so a notty case stays a notty case however the
    # suite itself was launched.
    env "${UNSET_ARGS[@]}" "$@" setsid --wait bash "$script" </dev/null
  fi
}

expect() { # $1=name $2=want-rc $3=want-reason-substr, then classify() args
  local name="$1" want_rc="$2" want_re="$3"; shift 3
  local got rc reason
  got="$(classify "$@")"
  rc="${got%% *}"; reason="${got#* }"
  [[ "$got" == *' '* ]] || reason=""
  if [[ "$rc" == "$want_rc" && "$reason" == *"$want_re"* ]]; then
    ok "$name"
  else
    bad "$name" "rc=$rc want=$want_rc reason='$reason' want~'$want_re'"
  fi
}

# (a) an agent scope anywhere in the ancestry is decisive, even below a human parent
expect "a: agent.slice ancestry classifies agent" 1 "agent-cgroup:" \
  "$CG_AGENT
$CG_HUMAN
$CG_TERM
$CG_INIT" notty

# (a2) a dispatch scope is agent evidence even outside agent.slice
expect "a2: dispatch scope classifies agent" 1 "agent-cgroup:" \
  "$CG_DISPATCH
$CG_TERM
$CG_INIT" tty

# (b) an agent scope above the launch still classifies agent
expect "b: agent.slice above the launch still classifies agent" 1 "agent-cgroup:" \
  "$CG_TERM
$CG_AGENT
$CG_INIT" tty

# (c) the lockout regression: a leaked marker cannot demote a human terminal ancestry
expect "c: terminal ancestry with a leaked marker classifies human" 0 "" \
  "$CG_TERM
$CG_INIT" tty AGENT_BUILD_SCOPE_ACTIVE=1 TMPJAIL_ACTIVE=1 CLAUDECODE=1

# (d) a hosted human session needs no TTY and ignores markers
expect "d: human.slice classifies human without a TTY" 0 "" \
  "$CG_HUMAN
$CG_INIT" notty AGENT_BUILD_SCOPE_ACTIVE=1

# (e) a console/ssh login scope lives outside user@.service and still counts as a terminal
expect "e: session-*.scope with a TTY classifies human" 0 "" \
  "$CG_LOGIN" tty AI_AGENT=1

# (f) a terminal scope without a TTY is a scripted launch, not the owner
expect "f: terminal ancestry without a TTY classifies agent" 1 "no-tty" \
  "$CG_TERM
$CG_INIT" notty AI_AGENT=1

# (g) markers never decide: indecisive ancestry with a TTY is the owner even when marked
expect "g: indecisive ancestry with a TTY classifies human despite markers" 0 "" \
  "$CG_INIT" tty CLAUDECODE=1
expect "g: indecisive ancestry without a TTY refuses" 1 "no-tty" \
  "$CG_INIT" notty

# (h) the real walk reads the kernel: a transient unit in agent.slice classifies agent
if systemctl --user show-environment >/dev/null 2>&1; then
  # The unit starts outside any /tmp jail this suite may be running in, so the probe is passed
  # as text rather than as a file the unit would have to see.
  probe=$(printf 'source %q; r="$(session_is_human)"; printf "%%s %%s" "$?" "$r"' "$LIB")
  got=$(systemd-run --user --pipe --quiet --wait --property=Slice=agent.slice \
    -- bash -c "$probe" 2>&1)
  if [[ "$got" == "1 agent-cgroup:"*"/agent.slice/"* ]]; then
    ok "h: a live agent.slice unit classifies agent (${got#1 })"
  else
    bad "h: a live agent.slice unit classifies agent" "got='$got'"
  fi
else
  printf 'skip h: no user systemd manager on this host\n'
fi

printf '\n%d passed, %d failed\n' "$PASS" "$FAIL"
(( FAIL == 0 ))
