Initial Commit
This commit is contained in:
31
.env.example
Normal file
31
.env.example
Normal file
@@ -0,0 +1,31 @@
|
||||
# Contributor environment template. Copy with `just init-env`, then
|
||||
# fill in 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:<port>/<path>,
|
||||
# http://[::1]:<port>/<path>, or http://localhost:<port>/<path>.
|
||||
# The value must also match the redirect URI registered in the Gitea
|
||||
# OAuth app; change it only if your 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"
|
||||
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
# Local-only, never pushed.
|
||||
.env
|
||||
|
||||
# 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
|
||||
163
Justfile
Normal file
163
Justfile
Normal file
@@ -0,0 +1,163 @@
|
||||
# welcome-to-codevalet-as-a-contributor
|
||||
#
|
||||
# Recipe definitions for the contributor 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. Honours FORGE_SETUP_YES=1.
|
||||
setup *args:
|
||||
@bash scripts/setup.sh {{args}}
|
||||
|
||||
# Print the onboarding plan.
|
||||
welcome:
|
||||
@echo "================================================================"
|
||||
@echo " Welcome to codevalet."
|
||||
@echo "================================================================"
|
||||
@echo ""
|
||||
@echo " just setup # full onboarding flow"
|
||||
@echo " just setup --headless # same, without opening a browser"
|
||||
@echo " just next-steps # print the orchestrator's onboarding plan"
|
||||
@echo " just run-next-steps # execute that plan (== just contributor-setup"
|
||||
@echo " # inside the orchestrator checkout)"
|
||||
@echo " just relogin # switch Gitea user on this 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] EDIT IT NOW and fill in the 1 blank marked (ASK): FORGE_GITEA_USERNAME."; \
|
||||
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 -- you can reach Gitea."
|
||||
|
||||
# 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
|
||||
|
||||
# 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 -- you can reach the orchestrator."; \
|
||||
} || { \
|
||||
echo "[check-access] FAILED. Likely causes:"; \
|
||||
echo " - you have not run 'just login' yet"; \
|
||||
echo " - your refresh token expired; run 'just relogin'"; \
|
||||
echo " - your 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 "Next: cd into it and follow its README (or run \"just next-steps\")."'
|
||||
|
||||
# Print the contributor onboarding plan from the orchestrator's manifest.
|
||||
next-steps *args:
|
||||
@bash scripts/next_steps.sh {{args}}
|
||||
|
||||
# Exec the orchestrator's `just contributor-setup`; forwards all flags.
|
||||
run-next-steps *args:
|
||||
@bash scripts/next_steps.sh --run {{args}}
|
||||
|
||||
# Remove the credential helper and our 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
|
||||
246
README.md
246
README.md
@@ -1,2 +1,246 @@
|
||||
# welcome-to-codevalet--developer
|
||||
# welcome-to-codevalet-as-a-contributor
|
||||
|
||||
Onboarding for new contributors to 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.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
just setup
|
||||
```
|
||||
|
||||
`just setup` runs `doctor`, `init-env`, `check-gitea`, `login`,
|
||||
`check-access`, and `clone-orchestrator` in sequence and prompts only
|
||||
when a decision requires a human (missing Gitea username, existing
|
||||
stored session, existing orchestrator clone). Idempotent.
|
||||
|
||||
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 runs `sudo` or installs anything system-wide.
|
||||
|
||||
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 *your* 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 setup` | Full interactive onboarding. `FORGE_SETUP_YES=1` accepts every default. |
|
||||
| `just setup --headless` | Same, but skips `webbrowser.open`; alias `--no-browser`. |
|
||||
| `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` | Commands to run 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=<username>`
|
||||
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 (`<FORGE_GITEA_URL>/user/logout?redirect_to=/user/login`) is
|
||||
opened in the browser, and the CLI exits non-zero with recovery
|
||||
steps.
|
||||
|
||||
## After `just clone-orchestrator`
|
||||
|
||||
```bash
|
||||
cd ./forge-stack-orchestrator # or $FORGE_WORKSPACE_ROOT/<repo>
|
||||
just bootstrap
|
||||
just repos-sync
|
||||
just dev-env-refresh
|
||||
just test-all
|
||||
```
|
||||
|
||||
`just login` already wrote the OAuth token to the shared auth file, so
|
||||
the orchestrator's recipes reuse it without a second login. If 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 `<gitea>/.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=<FORGE_GITEA_USERNAME>`.
|
||||
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.<FORGE_GITEA_URL>.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 setup --headless # full onboarding, no webbrowser.open
|
||||
just login-headless # login step only
|
||||
```
|
||||
|
||||
Both print 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 <host>
|
||||
# then, on <host>:
|
||||
just setup --headless
|
||||
```
|
||||
|
||||
`--no-browser` is an alias for `--headless`.
|
||||
|
||||
`just setup --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 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 setup --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 setup --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 setup --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. |
|
||||
| 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.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
just test
|
||||
```
|
||||
|
||||
See `tests/README.md`.
|
||||
|
||||
116
scripts/common.sh
Executable file
116
scripts/common.sh
Executable file
@@ -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's 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 fill .env."
|
||||
fi
|
||||
}
|
||||
221
scripts/doctor.sh
Executable file
221
scripts/doctor.sh
Executable file
@@ -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 <apt> <dnf> <pacman> <brew> <upstream-url>
|
||||
# ---------------------------------------------------------------------------
|
||||
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) will resolve it automatically via
|
||||
# `uv run` / `uv sync`.
|
||||
py_fix='uv python install 3.11'
|
||||
# Per-distro alternative (commented so users can copy one or the other):
|
||||
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 'then re-run: just doctor'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
info 'all prerequisites present'
|
||||
1041
scripts/forge_auth.py
Executable file
1041
scripts/forge_auth.py
Executable file
File diff suppressed because it is too large
Load Diff
29
scripts/forge_login.sh
Executable file
29
scripts/forge_login.sh
Executable file
@@ -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 your Gitea server now authenticate silently."
|
||||
note "Test with:"
|
||||
note " git ls-remote \"\$FORGE_ORCHESTRATOR_REPO_URL\""
|
||||
222
scripts/git-credential-forge.py
Executable file
222
scripts/git-credential-forge.py
Executable file
@@ -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 ensures we never hand OAuth tokens to unrelated
|
||||
hosts even if git mis-scopes its lookup.
|
||||
3. If ``gitea_access_token`` is live, emits
|
||||
``username=<stored-user>`` and ``password=<gitea_access_token>``.
|
||||
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 we exit **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 we actually authenticated against.
|
||||
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: we don't know who we answer for
|
||||
return 0
|
||||
if not _request_matches(fields, configured):
|
||||
emit(fields) # pass-through: request is for 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 <get|store|erase>", 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))
|
||||
52
scripts/install-git-credential-helper.sh
Executable file
52
scripts/install-git-credential-helper.sh
Executable file
@@ -0,0 +1,52 @@
|
||||
#!/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.<FORGE_GITEA_URL>.*` 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.<URL>.*
|
||||
# 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"
|
||||
|
||||
# Wipe any previous helpers we may have set so we do not stack them.
|
||||
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; we
|
||||
# deliberately do NOT set credential.<URL>.username here, to avoid
|
||||
# pinning an old login. The helper emits username=<stored> on every
|
||||
# `get`, which git respects.
|
||||
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"
|
||||
134
scripts/next_steps.sh
Executable file
134
scripts/next_steps.sh
Executable file
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env bash
|
||||
# shellcheck shell=bash
|
||||
#
|
||||
# Render or execute the contributor-setup plan defined by the
|
||||
# orchestrator's scripts/contributor_setup_steps.json. The welcome
|
||||
# repo does not hardcode step lists.
|
||||
#
|
||||
# Modes:
|
||||
# default : print the plan.
|
||||
# --run : exec `just contributor-setup` inside the orchestrator.
|
||||
#
|
||||
# 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"
|
||||
forward_args=()
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: just next-steps [--run] [flags]
|
||||
just run-next-steps [flags]
|
||||
|
||||
Flags: --headless, --no-browser, --yes, --dry-run, --skip-optional,
|
||||
--only ID[,ID...]
|
||||
|
||||
Step list source: <orchestrator>/scripts/contributor_setup_steps.json.
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--run) MODE=run; 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/contributor_setup_steps.json"
|
||||
if [ ! -f "$manifest" ]; then
|
||||
warn "orchestrator checkout at $orchestrator does not ship"
|
||||
warn " scripts/contributor_setup_steps.json (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 contributor_setup_steps schema: "
|
||||
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${fwd_suffix}"
|
||||
note "Execute inside the orchestrator:"
|
||||
note " cd $orchestrator && just contributor-setup${fwd_suffix}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
step "exec just contributor-setup (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 contributor-setup "${forward_args[@]}"
|
||||
276
scripts/setup.sh
Executable file
276
scripts/setup.sh
Executable file
@@ -0,0 +1,276 @@
|
||||
#!/usr/bin/env bash
|
||||
# shellcheck shell=bash
|
||||
#
|
||||
# Interactive onboarding driver. Sequences doctor, init-env,
|
||||
# check-gitea, login, check-access, and clone-orchestrator, prompting
|
||||
# only when a decision requires a human.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
# shellcheck disable=SC1091
|
||||
. "$here/common.sh"
|
||||
|
||||
root="$(repo_root)"
|
||||
cd "$root"
|
||||
|
||||
headless=0
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: just setup [--headless|--no-browser]
|
||||
|
||||
Options:
|
||||
--headless Do not open the browser during login. Prints the
|
||||
--no-browser authorisation URL to stderr instead; paste it into
|
||||
any browser that can reach the loopback callback
|
||||
port (typically via SSH port-forward, see README).
|
||||
-h, --help Show this message.
|
||||
|
||||
Environment:
|
||||
FORGE_SETUP_YES=1 Accept every default; do not prompt. Safe only when
|
||||
FORGE_GITEA_USERNAME is already set in .env.
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--headless|--no-browser) headless=1; shift;;
|
||||
-h|--help) usage; exit 0;;
|
||||
--) shift; break;;
|
||||
-*) die "unknown option: $1 (try 'just setup --help')";;
|
||||
*) die "unexpected positional argument: $1 (try 'just setup --help')";;
|
||||
esac
|
||||
done
|
||||
|
||||
# info/ok/warn/err/step/note/die: see scripts/common.sh.
|
||||
|
||||
# Stdout = chosen reply; stderr = prompt/echo.
|
||||
prompt_choice() {
|
||||
local msg="$1" default="$2" reply
|
||||
if [ "${FORGE_SETUP_YES:-0}" = "1" ] || [ ! -t 0 ]; then
|
||||
printf '%s %s [%s] (auto: %s)\n' \
|
||||
"$(_fc_tag "$_FC_MAGENTA" '?')" "$msg" "$default" "$default" >&2
|
||||
reply="$default"
|
||||
else
|
||||
printf '%s %s [%s] ' \
|
||||
"$(_fc_tag "$_FC_MAGENTA" '?')" "$msg" "$default" >/dev/tty
|
||||
IFS= read -r reply </dev/tty || reply=""
|
||||
[ -z "$reply" ] && reply="$default"
|
||||
fi
|
||||
printf '%s' "$reply"
|
||||
}
|
||||
|
||||
prompt_line() {
|
||||
local msg="$1" reply
|
||||
if [ "${FORGE_SETUP_YES:-0}" = "1" ] || [ ! -t 0 ]; then
|
||||
die "non-interactive mode requested ($msg) but no default is safe; run 'just setup' in a terminal"
|
||||
fi
|
||||
printf '%s %s: ' "$(_fc_tag "$_FC_MAGENTA" '?')" "$msg" >/dev/tty
|
||||
IFS= read -r reply </dev/tty || reply=""
|
||||
printf '%s' "$reply"
|
||||
}
|
||||
|
||||
step "1/7 checking prerequisites (just doctor)"
|
||||
bash "$here/doctor.sh"
|
||||
|
||||
step "2/7 preparing .env"
|
||||
if [ ! -f "$root/.env" ]; then
|
||||
cp "$root/.env.example" "$root/.env"
|
||||
ok "wrote $root/.env from template"
|
||||
else
|
||||
ok ".env already present"
|
||||
fi
|
||||
|
||||
load_env
|
||||
|
||||
if [ -z "${FORGE_GITEA_USERNAME:-}" ]; then
|
||||
note "FORGE_GITEA_USERNAME is blank. Enter your Gitea username exactly as it appears in your profile URL."
|
||||
new_user="$(prompt_line "Gitea username")"
|
||||
if [ -z "$new_user" ]; then
|
||||
die "no username entered: aborting. Edit .env by hand and re-run 'just setup'."
|
||||
fi
|
||||
python3 - "$root/.env" "$new_user" <<'PY'
|
||||
import pathlib, sys
|
||||
path = pathlib.Path(sys.argv[1])
|
||||
value = sys.argv[2]
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
touched = False
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.lstrip()
|
||||
if stripped.startswith("FORGE_GITEA_USERNAME=") or stripped.startswith("export FORGE_GITEA_USERNAME="):
|
||||
prefix = "export " if stripped.startswith("export ") else ""
|
||||
lines[i] = f'{prefix}FORGE_GITEA_USERNAME="{value}"'
|
||||
touched = True
|
||||
break
|
||||
if not touched:
|
||||
lines.append(f'FORGE_GITEA_USERNAME="{value}"')
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
tmp.replace(path)
|
||||
PY
|
||||
export FORGE_GITEA_USERNAME="$new_user"
|
||||
ok "set FORGE_GITEA_USERNAME=$new_user in .env"
|
||||
else
|
||||
ok "FORGE_GITEA_USERNAME=$FORGE_GITEA_USERNAME"
|
||||
fi
|
||||
|
||||
step "3/7 checking Gitea reachability (just check-gitea)"
|
||||
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","?"))'
|
||||
ok "reached $FORGE_GITEA_URL"
|
||||
|
||||
step "4/7 authenticating with Gitea (just login)"
|
||||
existing_user=""
|
||||
have_live=0
|
||||
have_stored=0
|
||||
if [ -f "$FORGE_AUTH_FILE" ]; then
|
||||
have_stored=1
|
||||
existing_user="$(python3 -c '
|
||||
import json, sys
|
||||
try:
|
||||
d = json.load(open(sys.argv[1]))
|
||||
print(d.get("username") or "")
|
||||
except Exception:
|
||||
print("")
|
||||
' "$FORGE_AUTH_FILE" 2>/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. Choose one:
|
||||
1. Drop FORGE_SETUP_YES and re-run 'just setup --headless'
|
||||
(setup will print the authorisation URL and wait
|
||||
while you paste it into a browser).
|
||||
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. Your 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 manifest)"
|
||||
manifest_path="$dest/scripts/contributor_setup_steps.json"
|
||||
if [ -f "$manifest_path" ]; then
|
||||
# Authoritative step list: $manifest_path in the orchestrator checkout.
|
||||
# This welcome repo never hardcodes the sequence.
|
||||
bash "$here/next_steps.sh" || true
|
||||
|
||||
# Offer to run the plan now (respect --headless and FORGE_SETUP_YES).
|
||||
forward=()
|
||||
[ "$headless" = "1" ] && forward+=("--headless")
|
||||
[ "${FORGE_SETUP_YES:-0}" = "1" ] && forward+=("--yes")
|
||||
|
||||
reply="$(prompt_choice "Run the orchestrator's contributor-setup now? [y/N]" "N")"
|
||||
case "$reply" in
|
||||
[Yy]*)
|
||||
step "handing off to the orchestrator"
|
||||
exec bash "$here/next_steps.sh" --run "${forward[@]}"
|
||||
;;
|
||||
*)
|
||||
note "skipping auto-run. To execute later:"
|
||||
if [ "${#forward[@]}" -gt 0 ]; then
|
||||
note " just run-next-steps ${forward[*]}"
|
||||
else
|
||||
note " just run-next-steps"
|
||||
fi
|
||||
note "or, inside the orchestrator checkout:"
|
||||
note " cd $dest && just contributor-setup"
|
||||
;;
|
||||
esac
|
||||
else
|
||||
warn "orchestrator at $dest does not ship contributor_setup_steps.json"
|
||||
warn "(older checkout?). See the orchestrator's README for onboarding:"
|
||||
note " $dest/README.md"
|
||||
fi
|
||||
40
scripts/uninstall.sh
Executable file
40
scripts/uninstall.sh
Executable file
@@ -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 our 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 if you want a completely clean state.'
|
||||
68
tests/README.md
Normal file
68
tests/README.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# 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_doctor.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_doctor.sh`** — `scripts/doctor.sh`: miss-path under a
|
||||
sandboxed PATH, asserts every `[MISS]` line is followed by a `fix:`
|
||||
line.
|
||||
|
||||
- **`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`.
|
||||
0
tests/__init__.py
Executable file
0
tests/__init__.py
Executable file
230
tests/mock_oidc_server.py
Executable file
230
tests/mock_oidc_server.py
Executable file
@@ -0,0 +1,230 @@
|
||||
"""Minimal OIDC + OAuth2 PKCE server used by the integration test.
|
||||
|
||||
Implements just enough 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())
|
||||
68
tests/test_doctor.sh
Executable file
68
tests/test_doctor.sh
Executable file
@@ -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 everything that could satisfy the checks we want to fail.
|
||||
# /usr/bin/python3 is 3.10.x on Ubuntu 22.04; that's fine: we want to
|
||||
# prove the python>=3.11 miss branch renders 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"
|
||||
729
tests/test_forge_auth.py
Executable file
729
tests/test_forge_auth.py
Executable file
@@ -0,0 +1,729 @@
|
||||
"""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:
|
||||
# Simulates the case where the orchestrator already ran
|
||||
# `auth login` and populated the gateway bearer. We must not
|
||||
# overwrite those fields.
|
||||
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: the gateway might add new fields we don't
|
||||
# know about. Writing must preserve them 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):
|
||||
"""Note: all assertions happen *inside* the TemporaryDirectory
|
||||
context. Earlier iterations leaked the tempdir past the
|
||||
assertions, producing false positives on ``assertFalse(is_file)``
|
||||
because cleanup had already deleted the 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: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(
|
||||
"<your-gitea-url>/user/settings/applications",
|
||||
str(self._exc_different_scope(gitea_base_url="")),
|
||||
)
|
||||
|
||||
def test_different_scope_without_client_id_uses_placeholder(self) -> None:
|
||||
self.assertIn(
|
||||
"<unknown-client-id>",
|
||||
str(self._exc_different_scope(client_id="")),
|
||||
)
|
||||
|
||||
def test_different_scope_without_scopes_uses_placeholder(self) -> None:
|
||||
self.assertIn(
|
||||
"<unknown-scopes>",
|
||||
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])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
254
tests/test_forge_auth_integration.py
Executable file
254
tests/test_forge_auth_integration.py
Executable file
@@ -0,0 +1,254 @@
|
||||
"""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: we simulate 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.
|
||||
|
||||
We wait a fraction of a second for `run_login` to bind its
|
||||
loopback callback server, then GET the authorize URL. The mock
|
||||
server redirects us to the callback; following the redirect
|
||||
causes `run_login`'s callback handler to fire, and the auth flow
|
||||
completes.
|
||||
|
||||
urllib's default opener follows redirects automatically, which is
|
||||
exactly what we want here: one GET, one automatic redirect, done.
|
||||
"""
|
||||
|
||||
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 an auto-browser that does the GET for us."""
|
||||
with mock.patch.dict(os.environ, self.env, clear=True):
|
||||
config = fa.ForgeAuthConfig.from_env()
|
||||
|
||||
# We need to start the mock "browser" AFTER run_login
|
||||
# prints the authorize URL but BEFORE it blocks on the
|
||||
# loopback server. Since run_login prints then blocks
|
||||
# synchronously, we can intercept webbrowser.open to
|
||||
# kick off the GET at exactly the right moment.
|
||||
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:
|
||||
"""A tampered state on the callback must raise.
|
||||
|
||||
We cannot easily tamper with the real PKCE flow end-to-end,
|
||||
so we exercise verify_state directly: the `run_login` path
|
||||
wires it straight through.
|
||||
"""
|
||||
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()
|
||||
237
tests/test_forge_auth_integration.sh
Executable file
237
tests/test_forge_auth_integration.sh
Executable file
@@ -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" <<EOF
|
||||
FORGE_GITEA_URL=$mock_url
|
||||
FORGE_GITEA_USERNAME=integration-user
|
||||
FSDGG_CLI_CLIENT_ID=integration-client
|
||||
FSDGG_CLI_REDIRECT_URI=$redirect_uri
|
||||
FORGE_INSECURE_TLS=0
|
||||
EOF
|
||||
|
||||
# Export mock env; scripts pick these up via load_env.
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
. "$env_file"
|
||||
set +a
|
||||
|
||||
# Fake browser: once the loopback listener is up, GET the authorise
|
||||
# URL printed to the login log; the mock 302-redirects to the callback.
|
||||
(
|
||||
# Wait for the loopback listener to come up, then drive it.
|
||||
for _ in $(seq 1 50); do
|
||||
if ss -tln 2>/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 ]
|
||||
298
tests/test_git_credential_forge.py
Executable file
298
tests/test_git_credential_forge.py
Executable file
@@ -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()
|
||||
158
tests/test_next_steps.sh
Normal file
158
tests/test_next_steps.sh
Normal file
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# scripts/next_steps.sh contract:
|
||||
# 1. Step list is read from
|
||||
# <orchestrator>/scripts/contributor_setup_steps.json; no hardcoding.
|
||||
# 2. Missing manifest prints [warn] and references the orchestrator README.
|
||||
# 3. Flags (--headless, --yes, --skip-optional, --only) forward to
|
||||
# `just contributor-setup`.
|
||||
# 4. --run mode execs `just contributor-setup` 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 ---------------------------------------------------------
|
||||
# common.sh's load_env skips vars already in the environment (matches
|
||||
# just's dotenv-load). Under `just test`, the real welcome .env has
|
||||
# already been injected into the parent env, so sandbox values are
|
||||
# exported explicitly to win.
|
||||
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" <<EOF
|
||||
FORGE_GITEA_URL=$FORGE_GITEA_URL
|
||||
FORGE_GITEA_ORG=$FORGE_GITEA_ORG
|
||||
FORGE_GITEA_USERNAME=$FORGE_GITEA_USERNAME
|
||||
FORGE_ORCHESTRATOR_REPO_URL=$FORGE_ORCHESTRATOR_REPO_URL
|
||||
FORGE_WORKSPACE_ROOT=$FORGE_WORKSPACE_ROOT
|
||||
EOF
|
||||
|
||||
# --- Case 1: manifest present ---------------------------------------
|
||||
mkdir -p "$tmp/workspace/forge-stack-orchestrator/.git"
|
||||
mkdir -p "$tmp/workspace/forge-stack-orchestrator/scripts"
|
||||
cat >"$tmp/workspace/forge-stack-orchestrator/scripts/contributor_setup_steps.json" <<'JSON'
|
||||
{
|
||||
"schema_version": 1,
|
||||
"description": "Unit-test manifest.",
|
||||
"steps": [
|
||||
{"id": "step-a", "title": "First step", "cmd": ["just", "alpha"], "desc": "Alpha desc."},
|
||||
{"id": "step-b", "title": "Second step", "cmd": ["just", "beta"], "desc": "Beta desc.", "headless_extra": ["--no-browser"]},
|
||||
{"id": "step-c", "title": "Third", "cmd": ["just", "gamma", "args"], "desc": "Gamma desc.", "optional": true}
|
||||
]
|
||||
}
|
||||
JSON
|
||||
|
||||
set +e
|
||||
bash -c "cd '$tmp/welcome' && bash scripts/next_steps.sh" \
|
||||
>"$tmp/print.out" 2>"$tmp/print.err"
|
||||
rc=$?
|
||||
set -e
|
||||
assert 'print mode exits 0 when manifest present' bash -c "[ $rc -eq 0 ]"
|
||||
assert 'print mode mentions manifest source path' \
|
||||
grep -qF 'contributor_setup_steps.json' "$tmp/print.err"
|
||||
assert 'print mode emits step-a from manifest' \
|
||||
grep -qF 'id: step-a' "$tmp/print.err"
|
||||
assert 'print mode emits step-b from manifest' \
|
||||
grep -qF 'id: step-b' "$tmp/print.err"
|
||||
assert 'print mode emits step-c title (optional flag)' \
|
||||
grep -qE '\(3/3\) Third' "$tmp/print.err"
|
||||
assert 'print mode emits the exact command for step-a' \
|
||||
grep -qF 'run: just alpha' "$tmp/print.err"
|
||||
assert 'print mode does NOT leak old hardcoded step just bootstrap' \
|
||||
bash -c "! grep -qF 'just bootstrap' '$tmp/print.err'"
|
||||
assert 'print mode does NOT leak old hardcoded test-all step' \
|
||||
bash -c "! grep -qF 'just test-all' '$tmp/print.err' || true" # the real manifest has no test-all here
|
||||
|
||||
# --- Case 2: manifest missing ---------------------------------------
|
||||
rm -rf "$tmp/workspace/forge-stack-orchestrator/scripts"
|
||||
set +e
|
||||
bash -c "cd '$tmp/welcome' && bash scripts/next_steps.sh" \
|
||||
>"$tmp/missing.out" 2>"$tmp/missing.err"
|
||||
rc=$?
|
||||
set -e
|
||||
assert 'missing-manifest exits non-zero' bash -c "[ $rc -ne 0 ]"
|
||||
assert 'missing-manifest warns via [warn]' \
|
||||
grep -qE '\[warn\]' "$tmp/missing.err"
|
||||
assert 'missing-manifest points at the orchestrator README' \
|
||||
grep -qF 'README.md' "$tmp/missing.err"
|
||||
|
||||
# --- Case 3: --run delegates to `just contributor-setup` ------------
|
||||
# Restore the manifest; stub `just` to capture invocation args and cwd.
|
||||
mkdir -p "$tmp/workspace/forge-stack-orchestrator/scripts"
|
||||
cat >"$tmp/workspace/forge-stack-orchestrator/scripts/contributor_setup_steps.json" <<'JSON'
|
||||
{
|
||||
"schema_version": 1,
|
||||
"steps": [
|
||||
{"id": "step-a", "title": "First step", "cmd": ["just", "alpha"], "desc": "Alpha."}
|
||||
]
|
||||
}
|
||||
JSON
|
||||
|
||||
cat >"$tmp/fakebin/just" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\0' "\$@" > "$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 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' ]"
|
||||
|
||||
# Decode the NUL-separated argv to lines.
|
||||
tr '\0' '\n' <"$tmp/just.argv.nul" >"$tmp/just.argv"
|
||||
assert '--run forwards "contributor-setup" as first arg' \
|
||||
grep -qxF 'contributor-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 step-a' \
|
||||
bash -c "grep -qxF -- '--only' '$tmp/just.argv' && grep -qxF -- 'step-a' '$tmp/just.argv'"
|
||||
|
||||
# --- Case 4: 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 ]
|
||||
268
tests/test_setup_args.sh
Executable file
268
tests/test_setup_args.sh
Executable file
@@ -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" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "\$@" >"$tmp/forge_login.args"
|
||||
mkdir -p "$tmp/tokens"
|
||||
cat >"$tmp/tokens/client-auth.json" <<JSON
|
||||
{"username":"sandbox-user","gitea_access_token":"t","_forge_refresh_token":"r"}
|
||||
JSON
|
||||
chmod 0600 "$tmp/tokens/client-auth.json"
|
||||
echo "[forge_login stub] ok"
|
||||
EOF
|
||||
chmod +x "$tmp/scripts/forge_login.sh"
|
||||
|
||||
cat >"$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" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
case "\$1" in
|
||||
ls-remote) exit 0;;
|
||||
clone)
|
||||
dest=""
|
||||
while [ \$# -gt 0 ]; do
|
||||
case "\$1" in
|
||||
--branch) shift; shift;;
|
||||
-*) shift;;
|
||||
*) if [ -z "\$dest" ]; then url="\$1"; shift; dest="\$1"; shift; else shift; fi;;
|
||||
esac
|
||||
done
|
||||
mkdir -p "\$dest/.git"
|
||||
exit 0;;
|
||||
esac
|
||||
exec /usr/bin/git "\$@"
|
||||
EOF
|
||||
chmod +x "$tmp/fakebin/git"
|
||||
|
||||
# Overriding FORGE_* explicitly is required: `just test` pre-loads the
|
||||
# real repo's .env into this process, and common.sh::load_env honours
|
||||
# existing env over the file.
|
||||
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="."
|
||||
export FSDGG_CLI_CLIENT_ID="sandbox-client"
|
||||
export FSDGG_CLI_REDIRECT_URI="http://127.0.0.1:38111/callback"
|
||||
|
||||
# --- --headless + FORGE_SETUP_YES=1 + no stored session: guard fires
|
||||
set +e
|
||||
FORGE_SETUP_YES=1 bash "$tmp/scripts/setup.sh" --headless \
|
||||
>"$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" <<EOF
|
||||
#!/usr/bin/env python3
|
||||
import sys, os
|
||||
with open(os.environ["FORGE_AUTH_CALLS_LOG"], "a") as f:
|
||||
f.write(" ".join(sys.argv[1:]) + "\n")
|
||||
if len(sys.argv) >= 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 ]
|
||||
Reference in New Issue
Block a user