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