tinyrelayfile
tinyrelay/internal/community/requests_test.go/

internal/community/requests_test.go raw
1package community
2
3import (
4 "context"
5 "database/sql"
6 "strings"
7 "testing"
8
9 "github.com/FelineStateMachine/tinyrelay/internal/event"
10 "github.com/FelineStateMachine/tinyrelay/internal/policy"
11 "github.com/FelineStateMachine/tinyrelay/internal/storage"
12)
13
14func requestService(t *testing.T) (*Service, *storage.Store, string) {
15 t.Helper()
16 ctx := context.Background()
17 st, err := storage.Open(ctx, t.TempDir()+"/relay.db")
18 if err != nil {
19 t.Fatal(err)
20 }
21 t.Cleanup(func() { st.Close() })
22 owner := strings.Repeat("a", 64)
23 svc, err := New(ctx, st, owner)
24 if err != nil {
25 t.Fatal(err)
26 }
27 p := policy.Defaults(owner)
28 svc.ConfigurePolicy(func() policy.Policy { return p })
29 return svc, st, owner
30}
31
32func accessRequest(pubkey, content string) event.Event {
33 return event.Event{ID: strings.Repeat("1", 64), PubKey: pubkey, Kind: event.KIND_NIP43_JOIN, Tags: [][]string{{"-"}}, Content: content}
34}
35
36func joinRequestRow(t *testing.T, st *storage.Store, pubkey string) (JoinRequest, int) {
37 t.Helper()
38 var r JoinRequest
39 var count int
40 if err := st.DB().QueryRow(`SELECT count(*) FROM join_requests`).Scan(&count); err != nil {
41 t.Fatal(err)
42 }
43 err := st.DB().QueryRow(`SELECT pubkey,reason,requested_at,status,decided_by,decided_at FROM join_requests WHERE pubkey=?`, pubkey).Scan(&r.PubKey, &r.Reason, &r.RequestedAt, &r.Status, &r.DecidedBy, &r.DecidedAt)
44 if err != nil && err != sql.ErrNoRows {
45 t.Fatal(err)
46 }
47 return r, count
48}
49
50func TestAccessRequestIsRecordedRefreshedAndAnswered(t *testing.T) {
51 ctx := context.Background()
52 for _, path := range []string{"transactional", "direct"} {
53 t.Run(path, func(t *testing.T) {
54 svc, st, owner := requestService(t)
55 stranger, member, banned := strings.Repeat("b", 64), strings.Repeat("c", 64), strings.Repeat("d", 64)
56 persisted := false
57 handle := func(ev event.Event) (MembershipResult, error) {
58 if path == "direct" {
59 return svc.HandleMembershipEvent(ctx, ev)
60 }
61 return svc.HandleMembershipEventTx(ctx, ev, func(*sql.Tx) error { persisted = true; return nil })
62 }
63 result, err := handle(accessRequest(stranger, " Hi, I am Bob from the meetup. "))
64 if err != nil {
65 t.Fatal(err)
66 }
67 if !result.OK || result.Stored || !result.AccessRequest || !result.NewRequest || result.Message != JoinRequestReceived {
68 t.Fatalf("first request result %+v", result)
69 }
70 if result.Message != "info: access request received, the relay owner will review it" {
71 t.Fatalf("OK message %q", result.Message)
72 }
73 row, count := joinRequestRow(t, st, stranger)
74 if count != 1 || row.Reason != "Hi, I am Bob from the meetup." || row.Status != "pending" || row.RequestedAt == 0 || row.DecidedBy != "" || row.DecidedAt != 0 {
75 t.Fatalf("row %+v count %d", row, count)
76 }
77 if persisted {
78 t.Fatal("access request was stored as an event")
79 }
80 if role, _ := svc.Role(ctx, stranger); role != "" {
81 t.Fatalf("request made a member: %q", role)
82 }
83 // The owner's own row queued one projection; the request adds none.
84 var intents int
85 if err := st.DB().QueryRow(`SELECT count(*) FROM work_intents WHERE kind='records-projection'`).Scan(&intents); err != nil || intents != 1 {
86 t.Fatalf("request queued a projection: %d %v", intents-1, err)
87 }
88
89 // A repeat refreshes the reason and time in the same row and is
90 // not a new request.
91 if _, err := st.DB().Exec(`UPDATE join_requests SET requested_at=1 WHERE pubkey=?`, stranger); err != nil {
92 t.Fatal(err)
93 }
94 long := strings.Repeat("x", 600)
95 result, err = handle(accessRequest(stranger, long))
96 if err != nil || !result.OK || result.NewRequest || !result.AccessRequest || result.Message != JoinRequestReceived {
97 t.Fatalf("repeat result %+v %v", result, err)
98 }
99 row, count = joinRequestRow(t, st, stranger)
100 if count != 1 || len(row.Reason) != JoinReasonMax || row.RequestedAt == 1 || row.Status != "pending" {
101 t.Fatalf("refreshed row %+v count %d", row, count)
102 }
103
104 // A member is told so, as before.
105 if _, err := svc.Execute(ctx, owner, "setmember", raws(member, map[string]any{"role": "member"})); err != nil {
106 t.Fatal(err)
107 }
108 result, err = handle(accessRequest(member, "again"))
109 if err != nil || !result.OK || result.Message != "duplicate: already a member" || result.AccessRequest {
110 t.Fatalf("member result %+v %v", result, err)
111 }
112 if _, count := joinRequestRow(t, st, member); count != 1 {
113 t.Fatalf("member request recorded: %d rows", count)
114 }
115
116 // A banned key is refused.
117 if _, err := svc.Execute(ctx, owner, "banpubkey", raws(banned, "spam")); err != nil {
118 t.Fatal(err)
119 }
120 if _, err := handle(accessRequest(banned, "let me in")); err == nil || !strings.HasPrefix(err.Error(), "restricted:") {
121 t.Fatalf("banned request: %v", err)
122 }
123 if _, count := joinRequestRow(t, st, banned); count != 1 {
124 t.Fatalf("banned request recorded: %d rows", count)
125 }
126
127 // A claimed join keeps its invite checks.
128 claimed := accessRequest(strings.Repeat("e", 64), "")
129 claimed.Tags = [][]string{{"-"}, {"claim", "nosuchcode"}}
130 if _, err := handle(claimed); err == nil || err.Error() != "invite_invalid" {
131 t.Fatalf("claimed join: %v", err)
132 }
133 })
134 }
135}
136
137func TestApproveAndDenyAccessRequests(t *testing.T) {
138 ctx := context.Background()
139 svc, st, owner := requestService(t)
140 moderator, member, alice, bob, carol := strings.Repeat("b", 64), strings.Repeat("c", 64), strings.Repeat("d", 64), strings.Repeat("e", 64), strings.Repeat("f", 64)
141 if _, err := svc.Execute(ctx, owner, "setmember", raws(moderator, map[string]any{"role": "moderator"})); err != nil {
142 t.Fatal(err)
143 }
144 if _, err := svc.Execute(ctx, owner, "setmember", raws(member, map[string]any{"role": "member"})); err != nil {
145 t.Fatal(err)
146 }
147 for _, name := range []string{"listjoinrequests", "approvejoin", "denyjoin"} {
148 if !containsMethod(svc.Methods(), name) {
149 t.Fatalf("methods lack %s", name)
150 }
151 }
152 for _, pk := range []string{alice, bob, carol} {
153 if _, err := svc.HandleMembershipEvent(ctx, accessRequest(pk, "please "+pk[:1])); err != nil {
154 t.Fatal(err)
155 }
156 }
157 // Only the owner and moderators list or decide.
158 for _, actor := range []string{member, alice, strings.Repeat("9", 64)} {
159 for _, method := range []string{"listjoinrequests", "approvejoin", "denyjoin"} {
160 if _, err := svc.Execute(ctx, actor, method, raws(alice)); err == nil || !strings.HasPrefix(err.Error(), "restricted:") {
161 t.Fatalf("%s by %s: %v", method, actor[:1], err)
162 }
163 }
164 }
165 listed, err := svc.Execute(ctx, moderator, "listjoinrequests", nil)
166 if err != nil {
167 t.Fatal(err)
168 }
169 if rows := listed.([]JoinRequest); len(rows) != 3 || rows[0].Status != "pending" || rows[0].Reason != "please "+rows[0].PubKey[:1] {
170 t.Fatalf("listjoinrequests %+v", rows)
171 }
172 var intentsBefore int
173 if err := st.DB().QueryRow(`SELECT count(*) FROM work_intents WHERE kind='records-projection'`).Scan(&intentsBefore); err != nil {
174 t.Fatal(err)
175 }
176 result, err := svc.Execute(ctx, moderator, "approvejoin", raws(alice))
177 if err != nil {
178 t.Fatal(err)
179 }
180 if got := result.(map[string]any); got["pubkey"] != alice || got["status"] != "approved" {
181 t.Fatalf("approvejoin = %v", got)
182 }
183 var role, via string
184 if err := st.DB().QueryRow(`SELECT role,via FROM community_members WHERE pubkey=?`, alice).Scan(&role, &via); err != nil || role != "member" || via != "request" {
185 t.Fatalf("approved member role=%q via=%q err=%v", role, via, err)
186 }
187 var intentsAfter int
188 if err := st.DB().QueryRow(`SELECT count(*) FROM work_intents WHERE kind='records-projection'`).Scan(&intentsAfter); err != nil || intentsAfter != intentsBefore+1 {
189 t.Fatalf("approval queued %d projections, want 1", intentsAfter-intentsBefore)
190 }
191 row, _ := joinRequestRow(t, st, alice)
192 if row.Status != "approved" || row.DecidedBy != moderator || row.DecidedAt == 0 {
193 t.Fatalf("approved row %+v", row)
194 }
195 if _, err := svc.Execute(ctx, owner, "denyjoin", raws(alice)); err == nil || !strings.HasPrefix(err.Error(), "invalid:") {
196 t.Fatalf("deny after approve: %v", err)
197 }
198 // A key that already became a member only has its request marked.
199 if _, err := svc.Execute(ctx, owner, "setmember", raws(bob, map[string]any{"name": "bob", "role": "member"})); err != nil {
200 t.Fatal(err)
201 }
202 if _, err := svc.Execute(ctx, owner, "approvejoin", raws(bob)); err != nil {
203 t.Fatal(err)
204 }
205 var name string
206 if err := st.DB().QueryRow(`SELECT name,via FROM community_members WHERE pubkey=?`, bob).Scan(&name, &via); err != nil || name != "bob" || via != "management" {
207 t.Fatalf("existing member changed: name=%q via=%q err=%v", name, via, err)
208 }
209 if row, _ := joinRequestRow(t, st, bob); row.Status != "approved" || row.DecidedBy != owner {
210 t.Fatalf("bob row %+v", row)
211 }
212 if _, err := svc.Execute(ctx, owner, "denyjoin", raws(carol)); err != nil {
213 t.Fatal(err)
214 }
215 if row, _ := joinRequestRow(t, st, carol); row.Status != "denied" || row.DecidedBy != owner || row.DecidedAt == 0 {
216 t.Fatalf("carol row %+v", row)
217 }
218 if role, _ := svc.Role(ctx, carol); role != "" {
219 t.Fatalf("denied key became %q", role)
220 }
221 if _, err := svc.Execute(ctx, owner, "approvejoin", raws(strings.Repeat("8", 64))); err == nil || !strings.HasPrefix(err.Error(), "not found:") {
222 t.Fatalf("approve unknown: %v", err)
223 }
224 // Decided requests follow the pending ones, newest decision first; a
225 // denied key may ask again.
226 if _, err := st.DB().Exec(`UPDATE join_requests SET decided_at=decided_at-60 WHERE pubkey=?`, alice); err != nil {
227 t.Fatal(err)
228 }
229 if _, err := svc.HandleMembershipEvent(ctx, accessRequest(strings.Repeat("7", 64), "new")); err != nil {
230 t.Fatal(err)
231 }
232 again, err := svc.HandleMembershipEvent(ctx, accessRequest(carol, "second try"))
233 if err != nil || !again.NewRequest {
234 t.Fatalf("denied key asking again %+v %v", again, err)
235 }
236 listed, _ = svc.Execute(ctx, owner, "listjoinrequests", nil)
237 rows := listed.([]JoinRequest)
238 if len(rows) != 4 || rows[0].Status != "pending" || rows[1].Status != "pending" || rows[2].Status == "pending" || rows[3].Status == "pending" {
239 t.Fatalf("order %+v", rows)
240 }
241 if rows[2].PubKey != bob || rows[3].PubKey != alice {
242 t.Fatalf("decided order %s %s", rows[2].PubKey[:1], rows[3].PubKey[:1])
243 }
244 audit, _ := svc.Execute(ctx, owner, "listaudit", nil)
245 seen := map[string]bool{}
246 for _, row := range audit.([]AuditRow) {
247 seen[row.Action+":"+row.Target] = true
248 }
249 if !seen["approvejoin:"+alice] || !seen["denyjoin:"+carol] || !seen["setmember:"+alice] {
250 t.Fatalf("audit %v", seen)
251 }
252}
253
254func containsMethod(methods []string, wanted string) bool {
255 for _, method := range methods {
256 if method == wanted {
257 return true
258 }
259 }
260 return false
261}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs