tinyrelayfile
tinyrelay/tinygit/gitrelay_test.go/

tinygit/gitrelay_test.go raw
1package tinygit
2
3import (
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"
16
17 "github.com/FelineStateMachine/tinyrelay/internal/event"
18 "github.com/FelineStateMachine/tinyrelay/internal/policy"
19 "github.com/FelineStateMachine/tinyrelay/internal/storage"
20)
21
22func 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}
32
33func 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}
76
77func 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}
113
114func 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 }
160
161 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}
183
184func 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}
235
236func 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}
289
290func 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 []string
306 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}
323
324func 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}
345
346func 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.ID
401 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 = prRepo
407 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}
424
425func 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}
461
462func 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}
534
535func 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 := 0
543 p := policy.Defaults("")
544 p.Features.Grasp = true
545 p.Features.Grasp02 = true
546 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)] = r
553 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}
568
569func 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 = true
578 p.Features.Grasp02 = true
579 var synced []string
580 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 nil
586 }})
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)] = r
594 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 tickProgress
607 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)] = changed
621 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 = true
636 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}
643
644func 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}
675
676func 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")] = r
691 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 advertisement
699 // for an empty repository. Seed one valid commit so this exercises the
700 // 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}
747
748func 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}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs