1package client23import (4 "context"5 "encoding/json"6 "io"7 "net/http"8 "net/http/httptest"9 "net/url"10 "strings"11 "testing"1213 "github.com/FelineStateMachine/tinyrelay/protocol/nostr"14 "github.com/coder/websocket"15)1617func TestRequestURLRejectsExternalAndEscapedPaths(t *testing.T) {18 c := Client{BaseURL: "https://relay.example"}19 for _, path := range []string{"https://evil.example/x", "//evil.example/x", "/../secret", "/rooms/%2e%2e/secret", "rooms/x"} {20 if _, err := c.requestURL(path); err == nil {21 t.Errorf("requestURL(%q) accepted unsafe path", path)22 }23 }24 got, err := c.requestURL("/healthz?ok=1")25 if err != nil || got.String() != "https://relay.example/healthz?ok=1" {26 t.Fatalf("safe path: %v %v", got, err)27 }28}2930func TestWebsocketURL(t *testing.T) {31 for raw, want := range map[string]string{"https://relay.example/x": "wss://relay.example/x", "http://relay.example": "ws://relay.example"} {32 u, err := websocketURL(raw)33 if err != nil || u != want {34 t.Errorf("websocketURL(%q) = %q, %v; want %q", raw, u, err, want)35 }36 }37 if _, err := websocketURL("file:///tmp/x"); err == nil {38 t.Error("accepted invalid websocket URL")39 }40 _ = url.URL{}41}4243func TestPublishChecksAcceptedReceiptAndUsesFreshNIP98Nonce(t *testing.T) {44 secret, err := nostr.GenerateKey()45 if err != nil {46 t.Fatal(err)47 }48 c, err := New("http://relay.example/t/lab", secret)49 if err != nil {50 t.Fatal(err)51 }52 var proofs []string53 c.HTTP = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {54 proofs = append(proofs, req.Header.Get("Authorization"))55 if req.URL.Path != "/t/lab/events" {56 t.Fatalf("path = %s", req.URL.Path)57 }58 return response(`{"accepted":false,"message":"blocked: test"}`), nil59 })}60 e := nostr.Event{Kind: 9, Content: "test"}61 if _, err := c.Publish(context.Background(), e); err == nil || !strings.Contains(err.Error(), "blocked: test") {62 t.Fatalf("publish error = %v", err)63 }64 if len(proofs) != 1 || !strings.Contains(proofs[0], "Nostr ") {65 t.Fatalf("proof = %#v", proofs)66 }67 first, err := c.proof(http.MethodGet, "http://relay.example/t/lab/query", nil)68 if err != nil {69 t.Fatal(err)70 }71 second, err := c.proof(http.MethodGet, "http://relay.example/t/lab/query", nil)72 if err != nil {73 t.Fatal(err)74 }75 if first == second {76 t.Fatal("identical NIP98 requests reused a proof")77 }78}7980func TestPublishRejectsMalformedReceipt(t *testing.T) {81 secret, err := nostr.GenerateKey()82 if err != nil {83 t.Fatal(err)84 }85 c, err := New("http://relay.example", secret)86 if err != nil {87 t.Fatal(err)88 }89 c.HTTP = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {90 return response("not-json"), nil91 })}92 if _, err := c.Publish(context.Background(), nostr.Event{Kind: 9}); err == nil || !strings.Contains(err.Error(), "invalid publish receipt") {93 t.Fatalf("publish error = %v", err)94 }95}9697type roundTripFunc func(*http.Request) (*http.Response, error)9899func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }100101func response(body string) *http.Response {102 return &http.Response{StatusCode: http.StatusOK, Status: "200 OK", Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}103}104105func TestQueryAuthenticatesAndFiltersSocketFrames(t *testing.T) {106 clientSecret, err := nostr.GenerateKey()107 if err != nil {108 t.Fatal(err)109 }110 eventSecret, err := nostr.GenerateKey()111 if err != nil {112 t.Fatal(err)113 }114 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {115 conn, err := websocket.Accept(w, r, nil)116 if err != nil {117 return118 }119 defer conn.CloseNow()120 ctx := r.Context()121 _, raw, err := conn.Read(ctx)122 if err != nil {123 return124 }125 var req []json.RawMessage126 if json.Unmarshal(raw, &req) != nil {127 return128 }129 _ = writeFrame(ctx, conn, []any{"AUTH", "challenge"})130 _, raw, err = conn.Read(ctx)131 if err != nil {132 return133 }134 var auth []json.RawMessage135 if json.Unmarshal(raw, &auth) != nil || len(auth) < 2 {136 return137 }138 var proof nostr.Event139 if json.Unmarshal(auth[1], &proof) != nil {140 return141 }142 _ = writeFrame(ctx, conn, []any{"OK", proof.ID, true, ""})143 _, _, err = conn.Read(ctx)144 if err != nil {145 return146 }147 matching := nostr.Event{Kind: 9, CreatedAt: 2, Tags: [][]string{{"h", "room"}}, Content: "match"}148 if err := nostr.Sign(&matching, eventSecret); err != nil {149 return150 }151 wrongRoom := matching152 wrongRoom.Tags = [][]string{{"h", "other"}}153 if err := nostr.Sign(&wrongRoom, eventSecret); err != nil {154 return155 }156 invalid := matching157 invalid.Content = "tampered"158 _ = writeFrame(ctx, conn, []any{"EVENT", "wrong-sub", matching})159 _ = writeFrame(ctx, conn, []any{"EVENT", "tinyagent-query", invalid})160 _ = writeFrame(ctx, conn, []any{"EVENT", "tinyagent-query", wrongRoom})161 _ = writeFrame(ctx, conn, []any{"EVENT", "tinyagent-query", matching})162 _ = writeFrame(ctx, conn, []any{"EOSE", "tinyagent-query"})163 }))164 defer server.Close()165 c, err := New(server.URL, clientSecret)166 if err != nil {167 t.Fatal(err)168 }169 got, err := c.Query(context.Background(), []Filter{{"#h": []any{"room"}}})170 if err != nil {171 t.Fatal(err)172 }173 if len(got) != 1 || got[0].Content != "match" {174 t.Fatalf("query returned %#v", got)175 }176}