Monorepo for Tangled tangled.org
2

Configure Feed

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

knotserver/xrpc: test list endpoints

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

author
Lewis
committer
Tangled
date (Jun 16, 2026, 9:04 PM +0300) commit 8b6da860 parent 2fd598e8 change-id otrszlpr
+336
+129
knotserver/xrpc/aclevents_test.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "net/http/httptest" 7 + "testing" 8 + 9 + "tangled.org/core/api/tangled" 10 + "tangled.org/core/eventstream" 11 + "tangled.org/core/knotserver/db" 12 + ) 13 + 14 + func eventsOfType(t *testing.T, x *Xrpc, nsid string) []eventstream.Event { 15 + t.Helper() 16 + all, err := x.Db.GetEvents(0, 1000) 17 + if err != nil { 18 + t.Fatalf("GetEvents: %v", err) 19 + } 20 + var out []eventstream.Event 21 + for _, e := range all { 22 + if e.Nsid == nsid { 23 + out = append(out, e) 24 + } 25 + } 26 + return out 27 + } 28 + 29 + func decodeMemberUpdate(t *testing.T, e eventstream.Event) db.KnotMemberUpdate { 30 + t.Helper() 31 + var m db.KnotMemberUpdate 32 + if err := json.Unmarshal(e.EventJson, &m); err != nil { 33 + t.Fatalf("decode memberUpdate: %v", err) 34 + } 35 + return m 36 + } 37 + 38 + func decodeCollaboratorUpdate(t *testing.T, e eventstream.Event) db.RepoCollaboratorUpdate { 39 + t.Helper() 40 + var c db.RepoCollaboratorUpdate 41 + if err := json.Unmarshal(e.EventJson, &c); err != nil { 42 + t.Fatalf("decode collaboratorUpdate: %v", err) 43 + } 44 + return c 45 + } 46 + 47 + func TestAddMember_EmitsAddEvent(t *testing.T) { 48 + x, _ := newACLXrpc(t) 49 + rec := httptest.NewRecorder() 50 + x.AddMember(rec, aclRequest(t, aclOwner, tangled.KnotAddMember_Input{Subject: aclSubject})) 51 + if rec.Code != http.StatusOK { 52 + t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) 53 + } 54 + 55 + evs := eventsOfType(t, x, db.KnotMemberUpdateNSID) 56 + if len(evs) != 1 { 57 + t.Fatalf("memberUpdate events = %d, want 1", len(evs)) 58 + } 59 + m := decodeMemberUpdate(t, evs[0]) 60 + if m.Op != db.AclOpAdd || m.Subject != aclSubject { 61 + t.Errorf("event = %+v, want op=add subject=%s", m, aclSubject) 62 + } 63 + } 64 + 65 + func TestRemoveMember_EmitsRemoveEvent(t *testing.T) { 66 + x, _ := newACLXrpc(t) 67 + seedMembers(t, x, aclSubject) 68 + 69 + rec := httptest.NewRecorder() 70 + x.RemoveMember(rec, aclRequest(t, aclOwner, tangled.KnotRemoveMember_Input{Subject: aclSubject})) 71 + if rec.Code != http.StatusOK { 72 + t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) 73 + } 74 + 75 + evs := eventsOfType(t, x, db.KnotMemberUpdateNSID) 76 + if len(evs) != 2 { 77 + t.Fatalf("memberUpdate events = %d, want 2 (add then remove)", len(evs)) 78 + } 79 + last := decodeMemberUpdate(t, evs[len(evs)-1]) 80 + if last.Op != db.AclOpRemove || last.Subject != aclSubject { 81 + t.Errorf("last event = %+v, want op=remove subject=%s", last, aclSubject) 82 + } 83 + } 84 + 85 + func TestAddMember_NoOpDoesNotEmit(t *testing.T) { 86 + x, _ := newACLXrpc(t) 87 + for range 2 { 88 + rec := httptest.NewRecorder() 89 + x.AddMember(rec, aclRequest(t, aclOwner, tangled.KnotAddMember_Input{Subject: aclSubject})) 90 + if rec.Code != http.StatusOK { 91 + t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) 92 + } 93 + } 94 + 95 + if evs := eventsOfType(t, x, db.KnotMemberUpdateNSID); len(evs) != 1 { 96 + t.Errorf("memberUpdate events = %d, want 1; a duplicate grant is a no-op and must not emit", len(evs)) 97 + } 98 + } 99 + 100 + func TestCollaborator_EmitsAddThenRemove(t *testing.T) { 101 + x, _ := newACLXrpc(t) 102 + seedRepo(t, x) 103 + 104 + addRec := httptest.NewRecorder() 105 + x.AddCollaborator(addRec, aclRequest(t, aclOwner, tangled.RepoAddCollaborator_Input{Repo: aclRepoDid, Subject: aclSubject})) 106 + if addRec.Code != http.StatusOK { 107 + t.Fatalf("add status = %d, body=%s", addRec.Code, addRec.Body.String()) 108 + } 109 + 110 + rmRec := httptest.NewRecorder() 111 + x.RemoveCollaborator(rmRec, aclRequest(t, aclOwner, tangled.RepoRemoveCollaborator_Input{Repo: aclRepoDid, Subject: aclSubject})) 112 + if rmRec.Code != http.StatusOK { 113 + t.Fatalf("remove status = %d, body=%s", rmRec.Code, rmRec.Body.String()) 114 + } 115 + 116 + evs := eventsOfType(t, x, db.RepoCollaboratorUpdateNSID) 117 + if len(evs) != 2 { 118 + t.Fatalf("collaboratorUpdate events = %d, want 2 (add then remove)", len(evs)) 119 + } 120 + 121 + add := decodeCollaboratorUpdate(t, evs[0]) 122 + if add.Op != db.AclOpAdd || add.Subject != aclSubject || add.Repo != aclRepoDid { 123 + t.Errorf("add event = %+v, want op=add subject=%s repo=%s", add, aclSubject, aclRepoDid) 124 + } 125 + rm := decodeCollaboratorUpdate(t, evs[1]) 126 + if rm.Op != db.AclOpRemove || rm.Subject != aclSubject || rm.Repo != aclRepoDid { 127 + t.Errorf("remove event = %+v, want op=remove subject=%s repo=%s", rm, aclSubject, aclRepoDid) 128 + } 129 + }
+207
knotserver/xrpc/list_test.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "net/http/httptest" 7 + "net/url" 8 + "testing" 9 + 10 + "tangled.org/core/api/tangled" 11 + ) 12 + 13 + func listRequest(t *testing.T, params url.Values) *http.Request { 14 + t.Helper() 15 + return httptest.NewRequest(http.MethodGet, "/xrpc/test?"+params.Encode(), nil) 16 + } 17 + 18 + func decodeMembers(t *testing.T, rec *httptest.ResponseRecorder) tangled.KnotListMembers_Output { 19 + t.Helper() 20 + var out tangled.KnotListMembers_Output 21 + if err := json.NewDecoder(rec.Body).Decode(&out); err != nil { 22 + t.Fatalf("decode members: %v", err) 23 + } 24 + return out 25 + } 26 + 27 + func seedMembers(t *testing.T, x *Xrpc, subjects ...string) { 28 + t.Helper() 29 + for _, s := range subjects { 30 + rec := httptest.NewRecorder() 31 + x.AddMember(rec, aclRequest(t, aclOwner, tangled.KnotAddMember_Input{Subject: s})) 32 + if rec.Code != http.StatusOK { 33 + t.Fatalf("seed member %s: status %d, body=%s", s, rec.Code, rec.Body.String()) 34 + } 35 + } 36 + } 37 + 38 + func TestListMembers_ReturnsAddedSubjects(t *testing.T) { 39 + x, _ := newACLXrpc(t) 40 + seedMembers(t, x, aclSubject, "did:plc:scallop") 41 + 42 + rec := httptest.NewRecorder() 43 + x.ListMembers(rec, listRequest(t, url.Values{"subject": {"knot.example"}})) 44 + if rec.Code != http.StatusOK { 45 + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) 46 + } 47 + 48 + out := decodeMembers(t, rec) 49 + if len(out.Items) != 2 { 50 + t.Fatalf("items = %d, want 2", len(out.Items)) 51 + } 52 + if out.Cursor != nil { 53 + t.Errorf("cursor = %q, want nil for a complete page", *out.Cursor) 54 + } 55 + 56 + for _, it := range out.Items { 57 + if it.AddedBy != aclOwner { 58 + t.Errorf("subject %s addedBy = %q, want %s", it.Subject, it.AddedBy, aclOwner) 59 + } 60 + if it.CreatedAt == "" { 61 + t.Errorf("subject %s has empty createdAt", it.Subject) 62 + } 63 + if it.Uri != nil || it.Cid != nil { 64 + t.Errorf("subject %s carries record uri/cid; a knot must omit them", it.Subject) 65 + } 66 + } 67 + } 68 + 69 + func TestListMembers_Empty(t *testing.T) { 70 + x, _ := newACLXrpc(t) 71 + rec := httptest.NewRecorder() 72 + x.ListMembers(rec, listRequest(t, url.Values{"subject": {"knot.example"}})) 73 + if rec.Code != http.StatusOK { 74 + t.Fatalf("status = %d, want 200", rec.Code) 75 + } 76 + out := decodeMembers(t, rec) 77 + if len(out.Items) != 0 { 78 + t.Errorf("items = %d, want 0", len(out.Items)) 79 + } 80 + } 81 + 82 + func TestListMembers_RejectsMalformedParams(t *testing.T) { 83 + x, _ := newACLXrpc(t) 84 + for _, q := range []url.Values{ 85 + {"limit": {"abc"}}, 86 + {"cursor": {"notanint"}}, 87 + {"order": {"ascending"}}, 88 + } { 89 + rec := httptest.NewRecorder() 90 + x.ListMembers(rec, listRequest(t, q)) 91 + if rec.Code != http.StatusBadRequest { 92 + t.Errorf("params %v: status = %d, want 400", q, rec.Code) 93 + } 94 + } 95 + } 96 + 97 + func TestListMembers_ClampsOutOfRangeLimit(t *testing.T) { 98 + x, _ := newACLXrpc(t) 99 + seedMembers(t, x, aclSubject) 100 + rec := httptest.NewRecorder() 101 + x.ListMembers(rec, listRequest(t, url.Values{"limit": {"5000"}})) 102 + if rec.Code != http.StatusOK { 103 + t.Fatalf("limit=5000 should clamp and return 200, got %d; body=%s", rec.Code, rec.Body.String()) 104 + } 105 + if len(decodeMembers(t, rec).Items) != 1 { 106 + t.Error("clamped limit must still return the seeded member") 107 + } 108 + } 109 + 110 + func TestListMembers_PaginatesWithoutOverlap(t *testing.T) { 111 + x, _ := newACLXrpc(t) 112 + all := []string{"did:plc:scallop", "did:plc:whelk", "did:plc:limpet"} 113 + seedMembers(t, x, all...) 114 + 115 + seen := map[string]bool{} 116 + params := url.Values{"subject": {"knot.example"}, "limit": {"2"}} 117 + 118 + rec := httptest.NewRecorder() 119 + x.ListMembers(rec, listRequest(t, params)) 120 + page1 := decodeMembers(t, rec) 121 + if len(page1.Items) != 2 || page1.Cursor == nil { 122 + t.Fatalf("page1 items=%d cursor=%v, want 2 items and a cursor", len(page1.Items), page1.Cursor) 123 + } 124 + for _, it := range page1.Items { 125 + seen[it.Subject] = true 126 + } 127 + 128 + params.Set("cursor", *page1.Cursor) 129 + rec = httptest.NewRecorder() 130 + x.ListMembers(rec, listRequest(t, params)) 131 + page2 := decodeMembers(t, rec) 132 + if len(page2.Items) != 1 { 133 + t.Fatalf("page2 items = %d, want 1", len(page2.Items)) 134 + } 135 + if page2.Cursor != nil { 136 + t.Errorf("page2 cursor = %q, want nil at end", *page2.Cursor) 137 + } 138 + for _, it := range page2.Items { 139 + if seen[it.Subject] { 140 + t.Errorf("subject %s repeated across pages", it.Subject) 141 + } 142 + seen[it.Subject] = true 143 + } 144 + 145 + if len(seen) != len(all) { 146 + t.Errorf("distinct subjects seen = %d, want %d", len(seen), len(all)) 147 + } 148 + } 149 + 150 + func TestListCollaborators_ScopedToRepo(t *testing.T) { 151 + x, _ := newACLXrpc(t) 152 + seedRepo(t, x) 153 + 154 + addRec := httptest.NewRecorder() 155 + x.AddCollaborator(addRec, aclRequest(t, aclOwner, tangled.RepoAddCollaborator_Input{Repo: aclRepoDid, Subject: aclSubject})) 156 + if addRec.Code != http.StatusOK { 157 + t.Fatalf("seed collaborator: status %d, body=%s", addRec.Code, addRec.Body.String()) 158 + } 159 + 160 + rec := httptest.NewRecorder() 161 + x.ListCollaborators(rec, listRequest(t, url.Values{"subject": {aclRepoDid}})) 162 + if rec.Code != http.StatusOK { 163 + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) 164 + } 165 + 166 + var out tangled.RepoListCollaborators_Output 167 + if err := json.NewDecoder(rec.Body).Decode(&out); err != nil { 168 + t.Fatalf("decode: %v", err) 169 + } 170 + if len(out.Items) != 1 { 171 + t.Fatalf("items = %d, want 1", len(out.Items)) 172 + } 173 + if out.Items[0].Subject != aclSubject { 174 + t.Errorf("subject = %q, want %s", out.Items[0].Subject, aclSubject) 175 + } 176 + if out.Items[0].AddedBy != aclOwner { 177 + t.Errorf("addedBy = %q, want %s", out.Items[0].AddedBy, aclOwner) 178 + } 179 + if out.Items[0].Uri != nil || out.Items[0].Cid != nil { 180 + t.Error("collaborator carries record uri/cid; a knot must omit them") 181 + } 182 + } 183 + 184 + func TestListCollaborators_MalformedSubjectBadRequest(t *testing.T) { 185 + x, _ := newACLXrpc(t) 186 + rec := httptest.NewRecorder() 187 + x.ListCollaborators(rec, listRequest(t, url.Values{"subject": {"notadid"}})) 188 + if rec.Code != http.StatusBadRequest { 189 + t.Errorf("status = %d, want 400", rec.Code) 190 + } 191 + } 192 + 193 + func TestListCollaborators_UnknownRepoEmpty(t *testing.T) { 194 + x, _ := newACLXrpc(t) 195 + rec := httptest.NewRecorder() 196 + x.ListCollaborators(rec, listRequest(t, url.Values{"subject": {"did:plc:scallop"}})) 197 + if rec.Code != http.StatusOK { 198 + t.Fatalf("status = %d, want 200", rec.Code) 199 + } 200 + var out tangled.RepoListCollaborators_Output 201 + if err := json.NewDecoder(rec.Body).Decode(&out); err != nil { 202 + t.Fatalf("decode: %v", err) 203 + } 204 + if len(out.Items) != 0 { 205 + t.Errorf("items = %d, want 0 for an unknown repo", len(out.Items)) 206 + } 207 + }