package knotacl import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "path/filepath" "slices" "strings" "sync" "testing" "tangled.org/core/api/tangled" "tangled.org/core/appview/db" "tangled.org/core/appview/models" "tangled.org/core/consts" "tangled.org/core/orm" "tangled.org/core/rbac" ) var capsKnotACL = []string{string(consts.CapKnotACL)} type fakeKnot struct { version string capabilities []string members []string collaborators []string listStatus int mu sync.Mutex listHits int } func (k *fakeKnot) ServeHTTP(w http.ResponseWriter, r *http.Request) { switch { case strings.HasSuffix(r.URL.Path, tangled.KnotVersionNSID): if k.version == "" { http.Error(w, "version down", http.StatusInternalServerError) return } json.NewEncoder(w).Encode(tangled.KnotVersion_Output{Version: k.version, Capabilities: k.capabilities}) case strings.HasSuffix(r.URL.Path, tangled.KnotListMembersNSID): k.hit() if k.listStatus != 0 { http.Error(w, "list down", k.listStatus) return } json.NewEncoder(w).Encode(memberPage(k.members, "")) case strings.HasSuffix(r.URL.Path, tangled.RepoListCollaboratorsNSID): k.hit() if k.listStatus != 0 { http.Error(w, "list down", k.listStatus) return } json.NewEncoder(w).Encode(collabPage(k.collaborators, "")) default: http.NotFound(w, r) } } func (k *fakeKnot) hit() { k.mu.Lock() k.listHits++ k.mu.Unlock() } func (k *fakeKnot) hits() int { k.mu.Lock() defer k.mu.Unlock() return k.listHits } func newServiceEnv(t *testing.T, knot *fakeKnot, seed func(e *rbac.Enforcer, host string)) (*Service, *db.DB, string) { t.Helper() srv := httptest.NewServer(knot) t.Cleanup(srv.Close) host := strings.TrimPrefix(srv.URL, "http://") dir := t.TempDir() enforcer, err := rbac.NewEnforcer(filepath.Join(dir, "rbac.db")) if err != nil { t.Fatalf("NewEnforcer: %v", err) } if err := enforcer.AddKnot(host); err != nil { t.Fatalf("AddKnot: %v", err) } if err := enforcer.AddKnotOwner(host, testOwner); err != nil { t.Fatalf("AddKnotOwner: %v", err) } if seed != nil { seed(enforcer, host) } d, err := db.Make(context.Background(), filepath.Join(dir, "appview.db")) if err != nil { t.Fatalf("db.Make: %v", err) } return NewService(enforcer, d, true, testLogger()), d, host } func testRepo(host string) *models.Repo { return &models.Repo{Did: testOwner, Knot: host, RepoDid: testRepoDid, Name: "anemone"} } func seedRepoRow(t *testing.T, d *db.DB, repo *models.Repo) { t.Helper() tx, err := d.Begin() if err != nil { t.Fatalf("begin: %v", err) } if err := db.AddRepo(tx, repo); err != nil { t.Fatalf("AddRepo: %v", err) } if err := tx.Commit(); err != nil { t.Fatalf("commit: %v", err) } } func seedRepoPolicies(t *testing.T, e *rbac.Enforcer, host string) { t.Helper() if err := e.AddRepo(testOwner, host, testRepoDid); err != nil { t.Fatalf("AddRepo: %v", err) } if err := e.AddCollaborator(testCollab, host, testRepoDid); err != nil { t.Fatalf("AddCollaborator: %v", err) } } func sortedRoles(roles []string) []string { s := slices.Clone(roles) slices.Sort(s) return slices.Compact(s) } func TestService_OldKnotUsesCasbinNoLiveQuery(t *testing.T) { ctx := context.Background() knot := &fakeKnot{version: "v1.14.0"} svc, _, host := newServiceEnv(t, knot, func(e *rbac.Enforcer, h string) { seedRepoPolicies(t, e, h) }) collab := svc.RolesInRepo(ctx, testRepo(host), testCollab) if !collab.IsCollaborator() || !collab.IsPushAllowed() { t.Errorf("collaborator roles = %v, want collaborator+push from casbin", collab.Roles) } if !svc.HasRepoPermission(ctx, testRepo(host), testOwner, "repo:owner") { t.Error("owner should hold repo:owner via casbin") } if knot.hits() != 0 { t.Errorf("listHits = %d, want 0; an old knot must never be live-queried", knot.hits()) } } func TestService_ParityOldVsNew(t *testing.T) { ctx := context.Background() oldSvc, _, oldHost := newServiceEnv(t, &fakeKnot{version: "v1.14.0"}, func(e *rbac.Enforcer, h string) { seedRepoPolicies(t, e, h) }) newSvc, newDb, newHost := newServiceEnv(t, &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL, collaborators: []string{testCollab}}, nil) seedRepoRow(t, newDb, testRepo(newHost)) for _, did := range []string{testOwner, testCollab} { oldRoles := sortedRoles(oldSvc.RolesInRepo(ctx, testRepo(oldHost), did).Roles) newRoles := sortedRoles(newSvc.RolesInRepo(ctx, testRepo(newHost), did).Roles) if !slices.Equal(oldRoles, newRoles) { t.Errorf("did %s: casbin roles %v != synth roles %v; synthesis has drifted from the policy grants", did, oldRoles, newRoles) } } } func TestService_NewKnotOwnerFromRecord(t *testing.T) { ctx := context.Background() svc, _, host := newServiceEnv(t, &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL}, nil) owner := svc.RolesInRepo(ctx, testRepo(host), testOwner) if !owner.IsOwner() || !owner.IsPushAllowed() || !owner.SettingsAllowed() || !owner.RepoDeleteAllowed() { t.Errorf("owner roles = %v, want the full owner set derived from repo.Did", owner.Roles) } if stranger := svc.RolesInRepo(ctx, testRepo(host), testStrange); len(stranger.Roles) != 0 { t.Errorf("stranger roles = %v, want empty", stranger.Roles) } } func TestService_NewKnotCollaboratorFromList(t *testing.T) { ctx := context.Background() svc, d, host := newServiceEnv(t, &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL, collaborators: []string{testCollab}}, nil) seedRepoRow(t, d, testRepo(host)) collab := svc.RolesInRepo(ctx, testRepo(host), testCollab) if !collab.IsCollaborator() || !collab.IsPushAllowed() { t.Errorf("collaborator roles = %v, want collaborator+push from the live list", collab.Roles) } if collab.IsOwner() || collab.RepoDeleteAllowed() { t.Errorf("collaborator must not hold owner/delete: %v", collab.Roles) } } func TestService_MixedFleet(t *testing.T) { ctx := context.Background() oldSvc, _, oldHost := newServiceEnv(t, &fakeKnot{version: "v1.14.0"}, func(e *rbac.Enforcer, h string) { seedRepoPolicies(t, e, h) }) newSvc, newDb, newHost := newServiceEnv(t, &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL, collaborators: []string{testCollab}}, nil) seedRepoRow(t, newDb, testRepo(newHost)) if !newSvc.RolesInRepo(ctx, testRepo(newHost), testCollab).IsCollaborator() { t.Error("new-knot collaborator must resolve from the live query with an empty casbin") } if !newSvc.RolesInRepo(ctx, testRepo(newHost), testOwner).IsOwner() { t.Error("new-knot owner must resolve from repo.Did") } if !oldSvc.RolesInRepo(ctx, testRepo(oldHost), testCollab).IsCollaborator() { t.Error("old-knot collaborator must resolve from casbin") } } func TestService_IsRepoCreateAllowed(t *testing.T) { ctx := context.Background() oldSvc, _, oldHost := newServiceEnv(t, &fakeKnot{version: "v1.14.0"}, func(e *rbac.Enforcer, h string) { if _, err := e.TryAddKnotMember(h, testCollab); err != nil { t.Fatalf("TryAddKnotMember: %v", err) } }) if !oldSvc.IsRepoCreateAllowed(ctx, oldHost, testCollab) { t.Error("old-knot member should be allowed to create") } if oldSvc.IsRepoCreateAllowed(ctx, oldHost, testStrange) { t.Error("old-knot non-member should not be allowed to create") } memberSvc, _, memberHost := newServiceEnv(t, &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL, members: []string{testCollab}}, nil) if !memberSvc.IsRepoCreateAllowed(ctx, memberHost, testCollab) { t.Error("new-knot listed member should be allowed to create") } ownerSvc, ownerDb, ownerHost := newServiceEnv(t, &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL}, nil) if err := db.AddKnot(ownerDb, ownerHost, testOwner); err != nil { t.Fatalf("db.AddKnot: %v", err) } if err := db.MarkRegistered(ownerDb, orm.FilterEq("domain", ownerHost), orm.FilterEq("did", testOwner)); err != nil { t.Fatalf("MarkRegistered: %v", err) } if !ownerSvc.IsRepoCreateAllowed(ctx, ownerHost, testOwner) { t.Error("new-knot registered owner should be allowed to create even when absent from listMembers") } if ownerSvc.IsRepoCreateAllowed(ctx, ownerHost, testStrange) { t.Error("new-knot non-member non-owner should not be allowed to create") } } func TestService_KnotDownDegrades(t *testing.T) { ctx := context.Background() knot := &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL, listStatus: http.StatusInternalServerError} svc, _, host := newServiceEnv(t, knot, nil) repo := testRepo(host) if !svc.RolesInRepo(ctx, repo, testOwner).IsOwner() { t.Error("owner must still resolve from repo.Did when the knot list is down") } if roles := svc.RolesInRepo(ctx, repo, testCollab); len(roles.Roles) != 0 { t.Errorf("non-owner roles when knot down = %v, want empty (degrade, not error)", roles.Roles) } if collabs := svc.Collaborators(ctx, repo); len(collabs) != 1 || collabs[0].Did != testOwner || collabs[0].Role != "owner" { t.Errorf("Collaborators when knot down = %v, want only the owner row", collabs) } if m := svc.KnotMembers(ctx, host); m != nil { t.Errorf("KnotMembers when knot down = %v, want nil", m) } if svc.IsRepoCreateAllowed(ctx, host, testStrange) { t.Error("create gate must be false when the knot is down and the user is not a registered owner") } } func TestService_KnotOwnerForeignRepoParity(t *testing.T) { ctx := context.Background() foreignRepoDid := "did:plc:whelk" oldSvc, _, oldHost := newServiceEnv(t, &fakeKnot{version: "v1.14.0"}, func(e *rbac.Enforcer, h string) { if err := e.AddRepo(testCollab, h, foreignRepoDid); err != nil { t.Fatalf("AddRepo: %v", err) } }) oldRepo := &models.Repo{Did: testCollab, Knot: oldHost, RepoDid: foreignRepoDid, Name: "barnacle"} oldRoles := sortedRoles(oldSvc.RolesInRepo(ctx, oldRepo, testOwner).Roles) newSvc, newDb, newHost := newServiceEnv(t, &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL}, nil) if err := db.AddKnot(newDb, newHost, testOwner); err != nil { t.Fatalf("db.AddKnot: %v", err) } if err := db.MarkRegistered(newDb, orm.FilterEq("domain", newHost), orm.FilterEq("did", testOwner)); err != nil { t.Fatalf("MarkRegistered: %v", err) } newRepo := &models.Repo{Did: testCollab, Knot: newHost, RepoDid: foreignRepoDid, Name: "barnacle"} newRoles := sortedRoles(newSvc.RolesInRepo(ctx, newRepo, testOwner).Roles) if !slices.Equal(oldRoles, newRoles) { t.Errorf("knot operator on a member repo: old=%v new=%v; the appview gate must not diverge by knot version", oldRoles, newRoles) } if !slices.Contains(newRoles, "repo:delete") { t.Errorf("knot operator must retain repo:delete on a member repo, got %v", newRoles) } if newSvc.HasRepoPermission(ctx, newRepo, testStrange, "repo:delete") { t.Error("a stranger must not hold repo:delete on a foreign repo") } } func TestService_KnotMembersIncludesOwner(t *testing.T) { ctx := context.Background() svc, d, host := newServiceEnv(t, &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL, members: []string{testCollab}}, nil) if err := db.AddKnot(d, host, testOwner); err != nil { t.Fatalf("db.AddKnot: %v", err) } if err := db.MarkRegistered(d, orm.FilterEq("domain", host), orm.FilterEq("did", testOwner)); err != nil { t.Fatalf("MarkRegistered: %v", err) } members := svc.KnotMembers(ctx, host) if !slices.Contains(members, testOwner) { t.Errorf("new-knot roster %v must include the registered owner so the dashboard renders the owner's repos", members) } if !slices.Contains(members, testCollab) { t.Errorf("new-knot roster %v must include listed members", members) } } func TestService_CollaboratorsNewKnot(t *testing.T) { ctx := context.Background() svc, d, host := newServiceEnv(t, &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL, collaborators: []string{testCollab}}, nil) seedRepoRow(t, d, testRepo(host)) collabs := svc.Collaborators(ctx, testRepo(host)) if len(collabs) != 2 { t.Fatalf("Collaborators = %v, want owner + one collaborator", collabs) } if collabs[0].Did != testOwner || collabs[0].Role != "owner" { t.Errorf("first row = %v, want the owner", collabs[0]) } if collabs[1].Did != testCollab || collabs[1].Role != "collaborator" { t.Errorf("second row = %v, want the collaborator", collabs[1]) } dupSvc, dupDb, dupHost := newServiceEnv(t, &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL, collaborators: []string{testCollab, testOwner}}, nil) seedRepoRow(t, dupDb, testRepo(dupHost)) rows := dupSvc.Collaborators(ctx, testRepo(dupHost)) ownerRows := 0 for _, c := range rows { if c.Did == testOwner { ownerRows++ } } if ownerRows != 1 { t.Errorf("owner listed %d times, want exactly the single owner row: %v", ownerRows, rows) } } func TestService_HasRepoPermissionErr_OwnerNeedsNoLiveQuery(t *testing.T) { ctx := context.Background() knot := &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL, listStatus: http.StatusInternalServerError} svc, _, host := newServiceEnv(t, knot, nil) ok, err := svc.HasRepoPermissionErr(ctx, testRepo(host), testOwner, "repo:push") if err != nil || !ok { t.Errorf("owner push = (%v, %v), want (true, nil) resolved from repo.Did with the list down", ok, err) } if knot.hits() != 0 { t.Errorf("listHits = %d, want 0; the owner must not trigger a live query", knot.hits()) } } func TestService_HasRepoPermissionErr_KnotDownIsUndetermined(t *testing.T) { ctx := context.Background() knot := &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL, listStatus: http.StatusInternalServerError} svc, _, host := newServiceEnv(t, knot, nil) ok, err := svc.HasRepoPermissionErr(ctx, testRepo(host), testCollab, "repo:push") if !errors.Is(err, ErrKnotUnreachable) { t.Errorf("err = %v, want ErrKnotUnreachable so the ingester can fail open instead of dropping the record", err) } if ok { t.Error("ok must be false when the answer is undetermined") } if svc.HasRepoPermission(ctx, testRepo(host), testCollab, "repo:push") { t.Error("HasRepoPermission must fail closed when the knot is unreachable") } } func TestService_HasRepoPermissionErr_DefinitiveDenyIsNotUndetermined(t *testing.T) { ctx := context.Background() svc, _, host := newServiceEnv(t, &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL}, nil) ok, err := svc.HasRepoPermissionErr(ctx, testRepo(host), testStrange, "repo:push") if err != nil { t.Errorf("err = %v, want nil; a reachable knot gives a definitive answer", err) } if ok { t.Error("a stranger must not hold push") } } func TestService_KnotMembersDegradesToOwner(t *testing.T) { ctx := context.Background() knot := &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL, listStatus: http.StatusInternalServerError} svc, d, host := newServiceEnv(t, knot, nil) if err := db.AddKnot(d, host, testOwner); err != nil { t.Fatalf("db.AddKnot: %v", err) } if err := db.MarkRegistered(d, orm.FilterEq("domain", host), orm.FilterEq("did", testOwner)); err != nil { t.Fatalf("MarkRegistered: %v", err) } members := svc.KnotMembers(ctx, host) if !slices.Contains(members, testOwner) { t.Errorf("KnotMembers with the list down = %v, want the registered owner so the dashboard still renders", members) } } func TestService_VersionDownFailsClosedToCasbin(t *testing.T) { ctx := context.Background() knot := &fakeKnot{version: ""} svc, _, host := newServiceEnv(t, knot, func(e *rbac.Enforcer, h string) { seedRepoPolicies(t, e, h) }) if !svc.RolesInRepo(ctx, testRepo(host), testCollab).IsCollaborator() { t.Error("an unreachable version probe must fail closed to the casbin path, which knows the collaborator") } if knot.hits() != 0 { t.Errorf("listHits = %d; failing closed must route to casbin, never the live list", knot.hits()) } } func TestService_RegisteredOwnersMemoizedPerRequest(t *testing.T) { svc, d, host := newServiceEnv(t, &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL}, nil) if err := db.AddKnot(d, host, testOwner); err != nil { t.Fatalf("db.AddKnot: %v", err) } if err := db.MarkRegistered(d, orm.FilterEq("domain", host), orm.FilterEq("did", testOwner)); err != nil { t.Fatalf("MarkRegistered: %v", err) } ctx := WithMemo(context.Background()) first := svc.nat.registeredOwners(ctx, host) if !slices.Contains(first, testOwner) { t.Fatalf("first read = %v, want testOwner", first) } if err := db.AddKnot(d, host, testCollab); err != nil { t.Fatalf("db.AddKnot: %v", err) } if err := db.MarkRegistered(d, orm.FilterEq("domain", host), orm.FilterEq("did", testCollab)); err != nil { t.Fatalf("MarkRegistered: %v", err) } if second := svc.nat.registeredOwners(ctx, host); slices.Contains(second, testCollab) { t.Errorf("second read in the same request saw a newly added owner %v; the memo did not short-circuit the DB", second) } if fresh := svc.nat.registeredOwners(context.Background(), host); !slices.Contains(fresh, testCollab) { t.Errorf("a fresh request %v must re-query and see the new owner", fresh) } first[0] = "did:plc:squid" if again := svc.nat.registeredOwners(ctx, host); slices.Contains(again, "did:plc:squid") { t.Errorf("mutating the returned slice corrupted the memo: %v", again) } }