Stitch any CI into Tangled
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}