Monorepo for Tangled tangled.org
2

Configure Feed

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

knotserver/xrpc: test member & collaborator handlers

Lewis: May this revision serve well! <lewis@tangled.org>

author
Lewis
committer
Tangled
date (Jun 16, 2026, 9:04 PM +0300) commit 2fd598e8 parent d8a997b4 change-id psmnvwoq
+491
+491
knotserver/xrpc/members_collaborators_test.go
··· 1 + package xrpc 2 + 3 + import ( 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 + 22 + const ( 23 + aclOwner = "did:plc:akshay" 24 + aclSubject = "did:plc:boltless" 25 + aclRepoDid = "did:plc:limpet" 26 + ) 27 + 28 + type fakeIngester struct { 29 + added []string 30 + removed []string 31 + } 32 + 33 + func (f *fakeIngester) AddDid(did string) { f.added = append(f.added, did) } 34 + func (f *fakeIngester) RemoveDid(did string) { f.removed = append(f.removed, did) } 35 + 36 + func 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 + 65 + func 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 + 80 + func 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 + 90 + func 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 + 110 + func 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 + 127 + func 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 + 136 + func 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 + 145 + func 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 + 154 + func 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 + 178 + func 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 + 199 + func 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 + 211 + func 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 + 224 + func 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 + 245 + func 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 + 255 + func 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 + 264 + func 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 + 276 + func 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 + 301 + func 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 + 310 + func 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 + 319 + func 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 + 346 + func 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 + 363 + func 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 + 378 + func 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 + 397 + func 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 + 417 + func 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 + 430 + func 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 + 459 + func 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 + 471 + func 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 + }