Monorepo for Tangled tangled.org
2

Configure Feed

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

at icy/qmlqxq 17 kB View raw
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 seedRepoRow(t *testing.T, d *db.DB, repo *models.Repo) { 109 t.Helper() 110 tx, err := d.Begin() 111 if err != nil { 112 t.Fatalf("begin: %v", err) 113 } 114 if err := db.AddRepo(tx, repo); err != nil { 115 t.Fatalf("AddRepo: %v", err) 116 } 117 if err := tx.Commit(); err != nil { 118 t.Fatalf("commit: %v", err) 119 } 120} 121 122func seedRepoPolicies(t *testing.T, e *rbac.Enforcer, host string) { 123 t.Helper() 124 if err := e.AddRepo(testOwner, host, testRepoDid); err != nil { 125 t.Fatalf("AddRepo: %v", err) 126 } 127 if err := e.AddCollaborator(testCollab, host, testRepoDid); err != nil { 128 t.Fatalf("AddCollaborator: %v", err) 129 } 130} 131 132func sortedRoles(roles []string) []string { 133 s := slices.Clone(roles) 134 slices.Sort(s) 135 return slices.Compact(s) 136} 137 138func TestService_OldKnotUsesCasbinNoLiveQuery(t *testing.T) { 139 ctx := context.Background() 140 knot := &fakeKnot{version: "v1.14.0"} 141 svc, _, host := newServiceEnv(t, knot, func(e *rbac.Enforcer, h string) { seedRepoPolicies(t, e, h) }) 142 143 collab := svc.RolesInRepo(ctx, testRepo(host), testCollab) 144 if !collab.IsCollaborator() || !collab.IsPushAllowed() { 145 t.Errorf("collaborator roles = %v, want collaborator+push from casbin", collab.Roles) 146 } 147 if !svc.HasRepoPermission(ctx, testRepo(host), testOwner, "repo:owner") { 148 t.Error("owner should hold repo:owner via casbin") 149 } 150 if knot.hits() != 0 { 151 t.Errorf("listHits = %d, want 0; an old knot must never be live-queried", knot.hits()) 152 } 153} 154 155func TestService_ParityOldVsNew(t *testing.T) { 156 ctx := context.Background() 157 oldSvc, _, oldHost := newServiceEnv(t, &fakeKnot{version: "v1.14.0"}, func(e *rbac.Enforcer, h string) { seedRepoPolicies(t, e, h) }) 158 newSvc, newDb, newHost := newServiceEnv(t, &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL, collaborators: []string{testCollab}}, nil) 159 seedRepoRow(t, newDb, testRepo(newHost)) 160 161 for _, did := range []string{testOwner, testCollab} { 162 oldRoles := sortedRoles(oldSvc.RolesInRepo(ctx, testRepo(oldHost), did).Roles) 163 newRoles := sortedRoles(newSvc.RolesInRepo(ctx, testRepo(newHost), did).Roles) 164 if !slices.Equal(oldRoles, newRoles) { 165 t.Errorf("did %s: casbin roles %v != synth roles %v; synthesis has drifted from the policy grants", did, oldRoles, newRoles) 166 } 167 } 168} 169 170func TestService_NewKnotOwnerFromRecord(t *testing.T) { 171 ctx := context.Background() 172 svc, _, host := newServiceEnv(t, &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL}, nil) 173 174 owner := svc.RolesInRepo(ctx, testRepo(host), testOwner) 175 if !owner.IsOwner() || !owner.IsPushAllowed() || !owner.SettingsAllowed() || !owner.RepoDeleteAllowed() { 176 t.Errorf("owner roles = %v, want the full owner set derived from repo.Did", owner.Roles) 177 } 178 if stranger := svc.RolesInRepo(ctx, testRepo(host), testStrange); len(stranger.Roles) != 0 { 179 t.Errorf("stranger roles = %v, want empty", stranger.Roles) 180 } 181} 182 183func TestService_NewKnotCollaboratorFromList(t *testing.T) { 184 ctx := context.Background() 185 svc, d, host := newServiceEnv(t, &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL, collaborators: []string{testCollab}}, nil) 186 seedRepoRow(t, d, testRepo(host)) 187 188 collab := svc.RolesInRepo(ctx, testRepo(host), testCollab) 189 if !collab.IsCollaborator() || !collab.IsPushAllowed() { 190 t.Errorf("collaborator roles = %v, want collaborator+push from the live list", collab.Roles) 191 } 192 if collab.IsOwner() || collab.RepoDeleteAllowed() { 193 t.Errorf("collaborator must not hold owner/delete: %v", collab.Roles) 194 } 195} 196 197func TestService_MixedFleet(t *testing.T) { 198 ctx := context.Background() 199 oldSvc, _, oldHost := newServiceEnv(t, &fakeKnot{version: "v1.14.0"}, func(e *rbac.Enforcer, h string) { seedRepoPolicies(t, e, h) }) 200 newSvc, newDb, newHost := newServiceEnv(t, &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL, collaborators: []string{testCollab}}, nil) 201 seedRepoRow(t, newDb, testRepo(newHost)) 202 203 if !newSvc.RolesInRepo(ctx, testRepo(newHost), testCollab).IsCollaborator() { 204 t.Error("new-knot collaborator must resolve from the live query with an empty casbin") 205 } 206 if !newSvc.RolesInRepo(ctx, testRepo(newHost), testOwner).IsOwner() { 207 t.Error("new-knot owner must resolve from repo.Did") 208 } 209 if !oldSvc.RolesInRepo(ctx, testRepo(oldHost), testCollab).IsCollaborator() { 210 t.Error("old-knot collaborator must resolve from casbin") 211 } 212} 213 214func TestService_IsRepoCreateAllowed(t *testing.T) { 215 ctx := context.Background() 216 217 oldSvc, _, oldHost := newServiceEnv(t, &fakeKnot{version: "v1.14.0"}, func(e *rbac.Enforcer, h string) { 218 if _, err := e.TryAddKnotMember(h, testCollab); err != nil { 219 t.Fatalf("TryAddKnotMember: %v", err) 220 } 221 }) 222 if !oldSvc.IsRepoCreateAllowed(ctx, oldHost, testCollab) { 223 t.Error("old-knot member should be allowed to create") 224 } 225 if oldSvc.IsRepoCreateAllowed(ctx, oldHost, testStrange) { 226 t.Error("old-knot non-member should not be allowed to create") 227 } 228 229 memberSvc, _, memberHost := newServiceEnv(t, &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL, members: []string{testCollab}}, nil) 230 if !memberSvc.IsRepoCreateAllowed(ctx, memberHost, testCollab) { 231 t.Error("new-knot listed member should be allowed to create") 232 } 233 234 ownerSvc, ownerDb, ownerHost := newServiceEnv(t, &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL}, nil) 235 if err := db.AddKnot(ownerDb, ownerHost, testOwner); err != nil { 236 t.Fatalf("db.AddKnot: %v", err) 237 } 238 if err := db.MarkRegistered(ownerDb, orm.FilterEq("domain", ownerHost), orm.FilterEq("did", testOwner)); err != nil { 239 t.Fatalf("MarkRegistered: %v", err) 240 } 241 if !ownerSvc.IsRepoCreateAllowed(ctx, ownerHost, testOwner) { 242 t.Error("new-knot registered owner should be allowed to create even when absent from listMembers") 243 } 244 if ownerSvc.IsRepoCreateAllowed(ctx, ownerHost, testStrange) { 245 t.Error("new-knot non-member non-owner should not be allowed to create") 246 } 247} 248 249func TestService_KnotDownDegrades(t *testing.T) { 250 ctx := context.Background() 251 knot := &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL, listStatus: http.StatusInternalServerError} 252 svc, _, host := newServiceEnv(t, knot, nil) 253 repo := testRepo(host) 254 255 if !svc.RolesInRepo(ctx, repo, testOwner).IsOwner() { 256 t.Error("owner must still resolve from repo.Did when the knot list is down") 257 } 258 if roles := svc.RolesInRepo(ctx, repo, testCollab); len(roles.Roles) != 0 { 259 t.Errorf("non-owner roles when knot down = %v, want empty (degrade, not error)", roles.Roles) 260 } 261 if collabs := svc.Collaborators(ctx, repo); len(collabs) != 1 || collabs[0].Did != testOwner || collabs[0].Role != "owner" { 262 t.Errorf("Collaborators when knot down = %v, want only the owner row", collabs) 263 } 264 if m := svc.KnotMembers(ctx, host); m != nil { 265 t.Errorf("KnotMembers when knot down = %v, want nil", m) 266 } 267 if svc.IsRepoCreateAllowed(ctx, host, testStrange) { 268 t.Error("create gate must be false when the knot is down and the user is not a registered owner") 269 } 270} 271 272func TestService_KnotOwnerForeignRepoParity(t *testing.T) { 273 ctx := context.Background() 274 foreignRepoDid := "did:plc:whelk" 275 276 oldSvc, _, oldHost := newServiceEnv(t, &fakeKnot{version: "v1.14.0"}, func(e *rbac.Enforcer, h string) { 277 if err := e.AddRepo(testCollab, h, foreignRepoDid); err != nil { 278 t.Fatalf("AddRepo: %v", err) 279 } 280 }) 281 oldRepo := &models.Repo{Did: testCollab, Knot: oldHost, RepoDid: foreignRepoDid, Name: "barnacle"} 282 oldRoles := sortedRoles(oldSvc.RolesInRepo(ctx, oldRepo, testOwner).Roles) 283 284 newSvc, newDb, newHost := newServiceEnv(t, &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL}, nil) 285 if err := db.AddKnot(newDb, newHost, testOwner); err != nil { 286 t.Fatalf("db.AddKnot: %v", err) 287 } 288 if err := db.MarkRegistered(newDb, orm.FilterEq("domain", newHost), orm.FilterEq("did", testOwner)); err != nil { 289 t.Fatalf("MarkRegistered: %v", err) 290 } 291 newRepo := &models.Repo{Did: testCollab, Knot: newHost, RepoDid: foreignRepoDid, Name: "barnacle"} 292 newRoles := sortedRoles(newSvc.RolesInRepo(ctx, newRepo, testOwner).Roles) 293 294 if !slices.Equal(oldRoles, newRoles) { 295 t.Errorf("knot operator on a member repo: old=%v new=%v; the appview gate must not diverge by knot version", oldRoles, newRoles) 296 } 297 if !slices.Contains(newRoles, "repo:delete") { 298 t.Errorf("knot operator must retain repo:delete on a member repo, got %v", newRoles) 299 } 300 if newSvc.HasRepoPermission(ctx, newRepo, testStrange, "repo:delete") { 301 t.Error("a stranger must not hold repo:delete on a foreign repo") 302 } 303} 304 305func TestService_KnotMembersIncludesOwner(t *testing.T) { 306 ctx := context.Background() 307 svc, d, host := newServiceEnv(t, &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL, members: []string{testCollab}}, nil) 308 if err := db.AddKnot(d, host, testOwner); err != nil { 309 t.Fatalf("db.AddKnot: %v", err) 310 } 311 if err := db.MarkRegistered(d, orm.FilterEq("domain", host), orm.FilterEq("did", testOwner)); err != nil { 312 t.Fatalf("MarkRegistered: %v", err) 313 } 314 315 members := svc.KnotMembers(ctx, host) 316 if !slices.Contains(members, testOwner) { 317 t.Errorf("new-knot roster %v must include the registered owner so the dashboard renders the owner's repos", members) 318 } 319 if !slices.Contains(members, testCollab) { 320 t.Errorf("new-knot roster %v must include listed members", members) 321 } 322} 323 324func TestService_CollaboratorsNewKnot(t *testing.T) { 325 ctx := context.Background() 326 327 svc, d, host := newServiceEnv(t, &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL, collaborators: []string{testCollab}}, nil) 328 seedRepoRow(t, d, testRepo(host)) 329 collabs := svc.Collaborators(ctx, testRepo(host)) 330 if len(collabs) != 2 { 331 t.Fatalf("Collaborators = %v, want owner + one collaborator", collabs) 332 } 333 if collabs[0].Did != testOwner || collabs[0].Role != "owner" { 334 t.Errorf("first row = %v, want the owner", collabs[0]) 335 } 336 if collabs[1].Did != testCollab || collabs[1].Role != "collaborator" { 337 t.Errorf("second row = %v, want the collaborator", collabs[1]) 338 } 339 340 dupSvc, dupDb, dupHost := newServiceEnv(t, &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL, collaborators: []string{testCollab, testOwner}}, nil) 341 seedRepoRow(t, dupDb, testRepo(dupHost)) 342 rows := dupSvc.Collaborators(ctx, testRepo(dupHost)) 343 ownerRows := 0 344 for _, c := range rows { 345 if c.Did == testOwner { 346 ownerRows++ 347 } 348 } 349 if ownerRows != 1 { 350 t.Errorf("owner listed %d times, want exactly the single owner row: %v", ownerRows, rows) 351 } 352} 353 354func TestService_HasRepoPermissionErr_OwnerNeedsNoLiveQuery(t *testing.T) { 355 ctx := context.Background() 356 knot := &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL, listStatus: http.StatusInternalServerError} 357 svc, _, host := newServiceEnv(t, knot, nil) 358 359 ok, err := svc.HasRepoPermissionErr(ctx, testRepo(host), testOwner, "repo:push") 360 if err != nil || !ok { 361 t.Errorf("owner push = (%v, %v), want (true, nil) resolved from repo.Did with the list down", ok, err) 362 } 363 if knot.hits() != 0 { 364 t.Errorf("listHits = %d, want 0; the owner must not trigger a live query", knot.hits()) 365 } 366} 367 368func TestService_HasRepoPermissionErr_KnotDownIsUndetermined(t *testing.T) { 369 ctx := context.Background() 370 knot := &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL, listStatus: http.StatusInternalServerError} 371 svc, _, host := newServiceEnv(t, knot, nil) 372 373 ok, err := svc.HasRepoPermissionErr(ctx, testRepo(host), testCollab, "repo:push") 374 if !errors.Is(err, ErrKnotUnreachable) { 375 t.Errorf("err = %v, want ErrKnotUnreachable so the ingester can fail open instead of dropping the record", err) 376 } 377 if ok { 378 t.Error("ok must be false when the answer is undetermined") 379 } 380 if svc.HasRepoPermission(ctx, testRepo(host), testCollab, "repo:push") { 381 t.Error("HasRepoPermission must fail closed when the knot is unreachable") 382 } 383} 384 385func TestService_HasRepoPermissionErr_DefinitiveDenyIsNotUndetermined(t *testing.T) { 386 ctx := context.Background() 387 svc, _, host := newServiceEnv(t, &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL}, nil) 388 389 ok, err := svc.HasRepoPermissionErr(ctx, testRepo(host), testStrange, "repo:push") 390 if err != nil { 391 t.Errorf("err = %v, want nil; a reachable knot gives a definitive answer", err) 392 } 393 if ok { 394 t.Error("a stranger must not hold push") 395 } 396} 397 398func TestService_KnotMembersDegradesToOwner(t *testing.T) { 399 ctx := context.Background() 400 knot := &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL, listStatus: http.StatusInternalServerError} 401 svc, d, host := newServiceEnv(t, knot, nil) 402 if err := db.AddKnot(d, host, testOwner); err != nil { 403 t.Fatalf("db.AddKnot: %v", err) 404 } 405 if err := db.MarkRegistered(d, orm.FilterEq("domain", host), orm.FilterEq("did", testOwner)); err != nil { 406 t.Fatalf("MarkRegistered: %v", err) 407 } 408 409 members := svc.KnotMembers(ctx, host) 410 if !slices.Contains(members, testOwner) { 411 t.Errorf("KnotMembers with the list down = %v, want the registered owner so the dashboard still renders", members) 412 } 413} 414 415func TestService_VersionDownFailsClosedToCasbin(t *testing.T) { 416 ctx := context.Background() 417 knot := &fakeKnot{version: ""} 418 svc, _, host := newServiceEnv(t, knot, func(e *rbac.Enforcer, h string) { seedRepoPolicies(t, e, h) }) 419 420 if !svc.RolesInRepo(ctx, testRepo(host), testCollab).IsCollaborator() { 421 t.Error("an unreachable version probe must fail closed to the casbin path, which knows the collaborator") 422 } 423 if knot.hits() != 0 { 424 t.Errorf("listHits = %d; failing closed must route to casbin, never the live list", knot.hits()) 425 } 426} 427 428func TestService_RegisteredOwnersMemoizedPerRequest(t *testing.T) { 429 svc, d, host := newServiceEnv(t, &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL}, nil) 430 if err := db.AddKnot(d, host, testOwner); err != nil { 431 t.Fatalf("db.AddKnot: %v", err) 432 } 433 if err := db.MarkRegistered(d, orm.FilterEq("domain", host), orm.FilterEq("did", testOwner)); err != nil { 434 t.Fatalf("MarkRegistered: %v", err) 435 } 436 437 ctx := WithMemo(context.Background()) 438 first := svc.nat.registeredOwners(ctx, host) 439 if !slices.Contains(first, testOwner) { 440 t.Fatalf("first read = %v, want testOwner", first) 441 } 442 443 if err := db.AddKnot(d, host, testCollab); err != nil { 444 t.Fatalf("db.AddKnot: %v", err) 445 } 446 if err := db.MarkRegistered(d, orm.FilterEq("domain", host), orm.FilterEq("did", testCollab)); err != nil { 447 t.Fatalf("MarkRegistered: %v", err) 448 } 449 450 if second := svc.nat.registeredOwners(ctx, host); slices.Contains(second, testCollab) { 451 t.Errorf("second read in the same request saw a newly added owner %v; the memo did not short-circuit the DB", second) 452 } 453 if fresh := svc.nat.registeredOwners(context.Background(), host); !slices.Contains(fresh, testCollab) { 454 t.Errorf("a fresh request %v must re-query and see the new owner", fresh) 455 } 456 457 first[0] = "did:plc:squid" 458 if again := svc.nat.registeredOwners(ctx, host); slices.Contains(again, "did:plc:squid") { 459 t.Errorf("mutating the returned slice corrupted the memo: %v", again) 460 } 461}