Monorepo for Tangled tangled.org
5

Configure Feed

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

1package knotacl 2 3import ( 4 "context" 5 "encoding/json" 6 "errors" 7 "net/http" 8 "net/http/httptest" 9 "path/filepath" 10 "slices" 11 "strings" 12 "sync" 13 "testing" 14 15 "tangled.org/core/api/tangled" 16 "tangled.org/core/appview/db" 17 "tangled.org/core/appview/models" 18 "tangled.org/core/consts" 19 "tangled.org/core/orm" 20 "tangled.org/core/rbac" 21) 22 23var capsKnotACL = []string{string(consts.CapKnotACL)} 24 25type fakeKnot struct { 26 version string 27 capabilities []string 28 members []string 29 collaborators []string 30 listStatus int 31 32 mu sync.Mutex 33 listHits int 34} 35 36func (k *fakeKnot) ServeHTTP(w http.ResponseWriter, r *http.Request) { 37 switch { 38 case strings.HasSuffix(r.URL.Path, tangled.KnotVersionNSID): 39 if k.version == "" { 40 http.Error(w, "version down", http.StatusInternalServerError) 41 return 42 } 43 json.NewEncoder(w).Encode(tangled.KnotVersion_Output{Version: k.version, Capabilities: k.capabilities}) 44 case strings.HasSuffix(r.URL.Path, tangled.KnotListMembersNSID): 45 k.hit() 46 if k.listStatus != 0 { 47 http.Error(w, "list down", k.listStatus) 48 return 49 } 50 json.NewEncoder(w).Encode(memberPage(k.members, "")) 51 case strings.HasSuffix(r.URL.Path, tangled.RepoListCollaboratorsNSID): 52 k.hit() 53 if k.listStatus != 0 { 54 http.Error(w, "list down", k.listStatus) 55 return 56 } 57 json.NewEncoder(w).Encode(collabPage(k.collaborators, "")) 58 default: 59 http.NotFound(w, r) 60 } 61} 62 63func (k *fakeKnot) hit() { 64 k.mu.Lock() 65 k.listHits++ 66 k.mu.Unlock() 67} 68 69func (k *fakeKnot) hits() int { 70 k.mu.Lock() 71 defer k.mu.Unlock() 72 return k.listHits 73} 74 75func newServiceEnv(t *testing.T, knot *fakeKnot, seed func(e *rbac.Enforcer, host string)) (*Service, *db.DB, string) { 76 t.Helper() 77 srv := httptest.NewServer(knot) 78 t.Cleanup(srv.Close) 79 host := strings.TrimPrefix(srv.URL, "http://") 80 81 dir := t.TempDir() 82 enforcer, err := rbac.NewEnforcer(filepath.Join(dir, "rbac.db")) 83 if err != nil { 84 t.Fatalf("NewEnforcer: %v", err) 85 } 86 if err := enforcer.AddKnot(host); err != nil { 87 t.Fatalf("AddKnot: %v", err) 88 } 89 if err := enforcer.AddKnotOwner(host, testOwner); err != nil { 90 t.Fatalf("AddKnotOwner: %v", err) 91 } 92 if seed != nil { 93 seed(enforcer, host) 94 } 95 96 d, err := db.Make(context.Background(), filepath.Join(dir, "appview.db")) 97 if err != nil { 98 t.Fatalf("db.Make: %v", err) 99 } 100 101 return NewService(enforcer, d, true, testLogger()), d, host 102} 103 104func testRepo(host string) *models.Repo { 105 return &models.Repo{Did: testOwner, Knot: host, RepoDid: testRepoDid, Name: "anemone"} 106} 107 108func seedRepoPolicies(t *testing.T, e *rbac.Enforcer, host string) { 109 t.Helper() 110 if err := e.AddRepo(testOwner, host, testRepoDid); err != nil { 111 t.Fatalf("AddRepo: %v", err) 112 } 113 if err := e.AddCollaborator(testCollab, host, testRepoDid); err != nil { 114 t.Fatalf("AddCollaborator: %v", err) 115 } 116} 117 118func sortedRoles(roles []string) []string { 119 s := slices.Clone(roles) 120 slices.Sort(s) 121 return slices.Compact(s) 122} 123 124func TestService_OldKnotUsesCasbinNoLiveQuery(t *testing.T) { 125 ctx := context.Background() 126 knot := &fakeKnot{version: "v1.14.0"} 127 svc, _, host := newServiceEnv(t, knot, func(e *rbac.Enforcer, h string) { seedRepoPolicies(t, e, h) }) 128 129 collab := svc.RolesInRepo(ctx, testRepo(host), testCollab) 130 if !collab.IsCollaborator() || !collab.IsPushAllowed() { 131 t.Errorf("collaborator roles = %v, want collaborator+push from casbin", collab.Roles) 132 } 133 if !svc.HasRepoPermission(ctx, testRepo(host), testOwner, "repo:owner") { 134 t.Error("owner should hold repo:owner via casbin") 135 } 136 if knot.hits() != 0 { 137 t.Errorf("listHits = %d, want 0; an old knot must never be live-queried", knot.hits()) 138 } 139} 140 141func TestService_ParityOldVsNew(t *testing.T) { 142 ctx := context.Background() 143 oldSvc, _, oldHost := newServiceEnv(t, &fakeKnot{version: "v1.14.0"}, func(e *rbac.Enforcer, h string) { seedRepoPolicies(t, e, h) }) 144 newSvc, _, newHost := newServiceEnv(t, &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL, collaborators: []string{testCollab}}, nil) 145 146 for _, did := range []string{testOwner, testCollab} { 147 oldRoles := sortedRoles(oldSvc.RolesInRepo(ctx, testRepo(oldHost), did).Roles) 148 newRoles := sortedRoles(newSvc.RolesInRepo(ctx, testRepo(newHost), did).Roles) 149 if !slices.Equal(oldRoles, newRoles) { 150 t.Errorf("did %s: casbin roles %v != synth roles %v; synthesis has drifted from the policy grants", did, oldRoles, newRoles) 151 } 152 } 153} 154 155func TestService_NewKnotOwnerFromRecord(t *testing.T) { 156 ctx := context.Background() 157 svc, _, host := newServiceEnv(t, &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL}, nil) 158 159 owner := svc.RolesInRepo(ctx, testRepo(host), testOwner) 160 if !owner.IsOwner() || !owner.IsPushAllowed() || !owner.SettingsAllowed() || !owner.RepoDeleteAllowed() { 161 t.Errorf("owner roles = %v, want the full owner set derived from repo.Did", owner.Roles) 162 } 163 if stranger := svc.RolesInRepo(ctx, testRepo(host), testStrange); len(stranger.Roles) != 0 { 164 t.Errorf("stranger roles = %v, want empty", stranger.Roles) 165 } 166} 167 168func TestService_NewKnotCollaboratorFromList(t *testing.T) { 169 ctx := context.Background() 170 svc, _, host := newServiceEnv(t, &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL, collaborators: []string{testCollab}}, nil) 171 172 collab := svc.RolesInRepo(ctx, testRepo(host), testCollab) 173 if !collab.IsCollaborator() || !collab.IsPushAllowed() { 174 t.Errorf("collaborator roles = %v, want collaborator+push from the live list", collab.Roles) 175 } 176 if collab.IsOwner() || collab.RepoDeleteAllowed() { 177 t.Errorf("collaborator must not hold owner/delete: %v", collab.Roles) 178 } 179} 180 181func TestService_MixedFleet(t *testing.T) { 182 ctx := context.Background() 183 oldSvc, _, oldHost := newServiceEnv(t, &fakeKnot{version: "v1.14.0"}, func(e *rbac.Enforcer, h string) { seedRepoPolicies(t, e, h) }) 184 newSvc, _, newHost := newServiceEnv(t, &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL, collaborators: []string{testCollab}}, nil) 185 186 if !newSvc.RolesInRepo(ctx, testRepo(newHost), testCollab).IsCollaborator() { 187 t.Error("new-knot collaborator must resolve from the live query with an empty casbin") 188 } 189 if !newSvc.RolesInRepo(ctx, testRepo(newHost), testOwner).IsOwner() { 190 t.Error("new-knot owner must resolve from repo.Did") 191 } 192 if !oldSvc.RolesInRepo(ctx, testRepo(oldHost), testCollab).IsCollaborator() { 193 t.Error("old-knot collaborator must resolve from casbin") 194 } 195} 196 197func TestService_IsRepoCreateAllowed(t *testing.T) { 198 ctx := context.Background() 199 200 oldSvc, _, oldHost := newServiceEnv(t, &fakeKnot{version: "v1.14.0"}, func(e *rbac.Enforcer, h string) { 201 if _, err := e.TryAddKnotMember(h, testCollab); err != nil { 202 t.Fatalf("TryAddKnotMember: %v", err) 203 } 204 }) 205 if !oldSvc.IsRepoCreateAllowed(ctx, oldHost, testCollab) { 206 t.Error("old-knot member should be allowed to create") 207 } 208 if oldSvc.IsRepoCreateAllowed(ctx, oldHost, testStrange) { 209 t.Error("old-knot non-member should not be allowed to create") 210 } 211 212 memberSvc, _, memberHost := newServiceEnv(t, &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL, members: []string{testCollab}}, nil) 213 if !memberSvc.IsRepoCreateAllowed(ctx, memberHost, testCollab) { 214 t.Error("new-knot listed member should be allowed to create") 215 } 216 217 ownerSvc, ownerDb, ownerHost := newServiceEnv(t, &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL}, nil) 218 if err := db.AddKnot(ownerDb, ownerHost, testOwner); err != nil { 219 t.Fatalf("db.AddKnot: %v", err) 220 } 221 if err := db.MarkRegistered(ownerDb, orm.FilterEq("domain", ownerHost), orm.FilterEq("did", testOwner)); err != nil { 222 t.Fatalf("MarkRegistered: %v", err) 223 } 224 if !ownerSvc.IsRepoCreateAllowed(ctx, ownerHost, testOwner) { 225 t.Error("new-knot registered owner should be allowed to create even when absent from listMembers") 226 } 227 if ownerSvc.IsRepoCreateAllowed(ctx, ownerHost, testStrange) { 228 t.Error("new-knot non-member non-owner should not be allowed to create") 229 } 230} 231 232func TestService_KnotDownDegrades(t *testing.T) { 233 ctx := context.Background() 234 knot := &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL, listStatus: http.StatusInternalServerError} 235 svc, _, host := newServiceEnv(t, knot, nil) 236 repo := testRepo(host) 237 238 if !svc.RolesInRepo(ctx, repo, testOwner).IsOwner() { 239 t.Error("owner must still resolve from repo.Did when the knot list is down") 240 } 241 if roles := svc.RolesInRepo(ctx, repo, testCollab); len(roles.Roles) != 0 { 242 t.Errorf("non-owner roles when knot down = %v, want empty (degrade, not error)", roles.Roles) 243 } 244 if collabs := svc.Collaborators(ctx, repo); len(collabs) != 1 || collabs[0].Did != testOwner || collabs[0].Role != "owner" { 245 t.Errorf("Collaborators when knot down = %v, want only the owner row", collabs) 246 } 247 if m := svc.KnotMembers(ctx, host); m != nil { 248 t.Errorf("KnotMembers when knot down = %v, want nil", m) 249 } 250 if svc.IsRepoCreateAllowed(ctx, host, testStrange) { 251 t.Error("create gate must be false when the knot is down and the user is not a registered owner") 252 } 253} 254 255func TestService_KnotOwnerForeignRepoParity(t *testing.T) { 256 ctx := context.Background() 257 foreignRepoDid := "did:plc:whelk" 258 259 oldSvc, _, oldHost := newServiceEnv(t, &fakeKnot{version: "v1.14.0"}, func(e *rbac.Enforcer, h string) { 260 if err := e.AddRepo(testCollab, h, foreignRepoDid); err != nil { 261 t.Fatalf("AddRepo: %v", err) 262 } 263 }) 264 oldRepo := &models.Repo{Did: testCollab, Knot: oldHost, RepoDid: foreignRepoDid, Name: "barnacle"} 265 oldRoles := sortedRoles(oldSvc.RolesInRepo(ctx, oldRepo, testOwner).Roles) 266 267 newSvc, newDb, newHost := newServiceEnv(t, &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL}, nil) 268 if err := db.AddKnot(newDb, newHost, testOwner); err != nil { 269 t.Fatalf("db.AddKnot: %v", err) 270 } 271 if err := db.MarkRegistered(newDb, orm.FilterEq("domain", newHost), orm.FilterEq("did", testOwner)); err != nil { 272 t.Fatalf("MarkRegistered: %v", err) 273 } 274 newRepo := &models.Repo{Did: testCollab, Knot: newHost, RepoDid: foreignRepoDid, Name: "barnacle"} 275 newRoles := sortedRoles(newSvc.RolesInRepo(ctx, newRepo, testOwner).Roles) 276 277 if !slices.Equal(oldRoles, newRoles) { 278 t.Errorf("knot operator on a member repo: old=%v new=%v; the appview gate must not diverge by knot version", oldRoles, newRoles) 279 } 280 if !slices.Contains(newRoles, "repo:delete") { 281 t.Errorf("knot operator must retain repo:delete on a member repo, got %v", newRoles) 282 } 283 if newSvc.HasRepoPermission(ctx, newRepo, testStrange, "repo:delete") { 284 t.Error("a stranger must not hold repo:delete on a foreign repo") 285 } 286} 287 288func TestService_KnotMembersIncludesOwner(t *testing.T) { 289 ctx := context.Background() 290 svc, d, host := newServiceEnv(t, &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL, members: []string{testCollab}}, nil) 291 if err := db.AddKnot(d, host, testOwner); err != nil { 292 t.Fatalf("db.AddKnot: %v", err) 293 } 294 if err := db.MarkRegistered(d, orm.FilterEq("domain", host), orm.FilterEq("did", testOwner)); err != nil { 295 t.Fatalf("MarkRegistered: %v", err) 296 } 297 298 members := svc.KnotMembers(ctx, host) 299 if !slices.Contains(members, testOwner) { 300 t.Errorf("new-knot roster %v must include the registered owner so the dashboard renders the owner's repos", members) 301 } 302 if !slices.Contains(members, testCollab) { 303 t.Errorf("new-knot roster %v must include listed members", members) 304 } 305} 306 307func TestService_CollaboratorsNewKnot(t *testing.T) { 308 ctx := context.Background() 309 310 svc, _, host := newServiceEnv(t, &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL, collaborators: []string{testCollab}}, nil) 311 collabs := svc.Collaborators(ctx, testRepo(host)) 312 if len(collabs) != 2 { 313 t.Fatalf("Collaborators = %v, want owner + one collaborator", collabs) 314 } 315 if collabs[0].Did != testOwner || collabs[0].Role != "owner" { 316 t.Errorf("first row = %v, want the owner", collabs[0]) 317 } 318 if collabs[1].Did != testCollab || collabs[1].Role != "collaborator" { 319 t.Errorf("second row = %v, want the collaborator", collabs[1]) 320 } 321 322 dupSvc, _, dupHost := newServiceEnv(t, &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL, collaborators: []string{testCollab, testOwner}}, nil) 323 rows := dupSvc.Collaborators(ctx, testRepo(dupHost)) 324 ownerRows := 0 325 for _, c := range rows { 326 if c.Did == testOwner { 327 ownerRows++ 328 } 329 } 330 if ownerRows != 1 { 331 t.Errorf("owner listed %d times, want exactly the single owner row: %v", ownerRows, rows) 332 } 333} 334 335func TestService_HasRepoPermissionErr_OwnerNeedsNoLiveQuery(t *testing.T) { 336 ctx := context.Background() 337 knot := &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL, listStatus: http.StatusInternalServerError} 338 svc, _, host := newServiceEnv(t, knot, nil) 339 340 ok, err := svc.HasRepoPermissionErr(ctx, testRepo(host), testOwner, "repo:push") 341 if err != nil || !ok { 342 t.Errorf("owner push = (%v, %v), want (true, nil) resolved from repo.Did with the list down", ok, err) 343 } 344 if knot.hits() != 0 { 345 t.Errorf("listHits = %d, want 0; the owner must not trigger a live query", knot.hits()) 346 } 347} 348 349func TestService_HasRepoPermissionErr_KnotDownIsUndetermined(t *testing.T) { 350 ctx := context.Background() 351 knot := &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL, listStatus: http.StatusInternalServerError} 352 svc, _, host := newServiceEnv(t, knot, nil) 353 354 ok, err := svc.HasRepoPermissionErr(ctx, testRepo(host), testCollab, "repo:push") 355 if !errors.Is(err, ErrKnotUnreachable) { 356 t.Errorf("err = %v, want ErrKnotUnreachable so the ingester can fail open instead of dropping the record", err) 357 } 358 if ok { 359 t.Error("ok must be false when the answer is undetermined") 360 } 361 if svc.HasRepoPermission(ctx, testRepo(host), testCollab, "repo:push") { 362 t.Error("HasRepoPermission must fail closed when the knot is unreachable") 363 } 364} 365 366func TestService_HasRepoPermissionErr_DefinitiveDenyIsNotUndetermined(t *testing.T) { 367 ctx := context.Background() 368 svc, _, host := newServiceEnv(t, &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL}, nil) 369 370 ok, err := svc.HasRepoPermissionErr(ctx, testRepo(host), testStrange, "repo:push") 371 if err != nil { 372 t.Errorf("err = %v, want nil; a reachable knot gives a definitive answer", err) 373 } 374 if ok { 375 t.Error("a stranger must not hold push") 376 } 377} 378 379func TestService_KnotMembersDegradesToOwner(t *testing.T) { 380 ctx := context.Background() 381 knot := &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL, listStatus: http.StatusInternalServerError} 382 svc, d, host := newServiceEnv(t, knot, nil) 383 if err := db.AddKnot(d, host, testOwner); err != nil { 384 t.Fatalf("db.AddKnot: %v", err) 385 } 386 if err := db.MarkRegistered(d, orm.FilterEq("domain", host), orm.FilterEq("did", testOwner)); err != nil { 387 t.Fatalf("MarkRegistered: %v", err) 388 } 389 390 members := svc.KnotMembers(ctx, host) 391 if !slices.Contains(members, testOwner) { 392 t.Errorf("KnotMembers with the list down = %v, want the registered owner so the dashboard still renders", members) 393 } 394} 395 396func TestService_VersionDownFailsClosedToCasbin(t *testing.T) { 397 ctx := context.Background() 398 knot := &fakeKnot{version: ""} 399 svc, _, host := newServiceEnv(t, knot, func(e *rbac.Enforcer, h string) { seedRepoPolicies(t, e, h) }) 400 401 if !svc.RolesInRepo(ctx, testRepo(host), testCollab).IsCollaborator() { 402 t.Error("an unreachable version probe must fail closed to the casbin path, which knows the collaborator") 403 } 404 if knot.hits() != 0 { 405 t.Errorf("listHits = %d; failing closed must route to casbin, never the live list", knot.hits()) 406 } 407} 408 409func TestService_RegisteredOwnersMemoizedPerRequest(t *testing.T) { 410 svc, d, host := newServiceEnv(t, &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL}, nil) 411 if err := db.AddKnot(d, host, testOwner); err != nil { 412 t.Fatalf("db.AddKnot: %v", err) 413 } 414 if err := db.MarkRegistered(d, orm.FilterEq("domain", host), orm.FilterEq("did", testOwner)); err != nil { 415 t.Fatalf("MarkRegistered: %v", err) 416 } 417 418 ctx := WithMemo(context.Background()) 419 first := svc.nat.registeredOwners(ctx, host) 420 if !slices.Contains(first, testOwner) { 421 t.Fatalf("first read = %v, want testOwner", first) 422 } 423 424 if err := db.AddKnot(d, host, testCollab); err != nil { 425 t.Fatalf("db.AddKnot: %v", err) 426 } 427 if err := db.MarkRegistered(d, orm.FilterEq("domain", host), orm.FilterEq("did", testCollab)); err != nil { 428 t.Fatalf("MarkRegistered: %v", err) 429 } 430 431 if second := svc.nat.registeredOwners(ctx, host); slices.Contains(second, testCollab) { 432 t.Errorf("second read in the same request saw a newly added owner %v; the memo did not short-circuit the DB", second) 433 } 434 if fresh := svc.nat.registeredOwners(context.Background(), host); !slices.Contains(fresh, testCollab) { 435 t.Errorf("a fresh request %v must re-query and see the new owner", fresh) 436 } 437 438 first[0] = "did:plc:squid" 439 if again := svc.nat.registeredOwners(ctx, host); slices.Contains(again, "did:plc:squid") { 440 t.Errorf("mutating the returned slice corrupted the memo: %v", again) 441 } 442}