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
235type mockDirectory struct {
236 ident *identity.Identity
237}
238
239func (m *mockDirectory) LookupDID(ctx context.Context, did syntax.DID) (*identity.Identity, error) {
240 return m.ident, nil
241}
242
243func (m *mockDirectory) LookupHandle(ctx context.Context, handle syntax.Handle) (*identity.Identity, error) {
244 return m.ident, nil
245}
246
247func (m *mockDirectory) Lookup(ctx context.Context, id syntax.AtIdentifier) (*identity.Identity, error) {
248 return m.ident, nil
249}
250
251func (m *mockDirectory) Purge(ctx context.Context, id syntax.AtIdentifier) error {
252 return nil
253}
254
255func TestSecrets_RBAC(t *testing.T) {
256 d, e := newTestXrpcDB(t)
257
258 repoOwnerDid := syntax.DID("did:plc:repoowner")
259 nonPusherDid := syntax.DID("did:plc:nonpusher")
260 pusherDid := syntax.DID("did:plc:pusher")
261 repoDid := syntax.DID("did:plc:testrepo123")
262
263 err := d.AddRepo(db.Repo{
264 Knot: "knot.test",
265 Owner: repoOwnerDid,
266 Rkey: "test-repo-rkey",
267 RepoDid: repoDid,
268 CreatedAt: time.Now().Format(time.RFC3339),
269 })
270 if err != nil {
271 t.Fatalf("AddRepo: %v", err)
272 }
273
274 err = e.AddRepo(repoOwnerDid.String(), rbac.ThisServer, repoDid.String())
275 if err != nil {
276 t.Fatalf("AddRepo permissions: %v", err)
277 }
278 err = e.AddCollaborator(pusherDid.String(), rbac.ThisServer, repoDid.String())
279 if err != nil {
280 t.Fatalf("AddCollaborator: %v", err)
281 }
282
283 vault, err := secrets.NewSQLiteManager(":memory:")
284 if err != nil {
285 t.Fatalf("secrets.NewSQLiteManager: %v", err)
286 }
287
288 var ts *httptest.Server
289 ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
290 if strings.HasPrefix(r.URL.Path, "/xrpc/com.atproto.repo.getRecord") {
291 w.Header().Set("Content-Type", "application/json")
292 _, _ = w.Write([]byte(`{
293 "uri": "at://did:plc:repoowner/sh.tangled.repo/test-repo-rkey",
294 "cid": "bafybeigdyrzt5s2nuxwos7552",
295 "value": {
296 "$type": "sh.tangled.repo",
297 "knot": "knot.test",
298 "repoDid": "did:plc:testrepo123",
299 "spindle": "spindle.test",
300 "createdAt": "2026-07-26T12:00:00Z"
301 }
302 }`))
303 return
304 }
305 w.WriteHeader(http.StatusNotFound)
306 }))
307 defer ts.Close()
308
309 h, err := syntax.ParseHandle("repoowner.test")
310 if err != nil {
311 t.Fatalf("syntax.ParseHandle: %v", err)
312 }
313
314 mockIdent := &identity.Identity{
315 DID: repoOwnerDid,
316 Handle: h,
317 Services: map[string]identity.ServiceEndpoint{
318 "atproto_pds": {
319 Type: "AtprotoPersonalDataServer",
320 URL: ts.URL,
321 },
322 },
323 }
324
325 resolver := idresolver.NewMockResolver(&mockDirectory{ident: mockIdent})
326
327 x := &Xrpc{
328 Logger: slog.Default(),
329 Db: d,
330 Enforcer: e,
331 Config: &config.Config{},
332 Resolver: resolver,
333 Vault: vault,
334 }
335
336 addInput := tangled.RepoAddSecret_Input{
337 Repo: "at://did:plc:repoowner/sh.tangled.repo/test-repo-rkey",
338 Key: "MY_SECRET",
339 Value: "supersecret",
340 }
341
342 sendAdd := func(actor syntax.DID, input tangled.RepoAddSecret_Input) (*httptest.ResponseRecorder, int) {
343 body, _ := json.Marshal(input)
344 req := httptest.NewRequest(http.MethodPost, "/"+tangled.RepoAddSecretNSID, bytes.NewReader(body))
345 ctx := context.WithValue(req.Context(), ActorDid, actor)
346 req = req.WithContext(ctx)
347 w := httptest.NewRecorder()
348 x.AddSecret(w, req)
349 return w, w.Code
350 }
351
352 w, code := sendAdd(pusherDid, addInput)
353 if code != http.StatusOK {
354 t.Fatalf("expected 200 for add secret, got %d (body: %s)", code, w.Body.String())
355 }
356
357 w, code = sendAdd(nonPusherDid, addInput)
358 if code != http.StatusUnauthorized {
359 t.Fatalf("expected 401 for unauthorized add secret, got %d", code)
360 }
361
362 sendList := func(actor syntax.DID, repo string) (*httptest.ResponseRecorder, int) {
363 req := httptest.NewRequest(http.MethodGet, "/"+tangled.RepoListSecretsNSID+"?repo="+repo, nil)
364 ctx := context.WithValue(req.Context(), ActorDid, actor)
365 req = req.WithContext(ctx)
366 w := httptest.NewRecorder()
367 x.ListSecrets(w, req)
368 return w, w.Code
369 }
370
371 w, code = sendList(pusherDid, addInput.Repo)
372 if code != http.StatusOK {
373 t.Fatalf("expected 200 for list secrets, got %d (body: %s)", code, w.Body.String())
374 }
375
376 var listOut tangled.RepoListSecrets_Output
377 if err := json.Unmarshal(w.Body.Bytes(), &listOut); err != nil {
378 t.Fatalf("failed to decode list secrets output: %v", err)
379 }
380 if len(listOut.Secrets) != 1 || listOut.Secrets[0].Key != "MY_SECRET" {
381 t.Fatalf("unexpected secrets list: %+v", listOut.Secrets)
382 }
383
384 w, code = sendList(nonPusherDid, addInput.Repo)
385 if code != http.StatusUnauthorized {
386 t.Fatalf("expected 401 for unauthorized list secrets, got %d", code)
387 }
388
389 removeInput := tangled.RepoRemoveSecret_Input{
390 Repo: addInput.Repo,
391 Key: "MY_SECRET",
392 }
393
394 sendRemove := func(actor syntax.DID, input tangled.RepoRemoveSecret_Input) (*httptest.ResponseRecorder, int) {
395 body, _ := json.Marshal(input)
396 req := httptest.NewRequest(http.MethodPost, "/"+tangled.RepoRemoveSecretNSID, bytes.NewReader(body))
397 ctx := context.WithValue(req.Context(), ActorDid, actor)
398 req = req.WithContext(ctx)
399 w := httptest.NewRecorder()
400 x.RemoveSecret(w, req)
401 return w, w.Code
402 }
403
404 w, code = sendRemove(pusherDid, removeInput)
405 if code != http.StatusOK {
406 t.Fatalf("expected 200 for remove secret, got %d (body: %s)", code, w.Body.String())
407 }
408
409 w, code = sendList(pusherDid, addInput.Repo)
410 if code != http.StatusOK {
411 t.Fatalf("list secrets failed: %d", code)
412 }
413 if err := json.Unmarshal(w.Body.Bytes(), &listOut); err != nil {
414 t.Fatalf("failed to decode: %v", err)
415 }
416 if len(listOut.Secrets) != 0 {
417 t.Fatal("secret was not removed")
418 }
419}