From ff37ad355ea96797cfe64e8bf9b084e63a87f428 Mon Sep 17 00:00:00 2001 From: Lewis Date: Fri, 5 Jun 2026 09:31:22 +0300 Subject: [PATCH] appview/knotacl: client, cache, membership tests Lewis: May this revision serve well! --- appview/knotacl/cache_test.go | 226 +++++++++++++++++++++++++++++ appview/knotacl/client_test.go | 182 +++++++++++++++++++++++ appview/knotacl/membership_test.go | 78 ++++++++++ 3 files changed, 486 insertions(+) create mode 100644 appview/knotacl/cache_test.go create mode 100644 appview/knotacl/client_test.go create mode 100644 appview/knotacl/membership_test.go diff --git a/appview/knotacl/cache_test.go b/appview/knotacl/cache_test.go new file mode 100644 index 00000000..8e74f35b --- /dev/null +++ b/appview/knotacl/cache_test.go @@ -0,0 +1,226 @@ +package knotacl + +import ( + "context" + "errors" + "fmt" + "slices" + "sync" + "testing" + "time" +) + +var cacheTestBase = time.Unix(1700000000, 0) + +type fakeLister struct { + mu sync.Mutex + memberCalls int + members []string + err error + started chan struct{} + block chan struct{} +} + +func (f *fakeLister) GetKnotMembers(ctx context.Context, host string) ([]string, error) { + f.mu.Lock() + f.memberCalls++ + members, err, started, block := f.members, f.err, f.started, f.block + f.mu.Unlock() + if started != nil { + close(started) + } + if block != nil { + <-block + } + if err != nil { + return nil, err + } + return members, nil +} + +func (f *fakeLister) GetRepoCollaborators(ctx context.Context, host, repoDid string) ([]string, error) { + f.mu.Lock() + defer f.mu.Unlock() + return f.members, f.err +} + +func (f *fakeLister) calls() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.memberCalls +} + +func (f *fakeLister) set(members []string, err error) { + f.mu.Lock() + defer f.mu.Unlock() + f.members, f.err = members, err +} + +type fakeClock struct { + mu sync.Mutex + t time.Time +} + +func (c *fakeClock) now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.t +} + +func (c *fakeClock) advance(d time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + c.t = c.t.Add(d) +} + +func TestCache_TTLCollapsesThenExpires(t *testing.T) { + clk := &fakeClock{t: cacheTestBase} + f := &fakeLister{members: []string{"did:plc:boltless"}} + c := newCache(f, cacheTTL, clk.now) + ctx := context.Background() + + if _, err := c.GetKnotMembers(ctx, "knot.nel.pet"); err != nil { + t.Fatal(err) + } + if _, err := c.GetKnotMembers(ctx, "knot.nel.pet"); err != nil { + t.Fatal(err) + } + if f.calls() != 1 { + t.Errorf("memberCalls=%d, want 1 within the TTL window", f.calls()) + } + + clk.advance(cacheTTL) + if _, err := c.GetKnotMembers(ctx, "knot.nel.pet"); err != nil { + t.Fatal(err) + } + if f.calls() != 2 { + t.Errorf("memberCalls=%d, want 2 once the entry expired", f.calls()) + } +} + +func TestCache_ErrorsNotCached(t *testing.T) { + clk := &fakeClock{t: cacheTestBase} + f := &fakeLister{err: errors.New("knot unreachable")} + c := newCache(f, cacheTTL, clk.now) + ctx := context.Background() + + if _, err := c.GetKnotMembers(ctx, "knot.nel.pet"); err == nil { + t.Fatal("want error on the first call") + } + f.set([]string{"did:plc:boltless"}, nil) + got, err := c.GetKnotMembers(ctx, "knot.nel.pet") + if err != nil { + t.Fatal(err) + } + if !slices.Equal(got, []string{"did:plc:boltless"}) { + t.Errorf("got %v after recovery, want the live value", got) + } + if f.calls() != 2 { + t.Errorf("memberCalls=%d, want 2; a failed fetch must not be cached", f.calls()) + } +} + +func TestCache_MemoShortCircuitsWithinRequest(t *testing.T) { + clk := &fakeClock{t: cacheTestBase} + f := &fakeLister{members: []string{"did:plc:boltless"}} + c := newCache(f, cacheTTL, clk.now) + ctx := WithMemo(context.Background()) + + first, err := c.GetKnotMembers(ctx, "knot.nel.pet") + if err != nil { + t.Fatal(err) + } + + clk.advance(2 * cacheTTL) + f.set([]string{"did:plc:akshay"}, nil) + + second, err := c.GetKnotMembers(ctx, "knot.nel.pet") + if err != nil { + t.Fatal(err) + } + if !slices.Equal(first, second) { + t.Errorf("memo must hold one snapshot per request: first=%v second=%v", first, second) + } + if f.calls() != 1 { + t.Errorf("memberCalls=%d, want 1; the request memo must not re-query even past the TTL", f.calls()) + } +} + +func TestCache_ReturnedSliceCannotCorruptCache(t *testing.T) { + clk := &fakeClock{t: cacheTestBase} + f := &fakeLister{members: []string{"did:plc:boltless", "did:plc:akshay"}} + c := newCache(f, cacheTTL, clk.now) + ctx := context.Background() + + got, err := c.GetKnotMembers(ctx, "knot.nel.pet") + if err != nil { + t.Fatal(err) + } + for i := range got { + got[i] = "did:plc:squid" + } + + again, err := c.GetKnotMembers(ctx, "knot.nel.pet") + if err != nil { + t.Fatal(err) + } + if slices.Contains(again, "did:plc:squid") { + t.Errorf("a caller mutating its returned slice corrupted the cached entry: %v", again) + } + if f.calls() != 1 { + t.Errorf("memberCalls=%d, want 1; the second read should be served from cache", f.calls()) + } +} + +func TestCache_SingleflightCollapsesConcurrentMisses(t *testing.T) { + clk := &fakeClock{t: cacheTestBase} + started := make(chan struct{}) + release := make(chan struct{}) + f := &fakeLister{members: []string{"did:plc:boltless"}, started: started, block: release} + c := newCache(f, cacheTTL, clk.now) + ctx := context.Background() + + var wg sync.WaitGroup + call := func() { + wg.Add(1) + go func() { + defer wg.Done() + if _, err := c.GetKnotMembers(ctx, "knot.nel.pet"); err != nil { + t.Errorf("GetKnotMembers: %v", err) + } + }() + } + + call() + <-started + for range make([]struct{}, 8) { + call() + } + time.Sleep(20 * time.Millisecond) + close(release) + wg.Wait() + + if f.calls() != 1 { + t.Errorf("memberCalls=%d, want 1; concurrent misses must collapse into a single knot query", f.calls()) + } +} + +func TestCache_CapIsHardUnderFreshFlood(t *testing.T) { + clk := &fakeClock{t: cacheTestBase} + f := &fakeLister{members: []string{"did:plc:limpet"}} + c := newCache(f, cacheTTL, clk.now) + ctx := context.Background() + + for i := 0; i < cacheMaxEntries+100; i++ { + if _, err := c.GetKnotMembers(ctx, fmt.Sprintf("knot-%d.nel.pet", i)); err != nil { + t.Fatal(err) + } + } + + c.mu.Lock() + n := len(c.entries) + c.mu.Unlock() + if n > cacheMaxEntries { + t.Errorf("entries=%d, want <= %d; all-fresh keys must not grow past the cap", n, cacheMaxEntries) + } +} diff --git a/appview/knotacl/client_test.go b/appview/knotacl/client_test.go new file mode 100644 index 00000000..edd56654 --- /dev/null +++ b/appview/knotacl/client_test.go @@ -0,0 +1,182 @@ +package knotacl + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "slices" + "strings" + "sync" + "testing" + + "tangled.org/core/api/tangled" +) + +func testLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +const ( + testOwner = "did:plc:akshay" + testCollab = "did:plc:boltless" + testRepoDid = "did:plc:limpet" + testStrange = "did:plc:scallop" +) + +type recordingKnot struct { + mu sync.Mutex + requests []string + handler func(w http.ResponseWriter, r *http.Request) +} + +func (k *recordingKnot) ServeHTTP(w http.ResponseWriter, r *http.Request) { + k.mu.Lock() + k.requests = append(k.requests, r.URL.String()) + k.mu.Unlock() + k.handler(w, r) +} + +func (k *recordingKnot) calls() []string { + k.mu.Lock() + defer k.mu.Unlock() + return slices.Clone(k.requests) +} + +func devClientFor(t *testing.T, handler func(w http.ResponseWriter, r *http.Request)) (*Client, *recordingKnot, string) { + t.Helper() + knot := &recordingKnot{handler: handler} + srv := httptest.NewServer(knot) + t.Cleanup(srv.Close) + host := strings.TrimPrefix(srv.URL, "http://") + return NewClient(true, testLogger()), knot, host +} + +func memberPage(items []string, cursor string) tangled.KnotListMembers_Output { + out := tangled.KnotListMembers_Output{ + Items: mapSlice(items, func(d string) *tangled.KnotListMembers_ListItem { + return &tangled.KnotListMembers_ListItem{Subject: d, AddedBy: testOwner, CreatedAt: "2026-06-03T00:00:00Z"} + }), + } + if cursor != "" { + out.Cursor = &cursor + } + return out +} + +func collabPage(items []string, cursor string) tangled.RepoListCollaborators_Output { + out := tangled.RepoListCollaborators_Output{ + Items: mapSlice(items, func(d string) *tangled.RepoListCollaborators_ListItem { + return &tangled.RepoListCollaborators_ListItem{Subject: d, AddedBy: testOwner, CreatedAt: "2026-06-03T00:00:00Z"} + }), + } + if cursor != "" { + out.Cursor = &cursor + } + return out +} + +func TestGetKnotMembers_SinglePage(t *testing.T) { + c, _, host := devClientFor(t, func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(memberPage([]string{testCollab, testOwner, testCollab}, "")) + }) + + got, err := c.GetKnotMembers(context.Background(), host) + if err != nil { + t.Fatalf("GetKnotMembers: %v", err) + } + want := []string{testOwner, testCollab} + if !slices.Equal(got, want) { + t.Errorf("members = %v, want sorted+deduped %v", got, want) + } +} + +func TestGetKnotMembers_Paginates(t *testing.T) { + c, knot, host := devClientFor(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("cursor") == "" { + json.NewEncoder(w).Encode(memberPage([]string{testOwner}, "page2")) + return + } + json.NewEncoder(w).Encode(memberPage([]string{testCollab}, "")) + }) + + got, err := c.GetKnotMembers(context.Background(), host) + if err != nil { + t.Fatalf("GetKnotMembers: %v", err) + } + if want := []string{testOwner, testCollab}; !slices.Equal(got, want) { + t.Errorf("members = %v, want union %v", got, want) + } + calls := knot.calls() + if len(calls) != 2 { + t.Fatalf("calls = %d, want 2 pages", len(calls)) + } + if !strings.Contains(calls[1], "cursor=page2") { + t.Errorf("second call %q did not carry the page-1 cursor", calls[1]) + } +} + +func TestGetKnotMembers_KnotDown(t *testing.T) { + c, _, host := devClientFor(t, func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + }) + + _, err := c.GetKnotMembers(context.Background(), host) + if err == nil { + t.Fatal("want error when knot is down; the Client must surface it for the Service to swallow") + } +} + +func TestGetRepoCollaborators_PassesRepoDidAsSubject(t *testing.T) { + c, knot, host := devClientFor(t, func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(collabPage([]string{testCollab}, "")) + }) + + got, err := c.GetRepoCollaborators(context.Background(), host, testRepoDid) + if err != nil { + t.Fatalf("GetRepoCollaborators: %v", err) + } + if want := []string{testCollab}; !slices.Equal(got, want) { + t.Errorf("collaborators = %v, want %v", got, want) + } + if calls := knot.calls(); len(calls) != 1 || !strings.Contains(calls[0], "subject="+strings.ReplaceAll(testRepoDid, ":", "%3A")) { + t.Errorf("subject param missing repo DID: %v", calls) + } +} + +func TestDrainStopsOnRepeatedCursor(t *testing.T) { + c, knot, host := devClientFor(t, func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(memberPage([]string{testRepoDid}, "stuck")) + }) + + got, err := c.GetKnotMembers(context.Background(), host) + if err != nil { + t.Fatalf("GetKnotMembers: %v", err) + } + if want := []string{testRepoDid}; !slices.Equal(got, want) { + t.Errorf("members = %v, want %v after dedup", got, want) + } + if calls := len(knot.calls()); calls != 2 { + t.Errorf("calls = %d, want 2; a knot echoing the same cursor must halt at once, not page to the cap", calls) + } +} + +func TestDrainStopsAtPageCap(t *testing.T) { + c, knot, host := devClientFor(t, func(w http.ResponseWriter, r *http.Request) { + cursor := r.URL.Query().Get("cursor") + json.NewEncoder(w).Encode(memberPage([]string{testCollab}, cursor+"x")) + }) + + got, err := c.GetKnotMembers(context.Background(), host) + if err != nil { + t.Fatalf("GetKnotMembers: %v", err) + } + if want := []string{testCollab}; !slices.Equal(got, want) { + t.Errorf("members = %v, want %v after dedup", got, want) + } + if calls := len(knot.calls()); calls != maxListPages { + t.Errorf("calls = %d, want the %d-page cap to halt an ever-advancing cursor", calls, maxListPages) + } +} diff --git a/appview/knotacl/membership_test.go b/appview/knotacl/membership_test.go new file mode 100644 index 00000000..98009b32 --- /dev/null +++ b/appview/knotacl/membership_test.go @@ -0,0 +1,78 @@ +package knotacl + +import ( + "context" + "slices" + "testing" + + "tangled.org/core/appview/db" + "tangled.org/core/orm" +) + +func registerOwner(t *testing.T, d *db.DB, host, ownerDid string) { + t.Helper() + if err := db.AddKnot(d, host, ownerDid); err != nil { + t.Fatalf("AddKnot: %v", err) + } + if err := db.MarkRegistered(d, orm.FilterEq("domain", host), orm.FilterEq("did", ownerDid)); err != nil { + t.Fatalf("MarkRegistered: %v", err) + } +} + +func TestService_IsKnotMemberNative(t *testing.T) { + ctx := context.Background() + svc, d, host := newServiceEnv(t, &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL, members: []string{testCollab}}, nil) + + if !svc.IsKnotMember(ctx, host, testCollab) { + t.Error("a did in the native listMembers must read as a member") + } + if svc.IsKnotMember(ctx, host, testStrange) { + t.Error("a stranger must not read as a member") + } + + registerOwner(t, d, host, testOwner) + if !svc.IsKnotMember(ctx, host, testOwner) { + t.Error("a registered owner must read as a member of its own native knot") + } +} + +func TestService_IsKnotMemberLegacy(t *testing.T) { + ctx := context.Background() + svc, _, host := newServiceEnv(t, &fakeKnot{version: "v1.14.0"}, nil) + + if !svc.IsKnotMember(ctx, host, testOwner) { + t.Error("the casbin-seeded owner must read as a member on an old knot") + } + if svc.IsKnotMember(ctx, host, testStrange) { + t.Error("a stranger must not read as a member on an old knot") + } +} + +func TestService_KnotsForUserNativeMemberAndOwner(t *testing.T) { + ctx := context.Background() + svc, d, host := newServiceEnv(t, &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL, members: []string{testCollab}}, nil) + registerOwner(t, d, host, testOwner) + + if got := svc.KnotsForUser(ctx, testCollab); !slices.Contains(got, host) { + t.Errorf("KnotsForUser(member) = %v; a native member must surface via the listMembers fan-out, not casbin", got) + } + if got := svc.KnotsForUser(ctx, testOwner); !slices.Contains(got, host) { + t.Errorf("KnotsForUser(owner) = %v, want the registered knot", got) + } + if got := svc.KnotsForUser(ctx, testStrange); slices.Contains(got, host) { + t.Errorf("KnotsForUser(stranger) = %v, want the knot omitted", got) + } +} + +func TestService_KnotsForUserNativeListDownDegrades(t *testing.T) { + ctx := context.Background() + svc, d, host := newServiceEnv(t, &fakeKnot{version: "v1.15.0", capabilities: capsKnotACL, listStatus: 500, members: []string{testCollab}}, nil) + registerOwner(t, d, host, testOwner) + + if got := svc.KnotsForUser(ctx, testOwner); !slices.Contains(got, host) { + t.Errorf("KnotsForUser(owner) = %v; an owner must surface from registrations even when the knot list is down", got) + } + if got := svc.KnotsForUser(ctx, testCollab); slices.Contains(got, host) { + t.Errorf("KnotsForUser(member) = %v; a member must not surface when the bounded fan-out cannot reach the knot", got) + } +} -- 2.51.2