Monorepo for Tangled
tangled.org
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}