diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..44407f4 --- /dev/null +++ b/.env.example @@ -0,0 +1,31 @@ +# Operator environment template. Copy with `just init-env`; then set +# FORGE_GITEA_USERNAME. `.env` is gitignored. + +# --- Gitea ----------------------------------------------------------- +FORGE_GITEA_URL="https://gitea.cvgitea.ddns.net:6006" +FORGE_GITEA_ORG="codevalet" +FORGE_GITEA_USERNAME="" + +# --- Orchestrator ---------------------------------------------------- +FORGE_ORCHESTRATOR_REPO_URL="https://gitea.cvgitea.ddns.net:6006/codevalet/forge-stack-orchestrator.git" +# Leave empty for the repo's default branch. +FORGE_ORCHESTRATOR_BRANCH="" +# "." clones into ./forge-stack-orchestrator (gitignored by this repo). +FORGE_WORKSPACE_ROOT="." + +# --- OAuth2 PKCE CLI app --------------------------------------------- +# Public client id; PKCE requires no secret. +FSDGG_CLI_CLIENT_ID="ba4ec9ec-8ae8-4450-9cec-fd532bbe63d5" +# +# FSDGG_CLI_REDIRECT_URI is NOT the Gitea URL. It is the local loopback +# listener this CLI binds to receive the OAuth2 authorization-code +# callback. RFC 8252 §7.3 (OAuth 2.0 for Native Apps) mandates +# http-scheme loopback: http://127.0.0.1:/, +# http://[::1]:/, or http://localhost:/. +# The value must also match the redirect URI registered in the Gitea +# OAuth app; change it only if the Gitea app registration was updated +# in sync. +FSDGG_CLI_REDIRECT_URI="http://127.0.0.1:38111/callback" + +# Set to 1 to skip TLS verification (self-signed dev Gitea only). +# FORGE_INSECURE_TLS="0" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6c43df8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Local-only, never pushed. +.env +.env.example.bak-* + +# Working scratch created by recipes. +state/ +build/ +logs/ + +# Default in-place orchestrator clone (FORGE_WORKSPACE_ROOT="."). +/forge-stack-orchestrator/ + +# Python test cache. +__pycache__/ +*.pyc +.pytest_cache/ +.coverage +TODOS_LISTS/ diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..4b3f49a --- /dev/null +++ b/Justfile @@ -0,0 +1,175 @@ +# welcome-to-codevalet-as-a-platform-operator +# +# Recipe definitions for the platform operator onboarding flow. +# Implementation lives in scripts/; each script has corresponding +# tests under tests/. + +set shell := ["bash", "-euo", "pipefail", "-c"] +set dotenv-load := true +set positional-arguments + +# Default: show the onboarding plan and the recipe list. +default: + @just welcome + @echo "" + @just --list + +# Full interactive onboarding. Accepts --headless / --no-browser / --deploy. Honours FORGE_SETUP_YES=1. +setup *args: + @bash scripts/setup.sh {{args}} + +# Full onboarding + orchestrator operator-setup handoff. +deploy *args: + @bash scripts/setup.sh --deploy {{args}} + +# Print the onboarding plan. +welcome: + @echo "================================================================" + @echo " Welcome to codevalet." + @echo "================================================================" + @echo "" + @echo " just deploy # full onboarding + operator-setup handoff" + @echo " just deploy --headless # same, without opening a browser" + @echo " just setup # onboarding only (stops after clone)" + @echo " just next-steps # print the orchestrator's operator-setup plan" + @echo " just run-next-steps # execute that plan (== just operator-setup" + @echo " # inside the orchestrator checkout)" + @echo " just relogin # switch Gitea user on the current machine" + @echo " just --list # every available recipe" + +# Check prerequisites (tools + Python version + PATH hygiene). +doctor: + @bash scripts/doctor.sh + +# Create .env from .env.example the first time. Never overwrites. +init-env: + @if [ -f .env ]; then \ + echo "[init-env] .env already exists; leaving it alone."; \ + else \ + cp .env.example .env; \ + echo "[init-env] wrote .env"; \ + echo "[init-env] set FORGE_GITEA_USERNAME in .env before login."; \ + fi + +# Ping Gitea /api/v1/version to confirm FORGE_GITEA_URL is reachable. +check-gitea: + @test -n "${FORGE_GITEA_URL:-}" \ + || { echo "[error] FORGE_GITEA_URL unset: run 'just init-env' and edit .env"; exit 1; } + @echo "[check-gitea] GET $FORGE_GITEA_URL/api/v1/version" + @curl -fsS --max-time 10 ${FORGE_INSECURE_TLS:+-k} "$FORGE_GITEA_URL/api/v1/version" \ + | python3 -c 'import json,sys; d=json.load(sys.stdin); print("[check-gitea] Gitea version:", d.get("version","?"))' + @echo "[check-gitea] OK. Gitea is reachable." + +# Browser OAuth2 (PKCE) login. Reuses a live token; runs the flow otherwise. +login: + @bash scripts/forge_login.sh + +# Like `just login`, but prints the URL instead of opening a browser. +login-headless: + @bash scripts/forge_login.sh --no-browser + +# Clear stored Gitea tokens (keeps the credential helper installed and the orchestrator-gateway fields). Run `just login` afterwards to sign in as a different user. +logout: + @python3 scripts/forge_auth.py logout + +# `just logout` + `just login` in one step. Equivalent to switching users. +relogin: + @bash scripts/forge_login.sh --force + +# Open Gitea's "Authorized OAuth2 Applications" page to revoke a stale grant. Resolves the "different scope" failure mode (see docs/oauth-grant-scope-mismatch.md). +revoke-grant: + @bash scripts/revoke_grant.sh + +# Force a token refresh (normally automatic inside the credential helper). +refresh: + @python3 scripts/forge_auth.py refresh --force + +# Print the current OAuth state (paths, whether the token is live). +status: + @python3 scripts/forge_auth.py status + +# git ls-remote against FORGE_ORCHESTRATOR_REPO_URL to confirm access. +check-access: + @test -n "${FORGE_ORCHESTRATOR_REPO_URL:-}" \ + || { echo "[error] FORGE_ORCHESTRATOR_REPO_URL unset: run 'just init-env' and edit .env"; exit 1; } + @echo "[check-access] git ls-remote $FORGE_ORCHESTRATOR_REPO_URL" + @GIT_TERMINAL_PROMPT=0 GCM_INTERACTIVE=Never \ + GIT_ASKPASS='' SSH_ASKPASS='' \ + VSCODE_GIT_ASKPASS_MAIN='' VSCODE_GIT_ASKPASS_NODE='' \ + VSCODE_GIT_ASKPASS_EXTRA_ARGS='' VSCODE_GIT_IPC_HANDLE='' \ + DISPLAY='' WAYLAND_DISPLAY='' \ + git ls-remote "$FORGE_ORCHESTRATOR_REPO_URL" HEAD >/dev/null 2>&1 \ + && { \ + echo "[check-access] OK. Orchestrator access is available."; \ + } || { \ + echo "[check-access] FAILED. Likely causes:"; \ + echo " - 'just login' has not completed"; \ + echo " - the stored refresh token expired; run 'just relogin'"; \ + echo " - the Gitea account is not yet in the org"; \ + echo " - FORGE_ORCHESTRATOR_REPO_URL in .env is wrong"; \ + exit 1; \ + } + +# Clone the orchestrator into FORGE_WORKSPACE_ROOT (idempotent). +clone-orchestrator: + @test -n "${FORGE_ORCHESTRATOR_REPO_URL:-}" \ + || { echo "[error] FORGE_ORCHESTRATOR_REPO_URL unset: run 'just init-env' and edit .env"; exit 1; } + @bash -c 'set -euo pipefail; \ + root="${FORGE_WORKSPACE_ROOT:-.}"; \ + mkdir -p "$root"; \ + name="$(basename "$FORGE_ORCHESTRATOR_REPO_URL" .git)"; \ + dest="$root/$name"; \ + if [ -d "$dest/.git" ]; then \ + echo "[clone-orchestrator] already cloned at: $dest"; \ + else \ + echo "[clone-orchestrator] cloning into: $dest"; \ + export GIT_TERMINAL_PROMPT=0 GCM_INTERACTIVE=Never; \ + unset GIT_ASKPASS SSH_ASKPASS \ + VSCODE_GIT_ASKPASS_MAIN VSCODE_GIT_ASKPASS_NODE \ + VSCODE_GIT_ASKPASS_EXTRA_ARGS VSCODE_GIT_IPC_HANDLE \ + DISPLAY WAYLAND_DISPLAY; \ + if [ -n "${FORGE_ORCHESTRATOR_BRANCH:-}" ]; then \ + git clone --branch "$FORGE_ORCHESTRATOR_BRANCH" "$FORGE_ORCHESTRATOR_REPO_URL" "$dest" \ + || { echo "[clone-orchestrator] FAILED. Run \"just login\" (or \"just relogin\" if expired) and retry." >&2; exit 1; }; \ + else \ + git clone "$FORGE_ORCHESTRATOR_REPO_URL" "$dest" \ + || { echo "[clone-orchestrator] FAILED. Run \"just login\" (or \"just relogin\" if expired) and retry." >&2; exit 1; }; \ + fi; \ + fi; \ + echo; \ + echo "[clone-orchestrator] orchestrator is at:"; \ + echo " $dest"; \ + echo; \ + echo "Continue in that checkout via its README or \"just next-steps\"."' + +# Print the platform operator onboarding plan from the orchestrator's manifest. +next-steps *args: + @bash scripts/next_steps.sh {{args}} + +# Exec the orchestrator's `just operator-setup` (default; override via --recipe); forwards all flags. +run-next-steps *args: + @bash scripts/next_steps.sh --run {{args}} + +# Remove the credential helper and welcome-managed fields from client-auth.json. +uninstall: + @bash scripts/uninstall.sh + +# Run the full test suite. +test: + @echo "[test] running Python unit tests..." + @python3 -m unittest discover -t . -s tests -p 'test_*.py' -v + @echo "" + @echo "[test] running shell integration tests..." + @bash tests/test_forge_auth_integration.sh + @echo "" + @echo "[test] running setup.sh argument / headless-wiring test..." + @bash tests/test_setup_args.sh + @echo "" + @echo "[test] running doctor.sh fix-command test..." + @bash tests/test_doctor.sh + @echo "" + @echo "[test] running next_steps.sh manifest-driven test..." + @bash tests/test_next_steps.sh + @echo "" + @echo "[test] running setup.sh --deploy flag test..." + @bash tests/test_setup_deploy_flag.sh diff --git a/README.md b/README.md index dc0517b..15c2e1d 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,268 @@ # welcome-to-codevalet-as-a-platform-operator +Onboarding for platform operators of the codevalet Gitea instance. +Produces an authenticated local checkout of +`forge-stack-orchestrator` with git operations against Gitea +authenticated silently through an OAuth2 (PKCE) token shared with the +orchestrator's devpi gateway, then hands off to the orchestrator's +operator-setup runner. + +## Quick start + +```bash +just deploy # interactive +just deploy --yes # auto-accept every prompt (session reuse, checkout reuse, handoff) +``` + +`just deploy` runs `doctor`, `init-env`, `check-gitea`, `login`, +`check-access`, `clone-orchestrator`, and then offers to execute the +orchestrator's `operator-setup` runner. It prompts only when a +decision requires a human (missing Gitea username, existing stored +session, existing orchestrator clone, final go-ahead for +`operator-setup`). Idempotent. + +`just setup` is the same flow minus the `operator-setup` handoff: +it stops at `clone-orchestrator`, which is the contributor entry +point. Operators almost always want `just deploy`. + +Individual recipes remain available; see `just --list`. + +## Prerequisites + +Linux, macOS, or Windows-via-WSL with: + +- `git`, `bash`, `curl` +- Python 3.11+ (a `uv python install 3.11` interpreter counts) +- [`just`](https://github.com/casey/just#installation) +- [`uv`](https://docs.astral.sh/uv/#installation) +- graphical web browser (optional; see *Headless and SSH hosts*) +- `~/.local/bin` on `PATH` + +`just doctor` verifies all of these and, for each miss, prints the +exact install command for the detected platform (`apt` / `dnf` / +`pacman` / `zypper` / `brew`) and a consolidated block at the end. No +recipe in this scaffold runs `sudo` or installs anything system-wide; +any additional host setup required by the orchestrator's runner is +handled inside the orchestrator checkout itself. + +A Gitea account on `FORGE_GITEA_URL` with membership in the +`codevalet` organisation is also required. + +## Configuring `.env` + +`just init-env` copies `.env.example` → `.env`. The only required +edit is `FORGE_GITEA_USERNAME`. Defaults: + +| Variable | Default | Change when | +| --- | --- | --- | +| `FORGE_GITEA_URL` | `https://gitea.cvgitea.ddns.net:6006` | different Gitea instance | +| `FORGE_GITEA_ORG` | `codevalet` | fork or sibling org | +| `FORGE_ORCHESTRATOR_REPO_URL` | `.../codevalet/forge-stack-orchestrator.git` | different fork | +| `FORGE_ORCHESTRATOR_BRANCH` | *(empty: default branch)* | branch pin | +| `FORGE_WORKSPACE_ROOT` | `.` (clone at `./forge-stack-orchestrator`, gitignored) | clone elsewhere | +| `FSDGG_CLI_CLIENT_ID` | registered PKCE CLI client | never | +| `FSDGG_CLI_REDIRECT_URI` | `http://127.0.0.1:38111/callback` | port conflict only; must stay `http://` + loopback per RFC 8252 §7.3 (`127.0.0.1`, `[::1]`, or `localhost`) and match the Gitea OAuth app registration | + +**`FSDGG_CLI_REDIRECT_URI` is not the Gitea URL.** The Gitea server is +at `FORGE_GITEA_URL` (remote, HTTPS). The redirect URI is the local +loopback HTTP listener the CLI binds on the local machine so Gitea can +hand back the OAuth authorisation code; OAuth 2.0 for Native Apps +(RFC 8252 §7.3) prohibits any non-loopback / non-HTTP scheme here, +and no public CA will issue a cert for `127.0.0.1` so HTTPS on +loopback is not a meaningful option. On a shared or remote host, +SSH-forward the port (see *Headless and SSH hosts*) rather than +trying to publish the callback over the network. + +`.env` is gitignored. OAuth client IDs are public by design; PKCE +requires no client secret. + +## Recipes + +| Recipe | Effect | +| --- | --- | +| `just deploy` | Full interactive onboarding + orchestrator `operator-setup` handoff. `FORGE_SETUP_YES=1` accepts every default. | +| `just deploy --headless` | Same, but skips `webbrowser.open`; alias `--no-browser`. | +| `just setup` | Onboarding only (stops at `clone-orchestrator`). | +| `just setup --deploy` | Equivalent to `just deploy`; flag-style spelling. | +| `just welcome` | Onboarding plan. | +| `just doctor` | Prerequisite checks with copy-pasteable fixes. | +| `just init-env` | Copies `.env.example` → `.env`. Never overwrites. | +| `just check-gitea` | Hits `FORGE_GITEA_URL/api/v1/version`. | +| `just login` | Browser PKCE OAuth2 flow; installs the git credential helper. | +| `just login-headless` | `just login` without opening a browser. | +| `just status` | Stored-token state. Exits non-zero when not live. | +| `just refresh` | Forces a token refresh (normally automatic). | +| `just logout` | Clears stored Gitea tokens; keeps the credential helper and orchestrator-gateway fields. | +| `just relogin` | `logout` + `login`. | +| `just check-access` | `git ls-remote` against the orchestrator. | +| `just clone-orchestrator` | Clones into `$FORGE_WORKSPACE_ROOT`. Idempotent. | +| `just next-steps` | Prints the orchestrator's operator-setup plan. | +| `just run-next-steps` | Executes that plan (== `just operator-setup` inside the orchestrator checkout). | +| `just uninstall` | Reverses `login` and `clone-orchestrator`. | +| `just test` | Full test suite. | + +## Switching Gitea users + +```bash +just relogin # or: just logout && just login +``` + +`just logout` leaves the credential helper installed and preserves +orchestrator-gateway fields in +`~/.forge-stack-devpi-gateway-gitea/client-auth.json`. Git operations +against `FORGE_GITEA_URL` fail loudly between `logout` and the next +`login`. + +## Wrong-user guard + +`just login` refuses to persist tokens that do not match +`FORGE_GITEA_USERNAME`: + +1. The authorise URL carries `prompt=login` (OIDC Core §3.1.2.1), + forcing Gitea to re-display its login form even under an active + session; when `FORGE_GITEA_USERNAME` is set, `login_hint=` + is also included. +2. After the token exchange, `userinfo.preferred_username` is compared + case-insensitively to `FORGE_GITEA_USERNAME`. On mismatch the token + is discarded, the auth file is left untouched, the Gitea logout + URL (`/user/logout?redirect_to=/user/login`) is + opened in the browser, and the CLI exits non-zero with recovery + steps. + +## After `just deploy` + +`just deploy` hands off to `just operator-setup` inside the +orchestrator checkout; the authoritative runbook for every operator +task lives there. `just setup` (without `--deploy`) stops after the +clone and leaves the handoff for manual execution: + +```bash +cd ./forge-stack-orchestrator # or $FORGE_WORKSPACE_ROOT/ +just operator-setup +``` + +`just login` already wrote the OAuth token to the shared auth file, +and the orchestrator's recipes reuse it without a second login. When the +orchestrator reports a missing gateway bearer, run `just repos-login` +inside the orchestrator checkout; it layers a gateway bearer on top +of the existing Gitea token without a second Gitea login. + +## `just login` internals + +1. Reads `FORGE_GITEA_URL`, `FSDGG_CLI_CLIENT_ID`, optional + `FORGE_GITEA_USERNAME` from `.env`. +2. GET `/.well-known/openid-configuration`; no endpoints are + hardcoded. +3. PKCE verifier (`secrets.token_urlsafe(64)`) and S256 challenge + (RFC 7636). +4. HMAC-signed CSRF `state`; session key held in process memory only. +5. Authorise URL carries `prompt=login` and, if set, + `login_hint=`. +6. Opens the default browser (or prints the URL under `--no-browser`) + and starts a one-shot loopback HTTP listener on the port from + `FSDGG_CLI_REDIRECT_URI`. +7. Verifies the returned state, POSTs the code + verifier to the token + endpoint, receives `access_token`, `refresh_token`, `expires_in`. +8. GET `/login/oauth/userinfo` for the authenticated username. +9. If `FORGE_GITEA_USERNAME` is set and does not match, discard the + token and open the Gitea logout URL (see *Wrong-user guard*). +10. Writes `~/.forge-stack-devpi-gateway-gitea/client-auth.json` + atomically (`.tmp` + `chmod 0600` + `rename`). Gateway-owned + fields are preserved. +11. Installs `git-credential-forge` + `forge_auth.py` under + `~/.local/bin/` and sets `git config --global + credential..helper`. Scope is `FORGE_GITEA_URL` + only; other git hosts are untouched. + +## Token refresh + +Access-token lifetime is set by Gitea (typically one hour). When it +expires the credential helper calls the token endpoint with the +stored refresh token and retries the git operation once; no user +interaction. When the refresh token also expires (Gitea default: 30 +days), the helper emits a one-line pointer at `just login` and the +git operation fails. + +## Headless and SSH hosts + +```bash +just deploy --headless # full onboarding + handoff, no webbrowser.open +just setup --headless # onboarding only, no webbrowser.open +just login-headless # login step only +``` + +Each prints the authorise URL on stderr. Paste it into any browser +that can reach the loopback callback port (`FSDGG_CLI_REDIRECT_URI`, +default `38111`). For a remote host: + +```bash +ssh -L 38111:127.0.0.1:38111 +# then, on : +just deploy --headless +``` + +`--no-browser` is an alias for `--headless`. + +`just deploy --headless` refreshes silently when the access token is +stale but the refresh token is still valid, avoiding the URL-paste +step entirely. + +Combining `--headless` with `FORGE_SETUP_YES=1` while a fresh browser +OAuth flow is required is contradictory: the authorise URL must be +pasted into a browser, but `FORGE_SETUP_YES=1` forbids interaction. +`just deploy` (and `just setup`) detects this and exits immediately +rather than hanging. Either drop `FORGE_SETUP_YES=1`, or run +`just login` once on a host with a browser to populate a valid +refresh token before running `just deploy --headless` on the +headless host. + +## Troubleshooting + +| Symptom | Resolution | +| --- | --- | +| `just doctor` reports a missing tool | Run the `fix:` command printed beside it. | +| `~/.local/bin` not on `PATH` | Add `export PATH="$HOME/.local/bin:$PATH"` to the shell rc and reopen. | +| `just check-gitea` → connection refused | Verify `FORGE_GITEA_URL` and network access. | +| `just login` → browser does not open | Run `just login-headless`. | +| `just login` → timed out waiting for OAuth callback | Consent was not completed in the browser; re-run. | +| `just login` → cannot bind `127.0.0.1:38111` | Another `just login` is running; wait or kill it. | +| "Why is the redirect URI `http://127.0.0.1`? The gateway is remote and HTTPS." | `FSDGG_CLI_REDIRECT_URI` is the CLI's local loopback listener, not the Gitea or gateway URL. OAuth 2.0 for Native Apps (RFC 8252 §7.3) requires `http` on a loopback address. The Gitea server (`FORGE_GITEA_URL`) is the remote HTTPS endpoint, reached during the authorise step. On a remote host, SSH-forward the redirect port. | +| `just deploy --headless` → "cannot complete a fresh login under --headless + FORGE_SETUP_YES=1" | Drop `FORGE_SETUP_YES=1`, or run `just login` once on a host with a browser to populate a refresh token, then re-run `just deploy --headless`. | +| `just login` rejects with a username-mismatch error | Follow the logout link printed, sign in as `FORGE_GITEA_USERNAME`, re-run. | +| `just check-access` → `Repository not found` | Account not in the `codevalet` org yet. | +| `just check-access` → asks for a password | `just login` did not complete. Re-run. | +| Git prompts for a password on pull/push | Refresh token expired. Run `just relogin`. | +| `just status` shows `live: False` | Run `just refresh`; also happens automatically on the next git op. | +| `just clone-orchestrator` prints `already cloned` | Intended; idempotent. | +| `just deploy` runs fine through step 6 but the handoff fails | Open the orchestrator checkout and re-read its onboarding docs. This scaffold ends at `clone-orchestrator`; everything past it lives in the orchestrator. | +| `just login` or `just deploy` exits with `Gitea server_error: "a grant exists with different scope"` | Run `just revoke-grant` (opens `/user/settings/applications` and prints the matching `FSDGG_CLI_CLIENT_ID`). Revoke the matching app, then re-run the failed recipe. Required only once after a scope-set change. Full reference: `docs/oauth-grant-scope-mismatch.md`. | +| Want a clean slate | `just uninstall`. | + +## Security properties + +- Only a public OAuth client ID ships in `.env.example`; PKCE removes + the need for a client secret. +- `.env` holds configuration only. Tokens live in + `~/.forge-stack-devpi-gateway-gitea/client-auth.json` at mode `0600`. +- The credential helper is scoped to `FORGE_GITEA_URL`; requests for + other hosts flow through git's normal credential chain. +- The OAuth `state` is HMAC-signed with an in-memory session key; a + replayed state from another session does not verify. +- Writes to the auth file are atomic (`.tmp` + `rename`); a crash + during `just login` or `just refresh` leaves the previous valid + state intact. +- `just check-access` and `just clone-orchestrator` neuter + `GIT_TERMINAL_PROMPT`, `GIT_ASKPASS`, and the VSCode / X11 askpass + variables so auth failures surface loudly instead of triggering GUI + credential prompts. +- This scaffold never executes any privileged action on the host; + its sole output is an authenticated orchestrator checkout, after + which every operational task is the orchestrator's responsibility. + +## Tests + +```bash +just test +``` + +See `tests/README.md`. diff --git a/scripts/common.sh b/scripts/common.sh new file mode 100755 index 0000000..5ebe728 --- /dev/null +++ b/scripts/common.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# +# Shared helpers sourced by every script in this directory. +# . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" +# +# Requires bash 4+ and coreutils. + +set -euo pipefail + +# ---- paths ---------------------------------------------------------- +LOCAL_BIN="${HOME}/.local/bin" +CRED_HELPER="${LOCAL_BIN}/git-credential-forge" + +# Canonical OAuth auth store: matches +# forge-stack-devpi-gateway-gitea/client_auth.auth_store_path() exactly. +FORGE_AUTH_DIR="${FSDGG_RUNTIME_DIR:-${HOME}/.forge-stack-devpi-gateway-gitea}" +FORGE_AUTH_FILE="${FSDGG_AUTH_STORE_PATH:-${FORGE_AUTH_DIR}/client-auth.json}" + +# ---- colors / logging ---------------------------------------------- +# ANSI enabled only when stderr is a TTY, NO_COLOR is unset, and +# TERM != "dumb"; empty strings otherwise (piped output byte-identical). +if [ -t 2 ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-dumb}" != "dumb" ]; then + _FC_RESET=$'\e[0m' + _FC_BOLD=$'\e[1m' + _FC_DIM=$'\e[2m' + _FC_RED=$'\e[31m' + _FC_GREEN=$'\e[32m' + _FC_YELLOW=$'\e[33m' + _FC_CYAN=$'\e[36m' + _FC_MAGENTA=$'\e[35m' +else + _FC_RESET=''; _FC_BOLD=''; _FC_DIM='' + _FC_RED=''; _FC_GREEN=''; _FC_YELLOW='' + _FC_CYAN=''; _FC_MAGENTA='' +fi + +# Width-6 tag inside ANSI envelope: pads "[ok]", "[err]" etc. to 6 +# visible columns so subsequent %-wNs fields stay aligned. +_fc_tag() { printf '%s%-6s%s' "$1" "[$2]" "$_FC_RESET"; } + +info() { printf '%s %s\n' "$(_fc_tag "$_FC_CYAN" info)" "$*" >&2; } +ok() { printf '%s %s\n' "$(_fc_tag "$_FC_GREEN" ok )" "$*" >&2; } +warn() { printf '%s %s\n' "$(_fc_tag "$_FC_YELLOW" warn)" "$*" >&2; } +err() { printf '%s %s\n' "$(_fc_tag "$_FC_RED" err )" "$*" >&2; } +step() { printf '\n%s==>%s %s%s%s\n' "$_FC_BOLD" "$_FC_RESET" "$_FC_BOLD" "$*" "$_FC_RESET" >&2; } +note() { printf '%s%s%s\n' "$_FC_DIM" "$*" "$_FC_RESET" >&2; } +die() { err "$@"; exit 1; } + +# ---- repo locator --------------------------------------------------- +repo_root() { + local here + here="$(cd "$(dirname "${BASH_SOURCE[1]:-$0}")" && pwd -P)" + local d="$here" + for _ in 1 2 3; do + if [ -f "$d/Justfile" ]; then + printf '%s\n' "$d" + return 0 + fi + d="$(dirname "$d")" + done + die "cannot locate repo root (Justfile) from $here" +} + +# Load $repo/.env into the environment. +# Existing environment values take precedence (matches just dotenv-load). +load_env() { + local root env line key rest + root="$(repo_root)" + env="$root/.env" + [ -f "$env" ] || return 0 + while IFS= read -r line || [ -n "$line" ]; do + # Strip CR from CRLF files and leading whitespace. + line="${line%$'\r'}" + line="${line#"${line%%[![:space:]]*}"}" + case "$line" in + ''|'#'*) continue ;; + *=*) ;; + *) continue ;; + esac + # Optional leading `export `. + line="${line#export }" + key="${line%%=*}" + rest="${line#*=}" + # Skip malformed keys. + case "$key" in + *[!A-Za-z0-9_]*|'') continue ;; + esac + # Existing env values win. + if [ -n "${!key+x}" ]; then + continue + fi + # Strip matching surrounding single or double quotes. + case "$rest" in + \"*\") rest="${rest#\"}"; rest="${rest%\"}" ;; + \'*\') rest="${rest#\'}"; rest="${rest%\'}" ;; + esac + export "$key=$rest" + done < "$env" +} + +# ---- prereq checks -------------------------------------------------- +require_cmd() { + local cmd="$1" hint="${2:-}" + if ! command -v "$cmd" >/dev/null 2>&1; then + [ -n "$hint" ] && warn "install hint: $hint" + die "required command not found: $cmd" + fi +} + +require_env() { + local name="$1" + if [ -z "${!name:-}" ]; then + die "environment variable '$name' is unset. Run 'just init-env' and set it in .env." + fi +} diff --git a/scripts/doctor.sh b/scripts/doctor.sh new file mode 100755 index 0000000..4ac2f57 --- /dev/null +++ b/scripts/doctor.sh @@ -0,0 +1,221 @@ +#!/usr/bin/env bash +# +# Verify prerequisites for every recipe. For each missing item, prints the +# EXACT command to install it on the detected platform. Exits non-zero if +# any hard requirement is missing; warns but continues for PATH hygiene +# and the web-browser check (headless systems can still use login-headless). + +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +# shellcheck disable=SC1091 +. "$here/common.sh" + +# --------------------------------------------------------------------------- +# Platform detection +# --------------------------------------------------------------------------- +uname_s="$(uname -s 2>/dev/null || echo unknown)" +pm="" # one of: apt dnf pacman zypper brew "" +case "$uname_s" in + Linux) + if [ -r /etc/os-release ]; then + # shellcheck disable=SC1091 + . /etc/os-release + id_all=" ${ID:-} ${ID_LIKE:-} " + case "$id_all" in + *' debian '*|*' ubuntu '*) pm=apt ;; + *' fedora '*|*' rhel '*|*' centos '*) pm=dnf ;; + *' arch '*) pm=pacman ;; + *' opensuse '*|*' suse '*) pm=zypper ;; + esac + fi + if [ -z "$pm" ]; then + for candidate in apt-get dnf pacman zypper; do + if command -v "$candidate" >/dev/null 2>&1; then + case "$candidate" in + apt-get) pm=apt ;; + dnf) pm=dnf ;; + pacman) pm=pacman ;; + zypper) pm=zypper ;; + esac + break + fi + done + fi + ;; + Darwin) + pm=brew + ;; +esac + +# --------------------------------------------------------------------------- +# Render a package-install command for the current platform. +# pkg_cmd +# --------------------------------------------------------------------------- +pkg_cmd() { + local apt_pkg="$1" dnf_pkg="$2" pac_pkg="$3" brew_pkg="$4" url="$5" + case "$pm" in + apt) echo "sudo apt-get update && sudo apt-get install -y $apt_pkg" ;; + dnf) echo "sudo dnf install -y $dnf_pkg" ;; + pacman) echo "sudo pacman -S --noconfirm $pac_pkg" ;; + zypper) echo "sudo zypper install -y $dnf_pkg" ;; + brew) echo "brew install $brew_pkg" ;; + *) echo "# install manually from: $url" ;; + esac +} + +# --------------------------------------------------------------------------- +# Accumulators +# --------------------------------------------------------------------------- +missing=0 +fix_commands=() + +record_miss() { + local name="$1" detail="$2" fix="$3" + printf ' %s %-12s -> %s\n' "$(_fc_tag "$_FC_RED" miss)" "$name" "$detail" + printf ' fix: %s\n' "$fix" + fix_commands+=("$fix") + missing=$((missing + 1)) +} + +check_cmd() { + local cmd="$1" fix="$2" + if command -v "$cmd" >/dev/null 2>&1; then + printf ' %s %-12s -> %s\n' "$(_fc_tag "$_FC_GREEN" ok)" "$cmd" "$(command -v "$cmd")" + else + record_miss "$cmd" "not found on PATH" "$fix" + fi +} + +info 'checking prerequisites...' + +# --------------------------------------------------------------------------- +# Required tools +# --------------------------------------------------------------------------- +check_cmd git "$(pkg_cmd git git git git https://git-scm.com/)" +check_cmd bash "$(pkg_cmd bash bash bash bash https://www.gnu.org/software/bash/)" +check_cmd curl "$(pkg_cmd curl curl curl curl https://curl.se/)" +check_cmd install "$(pkg_cmd coreutils coreutils coreutils coreutils https://www.gnu.org/software/coreutils/)" +check_cmd python3 "$(pkg_cmd python3 python3 python python3 https://www.python.org/)" + +# just and uv do not ship in most package managers, so prefer the upstream +# installers (no sudo, land binaries in ~/.local/bin / ~/.cargo/bin). +check_cmd just 'curl --proto "=https" --tlsv1.2 -LsSf https://just.systems/install.sh | bash -s -- --to "$HOME/.local/bin"' +check_cmd uv 'curl -LsSf https://astral.sh/uv/install.sh | sh' + +# --------------------------------------------------------------------------- +# Web browser (warning only: headless is supported via login-headless) +# --------------------------------------------------------------------------- +if command -v xdg-open >/dev/null 2>&1 || command -v open >/dev/null 2>&1; then + printf ' %s %-12s -> (detected)\n' "$(_fc_tag "$_FC_GREEN" ok)" 'web browser' +else + printf ' %s %-12s -> no xdg-open/open on PATH.\n' "$(_fc_tag "$_FC_YELLOW" warn)" 'web browser' + printf ' if this is a headless machine, run: just login-headless\n' + case "$pm" in + apt) printf ' otherwise install: sudo apt-get install -y xdg-utils\n' ;; + dnf) printf ' otherwise install: sudo dnf install -y xdg-utils\n' ;; + pacman) printf ' otherwise install: sudo pacman -S --noconfirm xdg-utils\n' ;; + esac +fi + +# --------------------------------------------------------------------------- +# Python 3.11+ : accepted via system python3 OR uv-managed interpreter +# --------------------------------------------------------------------------- +py_ok=0 +py_via="" +if command -v python3 >/dev/null 2>&1; then + if python3 - <<'PY' >/dev/null 2>&1 +import sys +sys.exit(0 if sys.version_info >= (3, 11) else 1) +PY + then + py_ok=1 + py_via="$(python3 -V 2>&1)" + fi +fi +if [ "$py_ok" -eq 0 ] && command -v uv >/dev/null 2>&1; then + if uv_py_path="$(uv python find '>=3.11' 2>/dev/null)" && [ -n "$uv_py_path" ]; then + py_ok=1 + py_via="$uv_py_path (via uv)" + fi +fi + +if [ "$py_ok" -eq 1 ]; then + printf ' %s %-12s -> %s\n' "$(_fc_tag "$_FC_GREEN" ok)" 'python>=3.11' "$py_via" +else + detail='python3 missing' + if command -v python3 >/dev/null 2>&1; then + detail="python3 is $(python3 -V 2>&1 | awk '{print $2}'); need 3.11+" + fi + # Primary fix: uv-managed Python. Works on every platform, no sudo, and + # the orchestrator (a uv project) resolves it automatically via + # `uv run` / `uv sync`. + py_fix='uv python install 3.11' + # Per-distro alternative (commented so either command can be copied): + case "$pm" in + apt) + py_alt='sudo add-apt-repository -y ppa:deadsnakes/ppa && sudo apt-get update && sudo apt-get install -y python3.11 python3.11-venv' + ;; + dnf) + py_alt='sudo dnf install -y python3.11' + ;; + pacman) + py_alt='sudo pacman -S --noconfirm python' + ;; + zypper) + py_alt='sudo zypper install -y python311' + ;; + brew) + py_alt='brew install python@3.11' + ;; + *) + py_alt='' + ;; + esac + printf ' %s %-12s -> %s\n' "$(_fc_tag "$_FC_RED" miss)" 'python>=3.11' "$detail" + printf ' fix: %s\n' "$py_fix" + if [ -n "$py_alt" ]; then + printf ' alt: %s\n' "$py_alt" + fi + fix_commands+=("$py_fix") + missing=$((missing + 1)) +fi + +# --------------------------------------------------------------------------- +# PATH hygiene: warning, not a hard miss. +# --------------------------------------------------------------------------- +case ":$PATH:" in + *":$LOCAL_BIN:"*) + printf ' %s %-12s -> %s is on PATH\n' "$(_fc_tag "$_FC_GREEN" ok)" 'PATH' "$LOCAL_BIN" + ;; + *) + rc='~/.bashrc' + [ -n "${ZSH_VERSION:-}" ] && rc='~/.zshrc' + printf ' %s %-12s -> %s is NOT on PATH.\n' "$(_fc_tag "$_FC_YELLOW" warn)" 'PATH' "$LOCAL_BIN" + # shellcheck disable=SC2016 + printf " fix: echo 'export PATH=\"%s:\$PATH\"' >> %s && exec \$SHELL -l\n" "$LOCAL_BIN" "$rc" + ;; +esac + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- +if [ "$missing" -gt 0 ]; then + printf '\n' + warn "$missing prerequisite(s) missing. Run the following to fix them:" + printf '\n' + # De-duplicate while preserving order. + seen='' + for c in "${fix_commands[@]}"; do + case "$seen" in + *"<<$c>>"*) continue ;; + esac + seen="$seen<<$c>>" + printf ' %s\n' "$c" + done + printf '\n' + warn 're-run: just doctor' + exit 1 +fi + +info 'all prerequisites present' diff --git a/scripts/forge_auth.py b/scripts/forge_auth.py new file mode 100755 index 0000000..139729d --- /dev/null +++ b/scripts/forge_auth.py @@ -0,0 +1,1174 @@ +#!/usr/bin/env python3 +"""PKCE OAuth2 client for the Gitea CLI app. + +Implements RFC 7636 (PKCE) + RFC 6749 §4.1 using only the Python +standard library. Persists tokens to the shared auth file consumed by +``forge-stack-devpi-gateway-gitea`` so the orchestrator's downstream +tooling reuses the same session without a second login. + +Auth-file path precedence: + ``$FSDGG_AUTH_STORE_PATH`` + → ``$FSDGG_RUNTIME_DIR/client-auth.json`` + → ``~/.forge-stack-devpi-gateway-gitea/client-auth.json`` + +Gateway-owned fields (``access_token``, ``expires_in``, +``public_base_url``, ``index_name``) in the auth file are preserved +on every write. Writes are atomic (``.tmp`` + ``rename``, +``chmod 0600``). CSRF ``state`` is HMAC-signed with a per-process +session key never written to disk. +""" +from __future__ import annotations + +import base64 +import dataclasses +import hashlib +import hmac +import json +import os +import secrets +import socket +import ssl +import sys +import threading +import time +import urllib.error +import urllib.parse +import urllib.request +import webbrowser +from dataclasses import dataclass, field +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path +from queue import Queue +from typing import Any +from urllib.parse import parse_qs, urlencode, urlparse + + +# -------------------------------------------------------------------- +# Errors +# -------------------------------------------------------------------- + + +class AuthError(RuntimeError): + """Any non-transient OAuth/auth-file error. Always user-visible.""" + + +class ScopeMismatchAuthError(AuthError): + """Gitea ``server_error`` caused by a grant/scope conflict. + + Raised when Gitea refuses an authorize request because a grant + already exists for ``client_id`` under a different scope set + (RFC 6749 §4.1.2.1; ``error_description`` contains "different + scope"). Recovery: revoke the grant under + ``/user/settings/applications`` and re-run. + + Subclassing ``AuthError`` keeps existing ``except AuthError`` + handlers (e.g., the credential helper) unchanged. Callers that + want to drive an interactive revoke-and-retry flow check for + this concrete subclass and read the structured attributes. + """ + + def __init__( + self, + message: str, + *, + gitea_base_url: str, + client_id: str, + scopes: str, + ) -> None: + super().__init__(message) + self.gitea_base_url = gitea_base_url + self.client_id = client_id + self.scopes = scopes + + @property + def revoke_url(self) -> str: + base = self.gitea_base_url.rstrip("/") + if not base: + return "/user/settings/applications" + return f"{base}/user/settings/applications" + + +# -------------------------------------------------------------------- +# CLI output helpers. Tag scheme matches scripts/common.sh: +# cyan=info, green=ok, yellow=warn, red=err. ANSI disabled when stderr +# is not a TTY, NO_COLOR is set, or TERM is "dumb". +# -------------------------------------------------------------------- + + +def _ansi_on() -> bool: + try: + tty = sys.stderr.isatty() + except (AttributeError, ValueError): + tty = False + return ( + tty + and not os.environ.get("NO_COLOR") + and os.environ.get("TERM", "dumb") != "dumb" + ) + + +def _c(code: str) -> str: + return code if _ansi_on() else "" + + +def _tag(color_code: str, label: str) -> str: + padded = f"[{label}]".ljust(6) + return f"{_c(color_code)}{padded}{_c(chr(0x1B) + '[0m')}" + + +def cli_info(msg: str) -> None: + print(f"{_tag(chr(0x1B) + '[36m', 'info')} {msg}", file=sys.stderr) + + +def cli_ok(msg: str) -> None: + print(f"{_tag(chr(0x1B) + '[32m', 'ok')} {msg}", file=sys.stderr) + + +def cli_warn(msg: str) -> None: + print(f"{_tag(chr(0x1B) + '[33m', 'warn')} {msg}", file=sys.stderr) + + +def cli_err(msg: str) -> None: + print(f"{_tag(chr(0x1B) + '[31m', 'err')} {msg}", file=sys.stderr) + + +# -------------------------------------------------------------------- +# Paths (match forge-stack-devpi-gateway-gitea exactly) +# -------------------------------------------------------------------- + + +AUTH_STORE_ENV_VAR = "FSDGG_AUTH_STORE_PATH" +RUNTIME_DIR_ENV_VAR = "FSDGG_RUNTIME_DIR" +DEFAULT_AUTH_DIR = Path.home() / ".forge-stack-devpi-gateway-gitea" +DEFAULT_AUTH_FILE = DEFAULT_AUTH_DIR / "client-auth.json" + + +def auth_store_path() -> Path: + configured = os.environ.get(AUTH_STORE_ENV_VAR, "").strip() + if configured: + return Path(configured).expanduser().resolve() + runtime_dir = os.environ.get(RUNTIME_DIR_ENV_VAR, "").strip() + if runtime_dir: + return Path(runtime_dir).expanduser().resolve() / "client-auth.json" + return DEFAULT_AUTH_FILE + + +# -------------------------------------------------------------------- +# PKCE primitives +# -------------------------------------------------------------------- + + +def pkce_pair() -> tuple[str, str]: + """Return ``(verifier, S256-challenge)`` suitable for Gitea. + + RFC 7636 §4.1: verifier is 43–128 chars of unreserved URL chars. + `secrets.token_urlsafe(64)` yields ~86 chars, well within bounds. + RFC 7636 §4.2: challenge = BASE64URL(SHA256(verifier)), no padding. + """ + verifier = secrets.token_urlsafe(64) + digest = hashlib.sha256(verifier.encode("ascii")).digest() + challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + return verifier, challenge + + +def sign_state(session_key: bytes, nonce: str) -> str: + """Return ``.`` using an in-memory HMAC-SHA256 key.""" + mac = hmac.new(session_key, nonce.encode("ascii"), hashlib.sha256).hexdigest() + return f"{nonce}.{mac}" + + +def verify_state(session_key: bytes, value: str) -> str: + """Return the nonce if the signature verifies; raise otherwise.""" + if "." not in value: + raise AuthError("OAuth state has no signature separator") + nonce, _, got_mac = value.rpartition(".") + want_mac = hmac.new( + session_key, nonce.encode("ascii"), hashlib.sha256 + ).hexdigest() + if not hmac.compare_digest(got_mac, want_mac): + raise AuthError("OAuth state signature mismatch (possible CSRF)") + return nonce + + +# -------------------------------------------------------------------- +# Config +# -------------------------------------------------------------------- + + +@dataclass(frozen=True) +class ForgeAuthConfig: + gitea_base_url: str + client_id: str + redirect_uri: str + scopes: str = "openid profile email read:user read:organization read:repository write:repository" + insecure_tls: bool = False # only for dev Gitea with self-signed certs + expected_username: str = "" # from FORGE_GITEA_USERNAME; empty = no check + + @classmethod + def from_env(cls) -> ForgeAuthConfig: + missing: list[str] = [] + + def must(name: str) -> str: + value = os.environ.get(name, "").strip() + if not value: + missing.append(name) + return value + + base = must("FORGE_GITEA_URL") + client_id = must("FSDGG_CLI_CLIENT_ID") + redirect = os.environ.get( + "FSDGG_CLI_REDIRECT_URI", "http://127.0.0.1:38111/callback" + ).strip() + if missing: + raise AuthError( + "missing required env vars: " + ", ".join(missing) + + "\n → run 'just init-env' and edit .env" + ) + parsed = urlparse(redirect) + if parsed.scheme != "http" or parsed.hostname not in {"127.0.0.1", "::1", "localhost"}: + raise AuthError( + "FSDGG_CLI_REDIRECT_URI must be a loopback URI per RFC 8252 §7.3: " + "http://127.0.0.1:/, http://[::1]:/, " + f"or http://localhost:/. Got: {redirect}. " + "This is the CLI's local callback listener, not the Gitea " + "server URL (that is FORGE_GITEA_URL)." + ) + if parsed.port is None: + raise AuthError("FSDGG_CLI_REDIRECT_URI must include an explicit port") + return cls( + gitea_base_url=base.rstrip("/"), + client_id=client_id, + redirect_uri=redirect, + insecure_tls=os.environ.get("FORGE_INSECURE_TLS", "").strip() == "1", + expected_username=os.environ.get("FORGE_GITEA_USERNAME", "").strip(), + ) + + +def build_gitea_logout_url(gitea_base_url: str) -> str: + """Return ``/user/logout?redirect_to=/user/login``.""" + return ( + f"{gitea_base_url.rstrip('/')}/user/logout" + f"?{urlencode({'redirect_to': '/user/login'})}" + ) + + +# -------------------------------------------------------------------- +# OIDC discovery + token exchange (stdlib HTTP) +# -------------------------------------------------------------------- + + +def _tls_context(insecure: bool) -> ssl.SSLContext: + ctx = ssl.create_default_context() + if insecure: + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + return ctx + + +def _http_get_json(url: str, *, insecure_tls: bool, timeout: float = 15.0) -> dict[str, Any]: + req = urllib.request.Request(url, method="GET", headers={"Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=timeout, context=_tls_context(insecure_tls)) as resp: + body = resp.read().decode("utf-8") + try: + data = json.loads(body) + except json.JSONDecodeError as exc: + raise AuthError(f"GET {url} returned non-JSON body: {exc}") from exc + if not isinstance(data, dict): + raise AuthError(f"GET {url} returned non-object JSON: {type(data).__name__}") + return data + + +def _http_post_form( + url: str, + form: dict[str, str], + *, + insecure_tls: bool, + timeout: float = 15.0, + auth_bearer: str | None = None, +) -> dict[str, Any]: + data = urlencode(form).encode("ascii") + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + } + if auth_bearer: + headers["Authorization"] = f"Bearer {auth_bearer}" + req = urllib.request.Request(url, data=data, method="POST", headers=headers) + try: + with urllib.request.urlopen(req, timeout=timeout, context=_tls_context(insecure_tls)) as resp: + body = resp.read().decode("utf-8") + except urllib.error.HTTPError as exc: + err_body = exc.read().decode("utf-8", errors="replace") + raise AuthError( + f"POST {url} failed with HTTP {exc.code}: {err_body.strip()}" + ) from exc + try: + payload = json.loads(body) + except json.JSONDecodeError as exc: + raise AuthError(f"POST {url} returned non-JSON body: {exc}") from exc + if not isinstance(payload, dict): + raise AuthError(f"POST {url} returned non-object JSON: {type(payload).__name__}") + if "error" in payload: + raise AuthError( + f"OAuth error from {url}: {payload.get('error')} " + f"({payload.get('error_description', '')})" + ) + return payload + + +def discover_endpoints(config: ForgeAuthConfig) -> dict[str, str]: + """Call Gitea's ``/.well-known/openid-configuration`` and return the + three endpoints used by the CLI, validated against the issuer. + """ + url = f"{config.gitea_base_url}/.well-known/openid-configuration" + payload = _http_get_json(url, insecure_tls=config.insecure_tls) + issuer = payload.get("issuer") + if not isinstance(issuer, str) or issuer.rstrip("/") != config.gitea_base_url.rstrip("/"): + raise AuthError( + f"OIDC discovery issuer {issuer!r} does not match " + f"FORGE_GITEA_URL {config.gitea_base_url!r}" + ) + out: dict[str, str] = {} + for key in ("authorization_endpoint", "token_endpoint", "userinfo_endpoint"): + value = payload.get(key) + if not isinstance(value, str) or not value.startswith(f"{config.gitea_base_url}/"): + raise AuthError(f"OIDC discovery missing/invalid {key}: {value!r}") + out[key] = value + return out + + +def build_authorize_url( + config: ForgeAuthConfig, + endpoints: dict[str, str], + *, + challenge: str, + state: str, + force_login_prompt: bool = True, +) -> str: + """PKCE authorise URL with optional ``prompt=login`` and ``login_hint``. + + ``prompt=login`` (OIDC Core §3.1.2.1) is the default: it forces + Gitea to re-authenticate the user even when a session cookie is + already present, which is the right ergonomic for the first attempt + of ``just login``. Setting ``force_login_prompt=False`` drops the + parameter so the second attempt of an auto-retry (after a grant + revocation) reuses the established session cookie and only triggers + the consent screen, halving the browser-side prompts. The post-auth + ``expected_username`` check in ``run_login`` still enforces the + wrong-user guard on either path. ``login_hint`` is unaffected. + """ + params: dict[str, str] = { + "client_id": config.client_id, + "redirect_uri": config.redirect_uri, + "response_type": "code", + "scope": config.scopes, + "state": state, + "code_challenge": challenge, + "code_challenge_method": "S256", + } + if force_login_prompt: + params["prompt"] = "login" + if config.expected_username: + params["login_hint"] = config.expected_username + return f"{endpoints['authorization_endpoint']}?{urlencode(params)}" + + +def exchange_code_for_tokens( + config: ForgeAuthConfig, + endpoints: dict[str, str], + *, + code: str, + verifier: str, +) -> dict[str, Any]: + return _http_post_form( + endpoints["token_endpoint"], + { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": config.redirect_uri, + "client_id": config.client_id, + "code_verifier": verifier, + }, + insecure_tls=config.insecure_tls, + ) + + +def refresh_access_token( + config: ForgeAuthConfig, + endpoints: dict[str, str], + *, + refresh_token: str, +) -> dict[str, Any]: + return _http_post_form( + endpoints["token_endpoint"], + { + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": config.client_id, + }, + insecure_tls=config.insecure_tls, + ) + + +def fetch_userinfo( + config: ForgeAuthConfig, + endpoints: dict[str, str], + *, + access_token: str, +) -> dict[str, Any]: + req = urllib.request.Request( + endpoints["userinfo_endpoint"], + method="GET", + headers={ + "Authorization": f"Bearer {access_token}", + "Accept": "application/json", + }, + ) + with urllib.request.urlopen(req, timeout=15, context=_tls_context(config.insecure_tls)) as resp: + body = resp.read().decode("utf-8") + return json.loads(body) + + +# -------------------------------------------------------------------- +# Loopback callback server (single-shot) +# -------------------------------------------------------------------- + + +class _CallbackHandler(BaseHTTPRequestHandler): + def do_GET(self) -> None: # noqa: N802 (BaseHTTPRequestHandler API) + parsed = urlparse(self.path) + query = parse_qs(parsed.query) + code = (query.get("code") or [None])[0] + state = (query.get("state") or [None])[0] + error = (query.get("error") or [None])[0] + error_description = (query.get("error_description") or [None])[0] + server = self.server # type: ignore[attr-defined] + if error: + self.send_response(400) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers() + self.wfile.write( + b"

Login failed

Close this window.

" + ) + server.result_queue.put( + _build_authorize_error( + error, + error_description, + server.gitea_base_url, + client_id=getattr(server, "client_id", ""), + scopes=getattr(server, "scopes", ""), + ) + ) + return + if not code or not state: + self.send_response(400) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers() + self.wfile.write(b"

Missing code or state

") + server.result_queue.put(AuthError("OAuth callback is missing code or state")) + return + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers() + self.wfile.write( + b"" + b"

Login complete.

" + b"

Close this window. Return to the terminal.

" + b"" + ) + server.result_queue.put((code, state)) + + def log_message(self, format: str, *args: Any) -> None: # noqa: A003 + return # silence default logging + + +class _LoopbackServer(HTTPServer): + def __init__( + self, + addr: tuple[str, int], + gitea_base_url: str = "", + client_id: str = "", + scopes: str = "", + ) -> None: + super().__init__(addr, _CallbackHandler) + self.result_queue: Queue[tuple[str, str] | BaseException] = Queue(maxsize=1) + # Read by _CallbackHandler when assembling _build_authorize_error. + self.gitea_base_url = gitea_base_url + self.client_id = client_id + self.scopes = scopes + + +def _build_authorize_error( + error: str, + error_description: str | None, + gitea_base_url: str, + client_id: str = "", + scopes: str = "", +) -> AuthError: + """Map an RFC 6749 §4.1.2.1 authorize-error redirect to ``AuthError``. + + Parameters + ---------- + error : str + The ``error`` query parameter (``server_error``, + ``access_denied``, ``invalid_request``, ...). + error_description : str or None + The ``error_description`` query parameter; human text set by + Gitea. ``None`` and empty string are treated identically. + gitea_base_url : str + Base URL of the Gitea server. Used to build the user-settings + URL in the remediation message. Empty string yields a + ```` placeholder. + client_id : str, optional + OAuth client id requesting the authorization. Surfaced in the + "different scope" message to disambiguate the grant row in + Gitea's settings page. + scopes : str, optional + Space-separated scope set requested by this client. Surfaced + in the "different scope" message. + + Returns + ------- + AuthError + A user-facing error whose body is at most 5 lines (see RULE + #2 §D.3) and, for the ``different scope`` case, references + ``docs/oauth-grant-scope-mismatch.md``. + """ + desc = (error_description or "").strip() + low = desc.lower() + base = gitea_base_url.rstrip("/") + settings_url = ( + f"{base}/user/settings/applications" + if base + else "/user/settings/applications" + ) + + if "different scope" in low or ("scope" in low and "grant" in low): + cid = (client_id or "").strip() + scope_fragment = scopes or "" + return ScopeMismatchAuthError( + f'Gitea server_error: "{desc or error}".\n' + f" Client ID: {cid}\n" + f" Revoke at: {settings_url} " + f"(\"Authorized OAuth2 Applications\").\n" + f" Requested: {scope_fragment}\n" + " See: docs/oauth-grant-scope-mismatch.md", + gitea_base_url=gitea_base_url, + client_id=cid, + scopes=scope_fragment, + ) + + if error == "access_denied" or "access_denied" in low or "denied" in low: + return AuthError( + f"access_denied: {desc or error}. Re-run 'just login' and approve." + ) + + if desc: + return AuthError(f"authorize endpoint returned error: {error} ({desc})") + return AuthError(f"authorize endpoint returned error: {error}") + + +def wait_for_callback( + host: str, + port: int, + *, + timeout_seconds: float = 300.0, + gitea_base_url: str = "", + client_id: str = "", + scopes: str = "", +) -> tuple[str, str]: + """Bind a loopback HTTP server, serve one request, return ``(code, state)``. + + Parameters + ---------- + host, port : str, int + Loopback address to bind. + timeout_seconds : float + Queue ``get`` timeout. + gitea_base_url, client_id, scopes : str + Forwarded to ``_LoopbackServer`` for the error path only; unused + on the success path. + + Raises + ------ + AuthError + On bind failure, timeout, or an authorize-error redirect. + """ + try: + server = _LoopbackServer( + (host, port), + gitea_base_url=gitea_base_url, + client_id=client_id, + scopes=scopes, + ) + except OSError as exc: + raise AuthError( + f"cannot bind loopback server on {host}:{port}: {exc}. " + f"Is another 'just login' still running? " + f"If port {port} is held by an unrelated process, override " + f"FSDGG_CLI_REDIRECT_URI in .env (note: the port must match " + f"the OAuth app registered in Gitea)." + ) from exc + thread = threading.Thread(target=server.handle_request, daemon=True) + thread.start() + try: + item = server.result_queue.get(timeout=timeout_seconds) + except Exception as exc: + raise AuthError( + f"timed out after {int(timeout_seconds)}s waiting for OAuth " + f"callback. Complete browser login and retry." + ) from exc + finally: + server.server_close() + if isinstance(item, BaseException): + raise item + return item + + +# -------------------------------------------------------------------- +# Auth-file read / merge / write +# -------------------------------------------------------------------- + + +GATEWAY_REQUIRED_FIELDS_DEFAULTS: dict[str, Any] = { + "username": "", + "access_token": "", + "expires_in": 0, + "issued_at": 0.0, + "public_base_url": "", + "index_name": "", +} + +GATEWAY_OPTIONAL_FIELDS: frozenset[str] = frozenset( + {"gitea_access_token", "gitea_token_expires_at"} +) + +# Welcome-repo-private extension fields. Kept in the same file for +# single-source-of-truth; the gateway's loader ignores unknown keys so +# these are forward-compatible. +FORGE_EXTRA_FIELDS: frozenset[str] = frozenset( + { + "_forge_refresh_token", + "_forge_client_id", + "_forge_gitea_base_url", + "_forge_issued_at", + } +) + + +@dataclass +class AuthFile: + """Round-trippable view over the stored JSON. Unknown keys are + preserved verbatim for forward-compat with the gateway schema. + """ + + raw: dict[str, Any] = field(default_factory=dict) + + @classmethod + def read(cls, path: Path) -> AuthFile: + if not path.is_file(): + return cls(raw={}) + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise AuthError( + f"{path} is not valid JSON: {exc}. " + f"Delete it and re-run 'just login' to rewrite it cleanly." + ) from exc + if not isinstance(payload, dict): + raise AuthError(f"{path} does not contain a JSON object") + return cls(raw=payload) + + # ---- accessors -------------------------------------------------- + + @property + def gitea_access_token(self) -> str: + value = self.raw.get("gitea_access_token") or "" + return str(value) if value else "" + + @property + def gitea_token_expires_at(self) -> float | None: + value = self.raw.get("gitea_token_expires_at") + if value is None: + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + @property + def refresh_token(self) -> str: + return str(self.raw.get("_forge_refresh_token") or "") + + @property + def username(self) -> str: + return str(self.raw.get("username") or "") + + # ---- predicates ------------------------------------------------- + + def has_live_gitea_token(self, *, leeway_seconds: float = 30.0) -> bool: + if not self.gitea_access_token: + return False + expires_at = self.gitea_token_expires_at + if expires_at is None: + return True # unknown expiry: trust the token; API will reject if stale + return time.time() + leeway_seconds < expires_at + + # ---- merging ---------------------------------------------------- + + def merge_login( + self, + *, + username: str, + gitea_access_token: str, + gitea_token_expires_at: float | None, + refresh_token: str, + client_id: str, + gitea_base_url: str, + ) -> None: + """Write Gitea-side fields without clobbering gateway fields. + + If an existing gateway bearer lives in the file, it is kept + intact so that a subsequent ``repos-login`` inside the + orchestrator does not have to re-run the browser flow. + """ + for key, default in GATEWAY_REQUIRED_FIELDS_DEFAULTS.items(): + self.raw.setdefault(key, default) + # Always refresh the "issued_at" of the *Gitea* login side. + now = time.time() + self.raw["username"] = username + self.raw["gitea_access_token"] = gitea_access_token + if gitea_token_expires_at is not None: + self.raw["gitea_token_expires_at"] = gitea_token_expires_at + self.raw["_forge_refresh_token"] = refresh_token + self.raw["_forge_client_id"] = client_id + self.raw["_forge_gitea_base_url"] = gitea_base_url + self.raw["_forge_issued_at"] = now + + def merge_refresh( + self, + *, + gitea_access_token: str, + gitea_token_expires_at: float | None, + refresh_token: str | None, + ) -> None: + self.raw["gitea_access_token"] = gitea_access_token + if gitea_token_expires_at is not None: + self.raw["gitea_token_expires_at"] = gitea_token_expires_at + if refresh_token: + self.raw["_forge_refresh_token"] = refresh_token + + def write(self, path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(self.raw, indent=2) + "\n", encoding="utf-8") + os.chmod(tmp, 0o600) + os.replace(tmp, path) + + +# -------------------------------------------------------------------- +# Top-level flows (login, refresh, logout) +# -------------------------------------------------------------------- + + +def _extract_expiry(payload: dict[str, Any]) -> float | None: + raw = payload.get("expires_in") + if raw is None: + return None + try: + return time.time() + int(raw) + except (TypeError, ValueError): + return None + + +def _print_headless_guidance(auth_url: str, redirect) -> None: + """Print the authorise URL and reachability guidance for headless mode. + + Uses the actual loopback host/port from the parsed redirect URI (not + hardcoded) and the current hostname/user so the SSH-forward template + is copy-pasteable. + """ + cb_host = redirect.hostname or "127.0.0.1" + cb_port = redirect.port + path = redirect.path or "/callback" + try: + hostname = socket.getfqdn() or socket.gethostname() or "" + except OSError: + hostname = "" + user = os.environ.get("USER") or os.environ.get("LOGNAME") or "" + in_ssh = bool(os.environ.get("SSH_CONNECTION") or os.environ.get("SSH_TTY")) + + info_tag = _tag(chr(0x1B) + "[36m", "info") + + lines = [ + "", + f"{info_tag} paste this URL into a browser:", + "", + f" {auth_url}", + "", + f"{info_tag} after consent, Gitea will 302-redirect that browser to the", + f" local callback on THIS machine: http://{cb_host}:{cb_port}{path}", + " (loopback HTTP per RFC 8252 §7.3; not a public endpoint).", + "", + ] + if in_ssh: + lines += [ + f"{info_tag} this process is running inside an SSH session. From the", + " machine where the browser will open, run:", + "", + f" ssh -L {cb_port}:127.0.0.1:{cb_port} {user}@{hostname}", + "", + " and paste the URL above into THAT machine's browser.", + "", + ] + else: + lines += [ + f"{info_tag} if this machine is remote, from the machine with the", + " browser run (before pasting the URL):", + "", + f" ssh -L {cb_port}:127.0.0.1:{cb_port} {user}@{hostname}", + "", + ] + lines += [ + f"{info_tag} reachability probe (run on the browser-side machine).", + " Any response, including 'Missing code or state',", + " confirms the listener is reachable:", + "", + f" curl -sS -m 2 http://127.0.0.1:{cb_port}/", + "", + " 'Connection refused' or a timeout means the SSH forward", + " above is not active yet.", + "", + ] + sys.stderr.write("\n".join(lines) + "\n") + sys.stderr.flush() + + +def run_login( + config: ForgeAuthConfig, + *, + open_browser: bool = True, + force: bool = False, + print_authorize_url: bool = True, + force_login_prompt: bool = True, +) -> AuthFile: + """Run the full PKCE flow and persist the result. + + Idempotent: if the stored file already carries a live Gitea token + and ``force`` is False, skip everything and return the existing + state. The caller is the one deciding when to force a refresh. + ``force_login_prompt`` is forwarded to ``build_authorize_url`` and + is set to False by the auto-retry path after a grant revocation so + Gitea reuses the existing browser session. + """ + store = auth_store_path() + existing = AuthFile.read(store) + if not force and existing.has_live_gitea_token(): + return existing + + endpoints = discover_endpoints(config) + verifier, challenge = pkce_pair() + session_key = secrets.token_bytes(32) + nonce = secrets.token_urlsafe(24) + state = sign_state(session_key, nonce) + + auth_url = build_authorize_url( + config, + endpoints, + challenge=challenge, + state=state, + force_login_prompt=force_login_prompt, + ) + redirect = urlparse(config.redirect_uri) + assert redirect.hostname and redirect.port # validated upstream + + if print_authorize_url: + if open_browser: + cli_info( + f"open this URL in the browser if automatic launch does not " + f"automatically:\n {auth_url}" + ) + else: + _print_headless_guidance(auth_url, redirect) + if open_browser: + try: + webbrowser.open(auth_url, new=2) + except webbrowser.Error: + pass + + code, returned_state = wait_for_callback( + redirect.hostname, + redirect.port, + gitea_base_url=config.gitea_base_url, + client_id=config.client_id, + scopes=config.scopes, + ) + verify_state(session_key, returned_state) + + token_payload = exchange_code_for_tokens( + config, endpoints, code=code, verifier=verifier + ) + access_token = str(token_payload.get("access_token") or "") + refresh_token = str(token_payload.get("refresh_token") or "") + if not access_token: + raise AuthError(f"token endpoint returned no access_token: {token_payload}") + if not refresh_token: + # Gitea currently always returns a refresh_token for PKCE public + # clients; flag loudly if that ever changes rather than silently + # degrading the UX. + raise AuthError("token endpoint returned no refresh_token; cannot auto-renew") + + userinfo = fetch_userinfo(config, endpoints, access_token=access_token) + username = str( + userinfo.get("preferred_username") + or userinfo.get("login") + or userinfo.get("name") + or "" + ) + if not username: + raise AuthError(f"userinfo did not expose a username: {userinfo}") + + # Post-auth identity check: prompt=login is a hint, not a guarantee. + if config.expected_username and username.lower() != config.expected_username.lower(): + logout_url = build_gitea_logout_url(config.gitea_base_url) + if open_browser: + try: + webbrowser.open(logout_url, new=2) + except webbrowser.Error: + pass + raise AuthError( + "Gitea authorised the login as " + f"'{username}', but FORGE_GITEA_USERNAME in .env is " + f"'{config.expected_username}'. The stored auth file has " + "NOT been updated.\n" + " Recovery:\n" + f" 1. Open the Gitea logout URL: {logout_url}\n" + f" 2. Sign in as '{config.expected_username}'\n" + " 3. Re-run 'just login'" + ) + + existing.merge_login( + username=username, + gitea_access_token=access_token, + gitea_token_expires_at=_extract_expiry(token_payload), + refresh_token=refresh_token, + client_id=config.client_id, + gitea_base_url=config.gitea_base_url, + ) + existing.write(store) + return existing + + +def run_refresh(config: ForgeAuthConfig, *, must_refresh: bool = False) -> AuthFile: + """Refresh the stored access token using the stored refresh token. + + If ``must_refresh`` is False and the access token is still live, + this is a no-op. If refresh fails, raises ``AuthError`` (the + credential helper surfaces this to git as "fall through to prompt", + and the CLI ``just refresh`` surfaces it with an instruction to + re-run ``just login``). + """ + store = auth_store_path() + existing = AuthFile.read(store) + if not must_refresh and existing.has_live_gitea_token(): + return existing + rt = existing.refresh_token + if not rt: + raise AuthError( + "no refresh token stored; run 'just login' to authenticate from scratch" + ) + endpoints = discover_endpoints(config) + token_payload = refresh_access_token(config, endpoints, refresh_token=rt) + new_access = str(token_payload.get("access_token") or "") + if not new_access: + raise AuthError(f"refresh returned no access_token: {token_payload}") + existing.merge_refresh( + gitea_access_token=new_access, + gitea_token_expires_at=_extract_expiry(token_payload), + refresh_token=str(token_payload.get("refresh_token") or ""), + ) + existing.write(store) + return existing + + +def run_logout() -> Path | None: + """Remove every codevalet-managed field from the auth file. + + If the file only ever held welcome-managed fields, delete it entirely. If the + gateway has already populated its own fields (access_token etc.), + wipe only the Gitea + welcome-repo keys and leave the rest. + """ + store = auth_store_path() + if not store.is_file(): + return None + try: + existing = AuthFile.read(store) + except AuthError: + store.unlink() + return store + for key in list(existing.raw.keys()): + if key in GATEWAY_OPTIONAL_FIELDS or key in FORGE_EXTRA_FIELDS: + existing.raw.pop(key, None) + # If nothing of value remains, remove the file outright. + if all( + not existing.raw.get(k) + for k in ("access_token", "public_base_url", "index_name") + ): + store.unlink() + return store + existing.write(store) + return store + + +# -------------------------------------------------------------------- +# CLI +# -------------------------------------------------------------------- + + +def _can_prompt_for_revoke() -> bool: + """Return True only when the environment is interactive enough to + block on ``input()`` and have the operator click "Revoke". + + Disabling vectors (any one of these returns False): + + * ``FORGE_AUTO_REVOKE`` set to ``0``/``no``/``false``: explicit + opt-out for callers that want strict fail-fast behaviour. + * ``FORGE_SETUP_YES=1``: non-interactive auto-yes mode used by + ``setup.sh`` and CI; never block on ``input()``. + * stderr or stdin is not a TTY: avoids deadlocks in piped or + backgrounded executions. + """ + val = os.environ.get("FORGE_AUTO_REVOKE", "1").strip().lower() + if val in {"0", "no", "false"}: + return False + if os.environ.get("FORGE_SETUP_YES", "0").strip() == "1": + return False + try: + return sys.stderr.isatty() and sys.stdin.isatty() + except (AttributeError, ValueError): + return False + + +def _prompt_revoke_and_wait( + exc: ScopeMismatchAuthError, *, open_browser: bool +) -> bool: + """Drive the manual revoke step. Returns True iff the operator + pressed Enter (i.e., agreed to retry); False on EOF/Ctrl-C. + + Side effects: prints the revoke URL and ``client_id`` to stderr; + when ``open_browser`` is True, also calls ``webbrowser.open`` on + the revoke URL (best-effort; failure is silent). + """ + cli_info( + 'opening Gitea\'s "Authorized OAuth2 Applications" page so the ' + "conflicting grant can be revoked." + ) + cli_info(f" URL: {exc.revoke_url}") + cli_info(f" Client ID: {exc.client_id}") + if open_browser: + try: + webbrowser.open(exc.revoke_url, new=2) + except webbrowser.Error: + pass + cli_info( + 'after clicking "Revoke" on the row matching the Client ID ' + "above, press Enter here to retry login (or Ctrl-C to abort)." + ) + try: + input("") + except (EOFError, KeyboardInterrupt): + return False + return True + + +def _cmd_login(argv: list[str]) -> int: + force = "--force" in argv + no_browser = "--no-browser" in argv + config = ForgeAuthConfig.from_env() + try: + state = run_login(config, open_browser=not no_browser, force=force) + except ScopeMismatchAuthError as exc: + cli_err(str(exc)) + if not _can_prompt_for_revoke(): + return 1 + if not _prompt_revoke_and_wait(exc, open_browser=not no_browser): + return 1 + # Single retry. ``force=True`` bypasses the live-token short- + # circuit; ``force_login_prompt=False`` reuses the browser + # session cookie established by the failed first attempt so + # Gitea only shows the consent screen on the retry. + state = run_login( + config, + open_browser=not no_browser, + force=True, + force_login_prompt=False, + ) + cli_ok(f"authenticated as: {state.username}") + cli_info(f"auth file: {auth_store_path()}") + return 0 + + +def _cmd_refresh(argv: list[str]) -> int: + must = "--force" in argv + config = ForgeAuthConfig.from_env() + state = run_refresh(config, must_refresh=must) + expires_in = ( + int((state.gitea_token_expires_at or 0) - time.time()) + if state.gitea_token_expires_at + else -1 + ) + if expires_in > 0: + cli_ok(f"token refreshed, valid for ~{expires_in}s") + else: + cli_ok("token refreshed") + return 0 + + +def _cmd_logout(_: list[str]) -> int: + path = run_logout() + if path is None: + cli_info("no auth file to remove") + else: + cli_ok(f"cleared {path}") + return 0 + + +def _cmd_status(_: list[str]) -> int: + path = auth_store_path() + state = AuthFile.read(path) + alive = state.has_live_gitea_token() + exp = state.gitea_token_expires_at + print(f"path: {path}") + print(f"file exists: {path.is_file()}") + print(f"has gitea_access_token: {bool(state.gitea_access_token)}") + print(f"has _forge_refresh_token: {bool(state.refresh_token)}") + print(f"username: {state.username or ''}") + print(f"expires_at: {exp if exp is not None else ''}") + print(f"live: {alive}") + return 0 if alive else 1 + + +_COMMANDS = { + "login": _cmd_login, + "refresh": _cmd_refresh, + "logout": _cmd_logout, + "status": _cmd_status, +} + + +def main(argv: list[str]) -> int: + if len(argv) < 2 or argv[1] not in _COMMANDS: + print( + "usage: forge_auth.py [--force] [--no-browser]", + file=sys.stderr, + ) + return 2 + try: + return _COMMANDS[argv[1]](argv[2:]) + except AuthError as exc: + cli_err(str(exc)) + return 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/scripts/forge_login.sh b/scripts/forge_login.sh new file mode 100755 index 0000000..92fd6cf --- /dev/null +++ b/scripts/forge_login.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# +# Run the interactive PKCE OAuth2 login against Gitea, then install +# the git credential helper so subsequent git operations against +# FORGE_GITEA_URL are silent. Idempotent. + +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +# shellcheck disable=SC1091 +. "$here/common.sh" + +load_env +require_env FORGE_GITEA_URL +require_env FSDGG_CLI_CLIENT_ID +require_cmd python3 + +# Forward every useful flag. --force re-runs PKCE even if an existing +# live token is present. --no-browser prints the URL but skips the +# webbrowser.open call (for headless / SSH sessions). +python3 "$here/forge_auth.py" login "$@" + +# Install the credential helper. Safe to run every time. +"$here/install-git-credential-helper.sh" + +ok "login complete" +note "git operations against the configured Gitea server now authenticate silently." +note "Test with:" +note " git ls-remote \"\$FORGE_ORCHESTRATOR_REPO_URL\"" diff --git a/scripts/git-credential-forge.py b/scripts/git-credential-forge.py new file mode 100755 index 0000000..f77b7fe --- /dev/null +++ b/scripts/git-credential-forge.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +"""Git credential helper backed by the codevalet OAuth auth file. + +Git invokes credential helpers with one of three verbs on argv[1]: + + get : read key=value fields from stdin; emit credentials on stdout + store : persist credentials (no-op here; tokens are OAuth-owned) + erase : forget credentials (no-op here; ``just logout`` owns that) + +On every ``get`` the helper: + +1. Reads the auth file (same path the gateway uses: + ``~/.forge-stack-devpi-gateway-gitea/client-auth.json`` by default, + or whatever ``FSDGG_AUTH_STORE_PATH`` / ``FSDGG_RUNTIME_DIR`` point + at). If the file is missing, the helper passes git's input through + unchanged so the normal prompt chain continues. +2. Checks that the git request host matches the host recorded in + ``_forge_gitea_base_url`` (or ``FORGE_GITEA_URL``). If not, passes + through. This prevents OAuth token disclosure to unrelated + hosts even if git mis-scopes its lookup. +3. If ``gitea_access_token`` is live, emits + ``username=`` and ``password=``. +4. If the token is expired and a refresh token is present, runs the + OAuth refresh flow (``forge_auth.run_refresh``) and retries once. + Refresh failures are surfaced by exiting non-zero; git then falls + through to the user's configured helper chain (normally a prompt). + +Security notes +-------------- +* The helper never writes to stdout except the credential key=value + block. Logs go to stderr. +* On OAuth refresh failure the helper exits **non-zero** rather than silently + returning stale credentials. +""" +from __future__ import annotations + +import os +import sys +from pathlib import Path +from urllib.parse import urlsplit + + +def _load_forge_auth_module(): + """Import ``forge_auth`` from the same directory as this script. + + This works both when the helper is invoked directly from + ``scripts/`` (development) and after installation under + ``~/.local/bin`` (because the installer copies ``forge_auth.py`` + alongside ``git-credential-forge``). + """ + here = Path(__file__).resolve().parent + if str(here) not in sys.path: + sys.path.insert(0, str(here)) + import forge_auth # noqa: E402 + + return forge_auth + + +forge_auth = _load_forge_auth_module() + + +# -------------------------------------------------------------------- +# git credential protocol I/O +# -------------------------------------------------------------------- + + +def read_git_fields(stream) -> dict[str, str]: + fields: dict[str, str] = {} + for line in stream: + line = line.rstrip("\n") + if not line: + break + if "=" not in line: + raise ValueError(f"unexpected git credential input line: {line!r}") + key, _, value = line.partition("=") + fields[key] = value + return fields + + +def emit(fields: dict[str, str]) -> None: + for key, value in fields.items(): + sys.stdout.write(f"{key}={value}\n") + + +# -------------------------------------------------------------------- +# Host matching +# -------------------------------------------------------------------- + + +def _configured_host() -> tuple[str, str, int | None] | None: + """Return (scheme, host, port) of the host this helper is allowed + to produce credentials for. + + Priority: + 1. The ``_forge_gitea_base_url`` field inside the stored auth + file: that is the host recorded during authentication. + 2. The ``FORGE_GITEA_URL`` env var (pre-login override). + Returns None if neither is set; the helper then passes through. + """ + store = forge_auth.auth_store_path() + state = forge_auth.AuthFile.read(store) + stored_url = str(state.raw.get("_forge_gitea_base_url") or "").strip() + if stored_url: + parsed = urlsplit(stored_url) + return parsed.scheme.lower(), (parsed.hostname or "").lower(), parsed.port + env_url = os.environ.get("FORGE_GITEA_URL", "").strip() + if env_url: + parsed = urlsplit(env_url) + return parsed.scheme.lower(), (parsed.hostname or "").lower(), parsed.port + return None + + +def _request_matches( + fields: dict[str, str], configured: tuple[str, str, int | None] +) -> bool: + scheme, host, port = configured + git_scheme = fields.get("protocol", "").lower() + git_host = fields.get("host", "").lower() + # git passes host:port as "host" when the URL carried a port. + if ":" in git_host: + host_part, _, port_str = git_host.partition(":") + try: + git_port = int(port_str) + except ValueError: + return False + git_host = host_part + else: + git_port = None + if git_scheme != scheme: + return False + if git_host != host: + return False + if port is not None and git_port != port: + return False + if port is None and git_port is not None: + # Stored URL had no explicit port (default 443) but request does; + # only match the default-HTTPS case. + if scheme == "https" and git_port != 443: + return False + if scheme == "http" and git_port != 80: + return False + return True + + +# -------------------------------------------------------------------- +# Commands +# -------------------------------------------------------------------- + + +def cmd_get(fields: dict[str, str]) -> int: + configured = _configured_host() + if configured is None: + emit(fields) # pass-through: helper scope is unknown + return 0 + if not _request_matches(fields, configured): + emit(fields) # pass-through: request targets a different host + return 0 + + state = forge_auth.AuthFile.read(forge_auth.auth_store_path()) + + # Fast path: stored access token is still live. + if state.has_live_gitea_token(): + _emit_credentials(fields, state) + return 0 + + # Slow path: try to refresh. + try: + config = forge_auth.ForgeAuthConfig.from_env() + except forge_auth.AuthError: + forge_auth.cli_warn( + "stored token is expired and FORGE_GITEA_URL / " + "FSDGG_CLI_CLIENT_ID are not set in the environment; " + "cannot refresh. Run 'just login' to re-authenticate." + ) + emit(fields) + return 0 + try: + refreshed = forge_auth.run_refresh(config, must_refresh=True) + except forge_auth.AuthError as exc: + forge_auth.cli_warn( + f"token refresh failed: {exc}. " + f"Run 'just login' to re-authenticate." + ) + emit(fields) + return 0 + _emit_credentials(fields, refreshed) + return 0 + + +def _emit_credentials(fields: dict[str, str], state: forge_auth.AuthFile) -> None: + token = state.gitea_access_token + if not token: + emit(fields) + return + out = dict(fields) + out["username"] = state.username or fields.get("username") or "oauth" + out["password"] = token + emit(out) + + +def cmd_consume_noop(stream) -> int: + for _ in stream: + pass + return 0 + + +def main(argv: list[str]) -> int: + if len(argv) < 2: + print("usage: git-credential-forge ", file=sys.stderr) + return 2 + action = argv[1] + if action == "get": + fields = read_git_fields(sys.stdin) + return cmd_get(fields) + if action in ("store", "erase"): + return cmd_consume_noop(sys.stdin) + print(f"git-credential-forge: unsupported action: {action}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/scripts/install-git-credential-helper.sh b/scripts/install-git-credential-helper.sh new file mode 100755 index 0000000..0097157 --- /dev/null +++ b/scripts/install-git-credential-helper.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# +# Install ~/.local/bin/git-credential-forge (copies both +# git-credential-forge.py and its forge_auth.py companion module) and +# wire `git config --global credential..*` so git +# calls the helper for every matching URL. Idempotent. + +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +# shellcheck disable=SC1091 +. "$here/common.sh" + +load_env +require_env FORGE_GITEA_URL +require_cmd git +require_cmd python3 + +helper_src="$here/git-credential-forge.py" +module_src="$here/forge_auth.py" +[ -f "$helper_src" ] || die "missing helper source: $helper_src" +[ -f "$module_src" ] || die "missing module source: $module_src" + +mkdir -p "$LOCAL_BIN" +install -m 0755 "$helper_src" "$CRED_HELPER" +install -m 0644 "$module_src" "$LOCAL_BIN/forge_auth.py" +info "installed credential helper -> $CRED_HELPER" +info "installed module -> $LOCAL_BIN/forge_auth.py" + +# Syntax check both installed artefacts before the first git invocation. +python3 -c 'import sys; compile(open(sys.argv[1]).read(), sys.argv[1], "exec")' "$CRED_HELPER" +python3 -c 'import sys; compile(open(sys.argv[1]).read(), sys.argv[1], "exec")' "$LOCAL_BIN/forge_auth.py" + +# Scope to FORGE_GITEA_URL only. git-config(1): credential..* +# applies only when git's resolved credential URL matches. Other hosts +# are not affected. +key="credential.${FORGE_GITEA_URL}.helper" +use_path_key="credential.${FORGE_GITEA_URL}.useHttpPath" + +# Remove previous helper entries to avoid stacking duplicate helpers. +git config --global --unset-all "$key" 2>/dev/null || true +git config --global --unset-all "$use_path_key" 2>/dev/null || true + +# Username is derived from the OAuth token at runtime by the helper. +# credential..username remains unset to avoid pinning stale state. +# The helper emits username= on every `get` request. +git config --global --add "$key" "$CRED_HELPER" +git config --global "$use_path_key" true + +info "git config --global $key -> $CRED_HELPER" +info "git config --global $use_path_key -> true" diff --git a/scripts/next_steps.sh b/scripts/next_steps.sh new file mode 100755 index 0000000..2608459 --- /dev/null +++ b/scripts/next_steps.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# +# Render or execute a setup plan defined by one of the orchestrator's +# setup-steps manifests. The welcome repo does not hardcode step +# lists; every step is read from the manifest at runtime. +# +# Default manifest / recipe is the operator pair +# (operator_setup_steps.json / operator-setup). setup.sh in this +# scaffold always targets the operator pair; the --manifest and +# --recipe flags here are the single override point for callers that +# need the contributor pair (or any future pair). +# +# Modes: +# default : print the plan. +# --run : exec `just ` inside the orchestrator. +# +# Manifest + recipe selection: +# --manifest NAME json file under /scripts/ +# (default: operator_setup_steps.json). +# --recipe NAME just recipe to exec under --run +# (default: operator-setup). +# +# Flags (forwarded to the orchestrator under --run): +# --headless, --no-browser headless mode +# --yes non-interactive +# --dry-run, --plan-only plan-only execution +# --skip-optional skip optional steps +# --only ID[,ID...] run the listed step ids +# -h, --help usage + +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +# shellcheck disable=SC1091 +. "$here/common.sh" + +load_env + +MODE="print" # "print" or "run" +# Operator-first defaults. `just next-steps` prints the operator plan +# and `just run-next-steps` execs `just operator-setup`. The +# contributor pair is reachable only via explicit --manifest / +# --recipe overrides. +MANIFEST_FILE="operator_setup_steps.json" +RECIPE="operator-setup" +forward_args=() + +usage() { + cat <<'USAGE' +Usage: just next-steps [--run] [flags] + just run-next-steps [flags] + +Manifest + recipe: + --manifest NAME json file under /scripts/ + (default: operator_setup_steps.json) + --recipe NAME just recipe to exec under --run + (default: operator-setup) + +Flags (forwarded): --headless, --no-browser, --yes, --dry-run, + --skip-optional, --only ID[,ID...] + +Step list source: /scripts/. +USAGE +} + +while [ $# -gt 0 ]; do + case "$1" in + --run) MODE=run; shift ;; + --manifest) shift; [ $# -gt 0 ] || die "--manifest needs an argument"; MANIFEST_FILE="$1"; shift ;; + --manifest=*) MANIFEST_FILE="${1#--manifest=}"; shift ;; + --recipe) shift; [ $# -gt 0 ] || die "--recipe needs an argument"; RECIPE="$1"; shift ;; + --recipe=*) RECIPE="${1#--recipe=}"; shift ;; + --headless|--no-browser) forward_args+=("--headless"); shift ;; + --yes) forward_args+=("--yes"); shift ;; + --dry-run|--plan-only) forward_args+=("--dry-run"); shift ;; + --skip-optional) forward_args+=("--skip-optional"); shift ;; + --only) shift; [ $# -gt 0 ] || die "--only needs an argument"; forward_args+=("--only" "$1"); shift ;; + --only=*) forward_args+=("--only" "${1#--only=}"); shift ;; + -h|--help) usage; exit 0 ;; + --) shift; break ;; + *) die "unexpected argument: $1 (try --help)" ;; + esac +done + +# Locate the orchestrator checkout. +workspace_root="${FORGE_WORKSPACE_ROOT:-.}" +repo_url="${FORGE_ORCHESTRATOR_REPO_URL:-}" +if [ -z "$repo_url" ]; then + die "FORGE_ORCHESTRATOR_REPO_URL is unset; run 'just init-env' and edit .env" +fi +repo_name="$(basename "$repo_url" .git)" +orchestrator="$workspace_root/$repo_name" + +if [ ! -d "$orchestrator/.git" ]; then + err "orchestrator checkout not found at $orchestrator" + note "run 'just clone-orchestrator' (or 'just setup') first." + exit 1 +fi + +manifest="$orchestrator/scripts/$MANIFEST_FILE" +if [ ! -f "$manifest" ]; then + warn "orchestrator checkout at $orchestrator does not ship" + warn " scripts/$MANIFEST_FILE (expected in newer versions)." + note "falling back to the orchestrator README; open:" + note " $orchestrator/README.md" + exit 1 +fi + +step "next steps (source: orchestrator manifest)" +note "source: $manifest" +note "orchestrator: $orchestrator" +printf '\n' >&2 + +python3 - "$manifest" >&2 <<'PY' +import json, sys, textwrap +path = sys.argv[1] +with open(path, "r", encoding="utf-8") as f: + data = json.load(f) +if data.get("schema_version") != 1: + sys.stderr.write( + f"[err] unsupported setup-steps manifest schema in {path}: " + f"{data.get('schema_version')!r}\n" + ) + sys.exit(2) +steps = data.get("steps", []) +total = len(steps) +for i, s in enumerate(steps, 1): + sid = s["id"] + title = s.get("title", sid) + desc = s.get("desc", "") + cmd = " ".join(s["cmd"]) + opt = "" if "optional" in title.lower() else (" [optional]" if s.get("optional") else "") + sys.stderr.write(f" ({i}/{total}) {title}{opt}\n") + sys.stderr.write(f" id: {sid}\n") + if desc: + for line in textwrap.wrap(desc, width=76, + initial_indent=" ", + subsequent_indent=" "): + sys.stderr.write(line + "\n") + sys.stderr.write(f" run: {cmd}\n\n") +PY + +if [ "$MODE" != "run" ]; then + # ``${arr:+...}`` on an empty array triggers set -u; build explicitly. + fwd_suffix="" + if [ "${#forward_args[@]}" -gt 0 ]; then + fwd_suffix=" ${forward_args[*]}" + fi + note "Execute via:" + note " just run-next-steps --manifest ${MANIFEST_FILE} --recipe ${RECIPE}${fwd_suffix}" + note "Execute inside the orchestrator:" + note " cd $orchestrator && just ${RECIPE}${fwd_suffix}" + exit 0 +fi + +step "exec just ${RECIPE} (cwd=$orchestrator)" +if ! command -v just >/dev/null 2>&1; then + die "just not on PATH; install via 'just doctor' fix hint" +fi +cd "$orchestrator" +exec just "${RECIPE}" "${forward_args[@]}" diff --git a/scripts/revoke_grant.sh b/scripts/revoke_grant.sh new file mode 100755 index 0000000..925706f --- /dev/null +++ b/scripts/revoke_grant.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# +# revoke_grant.sh: open Gitea's "Authorized OAuth2 Applications" page so +# the operator can revoke a stale OAuth grant whose scope set no longer +# matches the unified scope set requested by this scaffold and the +# orchestrator's gateway. See docs/oauth-grant-scope-mismatch.md for +# the full failure mode and recovery procedure. + +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +# shellcheck disable=SC1091 +. "$here/common.sh" + +load_env +require_env FORGE_GITEA_URL +require_env FSDGG_CLI_CLIENT_ID +require_cmd python3 + +base="${FORGE_GITEA_URL%/}" +url="${base}/user/settings/applications" +cid="${FSDGG_CLI_CLIENT_ID}" + +cat <&2 + reply="$default" + else + printf '%s %s [%s] ' \ + "$(_fc_tag "$_FC_MAGENTA" '?')" "$msg" "$default" >/dev/tty + IFS= read -r reply /dev/tty + IFS= read -r reply /dev/null || true)" + if python3 "$here/forge_auth.py" status >/dev/null 2>&1; then + have_live=1 + fi +fi + +if [ "$have_stored" = "1" ] && [ "$have_live" = "0" ] \ + && [ -n "$existing_user" ] && [ -n "${FORGE_GITEA_USERNAME:-}" ] \ + && [ "${existing_user,,}" = "${FORGE_GITEA_USERNAME,,}" ]; then + note "stored session for '$existing_user' is not live; attempting silent refresh..." + if python3 "$here/forge_auth.py" refresh --force >/dev/null 2>&1; then + ok "refreshed stored session without a browser" + have_live=1 + else + note "refresh failed (refresh token likely expired); a fresh login is required" + fi +fi + +run_login=1 +if [ "$have_live" = "1" ] && [ -n "$existing_user" ]; then + if [ -n "${FORGE_GITEA_USERNAME:-}" ] \ + && [ "${existing_user,,}" != "${FORGE_GITEA_USERNAME,,}" ]; then + note "stored token is for '$existing_user' but .env configures '$FORGE_GITEA_USERNAME'" + reply="$(prompt_choice "Log out and sign in as '$FORGE_GITEA_USERNAME'? [Y/n]" "Y")" + case "$reply" in + [Nn]*) run_login=0; note "keeping the existing '$existing_user' session (forge_login may still reject it)";; + *) python3 "$here/forge_auth.py" logout >/dev/null; ok "cleared stored Gitea tokens";; + esac + else + ok "stored token is live for '$existing_user'" + reply="$(prompt_choice "Reuse this session, or log out and re-authenticate? [R]euse / [L]ogout [R]" "R")" + case "$reply" in + [Ll]*) python3 "$here/forge_auth.py" logout >/dev/null; ok "cleared stored Gitea tokens";; + *) run_login=0; ok "reusing the stored session";; + esac + fi +elif [ "$have_stored" = "1" ] && [ -n "$existing_user" ]; then + note "stored session for '$existing_user' is stale and could not be refreshed silently; re-authenticating." +fi + +if [ "$run_login" = "1" ]; then + if [ "$headless" = "1" ] && [ "${FORGE_SETUP_YES:-0}" = "1" ]; then + die "cannot complete a fresh login under --headless + FORGE_SETUP_YES=1: + a browser-based OAuth step is required but both flags + forbid any interaction. Recovery options: + 1. Drop FORGE_SETUP_YES and re-run 'just setup --headless' + (setup prints the authorisation URL and waits for + browser completion). + 2. Run 'just login' once on a machine with a browser + to populate the stored refresh token, then re-run + 'just setup --headless' from the headless host. + 3. Run 'just setup' (with a browser available) instead." + fi + if [ "$headless" = "1" ]; then + bash "$here/forge_login.sh" --no-browser + else + bash "$here/forge_login.sh" + fi +else + bash "$here/install-git-credential-helper.sh" >/dev/null +fi + +step "5/7 confirming silent git access (just check-access)" +GIT_TERMINAL_PROMPT=0 GCM_INTERACTIVE=Never \ + GIT_ASKPASS="" SSH_ASKPASS="" \ + VSCODE_GIT_ASKPASS_MAIN="" VSCODE_GIT_ASKPASS_NODE="" \ + VSCODE_GIT_ASKPASS_EXTRA_ARGS="" VSCODE_GIT_IPC_HANDLE="" \ + DISPLAY="" WAYLAND_DISPLAY="" \ + git ls-remote "$FORGE_ORCHESTRATOR_REPO_URL" HEAD >/dev/null 2>&1 \ + || die "git ls-remote failed. The Gitea account may not yet be in the org, or FORGE_ORCHESTRATOR_REPO_URL is wrong." +ok "silent clone access confirmed" + +step "6/7 cloning the orchestrator (just clone-orchestrator)" +workspace_root="${FORGE_WORKSPACE_ROOT:-.}" +repo_name="$(basename "$FORGE_ORCHESTRATOR_REPO_URL" .git)" +dest="$workspace_root/$repo_name" + +if [ -d "$dest/.git" ]; then + note "orchestrator already present at $dest" + reply="$(prompt_choice "Reuse existing checkout, or wipe and re-clone? [R]euse / [W]ipe [R]" "R")" + case "$reply" in + [Ww]*) + note "removing $dest" + rm -rf "$dest" + ;; + *) + ok "keeping existing checkout" + ;; + esac +fi + +if [ ! -d "$dest/.git" ]; then + mkdir -p "$workspace_root" + export GIT_TERMINAL_PROMPT=0 GCM_INTERACTIVE=Never + unset GIT_ASKPASS SSH_ASKPASS \ + VSCODE_GIT_ASKPASS_MAIN VSCODE_GIT_ASKPASS_NODE \ + VSCODE_GIT_ASKPASS_EXTRA_ARGS VSCODE_GIT_IPC_HANDLE \ + DISPLAY WAYLAND_DISPLAY + if [ -n "${FORGE_ORCHESTRATOR_BRANCH:-}" ]; then + git clone --branch "$FORGE_ORCHESTRATOR_BRANCH" \ + "$FORGE_ORCHESTRATOR_REPO_URL" "$dest" + else + git clone "$FORGE_ORCHESTRATOR_REPO_URL" "$dest" + fi + ok "cloned into $dest" +fi + +step "7/7 next steps (from the orchestrator's operator manifest)" + +# The operator scaffold is pinned to the operator manifest/recipe pair; +# `--deploy` only controls whether to prompt for an automatic handoff. +# The contributor manifest is never referenced from this scaffold. +manifest_name="operator_setup_steps.json" +recipe_name="operator-setup" +manifest_path="$dest/scripts/$manifest_name" + +if [ ! -f "$manifest_path" ]; then + warn "orchestrator at $dest does not ship $manifest_name" + warn "(older checkout?). See the orchestrator's README for onboarding:" + note " $dest/README.md" + exit 0 +fi + +# Authoritative step list: $manifest_path in the orchestrator checkout. +# This welcome repo never hardcodes the sequence. +bash "$here/next_steps.sh" --manifest "$manifest_name" --recipe "$recipe_name" || true + +forward=() +[ "$headless" = "1" ] && forward+=("--headless") +[ "${FORGE_SETUP_YES:-0}" = "1" ] && forward+=("--yes") + +if [ "$deploy" != "1" ]; then + # `just setup` (no --deploy) prints the plan and stops: no prompt, + # no auto-run. The hint below is informational only. + note "skipping auto-run (no --deploy). To execute the plan later:" + if [ "${#forward[@]}" -gt 0 ]; then + note " just run-next-steps --manifest $manifest_name --recipe $recipe_name ${forward[*]}" + else + note " just run-next-steps --manifest $manifest_name --recipe $recipe_name" + fi + note "or, inside the orchestrator checkout:" + note " cd $dest && just $recipe_name" + exit 0 +fi + +# --deploy path: prompt [Y/n] (default Y). FORGE_SETUP_YES=1 accepts Y. +reply="$(prompt_choice "Hand off to just $recipe_name now? [Y/n]" "Y")" +case "$reply" in + [Nn]*) + note "skipping auto-run. To execute later:" + if [ "${#forward[@]}" -gt 0 ]; then + note " just run-next-steps --manifest $manifest_name --recipe $recipe_name ${forward[*]}" + else + note " just run-next-steps --manifest $manifest_name --recipe $recipe_name" + fi + note "or, inside the orchestrator checkout:" + note " cd $dest && just $recipe_name" + ;; + *) + step "handing off to the orchestrator" + exec bash "$here/next_steps.sh" --run --manifest "$manifest_name" --recipe "$recipe_name" "${forward[@]}" + ;; +esac diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh new file mode 100755 index 0000000..839801d --- /dev/null +++ b/scripts/uninstall.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# +# Reverse every side effect `just login` produced. Safe to re-run. + +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +# shellcheck disable=SC1091 +. "$here/common.sh" + +load_env + +# 1. Log out at the OAuth layer (clears welcome-managed fields from +# client-auth.json and keeps any fields owned by the orchestrator +# gateway untouched). +if [ -f "$here/forge_auth.py" ]; then + python3 "$here/forge_auth.py" logout || true +fi + +# 2. Remove git credential helper + git config entries scoped to the +# Gitea URL. +if [ -n "${FORGE_GITEA_URL:-}" ]; then + for k in helper username useHttpPath; do + git config --global --unset-all "credential.${FORGE_GITEA_URL}.$k" 2>/dev/null || true + done + git config --global --remove-section "credential.${FORGE_GITEA_URL}" 2>/dev/null || true + info "cleared git credential.${FORGE_GITEA_URL}.*" +fi + +# 3. Remove installed artefacts. +for f in "$CRED_HELPER" "$LOCAL_BIN/forge_auth.py"; do + if [ -e "$f" ]; then + rm -f "$f" + info "removed $f" + fi +done + +info 'uninstall complete.' +info "note: $FORGE_AUTH_FILE is left in place if the orchestrator wrote it;" +info ' delete it manually when a completely clean state is required.' diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..985d586 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,91 @@ +# Tests + +Deterministic and hermetic. Integration tests stand up a local mock +OIDC/OAuth2 server on an ephemeral port; no traffic to a real Gitea. + +```bash +just test # full suite +python3 -m unittest discover -t . -s tests -p 'test_*.py' -v +bash tests/test_forge_auth_integration.sh +bash tests/test_setup_args.sh +bash tests/test_setup_deploy_flag.sh +bash tests/test_doctor.sh +bash tests/test_next_steps.sh +``` + +## Inventory + +- **`test_forge_auth.py`**: `scripts/forge_auth.py` unit tests: PKCE + pair generation, HMAC state signing + CSRF rejection, + `ForgeAuthConfig.from_env` validation (loopback-only redirect, + missing port, missing env vars, `FORGE_GITEA_USERNAME` + propagation), `build_authorize_url` with `prompt=login` and + `login_hint`, `build_gitea_logout_url`, `AuthFile` + read/write/merge/`has_live_gitea_token`, `auth_store_path` + precedence, `run_logout`, `main()` dispatcher. + +- **`test_git_credential_forge.py`**: `scripts/git-credential-forge.py` + unit tests: credential protocol I/O, host/scheme/port matching, + live-token fast-path, pass-through for missing store or non-matching + host, expired-token refresh, refresh-failure handling, `store` / + `erase` no-ops, `main()` dispatcher. + +- **`test_forge_auth_integration.py`**: end-to-end Python integration + tests against `tests/mock_oidc_server.py`: full PKCE flow, + gateway-required schema on disk, idempotent re-login, refresh-token + rotation with server-side revocation, logout preserving + gateway-bearer fields. + +- **`test_forge_auth_integration.sh`**: shell end-to-end: drives + `forge_auth.py login` against the mock server, installs the + credential helper into a sandboxed `$HOME`, and exercises + `git credential fill`. Covers URL matching, `github.com` + non-leakage, rotated-token pickup, `just logout` teardown, and the + username-mismatch guard (login fails, auth file untouched, Gitea + logout URL surfaced, authorise URL carries `prompt=login` + + `login_hint`). + +- **`test_setup_args.sh`**: `scripts/setup.sh` coverage: argument + parsing, `--help`, `--headless` wiring to `forge_login.sh + --no-browser`, the `--headless + FORGE_SETUP_YES=1` hang guard, + live-token reuse, silent-refresh rescue, `prompt_choice` non-tty + stdout isolation. + +- **`test_setup_deploy_flag.sh`**: `scripts/setup.sh --deploy` + coverage: `--deploy` is parsed and surfaced in `--help`, + `setup.sh` with `--deploy` swaps the manifest/recipe pair to + `operator_setup_steps.json` / `operator-setup`, non-interactive + runs either hand off or print the `just run-next-steps --manifest + operator_setup_steps.json --recipe operator-setup` follow-up + hint, and `next_steps.sh --run --manifest ... --recipe + operator-setup` execs the expected recipe in the orchestrator + checkout. + +- **`test_doctor.sh`**: `scripts/doctor.sh`: miss-path under a + sandboxed PATH, asserts every `[miss]` line is followed by a `fix:` + line and the consolidated block is emitted. + +- **`test_next_steps.sh`**: `scripts/next_steps.sh` contract: + operator manifest / recipe is the default, + `--manifest contributor_setup_steps.json --recipe contributor-setup` + swaps to the contributor runner, missing manifest warns and + points at the orchestrator README, `--headless` / `--yes` / + `--skip-optional` / `--only ID` forward verbatim to the selected + `just `. Fixtures use generic step ids (the real manifests + ship with the orchestrator). + +- **`mock_oidc_server.py`**: test fixture implementing + `/.well-known/openid-configuration`, `/login/oauth/authorize`, + `/login/oauth/access_token`, `/login/oauth/userinfo`. PKCE + verification on `authorization_code`; rotation + revocation on + `refresh_token`. + +## Adding tests + +- Python: drop `test_*.py` in this directory, use `unittest`, stdlib + only. +- Shell: executable, deterministic, non-interactive. Use ephemeral + ports via `python3 -c 'import socket; ...'` and sandbox `$HOME` + with `mktemp -d`. Stub external binaries (e.g. `just`, `git`) by + dropping a script into a temp `fakebin/` and prepending it to + `PATH`. diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/tests/mock_oidc_server.py b/tests/mock_oidc_server.py new file mode 100755 index 0000000..fbe3c7f --- /dev/null +++ b/tests/mock_oidc_server.py @@ -0,0 +1,230 @@ +"""Minimal OIDC + OAuth2 PKCE server used by the integration test. + +Implements the subset of Gitea's `/.well-known/openid-configuration`, +`/login/oauth/authorize`, `/login/oauth/access_token`, and +`/login/oauth/userinfo` surface for the welcome-repo's `forge_auth.py` +to run an end-to-end login + refresh without touching a real Gitea. + +Test fixture only. Binds to loopback, accepts any non-empty +`client_id`, and issues deterministic opaque tokens; it does not +model authentication or authorisation. Not suitable for any purpose +other than driving the welcome-repo client during tests. +""" +from __future__ import annotations + +import base64 +import hashlib +import json +import sys +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any, Callable, cast +from urllib.parse import parse_qs, urlencode, urlparse + + +class _State: + """In-memory bookkeeping shared by every handler instance.""" + + def __init__(self, *, base_url: str, username: str) -> None: + self.base_url = base_url + self.username = username + # code -> {client_id, redirect_uri, code_challenge, used} + self.pending_codes: dict[str, dict[str, Any]] = {} + # refresh_token -> {client_id, revoked} + self.refresh_tokens: dict[str, dict[str, Any]] = {} + self.access_token_expires_in = 3600 + self.access_token_counter = 0 + self.refresh_token_counter = 0 + + def issue_access_token(self) -> str: + self.access_token_counter += 1 + return f"access-{self.access_token_counter}" + + def issue_refresh_token(self, *, client_id: str) -> str: + self.refresh_token_counter += 1 + tok = f"refresh-{self.refresh_token_counter}" + self.refresh_tokens[tok] = {"client_id": client_id, "revoked": False} + return tok + + +def _verify_pkce(challenge: str, verifier: str) -> bool: + expected = ( + base64.urlsafe_b64encode(hashlib.sha256(verifier.encode("ascii")).digest()) + .rstrip(b"=") + .decode("ascii") + ) + return expected == challenge + + +class _Handler(BaseHTTPRequestHandler): + state: _State + + def _send_json(self, code: int, body: dict) -> None: + data = json.dumps(body).encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def _read_form(self) -> dict[str, str]: + length = int(self.headers.get("Content-Length", "0") or "0") + raw = self.rfile.read(length).decode("ascii") if length else "" + return {k: v[0] for k, v in parse_qs(raw, keep_blank_values=True).items()} + + def do_GET(self) -> None: # noqa: N802 + parsed = urlparse(self.path) + path = parsed.path + query = {k: v[0] for k, v in parse_qs(parsed.query).items()} + + if path == "/.well-known/openid-configuration": + base = self.state.base_url + self._send_json(200, { + "issuer": base, + "authorization_endpoint": f"{base}/login/oauth/authorize", + "token_endpoint": f"{base}/login/oauth/access_token", + "userinfo_endpoint": f"{base}/login/oauth/userinfo", + }) + return + + if path == "/login/oauth/authorize": + client_id = query.get("client_id", "") + redirect_uri = query.get("redirect_uri", "") + code_challenge = query.get("code_challenge", "") + state_value = query.get("state", "") + if not (client_id and redirect_uri and code_challenge and state_value): + self._send_json(400, {"error": "invalid_request"}) + return + code = f"code-{len(self.state.pending_codes) + 1}" + self.state.pending_codes[code] = { + "client_id": client_id, + "redirect_uri": redirect_uri, + "code_challenge": code_challenge, + "used": False, + } + sep = "&" if "?" in redirect_uri else "?" + location = ( + f"{redirect_uri}{sep}" + f"{urlencode({'code': code, 'state': state_value})}" + ) + self.send_response(302) + self.send_header("Location", location) + self.end_headers() + return + + if path == "/login/oauth/userinfo": + auth = self.headers.get("Authorization", "") + if not auth.startswith("Bearer access-"): + self._send_json(401, {"error": "unauthorized"}) + return + self._send_json(200, { + "sub": "1", + "preferred_username": self.state.username, + "name": self.state.username, + "email": f"{self.state.username}@example.test", + }) + return + + self._send_json(404, {"error": "not_found"}) + + def do_POST(self) -> None: # noqa: N802 + if self.path != "/login/oauth/access_token": + self._send_json(404, {"error": "not_found"}) + return + form = self._read_form() + grant = form.get("grant_type", "") + + if grant == "authorization_code": + code = form.get("code", "") + verifier = form.get("code_verifier", "") + client_id = form.get("client_id", "") + entry = self.state.pending_codes.get(code) + if not entry or entry["used"]: + self._send_json(400, {"error": "invalid_grant", + "error_description": "code not found or already used"}) + return + if entry["client_id"] != client_id: + self._send_json(400, {"error": "invalid_client"}) + return + if not _verify_pkce(entry["code_challenge"], verifier): + self._send_json(400, {"error": "invalid_grant", + "error_description": "PKCE verification failed"}) + return + entry["used"] = True + access = self.state.issue_access_token() + refresh = self.state.issue_refresh_token(client_id=client_id) + self._send_json(200, { + "access_token": access, + "refresh_token": refresh, + "expires_in": self.state.access_token_expires_in, + "token_type": "Bearer", + "scope": "openid profile email", + }) + return + + if grant == "refresh_token": + rt = form.get("refresh_token", "") + client_id = form.get("client_id", "") + entry = self.state.refresh_tokens.get(rt) + if not entry or entry["revoked"]: + self._send_json(400, {"error": "invalid_grant", + "error_description": "refresh token invalid or revoked"}) + return + if entry["client_id"] != client_id: + self._send_json(400, {"error": "invalid_client"}) + return + # Rotate: revoke the old refresh token, issue new pair. + entry["revoked"] = True + access = self.state.issue_access_token() + new_rt = self.state.issue_refresh_token(client_id=client_id) + self._send_json(200, { + "access_token": access, + "refresh_token": new_rt, + "expires_in": self.state.access_token_expires_in, + "token_type": "Bearer", + }) + return + + self._send_json(400, {"error": "unsupported_grant_type"}) + + def log_message(self, format: str, *args: Any) -> None: # noqa: A003 + return + + +def make_server(*, username: str = "testuser") -> tuple[ThreadingHTTPServer, _State, str]: + # Bind to ephemeral port, then set base_url so the handler knows its URL. + server = ThreadingHTTPServer(("127.0.0.1", 0), _Handler) + port = server.server_address[1] + base_url = f"http://127.0.0.1:{port}" + state = _State(base_url=base_url, username=username) + # Share the state across all handler instances via the class attr. + cast(type, _Handler).state = state # type: ignore[assignment] + return server, state, base_url + + +def serve_forever(server: ThreadingHTTPServer) -> threading.Thread: + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return thread + + +def main() -> int: + import os + + username = os.environ.get("MOCK_OIDC_USERNAME", "testuser") + server, state, base_url = make_server(username=username) + serve_forever(server) + sys.stdout.write(f"{base_url}\n") + sys.stdout.flush() + try: + # Block until killed. + while True: + time.sleep(3600) + except KeyboardInterrupt: + server.shutdown() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_doctor.sh b/tests/test_doctor.sh new file mode 100755 index 0000000..f3eed05 --- /dev/null +++ b/tests/test_doctor.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# +# scripts/doctor.sh contract (miss path): +# 1. Exits non-zero. +# 2. Each miss is followed by a `fix: ...` line. +# 3. Emits a consolidated "Run the following to fix them" block. +# 4. No raw ANSI escapes leak into non-TTY stderr. + +set -euo pipefail + +here="$(cd "$(dirname "$0")" && pwd -P)" +repo="$(cd "$here/.." && pwd -P)" + +out="$(mktemp)" +trap 'rm -f "$out"' EXIT + +# Strip tools from PATH so the miss branches execute. +# /usr/bin/python3 is 3.10.x on Ubuntu 22.04; this exercises the +# python>=3.11 miss branch and its fix+alt lines. +if env -i HOME="$HOME" PATH="/usr/bin:/bin" bash "$repo/scripts/doctor.sh" >"$out" 2>&1; then + echo "FAIL: doctor.sh exited 0 despite missing prerequisites" + cat "$out" + exit 1 +fi + +fail=0 +must_contain() { + local needle="$1" + if ! grep -Fq -- "$needle" "$out"; then + echo "FAIL: expected to see: $needle" + fail=1 + fi +} + +must_contain '[miss] just' +must_contain '[miss] uv' +must_contain '[miss] python>=3.11' +must_contain 'fix: curl --proto "=https" --tlsv1.2 -LsSf https://just.systems/install.sh' +must_contain 'fix: curl -LsSf https://astral.sh/uv/install.sh | sh' +must_contain 'fix: uv python install 3.11' +must_contain 'Run the following to fix them' + +# No raw ANSI sequences when stderr is redirected to a file. +if grep -q $'\033\\[' "$out"; then + echo "FAIL: raw ANSI escape leaked into non-TTY stderr" + fail=1 +fi + +# Each [miss] line is followed by a "fix:" line. `[miss]` is 6 chars +# so %-6s adds no padding; the literal " %-12s" supplies one sep space. +awk ' + /^ \[miss\]/ { miss_line = NR; next } + miss_line && NR == miss_line + 1 { + if ($0 !~ /^ fix: /) { + printf "FAIL: [miss] at line %d not followed by fix: (got: %s)\n", miss_line, $0 + exit 1 + } + miss_line = 0 + } +' "$out" || fail=1 + +if [ "$fail" -ne 0 ]; then + echo "---- doctor.sh output ----" + cat "$out" + exit 1 +fi + +echo "PASS: doctor.sh prints fix commands for every miss" diff --git a/tests/test_forge_auth.py b/tests/test_forge_auth.py new file mode 100755 index 0000000..814e226 --- /dev/null +++ b/tests/test_forge_auth.py @@ -0,0 +1,976 @@ +"""Unit tests for scripts/forge_auth.py. + +Pure-logic tests (no network, no browser). The full PKCE flow is +covered by tests/test_forge_auth_integration.sh, which stands up a +local mock Gitea OIDC server and drives the CLI end-to-end. + +Run with: python3 -m unittest tests.test_forge_auth +""" +from __future__ import annotations + +import base64 +import hashlib +import io +import json +import os +import sys +import tempfile +import time +import unittest +from pathlib import Path +from unittest import mock +from urllib.parse import urlparse + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent +sys.path.insert(0, str(ROOT / "scripts")) + +import forge_auth as fa # noqa: E402 + + +# -------------------------------------------------------------------- +# PKCE primitives +# -------------------------------------------------------------------- +class PkcePairTests(unittest.TestCase): + + def test_verifier_length_in_range(self) -> None: + v, _ = fa.pkce_pair() + # RFC 7636: 43 <= len(verifier) <= 128 + self.assertGreaterEqual(len(v), 43) + self.assertLessEqual(len(v), 128) + + def test_challenge_is_correct_s256_encoding(self) -> None: + v, c = fa.pkce_pair() + expected = ( + base64.urlsafe_b64encode(hashlib.sha256(v.encode("ascii")).digest()) + .rstrip(b"=") + .decode("ascii") + ) + self.assertEqual(c, expected) + + def test_pairs_are_unique(self) -> None: + pairs = {fa.pkce_pair() for _ in range(10)} + self.assertEqual(len(pairs), 10) + + +class StateSigningTests(unittest.TestCase): + + def setUp(self) -> None: + self.key = b"\x01" * 32 + + def test_roundtrip(self) -> None: + nonce = "abc.123" # dot in nonce must still round-trip (rpartition) + signed = fa.sign_state(self.key, nonce) + self.assertEqual(fa.verify_state(self.key, signed), nonce) + + def test_tampered_mac_rejected(self) -> None: + signed = fa.sign_state(self.key, "n1") + tampered = signed[:-1] + ("0" if signed[-1] != "0" else "1") + with self.assertRaises(fa.AuthError): + fa.verify_state(self.key, tampered) + + def test_wrong_key_rejected(self) -> None: + signed = fa.sign_state(self.key, "n1") + with self.assertRaises(fa.AuthError): + fa.verify_state(b"\x02" * 32, signed) + + def test_missing_separator_raises(self) -> None: + with self.assertRaises(fa.AuthError): + fa.verify_state(self.key, "nosignaturehere") + + +# -------------------------------------------------------------------- +# ForgeAuthConfig.from_env +# -------------------------------------------------------------------- +class ConfigFromEnvTests(unittest.TestCase): + + def _env(self, **overrides: str) -> dict[str, str]: + env = { + "FORGE_GITEA_URL": "https://gitea.example.com", + "FSDGG_CLI_CLIENT_ID": "my-client-id", + "FSDGG_CLI_REDIRECT_URI": "http://127.0.0.1:38111/callback", + } + env.update(overrides) + return env + + def test_happy_path(self) -> None: + with mock.patch.dict(os.environ, self._env(), clear=True): + cfg = fa.ForgeAuthConfig.from_env() + self.assertEqual(cfg.gitea_base_url, "https://gitea.example.com") + self.assertEqual(cfg.client_id, "my-client-id") + self.assertEqual(cfg.redirect_uri, "http://127.0.0.1:38111/callback") + self.assertFalse(cfg.insecure_tls) + + def test_trailing_slash_stripped(self) -> None: + with mock.patch.dict( + os.environ, + self._env(FORGE_GITEA_URL="https://gitea.example.com/"), + clear=True, + ): + cfg = fa.ForgeAuthConfig.from_env() + self.assertEqual(cfg.gitea_base_url, "https://gitea.example.com") + + def test_localhost_redirect_allowed(self) -> None: + with mock.patch.dict( + os.environ, + self._env(FSDGG_CLI_REDIRECT_URI="http://localhost:45000/cb"), + clear=True, + ): + cfg = fa.ForgeAuthConfig.from_env() + self.assertEqual(cfg.redirect_uri, "http://localhost:45000/cb") + + def test_ipv6_loopback_redirect_allowed(self) -> None: + with mock.patch.dict( + os.environ, + self._env(FSDGG_CLI_REDIRECT_URI="http://[::1]:45000/cb"), + clear=True, + ): + cfg = fa.ForgeAuthConfig.from_env() + self.assertEqual(cfg.redirect_uri, "http://[::1]:45000/cb") + + def test_non_loopback_rejected(self) -> None: + with mock.patch.dict( + os.environ, + self._env(FSDGG_CLI_REDIRECT_URI="https://example.com/cb"), + clear=True, + ): + with self.assertRaises(fa.AuthError) as ctx: + fa.ForgeAuthConfig.from_env() + self.assertIn("RFC 8252", str(ctx.exception)) + + def test_https_loopback_rejected(self) -> None: + with mock.patch.dict( + os.environ, + self._env(FSDGG_CLI_REDIRECT_URI="https://127.0.0.1:38111/cb"), + clear=True, + ): + with self.assertRaises(fa.AuthError) as ctx: + fa.ForgeAuthConfig.from_env() + self.assertIn("RFC 8252", str(ctx.exception)) + + def test_missing_port_rejected(self) -> None: + with mock.patch.dict( + os.environ, + self._env(FSDGG_CLI_REDIRECT_URI="http://127.0.0.1/cb"), + clear=True, + ): + with self.assertRaises(fa.AuthError): + fa.ForgeAuthConfig.from_env() + + def test_missing_required_env_var_lists_all_missing(self) -> None: + env = self._env() + env["FORGE_GITEA_URL"] = "" + env["FSDGG_CLI_CLIENT_ID"] = "" + with mock.patch.dict(os.environ, env, clear=True): + with self.assertRaises(fa.AuthError) as ctx: + fa.ForgeAuthConfig.from_env() + msg = str(ctx.exception) + self.assertIn("FORGE_GITEA_URL", msg) + self.assertIn("FSDGG_CLI_CLIENT_ID", msg) + + def test_expected_username_read_from_env(self) -> None: + with mock.patch.dict( + os.environ, + self._env(FORGE_GITEA_USERNAME="CVJMAllaire"), + clear=True, + ): + cfg = fa.ForgeAuthConfig.from_env() + self.assertEqual(cfg.expected_username, "CVJMAllaire") + + def test_expected_username_defaults_to_empty(self) -> None: + with mock.patch.dict(os.environ, self._env(), clear=True): + cfg = fa.ForgeAuthConfig.from_env() + self.assertEqual(cfg.expected_username, "") + + def test_insecure_tls_flag(self) -> None: + with mock.patch.dict( + os.environ, self._env(FORGE_INSECURE_TLS="1"), clear=True + ): + cfg = fa.ForgeAuthConfig.from_env() + self.assertTrue(cfg.insecure_tls) + + +# -------------------------------------------------------------------- +# build_authorize_url +# -------------------------------------------------------------------- +class BuildAuthorizeUrlTests(unittest.TestCase): + + def setUp(self) -> None: + self.cfg = fa.ForgeAuthConfig( + gitea_base_url="https://g.example", + client_id="client-1", + redirect_uri="http://127.0.0.1:38111/callback", + ) + self.endpoints = { + "authorization_endpoint": "https://g.example/login/oauth/authorize", + "token_endpoint": "https://g.example/login/oauth/access_token", + "userinfo_endpoint": "https://g.example/login/oauth/userinfo", + } + + def test_url_contains_all_required_parameters(self) -> None: + url = fa.build_authorize_url( + self.cfg, self.endpoints, challenge="c123", state="s123" + ) + self.assertTrue(url.startswith("https://g.example/login/oauth/authorize?")) + # All required PKCE + OAuth2 params + for token in ( + "response_type=code", + "client_id=client-1", + "code_challenge=c123", + "code_challenge_method=S256", + "state=s123", + "redirect_uri=http%3A%2F%2F127.0.0.1%3A38111%2Fcallback", + ): + self.assertIn(token, url) + + def test_url_always_includes_prompt_login(self) -> None: + url = fa.build_authorize_url( + self.cfg, self.endpoints, challenge="c", state="s" + ) + # prompt=login forces Gitea to display the login page even when a + # session is already active: prevents silent auth under the + # wrong identity. + self.assertIn("prompt=login", url) + + def test_url_adds_login_hint_when_expected_username_set(self) -> None: + cfg = fa.ForgeAuthConfig( + gitea_base_url="https://g.example", + client_id="client-1", + redirect_uri="http://127.0.0.1:38111/callback", + expected_username="CVJMAllaire", + ) + url = fa.build_authorize_url( + cfg, self.endpoints, challenge="c", state="s" + ) + self.assertIn("login_hint=CVJMAllaire", url) + + def test_url_omits_login_hint_when_expected_username_unset(self) -> None: + url = fa.build_authorize_url( + self.cfg, self.endpoints, challenge="c", state="s" + ) + self.assertNotIn("login_hint=", url) + + +class BuildGiteaLogoutUrlTests(unittest.TestCase): + + def test_composes_logout_url_with_redirect_to_login(self) -> None: + url = fa.build_gitea_logout_url("https://gitea.example.com") + self.assertEqual( + url, + "https://gitea.example.com/user/logout?redirect_to=%2Fuser%2Flogin", + ) + + def test_trailing_slash_is_stripped(self) -> None: + url = fa.build_gitea_logout_url("https://gitea.example.com/") + self.assertTrue(url.startswith("https://gitea.example.com/user/logout")) + self.assertNotIn("//user/logout", url) + + +# -------------------------------------------------------------------- +# AuthFile (read / merge / write / has_live_gitea_token) +# -------------------------------------------------------------------- +class AuthFileTests(unittest.TestCase): + + def test_read_missing_file_returns_empty(self) -> None: + f = fa.AuthFile.read(Path("/nonexistent/path/client-auth.json")) + self.assertEqual(f.raw, {}) + self.assertFalse(f.has_live_gitea_token()) + + def test_read_malformed_json_raises(self) -> None: + with tempfile.TemporaryDirectory() as d: + p = Path(d) / "bad.json" + p.write_text("{ not json", encoding="utf-8") + with self.assertRaises(fa.AuthError): + fa.AuthFile.read(p) + + def test_read_non_object_raises(self) -> None: + with tempfile.TemporaryDirectory() as d: + p = Path(d) / "arr.json" + p.write_text("[1,2,3]", encoding="utf-8") + with self.assertRaises(fa.AuthError): + fa.AuthFile.read(p) + + def test_has_live_gitea_token_requires_token(self) -> None: + f = fa.AuthFile(raw={"gitea_access_token": ""}) + self.assertFalse(f.has_live_gitea_token()) + + def test_has_live_gitea_token_expired(self) -> None: + f = fa.AuthFile(raw={ + "gitea_access_token": "t", + "gitea_token_expires_at": time.time() - 10, + }) + self.assertFalse(f.has_live_gitea_token()) + + def test_has_live_gitea_token_live(self) -> None: + f = fa.AuthFile(raw={ + "gitea_access_token": "t", + "gitea_token_expires_at": time.time() + 3600, + }) + self.assertTrue(f.has_live_gitea_token()) + + def test_has_live_gitea_token_unknown_expiry_is_trusted(self) -> None: + f = fa.AuthFile(raw={"gitea_access_token": "t"}) + self.assertTrue(f.has_live_gitea_token()) + + def test_merge_login_fills_required_gateway_fields(self) -> None: + f = fa.AuthFile() + f.merge_login( + username="alice", + gitea_access_token="AAA", + gitea_token_expires_at=time.time() + 3600, + refresh_token="RRR", + client_id="client-1", + gitea_base_url="https://g.example", + ) + for required in ( + "username", "access_token", "expires_in", + "issued_at", "public_base_url", "index_name", + ): + self.assertIn(required, f.raw) + self.assertEqual(f.raw["username"], "alice") + self.assertEqual(f.raw["gitea_access_token"], "AAA") + self.assertEqual(f.raw["_forge_refresh_token"], "RRR") + self.assertEqual(f.raw["_forge_client_id"], "client-1") + self.assertEqual(f.raw["_forge_gitea_base_url"], "https://g.example") + + def test_merge_login_preserves_gateway_bearer(self) -> None: + # Gateway bearer fields remain intact across a Gitea login write. + f = fa.AuthFile(raw={ + "username": "old-alice", + "access_token": "GATEWAY-BEARER", + "expires_in": 7200, + "issued_at": 1000000.0, + "public_base_url": "https://gateway.example", + "index_name": "forge", + }) + f.merge_login( + username="alice", + gitea_access_token="NEW-GITEA", + gitea_token_expires_at=time.time() + 3600, + refresh_token="NEW-RT", + client_id="client-1", + gitea_base_url="https://g.example", + ) + self.assertEqual(f.raw["access_token"], "GATEWAY-BEARER") + self.assertEqual(f.raw["public_base_url"], "https://gateway.example") + self.assertEqual(f.raw["index_name"], "forge") + # And the Gitea fields got refreshed + self.assertEqual(f.raw["gitea_access_token"], "NEW-GITEA") + self.assertEqual(f.raw["username"], "alice") + + def test_merge_refresh_only_touches_gitea_fields(self) -> None: + f = fa.AuthFile(raw={ + "username": "alice", + "access_token": "GATEWAY-BEARER", + "public_base_url": "https://gateway.example", + }) + f.merge_refresh( + gitea_access_token="FRESH", + gitea_token_expires_at=time.time() + 3600, + refresh_token="ROTATED", + ) + self.assertEqual(f.raw["access_token"], "GATEWAY-BEARER") + self.assertEqual(f.raw["gitea_access_token"], "FRESH") + self.assertEqual(f.raw["_forge_refresh_token"], "ROTATED") + + def test_merge_refresh_empty_refresh_token_keeps_existing(self) -> None: + f = fa.AuthFile(raw={"_forge_refresh_token": "KEEP"}) + f.merge_refresh( + gitea_access_token="NEW", gitea_token_expires_at=None, refresh_token="" + ) + self.assertEqual(f.raw["_forge_refresh_token"], "KEEP") + + def test_write_is_atomic_and_0600(self) -> None: + with tempfile.TemporaryDirectory() as d: + p = Path(d) / "sub" / "client-auth.json" + f = fa.AuthFile(raw={"username": "u", "gitea_access_token": "T"}) + f.write(p) + self.assertTrue(p.is_file()) + mode = p.stat().st_mode & 0o777 + self.assertEqual(mode, 0o600) + # The tmp file must not still exist + self.assertFalse((p.parent / "client-auth.json.tmp").exists()) + roundtrip = json.loads(p.read_text(encoding="utf-8")) + self.assertEqual(roundtrip["username"], "u") + + def test_write_preserves_unknown_keys(self) -> None: + # Forward-compat: preserve unknown gateway fields verbatim. + raw = {"username": "u", "future_field": {"x": 1}, "access_token": "A"} + with tempfile.TemporaryDirectory() as d: + p = Path(d) / "a.json" + fa.AuthFile(raw=dict(raw)).write(p) + roundtrip = json.loads(p.read_text(encoding="utf-8")) + self.assertEqual(roundtrip, raw) + + +# -------------------------------------------------------------------- +# auth_store_path +# -------------------------------------------------------------------- +class AuthStorePathTests(unittest.TestCase): + + def test_explicit_env_var_wins(self) -> None: + with mock.patch.dict( + os.environ, + {"FSDGG_AUTH_STORE_PATH": "/tmp/somewhere/a.json", "FSDGG_RUNTIME_DIR": "/other"}, + clear=True, + ): + self.assertEqual(fa.auth_store_path(), Path("/tmp/somewhere/a.json")) + + def test_runtime_dir_fallback(self) -> None: + with mock.patch.dict( + os.environ, {"FSDGG_RUNTIME_DIR": "/tmp/rt"}, clear=True + ): + self.assertEqual(fa.auth_store_path(), Path("/tmp/rt/client-auth.json")) + + def test_default_path(self) -> None: + with mock.patch.dict(os.environ, {}, clear=True): + self.assertEqual(fa.auth_store_path(), fa.DEFAULT_AUTH_FILE) + + +# -------------------------------------------------------------------- +# run_logout +# -------------------------------------------------------------------- +class RunLogoutTests(unittest.TestCase): + """Invariant: every assertion runs *inside* the TemporaryDirectory + context. Tempdir teardown after the assertions would delete the + file under test and produce a false positive on + ``assertFalse(is_file)``. + """ + + def test_logout_no_file(self) -> None: + with tempfile.TemporaryDirectory() as d: + p = Path(d) / "client-auth.json" + with mock.patch.dict( + os.environ, {"FSDGG_AUTH_STORE_PATH": str(p)}, clear=True + ): + result = fa.run_logout() + self.assertIsNone(result) + self.assertFalse(p.is_file()) + + def test_logout_only_welcome_fields_removes_file(self) -> None: + payload = { + "username": "u", "access_token": "", "expires_in": 0, + "issued_at": 0.0, "public_base_url": "", "index_name": "", + "gitea_access_token": "A", "_forge_refresh_token": "R", + } + with tempfile.TemporaryDirectory() as d: + p = Path(d) / "client-auth.json" + p.write_text(json.dumps(payload), encoding="utf-8") + with mock.patch.dict( + os.environ, {"FSDGG_AUTH_STORE_PATH": str(p)}, clear=True + ): + result = fa.run_logout() + self.assertEqual(result, p) + self.assertFalse(p.is_file()) + + def test_logout_preserves_gateway_bearer(self) -> None: + payload = { + "username": "u", + "access_token": "GATEWAY-BEARER", + "expires_in": 3600, + "issued_at": 1000000.0, + "public_base_url": "https://gateway.example", + "index_name": "forge", + "gitea_access_token": "GITEA-A", + "gitea_token_expires_at": 2000000.0, + "_forge_refresh_token": "R", + "_forge_client_id": "c", + } + with tempfile.TemporaryDirectory() as d: + p = Path(d) / "client-auth.json" + p.write_text(json.dumps(payload), encoding="utf-8") + with mock.patch.dict( + os.environ, {"FSDGG_AUTH_STORE_PATH": str(p)}, clear=True + ): + fa.run_logout() + self.assertTrue(p.is_file()) + remaining = json.loads(p.read_text(encoding="utf-8")) + self.assertEqual(remaining["access_token"], "GATEWAY-BEARER") + self.assertEqual(remaining["public_base_url"], "https://gateway.example") + self.assertNotIn("gitea_access_token", remaining) + self.assertNotIn("_forge_refresh_token", remaining) + + +# -------------------------------------------------------------------- +# main() dispatcher +# -------------------------------------------------------------------- +class MainDispatcherTests(unittest.TestCase): + + def test_unknown_command_rc_2(self) -> None: + self.assertEqual(fa.main(["forge_auth.py", "nope"]), 2) + + def test_no_args_rc_2(self) -> None: + self.assertEqual(fa.main(["forge_auth.py"]), 2) + + def test_status_on_empty_store_rc_1(self) -> None: + with tempfile.TemporaryDirectory() as d: + with mock.patch.dict( + os.environ, + {"FSDGG_AUTH_STORE_PATH": str(Path(d) / "a.json")}, + clear=True, + ): + rc = fa.main(["forge_auth.py", "status"]) + self.assertEqual(rc, 1) + + def test_status_on_live_token_rc_0(self) -> None: + with tempfile.TemporaryDirectory() as d: + p = Path(d) / "a.json" + p.write_text(json.dumps({ + "username": "u", + "gitea_access_token": "LIVE", + "gitea_token_expires_at": time.time() + 3600, + }), encoding="utf-8") + with mock.patch.dict( + os.environ, {"FSDGG_AUTH_STORE_PATH": str(p)}, clear=True + ): + rc = fa.main(["forge_auth.py", "status"]) + self.assertEqual(rc, 0) + + +class HeadlessGuidanceTests(unittest.TestCase): + AUTH_URL = "https://gitea.example/login/oauth/authorize?x=1" + REDIRECT = urlparse("http://127.0.0.1:54321/callback") + + def _capture(self, env_extra: dict[str, str] | None = None) -> str: + env = {"USER": "alice"} + if env_extra is not None: + for k in ("SSH_CONNECTION", "SSH_TTY"): + env.pop(k, None) + env.update(env_extra) + buf = io.StringIO() + with mock.patch.dict(os.environ, env, clear=True), \ + mock.patch("forge_auth.sys.stderr", buf), \ + mock.patch("forge_auth.socket.getfqdn", return_value="box.example"), \ + mock.patch("forge_auth.socket.gethostname", return_value="box.example"): + fa._print_headless_guidance(self.AUTH_URL, self.REDIRECT) + return buf.getvalue() + + def test_prints_authorize_url(self) -> None: + out = self._capture() + self.assertIn(self.AUTH_URL, out) + + def test_prints_rfc_8252_reference(self) -> None: + self.assertIn("RFC 8252", self._capture()) + + def test_uses_configured_redirect_port(self) -> None: + out = self._capture() + self.assertIn("127.0.0.1:54321", out) + self.assertNotIn("38111", out) + + def test_ssh_forward_template_uses_configured_port(self) -> None: + out = self._capture() + self.assertIn("ssh -L 54321:127.0.0.1:54321 alice@box.example", out) + + def test_reachability_probe_included(self) -> None: + out = self._capture() + self.assertIn("curl -sS -m 2 http://127.0.0.1:54321/", out) + self.assertIn("Connection refused", out) + + def test_ssh_branch_when_SSH_CONNECTION_set(self) -> None: + out = self._capture({"SSH_CONNECTION": "1.2.3.4 22 5.6.7.8 22", "USER": "alice"}) + self.assertIn("running inside an SSH session", out) + + def test_non_ssh_branch_when_no_ssh_env(self) -> None: + out = self._capture() + self.assertIn("if this machine is remote", out) + self.assertNotIn("running inside an SSH session", out) + + +class BuildAuthorizeErrorTests(unittest.TestCase): + """Contract tests for ``_build_authorize_error``.""" + + BASE = "https://gitea.example.com" + CID = "ba4ec9ec-8ae8-4450-9cec-fd532bbe63d5" + SCOPES = "openid profile email read:user read:organization read:repository write:repository" + + def _exc_different_scope(self, **overrides: str) -> fa.AuthError: + kw: dict[str, object] = { + "error": "server_error", + "error_description": "a grant exists with different scope", + "gitea_base_url": self.BASE, + "client_id": self.CID, + "scopes": self.SCOPES, + } + kw.update(overrides) + return fa._build_authorize_error( + str(kw["error"]), + str(kw["error_description"]) if kw["error_description"] else None, + str(kw["gitea_base_url"]), + client_id=str(kw["client_id"]), + scopes=str(kw["scopes"]), + ) + + def test_different_scope_returns_autherror(self) -> None: + self.assertIsInstance(self._exc_different_scope(), fa.AuthError) + + def test_different_scope_message_is_five_lines_max(self) -> None: + # RULE #2 §D.3: user-facing error bodies cap at 5 lines. + s = str(self._exc_different_scope()) + self.assertLessEqual(len(s.splitlines()), 5) + + def test_different_scope_surfaces_gitea_phrasing(self) -> None: + self.assertIn("different scope", str(self._exc_different_scope()).lower()) + + def test_different_scope_cites_settings_url(self) -> None: + self.assertIn( + f"{self.BASE}/user/settings/applications", + str(self._exc_different_scope()), + ) + + def test_different_scope_disambiguates_authorized_section(self) -> None: + # Gitea's settings page has both "Authorized OAuth2 Applications" + # (user grants) and "Manage OAuth2 Applications" (app registrations); + # the message must point at the first. + self.assertIn( + '"Authorized OAuth2 Applications"', + str(self._exc_different_scope()), + ) + + def test_different_scope_includes_full_client_id(self) -> None: + self.assertIn(self.CID, str(self._exc_different_scope())) + + def test_different_scope_includes_requested_scopes(self) -> None: + self.assertIn(self.SCOPES, str(self._exc_different_scope())) + + def test_different_scope_links_to_remediation_doc(self) -> None: + # External rationale lives in the doc per RULE #2 §D/E. + self.assertIn( + "docs/oauth-grant-scope-mismatch.md", + str(self._exc_different_scope()), + ) + + def test_different_scope_without_base_url_uses_placeholder(self) -> None: + self.assertIn( + "/user/settings/applications", + str(self._exc_different_scope(gitea_base_url="")), + ) + + def test_different_scope_without_client_id_uses_placeholder(self) -> None: + self.assertIn( + "", + str(self._exc_different_scope(client_id="")), + ) + + def test_different_scope_without_scopes_uses_placeholder(self) -> None: + self.assertIn( + "", + str(self._exc_different_scope(scopes="")), + ) + + def test_access_denied_branch(self) -> None: + exc = fa._build_authorize_error( + "access_denied", + "The resource owner or authorization server denied the request.", + self.BASE, + ) + self.assertIn("access_denied", str(exc)) + self.assertIn("just login", str(exc)) + + def test_fallback_preserves_raw_error_and_description(self) -> None: + exc = fa._build_authorize_error( + "invalid_request", "redirect_uri mismatch", self.BASE, + ) + s = str(exc) + self.assertIn("invalid_request", s) + self.assertIn("redirect_uri mismatch", s) + + def test_fallback_with_no_description_only_includes_error_code(self) -> None: + exc = fa._build_authorize_error("temporarily_unavailable", None, self.BASE) + self.assertIn("temporarily_unavailable", str(exc)) + + +class AuthorizeUrlIsHeadlessInvariantTests(unittest.TestCase): + """Pin the invariant: authorize URL is independent of ``--no-browser``.""" + + def setUp(self) -> None: + self.cfg = fa.ForgeAuthConfig( + gitea_base_url="https://gitea.example.com", + client_id="ba4ec9ec-8ae8-4450-9cec-fd532bbe63d5", + redirect_uri="http://127.0.0.1:38111/callback", + expected_username="alice", + ) + self.endpoints = { + "authorization_endpoint": "https://gitea.example.com/login/oauth/authorize", + "token_endpoint": "https://gitea.example.com/login/oauth/access_token", + "userinfo_endpoint": "https://gitea.example.com/login/oauth/userinfo", + } + + def test_build_authorize_url_signature_has_no_headless_parameter(self) -> None: + import inspect + + sig = inspect.signature(fa.build_authorize_url) + self.assertNotIn("open_browser", sig.parameters) + self.assertNotIn("headless", sig.parameters) + self.assertNotIn("no_browser", sig.parameters) + + def test_url_is_deterministic_for_fixed_challenge_and_state(self) -> None: + url_a = fa.build_authorize_url( + self.cfg, self.endpoints, challenge="ch_1", state="st_1" + ) + url_b = fa.build_authorize_url( + self.cfg, self.endpoints, challenge="ch_1", state="st_1" + ) + self.assertEqual(url_a, url_b) + + def test_scope_parameter_matches_config_scopes_exactly(self) -> None: + from urllib.parse import parse_qs, urlparse + + url = fa.build_authorize_url( + self.cfg, self.endpoints, challenge="c", state="s" + ) + params = parse_qs(urlparse(url).query) + self.assertEqual(params["scope"], [self.cfg.scopes]) + + def test_default_url_carries_prompt_login(self) -> None: + from urllib.parse import parse_qs, urlparse + + url = fa.build_authorize_url( + self.cfg, self.endpoints, challenge="c", state="s" + ) + params = parse_qs(urlparse(url).query) + self.assertEqual(params["prompt"], ["login"]) + + def test_force_login_prompt_false_drops_prompt_param(self) -> None: + from urllib.parse import parse_qs, urlparse + + url = fa.build_authorize_url( + self.cfg, + self.endpoints, + challenge="c", + state="s", + force_login_prompt=False, + ) + params = parse_qs(urlparse(url).query) + self.assertNotIn("prompt", params) + # login_hint must still ride along; the retry path keeps it so + # Gitea can pre-fill the username field if a fresh login screen + # ever does appear (e.g., expired session cookie). + self.assertEqual(params["login_hint"], [self.cfg.expected_username]) + + +class ScopeMismatchAuthErrorTests(unittest.TestCase): + """Contract tests for the ``ScopeMismatchAuthError`` subclass. + + The "different scope" branch of ``_build_authorize_error`` must + return a ``ScopeMismatchAuthError`` so callers can drive a + revoke-and-retry recovery flow instead of swallowing the error. + The class is also a subclass of ``AuthError`` so existing + ``except AuthError`` handlers (e.g., the credential helper) keep + working unchanged. + """ + + BASE = "https://gitea.example.com" + CID = "ba4ec9ec-8ae8-4450-9cec-fd532bbe63d5" + SCOPES = "openid profile email read:user read:organization read:repository write:repository" + + def _build(self, **overrides): + kw = { + "error": "server_error", + "error_description": "a grant exists with different scope", + "gitea_base_url": self.BASE, + "client_id": self.CID, + "scopes": self.SCOPES, + } + kw.update(overrides) + return fa._build_authorize_error( + str(kw["error"]), + str(kw["error_description"]) if kw["error_description"] else None, + str(kw["gitea_base_url"]), + client_id=str(kw["client_id"]), + scopes=str(kw["scopes"]), + ) + + def test_different_scope_returns_subclass(self) -> None: + self.assertIsInstance(self._build(), fa.ScopeMismatchAuthError) + + def test_subclass_inherits_from_autherror(self) -> None: + self.assertTrue(issubclass(fa.ScopeMismatchAuthError, fa.AuthError)) + + def test_attributes_carry_diagnostic_fields(self) -> None: + exc = self._build() + self.assertEqual(exc.gitea_base_url, self.BASE) + self.assertEqual(exc.client_id, self.CID) + self.assertEqual(exc.scopes, self.SCOPES) + + def test_revoke_url_with_base(self) -> None: + exc = self._build() + self.assertEqual( + exc.revoke_url, + f"{self.BASE}/user/settings/applications", + ) + + def test_revoke_url_strips_trailing_slash(self) -> None: + exc = self._build(gitea_base_url=self.BASE + "/") + self.assertEqual( + exc.revoke_url, + f"{self.BASE}/user/settings/applications", + ) + + def test_revoke_url_without_base_uses_placeholder(self) -> None: + exc = self._build(gitea_base_url="") + self.assertEqual( + exc.revoke_url, + "/user/settings/applications", + ) + + def test_access_denied_branch_is_plain_autherror(self) -> None: + # Negative case: only the scope-conflict branch upgrades to + # the subclass; access_denied stays a generic AuthError. + exc = fa._build_authorize_error( + "access_denied", "denied by user", self.BASE, + ) + self.assertNotIsInstance(exc, fa.ScopeMismatchAuthError) + self.assertIsInstance(exc, fa.AuthError) + + +class CanPromptForRevokeTests(unittest.TestCase): + """``_can_prompt_for_revoke`` gates the interactive retry path. + + Returns False whenever the operator cannot reasonably be asked to + press Enter: explicit opt-out via ``FORGE_AUTO_REVOKE``, + non-interactive mode via ``FORGE_SETUP_YES``, or non-TTY stdio. + """ + + def setUp(self) -> None: + self._env_keys = ("FORGE_AUTO_REVOKE", "FORGE_SETUP_YES") + self._env_backup = {k: os.environ.get(k) for k in self._env_keys} + for k in self._env_keys: + os.environ.pop(k, None) + + def tearDown(self) -> None: + for k, v in self._env_backup.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + def _run(self, *, stderr_tty: bool = True, stdin_tty: bool = True) -> bool: + with mock.patch.object(sys.stderr, "isatty", lambda: stderr_tty), \ + mock.patch.object(sys.stdin, "isatty", lambda: stdin_tty): + return fa._can_prompt_for_revoke() + + def test_default_with_both_ttys_returns_true(self) -> None: + self.assertTrue(self._run()) + + def test_force_auto_revoke_off_disables(self) -> None: + for v in ("0", "no", "false", "FALSE", "No"): + with self.subTest(value=v): + os.environ["FORGE_AUTO_REVOKE"] = v + self.assertFalse(self._run()) + os.environ.pop("FORGE_AUTO_REVOKE", None) + + def test_setup_yes_disables(self) -> None: + os.environ["FORGE_SETUP_YES"] = "1" + self.assertFalse(self._run()) + + def test_no_stderr_tty_disables(self) -> None: + self.assertFalse(self._run(stderr_tty=False)) + + def test_no_stdin_tty_disables(self) -> None: + self.assertFalse(self._run(stdin_tty=False)) + + +class CmdLoginRetryOnScopeMismatchTests(unittest.TestCase): + """``_cmd_login`` auto-retries once after ``ScopeMismatchAuthError`` + when the prompt path is enabled; otherwise exits 1 immediately. + + The retry must call ``run_login`` with ``force=True`` and + ``force_login_prompt=False`` so the cached live-token short-circuit + cannot mask a stale grant and Gitea can reuse the existing browser + session cookie (only consent screen on retry). + """ + + def setUp(self) -> None: + # Capture stderr to keep cli_err/cli_ok/cli_info from polluting + # the test runner output for the entire class. + stderr_patch = mock.patch.object(sys, "stderr", new_callable=io.StringIO) + stderr_patch.start() + self.addCleanup(stderr_patch.stop) + self.fake_cfg = fa.ForgeAuthConfig( + gitea_base_url="https://gitea.example.com", + client_id="ba4ec9ec-8ae8-4450-9cec-fd532bbe63d5", + redirect_uri="http://127.0.0.1:38111/callback", + ) + self.fake_state = mock.Mock(username="alice") + self.scope_exc = fa.ScopeMismatchAuthError( + "boom", + gitea_base_url=self.fake_cfg.gitea_base_url, + client_id=self.fake_cfg.client_id, + scopes=self.fake_cfg.scopes, + ) + + def _common_patches(self, *, run_login_side_effect): + return [ + mock.patch.object( + fa.ForgeAuthConfig, "from_env", return_value=self.fake_cfg + ), + mock.patch.object( + fa, "run_login", side_effect=run_login_side_effect + ), + mock.patch.object( + fa, "auth_store_path", return_value=Path("/tmp/dummy.json") + ), + ] + + def _start(self, patches): + for p in patches: + p.start() + self.addCleanup(lambda: [p.stop() for p in patches]) + + def test_retries_when_prompt_allowed(self) -> None: + patches = self._common_patches( + run_login_side_effect=[self.scope_exc, self.fake_state] + ) + [ + mock.patch.object(fa, "_can_prompt_for_revoke", return_value=True), + mock.patch.object(fa, "_prompt_revoke_and_wait", return_value=True), + ] + self._start(patches) + rc = fa._cmd_login([]) + self.assertEqual(rc, 0) + self.assertEqual(fa.run_login.call_count, 2) + _, kwargs = fa.run_login.call_args_list[1] + self.assertTrue(kwargs.get("force")) + # The retry must drop ``prompt=login`` so Gitea reuses the + # browser session cookie established by the failed first call. + self.assertIs(kwargs.get("force_login_prompt"), False) + + def test_does_not_retry_when_prompt_disabled(self) -> None: + prompt_mock = mock.Mock(return_value=True) + patches = self._common_patches( + run_login_side_effect=[self.scope_exc] + ) + [ + mock.patch.object(fa, "_can_prompt_for_revoke", return_value=False), + mock.patch.object(fa, "_prompt_revoke_and_wait", new=prompt_mock), + ] + self._start(patches) + rc = fa._cmd_login([]) + self.assertEqual(rc, 1) + self.assertEqual(fa.run_login.call_count, 1) + prompt_mock.assert_not_called() + + def test_does_not_retry_when_user_aborts(self) -> None: + patches = self._common_patches( + run_login_side_effect=[self.scope_exc] + ) + [ + mock.patch.object(fa, "_can_prompt_for_revoke", return_value=True), + mock.patch.object(fa, "_prompt_revoke_and_wait", return_value=False), + ] + self._start(patches) + rc = fa._cmd_login([]) + self.assertEqual(rc, 1) + self.assertEqual(fa.run_login.call_count, 1) + + def test_unrelated_autherror_propagates(self) -> None: + patches = self._common_patches( + run_login_side_effect=[fa.AuthError("unrelated")] + ) + [ + mock.patch.object(fa, "_can_prompt_for_revoke", return_value=True), + mock.patch.object(fa, "_prompt_revoke_and_wait", return_value=True), + ] + self._start(patches) + with self.assertRaises(fa.AuthError): + fa._cmd_login([]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_forge_auth_integration.py b/tests/test_forge_auth_integration.py new file mode 100755 index 0000000..591ee86 --- /dev/null +++ b/tests/test_forge_auth_integration.py @@ -0,0 +1,240 @@ +"""End-to-end integration test for forge_auth.py against a mock OIDC server. + +Covers the full PKCE login flow (authorize → callback → token +exchange → userinfo → persist), transparent refresh, logout, and +the idempotent "already authenticated" short-circuit. + +No real network calls. No browser required: the test simulates the +browser by doing an HTTP GET to the authorize endpoint; the mock +server 302-redirects to the loopback callback, which +`forge_auth.run_login` is already listening on. + +Run with: python3 -m unittest tests.test_forge_auth_integration +""" +from __future__ import annotations + +import json +import os +import socket +import sys +import tempfile +import threading +import time +import unittest +import urllib.request +from http.client import HTTPResponse +from pathlib import Path +from unittest import mock + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent +sys.path.insert(0, str(ROOT / "scripts")) +sys.path.insert(0, str(HERE)) + +import forge_auth as fa # noqa: E402 +import mock_oidc_server # noqa: E402 + + +def _free_loopback_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +class _MockBrowser: + """Drive the authorize endpoint on a worker thread. + + Delay allows `run_login` to bind the loopback callback server. + The GET then follows the mock server redirect to the callback. + """ + + def __init__(self, authorize_url: str, delay_seconds: float = 0.2) -> None: + self.authorize_url = authorize_url + self.delay_seconds = delay_seconds + self.exc: BaseException | None = None + self._thread: threading.Thread | None = None + + def start(self) -> None: + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + + def join(self, timeout: float = 10.0) -> None: + if self._thread is not None: + self._thread.join(timeout) + + def _run(self) -> None: + try: + time.sleep(self.delay_seconds) + with urllib.request.urlopen(self.authorize_url, timeout=5) as resp: + resp.read() + except BaseException as exc: # noqa: BLE001 + self.exc = exc + + +class ForgeAuthIntegrationTests(unittest.TestCase): + + def setUp(self) -> None: + # Tempdir for auth store. + self._tmp_ctx = tempfile.TemporaryDirectory() + self.tmp = Path(self._tmp_ctx.name) + + # Mock Gitea on an ephemeral port. + self.server, self.server_state, self.base_url = mock_oidc_server.make_server( + username="integration-user" + ) + mock_oidc_server.serve_forever(self.server) + + # Loopback callback port (separate from the mock server port). + self.loopback_port = _free_loopback_port() + self.redirect_uri = f"http://127.0.0.1:{self.loopback_port}/callback" + + self.env = { + "FORGE_GITEA_URL": self.base_url, + "FSDGG_CLI_CLIENT_ID": "integration-client", + "FSDGG_CLI_REDIRECT_URI": self.redirect_uri, + "FSDGG_AUTH_STORE_PATH": str(self.tmp / "client-auth.json"), + "HOME": str(self.tmp), + } + + def tearDown(self) -> None: + self.server.shutdown() + self.server.server_close() + self._tmp_ctx.cleanup() + + # ----------------------------------------------------------------- + # Helpers + # ----------------------------------------------------------------- + def _login(self) -> fa.AuthFile: + """Run `run_login()` with a patched browser opener.""" + with mock.patch.dict(os.environ, self.env, clear=True): + config = fa.ForgeAuthConfig.from_env() + + # Start the mock browser from the patched opener so the GET + # occurs after URL construction and before callback wait. + browser_holder: dict[str, _MockBrowser] = {} + + def fake_webbrowser_open(url: str, new: int = 0) -> bool: + browser = _MockBrowser(url) + browser.start() + browser_holder["b"] = browser + return True + + with mock.patch("forge_auth.webbrowser.open", side_effect=fake_webbrowser_open): + state = fa.run_login(config, open_browser=True, force=False, + print_authorize_url=False) + + if "b" in browser_holder: + browser_holder["b"].join(timeout=5) + if browser_holder["b"].exc is not None: + raise browser_holder["b"].exc + return state + + # ----------------------------------------------------------------- + # Tests + # ----------------------------------------------------------------- + + def test_login_persists_full_auth_file(self) -> None: + state = self._login() + self.assertEqual(state.username, "integration-user") + self.assertTrue(state.gitea_access_token.startswith("access-")) + self.assertTrue(state.refresh_token.startswith("refresh-")) + self.assertTrue(state.has_live_gitea_token()) + + # The file must be mode 0600. + store = Path(self.env["FSDGG_AUTH_STORE_PATH"]) + self.assertTrue(store.is_file()) + self.assertEqual(store.stat().st_mode & 0o777, 0o600) + + # All gateway-required fields are populated (with defaults). + payload = json.loads(store.read_text(encoding="utf-8")) + for key in ( + "username", "access_token", "expires_in", + "issued_at", "public_base_url", "index_name", + ): + self.assertIn(key, payload) + # Welcome-managed fields are there too. + self.assertEqual(payload["_forge_client_id"], "integration-client") + self.assertEqual(payload["_forge_gitea_base_url"], self.base_url) + + def test_login_is_idempotent_when_token_live(self) -> None: + first = self._login() + first_access = first.gitea_access_token + first_counter = self.server_state.access_token_counter + + # Second call with force=False must NOT talk to the mock server. + with mock.patch.dict(os.environ, self.env, clear=True): + config = fa.ForgeAuthConfig.from_env() + with mock.patch("forge_auth.webbrowser.open") as wb: + second = fa.run_login(config, open_browser=True, force=False, + print_authorize_url=False) + wb.assert_not_called() + self.assertEqual(second.gitea_access_token, first_access) + self.assertEqual(self.server_state.access_token_counter, first_counter) + + def test_refresh_rotates_tokens(self) -> None: + state = self._login() + original_access = state.gitea_access_token + original_refresh = state.refresh_token + + # Force the refresh path. + with mock.patch.dict(os.environ, self.env, clear=True): + config = fa.ForgeAuthConfig.from_env() + refreshed = fa.run_refresh(config, must_refresh=True) + + self.assertNotEqual(refreshed.gitea_access_token, original_access) + self.assertNotEqual(refreshed.refresh_token, original_refresh) + self.assertTrue(refreshed.has_live_gitea_token()) + + # Old refresh token is now revoked on the server; using it must fail. + with mock.patch.dict(os.environ, self.env, clear=True): + config = fa.ForgeAuthConfig.from_env() + endpoints = fa.discover_endpoints(config) + with self.assertRaises(fa.AuthError): + fa.refresh_access_token( + config, endpoints, refresh_token=original_refresh + ) + + def test_logout_after_login_removes_fields(self) -> None: + self._login() + store = Path(self.env["FSDGG_AUTH_STORE_PATH"]) + self.assertTrue(store.is_file()) + + with mock.patch.dict(os.environ, self.env, clear=True): + fa.run_logout() + + # File should be gone (gateway never wrote its bearer in this test). + self.assertFalse(store.is_file()) + + def test_logout_preserves_gateway_bearer(self) -> None: + self._login() + store = Path(self.env["FSDGG_AUTH_STORE_PATH"]) + # Simulate the gateway subsequently writing its own bearer. + payload = json.loads(store.read_text(encoding="utf-8")) + payload.update({ + "access_token": "GATEWAY-BEARER", + "expires_in": 7200, + "public_base_url": "https://gateway.example", + "index_name": "forge", + }) + store.write_text(json.dumps(payload), encoding="utf-8") + + with mock.patch.dict(os.environ, self.env, clear=True): + fa.run_logout() + + self.assertTrue(store.is_file()) + remaining = json.loads(store.read_text(encoding="utf-8")) + self.assertEqual(remaining["access_token"], "GATEWAY-BEARER") + self.assertEqual(remaining["public_base_url"], "https://gateway.example") + self.assertNotIn("gitea_access_token", remaining) + self.assertNotIn("_forge_refresh_token", remaining) + + def test_callback_state_csrf_mismatch_raises(self) -> None: + """Reject a tampered callback state via `verify_state()`.""" + key = b"\x01" * 32 + signed = fa.sign_state(key, "nonce") + with self.assertRaises(fa.AuthError): + fa.verify_state(b"\x02" * 32, signed) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_forge_auth_integration.sh b/tests/test_forge_auth_integration.sh new file mode 100755 index 0000000..0c9c76d --- /dev/null +++ b/tests/test_forge_auth_integration.sh @@ -0,0 +1,237 @@ +#!/usr/bin/env bash +# +# End-to-end OAuth2 flow against tests/mock_oidc_server.py. Exercises +# forge_login.sh, forge_auth.py, the credential helper, and the +# username-mismatch guard in a sandboxed $HOME. Never touches a real +# Gitea. + +set -euo pipefail + +here="$(cd "$(dirname "$0")" && pwd -P)" +root="$here/.." + +# Disable every interactive credential fallback so git fails loudly +# rather than prompting (VSCode, X11/Wayland askpass, terminal). +export GIT_TERMINAL_PROMPT=0 +export GCM_INTERACTIVE=Never +unset GIT_ASKPASS SSH_ASKPASS \ + VSCODE_GIT_ASKPASS_MAIN VSCODE_GIT_ASKPASS_NODE \ + VSCODE_GIT_ASKPASS_EXTRA_ARGS VSCODE_GIT_IPC_HANDLE \ + DISPLAY WAYLAND_DISPLAY + +tmp="$(mktemp -d)" +cleanup() { + [ -n "${mock_pid:-}" ] && kill "$mock_pid" 2>/dev/null || true + [ -n "${browser_pid:-}" ] && kill "$browser_pid" 2>/dev/null || true + [ -n "${login_pid:-}" ] && kill "$login_pid" 2>/dev/null || true + rm -rf "$tmp" +} +trap cleanup EXIT INT TERM + +export HOME="$tmp/home" +mkdir -p "$HOME/.local/bin" "$HOME/.config" +export GIT_CONFIG_GLOBAL="$HOME/.gitconfig" +export GIT_CONFIG_SYSTEM=/dev/null +touch "$GIT_CONFIG_GLOBAL" +export FSDGG_AUTH_STORE_PATH="$HOME/.forge-stack-devpi-gateway-gitea/client-auth.json" + +pass=0 +fail=0 +assert() { + local msg="$1"; shift + if "$@"; then + printf '[ok] %s\n' "$msg" + pass=$((pass + 1)) + else + printf '[FAIL] %s\n' "$msg" + fail=$((fail + 1)) + fi +} + +# --- Start the mock Gitea ------------------------------------------ +MOCK_OIDC_USERNAME=integration-user \ + python3 -u "$here/mock_oidc_server.py" >"$tmp/mock.url" 2>"$tmp/mock.log" & +mock_pid=$! +# Wait for the server to announce its URL. +for _ in $(seq 1 40); do + mock_url="$(head -1 "$tmp/mock.url" 2>/dev/null || true)" + [ -n "$mock_url" ] && break + sleep 0.1 +done +[ -n "$mock_url" ] || { cat "$tmp/mock.log"; echo 'mock server never came up' >&2; exit 1; } +printf '[info] mock Gitea: %s\n' "$mock_url" + +# --- Pick an unused loopback port for the callback ----------------- +loopback_port="$(python3 -c ' +import socket +with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + print(s.getsockname()[1]) +')" +redirect_uri="http://127.0.0.1:${loopback_port}/callback" + +# --- Synthesize .env inside the welcome repo root ------------------ +env_file="$tmp/env" +cat >"$env_file" </dev/null | grep -q ":${loopback_port} " \ + || netstat -tln 2>/dev/null | grep -q ":${loopback_port} "; then + break + fi + sleep 0.1 + done + url="$(cat "$tmp/authorize.url" 2>/dev/null || true)" + if [ -n "$url" ]; then + curl -fsSL --max-time 10 "$url" >/dev/null 2>"$tmp/browser.log" || true + fi +) & +browser_pid=$! + +python3 "$root/scripts/forge_auth.py" login --no-browser 2> >(tee "$tmp/login.log" >&2) \ + < <(printf '') & +login_pid=$! + +for _ in $(seq 1 80); do + if grep -qE 'https?://.*authorize\?' "$tmp/login.log" 2>/dev/null; then + grep -oE 'https?://[^ ]+authorize\?[^ ]+' "$tmp/login.log" | head -1 >"$tmp/authorize.url" + break + fi + sleep 0.1 +done + +wait "$login_pid" || { echo 'forge_auth login failed'; cat "$tmp/login.log"; exit 1; } +wait "$browser_pid" 2>/dev/null || true + +assert 'client-auth.json was written' test -f "$FSDGG_AUTH_STORE_PATH" +assert 'client-auth.json is mode 0600' \ + bash -c '[ "$(stat -c %a "$FSDGG_AUTH_STORE_PATH" 2>/dev/null || stat -f %A "$FSDGG_AUTH_STORE_PATH")" = "600" ]' + +user="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["username"])' "$FSDGG_AUTH_STORE_PATH")" +assert 'username captured from userinfo' test "$user" = 'integration-user' + +token="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["gitea_access_token"])' "$FSDGG_AUTH_STORE_PATH")" +assert 'gitea_access_token captured' bash -c "[ -n \"$token\" ] && [[ '$token' == access-* ]]" + +refresh="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["_forge_refresh_token"])' "$FSDGG_AUTH_STORE_PATH")" +assert 'refresh token captured' bash -c "[ -n \"$refresh\" ] && [[ '$refresh' == refresh-* ]]" + +# --- Install the git credential helper ----------------------------- +bash "$root/scripts/install-git-credential-helper.sh" >"$tmp/inst.log" 2>&1 \ + || { cat "$tmp/inst.log"; echo 'helper install failed' >&2; exit 1; } + +mock_host="$(python3 -c 'import sys,urllib.parse as u; p=u.urlsplit(sys.argv[1]); print(f"{p.hostname}:{p.port}")' "$mock_url")" + +# --- Drive `git credential fill` ----------------------------------- +credfill_out="$( + printf 'protocol=http\nhost=%s\npath=someorg/somerepo.git\n\n' "$mock_host" \ + | PATH="$HOME/.local/bin:$PATH" git credential fill 2>"$tmp/credfill.err" +)" || { echo 'git credential fill failed'; cat "$tmp/credfill.err"; exit 1; } + +got_pass="$(printf '%s\n' "$credfill_out" | awk -F= '/^password=/ {print $2}')" +got_user="$(printf '%s\n' "$credfill_out" | awk -F= '/^username=/ {print $2}')" +assert 'git credential fill returns username from OAuth' test "$got_user" = 'integration-user' +assert 'git credential fill returns the OAuth access token as password' test "$got_pass" = "$token" + +# --- Unrelated-host probe: must NOT leak credentials --------------- +other_out="$( + printf 'protocol=https\nhost=github.com\npath=x/y.git\n\n' \ + | "$HOME/.local/bin/git-credential-forge" get +)" +assert 'unrelated host gets no username' bash -c "! printf '%s\n' \"$other_out\" | grep -q '^username='" +assert 'unrelated host gets no password' bash -c "! printf '%s\n' \"$other_out\" | grep -q '^password='" + +# --- Refresh path: force a refresh and re-run credential fill ------ +python3 "$root/scripts/forge_auth.py" refresh --force >"$tmp/refresh.log" 2>&1 \ + || { cat "$tmp/refresh.log"; echo 'refresh failed' >&2; exit 1; } + +new_token="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["gitea_access_token"])' "$FSDGG_AUTH_STORE_PATH")" +assert 'refresh rotates the access token' bash -c "[ \"$new_token\" != \"$token\" ]" + +credfill_out2="$( + printf 'protocol=http\nhost=%s\n\n' "$mock_host" \ + | PATH="$HOME/.local/bin:$PATH" git credential fill 2>>"$tmp/credfill.err" +)" +got_pass2="$(printf '%s\n' "$credfill_out2" | awk -F= '/^password=/ {print $2}')" +assert 'git credential fill picks up the rotated token' test "$got_pass2" = "$new_token" + +# --- Logout removes the file --------------------------------------- +python3 "$root/scripts/forge_auth.py" logout >"$tmp/logout.log" 2>&1 +assert 'logout removes client-auth.json' bash -c "[ ! -f \"$FSDGG_AUTH_STORE_PATH\" ]" + +# --- Username-mismatch guard --------------------------------------- +loopback_port2="$(python3 -c ' +import socket +with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + print(s.getsockname()[1]) +')" +export FSDGG_CLI_REDIRECT_URI="http://127.0.0.1:${loopback_port2}/callback" +export FORGE_GITEA_USERNAME="not-the-right-user" + +rm -f "$tmp/authorize.url" +( + for _ in $(seq 1 50); do + if ss -tln 2>/dev/null | grep -q ":${loopback_port2} " \ + || netstat -tln 2>/dev/null | grep -q ":${loopback_port2} "; then + break + fi + sleep 0.1 + done + url="$(cat "$tmp/authorize.url" 2>/dev/null || true)" + if [ -n "$url" ]; then + curl -fsSL --max-time 10 "$url" >/dev/null 2>"$tmp/browser2.log" || true + fi +) & +browser_pid=$! + +set +e +python3 "$root/scripts/forge_auth.py" login --no-browser \ + 2> >(tee "$tmp/mismatch.log" >&2) & +mismatch_pid=$! +for _ in $(seq 1 80); do + if grep -qE 'https?://.*authorize\?' "$tmp/mismatch.log" 2>/dev/null; then + grep -oE 'https?://[^ ]+authorize\?[^ ]+' "$tmp/mismatch.log" | head -1 >"$tmp/authorize.url" + break + fi + sleep 0.1 +done +wait "$mismatch_pid" +mismatch_rc=$? +wait "$browser_pid" 2>/dev/null || true +set -e + +assert 'login fails when username mismatches FORGE_GITEA_USERNAME' bash -c "[ $mismatch_rc -ne 0 ]" +assert 'mismatch error references the actual Gitea username' \ + grep -q 'integration-user' "$tmp/mismatch.log" +assert 'mismatch error references the expected username' \ + grep -q 'not-the-right-user' "$tmp/mismatch.log" +assert 'mismatch error points at Gitea logout URL' \ + grep -qE '/user/logout\?redirect_to=%2Fuser%2Flogin' "$tmp/mismatch.log" +assert 'mismatch does NOT create client-auth.json' \ + bash -c "[ ! -f \"$FSDGG_AUTH_STORE_PATH\" ]" + +auth_url="$(cat "$tmp/authorize.url" 2>/dev/null || true)" +assert 'authorize URL includes prompt=login' \ + bash -c "printf '%s\n' \"$auth_url\" | grep -q 'prompt=login'" +assert 'authorize URL carries login_hint' \ + bash -c "printf '%s\n' \"$auth_url\" | grep -q 'login_hint=not-the-right-user'" + +printf '\n%d pass / %d fail\n' "$pass" "$fail" +[ "$fail" -eq 0 ] diff --git a/tests/test_git_credential_forge.py b/tests/test_git_credential_forge.py new file mode 100755 index 0000000..9ebfda5 --- /dev/null +++ b/tests/test_git_credential_forge.py @@ -0,0 +1,298 @@ +"""Unit tests for scripts/git-credential-forge.py. + +Run with: python3 -m unittest tests.test_git_credential_forge +""" +from __future__ import annotations + +import importlib.util +import io +import json +import os +import sys +import tempfile +import time +import unittest +from pathlib import Path +from unittest import mock + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent +sys.path.insert(0, str(ROOT / "scripts")) + +# Load the helper as a module even though its filename has hyphens. +_helper_path = ROOT / "scripts" / "git-credential-forge.py" +_spec = importlib.util.spec_from_file_location("gcf", _helper_path) +assert _spec and _spec.loader +gcf = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(gcf) + +import forge_auth as fa # noqa: E402 (imported after sys.path is extended) + + +def _write_store(tmp: Path, payload: dict) -> Path: + p = tmp / "client-auth.json" + p.write_text(json.dumps(payload), encoding="utf-8") + return p + + +# -------------------------------------------------------------------- +# read_git_fields +# -------------------------------------------------------------------- +class ReadGitFieldsTests(unittest.TestCase): + + def test_full_block(self) -> None: + buf = io.StringIO("protocol=https\nhost=g.example:6006\npath=a/b.git\n\n") + self.assertEqual( + gcf.read_git_fields(buf), + {"protocol": "https", "host": "g.example:6006", "path": "a/b.git"}, + ) + + def test_eof_without_blank_line(self) -> None: + self.assertEqual( + gcf.read_git_fields(io.StringIO("protocol=https\nhost=x\n")), + {"protocol": "https", "host": "x"}, + ) + + def test_malformed_line_raises(self) -> None: + with self.assertRaises(ValueError): + gcf.read_git_fields(io.StringIO("notakeyvalue\n")) + + +# -------------------------------------------------------------------- +# host matching +# -------------------------------------------------------------------- +class RequestMatchesTests(unittest.TestCase): + + def test_exact_match_including_port(self) -> None: + self.assertTrue( + gcf._request_matches( + {"protocol": "https", "host": "g.example:6006"}, + ("https", "g.example", 6006), + ) + ) + + def test_wrong_scheme_no_match(self) -> None: + self.assertFalse( + gcf._request_matches( + {"protocol": "http", "host": "g.example:6006"}, + ("https", "g.example", 6006), + ) + ) + + def test_wrong_host_no_match(self) -> None: + self.assertFalse( + gcf._request_matches( + {"protocol": "https", "host": "other.example:6006"}, + ("https", "g.example", 6006), + ) + ) + + def test_wrong_port_no_match(self) -> None: + self.assertFalse( + gcf._request_matches( + {"protocol": "https", "host": "g.example:7000"}, + ("https", "g.example", 6006), + ) + ) + + def test_stored_default_https_request_no_port(self) -> None: + # Stored URL had no explicit port → default 443 inferred. + # Request without port is OK. + self.assertTrue( + gcf._request_matches( + {"protocol": "https", "host": "g.example"}, + ("https", "g.example", None), + ) + ) + + def test_stored_default_https_request_with_443(self) -> None: + self.assertTrue( + gcf._request_matches( + {"protocol": "https", "host": "g.example:443"}, + ("https", "g.example", None), + ) + ) + + def test_stored_default_https_request_with_other_port_no_match(self) -> None: + self.assertFalse( + gcf._request_matches( + {"protocol": "https", "host": "g.example:6006"}, + ("https", "g.example", None), + ) + ) + + +# -------------------------------------------------------------------- +# cmd_get (end-to-end, inside a sandboxed FSDGG_AUTH_STORE_PATH) +# -------------------------------------------------------------------- +class CmdGetTests(unittest.TestCase): + + def _run( + self, + *, + store_payload: dict | None, + stdin_text: str, + env_extra: dict[str, str] | None = None, + ) -> tuple[int, str, str]: + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + env = { + "FSDGG_AUTH_STORE_PATH": str(tmp / "client-auth.json"), + "FORGE_GITEA_URL": "https://g.example:6006", + "FSDGG_CLI_CLIENT_ID": "client-1", + "FSDGG_CLI_REDIRECT_URI": "http://127.0.0.1:38111/callback", + "HOME": str(tmp), + } + if env_extra: + env.update(env_extra) + if store_payload is not None: + _write_store(tmp, store_payload) + fields = gcf.read_git_fields(io.StringIO(stdin_text)) + buf_out, buf_err = io.StringIO(), io.StringIO() + real_out, real_err = sys.stdout, sys.stderr + sys.stdout, sys.stderr = buf_out, buf_err + try: + with mock.patch.dict(os.environ, env, clear=True): + rc = gcf.cmd_get(fields) + finally: + sys.stdout, sys.stderr = real_out, real_err + return rc, buf_out.getvalue(), buf_err.getvalue() + + # --- matching host ------------------------------------------------- + + def test_match_live_token_returns_credentials(self) -> None: + payload = { + "username": "alice", + "gitea_access_token": "LIVETOKEN", + "gitea_token_expires_at": time.time() + 3600, + "_forge_gitea_base_url": "https://g.example:6006", + } + rc, out, _ = self._run( + store_payload=payload, + stdin_text="protocol=https\nhost=g.example:6006\npath=org/repo.git\n\n", + ) + self.assertEqual(rc, 0) + parsed = dict(l.split("=", 1) for l in out.strip().splitlines()) + self.assertEqual(parsed["username"], "alice") + self.assertEqual(parsed["password"], "LIVETOKEN") + self.assertEqual(parsed["host"], "g.example:6006") + + def test_no_store_passes_through(self) -> None: + rc, out, _ = self._run( + store_payload=None, + stdin_text="protocol=https\nhost=g.example:6006\n\n", + ) + self.assertEqual(rc, 0) + self.assertNotIn("password=", out) + self.assertIn("host=g.example:6006", out) + + def test_non_matching_host_passes_through(self) -> None: + payload = { + "username": "alice", + "gitea_access_token": "LIVETOKEN", + "gitea_token_expires_at": time.time() + 3600, + "_forge_gitea_base_url": "https://g.example:6006", + } + rc, out, _ = self._run( + store_payload=payload, + stdin_text="protocol=https\nhost=github.com\n\n", + ) + self.assertEqual(rc, 0) + self.assertNotIn("password=", out) + self.assertIn("host=github.com", out) + + def test_match_but_no_token_passes_through(self) -> None: + payload = { + "username": "alice", + "gitea_access_token": "", + "_forge_gitea_base_url": "https://g.example:6006", + } + rc, out, _ = self._run( + store_payload=payload, + stdin_text="protocol=https\nhost=g.example:6006\n\n", + ) + self.assertEqual(rc, 0) + self.assertNotIn("password=", out) + + # --- expired token + refresh ------------------------------------- + + def test_expired_token_triggers_refresh(self) -> None: + payload = { + "username": "alice", + "gitea_access_token": "EXPIRED", + "gitea_token_expires_at": time.time() - 10, + "_forge_gitea_base_url": "https://g.example:6006", + "_forge_refresh_token": "REFRESH", + "_forge_client_id": "client-1", + } + # Patch the refresh path directly. + def fake_refresh(config, *, must_refresh=False): + f = fa.AuthFile.read(fa.auth_store_path()) + f.merge_refresh( + gitea_access_token="ROTATED", + gitea_token_expires_at=time.time() + 3600, + refresh_token="NEW-RT", + ) + f.write(fa.auth_store_path()) + return f + + with mock.patch.object(gcf.forge_auth, "run_refresh", side_effect=fake_refresh): + rc, out, _ = self._run( + store_payload=payload, + stdin_text="protocol=https\nhost=g.example:6006\n\n", + ) + self.assertEqual(rc, 0) + parsed = dict(l.split("=", 1) for l in out.strip().splitlines()) + self.assertEqual(parsed["password"], "ROTATED") + + def test_refresh_failure_passes_through_with_stderr(self) -> None: + payload = { + "username": "alice", + "gitea_access_token": "EXPIRED", + "gitea_token_expires_at": time.time() - 10, + "_forge_gitea_base_url": "https://g.example:6006", + "_forge_refresh_token": "DEAD", + } + + def fake_refresh(*_args, **_kwargs): + raise fa.AuthError("refresh token revoked") + + with mock.patch.object(gcf.forge_auth, "run_refresh", side_effect=fake_refresh): + rc, out, err = self._run( + store_payload=payload, + stdin_text="protocol=https\nhost=g.example:6006\n\n", + ) + self.assertEqual(rc, 0) + self.assertNotIn("password=", out) + self.assertIn("token refresh failed", err) + self.assertIn("just login", err) + + +# -------------------------------------------------------------------- +# main() dispatcher +# -------------------------------------------------------------------- +class MainDispatcherTests(unittest.TestCase): + + def _main(self, argv: list[str], stdin_text: str = "") -> int: + real_stdin = sys.stdin + sys.stdin = io.StringIO(stdin_text) + try: + return gcf.main(argv) + finally: + sys.stdin = real_stdin + + def test_store_is_noop(self) -> None: + self.assertEqual(self._main(["h", "store"], "username=x\npassword=y\n\n"), 0) + + def test_erase_is_noop(self) -> None: + self.assertEqual(self._main(["h", "erase"], "username=x\npassword=y\n\n"), 0) + + def test_no_action_rc_2(self) -> None: + self.assertEqual(self._main(["h"]), 2) + + def test_unknown_action_rc_2(self) -> None: + self.assertEqual(self._main(["h", "bogus"]), 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_next_steps.sh b/tests/test_next_steps.sh new file mode 100755 index 0000000..7f6b89e --- /dev/null +++ b/tests/test_next_steps.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +# +# scripts/next_steps.sh contract (operator-first scaffold): +# 1. Default manifest is operator_setup_steps.json; default recipe is +# operator-setup. Steps are read from the manifest; no hardcoding. +# 2. --manifest contributor_setup_steps.json --recipe contributor-setup +# swaps to the contributor dev-env bootstrap without changing the +# runner logic. +# 3. Missing manifest prints [warn] and references the orchestrator README. +# 4. Flags (--headless, --yes, --skip-optional, --only) forward verbatim +# to whichever recipe is selected. +# 5. --run mode execs `just ` in the orchestrator cwd. + +set -euo pipefail + +here="$(cd "$(dirname "$0")" && pwd -P)" +root="$(cd "$here/.." && pwd -P)" + +pass=0 +fail=0 +assert() { + local msg="$1"; shift + if "$@"; then + printf '[ok] %s\n' "$msg"; pass=$((pass + 1)) + else + printf '[FAIL] %s\n' "$msg"; fail=$((fail + 1)) + fi +} + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +# --- Sandbox --------------------------------------------------------- +export FORGE_GITEA_URL="http://127.0.0.1:1" +export FORGE_GITEA_ORG="x" +export FORGE_GITEA_USERNAME="sandbox" +export FORGE_ORCHESTRATOR_REPO_URL="http://127.0.0.1:1/x/forge-stack-orchestrator.git" +export FORGE_WORKSPACE_ROOT="$tmp/workspace" +unset FORGE_ORCHESTRATOR_BRANCH FSDGG_AUTH_STORE_PATH FSDGG_RUNTIME_DIR + +mkdir -p "$tmp/workspace" "$tmp/fakebin" +mkdir -p "$tmp/welcome/scripts" +cp "$root/scripts/common.sh" "$tmp/welcome/scripts/" +cp "$root/scripts/next_steps.sh" "$tmp/welcome/scripts/" +touch "$tmp/welcome/Justfile" + +cat >"$tmp/welcome/.env" <"$tmp/workspace/forge-stack-orchestrator/scripts/operator_setup_steps.json" <<'JSON' +{ + "schema_version": 1, + "description": "Operator manifest under test.", + "steps": [ + {"id": "op-step-a", "title": "Operator step A", "cmd": ["bash", "-c", "true"], "desc": "Operator A desc."}, + {"id": "op-step-b", "title": "Operator step B", "cmd": ["bash", "-c", "true"], "desc": "Operator B desc."}, + {"id": "op-step-c", "title": "Operator step C", "cmd": ["bash", "-c", "true"], "desc": "Operator C desc.", "optional": true} + ] +} +JSON + +cat >"$tmp/workspace/forge-stack-orchestrator/scripts/contributor_setup_steps.json" <<'JSON' +{ + "schema_version": 1, + "steps": [ + {"id": "cstep-a", "title": "Contributor A", "cmd": ["bash", "-c", "true"], "desc": "Contributor A."}, + {"id": "cstep-b", "title": "Contributor B", "cmd": ["bash", "-c", "true"], "desc": "Contributor B."} + ] +} +JSON + +# --- Case 1: default (no args) renders the OPERATOR plan ------------ +set +e +bash -c "cd '$tmp/welcome' && bash scripts/next_steps.sh" \ + >"$tmp/default.out" 2>"$tmp/default.err" +rc=$? +set -e +assert 'default: exits 0 when operator manifest present' bash -c "[ $rc -eq 0 ]" +assert 'default: operator_setup_steps.json is the source' \ + grep -qF 'operator_setup_steps.json' "$tmp/default.err" +assert 'default: emits id: op-step-a from operator manifest' \ + grep -qF 'id: op-step-a' "$tmp/default.err" +assert 'default: emits id: op-step-b from operator manifest' \ + grep -qF 'id: op-step-b' "$tmp/default.err" +assert 'default: optional flag shown on third step' \ + grep -qE '\(3/3\) Operator step C' "$tmp/default.err" +assert 'default: does NOT leak contributor ids into the operator plan' \ + bash -c "! grep -qF 'id: cstep-a' '$tmp/default.err'" +assert 'default: "Execute via" pointer uses operator-setup recipe' \ + grep -qE 'just run-next-steps --manifest operator_setup_steps.json --recipe operator-setup' "$tmp/default.err" + +# --- Case 2: explicit contributor manifest works unchanged ---------- +set +e +bash -c "cd '$tmp/welcome' && bash scripts/next_steps.sh --manifest contributor_setup_steps.json --recipe contributor-setup" \ + >"$tmp/contrib.out" 2>"$tmp/contrib.err" +rc=$? +set -e +assert 'contrib override: exits 0' bash -c "[ $rc -eq 0 ]" +assert 'contrib override: manifest path mentions contributor_setup_steps.json' \ + grep -qF 'contributor_setup_steps.json' "$tmp/contrib.err" +assert 'contrib override: emits contributor step ids' \ + grep -qF 'id: cstep-a' "$tmp/contrib.err" +assert 'contrib override: operator ids do NOT appear' \ + bash -c "! grep -qF 'id: op-step-a' '$tmp/contrib.err'" + +# --- Case 3: missing operator manifest warns ------------------------ +mv "$tmp/workspace/forge-stack-orchestrator/scripts/operator_setup_steps.json" \ + "$tmp/workspace/forge-stack-orchestrator/scripts/operator_setup_steps.json.bak" +set +e +bash -c "cd '$tmp/welcome' && bash scripts/next_steps.sh" \ + >"$tmp/missing.out" 2>"$tmp/missing.err" +rc=$? +set -e +mv "$tmp/workspace/forge-stack-orchestrator/scripts/operator_setup_steps.json.bak" \ + "$tmp/workspace/forge-stack-orchestrator/scripts/operator_setup_steps.json" +assert 'missing operator manifest: exits non-zero' bash -c "[ $rc -ne 0 ]" +assert 'missing operator manifest: warns via [warn]' \ + grep -qE '\[warn\]' "$tmp/missing.err" +assert 'missing operator manifest: points at the orchestrator README' \ + grep -qF 'README.md' "$tmp/missing.err" + +# --- Case 4: --run delegates to `just operator-setup` --------------- +cat >"$tmp/fakebin/just" < "$tmp/just.argv.nul" +pwd > "$tmp/just.cwd" +exit 0 +EOF +chmod +x "$tmp/fakebin/just" + +set +e +PATH="$tmp/fakebin:$PATH" bash -c "cd '$tmp/welcome' && bash scripts/next_steps.sh --run --headless --skip-optional --only op-step-a" \ + >"$tmp/run.out" 2>"$tmp/run.err" +rc=$? +set -e +assert '--run: exits 0 on success' bash -c "[ $rc -eq 0 ]" +assert '--run: invokes just' test -f "$tmp/just.argv.nul" +assert '--run: cwd is the orchestrator checkout' \ + bash -c "[ \"\$(cat '$tmp/just.cwd')\" = '$tmp/workspace/forge-stack-orchestrator' ]" + +tr '\0' '\n' <"$tmp/just.argv.nul" >"$tmp/just.argv" +assert '--run: first arg is "operator-setup" (the default)' \ + grep -qxF 'operator-setup' "$tmp/just.argv" +assert '--run: forwards --headless' \ + grep -qxF -- '--headless' "$tmp/just.argv" +assert '--run: forwards --skip-optional' \ + grep -qxF -- '--skip-optional' "$tmp/just.argv" +assert '--run: forwards --only op-step-a' \ + bash -c "grep -qxF -- '--only' '$tmp/just.argv' && grep -qxF -- 'op-step-a' '$tmp/just.argv'" + +# --- Case 5: --run --recipe contributor-setup swaps the recipe ------ +set +e +PATH="$tmp/fakebin:$PATH" bash -c "cd '$tmp/welcome' && bash scripts/next_steps.sh --run --manifest contributor_setup_steps.json --recipe contributor-setup --yes" \ + >"$tmp/run-contrib.out" 2>"$tmp/run-contrib.err" +rc=$? +set -e +assert '--run --recipe contributor-setup: exits 0' bash -c "[ $rc -eq 0 ]" +tr '\0' '\n' <"$tmp/just.argv.nul" >"$tmp/just.argv.contrib" +assert '--run --recipe contributor-setup: first arg is contributor-setup' \ + grep -qxF 'contributor-setup' "$tmp/just.argv.contrib" +assert '--run --recipe contributor-setup: forwards --yes' \ + grep -qxF -- '--yes' "$tmp/just.argv.contrib" + +# --- Case 6: next_steps.sh rejects unknown args --------------------- +set +e +bash -c "cd '$tmp/welcome' && bash scripts/next_steps.sh --bogus" \ + >"$tmp/bogus.out" 2>"$tmp/bogus.err" +rc=$? +set -e +assert 'unknown arg: exits non-zero' bash -c "[ $rc -ne 0 ]" +assert 'unknown arg: prints an error' \ + grep -qE '\[err\] +unexpected argument' "$tmp/bogus.err" + +printf '\n%d pass / %d fail\n' "$pass" "$fail" +[ "$fail" -eq 0 ] diff --git a/tests/test_setup_args.sh b/tests/test_setup_args.sh new file mode 100755 index 0000000..2cbfed4 --- /dev/null +++ b/tests/test_setup_args.sh @@ -0,0 +1,268 @@ +#!/usr/bin/env bash +# +# scripts/setup.sh: argument parsing, --headless wiring, detection +# ladder, and prompt_choice regression. Runs hermetically against a +# sandboxed $HOME with stubbed scripts; no network, no real Gitea. + +set -euo pipefail + +here="$(cd "$(dirname "$0")" && pwd -P)" +root="$here/.." + +pass=0 +fail=0 +assert() { + local msg="$1"; shift + if "$@"; then + printf '[ok] %s\n' "$msg" + pass=$((pass + 1)) + else + printf '[FAIL] %s\n' "$msg" + fail=$((fail + 1)) + fi +} + +assert 'setup.sh parses as valid bash' bash -n "$root/scripts/setup.sh" + +help_out="$(bash "$root/scripts/setup.sh" --help 2>&1)" +assert '--help prints the Usage header' \ + bash -c "printf '%s' \"$help_out\" | grep -q '^Usage: just setup'" +assert '--help documents --headless' \ + bash -c "printf '%s' \"$help_out\" | grep -q -- '--headless'" +assert '--help documents FORGE_SETUP_YES' \ + bash -c "printf '%s' \"$help_out\" | grep -q 'FORGE_SETUP_YES'" + +set +e +bash "$root/scripts/setup.sh" --not-a-flag >/dev/null 2>"$here/.bad.err" +rc=$? +set -e +assert 'unknown option exits non-zero' bash -c "[ $rc -ne 0 ]" +assert 'unknown option prints a clear error' \ + grep -q 'unknown option' "$here/.bad.err" +rm -f "$here/.bad.err" + +# --- Sandbox with stubbed dependencies ------------------------------ +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +mkdir -p "$tmp/scripts" "$tmp/home" "$tmp/tokens" +touch "$tmp/Justfile" + +cp "$root/scripts/setup.sh" "$tmp/scripts/setup.sh" +cp "$root/scripts/common.sh" "$tmp/scripts/common.sh" + +cat >"$tmp/.env.example" <<'EOF' +FORGE_GITEA_URL="http://127.0.0.1:1" +FORGE_GITEA_ORG="x" +FORGE_GITEA_USERNAME="sandbox-user" +FORGE_ORCHESTRATOR_REPO_URL="http://127.0.0.1:1/x/y.git" +FORGE_WORKSPACE_ROOT="." +FSDGG_CLI_CLIENT_ID="sandbox-client" +FSDGG_CLI_REDIRECT_URI="http://127.0.0.1:38111/callback" +EOF +cp "$tmp/.env.example" "$tmp/.env" + +cat >"$tmp/scripts/doctor.sh" <<'EOF' +#!/usr/bin/env bash +echo "[doctor] stubbed" +EOF +chmod +x "$tmp/scripts/doctor.sh" + +cat >"$tmp/scripts/forge_login.sh" <"$tmp/forge_login.args" +mkdir -p "$tmp/tokens" +cat >"$tmp/tokens/client-auth.json" <"$tmp/scripts/install-git-credential-helper.sh" <<'EOF' +#!/usr/bin/env bash +echo "[install-helper stub] ok" +EOF +chmod +x "$tmp/scripts/install-git-credential-helper.sh" + +cat >"$tmp/scripts/forge_auth.py" <<'EOF' +#!/usr/bin/env python3 +import sys +if len(sys.argv) >= 2 and sys.argv[1] == "status": + sys.exit(1) +sys.exit(0) +EOF +chmod +x "$tmp/scripts/forge_auth.py" + +mkdir -p "$tmp/fakebin" +cat >"$tmp/fakebin/curl" <<'EOF' +#!/usr/bin/env bash +for arg in "$@"; do + case "$arg" in + *"/api/v1/version") + echo '{"version":"0.0.0-stub"}' + exit 0;; + esac +done +exec /usr/bin/curl "$@" +EOF +chmod +x "$tmp/fakebin/curl" + +cat >"$tmp/fakebin/git" <"$tmp/setup_guard.out" 2>"$tmp/setup_guard.err" +rc=$? +set -e +assert 'headless + FORGE_SETUP_YES + no session -> exits non-zero (no hang)' \ + bash -c "[ $rc -ne 0 ]" +assert 'headless + FORGE_SETUP_YES guard message is actionable' \ + grep -q 'cannot complete a fresh login under --headless + FORGE_SETUP_YES=1' \ + "$tmp/setup_guard.err" +assert 'headless + FORGE_SETUP_YES guard does NOT invoke forge_login.sh' \ + bash -c "[ ! -f \"$tmp/forge_login.args\" ]" + +# --- --headless (interactive) + no stored session: forge_login.sh --no-browser +rm -f "$tmp/forge_login.args" +set +e +env -u FORGE_SETUP_YES bash "$tmp/scripts/setup.sh" --headless \ + >"$tmp/setup_headless.out" 2>"$tmp/setup_headless.err" +rc=$? +set -e +assert 'headless (interactive) exits 0 in sandbox' bash -c "[ $rc -eq 0 ]" +assert 'headless (interactive) invokes forge_login.sh' \ + test -f "$tmp/forge_login.args" +assert 'headless (interactive) forwards --no-browser' \ + grep -qxF -- '--no-browser' "$tmp/forge_login.args" + +# --- Default (browser) must NOT forward --no-browser +rm -f "$tmp/forge_login.args" "$tmp/tokens/client-auth.json" +set +e +env -u FORGE_SETUP_YES bash "$tmp/scripts/setup.sh" \ + >"$tmp/setup_browser.out" 2>"$tmp/setup_browser.err" +rc=$? +set -e +assert 'default (browser) invocation exits 0' bash -c "[ $rc -eq 0 ]" +assert 'default (browser) invocation does NOT pass --no-browser' \ + bash -c "! grep -qxF -- '--no-browser' \"$tmp/forge_login.args\"" + +# --- Live token + --headless: reuse without invoking forge_login.sh +rm -f "$tmp/forge_login.args" +cat >"$tmp/tokens/client-auth.json" <<'JSON' +{"username":"sandbox-user","gitea_access_token":"live-token", + "_forge_refresh_token":"r"} +JSON +chmod 0600 "$tmp/tokens/client-auth.json" +cat >"$tmp/scripts/forge_auth.py" <<'EOF' +#!/usr/bin/env python3 +import sys +sys.exit(0) +EOF +chmod +x "$tmp/scripts/forge_auth.py" + +set +e +FORGE_SETUP_YES=1 bash "$tmp/scripts/setup.sh" --headless \ + >"$tmp/setup_live.out" 2>"$tmp/setup_live.err" +rc=$? +set -e +assert 'live + --headless exits 0' bash -c "[ $rc -eq 0 ]" +assert 'live + --headless does NOT invoke forge_login.sh' \ + bash -c "[ ! -f \"$tmp/forge_login.args\" ]" +assert 'live + --headless reports reuse (on stderr, where logs belong)' \ + grep -q 'reusing the stored session' "$tmp/setup_live.err" + +# --- Stale token, silent-refresh rescue: forge_login.sh still skipped +rm -f "$tmp/forge_login.args" "$tmp/tokens/client-auth.json" \ + "$tmp/forge_auth.calls" +cat >"$tmp/tokens/client-auth.json" <<'JSON' +{"username":"sandbox-user","_forge_refresh_token":"r", + "gitea_access_token":""} +JSON +chmod 0600 "$tmp/tokens/client-auth.json" +cat >"$tmp/scripts/forge_auth.py" <= 2 and sys.argv[1] == "status": + sys.exit(1) +if len(sys.argv) >= 2 and sys.argv[1] == "refresh": + import json, time + p = "$tmp/tokens/client-auth.json" + d = json.load(open(p)) + d["gitea_access_token"] = "refreshed-token" + d["gitea_token_expires_at"] = time.time() + 3600 + open(p, "w").write(json.dumps(d) + "\n") + sys.exit(0) +sys.exit(0) +EOF +chmod +x "$tmp/scripts/forge_auth.py" +export FORGE_AUTH_CALLS_LOG="$tmp/forge_auth.calls" + +set +e +FORGE_SETUP_YES=1 bash "$tmp/scripts/setup.sh" --headless \ + >"$tmp/setup_refresh.out" 2>"$tmp/setup_refresh.err" +rc=$? +set -e +assert 'silent-refresh rescue exits 0' bash -c "[ $rc -eq 0 ]" +assert 'silent-refresh rescue skips forge_login.sh' \ + bash -c "[ ! -f \"$tmp/forge_login.args\" ]" +assert 'silent-refresh rescue called forge_auth.py refresh --force' \ + grep -qF 'refresh --force' "$tmp/forge_auth.calls" +assert 'silent-refresh rescue reports success (on stderr)' \ + grep -q 'refreshed stored session without a browser' "$tmp/setup_refresh.err" + +# --- prompt_choice regression: non-tty branch must not contaminate stdout +fake=$(bash -c " +prompt_choice() { + local msg=\"\$1\" default=\"\$2\" reply + if [ \"\${FORGE_SETUP_YES:-0}\" = '1' ] || [ ! -t 0 ]; then + printf '%s [%s] (auto: %s)\n' \"\$msg\" \"\$default\" \"\$default\" >&2 + reply=\"\$default\" + fi + printf '%s' \"\$reply\" +} +FORGE_SETUP_YES=1 +printf '%s' \"\$(prompt_choice 'Pick one?' 'R')\" +") +assert 'prompt_choice non-tty branch returns only the default character' \ + bash -c "[ \"$fake\" = 'R' ]" + +printf '\n%d pass / %d fail\n' "$pass" "$fail" +[ "$fail" -eq 0 ] diff --git a/tests/test_setup_deploy_flag.sh b/tests/test_setup_deploy_flag.sh new file mode 100644 index 0000000..7119780 --- /dev/null +++ b/tests/test_setup_deploy_flag.sh @@ -0,0 +1,265 @@ +#!/usr/bin/env bash +# +# scripts/setup.sh --deploy flag + scripts/next_steps.sh --manifest / +# --recipe flags. Runs hermetically against a sandboxed $HOME with +# stubbed scripts and a stubbed orchestrator checkout carrying both +# contributor_setup_steps.json and operator_setup_steps.json. +# +# Operator welcome scaffold dispatch contract: +# - `just setup` (no --deploy) ALWAYS uses operator_setup_steps.json +# and operator-setup. It STOPS after the clone (prints the plan as +# informational text; does NOT prompt; does NOT invoke any recipe). +# Operators who want the handoff use `just deploy`. +# - `just setup --deploy` (== `just deploy`) ALWAYS uses operator_* +# and prompts [Y/n] (default Y) before auto-handing off to +# `just operator-setup` inside the orchestrator checkout. +# - The contributor manifest never appears in the operator scaffold's +# dispatch surface; the bug fixed here was setup.sh defaulting to +# contributor_setup_steps.json when --deploy was absent. + +set -euo pipefail + +here="$(cd "$(dirname "$0")" && pwd -P)" +root="$here/.." + +pass=0 +fail=0 +assert() { + local msg="$1"; shift + if "$@"; then + printf '[ok] %s\n' "$msg" + pass=$((pass + 1)) + else + printf '[FAIL] %s\n' "$msg" + fail=$((fail + 1)) + fi +} + +# -- syntax --------------------------------------------------------------- +assert 'setup.sh parses as valid bash' bash -n "$root/scripts/setup.sh" +assert 'next_steps.sh parses as valid bash' bash -n "$root/scripts/next_steps.sh" + +# -- --help documents --deploy and --manifest/--recipe -------------------- +# NOTE: capture help output to a file; inlining it into `bash -c` would +# expose backticks in the text (e.g. `just operator-setup`) to command +# substitution. Use files or here-strings instead. +setup_help_file="$(mktemp)" +bash "$root/scripts/setup.sh" --help >"$setup_help_file" 2>&1 +assert 'setup --help documents --deploy' \ + grep -q -- '--deploy' "$setup_help_file" +assert 'setup --help mentions operator-setup' \ + grep -q 'operator-setup' "$setup_help_file" +assert 'setup --help mentions operator_setup_steps.json' \ + grep -q 'operator_setup_steps.json' "$setup_help_file" +rm -f "$setup_help_file" + +next_help_file="$(mktemp)" +bash "$root/scripts/next_steps.sh" --help >"$next_help_file" 2>&1 +assert 'next_steps --help documents --manifest' \ + grep -q -- '--manifest' "$next_help_file" +assert 'next_steps --help documents --recipe' \ + grep -q -- '--recipe' "$next_help_file" +rm -f "$next_help_file" + +# -- sandbox -------------------------------------------------------------- +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +mkdir -p "$tmp/scripts" "$tmp/home" "$tmp/tokens" "$tmp/workspace" +touch "$tmp/Justfile" + +cp "$root/scripts/setup.sh" "$tmp/scripts/setup.sh" +cp "$root/scripts/next_steps.sh" "$tmp/scripts/next_steps.sh" +cp "$root/scripts/common.sh" "$tmp/scripts/common.sh" + +cat >"$tmp/.env.example" <<'EOF' +FORGE_GITEA_URL="http://127.0.0.1:1" +FORGE_GITEA_ORG="x" +FORGE_GITEA_USERNAME="sandbox-user" +FORGE_ORCHESTRATOR_REPO_URL="http://127.0.0.1:1/x/y.git" +FORGE_WORKSPACE_ROOT="./workspace" +FSDGG_CLI_CLIENT_ID="sandbox-client" +FSDGG_CLI_REDIRECT_URI="http://127.0.0.1:38111/callback" +EOF +cp "$tmp/.env.example" "$tmp/.env" + +# stub doctor/login/helper +for name in doctor.sh forge_login.sh install-git-credential-helper.sh; do + cat >"$tmp/scripts/$name" <"$tmp/scripts/forge_auth.py" <<'EOF' +#!/usr/bin/env python3 +import sys +sys.exit(0) +EOF +chmod +x "$tmp/scripts/forge_auth.py" + +# curl stub +mkdir -p "$tmp/fakebin" +cat >"$tmp/fakebin/curl" <<'EOF' +#!/usr/bin/env bash +for arg in "$@"; do + case "$arg" in + *"/api/v1/version") + echo '{"version":"0.0.0-stub"}' + exit 0;; + esac +done +exec /usr/bin/curl "$@" +EOF +chmod +x "$tmp/fakebin/curl" + +# git stub: ls-remote OK; clone → seed the fake orchestrator with both manifests +cat >"$tmp/fakebin/git" <"\$dest/scripts/contributor_setup_steps.json" <<'JSON' +{"schema_version":1,"steps":[{"id":"noop","title":"Contributor noop","cmd":["true"]}]} +JSON + # Operator manifest (plain echo of one step) + cat >"\$dest/scripts/operator_setup_steps.json" <<'JSON' +{"schema_version":1,"steps":[{"id":"op-step","title":"Operator noop","cmd":["true"]}]} +JSON + # Fake Justfile exposing both recipes. + # The test records invocations to verify the selected recipe. + cat >"\$dest/Justfile" <<'JUST' +contributor-setup *args: + @echo "[stub-orchestrator] called: contributor-setup \$@" >>.invocations +operator-setup *args: + @echo "[stub-orchestrator] called: operator-setup \$@" >>.invocations +JUST + exit 0;; +esac +exec /usr/bin/git "\$@" +EOF +chmod +x "$tmp/fakebin/git" + +# setup.sh deploy path calls `exec bash next_steps.sh --run --manifest ... --recipe ...`. +# That path reaches `exec just `. The stub keeps the test +# deterministic and avoids a dependency on the sandboxed orchestrator. +cat >"$tmp/fakebin/just" <>"$tmp/just.calls" +exit 0 +EOF +chmod +x "$tmp/fakebin/just" + +export HOME="$tmp/home" +export PATH="$tmp/fakebin:/usr/bin:/bin" +export FSDGG_AUTH_STORE_PATH="$tmp/tokens/client-auth.json" +export FORGE_SETUP_YES=1 +export FORGE_GITEA_URL="http://127.0.0.1:1" +export FORGE_GITEA_ORG="x" +export FORGE_GITEA_USERNAME="sandbox-user" +export FORGE_ORCHESTRATOR_REPO_URL="http://127.0.0.1:1/x/y.git" +export FORGE_ORCHESTRATOR_BRANCH="" +export FORGE_WORKSPACE_ROOT="$tmp/workspace" +export FSDGG_CLI_CLIENT_ID="sandbox-client" +export FSDGG_CLI_REDIRECT_URI="http://127.0.0.1:38111/callback" +# Live stored token so setup.sh skips the login step (FORGE_SETUP_YES+headless +# would otherwise trip the guard). +mkdir -p "$tmp/tokens" +cat >"$tmp/tokens/client-auth.json" <<'JSON' +{"username":"sandbox-user","gitea_access_token":"live","_forge_refresh_token":"r", + "gitea_token_expires_at":32503680000} +JSON +chmod 0600 "$tmp/tokens/client-auth.json" + +# -- A) default path: operator manifest, no auto-run -------------------- +# Without --deploy the operator scaffold ALWAYS uses the operator +# manifest and STOPS after the clone. The contributor manifest must +# never be referenced; no recipe must be invoked. +rm -f "$tmp/just.calls" +set +e +FORGE_SETUP_YES=1 bash "$tmp/scripts/setup.sh" --headless \ + >"$tmp/out_default.out" 2>"$tmp/out_default.err" +rc=$? +set -e +assert 'default (no --deploy) exits 0' bash -c "[ $rc -eq 0 ]" +assert 'default path renders operator_setup_steps.json' \ + grep -qF 'operator_setup_steps.json' "$tmp/out_default.err" +assert 'default path mentions just operator-setup in the handoff hint' \ + grep -qF 'just operator-setup' "$tmp/out_default.err" +assert 'default path does NOT reference contributor_setup_steps.json' \ + bash -c "! grep -qF 'contributor_setup_steps.json' \"$tmp/out_default.err\"" +assert 'default path does NOT mention contributor-setup' \ + bash -c "! grep -qF 'contributor-setup' \"$tmp/out_default.err\"" +assert 'default path does NOT prompt for an operator-setup handoff' \ + bash -c "! grep -qF 'Hand off to' \"$tmp/out_default.err\"" +assert 'default path does NOT invoke any just recipe (no auto-run)' \ + bash -c "! [ -f \"$tmp/just.calls\" ]" + +# -- B) --deploy path: operator manifest, default-Y prompt, hands off --- +# Under FORGE_SETUP_YES=1 the [Y/n] prompt's default Y is taken, so the +# stubbed `just operator-setup` is invoked inside the orchestrator cwd. +rm -f "$tmp/just.calls" +set +e +FORGE_SETUP_YES=1 bash "$tmp/scripts/setup.sh" --headless --deploy \ + >"$tmp/out_deploy.out" 2>"$tmp/out_deploy.err" +rc=$? +set -e +assert '--deploy exits 0' bash -c "[ $rc -eq 0 ]" +assert '--deploy renders operator_setup_steps.json' \ + grep -qF 'operator_setup_steps.json' "$tmp/out_deploy.err" +assert '--deploy prompt names operator-setup' \ + grep -qF 'operator-setup' "$tmp/out_deploy.err" +assert '--deploy prompt is [Y/n] (default Y, handoff is the default)' \ + grep -qE 'Hand off to.*\[Y/n\]' "$tmp/out_deploy.err" +assert '--deploy invokes just operator-setup (FORGE_SETUP_YES=1 takes the default Y)' \ + bash -c "[ -f \"$tmp/just.calls\" ] && grep -qE '^just operator-setup' \"$tmp/just.calls\"" +assert '--deploy does NOT invoke just contributor-setup' \ + bash -c "! { [ -f \"$tmp/just.calls\" ] && grep -qE '^just contributor-setup' \"$tmp/just.calls\"; }" +assert '--deploy hands off in the cloned orchestrator cwd (basename of FORGE_ORCHESTRATOR_REPO_URL)' \ + grep -qF "(cwd=$tmp/workspace/y)" "$tmp/just.calls" + +# -- C) run-mode wiring: next_steps.sh --run --manifest --recipe ----- +# Invoke next_steps.sh directly with --run to prove the runner execs +# `just operator-setup` (not `just contributor-setup`) when --recipe is set. +rm -f "$tmp/just.calls" +set +e +bash "$tmp/scripts/next_steps.sh" --run --manifest operator_setup_steps.json --recipe operator-setup \ + >"$tmp/out_run_deploy.out" 2>"$tmp/out_run_deploy.err" +rc=$? +set -e +assert 'next_steps --run --recipe operator-setup exits 0' bash -c "[ $rc -eq 0 ]" +assert 'next_steps --run invoked `just operator-setup`' \ + grep -q '^just operator-setup' "$tmp/just.calls" +assert 'next_steps --run did NOT invoke `just contributor-setup`' \ + bash -c "! grep -q '^just contributor-setup' \"$tmp/just.calls\"" + +# -- D) print mode shows updated skip-autorun hints --------------------- +set +e +bash "$tmp/scripts/next_steps.sh" --manifest operator_setup_steps.json --recipe operator-setup \ + >"$tmp/out_print.out" 2>"$tmp/out_print.err" +rc=$? +set -e +assert 'next_steps print mode exits 0' bash -c "[ $rc -eq 0 ]" +assert 'print hint mentions operator-setup recipe' \ + grep -q "just operator-setup" "$tmp/out_print.err" +assert 'print hint mentions --recipe flag' \ + grep -q "run-next-steps --manifest operator_setup_steps.json --recipe operator-setup" "$tmp/out_print.err" + +printf '\n%d pass / %d fail\n' "$pass" "$fail" +[ "$fail" -eq 0 ]