Files
incredigo/internal/rotate/gitea_refs_test.go
T
leetcrypt 8dbb2f21fd rotate/gitea: multi-reference blast-and-cutover (LIVE-VM)
Rotate one Gitea PAT and rewrite it in EVERY on-disk reference in lockstep,
so a real in-use token can rotate without breaking git auth. The blob carries
one ref=<file> per reference; Rotate mints the new token, proves it
authenticates, then rewrites the old->new token literal in each ref file
(atomic temp+rename, minGiteaTokenLen-guarded), Verify asserts the new token
is present in each ref, and the spine revokes the old token last. A dead token
is never written into a git config; any failure leaves every ref on a live token.

Adds internal/rotate/gitea_refs.go (read-only reference enumerator) + driver and
enumerator unit tests. Proven end-to-end against real Gitea 1.25.0 in the sandbox
VM via lab/lab-gitea-blast-vm.sh (GITEA_BLAST_VM_OK): one rotation rewrote a fake
.git-credentials, an embedded git-remote URL, and a tea config; new->200, old->401.

Two documented follow-ons before any in-use host PAT: gopass-entry-sync (PAT
duplicated across other gopass entries) and token-scope-cloning (preserve the old
token's repo scopes on mint, like sendgrid).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-19 13:25:52 -07:00

102 lines
3.3 KiB
Go

package rotate
import (
"os"
"path/filepath"
"strings"
"testing"
)
const refTestTok = "0123456789abcdef0123456789abcdef01234567" // 40-hex, > minGiteaTokenLen
// TestEnumerateGiteaRefs proves the enumerator returns exactly the files that literally
// hold the token, skips files that do not, and skips missing/unreadable candidates.
func TestEnumerateGiteaRefs(t *testing.T) {
dir := t.TempDir()
hit1 := filepath.Join(dir, ".git-credentials")
hit2 := filepath.Join(dir, "config") // embedded git remote URL
miss := filepath.Join(dir, "unrelated.txt")
absent := filepath.Join(dir, "does-not-exist")
mustWrite(t, hit1, "http://alice:"+refTestTok+"@gitea.local\n")
mustWrite(t, hit2, "\turl = http://alice:"+refTestTok+"@gitea.local/a/b.git\n")
mustWrite(t, miss, "nothing sensitive here\n")
got, err := enumerateGiteaRefs(refTestTok, []string{hit1, hit2, miss, absent, ""})
if err != nil {
t.Fatalf("enumerate: %v", err)
}
want := map[string]bool{hit1: true, hit2: true}
if len(got) != len(want) {
t.Fatalf("got %v, want the two files holding the token", got)
}
for _, g := range got {
if !want[g] {
t.Errorf("unexpected ref %q", g)
}
}
}
// TestEnumerateGiteaRefsDedup proves duplicate candidate paths (absolute vs relative to
// the same file) collapse to a single reference.
func TestEnumerateGiteaRefsDedup(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, ".git-credentials")
mustWrite(t, f, "http://bob:"+refTestTok+"@gitea.local\n")
got, err := enumerateGiteaRefs(refTestTok, []string{f, f})
if err != nil {
t.Fatalf("enumerate: %v", err)
}
if len(got) != 1 {
t.Fatalf("expected dedup to 1 ref, got %v", got)
}
}
// TestEnumerateGiteaRefsRejectsShortToken proves the length guard: a short token is
// refused before any file is read, so it can never match unrelated bytes.
func TestEnumerateGiteaRefsRejectsShortToken(t *testing.T) {
if _, err := enumerateGiteaRefs("short", []string{"/etc/hosts"}); err == nil ||
!strings.Contains(err.Error(), "shorter than") {
t.Fatalf("expected short-token refusal, got %v", err)
}
}
// TestGiteaGitConfigRefFiles proves only repo roots with a real .git/config are returned.
func TestGiteaGitConfigRefFiles(t *testing.T) {
dir := t.TempDir()
repo := filepath.Join(dir, "repo")
if err := os.MkdirAll(filepath.Join(repo, ".git"), 0o755); err != nil {
t.Fatal(err)
}
cfg := filepath.Join(repo, ".git", "config")
mustWrite(t, cfg, "[core]\n")
noRepo := filepath.Join(dir, "not-a-repo") // no .git at all
got := giteaGitConfigRefFiles(repo, noRepo)
if len(got) != 1 || got[0] != cfg {
t.Fatalf("expected only %q, got %v", cfg, got)
}
}
// TestGiteaWellKnownRefFilesXDG proves the tea config path honors $XDG_CONFIG_HOME.
func TestGiteaWellKnownRefFilesXDG(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", "/xdg")
got := giteaWellKnownRefFiles("/home/alice")
joined := strings.Join(got, "\n")
if !strings.Contains(joined, "/home/alice/.git-credentials") {
t.Errorf("missing git-credentials in %v", got)
}
if !strings.Contains(joined, "/xdg/tea/config.yml") {
t.Errorf("tea config should honor XDG_CONFIG_HOME, got %v", got)
}
}
func mustWrite(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}