| 1 | package tinygit |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "path/filepath" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | |
| 10 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 11 | "github.com/FelineStateMachine/tinyrelay/internal/storage" |
| 12 | ) |
| 13 | |
| 14 | func TestValidMetadataShapeDoesNotGrantAdmission(t *testing.T) { |
| 15 | ctx := context.Background() |
| 16 | store, err := storage.Open(ctx, filepath.Join(t.TempDir(), "events.db" )) |
| 17 | if err != nil { |
| 18 | t.Fatal(err) |
| 19 | } |
| 20 | defer store.Close() |
| 21 | calls := 0 |
| 22 | denied := errors.New("blocked: host does not accept this repository" ) |
| 23 | g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(t.TempDir(), "git" ), Authorize: func(context.Context, Event, Repository) error { |
| 24 | calls++ |
| 25 | return denied |
| 26 | }}) |
| 27 | if err != nil { |
| 28 | t.Fatal(err) |
| 29 | } |
| 30 | secret := strings.Repeat("0" , 63) + "1" |
| 31 | for _, kind := range []int{30617, 30618} { |
| 32 | e := Event{Kind: kind, CreatedAt: 1, Tags: [][]string{{"d" , "demo" }}} |
| 33 | if err := event.Sign(&e, secret); err != nil { |
| 34 | t.Fatal(err) |
| 35 | } |
| 36 | if _, err := ParseMetadata(e); err != nil { |
| 37 | t.Fatalf("valid shape rejected: %v" , err) |
| 38 | } |
| 39 | if kind == 30618 { |
| 40 | // Pure parsing needs no announcement; admission still does. |
| 41 | if _, err := g.Validate(ctx, e); err == nil || !strings.Contains(err.Error(), "announcement is missing" ) { |
| 42 | t.Fatalf("state without authority admitted: %v" , err) |
| 43 | } |
| 44 | g.repos[key(e.PubKey, "demo" )] = Repository{Owner: e.PubKey, Identifier: "demo" , EventID: "announcement" } |
| 45 | } |
| 46 | for _, admit := range []func(context.Context, Event) error{ |
| 47 | func(ctx context.Context, e Event) error { _, err := g.Validate(ctx, e); return err }, |
| 48 | func(ctx context.Context, e Event) error { _, err := g.ValidateImported(ctx, e); return err }, |
| 49 | g.Publish, |
| 50 | } { |
| 51 | calls = 0 |
| 52 | if err := admit(ctx, e); !errors.Is(err, denied) || calls != 1 { |
| 53 | t.Fatalf("shape bypassed host admission: kind=%d calls=%d err=%v" , kind, calls, err) |
| 54 | } |
| 55 | // A tampered signature must fail before host authorization, even |
| 56 | // though the metadata shape still parses. |
| 57 | calls = 0 |
| 58 | tampered := e |
| 59 | tampered.Content = "tampered" |
| 60 | if err := admit(ctx, tampered); err == nil || errors.Is(err, denied) || calls != 0 { |
| 61 | t.Fatalf("signature stage bypassed: calls=%d err=%v" , calls, err) |
| 62 | } |
| 63 | } |
| 64 | } |
| 65 | result, err := store.Query(ctx, event.Filter{}, storage.QueryOptions{}) |
| 66 | if err != nil || len(result.Events) != 0 { |
| 67 | t.Fatalf("rejected metadata persisted: count=%d err=%v" , len(result.Events), err) |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | func TestMetadataShapePrecedesRepositoryLookup(t *testing.T) { |
| 72 | // No store or host is needed to reject shape. In particular, HEAD must |
| 73 | // not be deferred until after resolving the announcement and maintainers. |
| 74 | g := &GitRelay{} |
| 75 | for _, tag := range [][]string{{"HEAD" , "detached" }, {"refs/heads/bad..name" , strings.Repeat("a" , 40)}} { |
| 76 | e := Event{Kind: 30618, Tags: [][]string{{"d" , "demo" }, tag}} |
| 77 | if _, err := g.parseRepository(e); err == nil || !strings.HasPrefix(err.Error(), "invalid:" ) { |
| 78 | t.Fatalf("shape error must precede repository lookup: %v" , err) |
| 79 | } |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | func TestInvalidMetadataShapePrecedesHostAdmission(t *testing.T) { |
| 84 | ctx := context.Background() |
| 85 | store, err := storage.Open(ctx, filepath.Join(t.TempDir(), "events.db" )) |
| 86 | if err != nil { |
| 87 | t.Fatal(err) |
| 88 | } |
| 89 | defer store.Close() |
| 90 | calls := 0 |
| 91 | denied := errors.New("host denial" ) |
| 92 | g, err := New(Config{Store: tinyStore(store), Root: filepath.Join(t.TempDir(), "git" ), Authorize: func(context.Context, Event, Repository) error { |
| 93 | calls++ |
| 94 | return denied |
| 95 | }}) |
| 96 | if err != nil { |
| 97 | t.Fatal(err) |
| 98 | } |
| 99 | secret := strings.Repeat("0" , 63) + "1" |
| 100 | owner, err := event.PublicKey(secret) |
| 101 | if err != nil { |
| 102 | t.Fatal(err) |
| 103 | } |
| 104 | g.repos[key(owner, "demo" )] = Repository{Owner: owner, Identifier: "demo" , EventID: "announcement" } |
| 105 | e := Event{Kind: 30618, CreatedAt: 1, Tags: [][]string{{"d" , "demo" }, {"refs/heads/bad..name" , strings.Repeat("a" , 40)}}} |
| 106 | if err := event.Sign(&e, secret); err != nil { |
| 107 | t.Fatal(err) |
| 108 | } |
| 109 | for name, admit := range map[string]func(context.Context, Event) error{ |
| 110 | "validate" : func(ctx context.Context, e Event) error { _, err := g.Validate(ctx, e); return err }, |
| 111 | "import" : func(ctx context.Context, e Event) error { _, err := g.ValidateImported(ctx, e); return err }, |
| 112 | "publish" : g.Publish, |
| 113 | } { |
| 114 | t.Run(name, func(t *testing.T) { |
| 115 | calls = 0 |
| 116 | if err := admit(ctx, e); err == nil || !strings.HasPrefix(err.Error(), "invalid:" ) { |
| 117 | t.Errorf("invalid shape returned %v, want shape error before host denial" , err) |
| 118 | } |
| 119 | if calls != 0 { |
| 120 | t.Errorf("host authorizer called %d times for invalid shape" , calls) |
| 121 | } |
| 122 | result, err := store.Query(ctx, event.Filter{IDs: []string{e.ID}}, storage.QueryOptions{}) |
| 123 | if err != nil || len(result.Events) != 0 { |
| 124 | t.Fatalf("invalid shape persisted: count=%d err=%v" , len(result.Events), err) |
| 125 | } |
| 126 | }) |
| 127 | } |
| 128 | } |