Files
kandev/apps/backend/internal/worktree/gitcrypt_test.go
T

382 lines
11 KiB
Go

package worktree
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestHasGitCryptFilter(t *testing.T) {
tests := []struct {
name string
content string
expected bool
}{
{
name: "file with git-crypt filter",
content: "secretfile filter=git-crypt diff=git-crypt\n*.key filter=git-crypt diff=git-crypt",
expected: true,
},
{
name: "file without git-crypt filter",
content: "*.txt text\n*.bin binary",
expected: false,
},
{
name: "empty file",
content: "",
expected: false,
},
{
name: "filter with different name",
content: "*.lfs filter=lfs diff=lfs",
expected: false,
},
{
name: "git-crypt in comment",
content: "# This uses filter=git-crypt for encryption\n*.txt text",
expected: true, // We still detect it - conservative approach
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create temp file
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, ".gitattributes")
if err := os.WriteFile(tmpFile, []byte(tt.content), 0644); err != nil {
t.Fatalf("failed to write temp file: %v", err)
}
result := hasGitCryptFilter(tmpFile)
if result != tt.expected {
t.Errorf("hasGitCryptFilter() = %v, want %v", result, tt.expected)
}
})
}
}
func TestHasGitCryptFilter_NonexistentFile(t *testing.T) {
result := hasGitCryptFilter("/nonexistent/path/.gitattributes")
if result {
t.Error("hasGitCryptFilter() should return false for nonexistent file")
}
}
func TestIsGitCryptSmudgeError(t *testing.T) {
tests := []struct {
name string
output string
expected bool
}{
{
name: "smudge filter git-crypt failed",
output: "fatal: tools/secrets/config.yml: smudge filter git-crypt failed",
expected: true,
},
{
name: "external filter git-crypt smudge failed",
output: "error: external filter '\"git-crypt\" smudge' failed 1",
expected: true,
},
{
name: "external filter without quotes",
output: "error: external filter 'git-crypt smudge' failed",
expected: true,
},
{
name: "unrelated git error",
output: "fatal: pathspec 'foo' did not match any files",
expected: false,
},
{
name: "empty output",
output: "",
expected: false,
},
{
name: "branch checkout error",
output: "fatal: 'main' is already checked out at '/path/to/worktree'",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isGitCryptSmudgeError(tt.output)
if result != tt.expected {
t.Errorf("isGitCryptSmudgeError(%q) = %v, want %v", tt.output, result, tt.expected)
}
})
}
}
func TestUsesGitCrypt(t *testing.T) {
tmpDir := t.TempDir()
// Create a fake git repo structure
gitDir := filepath.Join(tmpDir, ".git")
if err := os.MkdirAll(filepath.Join(gitDir, "info"), 0755); err != nil {
t.Fatalf("failed to create .git/info: %v", err)
}
log := newTestLogger()
m := &Manager{logger: log}
// Test 1: No .gitattributes
if m.usesGitCrypt(tmpDir) {
t.Error("usesGitCrypt() should return false when no .gitattributes exists")
}
// Test 2: .gitattributes without git-crypt
gitattributes := filepath.Join(tmpDir, ".gitattributes")
if err := os.WriteFile(gitattributes, []byte("*.txt text\n"), 0644); err != nil {
t.Fatalf("failed to write .gitattributes: %v", err)
}
if m.usesGitCrypt(tmpDir) {
t.Error("usesGitCrypt() should return false when .gitattributes has no git-crypt")
}
// Test 3: .gitattributes with git-crypt
if err := os.WriteFile(gitattributes, []byte("secrets/* filter=git-crypt diff=git-crypt\n"), 0644); err != nil {
t.Fatalf("failed to write .gitattributes: %v", err)
}
if !m.usesGitCrypt(tmpDir) {
t.Error("usesGitCrypt() should return true when .gitattributes has git-crypt")
}
}
// ---------- E2E tests requiring git-crypt binary ----------
// skipIfNoGitCrypt skips the test if the git-crypt binary is not available.
func skipIfNoGitCrypt(t *testing.T) {
t.Helper()
if _, err := exec.LookPath("git-crypt"); err != nil {
t.Skip("git-crypt not installed, skipping e2e test")
}
}
// initGitCryptRepo creates a temporary git repository with git-crypt
// initialised and a mix of encrypted and plain-text files committed.
// Returns the repo path. The caller owns cleanup via t.TempDir().
func initGitCryptRepo(t *testing.T) string {
t.Helper()
repoPath := t.TempDir()
runGit(t, repoPath, "init", "-b", "main")
runGit(t, repoPath, "config", "user.email", "test@example.com")
runGit(t, repoPath, "config", "user.name", "Test User")
runGit(t, repoPath, "config", "commit.gpgsign", "false")
// Initialise git-crypt (generates symmetric key in .git/git-crypt/).
cmd := exec.Command("git-crypt", "init")
cmd.Dir = repoPath
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git-crypt init failed: %v\n%s", err, out)
}
// Set up .gitattributes: secret.txt is encrypted, public.txt is not.
if err := os.WriteFile(
filepath.Join(repoPath, ".gitattributes"),
[]byte("secret.txt filter=git-crypt diff=git-crypt\n"),
0644,
); err != nil {
t.Fatalf("write .gitattributes: %v", err)
}
if err := os.WriteFile(filepath.Join(repoPath, "secret.txt"), []byte("top-secret-value\n"), 0644); err != nil {
t.Fatalf("write secret.txt: %v", err)
}
if err := os.WriteFile(filepath.Join(repoPath, "public.txt"), []byte("public-value\n"), 0644); err != nil {
t.Fatalf("write public.txt: %v", err)
}
runGit(t, repoPath, "add", ".")
runGit(t, repoPath, "commit", "-m", "initial commit with git-crypt")
// Also create a feature branch for the "existing branch" test.
runGit(t, repoPath, "branch", "feature/encrypted-branch")
return repoPath
}
func TestCreateWorktree_GitCryptNewBranch(t *testing.T) {
skipIfNoGitCrypt(t)
cfg := newTestConfig(t)
log := newTestLogger()
store := newMockStore()
mgr, err := NewManager(cfg, store, log)
if err != nil {
t.Fatalf("NewManager: %v", err)
}
repoPath := initGitCryptRepo(t)
wt, err := mgr.Create(context.Background(), CreateRequest{
TaskID: "gc-task-1",
SessionID: "gc-session-1",
TaskTitle: "Git Crypt New Branch",
RepositoryID: "repo-gc",
RepositoryPath: repoPath,
BaseBranch: "main",
})
if err != nil {
t.Fatalf("Create() failed: %v", err)
}
// The worktree must exist and be on a new branch.
if wt.Path == "" {
t.Fatal("worktree path is empty")
}
gotBranch := strings.TrimSpace(runGit(t, wt.Path, "rev-parse", "--abbrev-ref", "HEAD"))
if gotBranch == "main" {
t.Fatal("expected a new branch, got main")
}
// Encrypted file must be decrypted in the worktree.
secret, err := os.ReadFile(filepath.Join(wt.Path, "secret.txt"))
if err != nil {
t.Fatalf("read secret.txt: %v", err)
}
if strings.TrimSpace(string(secret)) != "top-secret-value" {
t.Errorf("secret.txt = %q, want %q", string(secret), "top-secret-value\n")
}
// Plain-text file must also be present.
pub, err := os.ReadFile(filepath.Join(wt.Path, "public.txt"))
if err != nil {
t.Fatalf("read public.txt: %v", err)
}
if strings.TrimSpace(string(pub)) != "public-value" {
t.Errorf("public.txt = %q, want %q", string(pub), "public-value\n")
}
// Working tree must be clean.
status := strings.TrimSpace(runGit(t, wt.Path, "status", "--porcelain"))
if status != "" {
t.Errorf("worktree is not clean: %s", status)
}
}
func TestCreateWorktree_GitCryptExistingBranch(t *testing.T) {
skipIfNoGitCrypt(t)
cfg := newTestConfig(t)
log := newTestLogger()
store := newMockStore()
mgr, err := NewManager(cfg, store, log)
if err != nil {
t.Fatalf("NewManager: %v", err)
}
repoPath := initGitCryptRepo(t)
wt, err := mgr.Create(context.Background(), CreateRequest{
TaskID: "gc-task-2",
SessionID: "gc-session-2",
TaskTitle: "Git Crypt Existing Branch",
RepositoryID: "repo-gc",
RepositoryPath: repoPath,
BaseBranch: "main",
CheckoutBranch: "feature/encrypted-branch",
})
if err != nil {
t.Fatalf("Create() failed: %v", err)
}
gotBranch := strings.TrimSpace(runGit(t, wt.Path, "rev-parse", "--abbrev-ref", "HEAD"))
if gotBranch != "feature/encrypted-branch" {
t.Fatalf("branch = %q, want %q", gotBranch, "feature/encrypted-branch")
}
// Encrypted file must be decrypted.
secret, err := os.ReadFile(filepath.Join(wt.Path, "secret.txt"))
if err != nil {
t.Fatalf("read secret.txt: %v", err)
}
if strings.TrimSpace(string(secret)) != "top-secret-value" {
t.Errorf("secret.txt = %q, want %q", string(secret), "top-secret-value\n")
}
status := strings.TrimSpace(runGit(t, wt.Path, "status", "--porcelain"))
if status != "" {
t.Errorf("worktree is not clean: %s", status)
}
}
func TestCreateWorktree_GitCryptDetection(t *testing.T) {
skipIfNoGitCrypt(t)
log := newTestLogger()
m := &Manager{logger: log}
repoPath := initGitCryptRepo(t)
if !m.usesGitCrypt(repoPath) {
t.Error("usesGitCrypt() should return true for a git-crypt initialised repo")
}
// Verify that the object store actually has encrypted content.
raw := runGit(t, repoPath, "show", "HEAD:secret.txt")
if strings.Contains(raw, "top-secret-value") {
t.Error("expected encrypted blob in object store, got plaintext")
}
}
func TestUnlockGitCryptAndCheckout_ManualWorktree(t *testing.T) {
skipIfNoGitCrypt(t)
log := newTestLogger()
m := &Manager{logger: log}
repoPath := initGitCryptRepo(t)
// Manually create a worktree with --no-checkout (simulates what manager does).
wtPath := filepath.Join(t.TempDir(), "manual-wt")
runGit(t, repoPath, "worktree", "add", "-b", "manual-test", "--no-checkout", wtPath, "main")
// The worktree should have no files checked out.
entries, _ := os.ReadDir(wtPath)
fileCount := 0
for _, e := range entries {
if !strings.HasPrefix(e.Name(), ".") {
fileCount++
}
}
if fileCount != 0 {
t.Fatalf("expected 0 non-dot files after --no-checkout, got %d", fileCount)
}
// Now run unlockGitCryptAndCheckout.
if err := m.unlockGitCryptAndCheckout(context.Background(), wtPath); err != nil {
t.Fatalf("unlockGitCryptAndCheckout() failed: %v", err)
}
// Verify files are checked out and decrypted.
secret, err := os.ReadFile(filepath.Join(wtPath, "secret.txt"))
if err != nil {
t.Fatalf("read secret.txt after unlock: %v", err)
}
if strings.TrimSpace(string(secret)) != "top-secret-value" {
t.Errorf("secret.txt = %q, want %q", string(secret), "top-secret-value\n")
}
pub, err := os.ReadFile(filepath.Join(wtPath, "public.txt"))
if err != nil {
t.Fatalf("read public.txt after unlock: %v", err)
}
if strings.TrimSpace(string(pub)) != "public-value" {
t.Errorf("public.txt = %q, want %q", string(pub), "public-value\n")
}
status := strings.TrimSpace(runGit(t, wtPath, "status", "--porcelain"))
if status != "" {
t.Errorf("worktree not clean after unlock+checkout: %s", status)
}
}