1package tinygit23import (4 "context"5 "crypto/x509"6 "encoding/pem"7 "fmt"8 "net/http"9 "net/http/httptest"10 "os"11 "os/exec"12 "path/filepath"13 "strings"14 "testing"1516 "github.com/FelineStateMachine/tinyrelay/internal/event"17 "github.com/FelineStateMachine/tinyrelay/internal/policy"18 "github.com/FelineStateMachine/tinyrelay/internal/storage"19)2021func TestPullRequestTargetUsesEventIDForStandardNIP34(t *testing.T) {22 secret := strings.Repeat("0", 63) + "1"23 root := event.Event{Kind: event.KIND_GIT_PR, CreatedAt: 1, Content: "change", Tags: [][]string{{"a", "30617:owner:repo"}, {"c", strings.Repeat("a", 40)}, {"clone", "https://git.example/repo.git"}}}24 if err := event.Sign(&root, secret); err != nil {25 t.Fatal(err)26 }27 r, err := pullRequestTarget(root, event.KIND_GIT_PR)28 if err != nil {29 t.Fatal(err)30 }31 if r.Identifier != root.ID || r.Refs["refs/nostr/"+root.ID] != strings.Repeat("a", 40) {32 t.Fatalf("standard PR target = %#v", r)33 }34}3536func TestPullRequestUpdateRequiresRootAuthor(t *testing.T) {37 rootSecret := strings.Repeat("0", 63) + "1"38 otherSecret := strings.Repeat("0", 63) + "2"39 root := event.Event{Kind: event.KIND_GIT_PR, CreatedAt: 1, Tags: [][]string{{"a", "30617:owner:repo"}, {"c", strings.Repeat("a", 40)}, {"clone", "https://git.example/repo.git"}}}40 if err := event.Sign(&root, rootSecret); err != nil {41 t.Fatal(err)42 }43 update := event.Event{Kind: event.KIND_GIT_PR_UPDATE, CreatedAt: 2, Tags: [][]string{{"E", root.ID}, {"P", root.PubKey}, {"c", strings.Repeat("b", 40)}, {"clone", "https://git.example/repo.git"}}}44 if err := event.Sign(&update, otherSecret); err != nil {45 t.Fatal(err)46 }47 if err := validatePullRequestUpdate(root, update); err == nil {48 t.Fatal("accepted update signed by another author")49 }50}5152func TestPullRequestRepairRejectsMissingCloneBeforeGit(t *testing.T) {53 secret := strings.Repeat("0", 63) + "1"54 root := event.Event{Kind: event.KIND_GIT_PR, CreatedAt: 1, Tags: [][]string{{"a", "30617:owner:repo"}, {"c", strings.Repeat("a", 40)}}}55 owner, err := event.PublicKey(secret)56 if err != nil {57 t.Fatal(err)58 }59 root.Tags[0][1] = "30617:" + owner + ":repo"60 if err := event.Sign(&root, secret); err != nil {61 t.Fatal(err)62 }63 g := &GitRelay{}64 hosted := Repository{Owner: root.PubKey, Identifier: "repo", Refs: map[string]string{}}65 err = g.RepairPullRequestObjects(context.Background(), hosted, PullRequestRepair{Root: root})66 if err == nil || !strings.Contains(err.Error(), "no clone source") {67 t.Fatalf("missing clone error = %v", err)68 }69}7071func TestPullRequestRepairUsesHostedRepositoryRefs(t *testing.T) {72 rootDir := t.TempDir()73 remote := filepath.Join(rootDir, "remote", "repo.git")74 work := filepath.Join(rootDir, "work")75 prGitRun(t, "init", "--bare", remote)76 prGitRun(t, "init", work)77 prGitRun(t, "-C", work, "config", "user.email", "pr@example.test")78 prGitRun(t, "-C", work, "config", "user.name", "PR")79 if err := os.WriteFile(filepath.Join(work, "README"), []byte("one\n"), 0600); err != nil {80 t.Fatal(err)81 }82 prGitRun(t, "-C", work, "add", "README")83 prGitRun(t, "-C", work, "commit", "-m", "one")84 first := strings.TrimSpace(prGitOutput(t, "-C", work, "rev-parse", "HEAD"))85 prGitRun(t, "-C", work, "push", remote, "HEAD:refs/heads/main")86 if err := os.WriteFile(filepath.Join(work, "NEXT"), []byte("two\n"), 0600); err != nil {87 t.Fatal(err)88 }89 prGitRun(t, "-C", work, "add", "NEXT")90 prGitRun(t, "-C", work, "commit", "-m", "two")91 second := strings.TrimSpace(prGitOutput(t, "-C", work, "rev-parse", "HEAD"))92 prGitRun(t, "-C", work, "push", remote, "HEAD:refs/heads/pr")9394 server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { serveGitBackend(t, w, r, filepath.Dir(remote)) }))95 t.Cleanup(server.Close)96 cert, err := x509.ParseCertificate(server.Certificate().Raw)97 if err != nil {98 t.Fatal(err)99 }100 ca := filepath.Join(rootDir, "ca.pem")101 if err := os.WriteFile(ca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}), 0600); err != nil {102 t.Fatal(err)103 }104 t.Setenv("GIT_SSL_CAINFO", ca)105 store, err := storage.Open(context.Background(), filepath.Join(rootDir, "relay.db"))106 if err != nil {107 t.Fatal(err)108 }109 t.Cleanup(func() { _ = store.Close() })110 p := policy.Defaults("")111 g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(rootDir, "git"), Policy: func() Policy { return fromInternalPolicy(p) }, AllowPrivateRelays: true})112 if err != nil {113 t.Fatal(err)114 }115 secret := strings.Repeat("0", 63) + "1"116 owner, err := event.PublicKey(secret)117 if err != nil {118 t.Fatal(err)119 }120 clone := server.URL + "/repo.git"121 root := event.Event{Kind: event.KIND_GIT_PR, CreatedAt: 1, Content: "PR", Tags: [][]string{{"a", "30617:" + owner + ":repo"}, {"c", first}, {"clone", clone}}}122 if err := event.Sign(&root, secret); err != nil {123 t.Fatal(err)124 }125 update := event.Event{Kind: event.KIND_GIT_PR_UPDATE, CreatedAt: 2, Tags: [][]string{{"E", root.ID}, {"P", owner}, {"c", second}, {"clone", clone}}}126 if err := event.Sign(&update, secret); err != nil {127 t.Fatal(err)128 }129 hosted := Repository{Owner: owner, Identifier: "repo", Clone: []string{clone}, Refs: map[string]string{"refs/heads/main": first}}130 if err := g.FetchMissing(context.Background(), hosted, hosted.Clone, hosted.Refs); err != nil {131 t.Fatal(err)132 }133 if g.objectsPresent(context.Background(), hosted, []string{second}) {134 t.Fatal("PR update was already present before repair; test must exercise a real fetch")135 }136 if err := g.RepairPullRequestObjects(context.Background(), hosted, PullRequestRepair{Root: root, Updates: []event.Event{update}}); err != nil {137 t.Fatal(err)138 }139 gitDir := filepath.Join(rootDir, "git", owner, "repo.git")140 for ref, want := range map[string]string{"refs/heads/main": first, "refs/nostr/" + root.ID: first, "refs/nostr/" + update.ID: second} {141 got := strings.TrimSpace(prGitOutput(t, "--git-dir", gitDir, "rev-parse", ref))142 if got != want {143 t.Fatalf("%s = %s, want %s", ref, got, want)144 }145 }146 if _, err := os.Stat(filepath.Join(gitDir, "tinyrelay.refs")); !os.IsNotExist(err) {147 t.Fatalf("PR repair wrote signed state file: %v", err)148 }149 quarantines, err := filepath.Glob(filepath.Join(rootDir, "git", "prs", owner, "repo.repair-"+root.ID+"-*.git"))150 if err != nil {151 t.Fatal(err)152 }153 if len(quarantines) != 0 {154 t.Fatalf("PR repair quarantine was not removed: %v", quarantines)155 }156 blockedRoot := root157 blockedRoot.CreatedAt = 3158 if err := event.Sign(&blockedRoot, secret); err != nil {159 t.Fatal(err)160 }161 blockedUpdate := event.Event{Kind: event.KIND_GIT_PR_UPDATE, CreatedAt: 4, Tags: [][]string{{"E", blockedRoot.ID}, {"P", owner}, {"c", second}, {"clone", clone}}}162 if err := event.Sign(&blockedUpdate, secret); err != nil {163 t.Fatal(err)164 }165 // A ref-directory collision makes one update fail. The other signed ref166 // must not appear, regardless of map iteration or transaction order.167 blocker := "refs/nostr/" + blockedRoot.ID + "/child"168 prGitRun(t, "--git-dir", gitDir, "update-ref", blocker, first)169 if err := g.RepairPullRequestObjects(context.Background(), hosted, PullRequestRepair{Root: blockedRoot, Updates: []event.Event{blockedUpdate}}); err == nil {170 t.Fatal("repair ignored conflicting ref")171 }172 if err := exec.Command("git", "--git-dir", gitDir, "show-ref", "--verify", "--quiet", "refs/nostr/"+blockedUpdate.ID).Run(); err == nil {173 t.Fatal("failed repair partially promoted its signed refs")174 }175 if got := strings.TrimSpace(prGitOutput(t, "--git-dir", gitDir, "rev-parse", blocker)); got != first {176 t.Fatalf("repair changed blocking ref: %s", got)177 }178 if out := prGitOutput(t, "--git-dir", gitDir, "for-each-ref", "refs/tinyrelay/pr-repair/"); strings.TrimSpace(out) != "" {179 t.Fatalf("failed repair left temporary refs: %s", out)180 }181}182183func TestPullRequestRepairFailureLeavesHostedRefsUnchanged(t *testing.T) {184 secret := strings.Repeat("0", 63) + "1"185 owner, err := event.PublicKey(secret)186 if err != nil {187 t.Fatal(err)188 }189 rootDir := t.TempDir()190 hosted := Repository{Owner: owner, Identifier: "repo", Refs: map[string]string{191 "refs/heads/main": strings.Repeat("a", 40),192 }}193 g := &GitRelay{root: rootDir, allowPrivate: true, configured: make(map[string]struct{})}194 root := event.Event{Kind: event.KIND_GIT_PR, CreatedAt: 1, PubKey: owner,195 Tags: [][]string{{"a", "30617:" + owner + ":repo"}, {"c", strings.Repeat("b", 40)}, {"clone", "http://127.0.0.1:1/repo.git"}}}196 if err := event.Sign(&root, secret); err != nil {197 t.Fatal(err)198 }199 err = g.RepairPullRequestObjects(context.Background(), hosted, PullRequestRepair{Root: root})200 if err == nil {201 t.Fatal("repair unexpectedly succeeded from unavailable source")202 }203 if _, err := os.Stat(g.repoPath(hosted)); !os.IsNotExist(err) {204 // ensureRepo is allowed to create the host, but no signed ref may be205 // installed after a failed quarantine admission.206 got, _ := exec.Command("git", "--git-dir", g.repoPath(hosted), "show-ref", "refs/nostr/"+root.ID).CombinedOutput()207 if len(got) != 0 {208 t.Fatalf("failed repair installed hosted PR ref: %s", got)209 }210 }211}212213func TestPRQuarantineRejectsHistoryBeyondDepth(t *testing.T) {214 root := t.TempDir()215 work := filepath.Join(root, "work")216 remote := filepath.Join(root, "remote.git")217 prGitRun(t, "init", work)218 prGitRun(t, "-C", work, "config", "user.email", "pr@example.test")219 prGitRun(t, "-C", work, "config", "user.name", "PR")220 for i := 0; i < prRepairDepth+1; i++ {221 if err := os.WriteFile(filepath.Join(work, "history"), []byte(fmt.Sprintf("%d\n", i)), 0600); err != nil {222 t.Fatal(err)223 }224 prGitRun(t, "-C", work, "add", "history")225 prGitRun(t, "-C", work, "commit", "-m", fmt.Sprintf("commit %d", i))226 }227 tip := strings.TrimSpace(prGitOutput(t, "-C", work, "rev-parse", "HEAD"))228 base := strings.TrimSpace(prGitOutput(t, "-C", work, "rev-parse", "HEAD~1"))229 prGitRun(t, "init", "--bare", remote)230 prGitRun(t, "-C", work, "push", remote, "HEAD:refs/heads/main")231 server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { serveGitBackend(t, w, r, root) }))232 t.Cleanup(server.Close)233 ca := filepath.Join(root, "ca.pem")234 if err := os.WriteFile(ca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}), 0600); err != nil {235 t.Fatal(err)236 }237 t.Setenv("GIT_SSL_CAINFO", ca)238 secret := strings.Repeat("0", 63) + "1"239 owner, err := event.PublicKey(secret)240 if err != nil {241 t.Fatal(err)242 }243 hosted := Repository{Owner: owner, Identifier: "repo"}244 g := &GitRelay{root: filepath.Join(root, "hosted"), allowPrivate: true, configured: make(map[string]struct{})}245 pr := event.Event{Kind: event.KIND_GIT_PR, CreatedAt: 1, Tags: [][]string{{"a", "30617:" + owner + ":repo"}, {"c", tip}, {"clone", server.URL + "/remote.git"}}}246 if err := event.Sign(&pr, secret); err != nil {247 t.Fatal(err)248 }249 if err := g.RepairPullRequestObjects(context.Background(), hosted, PullRequestRepair{Root: pr}); err == nil || !strings.Contains(err.Error(), "object graph") {250 t.Fatalf("expected incomplete object graph rejection, got %v", err)251 }252 if g.objectsPresent(context.Background(), hosted, []string{tip}) {253 t.Fatal("rejected objects reached the hosted repository")254 }255 gitDir := g.repoPath(hosted)256 if _, err := os.Stat(filepath.Join(gitDir, "shallow")); !os.IsNotExist(err) {257 t.Fatalf("hosted shallow boundary changed: %v", err)258 }259 if out := prGitOutput(t, "--git-dir", gitDir, "for-each-ref", "refs/nostr/"); strings.TrimSpace(out) != "" {260 t.Fatalf("rejected PR installed refs: %s", out)261 }262 // Once the existing base is hosted, the exact same PR must succeed even263 // though its complete ancestry extends beyond the remote depth window.264 prGitRun(t, "--git-dir", gitDir, "fetch", work, base+":refs/heads/main")265 if err := g.RepairPullRequestObjects(context.Background(), hosted, PullRequestRepair{Root: pr}); err != nil {266 t.Fatalf("PR with hosted base failed: %v", err)267 }268 if got := strings.TrimSpace(prGitOutput(t, "--git-dir", gitDir, "rev-parse", "refs/nostr/"+pr.ID)); got != tip {269 t.Fatalf("PR tip = %s, want %s", got, tip)270 }271 prGitRun(t, "--git-dir", gitDir, "fsck", "--full", "--no-dangling")272 if _, err := os.Stat(filepath.Join(gitDir, "shallow")); !os.IsNotExist(err) {273 t.Fatalf("successful repair made host shallow: %v", err)274 }275}276277func TestPrivatePolicyDoesNotContactUnconfiguredRepairSource(t *testing.T) {278 var requests int279 server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {280 requests++281 w.WriteHeader(http.StatusInternalServerError)282 }))283 t.Cleanup(server.Close)284 root := t.TempDir()285 cert, err := x509.ParseCertificate(server.Certificate().Raw)286 if err != nil {287 t.Fatal(err)288 }289 ca := filepath.Join(root, "ca.pem")290 if err := os.WriteFile(ca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}), 0600); err != nil {291 t.Fatal(err)292 }293 t.Setenv("GIT_SSL_CAINFO", ca)294 store, err := storage.Open(context.Background(), filepath.Join(root, "relay.db"))295 if err != nil {296 t.Fatal(err)297 }298 t.Cleanup(func() { _ = store.Close() })299 p := policy.Defaults("")300 p.Reads = "members"301 g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git"), Policy: func() Policy { return fromInternalPolicy(p) }, AllowPrivateRelays: true})302 if err != nil {303 t.Fatal(err)304 }305 repo := Repository{Owner: strings.Repeat("a", 64), Identifier: "private", Clone: []string{server.URL + "/repo.git"}, Refs: map[string]string{"refs/heads/main": strings.Repeat("b", 40)}}306 err = g.FetchMissing(context.Background(), repo, repo.Clone, repo.Refs)307 if err == nil {308 t.Fatal("private-policy repair unexpectedly succeeded")309 }310 if requests != 0 {311 t.Fatalf("private-policy repair contacted unconfigured source %d time(s)", requests)312 }313}314315func TestGRASPTickResumesAfterCanceledRepository(t *testing.T) {316 p := policy.Policy{Features: policy.Features{Grasp: true, Grasp02: true}}317 owner := strings.Repeat("a", 64)318 first := Repository{Owner: owner, Identifier: "a", Refs: map[string]string{}}319 second := Repository{Owner: owner, Identifier: "b", Refs: map[string]string{}}320 var calls []string321 var cancel context.CancelFunc322 g := &GitRelay{323 root: t.TempDir(),324 policy: func() Policy { return fromInternalPolicy(p) },325 repos: map[string]Repository{key(first.Owner, first.Identifier): first, key(second.Owner, second.Identifier): second},326 gitSync: func(ctx context.Context, repo Repository) error {327 calls = append(calls, repo.Identifier)328 if len(calls) == 1 {329 cancel()330 return ctx.Err()331 }332 return nil333 },334 }335 ctx, done := context.WithCancel(context.Background())336 cancel = done337 if err := g.GRASPService().Tick(ctx); err == nil {338 t.Fatal("canceled GRASP tick unexpectedly succeeded")339 }340 status, err := g.GRASPService().SyncStatus(context.Background())341 if err != nil {342 t.Fatal(err)343 }344 if _, ok := status.Repositories[key(second.Owner, second.Identifier)]; ok {345 t.Fatalf("unattempted repository received a status: %#v", status.Repositories)346 }347 if err := g.GRASPService().Tick(context.Background()); err != nil {348 t.Fatal(err)349 }350 status, err = g.GRASPService().SyncStatus(context.Background())351 if err != nil {352 t.Fatal(err)353 }354 if status.Repositories[key(second.Owner, second.Identifier)].SucceededAt == 0 {355 t.Fatalf("resumed repository was not attempted: %#v", status.Repositories)356 }357 if len(calls) != 2 || calls[1] != "b" {358 t.Fatalf("resume order = %v, want [a b]", calls)359 }360}361362func prGitRun(t *testing.T, args ...string) {363 t.Helper()364 if out, err := exec.Command("git", args...).CombinedOutput(); err != nil {365 t.Fatalf("git %v: %v: %s", args, err, out)366 }367}368369func prGitOutput(t *testing.T, args ...string) string {370 t.Helper()371 out, err := exec.Command("git", args...).Output()372 if err != nil {373 t.Fatal(err)374 }375 return string(out)376}