Monorepo for Tangled tangled.org
2

Configure Feed

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

appview/knotacl: service & latch tests

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

author
Lewis
committer
Tangled
date (Jun 8, 2026, 4:18 PM +0300) commit 47f2d701 parent ff37ad35 change-id lwsrnwmx
+473
+31
appview/knotacl/latch_test.go
··· 1 + package knotacl 2 + 3 + import ( 4 + "context" 5 + "path/filepath" 6 + "testing" 7 + 8 + "tangled.org/core/appview/db" 9 + ) 10 + 11 + func TestLatchRoundTrip(t *testing.T) { 12 + d, err := db.Make(context.Background(), filepath.Join(t.TempDir(), "appview.db")) 13 + if err != nil { 14 + t.Fatalf("db.Make: %v", err) 15 + } 16 + 17 + l := NewLatch(d, testLogger()) 18 + 19 + if l.IsNative("clam.nel.pet") { 20 + t.Fatal("a fresh host must not read native through the adapter") 21 + } 22 + 23 + l.MarkNative("clam.nel.pet") 24 + 25 + if !l.IsNative("clam.nel.pet") { 26 + t.Fatal("a marked host must read back native through the adapter") 27 + } 28 + if l.IsNative("whelk.nel.pet") { 29 + t.Fatal("an unmarked sibling must stay non-native") 30 + } 31 + }
+442
appview/knotacl/service_test.go
··· 1 + package knotacl 2 + 3 + import ( 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 + 23 + var capsKnotACL = []string{string(consts.CapKnotACL)} 24 + 25 + type 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 + 36 + func (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 + 63 + func (k *fakeKnot) hit() { 64 + k.mu.Lock() 65 + k.listHits++ 66 + k.mu.Unlock() 67 + } 68 + 69 + func (k *fakeKnot) hits() int { 70 + k.mu.Lock() 71 + defer k.mu.Unlock() 72 + return k.listHits 73 + } 74 + 75 + func 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 + 104 + func testRepo(host string) *models.Repo { 105 + return &models.Repo{Did: testOwner, Knot: host, RepoDid: testRepoDid, Name: "anemone"} 106 + } 107 + 108 + func 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 + 118 + func sortedRoles(roles []string) []string { 119 + s := slices.Clone(roles) 120 + slices.Sort(s) 121 + return slices.Compact(s) 122 + } 123 + 124 + func 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 + 141 + func 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 + 155 + func 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 + 168 + func 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 + 181 + func 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 + 197 + func 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 + 232 + func 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 + 255 + func 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 + 288 + func 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 + 307 + func 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 + 335 + func 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 + 349 + func 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 + 366 + func 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 + 379 + func 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 + 396 + func 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 + 409 + func 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 + }