| 1 | package daemon |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "net/http/httptest" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | "time" |
| 10 | |
| 11 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 12 | "github.com/coder/websocket" |
| 13 | ) |
| 14 | |
| 15 | func TestNIP43InviteMountedRequestIsRelaySignedAndScoped(t *testing.T) { |
| 16 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) |
| 17 | defer cancel() |
| 18 | app, tenant := testTenant(t) |
| 19 | secret := strings.Repeat("0" , 63) + "1" |
| 20 | server := httptest.NewServer(app) |
| 21 | wsURL := "ws" + strings.TrimPrefix(server.URL, "http" ) + "/r/main" |
| 22 | conn, _, err := websocket.Dial(ctx, wsURL, nil) |
| 23 | if err != nil { |
| 24 | t.Fatal(err) |
| 25 | } |
| 26 | t.Cleanup(func() { |
| 27 | _ = conn.CloseNow() |
| 28 | server.Close() |
| 29 | }) |
| 30 | |
| 31 | _, challengeRaw, err := conn.Read(ctx) |
| 32 | if err != nil { |
| 33 | t.Fatal(err) |
| 34 | } |
| 35 | var challengeMessage []json.RawMessage |
| 36 | if err := json.Unmarshal(challengeRaw, &challengeMessage); err != nil || len(challengeMessage) < 2 { |
| 37 | t.Fatalf("AUTH challenge: %s" , challengeRaw) |
| 38 | } |
| 39 | var challenge string |
| 40 | if err := json.Unmarshal(challengeMessage[1], &challenge); err != nil { |
| 41 | t.Fatal(err) |
| 42 | } |
| 43 | auth := event.Event{Kind: event.KIND_AUTH, CreatedAt: time.Now().Unix(), Tags: [][]string{{"relay" , wsURL}, {"challenge" , challenge}}} |
| 44 | if err := event.Sign(&auth, secret); err != nil { |
| 45 | t.Fatal(err) |
| 46 | } |
| 47 | if err := writeRelayMessage(ctx, conn, []any{"AUTH" , auth}); err != nil { |
| 48 | t.Fatal(err) |
| 49 | } |
| 50 | if data := readRelayMessage(t, ctx, conn); !strings.Contains(string(data), `"OK","` +auth.ID+`",true` ) { |
| 51 | t.Fatalf("AUTH rejected: %s" , data) |
| 52 | } |
| 53 | |
| 54 | if err := writeRelayMessage(ctx, conn, []any{"REQ" , "zero" , map[string]any{"kinds" : []int{event.KIND_NIP43_INVITE}, "limit" : 0}}); err != nil { |
| 55 | t.Fatal(err) |
| 56 | } |
| 57 | if data := readRelayMessage(t, ctx, conn); !strings.Contains(string(data), `"EOSE","zero"` ) { |
| 58 | t.Fatalf("limit zero response: %s" , data) |
| 59 | } |
| 60 | var claims int |
| 61 | if err := tenant.store.DB().QueryRow(`SELECT count(*) FROM community_invites` ).Scan(&claims); err != nil { |
| 62 | t.Fatal(err) |
| 63 | } |
| 64 | if claims != 0 { |
| 65 | t.Fatalf("limit zero minted %d claims" , claims) |
| 66 | } |
| 67 | now := time.Now().Unix() |
| 68 | impossible := []struct { |
| 69 | name string |
| 70 | filter map[string]any |
| 71 | }{ |
| 72 | {name: "wrong id" , filter: map[string]any{"kinds" : []int{event.KIND_NIP43_INVITE}, "ids" : []string{strings.Repeat("f" , 64)}}}, |
| 73 | {name: "wrong author" , filter: map[string]any{"kinds" : []int{event.KIND_NIP43_INVITE}, "authors" : []string{strings.Repeat("e" , 64)}}}, |
| 74 | {name: "wrong tag" , filter: map[string]any{"kinds" : []int{event.KIND_NIP43_INVITE}, "#p" : []string{strings.Repeat("d" , 64)}}}, |
| 75 | {name: "expired" , filter: map[string]any{"kinds" : []int{event.KIND_NIP43_INVITE}, "until" : now - 60}}, |
| 76 | {name: "future" , filter: map[string]any{"kinds" : []int{event.KIND_NIP43_INVITE}, "since" : now + 60}}, |
| 77 | } |
| 78 | for _, tc := range impossible { |
| 79 | if err := writeRelayMessage(ctx, conn, []any{"REQ" , tc.name, tc.filter}); err != nil { |
| 80 | t.Fatal(err) |
| 81 | } |
| 82 | if data := readRelayMessage(t, ctx, conn); !strings.Contains(string(data), `"EOSE","` +tc.name+`"` ) { |
| 83 | t.Fatalf("%s response: %s" , tc.name, data) |
| 84 | } |
| 85 | } |
| 86 | if err := tenant.store.DB().QueryRow(`SELECT count(*) FROM community_invites` ).Scan(&claims); err != nil { |
| 87 | t.Fatal(err) |
| 88 | } |
| 89 | if claims != 0 { |
| 90 | t.Fatalf("impossible filters minted %d claims" , claims) |
| 91 | } |
| 92 | |
| 93 | if err := writeRelayMessage(ctx, conn, []any{"REQ" , "mixed" , map[string]any{"kinds" : []int{event.KIND_NIP43_INVITE, 1}, "limit" : 1}}); err != nil { |
| 94 | t.Fatal(err) |
| 95 | } |
| 96 | data := readRelayMessage(t, ctx, conn) |
| 97 | var message []json.RawMessage |
| 98 | if err := json.Unmarshal(data, &message); err != nil || len(message) < 3 { |
| 99 | t.Fatalf("invite response: %s" , data) |
| 100 | } |
| 101 | var responseKind string |
| 102 | if err := json.Unmarshal(message[0], &responseKind); err != nil || responseKind != "EVENT" { |
| 103 | t.Fatalf("expected EVENT, got %s" , data) |
| 104 | } |
| 105 | var invite event.Event |
| 106 | if err := json.Unmarshal(message[2], &invite); err != nil { |
| 107 | t.Fatal(err) |
| 108 | } |
| 109 | if invite.Kind != event.KIND_NIP43_INVITE || invite.PubKey != tenant.records.PublicKey() { |
| 110 | t.Fatalf("unexpected invite identity: %+v" , invite) |
| 111 | } |
| 112 | if err := event.Validate(invite); err != nil { |
| 113 | t.Fatalf("relay signature invalid: %v" , err) |
| 114 | } |
| 115 | if event.Tag(invite, "claim" ) == "" { |
| 116 | t.Fatal("invite omitted claim" ) |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | func writeRelayMessage(ctx context.Context, conn *websocket.Conn, value any) error { |
| 121 | data, err := json.Marshal(value) |
| 122 | if err != nil { |
| 123 | return err |
| 124 | } |
| 125 | return conn.Write(ctx, websocket.MessageText, data) |
| 126 | } |
| 127 | |
| 128 | func readRelayMessage(t *testing.T, ctx context.Context, conn *websocket.Conn) []byte { |
| 129 | t.Helper() |
| 130 | _, data, err := conn.Read(ctx) |
| 131 | if err != nil { |
| 132 | t.Fatal(err) |
| 133 | } |
| 134 | return data |
| 135 | } |