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)
}
}