Stitch any CI into Tangled
2

Configure Feed

Select the types of activity you want to include in your feed.

at main 5.7 kB View raw
1package main 2 3// Tests for the knotConsumer.process() authorization gate. The knot 4// /events stream hands us every pipeline record published on a knot 5// we're subscribed to, but only those matching the persisted Tangled 6// state should reach a Provider. This file pins both halves: a 7// trigger that satisfies AuthorizePipelineActor must reach Spawn, 8// and one that doesn't must be silently dropped. 9 10import ( 11 "context" 12 "encoding/json" 13 "log/slog" 14 "net/url" 15 "testing" 16 17 "tangled.org/core/api/tangled" 18 "tangled.org/core/eventconsumer" 19) 20 21// fakeSource is a minimal eventconsumer.Source: process() only ever 22// reads Source.Key(), so a string-keyed stub keeps tests independent 23// of the eventconsumer package's URL plumbing. 24type fakeSource struct{ key string } 25 26func (f fakeSource) Key() string { return f.key } 27func (f fakeSource) Url(int64, bool) (*url.URL, error) { return nil, nil } 28 29// newTestKnotConsumer wires a knotConsumer against the provided 30// store/provider with the fixed (hostname, owner) used across the 31// tests below. cursors and the underlying *eventconsumer.Consumer 32// are intentionally left nil; process() never touches them. 33func newTestKnotConsumer(st *store, provider Provider) *knotConsumer { 34 return &knotConsumer{ 35 log: slog.Default(), 36 provider: provider, 37 store: st, 38 hostname: "spindle.example", 39 ownerDID: "did:plc:owner", 40 } 41} 42 43// pipelineMessage builds an eventconsumer.Message for a pipeline 44// trigger pointing at (repoOwn, repoName) on the named knot. Output 45// matches what eventconsumer feeds process() at runtime. 46func pipelineMessage(t *testing.T, repoOwn, repoName string) eventconsumer.Message { 47 t.Helper() 48 repo := repoName 49 rec := tangled.Pipeline{ 50 LexiconTypeID: tangled.PipelineNSID, 51 TriggerMetadata: &tangled.Pipeline_TriggerMetadata{ 52 Kind: "push", 53 Repo: &tangled.Pipeline_TriggerRepo{ 54 Did: repoOwn, 55 Repo: &repo, 56 }, 57 }, 58 Workflows: []*tangled.Pipeline_Workflow{ 59 {Name: "ci.yml", Raw: "tack:\n fake: {}\n"}, 60 }, 61 } 62 body, err := json.Marshal(rec) 63 if err != nil { 64 t.Fatalf("marshal pipeline: %v", err) 65 } 66 return eventconsumer.Message{ 67 Rkey: "pl-1", 68 Nsid: tangled.PipelineNSID, 69 EventJson: body, 70 } 71} 72 73// TestKnotProcessAuthorized confirms a trigger whose repo opted into 74// us on the right knot AND whose actor is the spindle owner reaches 75// Spawn with the actor DID populated. 76func TestKnotProcessAuthorized(t *testing.T) { 77 st := newTestStore(t) 78 ctx := context.Background() 79 80 // The repo owner is the spindle owner, so no spindle.member 81 // row is needed. Repo claims us as its spindle on the right 82 // knot, so both gates should pass. 83 if err := st.UpsertRepo(ctx, 84 "did:plc:owner", "rk1", 85 "knot.example", "myrepo", 86 "spindle.example", "", "t", 87 ); err != nil { 88 t.Fatal(err) 89 } 90 91 stub := &stubProvider{} 92 kc := newTestKnotConsumer(st, stub) 93 94 if err := kc.process(ctx, 95 fakeSource{key: "knot.example"}, 96 pipelineMessage(t, "did:plc:owner", "myrepo"), 97 ); err != nil { 98 t.Fatalf("process: %v", err) 99 } 100 101 if got, want := stub.names(), []string{"ci.yml"}; !equalStrings(got, want) { 102 t.Fatalf("Spawn workflows = %v; want %v", got, want) 103 } 104} 105 106// TestKnotProcessRejectsUnauthorized covers the bug this commit 107// fixes: a knot we're already subscribed to publishes a pipeline 108// trigger for a repo that never opted into us. The store has no 109// matching repos row, so AuthorizePipelineActor must deny and 110// Spawn must NOT be invoked. 111func TestKnotProcessRejectsUnauthorized(t *testing.T) { 112 st := newTestStore(t) 113 114 // Note: NO repos row inserted. The knot may publish whatever 115 // pipeline records it likes, but absent a sh.tangled.repo 116 // declaring us the spindle, we must drop the event. 117 stub := &stubProvider{} 118 kc := newTestKnotConsumer(st, stub) 119 120 if err := kc.process(context.Background(), 121 fakeSource{key: "knot.example"}, 122 pipelineMessage(t, "did:plc:rando", "evilrepo"), 123 ); err != nil { 124 t.Fatalf("process: %v", err) 125 } 126 127 if got := stub.names(); len(got) != 0 { 128 t.Fatalf("Spawn called for unauthorized trigger: %v", got) 129 } 130} 131 132// TestKnotProcessRejectsNonMember confirms that even when a repo 133// declares us as its spindle, a trigger from a publisher who is not 134// the spindle owner and has no owner-vouched membership is dropped. 135// This is the gate that makes spindle_members load-bearing. 136func TestKnotProcessRejectsNonMember(t *testing.T) { 137 st := newTestStore(t) 138 ctx := context.Background() 139 140 // Repo claim is fine, but the owner of that repo isn't us, 141 // and no membership grant has been published. 142 if err := st.UpsertRepo(ctx, 143 "did:plc:alice", "rk1", 144 "knot.example", "myrepo", 145 "spindle.example", "", "t", 146 ); err != nil { 147 t.Fatal(err) 148 } 149 150 stub := &stubProvider{} 151 kc := newTestKnotConsumer(st, stub) 152 153 if err := kc.process(ctx, 154 fakeSource{key: "knot.example"}, 155 pipelineMessage(t, "did:plc:alice", "myrepo"), 156 ); err != nil { 157 t.Fatalf("process: %v", err) 158 } 159 160 if got := stub.names(); len(got) != 0 { 161 t.Fatalf("Spawn called for non-member: %v", got) 162 } 163 164 // Now grant alice membership and re-run; this time it must 165 // pass. Verifies the gate is actually consulting the live 166 // store rather than caching a denial. 167 if err := st.UpsertSpindleMember(ctx, 168 "did:plc:owner", "mk1", "spindle.example", "did:plc:alice", "t", 169 ); err != nil { 170 t.Fatal(err) 171 } 172 if err := kc.process(ctx, 173 fakeSource{key: "knot.example"}, 174 pipelineMessage(t, "did:plc:alice", "myrepo"), 175 ); err != nil { 176 t.Fatalf("process (after grant): %v", err) 177 } 178 if got, want := stub.names(), []string{"ci.yml"}; !equalStrings(got, want) { 179 t.Fatalf("post-grant Spawn = %v; want %v", got, want) 180 } 181}