8dbb2f21fd
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>
102 lines
3.3 KiB
Go
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)
|
|
}
|
|
}
|