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 418 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/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}