rdev/internal/worker/git_operations_test.go
jordan bc47e426b0 feat: Add CI pipeline proxy, DNS alias management, and worker executor system
- Add ListPipelines/GetPipeline to CIProvider port with Woodpecker adapter
- Add DNS alias endpoints: GET/POST/DELETE /projects/{id}/domains
- Implement worker executor daemon, build executor, and git operations
- Add build service, worker service, and build audit tracking
- Add worker registry with PostgreSQL adapter and migration
- Add multi-provider code agent interface (Claude Code + OpenCode)
- Add create-and-build combo endpoint
- Update landing-page cookbook to reflect all gaps closed
- Fix tech debt: unified validation, auth scopes, error wrapping, slog patterns

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 21:05:28 -07:00

416 lines
12 KiB
Go

package worker
import (
"context"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
)
func testGitOps(token string) *GitOperations {
return NewGitOperations(GitOperationsConfig{
GiteaToken: token,
GitUser: "test-user",
GitEmail: "test@example.com",
Logger: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn})),
})
}
func TestNewGitOperations_Defaults(t *testing.T) {
g := NewGitOperations(GitOperationsConfig{})
if g.gitUser != "rdev-worker" {
t.Errorf("expected default gitUser 'rdev-worker', got %q", g.gitUser)
}
if g.gitEmail != "worker@threesix.ai" {
t.Errorf("expected default gitEmail 'worker@threesix.ai', got %q", g.gitEmail)
}
if g.logger == nil {
t.Error("expected non-nil logger")
}
}
func TestNewGitOperations_CustomValues(t *testing.T) {
g := NewGitOperations(GitOperationsConfig{
GiteaToken: "my-token",
GitUser: "custom-user",
GitEmail: "custom@example.com",
})
if g.giteaToken != "my-token" {
t.Errorf("expected token 'my-token', got %q", g.giteaToken)
}
if g.gitUser != "custom-user" {
t.Errorf("expected gitUser 'custom-user', got %q", g.gitUser)
}
if g.gitEmail != "custom@example.com" {
t.Errorf("expected gitEmail 'custom@example.com', got %q", g.gitEmail)
}
}
func TestInjectToken(t *testing.T) {
tests := []struct {
name string
token string
url string
expect string
}{
{
name: "https URL with token",
token: "ghp_abc123",
url: "https://git.example.com/org/repo.git",
expect: "https://ghp_abc123@git.example.com/org/repo.git",
},
{
name: "http URL with token",
token: "ghp_abc123",
url: "http://git.example.com/org/repo.git",
expect: "http://ghp_abc123@git.example.com/org/repo.git",
},
{
name: "no token",
token: "",
url: "https://git.example.com/org/repo.git",
expect: "https://git.example.com/org/repo.git",
},
{
name: "ssh URL unchanged",
token: "ghp_abc123",
url: "git@git.example.com:org/repo.git",
expect: "git@git.example.com:org/repo.git",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := testGitOps(tt.token)
got := g.injectToken(tt.url)
if got != tt.expect {
t.Errorf("injectToken(%q) = %q, want %q", tt.url, got, tt.expect)
}
})
}
}
func TestRedactToken(t *testing.T) {
tests := []struct {
name string
token string
input string
expect string
}{
{
name: "redacts token from message",
token: "secret123",
input: "fatal: Authentication failed for 'https://secret123@git.example.com/repo.git'",
expect: "fatal: Authentication failed for 'https://[REDACTED]@git.example.com/repo.git'",
},
{
name: "no token to redact",
token: "",
input: "fatal: repository not found",
expect: "fatal: repository not found",
},
{
name: "token not present in message",
token: "secret123",
input: "fatal: repository not found",
expect: "fatal: repository not found",
},
{
name: "multiple occurrences",
token: "tok",
input: "tok appears twice: tok",
expect: "[REDACTED] appears twice: [REDACTED]",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := testGitOps(tt.token)
got := g.redactToken(tt.input)
if got != tt.expect {
t.Errorf("redactToken(%q) = %q, want %q", tt.input, got, tt.expect)
}
})
}
}
func TestEnsureGitDir(t *testing.T) {
g := testGitOps("")
t.Run("valid git directory", func(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o755); err != nil {
t.Fatal(err)
}
if err := g.EnsureGitDir(dir); err != nil {
t.Errorf("expected no error for valid git dir, got: %v", err)
}
})
t.Run("no .git directory", func(t *testing.T) {
dir := t.TempDir()
err := g.EnsureGitDir(dir)
if err == nil {
t.Error("expected error for non-git directory")
}
})
t.Run(".git is a file not directory", func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, ".git"), []byte("gitdir: .."), 0o644); err != nil {
t.Fatal(err)
}
err := g.EnsureGitDir(dir)
if err == nil {
t.Error("expected error when .git is a file")
}
})
}
// TestCommitAndPush_NoChanges tests that CommitAndPush returns nil when
// there are no staged changes in the repository.
func TestCommitAndPush_NoChanges(t *testing.T) {
g := testGitOps("")
ctx := context.Background()
// Create a real git repo with an initial commit
dir := t.TempDir()
if err := g.runGit(ctx, dir, "init"); err != nil {
t.Fatal("git init:", err)
}
if err := g.runGit(ctx, dir, "config", "user.name", "test"); err != nil {
t.Fatal("git config user.name:", err)
}
if err := g.runGit(ctx, dir, "config", "user.email", "test@test.com"); err != nil {
t.Fatal("git config user.email:", err)
}
// Create initial commit so HEAD exists
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("init"), 0o644); err != nil {
t.Fatal(err)
}
if err := g.runGit(ctx, dir, "add", "-A"); err != nil {
t.Fatal("git add:", err)
}
if err := g.runGit(ctx, dir, "commit", "-m", "initial"); err != nil {
t.Fatal("git commit:", err)
}
// No new changes — should return empty with no error
sha, files, err := g.CommitAndPush(ctx, dir, "no changes", false)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
if sha != "" {
t.Errorf("expected empty SHA, got: %q", sha)
}
if len(files) != 0 {
t.Errorf("expected no files, got: %v", files)
}
}
// TestCommitAndPush_WithChanges tests that CommitAndPush correctly stages,
// commits, and returns SHA and changed file list.
func TestCommitAndPush_WithChanges(t *testing.T) {
g := testGitOps("")
ctx := context.Background()
// Create a real git repo
dir := t.TempDir()
if err := g.runGit(ctx, dir, "init"); err != nil {
t.Fatal("git init:", err)
}
if err := g.runGit(ctx, dir, "config", "user.name", "test"); err != nil {
t.Fatal("git config user.name:", err)
}
if err := g.runGit(ctx, dir, "config", "user.email", "test@test.com"); err != nil {
t.Fatal("git config user.email:", err)
}
// Initial commit
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("init"), 0o644); err != nil {
t.Fatal(err)
}
if err := g.runGit(ctx, dir, "add", "-A"); err != nil {
t.Fatal("git add:", err)
}
if err := g.runGit(ctx, dir, "commit", "-m", "initial"); err != nil {
t.Fatal("git commit:", err)
}
// Create new files to commit
if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module test"), 0o644); err != nil {
t.Fatal(err)
}
// CommitAndPush without push (no remote)
sha, files, err := g.CommitAndPush(ctx, dir, "add go files", false)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
if sha == "" {
t.Error("expected non-empty SHA")
}
if len(sha) < 7 {
t.Errorf("expected SHA to be at least 7 chars, got: %q", sha)
}
if len(files) != 2 {
t.Errorf("expected 2 changed files, got %d: %v", len(files), files)
}
// Verify the files are in the list
fileSet := make(map[string]bool)
for _, f := range files {
fileSet[f] = true
}
if !fileSet["main.go"] {
t.Error("expected main.go in changed files")
}
if !fileSet["go.mod"] {
t.Error("expected go.mod in changed files")
}
}
// TestCommitAndPush_PushWithoutRemote tests that push fails gracefully
// when there's no remote configured.
func TestCommitAndPush_PushWithoutRemote(t *testing.T) {
g := testGitOps("")
ctx := context.Background()
dir := t.TempDir()
if err := g.runGit(ctx, dir, "init"); err != nil {
t.Fatal("git init:", err)
}
if err := g.runGit(ctx, dir, "config", "user.name", "test"); err != nil {
t.Fatal("git config:", err)
}
if err := g.runGit(ctx, dir, "config", "user.email", "test@test.com"); err != nil {
t.Fatal("git config:", err)
}
if err := os.WriteFile(filepath.Join(dir, "file.txt"), []byte("init"), 0o644); err != nil {
t.Fatal(err)
}
if err := g.runGit(ctx, dir, "add", "-A"); err != nil {
t.Fatal("git add:", err)
}
if err := g.runGit(ctx, dir, "commit", "-m", "initial"); err != nil {
t.Fatal("git commit:", err)
}
// Add a new file
if err := os.WriteFile(filepath.Join(dir, "new.txt"), []byte("new"), 0o644); err != nil {
t.Fatal(err)
}
// Push should fail (no remote) but commit succeeds — SHA is returned
sha, files, err := g.CommitAndPush(ctx, dir, "test push", true)
if err == nil {
t.Error("expected push error when no remote configured")
}
// Even though push failed, commit should have succeeded
if sha == "" {
t.Error("expected SHA from successful commit before push failure")
}
if len(files) != 1 || files[0] != "new.txt" {
t.Errorf("expected [new.txt], got: %v", files)
}
}
// TestCloneToTemp_InvalidURL tests that CloneToTemp fails on a bad URL.
func TestCloneToTemp_InvalidURL(t *testing.T) {
g := testGitOps("")
ctx := context.Background()
_, _, err := g.CloneToTemp(ctx, "https://invalid.example.com/no-such-repo.git")
if err == nil {
t.Error("expected error cloning invalid URL")
}
}
// TestCloneToTemp_LocalRepo tests cloning a local bare repository.
func TestCloneToTemp_LocalRepo(t *testing.T) {
g := testGitOps("")
ctx := context.Background()
// Create a bare repo to clone from
bareDir := t.TempDir()
if err := g.runGit(ctx, bareDir, "init", "--bare"); err != nil {
t.Fatal("git init --bare:", err)
}
// Create a source repo and push to the bare repo
srcDir := t.TempDir()
if err := g.runGit(ctx, srcDir, "init"); err != nil {
t.Fatal("git init:", err)
}
if err := g.runGit(ctx, srcDir, "config", "user.name", "test"); err != nil {
t.Fatal(err)
}
if err := g.runGit(ctx, srcDir, "config", "user.email", "test@test.com"); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(srcDir, "hello.txt"), []byte("hello"), 0o644); err != nil {
t.Fatal(err)
}
if err := g.runGit(ctx, srcDir, "add", "-A"); err != nil {
t.Fatal(err)
}
if err := g.runGit(ctx, srcDir, "commit", "-m", "initial"); err != nil {
t.Fatal(err)
}
if err := g.runGit(ctx, srcDir, "remote", "add", "origin", bareDir); err != nil {
t.Fatal(err)
}
if err := g.runGit(ctx, srcDir, "push", "origin", "master"); err != nil {
// Some git versions use "main" as default branch
if err2 := g.runGit(ctx, srcDir, "push", "origin", "main"); err2 != nil {
t.Fatalf("push failed for both master and main: master=%v, main=%v", err, err2)
}
}
// Clone the bare repo using file:// protocol
cloneDir, cleanup, err := g.CloneToTemp(ctx, "file://"+bareDir)
if err != nil {
t.Fatalf("CloneToTemp failed: %v", err)
}
defer cleanup()
// Verify the cloned file exists
content, err := os.ReadFile(filepath.Join(cloneDir, "hello.txt"))
if err != nil {
t.Fatalf("failed to read cloned file: %v", err)
}
if string(content) != "hello" {
t.Errorf("expected file content 'hello', got %q", string(content))
}
// Verify .git dir exists
if err := g.EnsureGitDir(cloneDir); err != nil {
t.Errorf("cloned dir should be a git repo: %v", err)
}
// Verify git config was set
userName, err := g.runGitOutput(ctx, cloneDir, "config", "user.name")
if err != nil {
t.Fatalf("failed to get user.name: %v", err)
}
if got := strings.TrimSpace(userName); got != "test-user" {
t.Errorf("expected user.name 'test-user', got %q", got)
}
}
func TestRunGit_ContextCancellation(t *testing.T) {
g := testGitOps("")
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
dir := t.TempDir()
err := g.runGit(ctx, dir, "status")
if err == nil {
t.Error("expected error when context is cancelled")
}
}