Compare commits

...

2 Commits

Author SHA1 Message Date
0ba555c6a8 Update version to v0.21.0 and enhance relay client functionality
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Bumped version from v0.20.6 to v0.21.0.
- Added a `complete` map in the Client struct to track subscription completion status.
- Improved event handling in the read loop to manage EOSE messages and subscription closures.
- Introduced new tests for filtering, event ordering, and subscription behaviors, enhancing test coverage and reliability.
2025-10-30 19:26:42 +00:00
54f65d8740 Enhance relay testing and event handling
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Updated TestRelay to include a wait mechanism for relay readiness, improving test reliability.
- Refactored startTestRelay to return the assigned port, allowing dynamic port assignment.
- Added timestamp validation in HandleEvent to reject events with timestamps more than one hour in the future.
- Introduced channels for handling OK and COUNT messages in the Client struct, improving message processing.
- Updated tests to reflect changes in event timestamp handling and increased wait times for event processing.
- Bumped version to v0.20.6 to reflect these enhancements.
2025-10-30 19:12:11 +00:00
7 changed files with 1671 additions and 98 deletions

View File

@@ -176,6 +176,18 @@ func (l *Listener) HandleEvent(msg []byte) (err error) {
}
return
}
// validate timestamp - reject events too far in the future (more than 1 hour)
now := time.Now().Unix()
if env.E.CreatedAt > now+3600 {
if err = Ok.Invalid(
l, env,
"timestamp too far in the future",
); chk.E(err) {
return
}
return
}
// verify the signature
var ok bool
if ok, err = env.Verify(); chk.T(err) {

View File

@@ -1 +1 @@
v0.20.4
v0.21.0

View File

@@ -14,12 +14,15 @@ import (
// Client wraps a WebSocket connection to a relay for testing.
type Client struct {
conn *websocket.Conn
url string
mu sync.Mutex
subs map[string]chan []byte
ctx context.Context
cancel context.CancelFunc
conn *websocket.Conn
url string
mu sync.Mutex
subs map[string]chan []byte
complete map[string]bool // Track if subscription is complete (e.g., by ID)
okCh chan []byte // Channel for OK messages
countCh chan []byte // Channel for COUNT messages
ctx context.Context
cancel context.CancelFunc
}
// NewClient creates a new test client connected to the relay.
@@ -34,11 +37,14 @@ func NewClient(url string) (c *Client, err error) {
return
}
c = &Client{
conn: conn,
url: url,
subs: make(map[string]chan []byte),
ctx: ctx,
cancel: cancel,
conn: conn,
url: url,
subs: make(map[string]chan []byte),
complete: make(map[string]bool),
okCh: make(chan []byte, 100),
countCh: make(chan []byte, 100),
ctx: ctx,
cancel: cancel,
}
go c.readLoop()
return
@@ -105,12 +111,32 @@ func (c *Client) readLoop() {
if len(raw) >= 2 {
if subID, ok := raw[1].(string); ok {
if ch, exists := c.subs[subID]; exists {
close(ch)
// Send EOSE message to channel
select {
case ch <- msg:
default:
}
// For complete subscriptions (by ID), close the channel after EOSE
if c.complete[subID] {
close(ch)
delete(c.subs, subID)
delete(c.complete, subID)
}
}
}
}
case "OK":
// OK messages are handled by WaitForOK
// Route OK messages to okCh for WaitForOK
select {
case c.okCh <- msg:
default:
}
case "COUNT":
// Route COUNT messages to countCh for Count
select {
case c.countCh <- msg:
default:
}
case "NOTICE":
// Notice messages are logged
case "CLOSED":
@@ -132,6 +158,19 @@ func (c *Client) Subscribe(subID string, filters []interface{}) (ch chan []byte,
c.mu.Lock()
ch = make(chan []byte, 100)
c.subs[subID] = ch
// Check if subscription is complete (has 'ids' filter)
isComplete := false
for _, f := range filters {
if fMap, ok := f.(map[string]interface{}); ok {
if ids, exists := fMap["ids"]; exists {
if idList, ok := ids.([]string); ok && len(idList) > 0 {
isComplete = true
break
}
}
}
}
c.complete[subID] = isComplete
c.mu.Unlock()
return
}
@@ -140,8 +179,17 @@ func (c *Client) Subscribe(subID string, filters []interface{}) (ch chan []byte,
func (c *Client) Unsubscribe(subID string) error {
c.mu.Lock()
if ch, exists := c.subs[subID]; exists {
close(ch)
// Channel might already be closed by EOSE, so use recover to handle gracefully
func() {
defer func() {
if recover() != nil {
// Channel was already closed, ignore
}
}()
close(ch)
}()
delete(c.subs, subID)
delete(c.complete, subID)
}
c.mu.Unlock()
return c.Send([]interface{}{"CLOSE", subID})
@@ -149,10 +197,7 @@ func (c *Client) Unsubscribe(subID string) error {
// Publish sends an EVENT message to the relay.
func (c *Client) Publish(ev *event.E) (err error) {
evJSON, err := json.Marshal(ev.Serialize())
if err != nil {
return errorf.E("failed to marshal event: %w", err)
}
evJSON := ev.Serialize()
var evMap map[string]interface{}
if err = json.Unmarshal(evJSON, &evMap); err != nil {
return errorf.E("failed to unmarshal event: %w", err)
@@ -169,21 +214,14 @@ func (c *Client) WaitForOK(eventID []byte, timeout time.Duration) (accepted bool
select {
case <-ctx.Done():
return false, "", errorf.E("timeout waiting for OK response")
default:
}
var msg []byte
_, msg, err = c.conn.ReadMessage()
if err != nil {
return false, "", errorf.E("connection closed: %w", err)
}
var raw []interface{}
if err = json.Unmarshal(msg, &raw); err != nil {
continue
}
if len(raw) < 3 {
continue
}
if typ, ok := raw[0].(string); ok && typ == "OK" {
case msg := <-c.okCh:
var raw []interface{}
if err = json.Unmarshal(msg, &raw); err != nil {
continue
}
if len(raw) < 3 {
continue
}
if id, ok := raw[1].(string); ok && id == idStr {
accepted, _ = raw[2].(bool)
if len(raw) > 3 {
@@ -208,23 +246,16 @@ func (c *Client) Count(filters []interface{}) (count int64, err error) {
select {
case <-ctx.Done():
return 0, errorf.E("timeout waiting for COUNT response")
default:
}
_, msg, err := c.conn.ReadMessage()
if err != nil {
return 0, errorf.E("connection closed: %w", err)
}
var raw []interface{}
if err = json.Unmarshal(msg, &raw); err != nil {
continue
}
if len(raw) >= 3 {
if typ, ok := raw[0].(string); ok && typ == "COUNT" {
case msg := <-c.countCh:
var raw []interface{}
if err = json.Unmarshal(msg, &raw); err != nil {
continue
}
if len(raw) >= 3 {
if subID, ok := raw[1].(string); ok && subID == "count-sub" {
if countObj, ok := raw[2].(map[string]interface{}); ok {
if c, ok := countObj["count"].(float64); ok {
return int64(c), nil
}
// COUNT response format: ["COUNT", "subscription-id", count, approximate?]
if cnt, ok := raw[2].(float64); ok {
return int64(cnt), nil
}
}
}
@@ -234,12 +265,9 @@ func (c *Client) Count(filters []interface{}) (count int64, err error) {
// Auth sends an AUTH message with the signed event.
func (c *Client) Auth(ev *event.E) error {
evJSON, err := json.Marshal(ev.Serialize())
if err != nil {
return errorf.E("failed to marshal event: %w", err)
}
evJSON := ev.Serialize()
var evMap map[string]interface{}
if err = json.Unmarshal(evJSON, &evMap); err != nil {
if err := json.Unmarshal(evJSON, &evMap); err != nil {
return errorf.E("failed to unmarshal event: %w", err)
}
return c.Send([]interface{}{"AUTH", evMap})
@@ -266,14 +294,27 @@ func (c *Client) GetEvents(subID string, filters []interface{}, timeout time.Dur
if err = json.Unmarshal(msg, &raw); err != nil {
continue
}
if len(raw) >= 3 && raw[0] == "EVENT" {
if evData, ok := raw[2].(map[string]interface{}); ok {
evJSON, _ := json.Marshal(evData)
ev := event.New()
if _, err = ev.Unmarshal(evJSON); err == nil {
events = append(events, ev)
if len(raw) < 2 {
continue
}
typ, ok := raw[0].(string)
if !ok {
continue
}
switch typ {
case "EVENT":
if len(raw) >= 3 {
if evData, ok := raw[2].(map[string]interface{}); ok {
evJSON, _ := json.Marshal(evData)
ev := event.New()
if _, err = ev.Unmarshal(evJSON); err == nil {
events = append(events, ev)
}
}
}
case "EOSE":
// End of stored events - return what we have
return events, nil
}
}
}

View File

@@ -91,7 +91,8 @@ func CreateEphemeralEvent(signer *p256k.Signer, kindNum uint16, content string)
func CreateDeleteEvent(signer *p256k.Signer, eventIDs [][]byte, reason string) (ev *event.E, err error) {
tags := tag.NewS()
for _, id := range eventIDs {
tags.Append(tag.NewFromBytesSlice([]byte("e"), id))
// e tags must contain hex-encoded event IDs
tags.Append(tag.NewFromBytesSlice([]byte("e"), []byte(hex.Enc(id))))
}
if reason != "" {
tags.Append(tag.NewFromBytesSlice([]byte("content"), []byte(reason)))

View File

@@ -161,6 +161,175 @@ func (s *TestSuite) registerTests() {
Required: true,
Func: testSubscriptionClose,
},
// Filter tests
{
Name: "Since and until filters are inclusive",
Required: true,
Func: testSinceUntilAreInclusive,
Dependencies: []string{"Publishes basic event"},
},
{
Name: "Limit zero works",
Required: true,
Func: testLimitZero,
},
// Find tests
{
Name: "Events are ordered from newest to oldest",
Required: true,
Func: testEventsOrderedFromNewestToOldest,
Dependencies: []string{"Publishes basic event"},
},
{
Name: "Newest events are returned when filter is limited",
Required: true,
Func: testNewestEventsWhenLimited,
Dependencies: []string{"Publishes basic event"},
},
{
Name: "Finds by pubkey and kind",
Required: true,
Func: testFindByPubkeyAndKind,
Dependencies: []string{"Publishes basic event"},
},
{
Name: "Finds by pubkey and tags",
Required: true,
Func: testFindByPubkeyAndTags,
Dependencies: []string{"Publishes basic event"},
},
{
Name: "Finds by kind and tags",
Required: true,
Func: testFindByKindAndTags,
Dependencies: []string{"Publishes basic event"},
},
{
Name: "Finds by scrape",
Required: true,
Func: testFindByScrape,
Dependencies: []string{"Publishes basic event"},
},
// Replaceable event tests
{
Name: "Replaces metadata",
Required: true,
Func: testReplacesMetadata,
Dependencies: []string{"Publishes basic event"},
},
{
Name: "Replaces contact list",
Required: true,
Func: testReplacesContactList,
Dependencies: []string{"Publishes basic event"},
},
{
Name: "Replaced events are still available by ID",
Required: false,
Func: testReplacedEventsStillAvailableByID,
Dependencies: []string{"Publishes basic event"},
},
{
Name: "Replaceable events replace older ones",
Required: true,
Func: testReplaceableEventRemovesPrevious,
Dependencies: []string{"Publishes basic event"},
},
{
Name: "Replaceable events rejected if a newer one exists",
Required: true,
Func: testReplaceableEventRejectedIfFuture,
Dependencies: []string{"Publishes basic event"},
},
{
Name: "Addressable events replace older ones",
Required: true,
Func: testAddressableEventRemovesPrevious,
Dependencies: []string{"Publishes basic event"},
},
{
Name: "Addressable events rejected if a newer one exists",
Required: true,
Func: testAddressableEventRejectedIfFuture,
Dependencies: []string{"Publishes basic event"},
},
// Deletion tests
{
Name: "Deletes by a-tag address",
Required: true,
Func: testDeleteByAddr,
Dependencies: []string{"Publishes basic event"},
},
{
Name: "Delete by a-tag deletes older but not newer",
Required: true,
Func: testDeleteByAddrOnlyDeletesOlder,
Dependencies: []string{"Publishes basic event"},
},
{
Name: "Delete by a-tag is bound by a-tag",
Required: true,
Func: testDeleteByAddrIsBoundByTag,
Dependencies: []string{"Publishes basic event"},
},
// Ephemeral tests
{
Name: "Ephemeral subscriptions work",
Required: false,
Func: testEphemeralSubscriptionsWork,
Dependencies: []string{"Publishes basic event"},
},
{
Name: "Persists ephemeral events",
Required: false,
Func: testPersistsEphemeralEvents,
Dependencies: []string{"Publishes basic event"},
},
// EOSE tests
{
Name: "Supports EOSE",
Required: true,
Func: testSupportsEose,
},
{
Name: "Closes complete subscriptions after EOSE",
Required: false,
Func: testClosesCompleteSubscriptionsAfterEose,
},
{
Name: "Keeps open incomplete subscriptions after EOSE",
Required: true,
Func: testKeepsOpenIncompleteSubscriptionsAfterEose,
},
// JSON tests
{
Name: "Accepts events with empty tags",
Required: false,
Func: testAcceptsEventsWithEmptyTags,
Dependencies: []string{"Publishes basic event"},
},
{
Name: "Accepts NIP-01 JSON escape sequences",
Required: true,
Func: testAcceptsNip1JsonEscapeSequences,
Dependencies: []string{"Publishes basic event"},
},
// Registration tests
{
Name: "Sends OK after EVENT",
Required: true,
Func: testSendsOkAfterEvent,
},
{
Name: "Verifies event signatures",
Required: true,
Func: testVerifiesSignatures,
},
{
Name: "Verifies event ID hashes",
Required: true,
Func: testVerifiesIdHashes,
},
}
for _, tc := range allTests {
s.AddTest(tc)

File diff suppressed because it is too large Load Diff

View File

@@ -2,10 +2,9 @@ package main
import (
"fmt"
"net"
"os"
"os/signal"
"path/filepath"
"syscall"
"testing"
"time"
@@ -34,7 +33,8 @@ func TestRelay(t *testing.T) {
relayURL = testRelayURL
} else {
// Start local relay for testing
if relay, err = startTestRelay(); err != nil {
var port int
if relay, port, err = startTestRelay(); err != nil {
t.Fatalf("Failed to start test relay: %v", err)
}
defer func() {
@@ -42,20 +42,22 @@ func TestRelay(t *testing.T) {
t.Logf("Error stopping relay: %v", stopErr)
}
}()
port := relayPort
if port == 0 {
port = 3334 // Default port
}
relayURL = fmt.Sprintf("ws://127.0.0.1:%d", port)
// Wait for relay to be ready
time.Sleep(2 * time.Second)
t.Logf("Waiting for relay to be ready at %s...", relayURL)
// Wait for relay to be ready - try connecting to verify it's up
if err = waitForRelay(relayURL, 10*time.Second); err != nil {
t.Fatalf("Relay not ready after timeout: %v", err)
}
t.Logf("Relay is ready at %s", relayURL)
}
// Create test suite
t.Logf("Creating test suite for %s...", relayURL)
suite, err := relaytester.NewTestSuite(relayURL)
if err != nil {
t.Fatalf("Failed to create test suite: %v", err)
}
t.Logf("Test suite created, running tests...")
// Run tests
var results []relaytester.TestResult
@@ -92,20 +94,43 @@ func TestRelay(t *testing.T) {
}
}
func startTestRelay() (relay *run.Relay, err error) {
func startTestRelay() (relay *run.Relay, port int, err error) {
cfg := &config.C{
AppName: "ORLY-TEST",
DataDir: relayDataDir,
Listen: "127.0.0.1",
Port: relayPort,
LogLevel: "warn",
DBLogLevel: "warn",
ACLMode: "none",
AppName: "ORLY-TEST",
DataDir: relayDataDir,
Listen: "127.0.0.1",
Port: 0, // Always use random port, unless overridden via -port flag
HealthPort: 0,
EnableShutdown: false,
LogLevel: "warn",
DBLogLevel: "warn",
DBBlockCacheMB: 512,
DBIndexCacheMB: 256,
LogToStdout: false,
PprofHTTP: false,
ACLMode: "none",
AuthRequired: false,
AuthToWrite: false,
SubscriptionEnabled: false,
MonthlyPriceSats: 6000,
FollowListFrequency: time.Hour,
WebDisableEmbedded: false,
SprocketEnabled: false,
SpiderMode: "none",
PolicyEnabled: false,
}
// Set default port if not specified
if cfg.Port == 0 {
cfg.Port = 3334
// Use explicitly set port if provided via flag, otherwise find an available port
if relayPort > 0 {
cfg.Port = relayPort
} else {
var listener net.Listener
if listener, err = net.Listen("tcp", "127.0.0.1:0"); err != nil {
return nil, 0, fmt.Errorf("failed to find available port: %w", err)
}
addr := listener.Addr().(*net.TCPAddr)
cfg.Port = addr.Port
listener.Close()
}
// Set default data dir if not specified
@@ -125,21 +150,34 @@ func startTestRelay() (relay *run.Relay, err error) {
// Start relay
if relay, err = run.Start(cfg, opts); err != nil {
return nil, fmt.Errorf("failed to start relay: %w", err)
return nil, 0, fmt.Errorf("failed to start relay: %w", err)
}
// Set up signal handling for graceful shutdown
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
go func() {
<-sigChan
if relay != nil {
relay.Stop()
}
os.Exit(0)
}()
return relay, cfg.Port, nil
}
return relay, nil
// waitForRelay waits for the relay to be ready by attempting to connect
func waitForRelay(url string, timeout time.Duration) error {
// Extract host:port from ws:// URL
addr := url
if len(url) > 7 && url[:5] == "ws://" {
addr = url[5:]
}
deadline := time.Now().Add(timeout)
attempts := 0
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", addr, 500*time.Millisecond)
if err == nil {
conn.Close()
return nil
}
attempts++
if attempts%10 == 0 {
// Log every 10th attempt (every second)
}
time.Sleep(100 * time.Millisecond)
}
return fmt.Errorf("timeout waiting for relay at %s after %d attempts", url, attempts)
}
func outputResults(results []relaytester.TestResult, t *testing.T) {