clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/standalone-relay
tinyrelay / internal / daemon / join_requests_test.go
join_requests_test.go 12.2K
1package daemon23import (4 "context"5 "encoding/json"6 "net/http"7 "net/http/httptest"8 "strconv"9 "strings"10 "testing"11 "time"1213 "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)1819const (20 testAskerSecret = "0000000000000000000000000000000000000000000000000000000000000021"21 testSecondSecret = "0000000000000000000000000000000000000000000000000000000000000022"22)2324// accessRequestEvent is what Flotilla publishes: a kind 28934 join with no25// 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}3031func 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}3940// waitProjections lets the tenant worker finish the membership projections41// 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 int47 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 return52 }53 if time.Now().After(deadline) {54 t.Fatalf("%d projections still queued", pending)55 }56 time.Sleep(50 * time.Millisecond)57 }58}5960func 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 := 067 for _, e := range result.Events {68 if e.PubKey != tenant.records.PublicKey() {69 continue70 }71 for _, tag := range e.Tags {72 if len(tag) > 1 && tag[1] == pubkey {73 count++74 break75 }76 }77 }78 return count79}8081func TestAccessRequestIsHeldReviewedAndWakesReviewersOnce(t *testing.T) {82 app, tenant := testTenant(t)83 ctx := context.Background()84 owner := tenant.Policy().Owner85 asker, _ := event.PublicKey(testAskerSecret)86 second, _ := event.PublicKey(testSecondSecret)87 member, _ := event.PublicKey(testMemberSecret)88 moderator, _ := event.PublicKey(testModSecret)89 now := time.Now().Unix()9091 // 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 }103104 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 int117 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 a156 // 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 }164165 // 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 }186187 // 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] = row215 }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] = true226 }227 if !seen["approvejoin"] || !seen["denyjoin"] {228 t.Fatalf("audit lacks the decisions: %v", seen)229 }230}231232func 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)] = true247 }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 value264 }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}