This repository has no description
0

Configure Feed

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

core / spindle / xrpc / xrpc_test.go
12 kB 419 lines
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}