package relay import ( "bytes" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "io" "math/rand" "net/http" "os" "path/filepath" "strings" "sync" "testing" "time" "git.smesh.lol/torly/pkg/blossom" "git.smesh.lol/torly/pkg/config" "git.smesh.lol/torly/pkg/nostr" ) type testRelay struct { addr string cfg config.Config tree *Tree } func startTestRelay(t *testing.T, authRequired bool) *testRelay { t.Helper() return startTestRelayWith(t, authRequired, nil) } func startTestRelayWith(t *testing.T, authRequired bool, mod func(*config.Config)) *testRelay { t.Helper() dir := t.TempDir() cfg := config.Config{ AppName: "torly-test", Description: "test relay", ListenHost: "127.0.0.1", DataDir: dir, AuthRequired: authRequired, BlossomEnabled: true, BlossomMaxBlob: 4 << 20, MaxEventBytes: 128 << 10, MaxSubsPerConn: 16, MaxFilters: 8, MaxLimit: 1000, QueryLimit: 1000, MaxFutureSecs: 900, ConnWorkers: 8, VerifyWorkers: 2, ExpiryInterval: 60, } if mod != nil { mod(&cfg) } var tree *Tree var err error for attempt := 0; attempt < 40; attempt++ { cfg.Port = 23000 + rand.Intn(7000) cfg.RelayURL = fmt.Sprintf("ws://127.0.0.1:%d/", cfg.Port) tree, err = Start(cfg) if err == nil { break } } if tree == nil { t.Fatalf("could not start relay: %v", err) } t.Cleanup(tree.Close) return &testRelay{addr: tree.Addr(), cfg: cfg, tree: tree} } func countFilter(t *testing.T, addr, filter string) (int, bool) { t.Helper() c := dialWS(t, addr) defer c.close() c.expect(`["AUTH"`) c.writeText([]byte(`["COUNT","c",` + filter + `]`)) msg := c.expect(`["COUNT","c"`) var parts []json.RawMessage if err := json.Unmarshal([]byte(msg), &parts); err != nil { t.Fatal(err) } var payload struct { Count int `json:"count"` Approximate bool `json:"approximate"` } if err := json.Unmarshal(parts[2], &payload); err != nil { t.Fatal(err) } return payload.Count, payload.Approximate } func testEvent(t *testing.T, seed byte, kind int, tags nostr.Tags, content string) *nostr.Event { t.Helper() var sk [32]byte sk[31] = seed ev := &nostr.Event{Kind: kind, CreatedAt: time.Now().Unix(), Tags: tags, Content: content} if err := ev.Sign(sk); err != nil { t.Fatal(err) } return ev } func TestNIP11AndHealth(t *testing.T) { tr := startTestRelay(t, false) req, _ := http.NewRequest("GET", "http://"+tr.addr+"/", nil) req.Header.Set("Accept", "application/nostr+json") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != 200 { t.Fatalf("nip11 status: %d", resp.StatusCode) } var doc config.NIP11 if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { t.Fatal(err) } if doc.Name != "torly-test" || len(doc.SupportedNIPs) == 0 || doc.Software == "" || doc.PubKey == "" { t.Fatalf("unexpected NIP-11: %+v", doc) } resp2, err := http.Get("http://" + tr.addr + "/health") if err != nil { t.Fatal(err) } defer resp2.Body.Close() body, _ := io.ReadAll(resp2.Body) if !strings.Contains(string(body), `"status":"ok"`) { t.Fatalf("health: %s", body) } } func TestPublishQueryCount(t *testing.T) { tr := startTestRelay(t, false) c := dialWS(t, tr.addr) defer c.close() c.expect(`["AUTH"`) ev := testEvent(t, 1, 1, nostr.Tags{{"t", "integration"}}, "hello integration world") raw := ev.AppendJSON(nil) msg := append([]byte(`["EVENT",`), raw...) msg = append(msg, ']') if err := c.writeText(msg); err != nil { t.Fatal(err) } ok := c.expect(`["OK","` + nostr.Hex(ev.ID[:]) + `"`) if !strings.Contains(ok, "true") { t.Fatalf("publish rejected: %s", ok) } if err := c.writeText([]byte(`["REQ","sub1",{"kinds":[1],"#t":["integration"]}]`)); err != nil { t.Fatal(err) } got := c.expect(`["EVENT","sub1"`) if !strings.Contains(got, nostr.Hex(ev.ID[:])) { t.Fatalf("wrong event: %s", got) } c.expect(`["EOSE","sub1"`) if err := c.writeText([]byte(`["COUNT","c1",{"kinds":[1],"#t":["integration"]}]`)); err != nil { t.Fatal(err) } count := c.expect(`["COUNT","c1"`) if !strings.Contains(count, `"count":1`) { t.Fatalf("count: %s", count) } // A tampered signature must be rejected. bad := testEvent(t, 1, 1, nil, "bad signature") badSig := bad.Sig badSig[0] ^= 1 bad.Sig = badSig raw = bad.AppendJSON(nil) msg = append([]byte(`["EVENT",`), raw...) msg = append(msg, ']') c.writeText(msg) rejected := c.expect(`["OK","` + nostr.Hex(bad.ID[:]) + `"`) if !strings.Contains(rejected, "false") || !strings.Contains(rejected, "invalid") { t.Fatalf("bad signature accepted: %s", rejected) } } func TestGraphQuery(t *testing.T) { tr := startTestRelay(t, false) c := dialWS(t, tr.addr) defer c.close() c.expect(`["AUTH"`) pubOf := func(seed byte) string { var sk [32]byte sk[31] = seed pub, err := nostr.PubKeyHex(sk) if err != nil { t.Fatal(err) } return pub } alice, bob, carol := pubOf(11), pubOf(12), pubOf(13) contacts := testEvent(t, 11, 3, nostr.Tags{{"p", bob}, {"p", carol}}, "") msg := append([]byte(`["EVENT",`), contacts.AppendJSON(nil)...) msg = append(msg, ']') c.writeText(msg) if ok := c.expect(`["OK","` + nostr.Hex(contacts.ID[:]) + `"`); !strings.Contains(ok, "true") { t.Fatalf("publish: %s", ok) } c.writeText([]byte(`["REQ","g",{"_graph":["` + alice + `",1,"PP","OUT"]}]`)) raw := c.expect(`["EVENT","g"`) parts, err := nostr.SplitArray([]byte(raw)) if err != nil { t.Fatal(err) } result, err := nostr.ParseEvent(parts[2]) if err != nil { t.Fatal(err) } if err := result.Verify(); err != nil { t.Fatalf("graph result signature: %v", err) } if result.Kind != nostr.KindGraphResult { t.Fatalf("kind = %d", result.Kind) } if edge, _ := result.Tags.First("edge"); edge != "pp" { t.Fatalf("edge tag = %q", edge) } if seed, _ := result.Tags.First("seed"); seed != alice { t.Fatalf("seed tag = %q", seed) } var content struct { PubkeysByDepth [][]string `json:"pubkeys_by_depth"` TotalPubkeys int `json:"total_pubkeys"` } if err := json.Unmarshal([]byte(result.Content), &content); err != nil { t.Fatalf("content %q: %v", result.Content, err) } if content.TotalPubkeys != 2 || len(content.PubkeysByDepth) != 1 { t.Fatalf("content: %+v", content) } got := map[string]bool{content.PubkeysByDepth[0][0]: true, content.PubkeysByDepth[0][1]: true} if !got[bob] || !got[carol] { t.Fatalf("follows = %v", content.PubkeysByDepth) } c.expect(`["EOSE","g"`) c.writeText([]byte(`["REQ","bad",{"_graph":["short",1,"pp","out"]}]`)) if closed := c.expect(`["CLOSED","bad"`); !strings.Contains(closed, "invalid") { t.Fatalf("invalid graph query: %s", closed) } c.writeText([]byte(`["COUNT","cg",{"_graph":["` + alice + `",1,"pp","out"]}]`)) if closed := c.expect(`["CLOSED","cg"`); !strings.Contains(closed, "_graph") { t.Fatalf("graph count: %s", closed) } } func TestLiveDelivery(t *testing.T) { tr := startTestRelay(t, false) sub := dialWS(t, tr.addr) defer sub.close() sub.expect(`["AUTH"`) sub.writeText([]byte(`["REQ","live",{"kinds":[1],"#t":["live"]}]`)) sub.expect(`["EOSE","live"`) pub := dialWS(t, tr.addr) defer pub.close() pub.expect(`["AUTH"`) ev := testEvent(t, 2, 1, nostr.Tags{{"t", "live"}}, "broadcast me") msg := append([]byte(`["EVENT",`), ev.AppendJSON(nil)...) msg = append(msg, ']') pub.writeText(msg) pub.expect(`["OK","` + nostr.Hex(ev.ID[:]) + `"`) got := sub.expect(`["EVENT","live"`) if !strings.Contains(got, nostr.Hex(ev.ID[:])) { t.Fatalf("live event missing: %s", got) } } func TestAuthRequired(t *testing.T) { tr := startTestRelay(t, true) c := dialWS(t, tr.addr) defer c.close() challengeMsg := c.expect(`["AUTH"`) var parts []json.RawMessage if err := json.Unmarshal([]byte(challengeMsg), &parts); err != nil { t.Fatal(err) } var challenge string json.Unmarshal(parts[1], &challenge) ev := testEvent(t, 3, 1, nil, "needs auth") msg := append([]byte(`["EVENT",`), ev.AppendJSON(nil)...) msg = append(msg, ']') c.writeText(msg) rejected := c.expect(`["OK","` + nostr.Hex(ev.ID[:]) + `"`) if !strings.Contains(rejected, "auth-required") { t.Fatalf("expected auth-required, got %s", rejected) } auth := testEvent(t, 3, 22242, nostr.Tags{ {"relay", tr.cfg.RelayURL}, {"challenge", challenge}, }, "") authMsg := append([]byte(`["AUTH",`), auth.AppendJSON(nil)...) authMsg = append(authMsg, ']') c.writeText(authMsg) if ok := c.expect(`["OK","` + nostr.Hex(auth.ID[:]) + `"`); !strings.Contains(ok, "true") { t.Fatalf("auth rejected: %s", ok) } c.writeText(msg) if ok := c.expect(`["OK","` + nostr.Hex(ev.ID[:]) + `"`); !strings.Contains(ok, "true") { t.Fatalf("authenticated publish rejected: %s", ok) } } func TestBlossom(t *testing.T) { tr := startTestRelay(t, false) body := []byte("blossom integration payload") sum := sha256.Sum256(body) hash := hex.EncodeToString(sum[:]) var sk [32]byte sk[31] = 9 pub, _ := nostr.PubKeyHex(sk) authEvent := func(action string) string { ev := &nostr.Event{ Kind: 24242, CreatedAt: time.Now().Unix(), Tags: nostr.Tags{ {"t", action}, {"x", hash}, {"expiration", fmt.Sprint(time.Now().Unix() + 300)}, }, } if err := ev.Sign(sk); err != nil { t.Fatal(err) } return "Nostr " + base64.StdEncoding.EncodeToString(ev.AppendJSON(nil)) } req, _ := http.NewRequest("PUT", "http://"+tr.addr+"/upload", bytes.NewReader(body)) req.Header.Set("Authorization", authEvent("upload")) req.Header.Set("Content-Type", "text/plain") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } raw, _ := io.ReadAll(resp.Body) resp.Body.Close() if resp.StatusCode != 200 { t.Fatalf("upload status %d: %s", resp.StatusCode, raw) } var desc blossom.Descriptor if err := json.Unmarshal(raw, &desc); err != nil { t.Fatal(err) } if desc.SHA256 != hash || desc.Size != int64(len(body)) { t.Fatalf("descriptor: %+v", desc) } resp, err = http.Get("http://" + tr.addr + "/" + hash) if err != nil { t.Fatal(err) } downloaded, _ := io.ReadAll(resp.Body) resp.Body.Close() if resp.StatusCode != 200 || !bytes.Equal(downloaded, body) { t.Fatalf("download: status %d body %q", resp.StatusCode, downloaded) } resp, err = http.Get("http://" + tr.addr + "/list/" + pub) if err != nil { t.Fatal(err) } listBody, _ := io.ReadAll(resp.Body) resp.Body.Close() if !strings.Contains(string(listBody), hash) { t.Fatalf("list: %s", listBody) } del, _ := http.NewRequest("DELETE", "http://"+tr.addr+"/"+hash, nil) del.Header.Set("Authorization", authEvent("delete")) resp, err = http.DefaultClient.Do(del) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != 204 { t.Fatalf("delete status: %d", resp.StatusCode) } resp, err = http.Get("http://" + tr.addr + "/" + hash) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != 404 { t.Fatalf("blob survived delete: %d", resp.StatusCode) } } func TestUnauthenticatedBlossomUploadRejected(t *testing.T) { tr := startTestRelay(t, false) req, _ := http.NewRequest("PUT", "http://"+tr.addr+"/upload", strings.NewReader("nope")) req.Header.Set("Content-Type", "text/plain") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != 401 { t.Fatalf("upload without auth: status %d", resp.StatusCode) } } func TestConcurrentPublish(t *testing.T) { tr := startTestRelay(t, false) const conns = 6 const perConn = 4 clients := make([]*testWS, conns) for i := range clients { clients[i] = dialWS(t, tr.addr) defer clients[i].close() clients[i].expect(`["AUTH"`) } var wg sync.WaitGroup errs := make(chan error, conns) for g := 0; g < conns; g++ { wg.Add(1) go func(g int) { defer wg.Done() c := clients[g] for i := 0; i < perConn; i++ { ev := testEvent(t, byte(g*perConn+i+1), 1, nostr.Tags{{"t", "concurrent"}}, "parallel publish") msg := append([]byte(`["EVENT",`), ev.AppendJSON(nil)...) msg = append(msg, ']') if err := c.writeText(msg); err != nil { errs <- err return } c.expect(`["OK","` + nostr.Hex(ev.ID[:]) + `"`) } }(g) } wg.Wait() close(errs) for err := range errs { t.Fatalf("publish: %v", err) } n, approx := countFilter(t, tr.addr, `{"#t":["concurrent"]}`) if n != conns*perConn || approx { t.Fatalf("count = %d (approximate=%v), want %d", n, approx, conns*perConn) } } func TestDuplicatePublish(t *testing.T) { tr := startTestRelay(t, false) c := dialWS(t, tr.addr) defer c.close() c.expect(`["AUTH"`) ev := testEvent(t, 4, 1, nil, "duplicate") msg := append([]byte(`["EVENT",`), ev.AppendJSON(nil)...) msg = append(msg, ']') c.writeText(msg) if ok := c.expect(`["OK","` + nostr.Hex(ev.ID[:]) + `"`); !strings.Contains(ok, "true") { t.Fatalf("first publish: %s", ok) } c.writeText(msg) dup := c.expect(`["OK","` + nostr.Hex(ev.ID[:]) + `"`) if !strings.Contains(dup, "true") || !strings.Contains(dup, "duplicate") { t.Fatalf("duplicate response: %s", dup) } } func TestEphemeralNotStored(t *testing.T) { tr := startTestRelay(t, false) sub := dialWS(t, tr.addr) defer sub.close() sub.expect(`["AUTH"`) sub.writeText([]byte(`["REQ","eph",{"kinds":[20001]}]`)) sub.expect(`["EOSE","eph"`) pub := dialWS(t, tr.addr) defer pub.close() pub.expect(`["AUTH"`) ev := testEvent(t, 5, 20001, nostr.Tags{{"t", "ephemeral"}}, "do not store") msg := append([]byte(`["EVENT",`), ev.AppendJSON(nil)...) msg = append(msg, ']') pub.writeText(msg) if ok := pub.expect(`["OK","` + nostr.Hex(ev.ID[:]) + `"`); !strings.Contains(ok, "true") { t.Fatalf("ephemeral rejected: %s", ok) } sub.expect(`["EVENT","eph"`) if n, _ := countFilter(t, tr.addr, `{"kinds":[20001]}`); n != 0 { t.Fatalf("ephemeral stored: n=%d", n) } } func TestProtectedEventRequiresAuth(t *testing.T) { tr := startTestRelay(t, false) c := dialWS(t, tr.addr) defer c.close() challenge := challengeFrom(t, c.expect(`["AUTH"`)) ev := testEvent(t, 6, 1, nostr.Tags{{"-"}}, "protected") msg := append([]byte(`["EVENT",`), ev.AppendJSON(nil)...) msg = append(msg, ']') c.writeText(msg) if ok := c.expect(`["OK","` + nostr.Hex(ev.ID[:]) + `"`); !strings.Contains(ok, "auth-required") { t.Fatalf("protected event accepted without auth: %s", ok) } auth := testEvent(t, 6, 22242, nostr.Tags{{"relay", tr.cfg.RelayURL}, {"challenge", challenge}}, "") authMsg := append([]byte(`["AUTH",`), auth.AppendJSON(nil)...) authMsg = append(authMsg, ']') c.writeText(authMsg) c.expect(`["OK","` + nostr.Hex(auth.ID[:]) + `"`) c.writeText(msg) if ok := c.expect(`["OK","` + nostr.Hex(ev.ID[:]) + `"`); !strings.Contains(ok, "true") { t.Fatalf("protected event rejected for its author: %s", ok) } } func TestAuthWrongChallenge(t *testing.T) { tr := startTestRelay(t, true) c := dialWS(t, tr.addr) defer c.close() c.expect(`["AUTH"`) auth := testEvent(t, 7, 22242, nostr.Tags{{"relay", tr.cfg.RelayURL}, {"challenge", "wrong"}}, "") msg := append([]byte(`["AUTH",`), auth.AppendJSON(nil)...) msg = append(msg, ']') c.writeText(msg) got := c.expect(`["OK","` + nostr.Hex(auth.ID[:]) + `"`) if !strings.Contains(got, "false") || !strings.Contains(got, "auth-required") { t.Fatalf("wrong challenge accepted: %s", got) } } func challengeFrom(t *testing.T, msg string) string { t.Helper() var parts []json.RawMessage if err := json.Unmarshal([]byte(msg), &parts); err != nil { t.Fatal(err) } var challenge string if err := json.Unmarshal(parts[1], &challenge); err != nil { t.Fatal(err) } return challenge } func TestMalformedMessages(t *testing.T) { tr := startTestRelay(t, false) c := dialWS(t, tr.addr) defer c.close() c.expect(`["AUTH"`) c.writeText([]byte(`this is not json`)) c.expect(`["NOTICE"`) c.writeText([]byte(`["NOPE"]`)) notice := c.expect(`["NOTICE"`) if !strings.Contains(notice, "unknown command") { t.Fatalf("unknown command notice: %s", notice) } c.writeText([]byte(`["REQ","onlyid"]`)) c.expect(`["NOTICE"`) c.writeText([]byte(`["REQ","badsub",{"kinds":"nope"}]`)) closed := c.expect(`["CLOSED","badsub"`) if !strings.Contains(closed, "invalid") { t.Fatalf("bad filter closed: %s", closed) } c.writeText([]byte(`["REQ",""]`)) c.expect(`["NOTICE"`) longID := strings.Repeat("x", 65) c.writeText([]byte(`["REQ","` + longID + `",{}]`)) c.expect(`["NOTICE"`) } func TestFilterAndSubscriptionLimits(t *testing.T) { tr := startTestRelayWith(t, false, func(c *config.Config) { c.MaxFilters = 2 c.MaxSubsPerConn = 2 }) c := dialWS(t, tr.addr) defer c.close() c.expect(`["AUTH"`) c.writeText([]byte(`["REQ","many",{},{},{}]`)) closed := c.expect(`["CLOSED","many"`) if !strings.Contains(closed, "too many filters") { t.Fatalf("filter limit: %s", closed) } c.writeText([]byte(`["REQ","one",{}]`)) c.expect(`["EOSE","one"`) c.writeText([]byte(`["REQ","two",{}]`)) c.expect(`["EOSE","two"`) c.writeText([]byte(`["REQ","three",{}]`)) closed = c.expect(`["CLOSED","three"`) if !strings.Contains(closed, "too many subscriptions") { t.Fatalf("subscription limit: %s", closed) } } func TestCountMultipleFiltersIsApproximate(t *testing.T) { tr := startTestRelay(t, false) c := dialWS(t, tr.addr) defer c.close() c.expect(`["AUTH"`) ev := testEvent(t, 8, 1, nostr.Tags{{"t", "countmulti"}}, "counted twice") msg := append([]byte(`["EVENT",`), ev.AppendJSON(nil)...) msg = append(msg, ']') c.writeText(msg) c.expect(`["OK","` + nostr.Hex(ev.ID[:]) + `"`) c.writeText([]byte(`["COUNT","cm",{"kinds":[1]},{"kinds":[1]}]`)) got := c.expect(`["COUNT","cm"`) if !strings.Contains(got, `"count":2`) || !strings.Contains(got, `"approximate":true`) { t.Fatalf("multi-filter count: %s", got) } } func TestStaticFilesAndNotFound(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("static"), 0o644); err != nil { t.Fatal(err) } tr := startTestRelayWith(t, false, func(c *config.Config) { c.StaticDir = dir }) resp, err := http.Get("http://" + tr.addr + "/") if err != nil { t.Fatal(err) } body, _ := io.ReadAll(resp.Body) resp.Body.Close() if resp.StatusCode != 200 || !strings.Contains(string(body), "static") { t.Fatalf("static index: %d %q", resp.StatusCode, body) } resp, err = http.Get("http://" + tr.addr + "/missing.txt") if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != 404 { t.Fatalf("missing static: %d", resp.StatusCode) } resp, err = http.Get("http://" + tr.addr + "/../server_test.go") if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != 404 { t.Fatalf("traversal: %d", resp.StatusCode) } } func TestNIP11Head(t *testing.T) { tr := startTestRelay(t, false) req, _ := http.NewRequest("HEAD", "http://"+tr.addr+"/", nil) req.Header.Set("Accept", "application/nostr+json") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != 200 || resp.Header.Get("Content-Length") == "" { t.Fatalf("NIP-11 HEAD: %d %v", resp.StatusCode, resp.Header) } body, _ := io.ReadAll(resp.Body) if len(body) != 0 { t.Fatalf("HEAD returned a body: %q", body) } }