From bbd85fae717a5ddd950b89e7eef850de497e634c Mon Sep 17 00:00:00 2001 From: reachableceo Date: Sat, 29 Aug 2026 06:58:35 -0500 Subject: [PATCH] Add end-to-end smoke driving the real CLI against the containerized fake --- internal/cli/cli.go | 14 +++ internal/fakeredmine/fakeredmine.go | 37 ++++--- smoke/fakeredmine/main.go | 23 +++++ smoke/smoke.sh | 149 ++++++++++++++++++++++++++++ 4 files changed, 211 insertions(+), 12 deletions(-) create mode 100644 smoke/fakeredmine/main.go create mode 100755 smoke/smoke.sh diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 868a523..e7ab2d4 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -53,6 +53,20 @@ func Run(args []string, stdout, stderr io.Writer) int { fmt.Fprint(stderr, usage) return 1 } + // Hoist a leading global --config PATH onto the subcommand (the + // subcommand flag sets already accept it anywhere). + var hoisted []string + for len(args) >= 2 && (args[0] == "--config" || args[0] == "-config") { + hoisted = append(hoisted, args[0], args[1]) + args = args[2:] + } + if len(hoisted) > 0 { + if len(args) == 0 { + fmt.Fprint(stderr, usage) + return 1 + } + args = append(args, hoisted...) + } switch args[0] { case "help", "-h", "--help": fmt.Fprint(stdout, usage) diff --git a/internal/fakeredmine/fakeredmine.go b/internal/fakeredmine/fakeredmine.go index 969938c..94c5d1a 100644 --- a/internal/fakeredmine/fakeredmine.go +++ b/internal/fakeredmine/fakeredmine.go @@ -126,14 +126,32 @@ type Status struct { // New starts a fake on a random port with Redmine's default // enumerations and an empty MOPAC project. func New(apiKey string) *Server { - s := &Server{ - APIKey: apiKey, - apiKeySet: true, - issues: map[int]*Issue{}, - versions: map[int]Version{}, + s := newServer(apiKey) + mux := http.NewServeMux() + mux.HandleFunc("/", s.handler) + s.srv = httptest.NewServer(mux) + s.URL = s.srv.URL + return s +} + +// ListenAndServe runs the fake on a fixed address (the smoke run boots it +// in a container). An empty apiKey disables auth checking. +func ListenAndServe(addr, apiKey string) error { + s := newServer(apiKey) + mux := http.NewServeMux() + mux.HandleFunc("/", s.handler) + return http.ListenAndServe(addr, mux) +} + +func newServer(apiKey string) *Server { + return &Server{ + APIKey: apiKey, + apiKeySet: true, + issues: map[int]*Issue{}, + versions: map[int]Version{}, categories: map[int]Category{}, - relations: map[int]Relation{}, - nextID: 100, + relations: map[int]Relation{}, + nextID: 100, Trackers: []IDName{ {1, "Bug"}, {2, "Feature"}, {3, "Support"}, {4, "Task"}, }, @@ -145,11 +163,6 @@ func New(apiKey string) *Server { {1, "Low"}, {2, "Normal"}, {3, "High"}, {4, "Urgent"}, {5, "Immediate"}, }, } - mux := http.NewServeMux() - mux.HandleFunc("/", s.handler) - s.srv = httptest.NewServer(mux) - s.URL = s.srv.URL - return s } // Close shuts the fake down. diff --git a/smoke/fakeredmine/main.go b/smoke/fakeredmine/main.go new file mode 100644 index 0000000..f81aca1 --- /dev/null +++ b/smoke/fakeredmine/main.go @@ -0,0 +1,23 @@ +// Command fakeredmine boots the in-memory fake Redmine on a fixed +// address for the smoke run (see smoke/smoke.sh). The real tracker is +// never contacted. +package main + +import ( + "flag" + "log" + "os" + + "git.knownelement.com/ukrrs/mopac-redmine-go/internal/fakeredmine" +) + +func main() { + addr := flag.String("addr", ":8601", "listen address") + flag.Parse() + key := os.Getenv("FAKE_KEY") + if key == "" { + key = "smoke-redmine-key-0123456789" + } + log.Printf("fakeredmine listening on %s", *addr) + log.Fatal(fakeredmine.ListenAndServe(*addr, key)) +} diff --git a/smoke/smoke.sh b/smoke/smoke.sh new file mode 100755 index 0000000..8d1138c --- /dev/null +++ b/smoke/smoke.sh @@ -0,0 +1,149 @@ +#!/bin/sh +# End-to-end smoke for mred: builds the CLI in the Docker builder, boots +# the FAKE Redmine in a container on 127.0.0.1:8601, drives the real +# binary from the host through a 0600 env file, and asserts the full +# command surface (issue create/list/show/update with a journal note, +# versions, categories, relations, -o json, exit codes) plus key +# redaction. No real tracker is ever contacted. Only the exact container +# ID spawned here is removed. +set -e + +cd "$(dirname "$0")/.." + +IMAGE="golang@sha256:e8c859f5632dcfde7b32d2012b4351728f6437930887c2f6a91ea242459e5514" +PORT=8601 +KEY="smoke-redmine-key-0123456789" +CID="" + +cleanup() { + if [ -n "$CID" ]; then + docker rm -f "$CID" >/dev/null 2>&1 || true + fi + rm -rf .smoke +} +trap cleanup EXIT INT TERM + +mkdir -p .smoke +umask 077 + +echo "--- build CLI (docker builder)" +docker run --rm -v "$PWD:/h" -w /h \ + -u "$(id -u):$(id -g)" -e HOME=/tmp -e GOFLAGS=-buildvcs=false \ + "$IMAGE" go build -o bin/mred ./cmd/mred + +echo "--- boot fake Redmine (container, port $PORT)" +CID=$(docker run -d --rm \ + -v "$PWD:/h" -w /h \ + -u "$(id -u):$(id -g)" -e HOME=/tmp -e GOFLAGS=-buildvcs=false \ + -p 127.0.0.1:$PORT:8601 \ + -e FAKE_KEY="$KEY" \ + "$IMAGE" go run ./smoke/fakeredmine -addr :8601) + +# wait for the fake to answer (any HTTP response, even 401, proves it is up) +i=0 +until [ -n "$CID" ] && [ "$(docker inspect -f '{{.State.Running}}' "$CID" 2>/dev/null)" = "true" ] && \ + python3 -c " +import urllib.request, urllib.error +req = urllib.request.Request('http://127.0.0.1:$PORT/trackers.json', + headers={'X-Redmine-API-Key': 'probe'}) +try: + urllib.request.urlopen(req, timeout=2) +except urllib.error.HTTPError: + raise SystemExit(0) +except Exception: + raise SystemExit(1) +raise SystemExit(0) +"; do + i=$((i+1)) + if [ "$i" -ge 60 ]; then + echo "smoke: fake Redmine did not come up; logs:" >&2 + docker logs "$CID" >&2 || true + exit 1 + fi + sleep 1 +done + +printf 'MRED_URL=http://127.0.0.1:%s\nMRED_KEY=%s\n' "$PORT" "$KEY" > .smoke/env +chmod 600 .smoke/env + +mred() { ./bin/mred --config .smoke/env "$@"; } + +echo "--- version create + list" +mred version create -p MOPAC -n Beta --due 2026-08-31 --status open > .smoke/version.out +grep -q "created version #.* Beta (open)" .smoke/version.out +mred version list -p MOPAC > .smoke/versions.out +grep -q "Beta" .smoke/versions.out + +echo "--- category create + list" +mred category create -p MOPAC -n "Quota & Backpressure" > .smoke/cat.out +grep -q "created category #" .smoke/cat.out +mred category list -p MOPAC > .smoke/cats.out +grep -q "Quota & Backpressure" .smoke/cats.out + +echo "--- issue create (full flags, json out)" +mred issue create -p MOPAC -s "Smoke: quota accounting" \ + --tracker feature --priority immediate \ + --category "quota & backpressure" --version beta \ + --due 2026-08-31 --est 8 -o json \ + --desc - <<'EOF' > .smoke/create.out +## Scope +- smoke body +EOF +grep -q '"id":' .smoke/create.out +ID=$(python3 -c 'import json; print(json.load(open(".smoke/create.out"))["issue"]["id"])') + +mred issue create -p MOPAC -s "Smoke: dispatcher piece" --tracker task > /dev/null +ID2=$(mred issue list -p MOPAC --status all -o json | python3 -c 'import json,sys +issues = json.load(sys.stdin)["issues"] +print([i["id"] for i in issues if i["subject"] == "Smoke: dispatcher piece"][0])') + +echo "--- issue list + filters" +mred issue list -p MOPAC > .smoke/list.out +grep -q "Smoke: quota accounting" .smoke/list.out +grep -q "@Beta" .smoke/list.out +mred issue list -p MOPAC --version Beta --limit 5 > .smoke/listver.out +grep -q "Smoke: quota accounting" .smoke/listver.out + +echo "--- issue update (status + note) then show with journals" +mred issue update "$ID" --status done --done-ratio 100 --note "REPORT delivered: smoke" > .smoke/upd.out +grep -q "updated issue #$ID" .smoke/upd.out +mred issue show "$ID" --with journals > .smoke/show.out +grep -q "REPORT delivered: smoke" .smoke/show.out +grep -q "Done" .smoke/show.out +grep -q "smoke body" .smoke/show.out + +echo "--- relation create" +mred relation create "$ID" "$ID2" --type blocks > .smoke/rel.out +grep -q "blocks" .smoke/rel.out + +echo "--- failure path: missing issue (exit 2, one-line stderr with http code)" +if mred issue show 999 > .smoke/missing.out 2> .smoke/missing.err; then + echo "smoke: missing issue should fail" >&2; exit 1 +fi +grep -q "http 404" .smoke/missing.err +[ "$(wc -l < .smoke/missing.err)" -eq 1 ] || { echo "smoke: stderr not one line" >&2; exit 1; } +[ ! -s .smoke/missing.out ] || { echo "smoke: stdout not empty on failure" >&2; exit 1; } + +echo "--- failure path: wrong key (exit 2, no key material in stderr)" +printf 'MRED_URL=http://127.0.0.1:%s\nMRED_KEY=wrong-key-abcdef\n' "$PORT" > .smoke/bad.env +chmod 600 .smoke/bad.env +if ./bin/mred --config .smoke/bad.env issue list -p MOPAC 2> .smoke/bad.err; then + echo "smoke: wrong key should fail" >&2; exit 1 +fi +grep -q "http 401" .smoke/bad.err + +echo "--- failure path: loose env file (exit 1)" +printf 'MRED_URL=http://127.0.0.1:%s\nMRED_KEY=%s\n' "$PORT" "$KEY" > .smoke/loose.env +chmod 644 .smoke/loose.env +if ./bin/mred --config .smoke/loose.env issue list -p MOPAC 2> .smoke/loose.err; then + echo "smoke: loose env file should be refused" >&2; exit 1 +fi +grep -q "insecure mode" .smoke/loose.err + +echo "--- redaction: no key material in any captured output" +for f in .smoke/*.out .smoke/*.err; do + grep -qF "$KEY" "$f" && { echo "smoke: API key leaked into $f" >&2; exit 1; } +done +grep -qF "wrong-key-abcdef" .smoke/bad.err && { echo "smoke: wrong key echoed" >&2; exit 1; } + +echo "smoke: OK"