Monorepo for Tangled tangled.org
2

Configure Feed

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

appview/knotacl: client, cache, membership tests

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

author
Lewis
committer
Tangled
date (Jun 8, 2026, 4:18 PM +0300) commit ff37ad35 parent d896d9a8 change-id xqxtsxkm
+486
+226
appview/knotacl/cache_test.go
··· 1 + package knotacl 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "slices" 8 + "sync" 9 + "testing" 10 + "time" 11 + ) 12 + 13 + var cacheTestBase = time.Unix(1700000000, 0) 14 + 15 + type fakeLister struct { 16 + mu sync.Mutex 17 + memberCalls int 18 + members []string 19 + err error 20 + started chan struct{} 21 + block chan struct{} 22 + } 23 + 24 + func (f *fakeLister) GetKnotMembers(ctx context.Context, host string) ([]string, error) { 25 + f.mu.Lock() 26 + f.memberCalls++ 27 + members, err, started, block := f.members, f.err, f.started, f.block 28 + f.mu.Unlock() 29 + if started != nil { 30 + close(started) 31 + } 32 + if block != nil { 33 + <-block 34 + } 35 + if err != nil { 36 + return nil, err 37 + } 38 + return members, nil 39 + } 40 + 41 + func (f *fakeLister) GetRepoCollaborators(ctx context.Context, host, repoDid string) ([]string, error) { 42 + f.mu.Lock() 43 + defer f.mu.Unlock() 44 + return f.members, f.err 45 + } 46 + 47 + func (f *fakeLister) calls() int { 48 + f.mu.Lock() 49 + defer f.mu.Unlock() 50 + return f.memberCalls 51 + } 52 + 53 + func (f *fakeLister) set(members []string, err error) { 54 + f.mu.Lock() 55 + defer f.mu.Unlock() 56 + f.members, f.err = members, err 57 + } 58 + 59 + type fakeClock struct { 60 + mu sync.Mutex 61 + t time.Time 62 + } 63 + 64 + func (c *fakeClock) now() time.Time { 65 + c.mu.Lock() 66 + defer c.mu.Unlock() 67 + return c.t 68 + } 69 + 70 + func (c *fakeClock) advance(d time.Duration) { 71 + c.mu.Lock() 72 + defer c.mu.Unlock() 73 + c.t = c.t.Add(d) 74 + } 75 + 76 + func TestCache_TTLCollapsesThenExpires(t *testing.T) { 77 + clk := &fakeClock{t: cacheTestBase} 78 + f := &fakeLister{members: []string{"did:plc:boltless"}} 79 + c := newCache(f, cacheTTL, clk.now) 80 + ctx := context.Background() 81 + 82 + if _, err := c.GetKnotMembers(ctx, "knot.nel.pet"); err != nil { 83 + t.Fatal(err) 84 + } 85 + if _, err := c.GetKnotMembers(ctx, "knot.nel.pet"); err != nil { 86 + t.Fatal(err) 87 + } 88 + if f.calls() != 1 { 89 + t.Errorf("memberCalls=%d, want 1 within the TTL window", f.calls()) 90 + } 91 + 92 + clk.advance(cacheTTL) 93 + if _, err := c.GetKnotMembers(ctx, "knot.nel.pet"); err != nil { 94 + t.Fatal(err) 95 + } 96 + if f.calls() != 2 { 97 + t.Errorf("memberCalls=%d, want 2 once the entry expired", f.calls()) 98 + } 99 + } 100 + 101 + func TestCache_ErrorsNotCached(t *testing.T) { 102 + clk := &fakeClock{t: cacheTestBase} 103 + f := &fakeLister{err: errors.New("knot unreachable")} 104 + c := newCache(f, cacheTTL, clk.now) 105 + ctx := context.Background() 106 + 107 + if _, err := c.GetKnotMembers(ctx, "knot.nel.pet"); err == nil { 108 + t.Fatal("want error on the first call") 109 + } 110 + f.set([]string{"did:plc:boltless"}, nil) 111 + got, err := c.GetKnotMembers(ctx, "knot.nel.pet") 112 + if err != nil { 113 + t.Fatal(err) 114 + } 115 + if !slices.Equal(got, []string{"did:plc:boltless"}) { 116 + t.Errorf("got %v after recovery, want the live value", got) 117 + } 118 + if f.calls() != 2 { 119 + t.Errorf("memberCalls=%d, want 2; a failed fetch must not be cached", f.calls()) 120 + } 121 + } 122 + 123 + func TestCache_MemoShortCircuitsWithinRequest(t *testing.T) { 124 + clk := &fakeClock{t: cacheTestBase} 125 + f := &fakeLister{members: []string{"did:plc:boltless"}} 126 + c := newCache(f, cacheTTL, clk.now) 127 + ctx := WithMemo(context.Background()) 128 + 129 + first, err := c.GetKnotMembers(ctx, "knot.nel.pet") 130 + if err != nil { 131 + t.Fatal(err) 132 + } 133 + 134 + clk.advance(2 * cacheTTL) 135 + f.set([]string{"did:plc:akshay"}, nil) 136 + 137 + second, err := c.GetKnotMembers(ctx, "knot.nel.pet") 138 + if err != nil { 139 + t.Fatal(err) 140 + } 141 + if !slices.Equal(first, second) { 142 + t.Errorf("memo must hold one snapshot per request: first=%v second=%v", first, second) 143 + } 144 + if f.calls() != 1 { 145 + t.Errorf("memberCalls=%d, want 1; the request memo must not re-query even past the TTL", f.calls()) 146 + } 147 + } 148 + 149 + func TestCache_ReturnedSliceCannotCorruptCache(t *testing.T) { 150 + clk := &fakeClock{t: cacheTestBase} 151 + f := &fakeLister{members: []string{"did:plc:boltless", "did:plc:akshay"}} 152 + c := newCache(f, cacheTTL, clk.now) 153 + ctx := context.Background() 154 + 155 + got, err := c.GetKnotMembers(ctx, "knot.nel.pet") 156 + if err != nil { 157 + t.Fatal(err) 158 + } 159 + for i := range got { 160 + got[i] = "did:plc:squid" 161 + } 162 + 163 + again, err := c.GetKnotMembers(ctx, "knot.nel.pet") 164 + if err != nil { 165 + t.Fatal(err) 166 + } 167 + if slices.Contains(again, "did:plc:squid") { 168 + t.Errorf("a caller mutating its returned slice corrupted the cached entry: %v", again) 169 + } 170 + if f.calls() != 1 { 171 + t.Errorf("memberCalls=%d, want 1; the second read should be served from cache", f.calls()) 172 + } 173 + } 174 + 175 + func TestCache_SingleflightCollapsesConcurrentMisses(t *testing.T) { 176 + clk := &fakeClock{t: cacheTestBase} 177 + started := make(chan struct{}) 178 + release := make(chan struct{}) 179 + f := &fakeLister{members: []string{"did:plc:boltless"}, started: started, block: release} 180 + c := newCache(f, cacheTTL, clk.now) 181 + ctx := context.Background() 182 + 183 + var wg sync.WaitGroup 184 + call := func() { 185 + wg.Add(1) 186 + go func() { 187 + defer wg.Done() 188 + if _, err := c.GetKnotMembers(ctx, "knot.nel.pet"); err != nil { 189 + t.Errorf("GetKnotMembers: %v", err) 190 + } 191 + }() 192 + } 193 + 194 + call() 195 + <-started 196 + for range make([]struct{}, 8) { 197 + call() 198 + } 199 + time.Sleep(20 * time.Millisecond) 200 + close(release) 201 + wg.Wait() 202 + 203 + if f.calls() != 1 { 204 + t.Errorf("memberCalls=%d, want 1; concurrent misses must collapse into a single knot query", f.calls()) 205 + } 206 + } 207 + 208 + func TestCache_CapIsHardUnderFreshFlood(t *testing.T) { 209 + clk := &fakeClock{t: cacheTestBase} 210 + f := &fakeLister{members: []string{"did:plc:limpet"}} 211 + c := newCache(f, cacheTTL, clk.now) 212 + ctx := context.Background() 213 + 214 + for i := 0; i < cacheMaxEntries+100; i++ { 215 + if _, err := c.GetKnotMembers(ctx, fmt.Sprintf("knot-%d.nel.pet", i)); err != nil { 216 + t.Fatal(err) 217 + } 218 + } 219 + 220 + c.mu.Lock() 221 + n := len(c.entries) 222 + c.mu.Unlock() 223 + if n > cacheMaxEntries { 224 + t.Errorf("entries=%d, want <= %d; all-fresh keys must not grow past the cap", n, cacheMaxEntries) 225 + } 226 + }
+182
appview/knotacl/client_test.go
··· 1 + package knotacl 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "io" 7 + "log/slog" 8 + "net/http" 9 + "net/http/httptest" 10 + "slices" 11 + "strings" 12 + "sync" 13 + "testing" 14 + 15 + "tangled.org/core/api/tangled" 16 + ) 17 + 18 + func testLogger() *slog.Logger { 19 + return slog.New(slog.NewTextHandler(io.Discard, nil)) 20 + } 21 + 22 + const ( 23 + testOwner = "did:plc:akshay" 24 + testCollab = "did:plc:boltless" 25 + testRepoDid = "did:plc:limpet" 26 + testStrange = "did:plc:scallop" 27 + ) 28 + 29 + type recordingKnot struct { 30 + mu sync.Mutex 31 + requests []string 32 + handler func(w http.ResponseWriter, r *http.Request) 33 + } 34 + 35 + func (k *recordingKnot) ServeHTTP(w http.ResponseWriter, r *http.Request) { 36 + k.mu.Lock() 37 + k.requests = append(k.requests, r.URL.String()) 38 + k.mu.Unlock() 39 + k.handler(w, r) 40 + } 41 + 42 + func (k *recordingKnot) calls() []string { 43 + k.mu.Lock() 44 + defer k.mu.Unlock() 45 + return slices.Clone(k.requests) 46 + } 47 + 48 + func devClientFor(t *testing.T, handler func(w http.ResponseWriter, r *http.Request)) (*Client, *recordingKnot, string) { 49 + t.Helper() 50 + knot := &recordingKnot{handler: handler} 51 + srv := httptest.NewServer(knot) 52 + t.Cleanup(srv.Close) 53 + host := strings.TrimPrefix(srv.URL, "http://") 54 + return NewClient(true, testLogger()), knot, host 55 + } 56 + 57 + func memberPage(items []string, cursor string) tangled.KnotListMembers_Output { 58 + out := tangled.KnotListMembers_Output{ 59 + Items: mapSlice(items, func(d string) *tangled.KnotListMembers_ListItem { 60 + return &tangled.KnotListMembers_ListItem{Subject: d, AddedBy: testOwner, CreatedAt: "2026-06-03T00:00:00Z"} 61 + }), 62 + } 63 + if cursor != "" { 64 + out.Cursor = &cursor 65 + } 66 + return out 67 + } 68 + 69 + func collabPage(items []string, cursor string) tangled.RepoListCollaborators_Output { 70 + out := tangled.RepoListCollaborators_Output{ 71 + Items: mapSlice(items, func(d string) *tangled.RepoListCollaborators_ListItem { 72 + return &tangled.RepoListCollaborators_ListItem{Subject: d, AddedBy: testOwner, CreatedAt: "2026-06-03T00:00:00Z"} 73 + }), 74 + } 75 + if cursor != "" { 76 + out.Cursor = &cursor 77 + } 78 + return out 79 + } 80 + 81 + func TestGetKnotMembers_SinglePage(t *testing.T) { 82 + c, _, host := devClientFor(t, func(w http.ResponseWriter, r *http.Request) { 83 + json.NewEncoder(w).Encode(memberPage([]string{testCollab, testOwner, testCollab}, "")) 84 + }) 85 + 86 + got, err := c.GetKnotMembers(context.Background(), host) 87 + if err != nil { 88 + t.Fatalf("GetKnotMembers: %v", err) 89 + } 90 + want := []string{testOwner, testCollab} 91 + if !slices.Equal(got, want) { 92 + t.Errorf("members = %v, want sorted+deduped %v", got, want) 93 + } 94 + } 95 + 96 + func TestGetKnotMembers_Paginates(t *testing.T) { 97 + c, knot, host := devClientFor(t, func(w http.ResponseWriter, r *http.Request) { 98 + if r.URL.Query().Get("cursor") == "" { 99 + json.NewEncoder(w).Encode(memberPage([]string{testOwner}, "page2")) 100 + return 101 + } 102 + json.NewEncoder(w).Encode(memberPage([]string{testCollab}, "")) 103 + }) 104 + 105 + got, err := c.GetKnotMembers(context.Background(), host) 106 + if err != nil { 107 + t.Fatalf("GetKnotMembers: %v", err) 108 + } 109 + if want := []string{testOwner, testCollab}; !slices.Equal(got, want) { 110 + t.Errorf("members = %v, want union %v", got, want) 111 + } 112 + calls := knot.calls() 113 + if len(calls) != 2 { 114 + t.Fatalf("calls = %d, want 2 pages", len(calls)) 115 + } 116 + if !strings.Contains(calls[1], "cursor=page2") { 117 + t.Errorf("second call %q did not carry the page-1 cursor", calls[1]) 118 + } 119 + } 120 + 121 + func TestGetKnotMembers_KnotDown(t *testing.T) { 122 + c, _, host := devClientFor(t, func(w http.ResponseWriter, r *http.Request) { 123 + http.Error(w, "boom", http.StatusInternalServerError) 124 + }) 125 + 126 + _, err := c.GetKnotMembers(context.Background(), host) 127 + if err == nil { 128 + t.Fatal("want error when knot is down; the Client must surface it for the Service to swallow") 129 + } 130 + } 131 + 132 + func TestGetRepoCollaborators_PassesRepoDidAsSubject(t *testing.T) { 133 + c, knot, host := devClientFor(t, func(w http.ResponseWriter, r *http.Request) { 134 + json.NewEncoder(w).Encode(collabPage([]string{testCollab}, "")) 135 + }) 136 + 137 + got, err := c.GetRepoCollaborators(context.Background(), host, testRepoDid) 138 + if err != nil { 139 + t.Fatalf("GetRepoCollaborators: %v", err) 140 + } 141 + if want := []string{testCollab}; !slices.Equal(got, want) { 142 + t.Errorf("collaborators = %v, want %v", got, want) 143 + } 144 + if calls := knot.calls(); len(calls) != 1 || !strings.Contains(calls[0], "subject="+strings.ReplaceAll(testRepoDid, ":", "%3A")) { 145 + t.Errorf("subject param missing repo DID: %v", calls) 146 + } 147 + } 148 + 149 + func TestDrainStopsOnRepeatedCursor(t *testing.T) { 150 + c, knot, host := devClientFor(t, func(w http.ResponseWriter, r *http.Request) { 151 + json.NewEncoder(w).Encode(memberPage([]string{testRepoDid}, "stuck")) 152 + }) 153 + 154 + got, err := c.GetKnotMembers(context.Background(), host) 155 + if err != nil { 156 + t.Fatalf("GetKnotMembers: %v", err) 157 + } 158 + if want := []string{testRepoDid}; !slices.Equal(got, want) { 159 + t.Errorf("members = %v, want %v after dedup", got, want) 160 + } 161 + if calls := len(knot.calls()); calls != 2 { 162 + t.Errorf("calls = %d, want 2; a knot echoing the same cursor must halt at once, not page to the cap", calls) 163 + } 164 + } 165 + 166 + func TestDrainStopsAtPageCap(t *testing.T) { 167 + c, knot, host := devClientFor(t, func(w http.ResponseWriter, r *http.Request) { 168 + cursor := r.URL.Query().Get("cursor") 169 + json.NewEncoder(w).Encode(memberPage([]string{testCollab}, cursor+"x")) 170 + }) 171 + 172 + got, err := c.GetKnotMembers(context.Background(), host) 173 + if err != nil { 174 + t.Fatalf("GetKnotMembers: %v", err) 175 + } 176 + if want := []string{testCollab}; !slices.Equal(got, want) { 177 + t.Errorf("members = %v, want %v after dedup", got, want) 178 + } 179 + if calls := len(knot.calls()); calls != maxListPages { 180 + t.Errorf("calls = %d, want the %d-page cap to halt an ever-advancing cursor", calls, maxListPages) 181 + } 182 + }
+78
appview/knotacl/membership_test.go
··· 1 + package knotacl 2 + 3 + import ( 4 + "context" 5 + "slices" 6 + "testing" 7 + 8 + "tangled.org/core/appview/db" 9 + "tangled.org/core/orm" 10 + ) 11 + 12 + func registerOwner(t *testing.T, d *db.DB, host, ownerDid string) { 13 + t.Helper() 14 + if err := db.AddKnot(d, host, ownerDid); err != nil { 15 + t.Fatalf("AddKnot: %v", err) 16 + } 17 + if err := db.MarkRegistered(d, orm.FilterEq("domain", host), orm.FilterEq("did", ownerDid)); err != nil { 18 + t.Fatalf("MarkRegistered: %v", err) 19 + } 20 + } 21 + 22 + func TestService_IsKnotMemberNative(t *testing.T) { 23 + ctx := context.Background() 24 + svc, d, host := newServiceEnv(t, &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL, members: []string{testCollab}}, nil) 25 + 26 + if !svc.IsKnotMember(ctx, host, testCollab) { 27 + t.Error("a did in the native listMembers must read as a member") 28 + } 29 + if svc.IsKnotMember(ctx, host, testStrange) { 30 + t.Error("a stranger must not read as a member") 31 + } 32 + 33 + registerOwner(t, d, host, testOwner) 34 + if !svc.IsKnotMember(ctx, host, testOwner) { 35 + t.Error("a registered owner must read as a member of its own native knot") 36 + } 37 + } 38 + 39 + func TestService_IsKnotMemberLegacy(t *testing.T) { 40 + ctx := context.Background() 41 + svc, _, host := newServiceEnv(t, &fakeKnot{version: "v1.14.0"}, nil) 42 + 43 + if !svc.IsKnotMember(ctx, host, testOwner) { 44 + t.Error("the casbin-seeded owner must read as a member on an old knot") 45 + } 46 + if svc.IsKnotMember(ctx, host, testStrange) { 47 + t.Error("a stranger must not read as a member on an old knot") 48 + } 49 + } 50 + 51 + func TestService_KnotsForUserNativeMemberAndOwner(t *testing.T) { 52 + ctx := context.Background() 53 + svc, d, host := newServiceEnv(t, &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL, members: []string{testCollab}}, nil) 54 + registerOwner(t, d, host, testOwner) 55 + 56 + if got := svc.KnotsForUser(ctx, testCollab); !slices.Contains(got, host) { 57 + t.Errorf("KnotsForUser(member) = %v; a native member must surface via the listMembers fan-out, not casbin", got) 58 + } 59 + if got := svc.KnotsForUser(ctx, testOwner); !slices.Contains(got, host) { 60 + t.Errorf("KnotsForUser(owner) = %v, want the registered knot", got) 61 + } 62 + if got := svc.KnotsForUser(ctx, testStrange); slices.Contains(got, host) { 63 + t.Errorf("KnotsForUser(stranger) = %v, want the knot omitted", got) 64 + } 65 + } 66 + 67 + func TestService_KnotsForUserNativeListDownDegrades(t *testing.T) { 68 + ctx := context.Background() 69 + svc, d, host := newServiceEnv(t, &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL, listStatus: 500, members: []string{testCollab}}, nil) 70 + registerOwner(t, d, host, testOwner) 71 + 72 + if got := svc.KnotsForUser(ctx, testOwner); !slices.Contains(got, host) { 73 + t.Errorf("KnotsForUser(owner) = %v; an owner must surface from registrations even when the knot list is down", got) 74 + } 75 + if got := svc.KnotsForUser(ctx, testCollab); slices.Contains(got, host) { 76 + t.Errorf("KnotsForUser(member) = %v; a member must not surface when the bounded fan-out cannot reach the knot", got) 77 + } 78 + }