diff --git a/knotserver/xrpc/aclevents_test.go b/knotserver/xrpc/aclevents_test.go new file mode 100644 --- /dev/null +++ b/knotserver/xrpc/aclevents_test.go @@ -0,0 +1,129 @@ +package xrpc + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "tangled.org/core/api/tangled" + "tangled.org/core/eventstream" + "tangled.org/core/knotserver/db" +) + +func eventsOfType(t *testing.T, x *Xrpc, nsid string) []eventstream.Event { + t.Helper() + all, err := x.Db.GetEvents(0, 1000) + if err != nil { + t.Fatalf("GetEvents: %v", err) + } + var out []eventstream.Event + for _, e := range all { + if e.Nsid == nsid { + out = append(out, e) + } + } + return out +} + +func decodeMemberUpdate(t *testing.T, e eventstream.Event) db.KnotMemberUpdate { + t.Helper() + var m db.KnotMemberUpdate + if err := json.Unmarshal(e.EventJson, &m); err != nil { + t.Fatalf("decode memberUpdate: %v", err) + } + return m +} + +func decodeCollaboratorUpdate(t *testing.T, e eventstream.Event) db.RepoCollaboratorUpdate { + t.Helper() + var c db.RepoCollaboratorUpdate + if err := json.Unmarshal(e.EventJson, &c); err != nil { + t.Fatalf("decode collaboratorUpdate: %v", err) + } + return c +} + +func TestAddMember_EmitsAddEvent(t *testing.T) { + x, _ := newACLXrpc(t) + rec := httptest.NewRecorder() + x.AddMember(rec, aclRequest(t, aclOwner, tangled.KnotAddMember_Input{Subject: aclSubject})) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) + } + + evs := eventsOfType(t, x, db.KnotMemberUpdateNSID) + if len(evs) != 1 { + t.Fatalf("memberUpdate events = %d, want 1", len(evs)) + } + m := decodeMemberUpdate(t, evs[0]) + if m.Op != db.AclOpAdd || m.Subject != aclSubject { + t.Errorf("event = %+v, want op=add subject=%s", m, aclSubject) + } +} + +func TestRemoveMember_EmitsRemoveEvent(t *testing.T) { + x, _ := newACLXrpc(t) + seedMembers(t, x, aclSubject) + + rec := httptest.NewRecorder() + x.RemoveMember(rec, aclRequest(t, aclOwner, tangled.KnotRemoveMember_Input{Subject: aclSubject})) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) + } + + evs := eventsOfType(t, x, db.KnotMemberUpdateNSID) + if len(evs) != 2 { + t.Fatalf("memberUpdate events = %d, want 2 (add then remove)", len(evs)) + } + last := decodeMemberUpdate(t, evs[len(evs)-1]) + if last.Op != db.AclOpRemove || last.Subject != aclSubject { + t.Errorf("last event = %+v, want op=remove subject=%s", last, aclSubject) + } +} + +func TestAddMember_NoOpDoesNotEmit(t *testing.T) { + x, _ := newACLXrpc(t) + for range 2 { + rec := httptest.NewRecorder() + x.AddMember(rec, aclRequest(t, aclOwner, tangled.KnotAddMember_Input{Subject: aclSubject})) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) + } + } + + if evs := eventsOfType(t, x, db.KnotMemberUpdateNSID); len(evs) != 1 { + t.Errorf("memberUpdate events = %d, want 1; a duplicate grant is a no-op and must not emit", len(evs)) + } +} + +func TestCollaborator_EmitsAddThenRemove(t *testing.T) { + x, _ := newACLXrpc(t) + seedRepo(t, x) + + addRec := httptest.NewRecorder() + x.AddCollaborator(addRec, aclRequest(t, aclOwner, tangled.RepoAddCollaborator_Input{Repo: aclRepoDid, Subject: aclSubject})) + if addRec.Code != http.StatusOK { + t.Fatalf("add status = %d, body=%s", addRec.Code, addRec.Body.String()) + } + + rmRec := httptest.NewRecorder() + x.RemoveCollaborator(rmRec, aclRequest(t, aclOwner, tangled.RepoRemoveCollaborator_Input{Repo: aclRepoDid, Subject: aclSubject})) + if rmRec.Code != http.StatusOK { + t.Fatalf("remove status = %d, body=%s", rmRec.Code, rmRec.Body.String()) + } + + evs := eventsOfType(t, x, db.RepoCollaboratorUpdateNSID) + if len(evs) != 2 { + t.Fatalf("collaboratorUpdate events = %d, want 2 (add then remove)", len(evs)) + } + + add := decodeCollaboratorUpdate(t, evs[0]) + if add.Op != db.AclOpAdd || add.Subject != aclSubject || add.Repo != aclRepoDid { + t.Errorf("add event = %+v, want op=add subject=%s repo=%s", add, aclSubject, aclRepoDid) + } + rm := decodeCollaboratorUpdate(t, evs[1]) + if rm.Op != db.AclOpRemove || rm.Subject != aclSubject || rm.Repo != aclRepoDid { + t.Errorf("remove event = %+v, want op=remove subject=%s repo=%s", rm, aclSubject, aclRepoDid) + } +} diff --git a/knotserver/xrpc/list_test.go b/knotserver/xrpc/list_test.go new file mode 100644 --- /dev/null +++ b/knotserver/xrpc/list_test.go @@ -0,0 +1,207 @@ +package xrpc + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "tangled.org/core/api/tangled" +) + +func listRequest(t *testing.T, params url.Values) *http.Request { + t.Helper() + return httptest.NewRequest(http.MethodGet, "/xrpc/test?"+params.Encode(), nil) +} + +func decodeMembers(t *testing.T, rec *httptest.ResponseRecorder) tangled.KnotListMembers_Output { + t.Helper() + var out tangled.KnotListMembers_Output + if err := json.NewDecoder(rec.Body).Decode(&out); err != nil { + t.Fatalf("decode members: %v", err) + } + return out +} + +func seedMembers(t *testing.T, x *Xrpc, subjects ...string) { + t.Helper() + for _, s := range subjects { + rec := httptest.NewRecorder() + x.AddMember(rec, aclRequest(t, aclOwner, tangled.KnotAddMember_Input{Subject: s})) + if rec.Code != http.StatusOK { + t.Fatalf("seed member %s: status %d, body=%s", s, rec.Code, rec.Body.String()) + } + } +} + +func TestListMembers_ReturnsAddedSubjects(t *testing.T) { + x, _ := newACLXrpc(t) + seedMembers(t, x, aclSubject, "did:plc:scallop") + + rec := httptest.NewRecorder() + x.ListMembers(rec, listRequest(t, url.Values{"subject": {"knot.example"}})) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + + out := decodeMembers(t, rec) + if len(out.Items) != 2 { + t.Fatalf("items = %d, want 2", len(out.Items)) + } + if out.Cursor != nil { + t.Errorf("cursor = %q, want nil for a complete page", *out.Cursor) + } + + for _, it := range out.Items { + if it.AddedBy != aclOwner { + t.Errorf("subject %s addedBy = %q, want %s", it.Subject, it.AddedBy, aclOwner) + } + if it.CreatedAt == "" { + t.Errorf("subject %s has empty createdAt", it.Subject) + } + if it.Uri != nil || it.Cid != nil { + t.Errorf("subject %s carries record uri/cid; a knot must omit them", it.Subject) + } + } +} + +func TestListMembers_Empty(t *testing.T) { + x, _ := newACLXrpc(t) + rec := httptest.NewRecorder() + x.ListMembers(rec, listRequest(t, url.Values{"subject": {"knot.example"}})) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + out := decodeMembers(t, rec) + if len(out.Items) != 0 { + t.Errorf("items = %d, want 0", len(out.Items)) + } +} + +func TestListMembers_RejectsMalformedParams(t *testing.T) { + x, _ := newACLXrpc(t) + for _, q := range []url.Values{ + {"limit": {"abc"}}, + {"cursor": {"notanint"}}, + {"order": {"ascending"}}, + } { + rec := httptest.NewRecorder() + x.ListMembers(rec, listRequest(t, q)) + if rec.Code != http.StatusBadRequest { + t.Errorf("params %v: status = %d, want 400", q, rec.Code) + } + } +} + +func TestListMembers_ClampsOutOfRangeLimit(t *testing.T) { + x, _ := newACLXrpc(t) + seedMembers(t, x, aclSubject) + rec := httptest.NewRecorder() + x.ListMembers(rec, listRequest(t, url.Values{"limit": {"5000"}})) + if rec.Code != http.StatusOK { + t.Fatalf("limit=5000 should clamp and return 200, got %d; body=%s", rec.Code, rec.Body.String()) + } + if len(decodeMembers(t, rec).Items) != 1 { + t.Error("clamped limit must still return the seeded member") + } +} + +func TestListMembers_PaginatesWithoutOverlap(t *testing.T) { + x, _ := newACLXrpc(t) + all := []string{"did:plc:scallop", "did:plc:whelk", "did:plc:limpet"} + seedMembers(t, x, all...) + + seen := map[string]bool{} + params := url.Values{"subject": {"knot.example"}, "limit": {"2"}} + + rec := httptest.NewRecorder() + x.ListMembers(rec, listRequest(t, params)) + page1 := decodeMembers(t, rec) + if len(page1.Items) != 2 || page1.Cursor == nil { + t.Fatalf("page1 items=%d cursor=%v, want 2 items and a cursor", len(page1.Items), page1.Cursor) + } + for _, it := range page1.Items { + seen[it.Subject] = true + } + + params.Set("cursor", *page1.Cursor) + rec = httptest.NewRecorder() + x.ListMembers(rec, listRequest(t, params)) + page2 := decodeMembers(t, rec) + if len(page2.Items) != 1 { + t.Fatalf("page2 items = %d, want 1", len(page2.Items)) + } + if page2.Cursor != nil { + t.Errorf("page2 cursor = %q, want nil at end", *page2.Cursor) + } + for _, it := range page2.Items { + if seen[it.Subject] { + t.Errorf("subject %s repeated across pages", it.Subject) + } + seen[it.Subject] = true + } + + if len(seen) != len(all) { + t.Errorf("distinct subjects seen = %d, want %d", len(seen), len(all)) + } +} + +func TestListCollaborators_ScopedToRepo(t *testing.T) { + x, _ := newACLXrpc(t) + seedRepo(t, x) + + addRec := httptest.NewRecorder() + x.AddCollaborator(addRec, aclRequest(t, aclOwner, tangled.RepoAddCollaborator_Input{Repo: aclRepoDid, Subject: aclSubject})) + if addRec.Code != http.StatusOK { + t.Fatalf("seed collaborator: status %d, body=%s", addRec.Code, addRec.Body.String()) + } + + rec := httptest.NewRecorder() + x.ListCollaborators(rec, listRequest(t, url.Values{"subject": {aclRepoDid}})) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + + var out tangled.RepoListCollaborators_Output + if err := json.NewDecoder(rec.Body).Decode(&out); err != nil { + t.Fatalf("decode: %v", err) + } + if len(out.Items) != 1 { + t.Fatalf("items = %d, want 1", len(out.Items)) + } + if out.Items[0].Subject != aclSubject { + t.Errorf("subject = %q, want %s", out.Items[0].Subject, aclSubject) + } + if out.Items[0].AddedBy != aclOwner { + t.Errorf("addedBy = %q, want %s", out.Items[0].AddedBy, aclOwner) + } + if out.Items[0].Uri != nil || out.Items[0].Cid != nil { + t.Error("collaborator carries record uri/cid; a knot must omit them") + } +} + +func TestListCollaborators_MalformedSubjectBadRequest(t *testing.T) { + x, _ := newACLXrpc(t) + rec := httptest.NewRecorder() + x.ListCollaborators(rec, listRequest(t, url.Values{"subject": {"notadid"}})) + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", rec.Code) + } +} + +func TestListCollaborators_UnknownRepoEmpty(t *testing.T) { + x, _ := newACLXrpc(t) + rec := httptest.NewRecorder() + x.ListCollaborators(rec, listRequest(t, url.Values{"subject": {"did:plc:scallop"}})) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var out tangled.RepoListCollaborators_Output + if err := json.NewDecoder(rec.Body).Decode(&out); err != nil { + t.Fatalf("decode: %v", err) + } + if len(out.Items) != 0 { + t.Errorf("items = %d, want 0 for an unknown repo", len(out.Items)) + } +}