clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

main
tinyrelay / tinygit / gitrelay_test.go
gitrelay_test.go 30.3K
1package tinygit23import (4 "context"5 "encoding/json"6 "errors"7 "io"8 "net/http"9 "net/http/httptest"10 "os"11 "os/exec"12 "path/filepath"13 "strings"14 "testing"15 "time"1617 "github.com/FelineStateMachine/tinyrelay/internal/event"18 "github.com/FelineStateMachine/tinyrelay/internal/policy"19 "github.com/FelineStateMachine/tinyrelay/internal/storage"20)2122func TestParsePathAndNpub(t *testing.T) {23 if _, _, _, ok := parsePath("/not-a-repo"); ok {24 t.Fatal("accepted malformed repository path")25 }26 // This is the bech32 encoding of 32 zero bytes.27 npub := "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq"28 if _, err := decodeNPub(npub); err == nil {29 t.Fatal("accepted an invalid checksum")30 }31}3233func TestPublishStagesRepositoryAndSignedStateHook(t *testing.T) {34 root := t.TempDir()35 store, err := storage.Open(context.Background(), filepath.Join(root, "relay.db"))36 if err != nil {37 t.Fatal(err)38 }39 defer store.Close()40 g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git")})41 if err != nil {42 t.Fatal(err)43 }44 secret := strings.Repeat("0", 63) + "1"45 owner, err := event.PublicKey(secret)46 if err != nil {47 t.Fatal(err)48 }49 announcement := event.Event{Kind: 30617, CreatedAt: 100, Tags: [][]string{{"d", "demo"}, {"clone", "https://relay.example/" + owner + "/demo.git"}}}50 if err := event.Sign(&announcement, secret); err != nil {51 t.Fatal(err)52 }53 if err := g.Publish(context.Background(), announcement); err != nil {54 t.Fatal(err)55 }56 state := event.Event{Kind: 30618, CreatedAt: 101, Tags: [][]string{{"d", "demo"}, {"HEAD", "ref: refs/heads/main"}, {"refs/heads/main", strings.Repeat("a", 40)}}}57 if err := event.Sign(&state, secret); err != nil {58 t.Fatal(err)59 }60 if err := g.Publish(context.Background(), state); err != nil {61 t.Fatal(err)62 }63 repo := filepath.Join(root, "git", owner, "demo.git")64 if _, err := os.Stat(filepath.Join(repo, "HEAD")); err != nil {65 t.Fatalf("bare repository missing: %v", err)66 }67 hook, err := os.ReadFile(filepath.Join(repo, "hooks", "pre-receive"))68 if err != nil || !strings.Contains(string(hook), "tinyrelay.refs") {69 t.Fatalf("state hook missing: %v", err)70 }71 refs, err := os.ReadFile(filepath.Join(repo, "tinyrelay.pending"))72 if err != nil || !strings.Contains(string(refs), "refs/heads/main "+strings.Repeat("a", 40)) {73 t.Fatalf("signed state was not staged: %v", err)74 }75}7677func TestStateHEADSelectsNonMainBranch(t *testing.T) {78 root := t.TempDir()79 store, err := storage.Open(context.Background(), filepath.Join(root, "relay.db"))80 if err != nil {81 t.Fatal(err)82 }83 defer store.Close()84 g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git")})85 if err != nil {86 t.Fatal(err)87 }88 secret := strings.Repeat("0", 63) + "1"89 owner, _ := event.PublicKey(secret)90 announcement := event.Event{Kind: 30617, CreatedAt: 100, Tags: [][]string{{"d", "feature"}}}91 if err := event.Sign(&announcement, secret); err != nil {92 t.Fatal(err)93 }94 if err := g.Publish(context.Background(), announcement); err != nil {95 t.Fatal(err)96 }97 state := event.Event{Kind: 30618, CreatedAt: 101, Tags: [][]string{{"d", "feature"}, {"HEAD", "ref: refs/heads/feat/atlas"}}}98 if err := event.Sign(&state, secret); err != nil {99 t.Fatal(err)100 }101 if err := g.Publish(context.Background(), state); err != nil {102 t.Fatal(err)103 }104 repo := filepath.Join(root, "git", owner, "feature.git")105 out, err := exec.Command("git", "--git-dir", repo, "symbolic-ref", "HEAD").Output()106 if err != nil {107 t.Fatal(err)108 }109 if strings.TrimSpace(string(out)) != "refs/heads/feat/atlas" {110 t.Fatalf("HEAD=%q", strings.TrimSpace(string(out)))111 }112}113114func TestStateProjectsExistingObjectsIntoNativeRefs(t *testing.T) {115 root := t.TempDir()116 store, err := storage.Open(context.Background(), filepath.Join(root, "relay.db"))117 if err != nil {118 t.Fatal(err)119 }120 defer store.Close()121 g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git")})122 if err != nil {123 t.Fatal(err)124 }125 secret := strings.Repeat("0", 63) + "1"126 owner, _ := event.PublicKey(secret)127 ann := event.Event{Kind: 30617, CreatedAt: 1, Tags: [][]string{{"d", "existing"}}}128 if err := event.Sign(&ann, secret); err != nil {129 t.Fatal(err)130 }131 if err := g.Publish(context.Background(), ann); err != nil {132 t.Fatal(err)133 }134 repo := filepath.Join(root, "git", owner, "existing.git")135 tree, err := exec.Command("git", "--git-dir", repo, "mktree").Output()136 if err != nil {137 t.Fatal(err)138 }139 commitCmd := exec.Command("git", "--git-dir", repo, "commit-tree", strings.TrimSpace(string(tree)), "-m", "one")140 commitCmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com", "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com")141 commit, err := commitCmd.Output()142 if err != nil {143 t.Fatal(err)144 }145 oid := strings.TrimSpace(string(commit))146 state := event.Event{Kind: 30618, CreatedAt: 2, Tags: [][]string{{"d", "existing"}, {"HEAD", "ref: refs/heads/main"}, {"refs/heads/main", oid}}}147 if err := event.Sign(&state, secret); err != nil {148 t.Fatal(err)149 }150 if err := g.Publish(context.Background(), state); err != nil {151 t.Fatal(err)152 }153 if out, err := exec.Command("git", "ls-remote", repo).CombinedOutput(); err != nil || !strings.Contains(string(out), oid+"\trefs/heads/main") {154 t.Fatalf("ls-remote did not advertise signed state: %s (%v)", out, err)155 }156 ref, err := exec.Command("git", "--git-dir", repo, "rev-parse", "refs/heads/main").Output()157 if err != nil || strings.TrimSpace(string(ref)) != oid {158 t.Fatalf("native ref not projected: %s (%v)", ref, err)159 }160161 state2 := event.Event{Kind: 30618, CreatedAt: 3, Tags: [][]string{{"d", "existing"}, {"HEAD", "ref: refs/heads/next"}, {"refs/heads/next", oid}}}162 if err := event.Sign(&state2, secret); err != nil {163 t.Fatal(err)164 }165 if err := g.Publish(context.Background(), state2); err != nil {166 t.Fatal(err)167 }168 if out, err := exec.Command("git", "ls-remote", repo).CombinedOutput(); err != nil || strings.Contains(string(out), "refs/heads/main") || !strings.Contains(string(out), oid+"\trefs/heads/next") {169 t.Fatalf("ls-remote after state replacement = %s (%v)", out, err)170 }171 refs, err := exec.Command("git", "--git-dir", repo, "for-each-ref", "--format=%(refname)", "refs/heads").Output()172 if err != nil {173 t.Fatal(err)174 }175 if got := strings.TrimSpace(string(refs)); got != "refs/heads/next" {176 t.Fatalf("native refs after replacement = %q", got)177 }178 head, err := exec.Command("git", "--git-dir", repo, "symbolic-ref", "HEAD").Output()179 if err != nil || strings.TrimSpace(string(head)) != "refs/heads/next" {180 t.Fatalf("HEAD after replacement = %q (%v)", strings.TrimSpace(string(head)), err)181 }182}183184func TestPendingPromotionRemovesRefsDroppedByState(t *testing.T) {185 root := t.TempDir()186 store, err := storage.Open(context.Background(), filepath.Join(root, "relay.db"))187 if err != nil {188 t.Fatal(err)189 }190 defer store.Close()191 g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git")})192 if err != nil {193 t.Fatal(err)194 }195 r := Repository{Owner: strings.Repeat("a", 64), Identifier: "pending"}196 if err := g.ensureRepo(r); err != nil {197 t.Fatal(err)198 }199 tree, err := exec.Command("git", "--git-dir", g.repoPath(r), "mktree").Output()200 if err != nil {201 t.Fatal(err)202 }203 objects := make([]string, 2)204 for i, message := range []string{"old", "new"} {205 cmd := exec.Command("git", "--git-dir", g.repoPath(r), "commit-tree", strings.TrimSpace(string(tree)), "-m", message)206 cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com", "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com")207 out, err := cmd.Output()208 if err != nil {209 t.Fatal(err)210 }211 objects[i] = strings.TrimSpace(string(out))212 }213 old := Repository{Owner: r.Owner, Identifier: r.Identifier, Refs: map[string]string{"refs/heads/old": objects[0]}}214 if err := g.writeState(old); err != nil {215 t.Fatal(err)216 }217 next := Repository{Owner: r.Owner, Identifier: r.Identifier, Refs: map[string]string{"refs/heads/new": objects[1]}}218 if err := g.writePendingState(next); err != nil {219 t.Fatal(err)220 }221 if err := os.Rename(g.pendingStatePath(next), g.statePath(next)); err != nil {222 t.Fatal(err)223 }224 if err := g.writeState(next); err != nil {225 t.Fatal(err)226 }227 refs, err := exec.Command("git", "--git-dir", g.repoPath(r), "for-each-ref", "--format=%(refname)", "refs/heads").Output()228 if err != nil {229 t.Fatal(err)230 }231 if got := strings.TrimSpace(string(refs)); got != "refs/heads/new" {232 t.Fatalf("native refs after pending promotion = %q", got)233 }234}235236func TestStaleStateWorkerCannotRewindHEAD(t *testing.T) {237 root := t.TempDir()238 store, err := storage.Open(context.Background(), filepath.Join(root, "relay.db"))239 if err != nil {240 t.Fatal(err)241 }242 defer store.Close()243 g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git")})244 if err != nil {245 t.Fatal(err)246 }247 secret := strings.Repeat("0", 63) + "1"248 owner, _ := event.PublicKey(secret)249 ann := event.Event{Kind: 30617, CreatedAt: 1, Tags: [][]string{{"d", "rewind"}}}250 if err := event.Sign(&ann, secret); err != nil {251 t.Fatal(err)252 }253 if err := g.Publish(context.Background(), ann); err != nil {254 t.Fatal(err)255 }256 first := event.Event{Kind: 30618, CreatedAt: 2, Tags: [][]string{{"d", "rewind"}, {"HEAD", "ref: refs/heads/first"}}}257 second := event.Event{Kind: 30618, CreatedAt: 3, Tags: [][]string{{"d", "rewind"}, {"HEAD", "ref: refs/heads/second"}}}258 for _, item := range []*event.Event{&first, &second} {259 if err := event.Sign(item, secret); err != nil {260 t.Fatal(err)261 }262 if _, err := store.Save(context.Background(), *item, storage.SaveOptions{Now: item.CreatedAt}); err != nil {263 t.Fatal(err)264 }265 }266 r1, err := g.parseRepository(first)267 if err != nil {268 t.Fatal(err)269 }270 r2, err := g.parseRepository(second)271 if err != nil {272 t.Fatal(err)273 }274 if err := g.CommitAfterStoreNoNotify(context.Background(), second, r2); err != nil {275 t.Fatal(err)276 }277 if err := g.CommitAfterStoreNoNotify(context.Background(), first, r1); err != nil {278 t.Fatal(err)279 }280 repo := filepath.Join(root, "git", owner, "rewind.git")281 head, err := exec.Command("git", "--git-dir", repo, "symbolic-ref", "HEAD").Output()282 if err != nil {283 t.Fatal(err)284 }285 if strings.TrimSpace(string(head)) != "refs/heads/second" {286 t.Fatalf("stale worker rewound HEAD to %q", strings.TrimSpace(string(head)))287 }288}289290func TestObjectsPresentBatchesManyRefsAndDetectsMissing(t *testing.T) {291 root := t.TempDir()292 store, err := storage.Open(context.Background(), filepath.Join(root, "relay.db"))293 if err != nil {294 t.Fatal(err)295 }296 defer store.Close()297 g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git")})298 if err != nil {299 t.Fatal(err)300 }301 r := Repository{Owner: strings.Repeat("a", 64), Identifier: "many"}302 if err := g.ensureRepo(r); err != nil {303 t.Fatal(err)304 }305 var objects []string306 for i := 0; i < 128; i++ {307 cmd := exec.Command("git", "--git-dir", g.repoPath(r), "hash-object", "-w", "--stdin")308 cmd.Stdin = strings.NewReader(strings.Repeat("object", i+1))309 id, err := cmd.Output()310 if err != nil {311 t.Fatal(err)312 }313 objects = append(objects, strings.TrimSpace(string(id)))314 }315 if !g.objectsPresent(context.Background(), r, objects) {316 t.Fatal("batched object check rejected present objects")317 }318 objects = append(objects, strings.Repeat("f", 40))319 if g.objectsPresent(context.Background(), r, objects) {320 t.Fatal("batched object check accepted a missing object")321 }322}323324func TestAdmitSourceRejectsUnsafeEndpoints(t *testing.T) {325 root := t.TempDir()326 store, err := storage.Open(context.Background(), filepath.Join(root, "relay.db"))327 if err != nil {328 t.Fatal(err)329 }330 defer store.Close()331 g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git"), ServiceURL: "https://relay.example"})332 if err != nil {333 t.Fatal(err)334 }335 for _, raw := range []string{"http://example.com/repo.git", "https://localhost/repo.git", "https://127.0.0.1/repo.git", "https://user@example.com/repo.git", "https://relay.example/"} {336 if _, err := g.AdmitSource(raw); err == nil {337 t.Errorf("accepted unsafe source %q", raw)338 }339 }340 got, err := g.AdmitSource("https://git.example/repo.git/")341 if err != nil || got != "https://git.example/repo.git" {342 t.Fatalf("admitted source = %q, %v", got, err)343 }344}345346func TestAlternativePRPublicationUsesSeparateRepository(t *testing.T) {347 root := t.TempDir()348 store, err := storage.Open(context.Background(), filepath.Join(root, "relay.db"))349 if err != nil {350 t.Fatal(err)351 }352 defer store.Close()353 g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git"), EnableGRASP06: true})354 if err != nil {355 t.Fatal(err)356 }357 secret := strings.Repeat("0", 63) + "1"358 owner, err := event.PublicKey(secret)359 if err != nil {360 t.Fatal(err)361 }362 object := "pending PR object"363 hashCommand := exec.Command("git", "hash-object", "--stdin")364 hashCommand.Stdin = strings.NewReader(object)365 hashOutput, err := hashCommand.Output()366 if err != nil {367 t.Fatal(err)368 }369 hash := strings.TrimSpace(string(hashOutput))370 pr := event.Event{Kind: 1617, CreatedAt: 100, Tags: [][]string{{"d", "demo"}, {"c", hash}}}371 if err := event.Sign(&pr, secret); err != nil {372 t.Fatal(err)373 }374 if err := g.Publish(context.Background(), pr); err != nil {375 t.Fatal(err)376 }377 if _, err := os.Stat(filepath.Join(root, "git", "prs", owner, "demo.git", "HEAD")); err != nil {378 t.Fatal(err)379 }380 prRepo := filepath.Join(root, "git", "prs", owner, "demo.git")381 if _, err := os.Stat(filepath.Join(prRepo, "tinyrelay.pending")); err != nil {382 t.Fatalf("missing-object PR was not retained in pending state: %v", err)383 }384 if refs, err := exec.Command("git", "--git-dir", prRepo, "for-each-ref", "refs/nostr").Output(); err != nil {385 t.Fatal(err)386 } else if len(strings.TrimSpace(string(refs))) != 0 {387 t.Fatalf("pending PR ref became visible: %s", refs)388 }389 if err := g.Reload(context.Background()); err != nil {390 t.Fatal(err)391 }392 if !g.IsPending(pr.ID) {393 t.Fatal("pending PR was not recovered after reload")394 }395 storeObject := exec.Command("git", "--git-dir", prRepo, "hash-object", "-w", "--stdin")396 storeObject.Stdin = strings.NewReader(object)397 if out, err := storeObject.CombinedOutput(); err != nil {398 t.Fatalf("store PR object: %v: %s", err, out)399 }400 ref := "refs/nostr/" + pr.ID401 if out, err := exec.Command("git", "--git-dir", prRepo, "update-ref", ref, hash).CombinedOutput(); err != nil {402 t.Fatalf("install PR ref: %v: %s", err, out)403 }404 hook := filepath.Join(prRepo, "hooks", "post-receive")405 cmd := exec.Command("sh", hook)406 cmd.Dir = prRepo407 cmd.Env = append(os.Environ(), "GIT_DIR="+prRepo)408 cmd.Stdin = strings.NewReader("0 " + hash + " " + ref + "\n")409 if out, err := cmd.CombinedOutput(); err != nil {410 t.Fatalf("post-receive promotion: %v: %s", err, out)411 }412 got, err := exec.Command("git", "--git-dir", prRepo, "rev-parse", ref).Output()413 if err != nil || strings.TrimSpace(string(got)) != hash {414 t.Fatalf("promoted PR ref = %q, %v", got, err)415 }416 content, err := exec.Command("git", "--git-dir", prRepo, "cat-file", "blob", hash).Output()417 if err != nil || strings.TrimSpace(string(content)) != object {418 t.Fatalf("promoted PR object content = %q, %v", content, err)419 }420 if len(g.Capabilities()) != 2 || g.Capabilities()[1] != "GRASP-06" {421 t.Fatalf("capabilities = %#v", g.Capabilities())422 }423}424425func TestJournalRecoveryCompletesStateFile(t *testing.T) {426 root := t.TempDir()427 store, err := storage.Open(context.Background(), filepath.Join(root, "relay.db"))428 if err != nil {429 t.Fatal(err)430 }431 defer store.Close()432 g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git")})433 if err != nil {434 t.Fatal(err)435 }436 secret := strings.Repeat("0", 63) + "1"437 owner, err := event.PublicKey(secret)438 if err != nil {439 t.Fatal(err)440 }441 state := event.Event{Kind: 30618, CreatedAt: 1, Tags: [][]string{{"d", "demo"}, {"refs/heads/main", strings.Repeat("c", 40)}}}442 if err := event.Sign(&state, secret); err != nil {443 t.Fatal(err)444 }445 if _, err := store.Save(context.Background(), state, storage.SaveOptions{Now: 1}); err != nil {446 t.Fatal(err)447 }448 r := Repository{Owner: owner, Identifier: "demo", Refs: map[string]string{"refs/heads/main": strings.Repeat("c", 40)}}449 jr := journalRecord{Repository: key(r.Owner, r.Identifier), EventID: state.ID, Kind: 30618, Refs: r.Refs}450 if err := g.writeJournal(r, jr); err != nil {451 t.Fatal(err)452 }453 if _, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git")}); err != nil {454 t.Fatal(err)455 }456 b, err := os.ReadFile(filepath.Join(root, "git", r.Owner, "demo.git", "tinyrelay.pending"))457 if err != nil || !strings.Contains(string(b), "refs/heads/main "+strings.Repeat("c", 40)) {458 t.Fatalf("recovery did not restore state: %q %v", b, err)459 }460}461462func TestNewRebuildsRepositoriesAfterRestart(t *testing.T) {463 root := t.TempDir()464 store, err := storage.Open(context.Background(), filepath.Join(root, "relay.db"))465 if err != nil {466 t.Fatal(err)467 }468 defer store.Close()469 secret := strings.Repeat("0", 63) + "1"470 owner, _ := event.PublicKey(secret)471 g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git")})472 if err != nil {473 t.Fatal(err)474 }475 announcement := event.Event{Kind: 30617, CreatedAt: 1, Tags: [][]string{{"d", "restart"}}}476 if err := event.Sign(&announcement, secret); err != nil {477 t.Fatal(err)478 }479 if err := g.Publish(context.Background(), announcement); err != nil {480 t.Fatal(err)481 }482 repoPath := filepath.Join(root, "git", owner, "restart.git")483 blobCmd := exec.Command("git", "--git-dir", repoPath, "hash-object", "-w", "--stdin")484 blobCmd.Stdin = strings.NewReader("restart content\n")485 content, err := blobCmd.Output()486 if err != nil {487 t.Fatal(err)488 }489 treeCmd := exec.Command("git", "--git-dir", repoPath, "mktree")490 treeCmd.Stdin = strings.NewReader("100644 blob " + strings.TrimSpace(string(content)) + "\tREADME\n")491 tree, err := treeCmd.Output()492 if err != nil {493 t.Fatal(err)494 }495 commitCmd := exec.Command("git", "--git-dir", repoPath, "commit-tree", strings.TrimSpace(string(tree)), "-m", "restart")496 commitCmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com", "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com")497 commit, err := commitCmd.Output()498 if err != nil {499 t.Fatal(err)500 }501 state := event.Event{Kind: 30618, CreatedAt: 2, Tags: [][]string{{"d", "restart"}, {"HEAD", "ref: refs/heads/main"}, {"refs/heads/main", strings.TrimSpace(string(commit))}}}502 if err := event.Sign(&state, secret); err != nil {503 t.Fatal(err)504 }505 if err := g.Publish(context.Background(), state); err != nil {506 t.Fatal(err)507 }508 if out, err := exec.Command("git", "--git-dir", repoPath, "update-ref", "refs/heads/unannounced", strings.TrimSpace(string(commit))).CombinedOutput(); err != nil {509 t.Fatalf("stage unannounced ref: %v %s", err, out)510 }511 restarted, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git")})512 if err != nil {513 t.Fatal(err)514 }515 r, err := restarted.lookup(owner, "restart")516 if err != nil {517 t.Fatalf("repository missing after New restart: %v", err)518 }519 if r.Refs["refs/heads/main"] != strings.TrimSpace(string(commit)) || r.Head != "ref: refs/heads/main" {520 t.Fatalf("signed state missing after restart: %#v", r)521 }522 if _, visible := r.Refs["refs/heads/unannounced"]; visible {523 t.Fatal("restart exposed a ref outside published signed authority")524 }525 page, err := restarted.Browse(context.Background(), BrowseRequest{Owner: owner, Repo: "restart", View: "file", Path: "README"})526 if err != nil || page.Content != "restart content\n" {527 t.Fatalf("browser content after restart = %q, %v", page.Content, err)528 }529 readme, err := exec.Command("git", "--git-dir", repoPath, "show", "refs/heads/main:README").Output()530 if err != nil || string(readme) != "restart content\n" {531 t.Fatalf("restarted content = %q, %v", readme, err)532 }533}534535func TestGRASPServiceTickPersistsProgressAndRepairOrder(t *testing.T) {536 root := t.TempDir()537 store, err := storage.Open(context.Background(), filepath.Join(root, "relay.db"))538 if err != nil {539 t.Fatal(err)540 }541 defer store.Close()542 called := 0543 p := policy.Defaults("")544 p.Features.Grasp = true545 p.Features.Grasp02 = true546 g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git"), Policy: func() Policy { return fromInternalPolicy(p) }, GitSync: func(context.Context, Repository) error { called++; return nil }})547 if err != nil {548 t.Fatal(err)549 }550 r := Repository{Owner: strings.Repeat("d", 64), Identifier: "repo", Clone: []string{"https://clone.example/repo.git"}, Relays: []string{"https://relay.example"}}551 g.mu.Lock()552 g.repos[key(r.Owner, r.Identifier)] = r553 g.mu.Unlock()554 if err := g.GRASPService().Tick(context.Background()); err != nil {555 t.Fatal(err)556 }557 if called != 1 {558 t.Fatalf("Git sync callback count = %d", called)559 }560 if _, err := os.Stat(filepath.Join(root, "git", ".tinyrelay", "grasp-progress.json")); err != nil {561 t.Fatal(err)562 }563 sources := RepairSources(r)564 if len(sources) != 2 || sources[0] != r.Clone[0] || sources[1] != r.Relays[0] {565 t.Fatalf("repair sources = %#v", sources)566 }567}568569func TestGRASPServiceTickContinuesAfterRepositoryFailure(t *testing.T) {570 root := t.TempDir()571 store, err := storage.Open(context.Background(), filepath.Join(root, "relay.db"))572 if err != nil {573 t.Fatal(err)574 }575 defer store.Close()576 p := policy.Defaults("")577 p.Features.Grasp = true578 p.Features.Grasp02 = true579 var synced []string580 g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git"), Policy: func() Policy { return fromInternalPolicy(p) }, GitSync: func(_ context.Context, r Repository) error {581 synced = append(synced, r.Identifier)582 if r.Identifier == "first" {583 return errors.New("temporary source failure")584 }585 return nil586 }})587 if err != nil {588 t.Fatal(err)589 }590 for _, id := range []string{"first", "second"} {591 r := Repository{Owner: strings.Repeat("e", 64), Identifier: id, Clone: []string{"https://clone.example/" + id}}592 g.mu.Lock()593 g.repos[key(r.Owner, r.Identifier)] = r594 g.mu.Unlock()595 }596 if err := g.GRASPService().Tick(context.Background()); err == nil {597 t.Fatal("Tick hid repository failure")598 }599 if len(synced) != 2 || synced[0] != "first" || synced[1] != "second" {600 t.Fatalf("sync order/calls = %#v", synced)601 }602 b, err := os.ReadFile(filepath.Join(root, "git", ".tinyrelay", "grasp-progress.json"))603 if err != nil {604 t.Fatal(err)605 }606 var progress tickProgress607 if err := json.Unmarshal(b, &progress); err != nil {608 t.Fatal(err)609 }610 if progress.Repos[key(strings.Repeat("e", 64), "first")].Error == "" || progress.Repos[key(strings.Repeat("e", 64), "second")].SucceededAt == 0 {611 t.Fatalf("per-repository progress = %#v", progress.Repos)612 }613 status, err := g.GRASPService().SyncStatus(context.Background())614 if err != nil || status.Repositories[key(strings.Repeat("e", 64), "second")].SucceededAt == 0 {615 t.Fatalf("SyncStatus = %#v, %v", status, err)616 }617 g.mu.Lock()618 changed := g.repos[key(strings.Repeat("e", 64), "first")]619 changed.EventID = "new-state"620 g.repos[key(changed.Owner, changed.Identifier)] = changed621 g.mu.Unlock()622 if err := g.GRASPService().Tick(context.Background()); err == nil {623 t.Fatal("changed repository state did not bypass backoff")624 }625 if len(synced) != 3 {626 t.Fatalf("changed state sync calls = %#v", synced)627 }628 if err := g.GRASPService().Tick(context.Background()); err != nil {629 t.Fatalf("second tick ignored durable backoff: %v", err)630 }631 status, err = g.GRASPService().SyncStatus(context.Background())632 if err != nil || status.Repositories[key(strings.Repeat("e", 64), "first")].Attempts != 2 || status.Repositories[key(strings.Repeat("e", 64), "first")].NextAt <= time.Now().Unix() {633 t.Fatalf("retry progress = %#v, %v", status, err)634 }635 p.Features.Grasp03 = true636 if err := g.GRASPService().Tick(context.Background()); err == nil {637 t.Fatal("policy profile change did not invalidate backoff")638 }639 if len(synced) != 5 {640 t.Fatalf("policy change sync calls = %#v", synced)641 }642}643644func TestValidateAndCommitAfterStoreAvoidDuplicateSave(t *testing.T) {645 root := t.TempDir()646 store, err := storage.Open(context.Background(), filepath.Join(root, "relay.db"))647 if err != nil {648 t.Fatal(err)649 }650 defer store.Close()651 g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git")})652 if err != nil {653 t.Fatal(err)654 }655 secret := strings.Repeat("0", 63) + "1"656 e := event.Event{Kind: 30617, CreatedAt: 1, Tags: [][]string{{"d", "manual"}}}657 if err := event.Sign(&e, secret); err != nil {658 t.Fatal(err)659 }660 r, err := g.Validate(context.Background(), e)661 if err != nil {662 t.Fatal(err)663 }664 if _, err := store.Save(context.Background(), e, storage.SaveOptions{Now: time.Now().Unix()}); err != nil {665 t.Fatal(err)666 }667 if err := g.CommitAfterStore(context.Background(), e, r); err != nil {668 t.Fatal(err)669 }670 q, err := store.Query(context.Background(), event.Filter{IDs: []string{e.ID}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 10})671 if err != nil || len(q.Events) != 1 {672 t.Fatalf("stored events = %d, err=%v", len(q.Events), err)673 }674}675676func TestSmartHTTPServesBareRepository(t *testing.T) {677 root := t.TempDir()678 store, err := storage.Open(context.Background(), filepath.Join(root, "relay.db"))679 if err != nil {680 t.Fatal(err)681 }682 defer store.Close()683 g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git")})684 if err != nil {685 t.Fatal(err)686 }687 owner := "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"688 r := Repository{Owner: owner, Identifier: "http", Refs: map[string]string{}}689 g.mu.Lock()690 g.repos[key(owner, "http")] = r691 g.mu.Unlock()692 if err := g.ensureRepo(r); err != nil {693 t.Fatal(err)694 }695 if _, err := os.Stat(filepath.Join(root, "git", owner, "http.git", "HEAD")); err != nil {696 t.Fatalf("repo: %v", err)697 }698 // Git 2.39 does not include upload-pack capabilities in an advertisement699 // for an empty repository. Seed one valid commit so this exercises the700 // capability negotiation used by browser clients.701 repo := g.repoPath(r)702 treeCmd := exec.Command("git", "--git-dir", repo, "mktree")703 treeCmd.Stdin = strings.NewReader("")704 tree, err := treeCmd.Output()705 if err != nil {706 t.Fatalf("empty tree: %v", err)707 }708 commitCmd := exec.Command("git", "--git-dir", repo, "commit-tree", strings.TrimSpace(string(tree)), "-m", "initial state")709 commitCmd.Env = append(os.Environ(),710 "GIT_AUTHOR_NAME=tinyrelay test", "GIT_AUTHOR_EMAIL=test@example.invalid",711 "GIT_COMMITTER_NAME=tinyrelay test", "GIT_COMMITTER_EMAIL=test@example.invalid",712 )713 commit, err := commitCmd.Output()714 if err != nil {715 t.Fatalf("initial commit: %v", err)716 }717 update := exec.Command("git", "--git-dir", repo, "update-ref", "refs/heads/main", strings.TrimSpace(string(commit)))718 if out, err := update.CombinedOutput(); err != nil {719 t.Fatalf("initial ref: %v: %s", err, out)720 }721 req := httptest.NewRequest(http.MethodGet, "/npub/npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6/http.git/info/refs?service=git-upload-pack", nil)722 if got, err := decodeNPub("npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6"); err != nil {723 t.Fatalf("known npub: %v", err)724 } else if got != owner {725 t.Fatalf("known npub owner: %s", got)726 }727 po, pi, ps, pa, pok := parsePathMode(req.URL.Path)728 if !pok || po != owner || pi != "http" || ps != "/info/refs" || pa {729 t.Fatalf("parsed path %s: %q %q %q %v %v", req.URL.Path, po, pi, ps, pa, pok)730 }731 res := httptest.NewRecorder()732 g.ServeHTTP(res, req)733 if res.Code != http.StatusOK {734 t.Fatalf("smart HTTP status = %d: %s", res.Code, res.Body.String())735 }736 body, _ := io.ReadAll(res.Result().Body)737 if len(body) == 0 {738 t.Fatal("smart HTTP returned an empty advertisement")739 }740 // gitworkshop.dev and other browser clients require these capabilities.741 for _, capability := range []string{" filter", " allow-tip-sha1-in-want", " allow-reachable-sha1-in-want", " object-format=sha1"} {742 if !strings.Contains(string(body), capability) {743 t.Errorf("advertisement lacks %q: %s", strings.TrimSpace(capability), body)744 }745 }746}747748func TestPendingStatePromotesAfterAuthorizedGitPush(t *testing.T) {749 root := t.TempDir()750 store, err := storage.Open(context.Background(), filepath.Join(root, "relay.db"))751 if err != nil {752 t.Fatal(err)753 }754 defer store.Close()755 g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(root, "git")})756 if err != nil {757 t.Fatal(err)758 }759 secret := strings.Repeat("0", 63) + "1"760 owner, _ := event.PublicKey(secret)761 ann := event.Event{Kind: 30617, CreatedAt: 1, Tags: [][]string{{"d", "push"}}}762 if err := event.Sign(&ann, secret); err != nil {763 t.Fatal(err)764 }765 if err := g.Publish(context.Background(), ann); err != nil {766 t.Fatal(err)767 }768 src := filepath.Join(root, "source.git")769 if out, err := exec.Command("git", "init", "--bare", src).CombinedOutput(); err != nil {770 t.Fatalf("init: %s", out)771 }772 work := filepath.Join(root, "work")773 if out, err := exec.Command("git", "clone", src, work).CombinedOutput(); err != nil {774 t.Fatalf("clone: %s", out)775 }776 for _, args := range [][]string{{"-C", work, "config", "user.email", "test@example.com"}, {"-C", work, "config", "user.name", "Test"}} {777 if out, err := exec.Command("git", args...).CombinedOutput(); err != nil {778 t.Fatalf("config: %s", out)779 }780 }781 if err := os.WriteFile(filepath.Join(work, "README"), []byte("pending\n"), 0600); err != nil {782 t.Fatal(err)783 }784 if out, err := exec.Command("git", "-C", work, "add", "README").CombinedOutput(); err != nil {785 t.Fatalf("add: %s", out)786 }787 if out, err := exec.Command("git", "-C", work, "commit", "-m", "pending").CombinedOutput(); err != nil {788 t.Fatalf("commit: %s", out)789 }790 if out, err := exec.Command("git", "-C", work, "tag", "v1").CombinedOutput(); err != nil {791 t.Fatalf("tag: %s", out)792 }793 shaBytes, err := exec.Command("git", "-C", work, "rev-parse", "HEAD").Output()794 if err != nil {795 t.Fatal(err)796 }797 sha := strings.TrimSpace(string(shaBytes))798 tagBytes, err := exec.Command("git", "-C", work, "rev-parse", "refs/tags/v1").Output()799 if err != nil {800 t.Fatal(err)801 }802 tagOID := strings.TrimSpace(string(tagBytes))803 state := event.Event{Kind: 30618, CreatedAt: 2, Tags: [][]string{{"d", "push"}, {"HEAD", "ref: refs/heads/main"}, {"refs/heads/main", sha}, {"refs/tags/v1", tagOID}}}804 if err := event.Sign(&state, secret); err != nil {805 t.Fatal(err)806 }807 if err := g.Publish(context.Background(), state); err != nil {808 t.Fatal(err)809 }810 target := filepath.Join(root, "git", owner, "push.git")811 if _, err := os.Stat(filepath.Join(target, "tinyrelay.pending")); err != nil {812 t.Fatal(err)813 }814 if out, err := exec.Command("git", "-C", work, "push", target, "HEAD:refs/heads/main", "refs/tags/v1:refs/tags/v1").CombinedOutput(); err != nil {815 t.Fatalf("push: %s", out)816 }817 if _, err := os.Stat(filepath.Join(target, "tinyrelay.refs")); err != nil {818 t.Fatalf("pending state was not promoted: %v", err)819 }820}