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 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}