tinyrelayfile
tinyrelay/internal/daemon/join_requests_test.go/

internal/daemon/join_requests_test.go raw
1package daemon
2
3import (
4 "context"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 "strconv"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/FelineStateMachine/tinyrelay/internal/community"
14 "github.com/FelineStateMachine/tinyrelay/internal/event"
15 "github.com/FelineStateMachine/tinyrelay/internal/relay"
16 "github.com/FelineStateMachine/tinyrelay/internal/storage"
17)
18
19const (
20 testAskerSecret = "0000000000000000000000000000000000000000000000000000000000000021"
21 testSecondSecret = "0000000000000000000000000000000000000000000000000000000000000022"
22)
23
24// accessRequestEvent is what Flotilla publishes: a kind 28934 join with no
25// claim tag and the reason in the content.
26func accessRequestEvent(t *testing.T, secret string, createdAt int64, reason string) event.Event {
27 t.Helper()
28 return signedEvent(t, secret, event.KIND_NIP43_JOIN, createdAt, [][]string{{"-"}}, reason)
29}
30
31func joinRequests(t *testing.T, tenant *Tenant, actor string) []community.JoinRequest {
32 t.Helper()
33 listed, err := tenant.Execute(context.Background(), actor, "listjoinrequests", nil)
34 if err != nil {
35 t.Fatal(err)
36 }
37 return listed.([]community.JoinRequest)
38}
39
40// waitProjections lets the tenant worker finish the membership projections
41// queued by an approval.
42func waitProjections(t *testing.T, tenant *Tenant) {
43 t.Helper()
44 deadline := time.Now().Add(15 * time.Second)
45 for {
46 var pending int
47 if err := tenant.store.DB().QueryRow(`SELECT count(*) FROM work_intents WHERE kind='records-projection' AND state IN ('pending','running')`).Scan(&pending); err != nil {
48 t.Fatal(err)
49 }
50 if pending == 0 {
51 return
52 }
53 if time.Now().After(deadline) {
54 t.Fatalf("%d projections still queued", pending)
55 }
56 time.Sleep(50 * time.Millisecond)
57 }
58}
59
60func recordsNaming(t *testing.T, tenant *Tenant, kind int, pubkey string) int {
61 t.Helper()
62 result, err := tenant.store.Query(context.Background(), event.Filter{Kinds: []int{kind}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}})
63 if err != nil {
64 t.Fatal(err)
65 }
66 count := 0
67 for _, e := range result.Events {
68 if e.PubKey != tenant.records.PublicKey() {
69 continue
70 }
71 for _, tag := range e.Tags {
72 if len(tag) > 1 && tag[1] == pubkey {
73 count++
74 break
75 }
76 }
77 }
78 return count
79}
80
81func TestAccessRequestIsHeldReviewedAndWakesReviewersOnce(t *testing.T) {
82 app, tenant := testTenant(t)
83 ctx := context.Background()
84 owner := tenant.Policy().Owner
85 asker, _ := event.PublicKey(testAskerSecret)
86 second, _ := event.PublicKey(testSecondSecret)
87 member, _ := event.PublicKey(testMemberSecret)
88 moderator, _ := event.PublicKey(testModSecret)
89 now := time.Now().Unix()
90
91 // The owner's device is registered so a request can wake it.
92 service := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusCreated) }))
93 defer service.Close()
94 tenant.pushClient = service.Client()
95 body, _ := json.Marshal(testSubscription(t, service.URL+"/send/owner-phone"))
96 subscribe := httptest.NewRequest("POST", "http://relay.test/push/subscribe", strings.NewReader(string(body)))
97 signRequest(t, subscribe, string(body))
98 subscribed := httptest.NewRecorder()
99 app.ServeHTTP(subscribed, subscribe)
100 if subscribed.Code != 200 {
101 t.Fatalf("subscribe %d %s", subscribed.Code, subscribed.Body.String())
102 }
103
104 request := accessRequestEvent(t, testAskerSecret, now, " Met you at the meetup, would like to follow the build. ")
105 reason, err := tenant.Publish(ctx, request, relay.Session{PubKeys: []string{asker}})
106 if err != nil {
107 t.Fatalf("access request refused: %v", err)
108 }
109 if reason != "info: access request received, the relay owner will review it" {
110 t.Fatalf("OK reason %q", reason)
111 }
112 rows := joinRequests(t, tenant, owner)
113 if len(rows) != 1 || rows[0].PubKey != asker || rows[0].Reason != "Met you at the meetup, would like to follow the build." || rows[0].Status != "pending" || rows[0].RequestedAt == 0 {
114 t.Fatalf("listjoinrequests %+v", rows)
115 }
116 var stored int
117 if err := tenant.store.DB().QueryRow(`SELECT count(*) FROM events WHERE kind=?`, event.KIND_NIP43_JOIN).Scan(&stored); err != nil || stored != 0 {
118 t.Fatalf("request stored as an event: %d %v", stored, err)
119 }
120 if role, _ := tenant.community.Role(ctx, asker); role != "" {
121 t.Fatalf("asker became %q", role)
122 }
123 notices := tenant.joinRequestNotices(ctx, request)
124 if len(notices) != 1 || notices[0].recipient != owner || notices[0].category != pushApprovals || notices[0].body != asker[:12]+" asks to join: Met you at the meetup, would like to follow the build." || notices[0].url != "http://relay.test/manage/people" {
125 t.Fatalf("notices %+v", notices)
126 }
127 if got := countIntents(t, tenant, notificationPush); got != 1 {
128 t.Fatalf("device notifications queued = %d, want 1", got)
129 }
130 // A repeat refreshes the request without waking the owner again.
131 if reason, err := tenant.Publish(ctx, accessRequestEvent(t, testAskerSecret, now+1, "Second reason"), relay.Session{PubKeys: []string{asker}}); err != nil || reason != community.JoinRequestReceived {
132 t.Fatalf("repeat %q %v", reason, err)
133 }
134 if rows := joinRequests(t, tenant, owner); len(rows) != 1 || rows[0].Reason != "Second reason" {
135 t.Fatalf("refreshed %+v", rows)
136 }
137 if got := countIntents(t, tenant, notificationPush); got != 1 {
138 t.Fatalf("device notifications after repeat = %d, want 1", got)
139 }
140 // Without a reason the summary says so; moderators are woken too.
141 if _, err := tenant.Execute(ctx, owner, "setmember", []json.RawMessage{json.RawMessage(strconv.Quote(moderator)), json.RawMessage(`{"role":"moderator"}`)}); err != nil {
142 t.Fatal(err)
143 }
144 if _, err := tenant.Execute(ctx, owner, "setmember", []json.RawMessage{json.RawMessage(strconv.Quote(member)), json.RawMessage(`{"role":"member"}`)}); err != nil {
145 t.Fatal(err)
146 }
147 quiet := accessRequestEvent(t, testSecondSecret, now, "")
148 notices = tenant.joinRequestNotices(ctx, quiet)
149 if len(notices) != 2 || notices[0].recipient != owner || notices[1].recipient != moderator || notices[1].body != second[:12]+" asks to join: no reason given" {
150 t.Fatalf("moderator notices %+v", notices)
151 }
152 if _, err := tenant.Publish(ctx, quiet, relay.Session{PubKeys: []string{second}}); err != nil {
153 t.Fatal(err)
154 }
155 // A member asking again is told so, and a claimed join still needs a
156 // valid invite.
157 if reason, err := tenant.Publish(ctx, accessRequestEvent(t, testMemberSecret, now, "again"), relay.Session{PubKeys: []string{member}}); err != nil || reason != "duplicate: already a member" {
158 t.Fatalf("member request %q %v", reason, err)
159 }
160 claimed := signedEvent(t, testAskerSecret, event.KIND_NIP43_JOIN, now+2, [][]string{{"-"}, {"claim", "nosuchcode"}}, "")
161 if _, err := tenant.Publish(ctx, claimed, relay.Session{PubKeys: []string{asker}}); err == nil || err.Error() != "invite_invalid" {
162 t.Fatalf("claimed join: %v", err)
163 }
164
165 // Only the owner and moderators review.
166 methods, err := tenant.Execute(ctx, owner, "supportedmethods", nil)
167 if err != nil {
168 t.Fatal(err)
169 }
170 for _, name := range []string{"listjoinrequests", "approvejoin", "denyjoin"} {
171 if !containsString(methods.([]string), name) {
172 t.Fatalf("supportedmethods lacks %s", name)
173 }
174 }
175 param := []json.RawMessage{json.RawMessage(strconv.Quote(asker))}
176 for _, actor := range []string{member, asker, strings.Repeat("e", 64)} {
177 for _, method := range []string{"listjoinrequests", "approvejoin", "denyjoin"} {
178 if _, err := tenant.Execute(ctx, actor, method, param); err == nil || !strings.HasPrefix(err.Error(), "restricted:") {
179 t.Fatalf("%s by non-admin: %v", method, err)
180 }
181 }
182 }
183 if rows := joinRequests(t, tenant, moderator); len(rows) != 2 {
184 t.Fatalf("moderator list %+v", rows)
185 }
186
187 // Approval makes a member and the signed records follow.
188 result, err := tenant.Execute(ctx, moderator, "approvejoin", param)
189 if err != nil {
190 t.Fatal(err)
191 }
192 if got := result.(map[string]any); got["status"] != "approved" || got["pubkey"] != asker {
193 t.Fatalf("approvejoin = %v", got)
194 }
195 if role, _ := tenant.community.Role(ctx, asker); role != "member" {
196 t.Fatalf("approved role = %q", role)
197 }
198 waitProjections(t, tenant)
199 if n := recordsNaming(t, tenant, event.KIND_ROSTER, asker); n == 0 {
200 t.Fatal("member list (kind 13534) does not name the approved key")
201 }
202 if n := recordsNaming(t, tenant, event.KIND_MEMBER_ADDED, asker); n == 0 {
203 t.Fatal("no add-user record (kind 8000) for the approved key")
204 }
205 rows = joinRequests(t, tenant, owner)
206 if len(rows) != 2 || rows[0].PubKey != second || rows[0].Status != "pending" || rows[1].PubKey != asker || rows[1].Status != "approved" || rows[1].DecidedBy != moderator || rows[1].DecidedAt == 0 {
207 t.Fatalf("after approval %+v", rows)
208 }
209 if _, err := tenant.Execute(ctx, owner, "denyjoin", []json.RawMessage{json.RawMessage(strconv.Quote(second))}); err != nil {
210 t.Fatal(err)
211 }
212 byKey := map[string]community.JoinRequest{}
213 for _, row := range joinRequests(t, tenant, owner) {
214 byKey[row.PubKey] = row
215 }
216 if len(byKey) != 2 || byKey[second].Status != "denied" || byKey[second].DecidedBy != owner || byKey[asker].Status != "approved" {
217 t.Fatalf("after denial %+v", byKey)
218 }
219 if role, _ := tenant.community.Role(ctx, second); role != "" {
220 t.Fatalf("denied role = %q", role)
221 }
222 audit, _ := tenant.Execute(ctx, owner, "listaudit", nil)
223 seen := map[string]bool{}
224 for _, row := range audit.([]community.AuditRow) {
225 seen[row.Action] = true
226 }
227 if !seen["approvejoin"] || !seen["denyjoin"] {
228 t.Fatalf("audit lacks the decisions: %v", seen)
229 }
230}
231
232func TestMCPJoinRequestTools(t *testing.T) {
233 app, tenant := testTenant(t)
234 ctx := context.Background()
235 asker, _ := event.PublicKey(testAskerSecret)
236 member, _ := event.PublicKey(testMemberSecret)
237 if _, err := tenant.Execute(ctx, tenant.Policy().Owner, "setmember", []json.RawMessage{json.RawMessage(strconv.Quote(member)), json.RawMessage(`{"role":"member"}`)}); err != nil {
238 t.Fatal(err)
239 }
240 if _, err := tenant.Publish(ctx, accessRequestEvent(t, testAskerSecret, time.Now().Unix(), "Let me in"), relay.Session{PubKeys: []string{asker}}); err != nil {
241 t.Fatal(err)
242 }
243 _, response := mcpCall{method: "tools/list", sign: true}.do(t, app, "/mcp")
244 names := map[string]bool{}
245 for _, tool := range response.Result.(map[string]any)["tools"].([]any) {
246 names[tool.(map[string]any)["name"].(string)] = true
247 }
248 for _, want := range []string{"list_join_requests", "approve_join", "deny_join"} {
249 if !names[want] {
250 t.Fatalf("missing tool %s", want)
251 }
252 }
253 call := func(name string, arguments map[string]any, secret string) (map[string]any, bool) {
254 t.Helper()
255 w, response := mcpCall{method: "tools/call", name: name, arguments: arguments, sign: true, secret: secret}.do(t, app, "/mcp")
256 if w.Code != http.StatusOK {
257 t.Fatalf("%s: %d %s", name, w.Code, w.Body.String())
258 }
259 return mcpToolResult(t, response)
260 }
261 structured := func(result map[string]any) map[string]any {
262 value, _ := result["structuredContent"].(map[string]any)
263 return value
264 }
265 result, isError := call("list_join_requests", map[string]any{}, "")
266 listed, _ := structured(result)["result"].([]any)
267 if isError || len(listed) != 1 || listed[0].(map[string]any)["pubkey"] != asker || listed[0].(map[string]any)["reason"] != "Let me in" || listed[0].(map[string]any)["status"] != "pending" {
268 t.Fatalf("list_join_requests: %v %v", isError, result)
269 }
270 if _, isError := call("list_join_requests", map[string]any{}, testMemberSecret); !isError {
271 t.Fatal("a member listed access requests")
272 }
273 if _, isError := call("approve_join", map[string]any{"pubkey": asker}, testMemberSecret); !isError {
274 t.Fatal("a member approved an access request")
275 }
276 result, isError = call("deny_join", map[string]any{"pubkey": asker}, "")
277 if isError || structured(result)["result"].(map[string]any)["status"] != "denied" {
278 t.Fatalf("deny_join: %v %v", isError, result)
279 }
280 result, isError = call("approve_join", map[string]any{"pubkey": asker}, "")
281 if isError || structured(result)["result"].(map[string]any)["status"] != "approved" {
282 t.Fatalf("approve_join: %v %v", isError, result)
283 }
284 if role, _ := tenant.community.Role(ctx, asker); role != "member" {
285 t.Fatalf("role after approve_join = %q", role)
286 }
287 llms := httptest.NewRecorder()
288 app.ServeHTTP(llms, httptest.NewRequest(http.MethodGet, "http://relay.test/llms.txt", nil))
289 if body := llms.Body.String(); !strings.Contains(body, "list_join_requests") || !strings.Contains(body, "approve_join, deny_join") {
290 t.Fatalf("llms.txt lacks the access request tools: %s", body)
291 }
292}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs