clone

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

feat/standalone-relay
tinyrelay / internal / community / requests_test.go
requests_test.go 10.4K
1package community23import (4 "context"5 "database/sql"6 "strings"7 "testing"89 "github.com/FelineStateMachine/tinyrelay/internal/event"10 "github.com/FelineStateMachine/tinyrelay/internal/policy"11 "github.com/FelineStateMachine/tinyrelay/internal/storage"12)1314func 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, owner30}3132func 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}3536func joinRequestRow(t *testing.T, st *storage.Store, pubkey string) (JoinRequest, int) {37 t.Helper()38 var r JoinRequest39 var count int40 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, count48}4950func 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 := false57 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 int85 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 }8889 // A repeat refreshes the reason and time in the same row and is90 // 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 }103104 // 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 }115116 // 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 }126127 // 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}136137func 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 int173 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 string184 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 int188 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 string206 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; a225 // 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] = true248 }249 if !seen["approvejoin:"+alice] || !seen["denyjoin:"+carol] || !seen["setmember:"+alice] {250 t.Fatalf("audit %v", seen)251 }252}253254func containsMethod(methods []string, wanted string) bool {255 for _, method := range methods {256 if method == wanted {257 return true258 }259 }260 return false261}