This repository has no description
1package xrpc
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "github.com/bluesky-social/indigo/atproto/identity"
8 "log/slog"
9 "net/http"
10 "net/http/httptest"
11 "path/filepath"
12 "strings"
13 "testing"
14 "time"
15
16 "github.com/bluesky-social/indigo/atproto/syntax"
17 "tangled.org/core/api/tangled"
18 "tangled.org/core/idresolver"
19 "tangled.org/core/rbac"
20 "tangled.org/core/spindle/config"
21 "tangled.org/core/spindle/db"
22 "tangled.org/core/spindle/models"
23 "tangled.org/core/spindle/secrets"
24)
25
26type mockTrigger struct {
27 triggered bool
28}
29
30func (m *mockTrigger) TriggerManual(ctx context.Context, repoDid syntax.DID, sha, ref string, workflows []string, sourceRepo syntax.DID, pull PullContext, inputs []*tangled.Pipeline_Pair) (syntax.ATURI, error) {
31 m.triggered = true
32 return syntax.ParseATURI("at://did:plc:repoowner/sh.tangled.ci.pipeline/testrkey")
33}
34
35func newTestXrpcDB(t *testing.T) (*db.DB, *rbac.Enforcer) {
36 t.Helper()
37 p := filepath.Join(t.TempDir(), "spindle_xrpc.db")
38 d, err := db.Make(context.Background(), p)
39 if err != nil {
40 t.Fatalf("db.Make: %v", err)
41 }
42 t.Cleanup(func() { d.Close() })
43 e, err := rbac.NewEnforcer(p)
44 if err != nil {
45 t.Fatalf("rbac.NewEnforcer: %v", err)
46 }
47 e.E.EnableAutoSave(true)
48 return d, e
49}
50
51func TestTriggerPipeline_RBAC(t *testing.T) {
52 d, e := newTestXrpcDB(t)
53
54 repoOwnerDid := syntax.DID("did:plc:repoowner")
55 nonPusherDid := syntax.DID("did:plc:nonpusher")
56 pusherDid := syntax.DID("did:plc:pusher")
57 repoDid := syntax.DID("did:plc:testrepo123")
58
59 err := d.AddRepo(db.Repo{
60 Knot: "knot.test",
61 Owner: repoOwnerDid,
62 Rkey: "test-repo-rkey",
63 RepoDid: repoDid,
64 CreatedAt: time.Now().Format(time.RFC3339),
65 })
66 if err != nil {
67 t.Fatalf("AddRepo: %v", err)
68 }
69
70 err = e.AddRepo(repoOwnerDid.String(), rbac.ThisServer, repoDid.String())
71 if err != nil {
72 t.Fatalf("AddRepo permissions: %v", err)
73 }
74 err = e.AddCollaborator(pusherDid.String(), rbac.ThisServer, repoDid.String())
75 if err != nil {
76 t.Fatalf("AddCollaborator: %v", err)
77 }
78
79 trigger := &mockTrigger{}
80 x := &Xrpc{
81 Logger: slog.Default(),
82 Db: d,
83 Enforcer: e,
84 Config: &config.Config{},
85 Trigger: trigger,
86 }
87
88 sendReq := func(actor syntax.DID, input tangled.CiTriggerPipeline_Input) (*httptest.ResponseRecorder, int) {
89 body, _ := json.Marshal(input)
90 req := httptest.NewRequest(http.MethodPost, "/com.atproto.repo.createRecord", bytes.NewReader(body))
91 ctx := context.WithValue(req.Context(), ActorDid, actor)
92 req = req.WithContext(ctx)
93
94 w := httptest.NewRecorder()
95 x.TriggerPipeline(w, req)
96 return w, w.Code
97 }
98
99 sha := "0123456789abcdef0123456789abcdef01234567"
100 ref := "refs/heads/main"
101
102 input := tangled.CiTriggerPipeline_Input{
103 Repo: repoDid.String(),
104 Trigger: &tangled.CiTriggerPipeline_Input_Trigger{
105 CiTrigger_Manual: &tangled.CiTrigger_Manual{
106 Sha: sha,
107 Ref: &ref,
108 },
109 },
110 }
111
112 w, code := sendReq(pusherDid, input)
113 if code != http.StatusOK {
114 t.Fatalf("expected 200 for pusher, got %d (body: %s)", code, w.Body.String())
115 }
116 if !trigger.triggered {
117 t.Fatal("expected pipeline trigger to be called")
118 }
119
120 trigger.triggered = false
121
122 w, code = sendReq(nonPusherDid, input)
123 if code != http.StatusBadRequest {
124 t.Fatalf("expected 400 for non-pusher, got %d", code)
125 }
126 if !strings.Contains(w.Body.String(), "AccessControl") {
127 t.Fatalf("expected AccessControl, got: %s", w.Body.String())
128 }
129 if trigger.triggered {
130 t.Fatal("expected pipeline trigger not to be called for non-pusher")
131 }
132
133 badInput := input
134 badInput.Repo = "did:plc:unknownrepo"
135 w, code = sendReq(pusherDid, badInput)
136 if code != http.StatusBadRequest {
137 t.Fatalf("expected 400 for unknown repo, got %d", code)
138 }
139 if !strings.Contains(w.Body.String(), "RepoNotFound") {
140 t.Fatalf("expected RepoNotFound, got: %s", w.Body.String())
141 }
142}
143
144func TestCancelPipeline_RBAC(t *testing.T) {
145 d, e := newTestXrpcDB(t)
146
147 repoOwnerDid := syntax.DID("did:plc:repoowner")
148 nonPusherDid := syntax.DID("did:plc:nonpusher")
149 pusherDid := syntax.DID("did:plc:pusher")
150 repoDid := syntax.DID("did:plc:testrepo123")
151
152 err := d.AddRepo(db.Repo{
153 Knot: "knot.test",
154 Owner: repoOwnerDid,
155 Rkey: "test-repo-rkey",
156 RepoDid: repoDid,
157 CreatedAt: time.Now().Format(time.RFC3339),
158 })
159 if err != nil {
160 t.Fatalf("AddRepo: %v", err)
161 }
162
163 err = e.AddRepo(repoOwnerDid.String(), rbac.ThisServer, repoDid.String())
164 if err != nil {
165 t.Fatalf("AddRepo permissions: %v", err)
166 }
167 err = e.AddCollaborator(pusherDid.String(), rbac.ThisServer, repoDid.String())
168 if err != nil {
169 t.Fatalf("AddCollaborator: %v", err)
170 }
171
172 pipelineTid := "3mrkp6iz6os2o"
173 repoDidStr := repoDid.String()
174 tpl := tangled.Pipeline{
175 TriggerMetadata: &tangled.Pipeline_TriggerMetadata{
176 Kind: "manual",
177 Repo: &tangled.Pipeline_TriggerRepo{
178 RepoDid: &repoDidStr,
179 Knot: "knot.test",
180 Did: repoOwnerDid.String(),
181 },
182 },
183 Workflows: []*tangled.Pipeline_Workflow{
184 {Name: "test-workflow"},
185 },
186 }
187 err = d.CreatePipelineEvent(pipelineTid, tpl, nil)
188 if err != nil {
189 t.Fatalf("CreatePipelineEvent: %v", err)
190 }
191
192 _, err = d.Exec(`UPDATE pipelines SET repo_did = ? WHERE id = ?`, repoDid.String(), pipelineTid)
193 if err != nil {
194 t.Fatalf("Update pipeline repo association: %v", err)
195 }
196
197 x := &Xrpc{
198 Logger: slog.Default(),
199 Db: d,
200 Enforcer: e,
201 Config: &config.Config{},
202 Engines: make(map[string]models.Engine),
203 }
204
205 sendReq := func(actor syntax.DID, input tangled.CiCancelPipeline_Input) (*httptest.ResponseRecorder, int) {
206 body, _ := json.Marshal(input)
207 req := httptest.NewRequest(http.MethodPost, "/com.atproto.repo.createRecord", bytes.NewReader(body))
208 ctx := context.WithValue(req.Context(), ActorDid, actor)
209 req = req.WithContext(ctx)
210
211 w := httptest.NewRecorder()
212 x.CancelPipeline(w, req)
213 return w, w.Code
214 }
215
216 input := tangled.CiCancelPipeline_Input{
217 Repo: repoDid.String(),
218 Pipeline: pipelineTid,
219 }
220
221 w, code := sendReq(pusherDid, input)
222 if code != http.StatusOK {
223 t.Fatalf("expected 200 for pusher, got %d (body: %s)", code, w.Body.String())
224 }
225
226 w, code = sendReq(nonPusherDid, input)
227 if code != http.StatusBadRequest {
228 t.Fatalf("expected 400 for non-pusher, got %d", code)
229 }
230 if !strings.Contains(w.Body.String(), "AccessControl") {
231 t.Fatalf("expected AccessControl, got: %s", w.Body.String())
232 }
233}
234
235func TestSecrets_RBAC(t *testing.T) {
236 d, e := newTestXrpcDB(t)
237
238 repoOwnerDid := syntax.DID("did:plc:repoowner")
239 nonPusherDid := syntax.DID("did:plc:nonpusher")
240 pusherDid := syntax.DID("did:plc:pusher")
241 repoDid := syntax.DID("did:plc:testrepo123")
242
243 err := d.AddRepo(db.Repo{
244 Knot: "knot.test",
245 Owner: repoOwnerDid,
246 Rkey: "test-repo-rkey",
247 RepoDid: repoDid,
248 CreatedAt: time.Now().Format(time.RFC3339),
249 })
250 if err != nil {
251 t.Fatalf("AddRepo: %v", err)
252 }
253
254 err = e.AddRepo(repoOwnerDid.String(), rbac.ThisServer, repoDid.String())
255 if err != nil {
256 t.Fatalf("AddRepo permissions: %v", err)
257 }
258 err = e.AddCollaborator(pusherDid.String(), rbac.ThisServer, repoDid.String())
259 if err != nil {
260 t.Fatalf("AddCollaborator: %v", err)
261 }
262
263 vault, err := secrets.NewSQLiteManager(":memory:")
264 if err != nil {
265 t.Fatalf("secrets.NewSQLiteManager: %v", err)
266 }
267
268 var ts *httptest.Server
269 ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
270 if strings.HasPrefix(r.URL.Path, "/xrpc/com.atproto.repo.getRecord") {
271 w.Header().Set("Content-Type", "application/json")
272 _, _ = w.Write([]byte(`{
273 "uri": "at://did:plc:repoowner/sh.tangled.repo/test-repo-rkey",
274 "cid": "bafybeigdyrzt5s2nuxwos7552",
275 "value": {
276 "$type": "sh.tangled.repo",
277 "knot": "knot.test",
278 "repoDid": "did:plc:testrepo123",
279 "spindle": "spindle.test",
280 "createdAt": "2026-07-26T12:00:00Z"
281 }
282 }`))
283 return
284 }
285 w.WriteHeader(http.StatusNotFound)
286 }))
287 defer ts.Close()
288
289 h, err := syntax.ParseHandle("repoowner.test")
290 if err != nil {
291 t.Fatalf("syntax.ParseHandle: %v", err)
292 }
293
294 mockIdent := &identity.Identity{
295 DID: repoOwnerDid,
296 Handle: h,
297 Services: map[string]identity.ServiceEndpoint{
298 "atproto_pds": {
299 Type: "AtprotoPersonalDataServer",
300 URL: ts.URL,
301 },
302 },
303 }
304
305 resolver := idresolver.NewMockResolver(idresolver.MockDirectory{Ident: mockIdent})
306
307 x := &Xrpc{
308 Logger: slog.Default(),
309 Db: d,
310 Enforcer: e,
311 Config: &config.Config{},
312 Resolver: resolver,
313 Vault: vault,
314 }
315
316 addInput := tangled.RepoAddSecret_Input{
317 Repo: "at://did:plc:repoowner/sh.tangled.repo/test-repo-rkey",
318 Key: "MY_SECRET",
319 Value: "supersecret",
320 }
321
322 sendAdd := func(actor syntax.DID, input tangled.RepoAddSecret_Input) (*httptest.ResponseRecorder, int) {
323 body, _ := json.Marshal(input)
324 req := httptest.NewRequest(http.MethodPost, "/"+tangled.RepoAddSecretNSID, bytes.NewReader(body))
325 ctx := context.WithValue(req.Context(), ActorDid, actor)
326 req = req.WithContext(ctx)
327 w := httptest.NewRecorder()
328 x.AddSecret(w, req)
329 return w, w.Code
330 }
331
332 w, code := sendAdd(pusherDid, addInput)
333 if code != http.StatusOK {
334 t.Fatalf("expected 200 for add secret, got %d (body: %s)", code, w.Body.String())
335 }
336
337 w, code = sendAdd(nonPusherDid, addInput)
338 if code != http.StatusUnauthorized {
339 t.Fatalf("expected 401 for unauthorized add secret, got %d", code)
340 }
341
342 sendList := func(actor syntax.DID, repo string) (*httptest.ResponseRecorder, int) {
343 req := httptest.NewRequest(http.MethodGet, "/"+tangled.RepoListSecretsNSID+"?repo="+repo, nil)
344 ctx := context.WithValue(req.Context(), ActorDid, actor)
345 req = req.WithContext(ctx)
346 w := httptest.NewRecorder()
347 x.ListSecrets(w, req)
348 return w, w.Code
349 }
350
351 w, code = sendList(pusherDid, addInput.Repo)
352 if code != http.StatusOK {
353 t.Fatalf("expected 200 for list secrets, got %d (body: %s)", code, w.Body.String())
354 }
355
356 var listOut tangled.RepoListSecrets_Output
357 if err := json.Unmarshal(w.Body.Bytes(), &listOut); err != nil {
358 t.Fatalf("failed to decode list secrets output: %v", err)
359 }
360 if len(listOut.Secrets) != 1 || listOut.Secrets[0].Key != "MY_SECRET" {
361 t.Fatalf("unexpected secrets list: %+v", listOut.Secrets)
362 }
363
364 w, code = sendList(nonPusherDid, addInput.Repo)
365 if code != http.StatusUnauthorized {
366 t.Fatalf("expected 401 for unauthorized list secrets, got %d", code)
367 }
368
369 removeInput := tangled.RepoRemoveSecret_Input{
370 Repo: addInput.Repo,
371 Key: "MY_SECRET",
372 }
373
374 sendRemove := func(actor syntax.DID, input tangled.RepoRemoveSecret_Input) (*httptest.ResponseRecorder, int) {
375 body, _ := json.Marshal(input)
376 req := httptest.NewRequest(http.MethodPost, "/"+tangled.RepoRemoveSecretNSID, bytes.NewReader(body))
377 ctx := context.WithValue(req.Context(), ActorDid, actor)
378 req = req.WithContext(ctx)
379 w := httptest.NewRecorder()
380 x.RemoveSecret(w, req)
381 return w, w.Code
382 }
383
384 w, code = sendRemove(pusherDid, removeInput)
385 if code != http.StatusOK {
386 t.Fatalf("expected 200 for remove secret, got %d (body: %s)", code, w.Body.String())
387 }
388
389 w, code = sendList(pusherDid, addInput.Repo)
390 if code != http.StatusOK {
391 t.Fatalf("list secrets failed: %d", code)
392 }
393 if err := json.Unmarshal(w.Body.Bytes(), &listOut); err != nil {
394 t.Fatalf("failed to decode: %v", err)
395 }
396 if len(listOut.Secrets) != 0 {
397 t.Fatal("secret was not removed")
398 }
399}