mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-07 00:24:17 +08:00
Close go-git repository handles across store operations and tests to avoid leaked handles blocking recovery cleanup. Abort recovery before mutating the worktree when close failures occur and retain backups when rollback fails.
2237 lines
80 KiB
Go
2237 lines
80 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-git/go-git/v6"
|
|
gitconfig "github.com/go-git/go-git/v6/config"
|
|
"github.com/go-git/go-git/v6/plumbing"
|
|
"github.com/go-git/go-git/v6/plumbing/object"
|
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
|
)
|
|
|
|
type testBranchSpec struct {
|
|
name string
|
|
contents string
|
|
}
|
|
|
|
type callbackTokenStorage struct {
|
|
save func(string) error
|
|
}
|
|
|
|
func (s *callbackTokenStorage) SaveTokenToFile(path string) error {
|
|
return s.save(path)
|
|
}
|
|
|
|
func TestEnsureRepositoryUsesRemoteDefaultBranchWhenBranchNotConfigured(t *testing.T) {
|
|
root := t.TempDir()
|
|
remoteDir := setupGitRemoteRepository(t, root, "trunk",
|
|
testBranchSpec{name: "trunk", contents: "remote default branch\n"},
|
|
testBranchSpec{name: "release/2026", contents: "release branch\n"},
|
|
)
|
|
|
|
store := NewGitTokenStore(remoteDir, "", "", "")
|
|
store.SetBaseDir(filepath.Join(root, "workspace", "auths"))
|
|
|
|
if err := store.EnsureRepository(); err != nil {
|
|
t.Fatalf("EnsureRepository: %v", err)
|
|
}
|
|
|
|
assertRepositoryBranchAndContents(t, filepath.Join(root, "workspace"), "trunk", "remote default branch\n")
|
|
advanceRemoteBranch(t, filepath.Join(root, "seed"), remoteDir, "trunk", "remote default branch updated\n", "advance trunk")
|
|
advanceRemoteBranch(t, filepath.Join(root, "seed"), remoteDir, "release/2026", "release branch updated\n", "advance release")
|
|
|
|
if err := store.EnsureRepository(); err != nil {
|
|
t.Fatalf("EnsureRepository second call: %v", err)
|
|
}
|
|
|
|
assertRepositoryBranchAndContents(t, filepath.Join(root, "workspace"), "trunk", "remote default branch updated\n")
|
|
assertRemoteHeadBranch(t, remoteDir, "trunk")
|
|
}
|
|
|
|
func TestEnsureRepositoryUsesConfiguredBranchWhenExplicitlySet(t *testing.T) {
|
|
root := t.TempDir()
|
|
remoteDir := setupGitRemoteRepository(t, root, "trunk",
|
|
testBranchSpec{name: "trunk", contents: "remote default branch\n"},
|
|
testBranchSpec{name: "release/2026", contents: "release branch\n"},
|
|
)
|
|
|
|
store := NewGitTokenStore(remoteDir, "", "", "release/2026")
|
|
store.SetBaseDir(filepath.Join(root, "workspace", "auths"))
|
|
|
|
if err := store.EnsureRepository(); err != nil {
|
|
t.Fatalf("EnsureRepository: %v", err)
|
|
}
|
|
|
|
assertRepositoryBranchAndContents(t, filepath.Join(root, "workspace"), "release/2026", "release branch\n")
|
|
advanceRemoteBranch(t, filepath.Join(root, "seed"), remoteDir, "trunk", "remote default branch updated\n", "advance trunk")
|
|
advanceRemoteBranch(t, filepath.Join(root, "seed"), remoteDir, "release/2026", "release branch updated\n", "advance release")
|
|
|
|
if err := store.EnsureRepository(); err != nil {
|
|
t.Fatalf("EnsureRepository second call: %v", err)
|
|
}
|
|
|
|
assertRepositoryBranchAndContents(t, filepath.Join(root, "workspace"), "release/2026", "release branch updated\n")
|
|
assertRemoteHeadBranch(t, remoteDir, "trunk")
|
|
}
|
|
|
|
func TestEnsureRepositoryReturnsErrorForMissingConfiguredBranch(t *testing.T) {
|
|
root := t.TempDir()
|
|
remoteDir := setupGitRemoteRepository(t, root, "trunk",
|
|
testBranchSpec{name: "trunk", contents: "remote default branch\n"},
|
|
)
|
|
|
|
store := NewGitTokenStore(remoteDir, "", "", "missing-branch")
|
|
store.SetBaseDir(filepath.Join(root, "workspace", "auths"))
|
|
|
|
err := store.EnsureRepository()
|
|
if err == nil {
|
|
t.Fatal("EnsureRepository succeeded, want error for nonexistent configured branch")
|
|
}
|
|
assertRemoteHeadBranch(t, remoteDir, "trunk")
|
|
}
|
|
|
|
func TestEnsureRepositoryReturnsErrorForMissingConfiguredBranchOnExistingRepositoryPull(t *testing.T) {
|
|
root := t.TempDir()
|
|
remoteDir := setupGitRemoteRepository(t, root, "trunk",
|
|
testBranchSpec{name: "trunk", contents: "remote default branch\n"},
|
|
)
|
|
|
|
baseDir := filepath.Join(root, "workspace", "auths")
|
|
store := NewGitTokenStore(remoteDir, "", "", "")
|
|
store.SetBaseDir(baseDir)
|
|
|
|
if err := store.EnsureRepository(); err != nil {
|
|
t.Fatalf("EnsureRepository initial clone: %v", err)
|
|
}
|
|
|
|
reopened := NewGitTokenStore(remoteDir, "", "", "missing-branch")
|
|
reopened.SetBaseDir(baseDir)
|
|
|
|
err := reopened.EnsureRepository()
|
|
if err == nil {
|
|
t.Fatal("EnsureRepository succeeded on reopen, want error for nonexistent configured branch")
|
|
}
|
|
assertRepositoryHeadBranch(t, filepath.Join(root, "workspace"), "trunk")
|
|
assertRemoteHeadBranch(t, remoteDir, "trunk")
|
|
}
|
|
|
|
func TestEnsureRepositoryInitializesEmptyRemoteUsingConfiguredBranch(t *testing.T) {
|
|
root := t.TempDir()
|
|
remoteDir := filepath.Join(root, "remote.git")
|
|
remoteRepo, errInit := git.PlainInit(remoteDir, true)
|
|
if errInit != nil {
|
|
t.Fatalf("init bare remote: %v", errInit)
|
|
}
|
|
if errClose := remoteRepo.Close(); errClose != nil {
|
|
t.Fatalf("close bare remote: %v", errClose)
|
|
}
|
|
|
|
branch := "feature/gemini-fix"
|
|
store := NewGitTokenStore(remoteDir, "", "", branch)
|
|
store.SetBaseDir(filepath.Join(root, "workspace", "auths"))
|
|
|
|
if err := store.EnsureRepository(); err != nil {
|
|
t.Fatalf("EnsureRepository: %v", err)
|
|
}
|
|
|
|
assertRepositoryHeadBranch(t, filepath.Join(root, "workspace"), branch)
|
|
assertRemoteBranchExistsWithCommit(t, remoteDir, branch)
|
|
assertRemoteBranchDoesNotExist(t, remoteDir, "master")
|
|
}
|
|
|
|
func TestEnsureRepositoryExistingRepoSwitchesToConfiguredBranch(t *testing.T) {
|
|
root := t.TempDir()
|
|
remoteDir := setupGitRemoteRepository(t, root, "master",
|
|
testBranchSpec{name: "master", contents: "remote master branch\n"},
|
|
testBranchSpec{name: "develop", contents: "remote develop branch\n"},
|
|
)
|
|
|
|
baseDir := filepath.Join(root, "workspace", "auths")
|
|
store := NewGitTokenStore(remoteDir, "", "", "")
|
|
store.SetBaseDir(baseDir)
|
|
|
|
if err := store.EnsureRepository(); err != nil {
|
|
t.Fatalf("EnsureRepository initial clone: %v", err)
|
|
}
|
|
assertRepositoryBranchAndContents(t, filepath.Join(root, "workspace"), "master", "remote master branch\n")
|
|
|
|
reopened := NewGitTokenStore(remoteDir, "", "", "develop")
|
|
reopened.SetBaseDir(baseDir)
|
|
|
|
if err := reopened.EnsureRepository(); err != nil {
|
|
t.Fatalf("EnsureRepository reopen: %v", err)
|
|
}
|
|
assertRepositoryBranchAndContents(t, filepath.Join(root, "workspace"), "develop", "remote develop branch\n")
|
|
|
|
workspaceDir := filepath.Join(root, "workspace")
|
|
if err := os.WriteFile(filepath.Join(workspaceDir, "branch.txt"), []byte("local develop update\n"), 0o600); err != nil {
|
|
t.Fatalf("write local branch marker: %v", err)
|
|
}
|
|
|
|
reopened.mu.Lock()
|
|
err := reopened.commitAndPushLocked("Update develop branch marker", "branch.txt")
|
|
reopened.mu.Unlock()
|
|
if err != nil {
|
|
t.Fatalf("commitAndPushLocked: %v", err)
|
|
}
|
|
|
|
assertRepositoryHeadBranch(t, workspaceDir, "develop")
|
|
assertRemoteBranchContents(t, remoteDir, "develop", "local develop update\n")
|
|
assertRemoteBranchContents(t, remoteDir, "master", "remote master branch\n")
|
|
}
|
|
|
|
func TestEnsureRepositoryExistingRepoSwitchesToConfiguredBranchCreatedAfterClone(t *testing.T) {
|
|
root := t.TempDir()
|
|
remoteDir := setupGitRemoteRepository(t, root, "master",
|
|
testBranchSpec{name: "master", contents: "remote master branch\n"},
|
|
)
|
|
|
|
baseDir := filepath.Join(root, "workspace", "auths")
|
|
store := NewGitTokenStore(remoteDir, "", "", "")
|
|
store.SetBaseDir(baseDir)
|
|
|
|
if err := store.EnsureRepository(); err != nil {
|
|
t.Fatalf("EnsureRepository initial clone: %v", err)
|
|
}
|
|
assertRepositoryBranchAndContents(t, filepath.Join(root, "workspace"), "master", "remote master branch\n")
|
|
|
|
advanceRemoteBranchFromNewBranch(t, filepath.Join(root, "seed"), remoteDir, "release/2026", "release branch\n", "create release")
|
|
|
|
reopened := NewGitTokenStore(remoteDir, "", "", "release/2026")
|
|
reopened.SetBaseDir(baseDir)
|
|
|
|
if err := reopened.EnsureRepository(); err != nil {
|
|
t.Fatalf("EnsureRepository reopen: %v", err)
|
|
}
|
|
assertRepositoryBranchAndContents(t, filepath.Join(root, "workspace"), "release/2026", "release branch\n")
|
|
}
|
|
|
|
func TestEnsureRepositoryResetsToRemoteDefaultWhenBranchUnset(t *testing.T) {
|
|
root := t.TempDir()
|
|
remoteDir := setupGitRemoteRepository(t, root, "master",
|
|
testBranchSpec{name: "master", contents: "remote master branch\n"},
|
|
testBranchSpec{name: "develop", contents: "remote develop branch\n"},
|
|
)
|
|
|
|
baseDir := filepath.Join(root, "workspace", "auths")
|
|
// First store pins to develop and prepares local workspace
|
|
storePinned := NewGitTokenStore(remoteDir, "", "", "develop")
|
|
storePinned.SetBaseDir(baseDir)
|
|
if err := storePinned.EnsureRepository(); err != nil {
|
|
t.Fatalf("EnsureRepository pinned: %v", err)
|
|
}
|
|
assertRepositoryBranchAndContents(t, filepath.Join(root, "workspace"), "develop", "remote develop branch\n")
|
|
|
|
// Second store has branch unset and should reset local workspace to remote default (master)
|
|
storeDefault := NewGitTokenStore(remoteDir, "", "", "")
|
|
storeDefault.SetBaseDir(baseDir)
|
|
if err := storeDefault.EnsureRepository(); err != nil {
|
|
t.Fatalf("EnsureRepository default: %v", err)
|
|
}
|
|
// Local HEAD should now follow remote default (master)
|
|
assertRepositoryHeadBranch(t, filepath.Join(root, "workspace"), "master")
|
|
|
|
// Make a local change and push using the store with branch unset; push should update remote master
|
|
workspaceDir := filepath.Join(root, "workspace")
|
|
if err := os.WriteFile(filepath.Join(workspaceDir, "branch.txt"), []byte("local master update\n"), 0o600); err != nil {
|
|
t.Fatalf("write local master marker: %v", err)
|
|
}
|
|
storeDefault.mu.Lock()
|
|
if err := storeDefault.commitAndPushLocked("Update master marker", "branch.txt"); err != nil {
|
|
storeDefault.mu.Unlock()
|
|
t.Fatalf("commitAndPushLocked: %v", err)
|
|
}
|
|
storeDefault.mu.Unlock()
|
|
|
|
assertRemoteBranchContents(t, remoteDir, "master", "local master update\n")
|
|
}
|
|
|
|
func TestGitTokenStoreRefusesWatcherOriginatedAuthDeletion(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
remoteDir := setupGitRemoteRepository(t, root, "master",
|
|
testBranchSpec{name: "master", contents: "remote master branch\n"},
|
|
)
|
|
store := NewGitTokenStore(remoteDir, "", "", "")
|
|
baseDir := filepath.Join(root, "workspace", "auths")
|
|
store.SetBaseDir(baseDir)
|
|
if err := store.EnsureRepository(); err != nil {
|
|
t.Fatalf("EnsureRepository: %v", err)
|
|
}
|
|
|
|
auth := &cliproxyauth.Auth{
|
|
ID: "protected.json",
|
|
FileName: "protected.json",
|
|
Provider: "codex",
|
|
Metadata: map[string]any{"type": "codex", "access_token": "token"},
|
|
}
|
|
path, err := store.Save(context.Background(), auth)
|
|
if err != nil {
|
|
t.Fatalf("Save: %v", err)
|
|
}
|
|
assertRemoteTreePath(t, remoteDir, "master", "auths/protected.json", true)
|
|
|
|
if err := os.Remove(path); err != nil {
|
|
t.Fatalf("simulate unexpected local removal: %v", err)
|
|
}
|
|
err = store.PersistAuthFiles(context.Background(), "Remove auth protected.json", path)
|
|
if err == nil {
|
|
t.Fatal("PersistAuthFiles watcher removal error = nil, want fail-closed rejection")
|
|
}
|
|
if got := err.Error(); !strings.Contains(got, "refusing watcher-originated removal") {
|
|
t.Fatalf("PersistAuthFiles error = %q, want watcher-removal rejection", got)
|
|
}
|
|
assertRemoteTreePath(t, remoteDir, "master", "auths/protected.json", true)
|
|
}
|
|
|
|
func TestGitTokenStoreWatcherRemovalNoOpsAfterExplicitDelete(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
remoteDir := setupGitRemoteRepository(t, root, "master",
|
|
testBranchSpec{name: "master", contents: "remote master branch\n"},
|
|
)
|
|
store := NewGitTokenStore(remoteDir, "", "", "")
|
|
baseDir := filepath.Join(root, "workspace", "auths")
|
|
store.SetBaseDir(baseDir)
|
|
if err := store.EnsureRepository(); err != nil {
|
|
t.Fatalf("EnsureRepository: %v", err)
|
|
}
|
|
|
|
auth := &cliproxyauth.Auth{
|
|
ID: "explicit.json",
|
|
FileName: "explicit.json",
|
|
Provider: "codex",
|
|
Metadata: map[string]any{"type": "codex", "access_token": "token"},
|
|
}
|
|
path, err := store.Save(context.Background(), auth)
|
|
if err != nil {
|
|
t.Fatalf("Save: %v", err)
|
|
}
|
|
// Management deletes unlink the file before invoking Store.Delete.
|
|
if err := os.Remove(path); err != nil {
|
|
t.Fatalf("pre-remove explicit auth: %v", err)
|
|
}
|
|
if err := store.Delete(context.Background(), path); err != nil {
|
|
t.Fatalf("Delete after pre-remove: %v", err)
|
|
}
|
|
assertRemoteTreePath(t, remoteDir, "master", "auths/explicit.json", false)
|
|
|
|
if err := store.Delete(context.Background(), path); err != nil {
|
|
t.Fatalf("repeated Delete: %v", err)
|
|
}
|
|
assertRemoteTreePath(t, remoteDir, "master", "auths/explicit.json", false)
|
|
|
|
if err := store.PersistAuthFiles(context.Background(), "Remove auth explicit.json", path); err != nil {
|
|
t.Fatalf("watcher removal after explicit delete: %v", err)
|
|
}
|
|
assertRemoteTreePath(t, remoteDir, "master", "auths/explicit.json", false)
|
|
}
|
|
|
|
func TestGitTokenStoreRepeatedDeleteDoesNotOverwriteRemoteOnlyChanges(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
remoteDir := setupGitRemoteRepository(t, root, "master",
|
|
testBranchSpec{name: "master", contents: "remote master branch\n"},
|
|
)
|
|
storeA := NewGitTokenStore(remoteDir, "", "", "")
|
|
baseA := filepath.Join(root, "workspace-a", "auths")
|
|
storeA.SetBaseDir(baseA)
|
|
if err := storeA.EnsureRepository(); err != nil {
|
|
t.Fatalf("EnsureRepository A: %v", err)
|
|
}
|
|
authA := &cliproxyauth.Auth{
|
|
ID: "a.json",
|
|
FileName: "a.json",
|
|
Provider: "codex",
|
|
Metadata: map[string]any{"type": "codex", "access_token": "a"},
|
|
}
|
|
pathA, err := storeA.Save(context.Background(), authA)
|
|
if err != nil {
|
|
t.Fatalf("Save A: %v", err)
|
|
}
|
|
if err := storeA.Delete(context.Background(), pathA); err != nil {
|
|
t.Fatalf("Delete A: %v", err)
|
|
}
|
|
|
|
storeB := NewGitTokenStore(remoteDir, "", "", "")
|
|
baseB := filepath.Join(root, "workspace-b", "auths")
|
|
storeB.SetBaseDir(baseB)
|
|
if err := storeB.EnsureRepository(); err != nil {
|
|
t.Fatalf("EnsureRepository B: %v", err)
|
|
}
|
|
authB := &cliproxyauth.Auth{
|
|
ID: "b.json",
|
|
FileName: "b.json",
|
|
Provider: "codex",
|
|
Metadata: map[string]any{"type": "codex", "access_token": "b"},
|
|
}
|
|
if _, err := storeB.Save(context.Background(), authB); err != nil {
|
|
t.Fatalf("Save B: %v", err)
|
|
}
|
|
assertRemoteTreePath(t, remoteDir, "master", "auths/b.json", true)
|
|
|
|
if err := storeA.Delete(context.Background(), pathA); err != nil {
|
|
t.Fatalf("repeated Delete A: %v", err)
|
|
}
|
|
assertRemoteTreePath(t, remoteDir, "master", "auths/b.json", true)
|
|
}
|
|
|
|
func TestGitTokenStoreRejectsPathsOutsideRepositoryBeforeMutation(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
remoteDir := setupGitRemoteRepository(t, root, "master",
|
|
testBranchSpec{name: "master", contents: "remote master branch\n"},
|
|
)
|
|
store := NewGitTokenStore(remoteDir, "", "", "")
|
|
baseDir := filepath.Join(root, "workspace", "auths")
|
|
store.SetBaseDir(baseDir)
|
|
if err := store.EnsureRepository(); err != nil {
|
|
t.Fatalf("EnsureRepository: %v", err)
|
|
}
|
|
|
|
outsidePath := filepath.Join(root, "outside.json")
|
|
outsideContents := []byte("outside\n")
|
|
if err := os.WriteFile(outsidePath, outsideContents, 0o600); err != nil {
|
|
t.Fatalf("write outside file: %v", err)
|
|
}
|
|
if err := store.Delete(context.Background(), outsidePath); err == nil {
|
|
t.Fatal("Delete outside repository error = nil, want rejection")
|
|
}
|
|
if got, errRead := os.ReadFile(outsidePath); errRead != nil {
|
|
t.Fatalf("read outside file after delete rejection: %v", errRead)
|
|
} else if string(got) != string(outsideContents) {
|
|
t.Fatalf("outside file contents = %q, want %q", got, outsideContents)
|
|
}
|
|
|
|
outsideSavePath := filepath.Join(root, "outside-save.json")
|
|
auth := &cliproxyauth.Auth{
|
|
ID: "outside-save.json",
|
|
FileName: "outside-save.json",
|
|
Provider: "codex",
|
|
Attributes: map[string]string{
|
|
cliproxyauth.AttributePath: outsideSavePath,
|
|
},
|
|
Metadata: map[string]any{"type": "codex", "access_token": "token"},
|
|
}
|
|
if _, err := store.Save(context.Background(), auth); err == nil {
|
|
t.Fatal("Save outside repository error = nil, want rejection")
|
|
}
|
|
if _, errStat := os.Stat(outsideSavePath); !errors.Is(errStat, os.ErrNotExist) {
|
|
t.Fatalf("outside save path stat error = %v, want not exist", errStat)
|
|
}
|
|
}
|
|
|
|
func TestGitTokenStorePersistConfigDropsUnrelatedStagedDeletions(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
remoteDir := setupGitRemoteRepository(t, root, "master",
|
|
testBranchSpec{name: "master", contents: "remote master branch\n"},
|
|
)
|
|
store := NewGitTokenStore(remoteDir, "", "", "")
|
|
baseDir := filepath.Join(root, "workspace", "auths")
|
|
store.SetBaseDir(baseDir)
|
|
if err := store.EnsureRepository(); err != nil {
|
|
t.Fatalf("EnsureRepository: %v", err)
|
|
}
|
|
|
|
auth := &cliproxyauth.Auth{
|
|
ID: "protected.json",
|
|
FileName: "protected.json",
|
|
Provider: "codex",
|
|
Metadata: map[string]any{"type": "codex", "access_token": "token"},
|
|
}
|
|
authPath, err := store.Save(context.Background(), auth)
|
|
if err != nil {
|
|
t.Fatalf("Save: %v", err)
|
|
}
|
|
configPath := store.ConfigPath()
|
|
if err := os.WriteFile(configPath, []byte("version: one\n"), 0o600); err != nil {
|
|
t.Fatalf("write initial config: %v", err)
|
|
}
|
|
if err := store.PersistConfig(context.Background()); err != nil {
|
|
t.Fatalf("PersistConfig initial: %v", err)
|
|
}
|
|
|
|
repo, err := git.PlainOpen(filepath.Join(root, "workspace"))
|
|
if err != nil {
|
|
t.Fatalf("open workspace repo: %v", err)
|
|
}
|
|
repoClosed := false
|
|
defer func() {
|
|
if !repoClosed {
|
|
if errClose := repo.Close(); errClose != nil {
|
|
t.Errorf("close workspace repo after test failure: %v", errClose)
|
|
}
|
|
}
|
|
}()
|
|
worktree, err := repo.Worktree()
|
|
if err != nil {
|
|
t.Fatalf("open workspace worktree: %v", err)
|
|
}
|
|
if _, err := worktree.Remove("auths/protected.json"); err != nil {
|
|
t.Fatalf("stage unexpected auth removal: %v", err)
|
|
}
|
|
errClose := repo.Close()
|
|
repoClosed = true
|
|
if errClose != nil {
|
|
t.Fatalf("close workspace repo after staged removal: %v", errClose)
|
|
}
|
|
if _, err := os.Stat(authPath); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("removed auth stat error = %v, want not exist", err)
|
|
}
|
|
if err := os.WriteFile(configPath, []byte("version: two\n"), 0o600); err != nil {
|
|
t.Fatalf("write updated config: %v", err)
|
|
}
|
|
|
|
if err := store.PersistConfig(context.Background()); err != nil {
|
|
t.Fatalf("PersistConfig with corrupt index: %v", err)
|
|
}
|
|
assertRemoteTreePath(t, remoteDir, "master", "auths/protected.json", true)
|
|
assertRemoteFileContents(t, remoteDir, "master", "config/config.yaml", "version: two\n")
|
|
}
|
|
|
|
func TestGitTokenStorePersistConfigRepairsIndexAfterUnstagedPull(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
remoteDir := setupGitRemoteRepository(t, root, "master",
|
|
testBranchSpec{name: "master", contents: "remote master branch\n"},
|
|
)
|
|
store := NewGitTokenStore(remoteDir, "", "", "")
|
|
store.SetBaseDir(filepath.Join(root, "workspace", "auths"))
|
|
if err := store.EnsureRepository(); err != nil {
|
|
t.Fatalf("EnsureRepository: %v", err)
|
|
}
|
|
configPath := store.ConfigPath()
|
|
if err := os.WriteFile(configPath, []byte("source: local-config\n"), 0o600); err != nil {
|
|
t.Fatalf("write local config: %v", err)
|
|
}
|
|
advanceRemoteBranch(t, filepath.Join(root, "seed"), remoteDir, "master", "remote branch advanced\n", "advance remote")
|
|
|
|
if err := store.PersistConfig(context.Background()); err != nil {
|
|
t.Fatalf("PersistConfig after unstaged pull: %v", err)
|
|
}
|
|
assertRemoteBranchContents(t, remoteDir, "master", "remote branch advanced\n")
|
|
assertRemoteFileContents(t, remoteDir, "master", "config/config.yaml", "source: local-config\n")
|
|
}
|
|
|
|
func TestGitTokenStorePersistConfigPreservesRemoteOnlyAuthAfterDivergence(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
remoteDir := setupGitRemoteRepository(t, root, "master",
|
|
testBranchSpec{name: "master", contents: "remote master branch\n"},
|
|
)
|
|
storeA := NewGitTokenStore(remoteDir, "", "", "")
|
|
storeA.SetBaseDir(filepath.Join(root, "workspace-a", "auths"))
|
|
if err := storeA.EnsureRepository(); err != nil {
|
|
t.Fatalf("EnsureRepository A: %v", err)
|
|
}
|
|
|
|
storeB := NewGitTokenStore(remoteDir, "", "", "")
|
|
storeB.SetBaseDir(filepath.Join(root, "workspace-b", "auths"))
|
|
if err := storeB.EnsureRepository(); err != nil {
|
|
t.Fatalf("EnsureRepository B: %v", err)
|
|
}
|
|
authB := &cliproxyauth.Auth{
|
|
ID: "remote-only.json",
|
|
FileName: "remote-only.json",
|
|
Provider: "codex",
|
|
Metadata: map[string]any{"type": "codex", "access_token": "remote"},
|
|
}
|
|
if _, err := storeB.Save(context.Background(), authB); err != nil {
|
|
t.Fatalf("Save B: %v", err)
|
|
}
|
|
assertRemoteTreePath(t, remoteDir, "master", "auths/remote-only.json", true)
|
|
|
|
configPathA := storeA.ConfigPath()
|
|
if err := os.WriteFile(configPathA, []byte("source: store-a\n"), 0o600); err != nil {
|
|
t.Fatalf("write config A: %v", err)
|
|
}
|
|
if err := storeA.PersistConfig(context.Background()); err != nil {
|
|
t.Fatalf("PersistConfig A after divergence: %v", err)
|
|
}
|
|
|
|
assertRemoteTreePath(t, remoteDir, "master", "auths/remote-only.json", true)
|
|
assertRemoteFileContents(t, remoteDir, "master", "config/config.yaml", "source: store-a\n")
|
|
}
|
|
|
|
func TestGitTokenStoreRejectsStaleForcePush(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := t.TempDir()
|
|
remoteDir := setupGitRemoteRepository(t, root, "master",
|
|
testBranchSpec{name: "master", contents: "remote master branch\n"},
|
|
)
|
|
storeA := NewGitTokenStore(remoteDir, "", "", "")
|
|
storeA.SetBaseDir(filepath.Join(root, "workspace-a", "auths"))
|
|
if err := storeA.EnsureRepository(); err != nil {
|
|
t.Fatalf("EnsureRepository A: %v", err)
|
|
}
|
|
storeB := NewGitTokenStore(remoteDir, "", "", "")
|
|
storeB.SetBaseDir(filepath.Join(root, "workspace-b", "auths"))
|
|
if err := storeB.EnsureRepository(); err != nil {
|
|
t.Fatalf("EnsureRepository B: %v", err)
|
|
}
|
|
|
|
authB := &cliproxyauth.Auth{
|
|
ID: "concurrent.json",
|
|
FileName: "concurrent.json",
|
|
Provider: "codex",
|
|
Metadata: map[string]any{"type": "codex", "access_token": "remote"},
|
|
}
|
|
if _, err := storeB.Save(context.Background(), authB); err != nil {
|
|
t.Fatalf("Save B: %v", err)
|
|
}
|
|
configPathA := storeA.ConfigPath()
|
|
if err := os.WriteFile(configPathA, []byte("source: stale-a\n"), 0o600); err != nil {
|
|
t.Fatalf("write stale config A: %v", err)
|
|
}
|
|
|
|
storeA.mu.Lock()
|
|
errPush := storeA.commitAndPushLocked("Update stale config", "config/config.yaml")
|
|
storeA.mu.Unlock()
|
|
if errPush == nil {
|
|
t.Fatal("stale force push error = nil, want lease rejection")
|
|
}
|
|
assertRemoteTreePath(t, remoteDir, "master", "auths/concurrent.json", true)
|
|
assertRemoteTreePath(t, remoteDir, "master", "config/config.yaml", false)
|
|
|
|
if err := storeA.PersistConfig(context.Background()); err != nil {
|
|
t.Fatalf("PersistConfig A after lease rejection: %v", err)
|
|
}
|
|
assertRemoteTreePath(t, remoteDir, "master", "auths/concurrent.json", true)
|
|
assertRemoteFileContents(t, remoteDir, "master", "config/config.yaml", "source: stale-a\n")
|
|
}
|
|
|
|
func TestGitTokenStoreSaveRetryAfterLeaseConflictCommitsMatchingContent(t *testing.T) {
|
|
root := t.TempDir()
|
|
remoteDir := setupGitRemoteRepository(t, root, "master",
|
|
testBranchSpec{name: "master", contents: "remote master branch\n"},
|
|
)
|
|
storeA := NewGitTokenStore(remoteDir, "", "", "")
|
|
storeA.SetBaseDir(filepath.Join(root, "workspace-a", "auths"))
|
|
if errEnsure := storeA.EnsureRepository(); errEnsure != nil {
|
|
t.Fatalf("EnsureRepository A: %v", errEnsure)
|
|
}
|
|
storeB := NewGitTokenStore(remoteDir, "", "", "")
|
|
storeB.SetBaseDir(filepath.Join(root, "workspace-b", "auths"))
|
|
if errEnsure := storeB.EnsureRepository(); errEnsure != nil {
|
|
t.Fatalf("EnsureRepository B: %v", errEnsure)
|
|
}
|
|
|
|
authA := &cliproxyauth.Auth{
|
|
ID: "local.json",
|
|
FileName: "local.json",
|
|
Provider: "codex",
|
|
Metadata: map[string]any{"type": "codex", "access_token": "local"},
|
|
}
|
|
remoteAdvanced := false
|
|
authA.Storage = &callbackTokenStorage{save: func(path string) error {
|
|
raw, errMarshal := json.Marshal(authA.Metadata)
|
|
if errMarshal != nil {
|
|
return errMarshal
|
|
}
|
|
if errWrite := os.WriteFile(path, raw, 0o600); errWrite != nil {
|
|
return errWrite
|
|
}
|
|
if remoteAdvanced {
|
|
return nil
|
|
}
|
|
remoteAdvanced = true
|
|
_, errSave := storeB.Save(context.Background(), &cliproxyauth.Auth{
|
|
ID: "concurrent.json",
|
|
FileName: "concurrent.json",
|
|
Provider: "codex",
|
|
Metadata: map[string]any{"type": "codex", "access_token": "remote"},
|
|
})
|
|
return errSave
|
|
}}
|
|
if _, errSave := storeA.Save(context.Background(), authA); errSave == nil {
|
|
t.Fatal("first Save error = nil, want lease rejection")
|
|
}
|
|
assertRemoteTreePath(t, remoteDir, "master", "auths/local.json", false)
|
|
assertRemoteTreePath(t, remoteDir, "master", "auths/concurrent.json", true)
|
|
|
|
authA.Storage = nil
|
|
if _, errSave := storeA.Save(context.Background(), authA); errSave != nil {
|
|
t.Fatalf("second Save after lease rejection: %v", errSave)
|
|
}
|
|
assertRemoteFileContents(t, remoteDir, "master", "auths/local.json", `{"access_token":"local","disabled":false,"type":"codex"}`)
|
|
assertRemoteTreePath(t, remoteDir, "master", "auths/concurrent.json", true)
|
|
}
|
|
|
|
func TestGitTokenStoreConcurrentInitializationDoesNotOverwriteCreatedBranch(t *testing.T) {
|
|
root := t.TempDir()
|
|
remoteDir := filepath.Join(root, "remote.git")
|
|
remoteRepo, errInitRemote := git.PlainInit(remoteDir, true)
|
|
if errInitRemote != nil {
|
|
t.Fatalf("init bare remote: %v", errInitRemote)
|
|
}
|
|
remoteRepoClosed := false
|
|
defer func() {
|
|
if !remoteRepoClosed {
|
|
if errClose := remoteRepo.Close(); errClose != nil {
|
|
t.Errorf("close initialized remote repo after test failure: %v", errClose)
|
|
}
|
|
}
|
|
}()
|
|
if errHead := remoteRepo.Storer.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.NewBranchReferenceName("master"))); errHead != nil {
|
|
t.Fatalf("set remote HEAD: %v", errHead)
|
|
}
|
|
errCloseRemote := remoteRepo.Close()
|
|
remoteRepoClosed = true
|
|
if errCloseRemote != nil {
|
|
t.Fatalf("close initialized remote repo: %v", errCloseRemote)
|
|
}
|
|
|
|
workspaceDir := filepath.Join(root, "workspace")
|
|
localRepo, errInitLocal := git.PlainInit(workspaceDir, false)
|
|
if errInitLocal != nil {
|
|
t.Fatalf("init local repository: %v", errInitLocal)
|
|
}
|
|
localRepoClosed := false
|
|
defer func() {
|
|
if !localRepoClosed {
|
|
if errClose := localRepo.Close(); errClose != nil {
|
|
t.Errorf("close initialized local repo after test failure: %v", errClose)
|
|
}
|
|
}
|
|
}()
|
|
if errSigning := disableGitCommitSigning(workspaceDir); errSigning != nil {
|
|
t.Fatalf("disable local commit signing: %v", errSigning)
|
|
}
|
|
if _, errRemote := localRepo.CreateRemote(&gitconfig.RemoteConfig{Name: "origin", URLs: []string{remoteDir}}); errRemote != nil {
|
|
t.Fatalf("create local origin: %v", errRemote)
|
|
}
|
|
for _, path := range []string{"auths/.gitkeep", "config/.gitkeep"} {
|
|
fullPath := filepath.Join(workspaceDir, filepath.FromSlash(path))
|
|
if errMkdir := os.MkdirAll(filepath.Dir(fullPath), 0o700); errMkdir != nil {
|
|
t.Fatalf("create local placeholder parent: %v", errMkdir)
|
|
}
|
|
if errWrite := os.WriteFile(fullPath, nil, 0o600); errWrite != nil {
|
|
t.Fatalf("write local placeholder: %v", errWrite)
|
|
}
|
|
}
|
|
errCloseLocal := localRepo.Close()
|
|
localRepoClosed = true
|
|
if errCloseLocal != nil {
|
|
t.Fatalf("close initialized local repo: %v", errCloseLocal)
|
|
}
|
|
|
|
winnerDir := filepath.Join(root, "winner")
|
|
winnerRepo, errInitWinner := git.PlainInit(winnerDir, false)
|
|
if errInitWinner != nil {
|
|
t.Fatalf("init winning repository: %v", errInitWinner)
|
|
}
|
|
winnerRepoClosed := false
|
|
defer func() {
|
|
if !winnerRepoClosed {
|
|
if errClose := winnerRepo.Close(); errClose != nil {
|
|
t.Errorf("close winning repository after test failure: %v", errClose)
|
|
}
|
|
}
|
|
}()
|
|
if errSigning := disableGitCommitSigning(winnerDir); errSigning != nil {
|
|
t.Fatalf("disable winner commit signing: %v", errSigning)
|
|
}
|
|
if errHead := winnerRepo.Storer.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.NewBranchReferenceName("master"))); errHead != nil {
|
|
t.Fatalf("set winner HEAD: %v", errHead)
|
|
}
|
|
winnerFiles := map[string]string{
|
|
"auths/remote.json": `{"type":"codex","access_token":"remote"}`,
|
|
"config/config.yaml": "source: winner\n",
|
|
}
|
|
winnerWorktree, errWinnerWorktree := winnerRepo.Worktree()
|
|
if errWinnerWorktree != nil {
|
|
t.Fatalf("open winning worktree: %v", errWinnerWorktree)
|
|
}
|
|
for path, contents := range winnerFiles {
|
|
fullPath := filepath.Join(winnerDir, filepath.FromSlash(path))
|
|
if errMkdir := os.MkdirAll(filepath.Dir(fullPath), 0o700); errMkdir != nil {
|
|
t.Fatalf("create winning file parent: %v", errMkdir)
|
|
}
|
|
if errWrite := os.WriteFile(fullPath, []byte(contents), 0o600); errWrite != nil {
|
|
t.Fatalf("write winning file: %v", errWrite)
|
|
}
|
|
if _, errAdd := winnerWorktree.Add(path); errAdd != nil {
|
|
t.Fatalf("add winning file: %v", errAdd)
|
|
}
|
|
}
|
|
if _, errCommit := winnerWorktree.Commit("Initialize complete store", &git.CommitOptions{Author: &object.Signature{
|
|
Name: "CLIProxyAPI", Email: "cliproxy@local", When: time.Unix(1711929600, 0),
|
|
}}); errCommit != nil {
|
|
t.Fatalf("commit winning repository: %v", errCommit)
|
|
}
|
|
if _, errRemote := winnerRepo.CreateRemote(&gitconfig.RemoteConfig{Name: "origin", URLs: []string{remoteDir}}); errRemote != nil {
|
|
t.Fatalf("create winner origin: %v", errRemote)
|
|
}
|
|
if errPush := winnerRepo.Push(&git.PushOptions{RemoteName: "origin", RefSpecs: []gitconfig.RefSpec{"refs/heads/master:refs/heads/master"}}); errPush != nil {
|
|
t.Fatalf("push winning initialization: %v", errPush)
|
|
}
|
|
errCloseWinner := winnerRepo.Close()
|
|
winnerRepoClosed = true
|
|
if errCloseWinner != nil {
|
|
t.Fatalf("close winning repository: %v", errCloseWinner)
|
|
}
|
|
|
|
store := NewGitTokenStore(remoteDir, "", "", "master")
|
|
store.SetBaseDir(filepath.Join(workspaceDir, "auths"))
|
|
store.mu.Lock()
|
|
errInitialize := store.commitAndPushInitialLocked("Initialize git token store", "auths/.gitkeep", "config/.gitkeep")
|
|
store.mu.Unlock()
|
|
if errInitialize == nil {
|
|
t.Fatal("late initialization push error = nil, want branch-creation rejection")
|
|
}
|
|
assertRemoteFileContents(t, remoteDir, "master", "auths/remote.json", winnerFiles["auths/remote.json"])
|
|
assertRemoteFileContents(t, remoteDir, "master", "config/config.yaml", winnerFiles["config/config.yaml"])
|
|
|
|
if errEnsure := store.EnsureRepository(); errEnsure != nil {
|
|
t.Fatalf("EnsureRepository after initialization race: %v", errEnsure)
|
|
}
|
|
assertLocalFileContents(t, filepath.Join(workspaceDir, "auths", "remote.json"), winnerFiles["auths/remote.json"])
|
|
assertLocalFileContents(t, filepath.Join(workspaceDir, "config", "config.yaml"), winnerFiles["config/config.yaml"])
|
|
}
|
|
|
|
func TestEnsureRepositoryRetryRestoresTrackedAuthOnUpToDatePull(t *testing.T) {
|
|
root := t.TempDir()
|
|
remoteDir := setupGitRemoteRepository(t, root, "master",
|
|
testBranchSpec{name: "master", contents: "remote master branch\n"},
|
|
)
|
|
store := NewGitTokenStore(remoteDir, "", "", "")
|
|
baseDir := filepath.Join(root, "workspace", "auths")
|
|
store.SetBaseDir(baseDir)
|
|
if errEnsure := store.EnsureRepository(); errEnsure != nil {
|
|
t.Fatalf("EnsureRepository: %v", errEnsure)
|
|
}
|
|
authPath, errSave := store.Save(context.Background(), &cliproxyauth.Auth{
|
|
ID: "retry.json",
|
|
FileName: "retry.json",
|
|
Provider: "codex",
|
|
Metadata: map[string]any{"type": "codex", "access_token": "remote"},
|
|
})
|
|
if errSave != nil {
|
|
t.Fatalf("Save: %v", errSave)
|
|
}
|
|
|
|
repo, errOpen := git.PlainOpen(filepath.Join(root, "workspace"))
|
|
if errOpen != nil {
|
|
t.Fatalf("open workspace repository: %v", errOpen)
|
|
}
|
|
repoClosed := false
|
|
defer func() {
|
|
if !repoClosed {
|
|
if errClose := repo.Close(); errClose != nil {
|
|
t.Errorf("close workspace repository after test failure: %v", errClose)
|
|
}
|
|
}
|
|
}()
|
|
worktree, errWorktree := repo.Worktree()
|
|
if errWorktree != nil {
|
|
t.Fatalf("open workspace worktree: %v", errWorktree)
|
|
}
|
|
if _, errRemove := worktree.Remove("auths/retry.json"); errRemove != nil {
|
|
t.Fatalf("stage missing auth: %v", errRemove)
|
|
}
|
|
cfg, errConfig := repo.Config()
|
|
if errConfig != nil {
|
|
t.Fatalf("read workspace config: %v", errConfig)
|
|
}
|
|
cfg.Remotes["origin"].URLs = []string{filepath.Join(root, "missing.git")}
|
|
if errSetConfig := repo.SetConfig(cfg); errSetConfig != nil {
|
|
t.Fatalf("break workspace origin: %v", errSetConfig)
|
|
}
|
|
errClose := repo.Close()
|
|
repoClosed = true
|
|
if errClose != nil {
|
|
t.Fatalf("close workspace repository before unavailable remote check: %v", errClose)
|
|
}
|
|
if errEnsure := store.EnsureRepository(); errEnsure == nil {
|
|
t.Fatal("EnsureRepository with unavailable remote error = nil, want retryable failure")
|
|
}
|
|
if _, errStat := os.Stat(authPath); !errors.Is(errStat, os.ErrNotExist) {
|
|
t.Fatalf("missing auth stat error = %v, want not exist", errStat)
|
|
}
|
|
|
|
repoRestore, errOpen := git.PlainOpen(filepath.Join(root, "workspace"))
|
|
if errOpen != nil {
|
|
t.Fatalf("reopen workspace repository: %v", errOpen)
|
|
}
|
|
repoRestoreClosed := false
|
|
defer func() {
|
|
if !repoRestoreClosed {
|
|
if errClose := repoRestore.Close(); errClose != nil {
|
|
t.Errorf("close restored workspace repository after test failure: %v", errClose)
|
|
}
|
|
}
|
|
}()
|
|
cfg.Remotes["origin"].URLs = []string{remoteDir}
|
|
if errSetConfig := repoRestore.SetConfig(cfg); errSetConfig != nil {
|
|
t.Fatalf("restore workspace origin: %v", errSetConfig)
|
|
}
|
|
errCloseRestore := repoRestore.Close()
|
|
repoRestoreClosed = true
|
|
if errCloseRestore != nil {
|
|
t.Fatalf("close workspace repository before retry: %v", errCloseRestore)
|
|
}
|
|
if errEnsure := store.EnsureRepository(); errEnsure != nil {
|
|
t.Fatalf("EnsureRepository retry: %v", errEnsure)
|
|
}
|
|
assertLocalFileContents(t, authPath, `{"access_token":"remote","disabled":false,"type":"codex"}`)
|
|
auths, errList := store.List(context.Background())
|
|
if errList != nil {
|
|
t.Fatalf("List after retry: %v", errList)
|
|
}
|
|
if len(auths) != 1 || auths[0].ID != "retry.json" {
|
|
t.Fatalf("List after retry = %#v, want retry.json", auths)
|
|
}
|
|
|
|
if errDelete := store.Delete(context.Background(), authPath); errDelete != nil {
|
|
t.Fatalf("explicit Delete after retry: %v", errDelete)
|
|
}
|
|
assertRemoteTreePath(t, remoteDir, "master", "auths/retry.json", false)
|
|
auths, errList = store.List(context.Background())
|
|
if errList != nil {
|
|
t.Fatalf("List after explicit Delete: %v", errList)
|
|
}
|
|
if len(auths) != 0 {
|
|
t.Fatalf("List after explicit Delete = %#v, want empty", auths)
|
|
}
|
|
}
|
|
|
|
func TestEnsureRepositoryReconcilesRemoteAuthChangesAroundLocalConfig(t *testing.T) {
|
|
root := t.TempDir()
|
|
remoteDir := setupGitRemoteRepository(t, root, "master",
|
|
testBranchSpec{name: "master", contents: "remote master branch\n"},
|
|
)
|
|
owner := NewGitTokenStore(remoteDir, "", "", "")
|
|
owner.SetBaseDir(filepath.Join(root, "owner", "auths"))
|
|
if errEnsure := owner.EnsureRepository(); errEnsure != nil {
|
|
t.Fatalf("EnsureRepository owner: %v", errEnsure)
|
|
}
|
|
for _, id := range []string{"modified.json", "deleted.json"} {
|
|
if _, errSave := owner.Save(context.Background(), &cliproxyauth.Auth{
|
|
ID: id, FileName: id, Provider: "codex",
|
|
Metadata: map[string]any{"type": "codex", "access_token": "old"},
|
|
}); errSave != nil {
|
|
t.Fatalf("Save owner %s: %v", id, errSave)
|
|
}
|
|
}
|
|
if errWrite := os.WriteFile(owner.ConfigPath(), []byte("source: original\n"), 0o600); errWrite != nil {
|
|
t.Fatalf("write owner config: %v", errWrite)
|
|
}
|
|
if errPersist := owner.PersistConfig(context.Background()); errPersist != nil {
|
|
t.Fatalf("PersistConfig owner: %v", errPersist)
|
|
}
|
|
|
|
storeA := NewGitTokenStore(remoteDir, "", "", "")
|
|
storeA.SetBaseDir(filepath.Join(root, "workspace-a", "auths"))
|
|
if errEnsure := storeA.EnsureRepository(); errEnsure != nil {
|
|
t.Fatalf("EnsureRepository A: %v", errEnsure)
|
|
}
|
|
storeB := NewGitTokenStore(remoteDir, "", "", "")
|
|
storeB.SetBaseDir(filepath.Join(root, "workspace-b", "auths"))
|
|
if errEnsure := storeB.EnsureRepository(); errEnsure != nil {
|
|
t.Fatalf("EnsureRepository B: %v", errEnsure)
|
|
}
|
|
if errWrite := os.WriteFile(storeA.ConfigPath(), []byte("source: local-a\n"), 0o600); errWrite != nil {
|
|
t.Fatalf("write local config A: %v", errWrite)
|
|
}
|
|
if _, errSave := storeB.Save(context.Background(), &cliproxyauth.Auth{
|
|
ID: "modified.json", FileName: "modified.json", Provider: "codex",
|
|
Metadata: map[string]any{"type": "codex", "access_token": "new"},
|
|
}); errSave != nil {
|
|
t.Fatalf("Save remote auth update: %v", errSave)
|
|
}
|
|
if errDelete := storeB.Delete(context.Background(), filepath.Join(storeB.AuthDir(), "deleted.json")); errDelete != nil {
|
|
t.Fatalf("Delete remote auth: %v", errDelete)
|
|
}
|
|
|
|
if errEnsure := storeA.EnsureRepository(); errEnsure != nil {
|
|
t.Fatalf("EnsureRepository A after remote auth changes: %v", errEnsure)
|
|
}
|
|
assertLocalFileContents(t, storeA.ConfigPath(), "source: local-a\n")
|
|
assertLocalJSONValue(t, filepath.Join(storeA.AuthDir(), "modified.json"), "access_token", "new")
|
|
if _, errStat := os.Stat(filepath.Join(storeA.AuthDir(), "deleted.json")); !errors.Is(errStat, os.ErrNotExist) {
|
|
t.Fatalf("deleted local auth stat error = %v, want not exist", errStat)
|
|
}
|
|
}
|
|
|
|
func TestEnsureRepositoryReconcilesRemoteConfigChangesAroundLocalAuth(t *testing.T) {
|
|
root := t.TempDir()
|
|
remoteDir := setupGitRemoteRepository(t, root, "master",
|
|
testBranchSpec{name: "master", contents: "remote master branch\n"},
|
|
)
|
|
owner := NewGitTokenStore(remoteDir, "", "", "")
|
|
owner.SetBaseDir(filepath.Join(root, "owner", "auths"))
|
|
if errEnsure := owner.EnsureRepository(); errEnsure != nil {
|
|
t.Fatalf("EnsureRepository owner: %v", errEnsure)
|
|
}
|
|
if _, errSave := owner.Save(context.Background(), &cliproxyauth.Auth{
|
|
ID: "local.json", FileName: "local.json", Provider: "codex",
|
|
Metadata: map[string]any{"type": "codex", "access_token": "old"},
|
|
}); errSave != nil {
|
|
t.Fatalf("Save owner auth: %v", errSave)
|
|
}
|
|
if errWrite := os.WriteFile(owner.ConfigPath(), []byte("source: original\n"), 0o600); errWrite != nil {
|
|
t.Fatalf("write owner config: %v", errWrite)
|
|
}
|
|
if errPersist := owner.PersistConfig(context.Background()); errPersist != nil {
|
|
t.Fatalf("PersistConfig owner: %v", errPersist)
|
|
}
|
|
|
|
storeA := NewGitTokenStore(remoteDir, "", "", "")
|
|
storeA.SetBaseDir(filepath.Join(root, "workspace-a", "auths"))
|
|
if errEnsure := storeA.EnsureRepository(); errEnsure != nil {
|
|
t.Fatalf("EnsureRepository A: %v", errEnsure)
|
|
}
|
|
storeB := NewGitTokenStore(remoteDir, "", "", "")
|
|
storeB.SetBaseDir(filepath.Join(root, "workspace-b", "auths"))
|
|
if errEnsure := storeB.EnsureRepository(); errEnsure != nil {
|
|
t.Fatalf("EnsureRepository B: %v", errEnsure)
|
|
}
|
|
localAuthPath := filepath.Join(storeA.AuthDir(), "local.json")
|
|
localAuthContents := `{"type":"codex","access_token":"local-dirty"}`
|
|
if errWrite := os.WriteFile(localAuthPath, []byte(localAuthContents), 0o600); errWrite != nil {
|
|
t.Fatalf("write local dirty auth: %v", errWrite)
|
|
}
|
|
if errWrite := os.WriteFile(storeB.ConfigPath(), []byte("source: remote-modified\n"), 0o600); errWrite != nil {
|
|
t.Fatalf("write remote config update: %v", errWrite)
|
|
}
|
|
if errPersist := storeB.PersistConfig(context.Background()); errPersist != nil {
|
|
t.Fatalf("PersistConfig B: %v", errPersist)
|
|
}
|
|
|
|
if errEnsure := storeA.EnsureRepository(); errEnsure != nil {
|
|
t.Fatalf("EnsureRepository A after remote config update: %v", errEnsure)
|
|
}
|
|
assertLocalFileContents(t, storeA.ConfigPath(), "source: remote-modified\n")
|
|
assertLocalFileContents(t, localAuthPath, localAuthContents)
|
|
|
|
if errRemove := os.Remove(storeB.ConfigPath()); errRemove != nil {
|
|
t.Fatalf("remove config B: %v", errRemove)
|
|
}
|
|
storeB.mu.Lock()
|
|
errDeleteConfig := storeB.commitAndPushLocked("Delete config", "config/config.yaml")
|
|
storeB.mu.Unlock()
|
|
if errDeleteConfig != nil {
|
|
t.Fatalf("commit remote config deletion: %v", errDeleteConfig)
|
|
}
|
|
if errEnsure := storeA.EnsureRepository(); errEnsure != nil {
|
|
t.Fatalf("EnsureRepository A after remote config deletion: %v", errEnsure)
|
|
}
|
|
if _, errStat := os.Stat(storeA.ConfigPath()); !errors.Is(errStat, os.ErrNotExist) {
|
|
t.Fatalf("deleted local config stat error = %v, want not exist", errStat)
|
|
}
|
|
assertLocalFileContents(t, localAuthPath, localAuthContents)
|
|
}
|
|
|
|
func TestEnsureRepositoryFailsClosedOnSamePathConflict(t *testing.T) {
|
|
root := t.TempDir()
|
|
remoteDir := setupGitRemoteRepository(t, root, "master",
|
|
testBranchSpec{name: "master", contents: "remote master branch\n"},
|
|
)
|
|
owner := NewGitTokenStore(remoteDir, "", "", "")
|
|
owner.SetBaseDir(filepath.Join(root, "owner", "auths"))
|
|
if errEnsure := owner.EnsureRepository(); errEnsure != nil {
|
|
t.Fatalf("EnsureRepository owner: %v", errEnsure)
|
|
}
|
|
if errWrite := os.WriteFile(owner.ConfigPath(), []byte("source: original\n"), 0o600); errWrite != nil {
|
|
t.Fatalf("write owner config: %v", errWrite)
|
|
}
|
|
if errPersist := owner.PersistConfig(context.Background()); errPersist != nil {
|
|
t.Fatalf("PersistConfig owner: %v", errPersist)
|
|
}
|
|
|
|
storeA := NewGitTokenStore(remoteDir, "", "", "")
|
|
storeA.SetBaseDir(filepath.Join(root, "workspace-a", "auths"))
|
|
if errEnsure := storeA.EnsureRepository(); errEnsure != nil {
|
|
t.Fatalf("EnsureRepository A: %v", errEnsure)
|
|
}
|
|
storeB := NewGitTokenStore(remoteDir, "", "", "")
|
|
storeB.SetBaseDir(filepath.Join(root, "workspace-b", "auths"))
|
|
if errEnsure := storeB.EnsureRepository(); errEnsure != nil {
|
|
t.Fatalf("EnsureRepository B: %v", errEnsure)
|
|
}
|
|
if errWrite := os.WriteFile(storeA.ConfigPath(), []byte("source: local\n"), 0o600); errWrite != nil {
|
|
t.Fatalf("write local config: %v", errWrite)
|
|
}
|
|
if errWrite := os.WriteFile(storeB.ConfigPath(), []byte("source: remote\n"), 0o600); errWrite != nil {
|
|
t.Fatalf("write remote config: %v", errWrite)
|
|
}
|
|
if errPersist := storeB.PersistConfig(context.Background()); errPersist != nil {
|
|
t.Fatalf("PersistConfig B: %v", errPersist)
|
|
}
|
|
|
|
errEnsure := storeA.EnsureRepository()
|
|
if errEnsure == nil || !strings.Contains(errEnsure.Error(), "conflicts with local change") {
|
|
t.Fatalf("EnsureRepository conflict error = %v, want fail-closed conflict", errEnsure)
|
|
}
|
|
assertLocalFileContents(t, storeA.ConfigPath(), "source: local\n")
|
|
assertRemoteFileContents(t, remoteDir, "master", "config/config.yaml", "source: remote\n")
|
|
}
|
|
|
|
func TestInstallRecoveredGitDirectoryRetainsBackupWhenRestoreFails(t *testing.T) {
|
|
backupPath := filepath.Join("recovery", "corrupt.git")
|
|
installErr := errors.New("install failed")
|
|
restoreErr := errors.New("restore failed")
|
|
calls := 0
|
|
rename := func(_, _ string) error {
|
|
calls++
|
|
switch calls {
|
|
case 1:
|
|
return nil
|
|
case 2:
|
|
return installErr
|
|
default:
|
|
return restoreErr
|
|
}
|
|
}
|
|
|
|
retain, errInstall := installRecoveredGitDirectory("repo/.git", "clone/.git", backupPath, rename)
|
|
if !retain {
|
|
t.Fatal("retain recovery = false, want true after failed rollback")
|
|
}
|
|
if !errors.Is(errInstall, installErr) || !errors.Is(errInstall, restoreErr) {
|
|
t.Fatalf("install error = %v, want install and restore failures", errInstall)
|
|
}
|
|
if !strings.Contains(errInstall.Error(), backupPath) {
|
|
t.Fatalf("install error = %q, want retained backup path %q", errInstall, backupPath)
|
|
}
|
|
}
|
|
|
|
func TestRecoverRepositoryCloseFailuresAbortBeforeMutation(t *testing.T) {
|
|
root := t.TempDir()
|
|
remoteDir := setupGitRemoteRepository(t, root, "master",
|
|
testBranchSpec{name: "master", contents: "remote master branch\n"},
|
|
)
|
|
store := NewGitTokenStore(remoteDir, "", "", "")
|
|
store.SetBaseDir(filepath.Join(root, "workspace", "auths"))
|
|
if errEnsure := store.EnsureRepository(); errEnsure != nil {
|
|
t.Fatalf("EnsureRepository: %v", errEnsure)
|
|
}
|
|
|
|
repoDir := filepath.Join(root, "workspace")
|
|
baselineRepo, errOpen := git.PlainOpen(repoDir)
|
|
if errOpen != nil {
|
|
t.Fatalf("open baseline repository: %v", errOpen)
|
|
}
|
|
baselineRepoClosed := false
|
|
defer func() {
|
|
if !baselineRepoClosed {
|
|
if errClose := baselineRepo.Close(); errClose != nil {
|
|
t.Errorf("close baseline repository after test failure: %v", errClose)
|
|
}
|
|
}
|
|
}()
|
|
head, errHead := baselineRepo.Head()
|
|
if errHead != nil {
|
|
t.Fatalf("read baseline head: %v", errHead)
|
|
}
|
|
commit, errCommit := baselineRepo.CommitObject(head.Hash())
|
|
if errCommit != nil {
|
|
t.Fatalf("read baseline commit: %v", errCommit)
|
|
}
|
|
baselineTree, errTree := commit.Tree()
|
|
if errTree != nil {
|
|
t.Fatalf("read baseline tree: %v", errTree)
|
|
}
|
|
gitMarkerPath := filepath.Join(repoDir, ".git", "recovery-close-marker")
|
|
if errWrite := os.WriteFile(gitMarkerPath, []byte("original git\n"), 0o600); errWrite != nil {
|
|
t.Fatalf("write repository marker: %v", errWrite)
|
|
}
|
|
worktreeMarkerPath := filepath.Join(repoDir, "recovery-close-marker.txt")
|
|
if errWrite := os.WriteFile(worktreeMarkerPath, []byte("original worktree\n"), 0o600); errWrite != nil {
|
|
t.Fatalf("write worktree marker: %v", errWrite)
|
|
}
|
|
|
|
errBaselineClose := errors.New("close baseline failed")
|
|
errClonedClose := errors.New("close clone failed")
|
|
closeCalls := 0
|
|
errRecover := store.recoverRepositoryLocked(repoDir, nil, baselineRepo, baselineTree, nil, func(repo *git.Repository) error {
|
|
closeCalls++
|
|
errClose := repo.Close()
|
|
if repo == baselineRepo {
|
|
baselineRepoClosed = true
|
|
}
|
|
if errClose != nil {
|
|
return errClose
|
|
}
|
|
if repo == baselineRepo {
|
|
return errBaselineClose
|
|
}
|
|
return errClonedClose
|
|
}, os.Rename)
|
|
if !errors.Is(errRecover, errBaselineClose) || !errors.Is(errRecover, errClonedClose) {
|
|
t.Fatalf("recoverRepositoryLocked() error = %v, want both close failures", errRecover)
|
|
}
|
|
if closeCalls != 2 {
|
|
t.Fatalf("repository close calls = %d, want 2", closeCalls)
|
|
}
|
|
assertLocalFileContents(t, gitMarkerPath, "original git\n")
|
|
assertLocalFileContents(t, worktreeMarkerPath, "original worktree\n")
|
|
recoveryDirs, errGlob := filepath.Glob(filepath.Join(root, ".gitstore-recovery-*"))
|
|
if errGlob != nil {
|
|
t.Fatalf("glob recovery directories: %v", errGlob)
|
|
}
|
|
if len(recoveryDirs) != 0 {
|
|
t.Fatalf("recovery directories = %v, want none", recoveryDirs)
|
|
}
|
|
}
|
|
|
|
func TestRecoverRepositoryWorktreeBackupRollbackFailureRetainsRecoveryDirectory(t *testing.T) {
|
|
root := t.TempDir()
|
|
remoteDir := setupGitRemoteRepository(t, root, "master",
|
|
testBranchSpec{name: "master", contents: "remote master branch\n"},
|
|
)
|
|
store := NewGitTokenStore(remoteDir, "", "", "")
|
|
store.SetBaseDir(filepath.Join(root, "workspace", "auths"))
|
|
if errEnsure := store.EnsureRepository(); errEnsure != nil {
|
|
t.Fatalf("EnsureRepository: %v", errEnsure)
|
|
}
|
|
|
|
repoDir := filepath.Join(root, "workspace")
|
|
if errWrite := os.WriteFile(filepath.Join(repoDir, "a.txt"), []byte("alpha\n"), 0o600); errWrite != nil {
|
|
t.Fatalf("write file a: %v", errWrite)
|
|
}
|
|
if errWrite := os.WriteFile(filepath.Join(repoDir, "z.txt"), []byte("zeta\n"), 0o600); errWrite != nil {
|
|
t.Fatalf("write file z: %v", errWrite)
|
|
}
|
|
|
|
errMoveZ := errors.New("move z failed")
|
|
errRestoreA := errors.New("restore a failed")
|
|
errRecover := store.recoverRepositoryLocked(repoDir, nil, nil, nil, nil, (*git.Repository).Close, func(source, target string) error {
|
|
if strings.HasSuffix(source, "z.txt") && strings.Contains(target, ".gitstore-recovery-") {
|
|
return errMoveZ
|
|
}
|
|
if strings.HasSuffix(source, "a.txt") && strings.Contains(source, ".gitstore-recovery-") {
|
|
return errRestoreA
|
|
}
|
|
return os.Rename(source, target)
|
|
})
|
|
|
|
if !errors.Is(errRecover, errMoveZ) || !errors.Is(errRecover, errRestoreA) {
|
|
t.Fatalf("recoverRepositoryLocked error = %v, want both move and restore errors", errRecover)
|
|
}
|
|
if !strings.Contains(errRecover.Error(), "backup retained at") {
|
|
t.Fatalf("recoverRepositoryLocked error = %q, want backup retention notice", errRecover)
|
|
}
|
|
recoveryDirs, errGlob := filepath.Glob(filepath.Join(root, ".gitstore-recovery-*"))
|
|
if errGlob != nil {
|
|
t.Fatalf("glob recovery directories: %v", errGlob)
|
|
}
|
|
if len(recoveryDirs) != 1 {
|
|
t.Fatalf("recovery directories count = %d, want 1 retained", len(recoveryDirs))
|
|
}
|
|
}
|
|
|
|
func TestRecoverRepositoryRecoveredCloseFailureTriggersRollback(t *testing.T) {
|
|
root := t.TempDir()
|
|
remoteDir := setupGitRemoteRepository(t, root, "master",
|
|
testBranchSpec{name: "master", contents: "remote master branch\n"},
|
|
)
|
|
store := NewGitTokenStore(remoteDir, "", "", "")
|
|
store.SetBaseDir(filepath.Join(root, "workspace", "auths"))
|
|
if errEnsure := store.EnsureRepository(); errEnsure != nil {
|
|
t.Fatalf("EnsureRepository: %v", errEnsure)
|
|
}
|
|
|
|
repoDir := filepath.Join(root, "workspace")
|
|
originalMarkerPath := filepath.Join(repoDir, ".git", "pre-recovery-marker")
|
|
if errWrite := os.WriteFile(originalMarkerPath, []byte("pre-recovery git\n"), 0o600); errWrite != nil {
|
|
t.Fatalf("write pre-recovery marker: %v", errWrite)
|
|
}
|
|
|
|
errRecoveredClose := errors.New("close recovered repo failed")
|
|
closeCallCount := 0
|
|
errRecover := store.recoverRepositoryLocked(repoDir, nil, nil, nil, nil, func(repo *git.Repository) error {
|
|
closeCallCount++
|
|
if err := repo.Close(); err != nil {
|
|
return err
|
|
}
|
|
if closeCallCount == 3 {
|
|
return errRecoveredClose
|
|
}
|
|
return nil
|
|
}, os.Rename)
|
|
if !errors.Is(errRecover, errRecoveredClose) {
|
|
t.Fatalf("recoverRepositoryLocked() error = %v, want recovered close failure", errRecover)
|
|
}
|
|
assertLocalFileContents(t, originalMarkerPath, "pre-recovery git\n")
|
|
recoveryDirs, errGlob := filepath.Glob(filepath.Join(root, ".gitstore-recovery-*"))
|
|
if errGlob != nil {
|
|
t.Fatalf("glob recovery directories: %v", errGlob)
|
|
}
|
|
if len(recoveryDirs) != 0 {
|
|
t.Fatalf("recovery directories = %v, want none", recoveryDirs)
|
|
}
|
|
}
|
|
|
|
func TestGitTokenStoreCorruptionRecoveryUsesLatestRemoteAuthTree(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
updateRemote func(*testing.T, *GitTokenStore)
|
|
wantExists bool
|
|
wantAuthToken string
|
|
}{
|
|
{
|
|
name: "modification",
|
|
updateRemote: func(t *testing.T, store *GitTokenStore) {
|
|
t.Helper()
|
|
if _, errSave := store.Save(context.Background(), &cliproxyauth.Auth{
|
|
ID: "victim.json", FileName: "victim.json", Provider: "codex",
|
|
Metadata: map[string]any{"type": "codex", "access_token": "remote-new"},
|
|
}); errSave != nil {
|
|
t.Fatalf("update remote auth: %v", errSave)
|
|
}
|
|
},
|
|
wantExists: true,
|
|
wantAuthToken: "remote-new",
|
|
},
|
|
{
|
|
name: "deletion",
|
|
updateRemote: func(t *testing.T, store *GitTokenStore) {
|
|
t.Helper()
|
|
if errDelete := store.Delete(context.Background(), filepath.Join(store.AuthDir(), "victim.json")); errDelete != nil {
|
|
t.Fatalf("delete remote auth: %v", errDelete)
|
|
}
|
|
},
|
|
wantExists: false,
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
root := t.TempDir()
|
|
remoteDir := setupGitRemoteRepository(t, root, "master",
|
|
testBranchSpec{name: "master", contents: "remote master branch\n"},
|
|
)
|
|
owner := NewGitTokenStore(remoteDir, "", "", "")
|
|
owner.SetBaseDir(filepath.Join(root, "owner", "auths"))
|
|
if errEnsure := owner.EnsureRepository(); errEnsure != nil {
|
|
t.Fatalf("EnsureRepository owner: %v", errEnsure)
|
|
}
|
|
if _, errSave := owner.Save(context.Background(), &cliproxyauth.Auth{
|
|
ID: "victim.json", FileName: "victim.json", Provider: "codex",
|
|
Metadata: map[string]any{"type": "codex", "access_token": "remote-old"},
|
|
}); errSave != nil {
|
|
t.Fatalf("save initial auth: %v", errSave)
|
|
}
|
|
|
|
store := NewGitTokenStore(remoteDir, "", "", "")
|
|
store.SetBaseDir(filepath.Join(root, "workspace", "auths"))
|
|
if errEnsure := store.EnsureRepository(); errEnsure != nil {
|
|
t.Fatalf("EnsureRepository workspace: %v", errEnsure)
|
|
}
|
|
test.updateRemote(t, owner)
|
|
removeHeadFileObject(t, filepath.Join(root, "workspace"), "corrupt-object.txt")
|
|
|
|
if errEnsure := store.EnsureRepository(); errEnsure != nil {
|
|
t.Fatalf("EnsureRepository recovery: %v", errEnsure)
|
|
}
|
|
victimPath := filepath.Join(store.AuthDir(), "victim.json")
|
|
if test.wantExists {
|
|
assertLocalJSONValue(t, victimPath, "access_token", test.wantAuthToken)
|
|
} else if _, errStat := os.Stat(victimPath); !errors.Is(errStat, os.ErrNotExist) {
|
|
t.Fatalf("deleted local auth stat error = %v, want not exist", errStat)
|
|
}
|
|
|
|
if _, errSave := store.Save(context.Background(), &cliproxyauth.Auth{
|
|
ID: "unrelated.json", FileName: "unrelated.json", Provider: "codex",
|
|
Metadata: map[string]any{"type": "codex", "access_token": "local"},
|
|
}); errSave != nil {
|
|
t.Fatalf("Save after recovery: %v", errSave)
|
|
}
|
|
assertRemoteTreePath(t, remoteDir, "master", "auths/victim.json", test.wantExists)
|
|
if test.wantExists {
|
|
assertRemoteFileContents(t, remoteDir, "master", "auths/victim.json", `{"access_token":"remote-new","disabled":false,"type":"codex"}`)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGitTokenStoreCorruptionRecoveryPreservesOnlyNonConflictingLocalChanges(t *testing.T) {
|
|
setup := func(t *testing.T) (string, *GitTokenStore, *GitTokenStore) {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
remoteDir := setupGitRemoteRepository(t, root, "master",
|
|
testBranchSpec{name: "master", contents: "remote master branch\n"},
|
|
)
|
|
owner := NewGitTokenStore(remoteDir, "", "", "")
|
|
owner.SetBaseDir(filepath.Join(root, "owner", "auths"))
|
|
if errEnsure := owner.EnsureRepository(); errEnsure != nil {
|
|
t.Fatalf("EnsureRepository owner: %v", errEnsure)
|
|
}
|
|
if _, errSave := owner.Save(context.Background(), &cliproxyauth.Auth{
|
|
ID: "victim.json", FileName: "victim.json", Provider: "codex",
|
|
Metadata: map[string]any{"type": "codex", "access_token": "remote-old"},
|
|
}); errSave != nil {
|
|
t.Fatalf("save initial auth: %v", errSave)
|
|
}
|
|
store := NewGitTokenStore(remoteDir, "", "", "")
|
|
store.SetBaseDir(filepath.Join(root, "workspace", "auths"))
|
|
if errEnsure := store.EnsureRepository(); errEnsure != nil {
|
|
t.Fatalf("EnsureRepository workspace: %v", errEnsure)
|
|
}
|
|
return filepath.Join(root, "workspace"), owner, store
|
|
}
|
|
|
|
t.Run("non-conflicting change", func(t *testing.T) {
|
|
workspaceDir, owner, store := setup(t)
|
|
if errWrite := os.WriteFile(store.ConfigPath(), []byte("source: local\n"), 0o600); errWrite != nil {
|
|
t.Fatalf("write local config: %v", errWrite)
|
|
}
|
|
if _, errSave := owner.Save(context.Background(), &cliproxyauth.Auth{
|
|
ID: "victim.json", FileName: "victim.json", Provider: "codex",
|
|
Metadata: map[string]any{"type": "codex", "access_token": "remote-new"},
|
|
}); errSave != nil {
|
|
t.Fatalf("update remote auth: %v", errSave)
|
|
}
|
|
removeHeadFileObject(t, workspaceDir, "corrupt-object.txt")
|
|
|
|
if errEnsure := store.EnsureRepository(); errEnsure != nil {
|
|
t.Fatalf("EnsureRepository recovery: %v", errEnsure)
|
|
}
|
|
assertLocalFileContents(t, store.ConfigPath(), "source: local\n")
|
|
assertLocalJSONValue(t, filepath.Join(store.AuthDir(), "victim.json"), "access_token", "remote-new")
|
|
})
|
|
|
|
t.Run("same-path conflict", func(t *testing.T) {
|
|
workspaceDir, owner, store := setup(t)
|
|
victimPath := filepath.Join(store.AuthDir(), "victim.json")
|
|
localContents := `{"type":"codex","access_token":"local"}`
|
|
if errWrite := os.WriteFile(victimPath, []byte(localContents), 0o600); errWrite != nil {
|
|
t.Fatalf("write local auth: %v", errWrite)
|
|
}
|
|
if _, errSave := owner.Save(context.Background(), &cliproxyauth.Auth{
|
|
ID: "victim.json", FileName: "victim.json", Provider: "codex",
|
|
Metadata: map[string]any{"type": "codex", "access_token": "remote-new"},
|
|
}); errSave != nil {
|
|
t.Fatalf("update remote auth: %v", errSave)
|
|
}
|
|
removeHeadFileObject(t, workspaceDir, "corrupt-object.txt")
|
|
|
|
errEnsure := store.EnsureRepository()
|
|
if errEnsure == nil || !strings.Contains(errEnsure.Error(), "conflicts with local change") {
|
|
t.Fatalf("EnsureRepository conflict error = %v, want fail-closed conflict", errEnsure)
|
|
}
|
|
assertLocalFileContents(t, victimPath, localContents)
|
|
assertRemoteFileContents(t, owner.remote, "master", "auths/victim.json", `{"access_token":"remote-new","disabled":false,"type":"codex"}`)
|
|
})
|
|
}
|
|
|
|
func TestGitTokenStoreFullPackfileCorruptionFailsClosedWithDirtyManagedFile(t *testing.T) {
|
|
setup := func(t *testing.T) (string, string, *GitTokenStore) {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
remoteDir := setupGitRemoteRepository(t, root, "master",
|
|
testBranchSpec{name: "master", contents: "remote master branch\n"},
|
|
)
|
|
workspaceDir := filepath.Join(root, "workspace")
|
|
store := NewGitTokenStore(remoteDir, "", "", "")
|
|
store.SetBaseDir(filepath.Join(workspaceDir, "auths"))
|
|
if errEnsure := store.EnsureRepository(); errEnsure != nil {
|
|
t.Fatalf("EnsureRepository: %v", errEnsure)
|
|
}
|
|
return remoteDir, workspaceDir, store
|
|
}
|
|
|
|
t.Run("config", func(t *testing.T) {
|
|
remoteDir, workspaceDir, store := setup(t)
|
|
configPath := store.ConfigPath()
|
|
if errWrite := os.WriteFile(configPath, []byte("source: remote\n"), 0o600); errWrite != nil {
|
|
t.Fatalf("write initial config: %v", errWrite)
|
|
}
|
|
if errPersist := store.PersistConfig(context.Background()); errPersist != nil {
|
|
t.Fatalf("PersistConfig initial config: %v", errPersist)
|
|
}
|
|
|
|
localContents := "source: local-dirty\n"
|
|
if errWrite := os.WriteFile(configPath, []byte(localContents), 0o600); errWrite != nil {
|
|
t.Fatalf("write dirty config: %v", errWrite)
|
|
}
|
|
corruptGitRepository(t, workspaceDir)
|
|
|
|
errPersist := store.PersistConfig(context.Background())
|
|
if errPersist == nil || !strings.Contains(errPersist.Error(), "inspect recovery baseline") {
|
|
t.Fatalf("PersistConfig error = %v, want fail-closed recovery baseline error", errPersist)
|
|
}
|
|
assertLocalFileContents(t, configPath, localContents)
|
|
assertRemoteFileContents(t, remoteDir, "master", "config/config.yaml", "source: remote\n")
|
|
})
|
|
|
|
t.Run("auth", func(t *testing.T) {
|
|
remoteDir, workspaceDir, store := setup(t)
|
|
authPath, errSave := store.Save(context.Background(), &cliproxyauth.Auth{
|
|
ID: "dirty.json", FileName: "dirty.json", Provider: "codex",
|
|
Metadata: map[string]any{"type": "codex", "access_token": "remote"},
|
|
})
|
|
if errSave != nil {
|
|
t.Fatalf("Save initial auth: %v", errSave)
|
|
}
|
|
|
|
localContents := `{"type":"codex","access_token":"local-dirty"}`
|
|
if errWrite := os.WriteFile(authPath, []byte(localContents), 0o600); errWrite != nil {
|
|
t.Fatalf("write dirty auth: %v", errWrite)
|
|
}
|
|
corruptGitRepository(t, workspaceDir)
|
|
|
|
_, errSave = store.Save(context.Background(), &cliproxyauth.Auth{
|
|
ID: "unrelated.json", FileName: "unrelated.json", Provider: "codex",
|
|
Metadata: map[string]any{"type": "codex", "access_token": "unrelated"},
|
|
})
|
|
if errSave == nil || !strings.Contains(errSave.Error(), "inspect recovery baseline") {
|
|
t.Fatalf("Save error = %v, want fail-closed recovery baseline error", errSave)
|
|
}
|
|
assertLocalFileContents(t, authPath, localContents)
|
|
assertRemoteFileContents(t, remoteDir, "master", "auths/dirty.json", `{"access_token":"remote","disabled":false,"type":"codex"}`)
|
|
assertRemoteTreePath(t, remoteDir, "master", "auths/unrelated.json", false)
|
|
})
|
|
}
|
|
|
|
func TestGitTokenStoreMissingPackfileRecoveryFailsClosedWithoutBaseline(t *testing.T) {
|
|
root := t.TempDir()
|
|
remoteDir := setupGitRemoteRepository(t, root, "master",
|
|
testBranchSpec{name: "master", contents: "remote master branch\n"},
|
|
)
|
|
store := NewGitTokenStore(remoteDir, "", "", "")
|
|
baseDir := filepath.Join(root, "workspace", "auths")
|
|
store.SetBaseDir(baseDir)
|
|
if errEnsure := store.EnsureRepository(); errEnsure != nil {
|
|
t.Fatalf("EnsureRepository: %v", errEnsure)
|
|
}
|
|
auth := &cliproxyauth.Auth{
|
|
ID: "recover.json",
|
|
FileName: "recover.json",
|
|
Provider: "codex",
|
|
Metadata: map[string]any{"type": "codex", "access_token": "remote"},
|
|
}
|
|
authPath, errSave := store.Save(context.Background(), auth)
|
|
if errSave != nil {
|
|
t.Fatalf("Save: %v", errSave)
|
|
}
|
|
|
|
corruptGitRepository(t, filepath.Join(root, "workspace"))
|
|
if errRemove := os.Remove(authPath); errRemove != nil {
|
|
t.Fatalf("remove local auth before recovery: %v", errRemove)
|
|
}
|
|
|
|
errEnsure := store.EnsureRepository()
|
|
if errEnsure == nil || !strings.Contains(errEnsure.Error(), "inspect recovery baseline") {
|
|
t.Fatalf("EnsureRepository error = %v, want fail-closed recovery baseline error", errEnsure)
|
|
}
|
|
if _, errStat := os.Stat(authPath); !errors.Is(errStat, os.ErrNotExist) {
|
|
t.Fatalf("local deleted auth stat error = %v, want not exist", errStat)
|
|
}
|
|
assertRemoteTreePath(t, remoteDir, "master", "auths/recover.json", true)
|
|
}
|
|
|
|
func TestInspectRecoveryBaselineFailureClosesRepositoryHandle(t *testing.T) {
|
|
root := t.TempDir()
|
|
remoteDir := setupGitRemoteRepository(t, root, "master",
|
|
testBranchSpec{name: "master", contents: "remote master branch\n"},
|
|
)
|
|
repoDir := filepath.Join(root, "workspace")
|
|
store := NewGitTokenStore(remoteDir, "", "", "")
|
|
store.SetBaseDir(filepath.Join(repoDir, "auths"))
|
|
if errEnsure := store.EnsureRepository(); errEnsure != nil {
|
|
t.Fatalf("EnsureRepository: %v", errEnsure)
|
|
}
|
|
|
|
corruptGitRepository(t, repoDir)
|
|
|
|
repo, tree, dirty, errInspect := inspectRecoveryBaseline(repoDir)
|
|
if errInspect == nil {
|
|
t.Fatal("inspectRecoveryBaseline error = nil, want corruption error")
|
|
}
|
|
if repo != nil || tree != nil || dirty != nil {
|
|
t.Fatalf("inspectRecoveryBaseline returned non-nil values on error: repo=%v, tree=%v, dirty=%v", repo, tree, dirty)
|
|
}
|
|
|
|
gitDir := filepath.Join(repoDir, ".git")
|
|
renamedGitDir := filepath.Join(repoDir, ".git.renamed")
|
|
if errRename := os.Rename(gitDir, renamedGitDir); errRename != nil {
|
|
t.Fatalf("rename .git after inspectRecoveryBaseline failure: %v", errRename)
|
|
}
|
|
if errRenameBack := os.Rename(renamedGitDir, gitDir); errRenameBack != nil {
|
|
t.Fatalf("restore renamed .git: %v", errRenameBack)
|
|
}
|
|
|
|
if errRemove := os.RemoveAll(repoDir); errRemove != nil {
|
|
t.Fatalf("remove repoDir after handle release: %v", errRemove)
|
|
}
|
|
if errRetry := store.EnsureRepository(); errRetry != nil {
|
|
t.Fatalf("EnsureRepository retry after cleanup: %v", errRetry)
|
|
}
|
|
assertRemoteBranchContents(t, remoteDir, "master", "remote master branch\n")
|
|
}
|
|
|
|
func TestCommitAndPushLockedPushesBeforeRunningGC(t *testing.T) {
|
|
root := t.TempDir()
|
|
remoteDir := setupGitRemoteRepository(t, root, "master",
|
|
testBranchSpec{name: "master", contents: "remote master branch\n"},
|
|
)
|
|
|
|
store := NewGitTokenStore(remoteDir, "", "", "")
|
|
store.SetBaseDir(filepath.Join(root, "workspace", "auths"))
|
|
if err := store.EnsureRepository(); err != nil {
|
|
t.Fatalf("EnsureRepository: %v", err)
|
|
}
|
|
|
|
workspaceDir := filepath.Join(root, "workspace")
|
|
updates := []string{
|
|
"local master update one\n",
|
|
"local master update two\n",
|
|
}
|
|
for _, contents := range updates {
|
|
if err := os.WriteFile(filepath.Join(workspaceDir, "branch.txt"), []byte(contents), 0o600); err != nil {
|
|
t.Fatalf("write local master marker: %v", err)
|
|
}
|
|
|
|
store.lastGC = time.Now().Add(-gcInterval)
|
|
store.mu.Lock()
|
|
err := store.commitAndPushLocked("Update master marker", "branch.txt")
|
|
store.mu.Unlock()
|
|
if err != nil {
|
|
t.Fatalf("commitAndPushLocked with forced GC: %v", err)
|
|
}
|
|
|
|
assertRemoteBranchContents(t, remoteDir, "master", contents)
|
|
}
|
|
}
|
|
|
|
func TestEnsureRepositoryFollowsRenamedRemoteDefaultBranchWhenAvailable(t *testing.T) {
|
|
root := t.TempDir()
|
|
remoteDir := setupGitRemoteRepository(t, root, "master",
|
|
testBranchSpec{name: "master", contents: "remote master branch\n"},
|
|
testBranchSpec{name: "main", contents: "remote main branch\n"},
|
|
)
|
|
|
|
baseDir := filepath.Join(root, "workspace", "auths")
|
|
store := NewGitTokenStore(remoteDir, "", "", "")
|
|
store.SetBaseDir(baseDir)
|
|
|
|
if err := store.EnsureRepository(); err != nil {
|
|
t.Fatalf("EnsureRepository initial clone: %v", err)
|
|
}
|
|
assertRepositoryBranchAndContents(t, filepath.Join(root, "workspace"), "master", "remote master branch\n")
|
|
|
|
setRemoteHeadBranch(t, remoteDir, "main")
|
|
advanceRemoteBranch(t, filepath.Join(root, "seed"), remoteDir, "main", "remote main branch updated\n", "advance main")
|
|
|
|
reopened := NewGitTokenStore(remoteDir, "", "", "")
|
|
reopened.SetBaseDir(baseDir)
|
|
|
|
if err := reopened.EnsureRepository(); err != nil {
|
|
t.Fatalf("EnsureRepository after remote default rename: %v", err)
|
|
}
|
|
assertRepositoryBranchAndContents(t, filepath.Join(root, "workspace"), "main", "remote main branch updated\n")
|
|
assertRemoteHeadBranch(t, remoteDir, "main")
|
|
}
|
|
|
|
func TestEnsureRepositoryKeepsCurrentBranchWhenRemoteDefaultCannotBeResolved(t *testing.T) {
|
|
root := t.TempDir()
|
|
remoteDir := setupGitRemoteRepository(t, root, "master",
|
|
testBranchSpec{name: "master", contents: "remote master branch\n"},
|
|
testBranchSpec{name: "develop", contents: "remote develop branch\n"},
|
|
)
|
|
|
|
baseDir := filepath.Join(root, "workspace", "auths")
|
|
pinned := NewGitTokenStore(remoteDir, "", "", "develop")
|
|
pinned.SetBaseDir(baseDir)
|
|
if err := pinned.EnsureRepository(); err != nil {
|
|
t.Fatalf("EnsureRepository pinned: %v", err)
|
|
}
|
|
assertRepositoryBranchAndContents(t, filepath.Join(root, "workspace"), "develop", "remote develop branch\n")
|
|
|
|
authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("WWW-Authenticate", `Basic realm="git"`)
|
|
http.Error(w, "auth required", http.StatusUnauthorized)
|
|
}))
|
|
defer authServer.Close()
|
|
|
|
repo, err := git.PlainOpen(filepath.Join(root, "workspace"))
|
|
if err != nil {
|
|
t.Fatalf("open workspace repo: %v", err)
|
|
}
|
|
repoClosed := false
|
|
defer func() {
|
|
if !repoClosed {
|
|
if errClose := repo.Close(); errClose != nil {
|
|
t.Errorf("close workspace repo after test failure: %v", errClose)
|
|
}
|
|
}
|
|
}()
|
|
cfg, err := repo.Config()
|
|
if err != nil {
|
|
t.Fatalf("read repo config: %v", err)
|
|
}
|
|
cfg.Remotes["origin"].URLs = []string{authServer.URL}
|
|
if err := repo.SetConfig(cfg); err != nil {
|
|
t.Fatalf("set repo config: %v", err)
|
|
}
|
|
errClose := repo.Close()
|
|
repoClosed = true
|
|
if errClose != nil {
|
|
t.Fatalf("close workspace repo before fallback check: %v", errClose)
|
|
}
|
|
|
|
reopened := NewGitTokenStore(remoteDir, "", "", "")
|
|
reopened.SetBaseDir(baseDir)
|
|
|
|
if err := reopened.EnsureRepository(); err != nil {
|
|
t.Fatalf("EnsureRepository default branch fallback: %v", err)
|
|
}
|
|
assertRepositoryHeadBranch(t, filepath.Join(root, "workspace"), "develop")
|
|
}
|
|
|
|
func removeHeadFileObject(t *testing.T, repoDir, path string) {
|
|
t.Helper()
|
|
|
|
repo, errOpen := git.PlainOpen(repoDir)
|
|
if errOpen != nil {
|
|
t.Fatalf("open repository before object removal: %v", errOpen)
|
|
}
|
|
defer func() {
|
|
if errClose := repo.Close(); errClose != nil {
|
|
t.Errorf("close repository: %v", errClose)
|
|
}
|
|
}()
|
|
worktree, errWorktree := repo.Worktree()
|
|
if errWorktree != nil {
|
|
t.Fatalf("open worktree before object removal: %v", errWorktree)
|
|
}
|
|
fullPath := filepath.Join(repoDir, filepath.FromSlash(path))
|
|
if errWrite := os.WriteFile(fullPath, []byte("corrupt me\n"), 0o600); errWrite != nil {
|
|
t.Fatalf("write corruption marker: %v", errWrite)
|
|
}
|
|
if _, errAdd := worktree.Add(path); errAdd != nil {
|
|
t.Fatalf("add corruption marker: %v", errAdd)
|
|
}
|
|
if _, errCommit := worktree.Commit("Add corruption marker", &git.CommitOptions{Author: &object.Signature{
|
|
Name: "CLIProxyAPI", Email: "cliproxy@local", When: time.Unix(1711929600, 0),
|
|
}}); errCommit != nil {
|
|
t.Fatalf("commit corruption marker: %v", errCommit)
|
|
}
|
|
head, errHead := repo.Head()
|
|
if errHead != nil {
|
|
t.Fatalf("read repository head: %v", errHead)
|
|
}
|
|
commit, errCommit := repo.CommitObject(head.Hash())
|
|
if errCommit != nil {
|
|
t.Fatalf("read repository commit: %v", errCommit)
|
|
}
|
|
tree, errTree := commit.Tree()
|
|
if errTree != nil {
|
|
t.Fatalf("read repository tree: %v", errTree)
|
|
}
|
|
file, errFile := tree.File(path)
|
|
if errFile != nil {
|
|
t.Fatalf("read repository file %s: %v", path, errFile)
|
|
}
|
|
objectPath := filepath.Join(repoDir, ".git", "objects", file.Hash.String()[:2], file.Hash.String()[2:])
|
|
if errRemove := os.Remove(objectPath); errRemove != nil {
|
|
t.Fatalf("remove repository object for %s: %v", path, errRemove)
|
|
}
|
|
if errVerify := verifyRepositoryHead(repo); !isRepositoryCorruptionError(errVerify) {
|
|
t.Fatalf("verifyRepositoryHead error = %v, want repository corruption", errVerify)
|
|
}
|
|
}
|
|
|
|
func corruptGitRepository(t *testing.T, repoDir string) {
|
|
t.Helper()
|
|
|
|
repo, errOpen := git.PlainOpen(repoDir)
|
|
if errOpen != nil {
|
|
t.Fatalf("open repository before corruption: %v", errOpen)
|
|
}
|
|
defer func() {
|
|
if errClose := repo.Close(); errClose != nil {
|
|
t.Errorf("close corrupted repository: %v", errClose)
|
|
}
|
|
}()
|
|
if errRepack := repo.RepackObjects(&git.RepackConfig{}); errRepack != nil {
|
|
t.Fatalf("repack repository objects: %v", errRepack)
|
|
}
|
|
objectsDir := filepath.Join(repoDir, ".git", "objects")
|
|
objectEntries, errReadDir := os.ReadDir(objectsDir)
|
|
if errReadDir != nil {
|
|
t.Fatalf("read object directory: %v", errReadDir)
|
|
}
|
|
for _, entry := range objectEntries {
|
|
if entry.IsDir() && len(entry.Name()) == 2 {
|
|
if errRemove := os.RemoveAll(filepath.Join(objectsDir, entry.Name())); errRemove != nil {
|
|
t.Fatalf("remove loose object directory %s: %v", entry.Name(), errRemove)
|
|
}
|
|
}
|
|
}
|
|
packfiles, errGlob := filepath.Glob(filepath.Join(objectsDir, "pack", "*.pack"))
|
|
if errGlob != nil {
|
|
t.Fatalf("glob packfiles: %v", errGlob)
|
|
}
|
|
if len(packfiles) == 0 {
|
|
t.Fatal("no packfiles found to corrupt")
|
|
}
|
|
for _, packfile := range packfiles {
|
|
if errRemove := os.Remove(packfile); errRemove != nil {
|
|
t.Fatalf("remove packfile %s: %v", filepath.Base(packfile), errRemove)
|
|
}
|
|
}
|
|
if errVerify := verifyRepositoryHead(repo); !isRepositoryCorruptionError(errVerify) {
|
|
t.Fatalf("verifyRepositoryHead error = %v, want repository corruption", errVerify)
|
|
}
|
|
}
|
|
|
|
func setupGitRemoteRepository(t *testing.T, root, defaultBranch string, branches ...testBranchSpec) string {
|
|
t.Helper()
|
|
|
|
remoteDir := filepath.Join(root, "remote.git")
|
|
remoteRepo, errInit := git.PlainInit(remoteDir, true)
|
|
if errInit != nil {
|
|
t.Fatalf("init bare remote: %v", errInit)
|
|
}
|
|
if errClose := remoteRepo.Close(); errClose != nil {
|
|
t.Fatalf("close initialized bare remote: %v", errClose)
|
|
}
|
|
|
|
seedDir := filepath.Join(root, "seed")
|
|
seedRepo, err := git.PlainInit(seedDir, false)
|
|
if err != nil {
|
|
t.Fatalf("init seed repo: %v", err)
|
|
}
|
|
defer func() {
|
|
if errClose := seedRepo.Close(); errClose != nil {
|
|
t.Errorf("close seed repo: %v", errClose)
|
|
}
|
|
}()
|
|
seedConfig, errConfig := seedRepo.Config()
|
|
if errConfig != nil {
|
|
t.Fatalf("get seed repo config: %v", errConfig)
|
|
}
|
|
seedConfig.Commit.GpgSign = gitconfig.OptBoolFalse
|
|
if errSetConfig := seedRepo.SetConfig(seedConfig); errSetConfig != nil {
|
|
t.Fatalf("disable seed repo commit signing: %v", errSetConfig)
|
|
}
|
|
if err := seedRepo.Storer.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.NewBranchReferenceName(defaultBranch))); err != nil {
|
|
t.Fatalf("set seed HEAD: %v", err)
|
|
}
|
|
|
|
worktree, err := seedRepo.Worktree()
|
|
if err != nil {
|
|
t.Fatalf("open seed worktree: %v", err)
|
|
}
|
|
|
|
defaultSpec, ok := findBranchSpec(branches, defaultBranch)
|
|
if !ok {
|
|
t.Fatalf("missing default branch spec for %q", defaultBranch)
|
|
}
|
|
commitBranchMarker(t, seedDir, worktree, defaultSpec, "seed default branch")
|
|
|
|
for _, branch := range branches {
|
|
if branch.name == defaultBranch {
|
|
continue
|
|
}
|
|
if err := worktree.Checkout(&git.CheckoutOptions{Branch: plumbing.NewBranchReferenceName(defaultBranch)}); err != nil {
|
|
t.Fatalf("checkout default branch %s: %v", defaultBranch, err)
|
|
}
|
|
if err := worktree.Checkout(&git.CheckoutOptions{Branch: plumbing.NewBranchReferenceName(branch.name), Create: true}); err != nil {
|
|
t.Fatalf("create branch %s: %v", branch.name, err)
|
|
}
|
|
commitBranchMarker(t, seedDir, worktree, branch, "seed branch "+branch.name)
|
|
}
|
|
|
|
if _, err := seedRepo.CreateRemote(&gitconfig.RemoteConfig{Name: "origin", URLs: []string{remoteDir}}); err != nil {
|
|
t.Fatalf("create origin remote: %v", err)
|
|
}
|
|
if err := seedRepo.Push(&git.PushOptions{
|
|
RemoteName: "origin",
|
|
RefSpecs: []gitconfig.RefSpec{gitconfig.RefSpec("refs/heads/*:refs/heads/*")},
|
|
}); err != nil {
|
|
t.Fatalf("push seed branches: %v", err)
|
|
}
|
|
|
|
remoteRepo, err = git.PlainOpen(remoteDir)
|
|
if err != nil {
|
|
t.Fatalf("open remote repo: %v", err)
|
|
}
|
|
defer func() {
|
|
if errClose := remoteRepo.Close(); errClose != nil {
|
|
t.Errorf("close remote repo: %v", errClose)
|
|
}
|
|
}()
|
|
if err := remoteRepo.Storer.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.NewBranchReferenceName(defaultBranch))); err != nil {
|
|
t.Fatalf("set remote HEAD: %v", err)
|
|
}
|
|
|
|
return remoteDir
|
|
}
|
|
|
|
func commitBranchMarker(t *testing.T, seedDir string, worktree *git.Worktree, branch testBranchSpec, message string) {
|
|
t.Helper()
|
|
|
|
if err := os.WriteFile(filepath.Join(seedDir, "branch.txt"), []byte(branch.contents), 0o600); err != nil {
|
|
t.Fatalf("write branch marker for %s: %v", branch.name, err)
|
|
}
|
|
if _, err := worktree.Add("branch.txt"); err != nil {
|
|
t.Fatalf("add branch marker for %s: %v", branch.name, err)
|
|
}
|
|
if _, err := worktree.Commit(message, &git.CommitOptions{
|
|
Author: &object.Signature{
|
|
Name: "CLIProxyAPI",
|
|
Email: "cliproxy@local",
|
|
When: time.Unix(1711929600, 0),
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("commit branch marker for %s: %v", branch.name, err)
|
|
}
|
|
}
|
|
|
|
func advanceRemoteBranch(t *testing.T, seedDir, remoteDir, branch, contents, message string) {
|
|
t.Helper()
|
|
|
|
seedRepo, err := git.PlainOpen(seedDir)
|
|
if err != nil {
|
|
t.Fatalf("open seed repo: %v", err)
|
|
}
|
|
defer func() {
|
|
if errClose := seedRepo.Close(); errClose != nil {
|
|
t.Errorf("close seed repo: %v", errClose)
|
|
}
|
|
}()
|
|
worktree, err := seedRepo.Worktree()
|
|
if err != nil {
|
|
t.Fatalf("open seed worktree: %v", err)
|
|
}
|
|
if err := worktree.Checkout(&git.CheckoutOptions{Branch: plumbing.NewBranchReferenceName(branch)}); err != nil {
|
|
t.Fatalf("checkout branch %s: %v", branch, err)
|
|
}
|
|
commitBranchMarker(t, seedDir, worktree, testBranchSpec{name: branch, contents: contents}, message)
|
|
if err := seedRepo.Push(&git.PushOptions{
|
|
RemoteName: "origin",
|
|
RefSpecs: []gitconfig.RefSpec{
|
|
gitconfig.RefSpec(plumbing.NewBranchReferenceName(branch).String() + ":" + plumbing.NewBranchReferenceName(branch).String()),
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("push branch %s update to %s: %v", branch, remoteDir, err)
|
|
}
|
|
}
|
|
|
|
func advanceRemoteBranchFromNewBranch(t *testing.T, seedDir, remoteDir, branch, contents, message string) {
|
|
t.Helper()
|
|
|
|
seedRepo, err := git.PlainOpen(seedDir)
|
|
if err != nil {
|
|
t.Fatalf("open seed repo: %v", err)
|
|
}
|
|
defer func() {
|
|
if errClose := seedRepo.Close(); errClose != nil {
|
|
t.Errorf("close seed repo: %v", errClose)
|
|
}
|
|
}()
|
|
worktree, err := seedRepo.Worktree()
|
|
if err != nil {
|
|
t.Fatalf("open seed worktree: %v", err)
|
|
}
|
|
if err := worktree.Checkout(&git.CheckoutOptions{Branch: plumbing.NewBranchReferenceName("master")}); err != nil {
|
|
t.Fatalf("checkout master before creating %s: %v", branch, err)
|
|
}
|
|
if err := worktree.Checkout(&git.CheckoutOptions{Branch: plumbing.NewBranchReferenceName(branch), Create: true}); err != nil {
|
|
t.Fatalf("create branch %s: %v", branch, err)
|
|
}
|
|
commitBranchMarker(t, seedDir, worktree, testBranchSpec{name: branch, contents: contents}, message)
|
|
if err := seedRepo.Push(&git.PushOptions{
|
|
RemoteName: "origin",
|
|
RefSpecs: []gitconfig.RefSpec{
|
|
gitconfig.RefSpec(plumbing.NewBranchReferenceName(branch).String() + ":" + plumbing.NewBranchReferenceName(branch).String()),
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("push new branch %s update to %s: %v", branch, remoteDir, err)
|
|
}
|
|
}
|
|
|
|
func findBranchSpec(branches []testBranchSpec, name string) (testBranchSpec, bool) {
|
|
for _, branch := range branches {
|
|
if branch.name == name {
|
|
return branch, true
|
|
}
|
|
}
|
|
return testBranchSpec{}, false
|
|
}
|
|
|
|
func assertLocalFileContents(t *testing.T, path, wantContents string) {
|
|
t.Helper()
|
|
|
|
contents, errRead := os.ReadFile(path)
|
|
if errRead != nil {
|
|
t.Fatalf("read local file %s: %v", path, errRead)
|
|
}
|
|
if string(contents) != wantContents {
|
|
t.Fatalf("local file %s contents = %q, want %q", path, contents, wantContents)
|
|
}
|
|
}
|
|
|
|
func assertLocalJSONValue(t *testing.T, path, key, wantValue string) {
|
|
t.Helper()
|
|
|
|
contents, errRead := os.ReadFile(path)
|
|
if errRead != nil {
|
|
t.Fatalf("read local JSON file %s: %v", path, errRead)
|
|
}
|
|
metadata := make(map[string]any)
|
|
if errUnmarshal := json.Unmarshal(contents, &metadata); errUnmarshal != nil {
|
|
t.Fatalf("unmarshal local JSON file %s: %v", path, errUnmarshal)
|
|
}
|
|
if gotValue, _ := metadata[key].(string); gotValue != wantValue {
|
|
t.Fatalf("local JSON file %s value %s = %q, want %q", path, key, gotValue, wantValue)
|
|
}
|
|
}
|
|
|
|
func assertRemoteTreePath(t *testing.T, remoteDir, branch, path string, want bool) {
|
|
t.Helper()
|
|
|
|
repo, err := git.PlainOpen(remoteDir)
|
|
if err != nil {
|
|
t.Fatalf("open remote repo: %v", err)
|
|
}
|
|
defer func() {
|
|
if errClose := repo.Close(); errClose != nil {
|
|
t.Errorf("close remote repo: %v", errClose)
|
|
}
|
|
}()
|
|
ref, err := repo.Reference(plumbing.NewBranchReferenceName(branch), true)
|
|
if err != nil {
|
|
t.Fatalf("read remote branch %s: %v", branch, err)
|
|
}
|
|
commit, err := repo.CommitObject(ref.Hash())
|
|
if err != nil {
|
|
t.Fatalf("read remote commit: %v", err)
|
|
}
|
|
tree, err := commit.Tree()
|
|
if err != nil {
|
|
t.Fatalf("read remote tree: %v", err)
|
|
}
|
|
_, err = tree.File(filepath.ToSlash(path))
|
|
got := err == nil
|
|
if err != nil && !errors.Is(err, object.ErrFileNotFound) {
|
|
t.Fatalf("inspect remote path %s: %v", path, err)
|
|
}
|
|
if got != want {
|
|
t.Fatalf("remote path %s exists = %v, want %v", path, got, want)
|
|
}
|
|
}
|
|
|
|
func assertRemoteFileContents(t *testing.T, remoteDir, branch, path, wantContents string) {
|
|
t.Helper()
|
|
|
|
repo, err := git.PlainOpen(remoteDir)
|
|
if err != nil {
|
|
t.Fatalf("open remote repo: %v", err)
|
|
}
|
|
defer func() {
|
|
if errClose := repo.Close(); errClose != nil {
|
|
t.Errorf("close remote repo: %v", errClose)
|
|
}
|
|
}()
|
|
ref, err := repo.Reference(plumbing.NewBranchReferenceName(branch), true)
|
|
if err != nil {
|
|
t.Fatalf("read remote branch %s: %v", branch, err)
|
|
}
|
|
commit, err := repo.CommitObject(ref.Hash())
|
|
if err != nil {
|
|
t.Fatalf("read remote commit: %v", err)
|
|
}
|
|
tree, err := commit.Tree()
|
|
if err != nil {
|
|
t.Fatalf("read remote tree: %v", err)
|
|
}
|
|
file, err := tree.File(filepath.ToSlash(path))
|
|
if err != nil {
|
|
t.Fatalf("read remote file %s: %v", path, err)
|
|
}
|
|
contents, err := file.Contents()
|
|
if err != nil {
|
|
t.Fatalf("read remote file %s contents: %v", path, err)
|
|
}
|
|
if contents != wantContents {
|
|
t.Fatalf("remote file %s contents = %q, want %q", path, contents, wantContents)
|
|
}
|
|
}
|
|
|
|
func assertRepositoryBranchAndContents(t *testing.T, repoDir, branch, wantContents string) {
|
|
t.Helper()
|
|
|
|
repo, err := git.PlainOpen(repoDir)
|
|
if err != nil {
|
|
t.Fatalf("open local repo: %v", err)
|
|
}
|
|
defer func() {
|
|
if errClose := repo.Close(); errClose != nil {
|
|
t.Errorf("close local repo: %v", errClose)
|
|
}
|
|
}()
|
|
head, err := repo.Head()
|
|
if err != nil {
|
|
t.Fatalf("local repo head: %v", err)
|
|
}
|
|
if got, want := head.Name(), plumbing.NewBranchReferenceName(branch); got != want {
|
|
t.Fatalf("local head branch = %s, want %s", got, want)
|
|
}
|
|
contents, err := os.ReadFile(filepath.Join(repoDir, "branch.txt"))
|
|
if err != nil {
|
|
t.Fatalf("read branch marker: %v", err)
|
|
}
|
|
if got := string(contents); got != wantContents {
|
|
t.Fatalf("branch marker contents = %q, want %q", got, wantContents)
|
|
}
|
|
}
|
|
|
|
func assertRepositoryHeadBranch(t *testing.T, repoDir, branch string) {
|
|
t.Helper()
|
|
|
|
repo, err := git.PlainOpen(repoDir)
|
|
if err != nil {
|
|
t.Fatalf("open local repo: %v", err)
|
|
}
|
|
defer func() {
|
|
if errClose := repo.Close(); errClose != nil {
|
|
t.Errorf("close local repo: %v", errClose)
|
|
}
|
|
}()
|
|
head, err := repo.Head()
|
|
if err != nil {
|
|
t.Fatalf("local repo head: %v", err)
|
|
}
|
|
if got, want := head.Name(), plumbing.NewBranchReferenceName(branch); got != want {
|
|
t.Fatalf("local head branch = %s, want %s", got, want)
|
|
}
|
|
}
|
|
|
|
func assertRemoteHeadBranch(t *testing.T, remoteDir, branch string) {
|
|
t.Helper()
|
|
|
|
remoteRepo, err := git.PlainOpen(remoteDir)
|
|
if err != nil {
|
|
t.Fatalf("open remote repo: %v", err)
|
|
}
|
|
defer func() {
|
|
if errClose := remoteRepo.Close(); errClose != nil {
|
|
t.Errorf("close remote repo: %v", errClose)
|
|
}
|
|
}()
|
|
head, err := remoteRepo.Reference(plumbing.HEAD, false)
|
|
if err != nil {
|
|
t.Fatalf("read remote HEAD: %v", err)
|
|
}
|
|
if got, want := head.Target(), plumbing.NewBranchReferenceName(branch); got != want {
|
|
t.Fatalf("remote HEAD target = %s, want %s", got, want)
|
|
}
|
|
}
|
|
|
|
func setRemoteHeadBranch(t *testing.T, remoteDir, branch string) {
|
|
t.Helper()
|
|
|
|
remoteRepo, err := git.PlainOpen(remoteDir)
|
|
if err != nil {
|
|
t.Fatalf("open remote repo: %v", err)
|
|
}
|
|
defer func() {
|
|
if errClose := remoteRepo.Close(); errClose != nil {
|
|
t.Errorf("close remote repo: %v", errClose)
|
|
}
|
|
}()
|
|
if err := remoteRepo.Storer.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.NewBranchReferenceName(branch))); err != nil {
|
|
t.Fatalf("set remote HEAD to %s: %v", branch, err)
|
|
}
|
|
}
|
|
|
|
func assertRemoteBranchExistsWithCommit(t *testing.T, remoteDir, branch string) {
|
|
t.Helper()
|
|
|
|
remoteRepo, err := git.PlainOpen(remoteDir)
|
|
if err != nil {
|
|
t.Fatalf("open remote repo: %v", err)
|
|
}
|
|
defer func() {
|
|
if errClose := remoteRepo.Close(); errClose != nil {
|
|
t.Errorf("close remote repo: %v", errClose)
|
|
}
|
|
}()
|
|
ref, err := remoteRepo.Reference(plumbing.NewBranchReferenceName(branch), false)
|
|
if err != nil {
|
|
t.Fatalf("read remote branch %s: %v", branch, err)
|
|
}
|
|
if got := ref.Hash(); got == plumbing.ZeroHash {
|
|
t.Fatalf("remote branch %s hash = %s, want non-zero hash", branch, got)
|
|
}
|
|
}
|
|
|
|
func assertRemoteBranchDoesNotExist(t *testing.T, remoteDir, branch string) {
|
|
t.Helper()
|
|
|
|
remoteRepo, err := git.PlainOpen(remoteDir)
|
|
if err != nil {
|
|
t.Fatalf("open remote repo: %v", err)
|
|
}
|
|
defer func() {
|
|
if errClose := remoteRepo.Close(); errClose != nil {
|
|
t.Errorf("close remote repo: %v", errClose)
|
|
}
|
|
}()
|
|
if _, err := remoteRepo.Reference(plumbing.NewBranchReferenceName(branch), false); err == nil {
|
|
t.Fatalf("remote branch %s exists, want missing", branch)
|
|
} else if err != plumbing.ErrReferenceNotFound {
|
|
t.Fatalf("read remote branch %s: %v", branch, err)
|
|
}
|
|
}
|
|
|
|
func assertRemoteBranchContents(t *testing.T, remoteDir, branch, wantContents string) {
|
|
t.Helper()
|
|
|
|
remoteRepo, err := git.PlainOpen(remoteDir)
|
|
if err != nil {
|
|
t.Fatalf("open remote repo: %v", err)
|
|
}
|
|
defer func() {
|
|
if errClose := remoteRepo.Close(); errClose != nil {
|
|
t.Errorf("close remote repo: %v", errClose)
|
|
}
|
|
}()
|
|
ref, err := remoteRepo.Reference(plumbing.NewBranchReferenceName(branch), false)
|
|
if err != nil {
|
|
t.Fatalf("read remote branch %s: %v", branch, err)
|
|
}
|
|
commit, err := remoteRepo.CommitObject(ref.Hash())
|
|
if err != nil {
|
|
t.Fatalf("read remote branch %s commit: %v", branch, err)
|
|
}
|
|
tree, err := commit.Tree()
|
|
if err != nil {
|
|
t.Fatalf("read remote branch %s tree: %v", branch, err)
|
|
}
|
|
file, err := tree.File("branch.txt")
|
|
if err != nil {
|
|
t.Fatalf("read remote branch %s file: %v", branch, err)
|
|
}
|
|
contents, err := file.Contents()
|
|
if err != nil {
|
|
t.Fatalf("read remote branch %s contents: %v", branch, err)
|
|
}
|
|
if contents != wantContents {
|
|
t.Fatalf("remote branch %s contents = %q, want %q", branch, contents, wantContents)
|
|
}
|
|
}
|