Gitfed
bastien-mrq/gitfed / internal / adminrpc / adminrpc_test.go
package adminrpc

import (
	"bytes"
	"os/exec"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"git.neuromancer.ovh/bastien-mrq/gitfed/internal/admin"
	"git.neuromancer.ovh/bastien-mrq/gitfed/internal/ca"
	"git.neuromancer.ovh/bastien-mrq/gitfed/internal/federation"
	"git.neuromancer.ovh/bastien-mrq/gitfed/internal/store"
)

// TestErrNotFoundSurvivesRPC reproduces a real bug: the wire only carries
// error strings, so a naive client just wrapping resp.Error in a fresh
// errors.New produces a value that will never compare equal to
// store.ErrNotFound — breaking every `err == store.ErrNotFound` check
// downstream (this exact thing 500'd gitfed-web's repo settings page,
// since GetACL on a repo with no collaborators yet returns ErrNotFound).
func TestErrNotFoundSurvivesRPC(t *testing.T) {
	dir := t.TempDir()
	st, err := store.Open(filepath.Join(dir, "gitfed.db"))
	if err != nil {
		t.Fatalf("open store: %v", err)
	}
	defer st.Close()

	localCA, err := ca.LoadOrCreate(filepath.Join(dir, "ca"))
	if err != nil {
		t.Fatalf("load CA: %v", err)
	}
	a := admin.New(st, federation.NewResolver(st, "local.test", true), "local.test", dir, localCA)
	if err := a.CreateRepo("alice/demo", "alice"); err != nil {
		t.Fatalf("create repo: %v", err)
	}
	// No collaborators granted, so the ACL bucket has no entry for this
	// repo — GetACL is expected to return store.ErrNotFound.

	socketPath := filepath.Join(dir, "admin.sock")
	srv := NewServer(a, socketPath)
	go func() { _ = srv.ListenAndServe() }()

	client := NewClient(socketPath)
	deadline := time.Now().Add(2 * time.Second)
	for {
		if client.Ping() == nil {
			break
		}
		if time.Now().After(deadline) {
			t.Fatal("server never came up")
		}
		time.Sleep(10 * time.Millisecond)
	}

	_, err = client.GetACL("alice/demo")
	if err == nil {
		t.Fatal("expected store.ErrNotFound, got nil")
	}
	if err != store.ErrNotFound {
		t.Fatalf("got error %q (%T), want the exact store.ErrNotFound sentinel — identity was lost crossing the RPC boundary", err, err)
	}
}

// git runs a plumbing command against a bare repo and returns trimmed stdout.
func gitPlumb(t *testing.T, gitDir string, stdin []byte, args ...string) string {
	t.Helper()
	cmd := exec.Command("git", append([]string{"--git-dir=" + gitDir}, args...)...)
	if stdin != nil {
		cmd.Stdin = bytes.NewReader(stdin)
	}
	var out, errBuf bytes.Buffer
	cmd.Stdout = &out
	cmd.Stderr = &errBuf
	cmd.Env = append(cmd.Environ(),
		"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
	if err := cmd.Run(); err != nil {
		t.Fatalf("git %v: %v: %s", args, err, errBuf.String())
	}
	return strings.TrimSpace(out.String())
}

// TestBinaryFileSurvivesRPC reproduces the corrupted-gif bug: fileResult
// carries content as a JSON string, and encoding/json replaces invalid UTF-8
// with U+FFFD — every image served by /repo-raw through the string-based
// GetRepoFile arrived mangled and wouldn't render. GetRepoFileRaw carries
// []byte (base64 on the wire) and must round-trip byte-for-byte.
func TestBinaryFileSurvivesRPC(t *testing.T) {
	dir := t.TempDir()
	st, err := store.Open(filepath.Join(dir, "gitfed.db"))
	if err != nil {
		t.Fatalf("open store: %v", err)
	}
	defer st.Close()

	localCA, err := ca.LoadOrCreate(filepath.Join(dir, "ca"))
	if err != nil {
		t.Fatalf("load CA: %v", err)
	}
	a := admin.New(st, federation.NewResolver(st, "local.test", true), "local.test", dir, localCA)
	if err := a.CreateRepo("alice/demo", "alice"); err != nil {
		t.Fatalf("create repo: %v", err)
	}
	repo, err := st.GetRepo("alice/demo")
	if err != nil {
		t.Fatalf("get repo: %v", err)
	}

	// A GIF89a header plus bytes that are not valid UTF-8 (0x89, 0xFF) and
	// a NUL — exactly the kind of content a JSON string cannot carry.
	binary := []byte("GIF89a\x00\x89\xff\xfe binary\x00tail")
	blob := gitPlumb(t, repo.Path, binary, "hash-object", "-w", "--stdin")
	tree := gitPlumb(t, repo.Path, []byte("100644 blob "+blob+"\tdemo.gif\n"), "mktree")
	commit := gitPlumb(t, repo.Path, nil, "commit-tree", tree, "-m", "add gif")
	gitPlumb(t, repo.Path, nil, "update-ref", "refs/heads/main", commit)

	socketPath := filepath.Join(dir, "admin.sock")
	srv := NewServer(a, socketPath)
	go func() { _ = srv.ListenAndServe() }()

	client := NewClient(socketPath)
	deadline := time.Now().Add(2 * time.Second)
	for {
		if client.Ping() == nil {
			break
		}
		if time.Now().After(deadline) {
			t.Fatal("server never came up")
		}
		time.Sleep(10 * time.Millisecond)
	}

	data, found, err := client.GetRepoFileRaw("alice/demo", "demo.gif")
	if err != nil {
		t.Fatalf("GetRepoFileRaw: %v", err)
	}
	if !found {
		t.Fatal("GetRepoFileRaw: file not found")
	}
	if !bytes.Equal(data, binary) {
		t.Fatalf("binary content corrupted crossing the RPC boundary:\n got %q\nwant %q", data, binary)
	}
}