This repository has no description
0

Configure Feed

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

core / knotserver / xrpc / members_collaborators_test.go
17 kB 491 lines
1package xrpc 2 3import ( 4 "bytes" 5 "context" 6 "encoding/json" 7 "io" 8 "log/slog" 9 "net/http" 10 "net/http/httptest" 11 "path/filepath" 12 "testing" 13 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 "tangled.org/core/api/tangled" 16 "tangled.org/core/idresolver" 17 "tangled.org/core/knotserver/config" 18 "tangled.org/core/knotserver/db" 19 "tangled.org/core/rbac" 20) 21 22const ( 23 aclOwner = "did:plc:akshay" 24 aclSubject = "did:plc:boltless" 25 aclRepoDid = "did:plc:limpet" 26) 27 28type fakeIngester struct { 29 added []string 30 removed []string 31} 32 33func (f *fakeIngester) AddDid(did string) { f.added = append(f.added, did) } 34func (f *fakeIngester) RemoveDid(did string) { f.removed = append(f.removed, did) } 35 36func newACLXrpc(t *testing.T) (*Xrpc, *fakeIngester) { 37 t.Helper() 38 dir := t.TempDir() 39 d, err := db.Setup(context.Background(), filepath.Join(dir, "knot.db")) 40 if err != nil { 41 t.Fatalf("db.Setup: %v", err) 42 } 43 e, err := rbac.NewEnforcer(filepath.Join(dir, "rbac.db")) 44 if err != nil { 45 t.Fatalf("NewEnforcer: %v", err) 46 } 47 if err := e.AddKnot(rbac.ThisServer); err != nil { 48 t.Fatalf("AddKnot: %v", err) 49 } 50 if err := e.AddKnotOwner(rbac.ThisServer, aclOwner); err != nil { 51 t.Fatalf("AddKnotOwner: %v", err) 52 } 53 ing := &fakeIngester{} 54 x := &Xrpc{ 55 Db: d, 56 Enforcer: e, 57 Ingester: ing, 58 Resolver: idresolver.DefaultResolver("http://127.0.0.1:1"), 59 Config: &config.Config{Server: config.Server{Hostname: "knot.example", MaxResponseKB: 5120}}, 60 Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), 61 } 62 return x, ing 63} 64 65func aclRequest(t *testing.T, actor string, body any) *http.Request { 66 t.Helper() 67 var buf bytes.Buffer 68 if body != nil { 69 if err := json.NewEncoder(&buf).Encode(body); err != nil { 70 t.Fatalf("encode body: %v", err) 71 } 72 } 73 req := httptest.NewRequest(http.MethodPost, "/xrpc/test", &buf) 74 if actor != "" { 75 req = req.WithContext(context.WithValue(req.Context(), ActorDid, syntax.DID(actor))) 76 } 77 return req 78} 79 80func seedRepo(t *testing.T, x *Xrpc) { 81 t.Helper() 82 if err := x.Db.StoreRepoKey(aclRepoDid, []byte("signing"), aclOwner, "reponame"); err != nil { 83 t.Fatalf("StoreRepoKey: %v", err) 84 } 85 if err := x.Enforcer.AddRepo(aclOwner, rbac.ThisServer, aclRepoDid); err != nil { 86 t.Fatalf("AddRepo: %v", err) 87 } 88} 89 90func TestAddMember_HappyPath(t *testing.T) { 91 x, ing := newACLXrpc(t) 92 rec := httptest.NewRecorder() 93 x.AddMember(rec, aclRequest(t, aclOwner, tangled.KnotAddMember_Input{Subject: aclSubject})) 94 95 if rec.Code != http.StatusOK { 96 t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) 97 } 98 if n, err := db.CountKnotMembersBySubject(x.Db, aclSubject); err != nil || n != 1 { 99 t.Errorf("member rows = %d (err %v), want 1", n, err) 100 } 101 isM, err := x.Enforcer.IsKnotMember(aclSubject, rbac.ThisServer) 102 if err != nil || !isM { 103 t.Errorf("IsKnotMember = %v (err %v), want true", isM, err) 104 } 105 if len(ing.added) != 1 || ing.added[0] != aclSubject { 106 t.Errorf("ingester.added = %v, want [%s]", ing.added, aclSubject) 107 } 108} 109 110func TestAddMember_AlreadyMemberShortCircuits(t *testing.T) { 111 x, ing := newACLXrpc(t) 112 for i := 0; i < 2; i++ { 113 rec := httptest.NewRecorder() 114 x.AddMember(rec, aclRequest(t, aclOwner, tangled.KnotAddMember_Input{Subject: aclSubject})) 115 if rec.Code != http.StatusOK { 116 t.Fatalf("add %d status = %d, want 200", i, rec.Code) 117 } 118 } 119 if n, _ := db.CountKnotMembersBySubject(x.Db, aclSubject); n != 1 { 120 t.Errorf("member rows = %d, want 1 after duplicate add", n) 121 } 122 if len(ing.added) != 1 { 123 t.Errorf("ingester.added = %v, want a single entry; second add must short-circuit", ing.added) 124 } 125} 126 127func TestAddMember_MissingActorForbidden(t *testing.T) { 128 x, _ := newACLXrpc(t) 129 rec := httptest.NewRecorder() 130 x.AddMember(rec, aclRequest(t, "", tangled.KnotAddMember_Input{Subject: aclSubject})) 131 if rec.Code != http.StatusForbidden { 132 t.Errorf("status = %d, want 403", rec.Code) 133 } 134} 135 136func TestAddMember_NonOwnerForbidden(t *testing.T) { 137 x, _ := newACLXrpc(t) 138 rec := httptest.NewRecorder() 139 x.AddMember(rec, aclRequest(t, aclSubject, tangled.KnotAddMember_Input{Subject: "did:plc:scallop"})) 140 if rec.Code != http.StatusForbidden { 141 t.Errorf("status = %d, want 403", rec.Code) 142 } 143} 144 145func TestAddMember_MalformedSubjectBadRequest(t *testing.T) { 146 x, _ := newACLXrpc(t) 147 rec := httptest.NewRecorder() 148 x.AddMember(rec, aclRequest(t, aclOwner, tangled.KnotAddMember_Input{Subject: "notadid"})) 149 if rec.Code != http.StatusBadRequest { 150 t.Errorf("status = %d, want 400", rec.Code) 151 } 152} 153 154func TestRemoveMember_HappyPathClearsBothStores(t *testing.T) { 155 x, ing := newACLXrpc(t) 156 addRec := httptest.NewRecorder() 157 x.AddMember(addRec, aclRequest(t, aclOwner, tangled.KnotAddMember_Input{Subject: aclSubject})) 158 if addRec.Code != http.StatusOK { 159 t.Fatalf("setup add status = %d", addRec.Code) 160 } 161 162 rec := httptest.NewRecorder() 163 x.RemoveMember(rec, aclRequest(t, aclOwner, tangled.KnotRemoveMember_Input{Subject: aclSubject})) 164 if rec.Code != http.StatusOK { 165 t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) 166 } 167 if n, _ := db.CountKnotMembersBySubject(x.Db, aclSubject); n != 0 { 168 t.Errorf("member rows = %d, want 0", n) 169 } 170 if isM, _ := x.Enforcer.IsKnotMember(aclSubject, rbac.ThisServer); isM { 171 t.Error("IsKnotMember still true after remove") 172 } 173 if len(ing.removed) != 1 || ing.removed[0] != aclSubject { 174 t.Errorf("ingester.removed = %v, want [%s]", ing.removed, aclSubject) 175 } 176} 177 178func TestRemoveMember_KeepsDidWhenOtherPolicyExists(t *testing.T) { 179 x, ing := newACLXrpc(t) 180 addRec := httptest.NewRecorder() 181 x.AddMember(addRec, aclRequest(t, aclOwner, tangled.KnotAddMember_Input{Subject: aclSubject})) 182 if addRec.Code != http.StatusOK { 183 t.Fatalf("setup add status = %d", addRec.Code) 184 } 185 if err := x.Enforcer.AddRepo(aclSubject, rbac.ThisServer, aclRepoDid); err != nil { 186 t.Fatalf("AddRepo: %v", err) 187 } 188 189 rec := httptest.NewRecorder() 190 x.RemoveMember(rec, aclRequest(t, aclOwner, tangled.KnotRemoveMember_Input{Subject: aclSubject})) 191 if rec.Code != http.StatusOK { 192 t.Fatalf("status = %d, want 200", rec.Code) 193 } 194 if len(ing.removed) != 0 { 195 t.Errorf("ingester.removed = %v, want empty; did still has repo policy", ing.removed) 196 } 197} 198 199func TestRemoveMember_NonMemberNoop(t *testing.T) { 200 x, _ := newACLXrpc(t) 201 rec := httptest.NewRecorder() 202 x.RemoveMember(rec, aclRequest(t, aclOwner, tangled.KnotRemoveMember_Input{Subject: aclSubject})) 203 if rec.Code != http.StatusOK { 204 t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) 205 } 206 if isM, _ := x.Enforcer.IsKnotMember(aclSubject, rbac.ThisServer); isM { 207 t.Error("non-member became a member after remove no-op") 208 } 209} 210 211func TestRemoveCollaborator_NonCollaboratorNoop(t *testing.T) { 212 x, _ := newACLXrpc(t) 213 seedRepo(t, x) 214 rec := httptest.NewRecorder() 215 x.RemoveCollaborator(rec, aclRequest(t, aclOwner, tangled.RepoRemoveCollaborator_Input{Repo: aclRepoDid, Subject: aclSubject})) 216 if rec.Code != http.StatusOK { 217 t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) 218 } 219 if isC, _ := x.Enforcer.IsRepoCollaborator(aclSubject, rbac.ThisServer, aclRepoDid); isC { 220 t.Error("non-collaborator became a collaborator after remove no-op") 221 } 222} 223 224func TestAddCollaborator_HappyPath(t *testing.T) { 225 x, ing := newACLXrpc(t) 226 seedRepo(t, x) 227 228 rec := httptest.NewRecorder() 229 x.AddCollaborator(rec, aclRequest(t, aclOwner, tangled.RepoAddCollaborator_Input{Repo: aclRepoDid, Subject: aclSubject})) 230 if rec.Code != http.StatusOK { 231 t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) 232 } 233 list, _, err := db.ListCollaborators(x.Db, syntax.DID(aclRepoDid), db.ListPage{Limit: db.ListMaxLimit}) 234 if err != nil || len(list) != 1 || list[0].Subject != syntax.DID(aclSubject) { 235 t.Errorf("collaborators = %+v (err %v), want one boltless", list, err) 236 } 237 if isC, _ := x.Enforcer.IsRepoCollaborator(aclSubject, rbac.ThisServer, aclRepoDid); !isC { 238 t.Error("IsRepoCollaborator false after add") 239 } 240 if len(ing.added) != 1 || ing.added[0] != aclSubject { 241 t.Errorf("ingester.added = %v", ing.added) 242 } 243} 244 245func TestAddCollaborator_MalformedRepoBadRequest(t *testing.T) { 246 x, _ := newACLXrpc(t) 247 seedRepo(t, x) 248 rec := httptest.NewRecorder() 249 x.AddCollaborator(rec, aclRequest(t, aclOwner, tangled.RepoAddCollaborator_Input{Repo: "notadid", Subject: aclSubject})) 250 if rec.Code != http.StatusBadRequest { 251 t.Errorf("status = %d, want 400", rec.Code) 252 } 253} 254 255func TestAddCollaborator_UnknownRepoNotFound(t *testing.T) { 256 x, _ := newACLXrpc(t) 257 rec := httptest.NewRecorder() 258 x.AddCollaborator(rec, aclRequest(t, aclOwner, tangled.RepoAddCollaborator_Input{Repo: "did:plc:scallop", Subject: aclSubject})) 259 if rec.Code != http.StatusNotFound { 260 t.Errorf("status = %d, want 404", rec.Code) 261 } 262} 263 264func TestAddCollaborator_NoInvitePermissionForbidden(t *testing.T) { 265 x, _ := newACLXrpc(t) 266 if err := x.Db.StoreRepoKey(aclRepoDid, []byte("signing"), aclOwner, "reponame"); err != nil { 267 t.Fatalf("StoreRepoKey: %v", err) 268 } 269 rec := httptest.NewRecorder() 270 x.AddCollaborator(rec, aclRequest(t, aclOwner, tangled.RepoAddCollaborator_Input{Repo: aclRepoDid, Subject: aclSubject})) 271 if rec.Code != http.StatusForbidden { 272 t.Errorf("status = %d, want 403", rec.Code) 273 } 274} 275 276func TestRemoveCollaborator_HappyPathClearsBothStores(t *testing.T) { 277 x, ing := newACLXrpc(t) 278 seedRepo(t, x) 279 addRec := httptest.NewRecorder() 280 x.AddCollaborator(addRec, aclRequest(t, aclOwner, tangled.RepoAddCollaborator_Input{Repo: aclRepoDid, Subject: aclSubject})) 281 if addRec.Code != http.StatusOK { 282 t.Fatalf("setup add status = %d", addRec.Code) 283 } 284 285 rec := httptest.NewRecorder() 286 x.RemoveCollaborator(rec, aclRequest(t, aclOwner, tangled.RepoRemoveCollaborator_Input{Repo: aclRepoDid, Subject: aclSubject})) 287 if rec.Code != http.StatusOK { 288 t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) 289 } 290 if list, _, _ := db.ListCollaborators(x.Db, syntax.DID(aclRepoDid), db.ListPage{Limit: db.ListMaxLimit}); len(list) != 0 { 291 t.Errorf("collaborators = %d, want 0", len(list)) 292 } 293 if isC, _ := x.Enforcer.IsRepoCollaborator(aclSubject, rbac.ThisServer, aclRepoDid); isC { 294 t.Error("IsRepoCollaborator still true after remove") 295 } 296 if len(ing.removed) != 1 || ing.removed[0] != aclSubject { 297 t.Errorf("ingester.removed = %v", ing.removed) 298 } 299} 300 301func TestRemoveCollaborator_MalformedRepoBadRequest(t *testing.T) { 302 x, _ := newACLXrpc(t) 303 rec := httptest.NewRecorder() 304 x.RemoveCollaborator(rec, aclRequest(t, aclOwner, tangled.RepoRemoveCollaborator_Input{Repo: "notadid", Subject: aclSubject})) 305 if rec.Code != http.StatusBadRequest { 306 t.Errorf("status = %d, want 400", rec.Code) 307 } 308} 309 310func TestRemoveCollaborator_UnknownRepoNotFound(t *testing.T) { 311 x, _ := newACLXrpc(t) 312 rec := httptest.NewRecorder() 313 x.RemoveCollaborator(rec, aclRequest(t, aclOwner, tangled.RepoRemoveCollaborator_Input{Repo: "did:plc:scallop", Subject: aclSubject})) 314 if rec.Code != http.StatusNotFound { 315 t.Errorf("status = %d, want 404", rec.Code) 316 } 317} 318 319func TestRemoveCollaborator_OwnerKeepsRepoRights(t *testing.T) { 320 x, _ := newACLXrpc(t) 321 seedRepo(t, x) 322 323 if ok, _ := x.Enforcer.IsSettingsAllowed(aclOwner, rbac.ThisServer, aclRepoDid); !ok { 324 t.Fatal("precondition: owner lacks repo:settings") 325 } 326 if ok, _ := x.Enforcer.IsPushAllowed(aclOwner, rbac.ThisServer, aclRepoDid); !ok { 327 t.Fatal("precondition: owner lacks repo:push") 328 } 329 330 rec := httptest.NewRecorder() 331 x.RemoveCollaborator(rec, aclRequest(t, aclOwner, tangled.RepoRemoveCollaborator_Input{Repo: aclRepoDid, Subject: aclOwner})) 332 if rec.Code != http.StatusOK { 333 t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) 334 } 335 if ok, _ := x.Enforcer.IsSettingsAllowed(aclOwner, rbac.ThisServer, aclRepoDid); !ok { 336 t.Error("owner lost repo:settings after removeCollaborator(owner)") 337 } 338 if ok, _ := x.Enforcer.IsPushAllowed(aclOwner, rbac.ThisServer, aclRepoDid); !ok { 339 t.Error("owner lost repo:push after removeCollaborator(owner)") 340 } 341 if ok, _ := x.Enforcer.IsRepoOwner(aclOwner, rbac.ThisServer, aclRepoDid); !ok { 342 t.Error("owner lost repo:owner after removeCollaborator(owner)") 343 } 344} 345 346func TestAddCollaborator_OwnerIsNoOp(t *testing.T) { 347 x, ing := newACLXrpc(t) 348 seedRepo(t, x) 349 350 rec := httptest.NewRecorder() 351 x.AddCollaborator(rec, aclRequest(t, aclOwner, tangled.RepoAddCollaborator_Input{Repo: aclRepoDid, Subject: aclOwner})) 352 if rec.Code != http.StatusOK { 353 t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) 354 } 355 if list, _, _ := db.ListCollaborators(x.Db, syntax.DID(aclRepoDid), db.ListPage{Limit: db.ListMaxLimit}); len(list) != 0 { 356 t.Errorf("collaborators = %d, want 0; owner must not become a redundant collaborator row", len(list)) 357 } 358 if len(ing.added) != 0 { 359 t.Errorf("ingester.added = %v, want empty for an owner no-op", ing.added) 360 } 361} 362 363func TestRemoveMember_OwnerRejected(t *testing.T) { 364 x, _ := newACLXrpc(t) 365 rec := httptest.NewRecorder() 366 x.RemoveMember(rec, aclRequest(t, aclOwner, tangled.KnotRemoveMember_Input{Subject: aclOwner})) 367 if rec.Code != http.StatusBadRequest { 368 t.Fatalf("status = %d, want 400 for removing the owner", rec.Code) 369 } 370 if isOwner, _ := x.Enforcer.IsKnotOwner(aclOwner, rbac.ThisServer); !isOwner { 371 t.Error("owner demoted by removeMember") 372 } 373 if isM, _ := x.Enforcer.IsKnotMember(aclOwner, rbac.ThisServer); !isM { 374 t.Error("owner lost membership after removeMember") 375 } 376} 377 378func TestAddMember_HealsCasbinOnlyDrift(t *testing.T) { 379 x, _ := newACLXrpc(t) 380 if _, err := x.Enforcer.TryAddKnotMember(rbac.ThisServer, aclSubject); err != nil { 381 t.Fatalf("seed casbin-only membership: %v", err) 382 } 383 if n, _ := db.CountKnotMembersBySubject(x.Db, aclSubject); n != 0 { 384 t.Fatalf("precondition: table already has %d rows", n) 385 } 386 387 rec := httptest.NewRecorder() 388 x.AddMember(rec, aclRequest(t, aclOwner, tangled.KnotAddMember_Input{Subject: aclSubject})) 389 if rec.Code != http.StatusOK { 390 t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) 391 } 392 if n, _ := db.CountKnotMembersBySubject(x.Db, aclSubject); n != 1 { 393 t.Errorf("member rows = %d, want 1; canonical table must be healed", n) 394 } 395} 396 397func TestAddCollaborator_HealsCasbinOnlyDrift(t *testing.T) { 398 x, _ := newACLXrpc(t) 399 seedRepo(t, x) 400 if err := x.Enforcer.AddCollaborator(aclSubject, rbac.ThisServer, aclRepoDid); err != nil { 401 t.Fatalf("seed casbin-only collaborator: %v", err) 402 } 403 if list, _, _ := db.ListCollaborators(x.Db, syntax.DID(aclRepoDid), db.ListPage{Limit: db.ListMaxLimit}); len(list) != 0 { 404 t.Fatalf("precondition: table already has %d rows", len(list)) 405 } 406 407 rec := httptest.NewRecorder() 408 x.AddCollaborator(rec, aclRequest(t, aclOwner, tangled.RepoAddCollaborator_Input{Repo: aclRepoDid, Subject: aclSubject})) 409 if rec.Code != http.StatusOK { 410 t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) 411 } 412 if list, _, _ := db.ListCollaborators(x.Db, syntax.DID(aclRepoDid), db.ListPage{Limit: db.ListMaxLimit}); len(list) != 1 { 413 t.Errorf("collaborators = %d, want 1; canonical table must be healed", len(list)) 414 } 415} 416 417func adminAddReq(t *testing.T, user, pass, subject string) *http.Request { 418 t.Helper() 419 var buf bytes.Buffer 420 if err := json.NewEncoder(&buf).Encode(tangled.KnotAddMember_Input{Subject: subject}); err != nil { 421 t.Fatalf("encode body: %v", err) 422 } 423 req := httptest.NewRequest(http.MethodPost, "/addMember", &buf) 424 if user != "" || pass != "" { 425 req.SetBasicAuth(user, pass) 426 } 427 return req 428} 429 430func TestAddMemberAdmin_RejectsBadCredentials(t *testing.T) { 431 x, _ := newACLXrpc(t) 432 x.Config.Server.AdminSecret = "hunter2" 433 x.Config.Server.Owner = aclOwner 434 srv := x.AdminRouter() 435 436 cases := []struct { 437 name string 438 user string 439 pass string 440 }{ 441 {"no credentials", "", ""}, 442 {"wrong secret", "admin", "nope"}, 443 {"wrong user", "root", "hunter2"}, 444 } 445 for _, c := range cases { 446 t.Run(c.name, func(t *testing.T) { 447 rec := httptest.NewRecorder() 448 srv.ServeHTTP(rec, adminAddReq(t, c.user, c.pass, "did:plc:whelk")) 449 if rec.Code != http.StatusUnauthorized { 450 t.Fatalf("status = %d, want 401", rec.Code) 451 } 452 if n, _ := db.CountKnotMembersBySubject(x.Db, "did:plc:whelk"); n != 0 { 453 t.Errorf("member rows = %d, want 0; bad credentials must not add", n) 454 } 455 }) 456 } 457} 458 459func TestAddMemberAdmin_RejectsWhenSecretUnset(t *testing.T) { 460 x, _ := newACLXrpc(t) 461 x.Config.Server.Owner = aclOwner 462 srv := x.AdminRouter() 463 464 rec := httptest.NewRecorder() 465 srv.ServeHTTP(rec, adminAddReq(t, "admin", "hunter2", "did:plc:whelk")) 466 if rec.Code != http.StatusUnauthorized { 467 t.Fatalf("status = %d, want 401; admin api must stay closed with no secret configured", rec.Code) 468 } 469} 470 471func TestAddMemberAdmin_AddsMember(t *testing.T) { 472 x, ing := newACLXrpc(t) 473 x.Config.Server.AdminSecret = "hunter2" 474 x.Config.Server.Owner = aclOwner 475 srv := x.AdminRouter() 476 477 rec := httptest.NewRecorder() 478 srv.ServeHTTP(rec, adminAddReq(t, "admin", "hunter2", "did:plc:whelk")) 479 if rec.Code != http.StatusOK { 480 t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) 481 } 482 if n, _ := db.CountKnotMembersBySubject(x.Db, "did:plc:whelk"); n != 1 { 483 t.Errorf("member rows = %d, want 1 after admin add", n) 484 } 485 if isM, _ := x.Enforcer.IsKnotMember("did:plc:whelk", rbac.ThisServer); !isM { 486 t.Error("IsKnotMember false after admin add") 487 } 488 if len(ing.added) != 1 || ing.added[0] != "did:plc:whelk" { 489 t.Errorf("ingester.added = %v, want [did:plc:whelk]", ing.added) 490 } 491}