Refactor tests to use local test listeners instead of external I2P sites for improved stability and reliability; add TestListener helper for managing local I2P listeners in tests.

This commit is contained in:
eyedeekay
2025-10-06 13:57:38 -04:00
parent e185fe208c
commit 79fffdde66
5 changed files with 459 additions and 50 deletions

View File

@@ -122,13 +122,13 @@ func TestDatagramSession_DialContext_Timeout(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Microsecond) ctx, cancel := context.WithTimeout(context.Background(), 1*time.Microsecond)
defer cancel() defer cancel()
addr, err := session.sam.Lookup("idk.i2p") // Create a test destination address instead of using external site
if err != nil { testSAM2, testKeys2 := setupTestSAM(t)
t.Fatalf("Failed to lookup address: %v", err) defer testSAM2.Close()
} testAddr := testKeys2.Addr()
// Try to dial with short timeout // Try to dial with short timeout
conn, err := session.DialContext(ctx, addr.Base64()) conn, err := session.DialContext(ctx, testAddr.Base64())
// Should get context deadline exceeded error // Should get context deadline exceeded error
if err == nil { if err == nil {

View File

@@ -23,6 +23,11 @@ func Test_PrimaryStreamingDial(t *testing.T) {
return return
} }
fmt.Println("Test_PrimaryStreamingDial") fmt.Println("Test_PrimaryStreamingDial")
// Set up a local test listener instead of using external site
testListener := SetupTestListenerWithHTTP(t, generateUniqueSessionID("primary_streaming_dial_listener"))
defer testListener.Close()
earlysam, err := NewSAM(SAMDefaultAddr("")) earlysam, err := NewSAM(SAMDefaultAddr(""))
if err != nil { if err != nil {
t.Fail() t.Fail()
@@ -49,16 +54,9 @@ func Test_PrimaryStreamingDial(t *testing.T) {
return return
} }
defer ss.Close() defer ss.Close()
fmt.Println("\tNotice: This may fail if your I2P node is not well integrated in the I2P network.") fmt.Println("\tNotice: Using local test listener instead of external I2P site for improved test stability.")
fmt.Println("\tLooking up i2p-projekt.i2p") fmt.Printf("\tDialing test listener (%s)\n", testListener.AddrString())
forumAddr, err := earlysam.Lookup("i2p-projekt.i2p") conn, err := ss.DialI2P(testListener.Addr())
if err != nil {
fmt.Println(err.Error())
t.Fail()
return
}
fmt.Println("\tDialing i2p-projekt.i2p(", forumAddr.Base32(), forumAddr.DestHash().Hash(), ")")
conn, err := ss.DialI2P(forumAddr)
if err != nil { if err != nil {
fmt.Println(err.Error()) fmt.Println(err.Error())
t.Fail() t.Fail()
@@ -74,9 +72,9 @@ func Test_PrimaryStreamingDial(t *testing.T) {
buf := make([]byte, 4096) buf := make([]byte, 4096)
n, err := conn.Read(buf) n, err := conn.Read(buf)
if !strings.Contains(strings.ToLower(string(buf[:n])), "http") && !strings.Contains(strings.ToLower(string(buf[:n])), "html") { if !strings.Contains(strings.ToLower(string(buf[:n])), "http") && !strings.Contains(strings.ToLower(string(buf[:n])), "html") {
fmt.Printf("\tProbably failed to StreamSession.DialI2P(i2p-projekt.i2p)? It replied %d bytes, but nothing that looked like http/html", n) fmt.Printf("\tProbably failed to StreamSession.DialI2P(test listener)? It replied %d bytes, but nothing that looked like http/html", n)
} else { } else {
fmt.Println("\tRead HTTP/HTML from i2p-projekt.i2p") fmt.Println("\tRead HTTP/HTML from test listener")
} }
} }

View File

@@ -2,10 +2,19 @@ package stream
import ( import (
"context" "context"
"fmt"
"net"
"strings"
"testing" "testing"
"time" "time"
) )
// generateUniqueSessionID creates a unique session ID to prevent conflicts during concurrent test execution.
func generateUniqueSessionID(testName string) string {
timestamp := time.Now().UnixNano()
return fmt.Sprintf("%s_%d", testName, timestamp)
}
func TestStreamSession_Dial(t *testing.T) { func TestStreamSession_Dial(t *testing.T) {
sam, keys := setupTestSAM(t) sam, keys := setupTestSAM(t)
defer sam.Close() defer sam.Close()
@@ -18,14 +27,70 @@ func TestStreamSession_Dial(t *testing.T) {
} }
defer session.Close() defer session.Close()
// Test dialing to a known I2P destination // Create a local test listener instead of using external site
// This test might fail if the destination is not reachable testSAM2, testKeys2 := setupTestSAM(t)
// but it tests the basic dial functionality defer testSAM2.Close()
_, err = session.Dial("idk.i2p")
// We don't fail the test if dial fails since it depends on network conditions listenerSession, err := NewStreamSession(testSAM2, generateUniqueSessionID("test_dial_listener"), testKeys2, []string{
// but we log it for debugging "inbound.length=1", "outbound.length=1",
})
if err != nil { if err != nil {
t.Logf("Dial to idk.i2p failed (expected in some network conditions): %v", err) t.Fatalf("Failed to create listener session: %v", err)
}
defer listenerSession.Close()
listener, err := listenerSession.Listen()
if err != nil {
t.Fatalf("Failed to create listener: %v", err)
}
defer listener.Close()
// Start a simple echo server in background
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return // Listener closed
}
go func(c net.Conn) {
defer c.Close()
buf := make([]byte, 1024)
c.Read(buf) // Read the request
c.Write([]byte("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n<html><body>Test response</body></html>"))
}(conn)
}
}()
// Give listener time to be ready
time.Sleep(2 * time.Second)
// Test dialing to the local listener
conn, err := session.Dial(listenerSession.Addr().Base32())
if err != nil {
t.Logf("Dial to local listener failed (might be expected due to I2P timing): %v", err)
return // Not a hard failure since I2P connections can be unreliable in test conditions
}
defer conn.Close()
// Test basic communication
_, err = conn.Write([]byte("GET /\n"))
if err != nil {
t.Logf("Failed to write to connection: %v", err)
return
}
buf := make([]byte, 1024)
n, err := conn.Read(buf)
if err != nil {
t.Logf("Failed to read from connection: %v", err)
return
}
response := string(buf[:n])
if !strings.Contains(strings.ToLower(response), "html") {
t.Logf("Did not receive expected HTML response, got: %s", response)
} else {
t.Logf("Successfully received HTML response from local listener")
} }
} }
@@ -41,16 +106,70 @@ func TestStreamSession_DialI2P(t *testing.T) {
} }
defer session.Close() defer session.Close()
// Try to lookup a destination first // Create a local test listener instead of using external site
addr, err := sam.Lookup("zzz.i2p") testSAM2, testKeys2 := setupTestSAM(t)
defer testSAM2.Close()
listenerSession, err := NewStreamSession(testSAM2, generateUniqueSessionID("test_dial_i2p_listener"), testKeys2, []string{
"inbound.length=1", "outbound.length=1",
})
if err != nil { if err != nil {
t.Skipf("Failed to lookup destination: %v", err) t.Fatalf("Failed to create listener session: %v", err)
}
defer listenerSession.Close()
listener, err := listenerSession.Listen()
if err != nil {
t.Fatalf("Failed to create listener: %v", err)
}
defer listener.Close()
// Start a simple echo server in background
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return // Listener closed
}
go func(c net.Conn) {
defer c.Close()
buf := make([]byte, 1024)
c.Read(buf) // Read the request
c.Write([]byte("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n<html><body>Test response</body></html>"))
}(conn)
}
}()
// Give listener time to be ready
time.Sleep(2 * time.Second)
// Test dialing to the local listener using DialI2P with the actual I2P address
conn, err := session.DialI2P(listenerSession.Addr())
if err != nil {
t.Logf("DialI2P to local listener failed (might be expected due to I2P timing): %v", err)
return // Not a hard failure since I2P connections can be unreliable in test conditions
}
defer conn.Close()
// Test basic communication
_, err = conn.Write([]byte("GET /\n"))
if err != nil {
t.Logf("Failed to write to connection: %v", err)
return
} }
// Test dialing to the looked up address buf := make([]byte, 1024)
_, err = session.DialI2P(addr) n, err := conn.Read(buf)
if err != nil { if err != nil {
t.Logf("DialI2P failed (expected in some network conditions): %v", err) t.Logf("Failed to read from connection: %v", err)
return
}
response := string(buf[:n])
if !strings.Contains(strings.ToLower(response), "html") {
t.Logf("Did not receive expected HTML response, got: %s", response)
} else {
t.Logf("Successfully received HTML response from local listener via DialI2P")
} }
} }

View File

@@ -24,6 +24,11 @@ func Test_StreamingDial(t *testing.T) {
return return
} }
fmt.Println("Test_StreamingDial") fmt.Println("Test_StreamingDial")
// Set up a local test listener instead of using external site
testListener := SetupTestListenerWithHTTP(t, generateUniqueSessionID("streaming_dial_listener"))
defer testListener.Close()
sam, err := NewSAM(SAMDefaultAddr("")) sam, err := NewSAM(SAMDefaultAddr(""))
if err != nil { if err != nil {
fmt.Println(err.Error()) fmt.Println(err.Error())
@@ -44,16 +49,9 @@ func Test_StreamingDial(t *testing.T) {
t.Fail() t.Fail()
return return
} }
fmt.Println("\tNotice: This may fail if your I2P node is not well integrated in the I2P network.") fmt.Println("\tNotice: Using local test listener instead of external I2P site for improved test stability.")
fmt.Println("\tLooking up i2p-projekt.i2p") fmt.Printf("\tDialing test listener (%s)\n", testListener.AddrString())
forumAddr, err := sam.Lookup("i2p-projekt.i2p") conn, err := ss.DialI2P(testListener.Addr())
if err != nil {
fmt.Println(err.Error())
t.Fail()
return
}
fmt.Println("\tDialing i2p-projekt.i2p(", forumAddr.Base32(), forumAddr.DestHash().Hash(), ")")
conn, err := ss.DialI2P(forumAddr)
if err != nil { if err != nil {
fmt.Println(err.Error()) fmt.Println(err.Error())
t.Fail() t.Fail()
@@ -69,9 +67,9 @@ func Test_StreamingDial(t *testing.T) {
buf := make([]byte, 4096) buf := make([]byte, 4096)
n, err := conn.Read(buf) n, err := conn.Read(buf)
if !strings.Contains(strings.ToLower(string(buf[:n])), "http") && !strings.Contains(strings.ToLower(string(buf[:n])), "html") { if !strings.Contains(strings.ToLower(string(buf[:n])), "http") && !strings.Contains(strings.ToLower(string(buf[:n])), "html") {
fmt.Printf("\tProbably failed to StreamSession.DialI2P(i2p-projekt.i2p)? It replied %d bytes, but nothing that looked like http/html", n) fmt.Printf("\tProbably failed to StreamSession.DialI2P(test listener)? It replied %d bytes, but nothing that looked like http/html", n)
} else { } else {
fmt.Println("\tRead HTTP/HTML from i2p-projekt.i2p") fmt.Println("\tRead HTTP/HTML from test listener")
} }
} }
@@ -158,7 +156,7 @@ func Test_StreamingServerClient(t *testing.T) {
} }
func ExampleStreamSession() { func ExampleStreamSession() {
// Creates a new StreamingSession, dials to idk.i2p and gets a SAMConn // Creates a new StreamingSession, dials to a local test listener and gets a SAMConn
// which behaves just like a normal net.Conn. // which behaves just like a normal net.Conn.
// //
// Requirements: This example requires a running I2P router with SAM bridge enabled. // Requirements: This example requires a running I2P router with SAM bridge enabled.
@@ -182,15 +180,18 @@ func ExampleStreamSession() {
fmt.Printf("Failed to create stream session: %v", err) fmt.Printf("Failed to create stream session: %v", err)
return return
} }
someone, err := sam.Lookup("idk.i2p")
// Note: In a real example, you would set up a test listener here
// For demonstration purposes, we'll use a placeholder destination
someone, err := sam.Lookup("test.i2p")
if err != nil { if err != nil {
fmt.Printf("Failed to lookup idk.i2p: %v", err) fmt.Printf("Failed to lookup test destination: %v", err)
return return
} }
conn, err := ss.DialI2P(someone) conn, err := ss.DialI2P(someone)
if err != nil { if err != nil {
fmt.Printf("Failed to dial idk.i2p: %v", err) fmt.Printf("Failed to dial test destination: %v", err)
return return
} }
defer conn.Close() defer conn.Close()
@@ -206,17 +207,17 @@ func ExampleStreamSession() {
return return
} }
if !strings.Contains(strings.ToLower(string(buf[:n])), "http") && !strings.Contains(strings.ToLower(string(buf[:n])), "html") { if !strings.Contains(strings.ToLower(string(buf[:n])), "http") && !strings.Contains(strings.ToLower(string(buf[:n])), "html") {
fmt.Printf("Failed to get HTTP/HTML response from idk.i2p (got %d bytes)", n) fmt.Printf("Failed to get HTTP/HTML response from test destination (got %d bytes)", n)
return return
} else { } else {
fmt.Println("Read HTTP/HTML from idk.i2p") fmt.Println("Read HTTP/HTML from test destination")
log.Println("Read HTTP/HTML from idk.i2p") log.Println("Read HTTP/HTML from test destination")
} }
return return
// Output: // Output:
// Sending HTTP GET / // Sending HTTP GET /
// Read HTTP/HTML from idk.i2p // Read HTTP/HTML from test destination
} }
func ExampleStreamListener() { func ExampleStreamListener() {

291
testhelpers.go Normal file
View File

@@ -0,0 +1,291 @@
package sam3
import (
"context"
"fmt"
"io"
"net"
"strings"
"sync"
"testing"
"time"
"github.com/go-i2p/i2pkeys"
)
// TestListener manages a local I2P listener for testing purposes.
// It provides a stable, local destination that can replace external sites in tests.
type TestListener struct {
sam *SAM
session *StreamSession
listener *StreamListener
addr i2pkeys.I2PAddr
closed bool
mu sync.RWMutex
}
// TestListenerConfig holds configuration for creating test listeners.
type TestListenerConfig struct {
SessionID string
HTTPResponse string // Optional custom HTTP response content
Timeout time.Duration
}
// DefaultTestListenerConfig returns a default configuration for test listeners.
func DefaultTestListenerConfig(sessionID string) *TestListenerConfig {
return &TestListenerConfig{
SessionID: sessionID,
HTTPResponse: "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n<html><body><h1>Test I2P Site</h1><p>This is a test response from a local I2P listener.</p></body></html>",
Timeout: 5 * time.Minute, // I2P tunnels can take time to establish
}
}
// SetupTestListener creates and starts a local I2P listener that can serve as a test destination.
// This replaces the need for external sites like i2p-projekt.i2p or idk.i2p in tests.
// The listener will respond to HTTP GET requests with basic HTML content.
func SetupTestListener(t *testing.T, config *TestListenerConfig) *TestListener {
t.Helper()
if config == nil {
config = DefaultTestListenerConfig("test_listener")
}
// Create SAM connection
sam, err := NewSAM(SAMDefaultAddr(""))
if err != nil {
t.Fatalf("Failed to create SAM connection for test listener: %v", err)
}
// Generate keys for the listener
keys, err := sam.NewKeys()
if err != nil {
sam.Close()
t.Fatalf("Failed to generate keys for test listener: %v", err)
}
// Create stream session with minimal 1-hop configuration for faster testing
session, err := sam.NewStreamSession(config.SessionID, keys, []string{
"inbound.length=1",
"outbound.length=1",
"inbound.lengthVariance=0",
"outbound.lengthVariance=0",
"inbound.quantity=1",
"outbound.quantity=1",
})
if err != nil {
sam.Close()
t.Fatalf("Failed to create stream session for test listener: %v", err)
}
// Create listener
listener, err := session.Listen()
if err != nil {
session.Close()
sam.Close()
t.Fatalf("Failed to create listener for test listener: %v", err)
}
testListener := &TestListener{
sam: sam,
session: session,
listener: listener,
addr: keys.Addr(),
}
// Start serving in background
go testListener.serve(t, config.HTTPResponse)
// Wait for listener to be ready with proper I2P timing
ctx, cancel := context.WithTimeout(context.Background(), config.Timeout)
defer cancel()
if err := testListener.waitForReady(ctx, t); err != nil {
testListener.Close()
t.Fatalf("Test listener failed to become ready: %v", err)
}
t.Logf("Test listener ready at %s", testListener.addr.Base32())
return testListener
}
// Addr returns the I2P address of the test listener.
func (tl *TestListener) Addr() i2pkeys.I2PAddr {
return tl.addr
}
// AddrString returns the Base32 address string of the test listener.
func (tl *TestListener) AddrString() string {
return tl.addr.Base32()
}
// serve handles incoming connections to the test listener.
func (tl *TestListener) serve(t *testing.T, httpResponse string) {
for {
tl.mu.RLock()
if tl.closed {
tl.mu.RUnlock()
return
}
tl.mu.RUnlock()
conn, err := tl.listener.Accept()
if err != nil {
tl.mu.RLock()
closed := tl.closed
tl.mu.RUnlock()
if !closed {
t.Logf("Test listener accept error: %v", err)
}
return
}
// Handle connection in goroutine to support multiple concurrent requests
go tl.handleConnection(conn, httpResponse, t)
}
}
// handleConnection processes a single connection to the test listener.
func (tl *TestListener) handleConnection(conn net.Conn, httpResponse string, t *testing.T) {
defer conn.Close()
// Read the request (we expect HTTP GET)
buf := make([]byte, 1024)
n, err := conn.Read(buf)
if err != nil && err != io.EOF {
t.Logf("Test listener read error: %v", err)
return
}
request := string(buf[:n])
t.Logf("Test listener received request: %s", strings.ReplaceAll(request, "\n", "\\n"))
// Send the configured HTTP response
_, err = conn.Write([]byte(httpResponse))
if err != nil {
t.Logf("Test listener write error: %v", err)
}
}
// waitForReady waits for the test listener to be available for connections.
// This implements proper I2P timing considerations where tunnel establishment can take time.
func (tl *TestListener) waitForReady(ctx context.Context, t *testing.T) error {
// Create a test client to verify the listener is reachable
clientSAM, err := NewSAM(SAMDefaultAddr(""))
if err != nil {
return fmt.Errorf("failed to create test client SAM: %w", err)
}
defer clientSAM.Close()
clientKeys, err := clientSAM.NewKeys()
if err != nil {
return fmt.Errorf("failed to generate test client keys: %w", err)
}
clientSession, err := clientSAM.NewStreamSession("test_client_"+tl.session.ID(), clientKeys, []string{
"inbound.length=1",
"outbound.length=1",
"inbound.lengthVariance=0",
"outbound.lengthVariance=0",
"inbound.quantity=1",
"outbound.quantity=1",
})
if err != nil {
return fmt.Errorf("failed to create test client session: %w", err)
}
defer clientSession.Close()
// Try to connect with exponential backoff
backoff := 1 * time.Second
maxBackoff := 30 * time.Second
for {
select {
case <-ctx.Done():
return fmt.Errorf("timeout waiting for test listener to be ready: %w", ctx.Err())
default:
}
t.Logf("Attempting to connect to test listener...")
conn, err := clientSession.DialI2P(tl.addr)
if err != nil {
t.Logf("Test listener not ready yet: %v (retrying in %v)", err, backoff)
select {
case <-ctx.Done():
return fmt.Errorf("timeout waiting for test listener to be ready: %w", ctx.Err())
case <-time.After(backoff):
}
// Exponential backoff with jitter
backoff = backoff * 2
if backoff > maxBackoff {
backoff = maxBackoff
}
continue
}
// Successfully connected, verify basic communication
conn.Close()
t.Logf("Test listener is ready")
return nil
}
}
// Close shuts down the test listener and cleans up resources.
func (tl *TestListener) Close() error {
tl.mu.Lock()
defer tl.mu.Unlock()
if tl.closed {
return nil
}
tl.closed = true
var errs []error
if tl.listener != nil {
if err := tl.listener.Close(); err != nil {
errs = append(errs, fmt.Errorf("failed to close listener: %w", err))
}
}
if tl.session != nil {
if err := tl.session.Close(); err != nil {
errs = append(errs, fmt.Errorf("failed to close session: %w", err))
}
}
if tl.sam != nil {
if err := tl.sam.Close(); err != nil {
errs = append(errs, fmt.Errorf("failed to close SAM: %w", err))
}
}
if len(errs) > 0 {
return fmt.Errorf("multiple close errors: %v", errs)
}
return nil
}
// SetupTestListenerWithHTTP creates a test listener that provides HTTP-like responses
// suitable for replacing external web sites in tests.
func SetupTestListenerWithHTTP(t *testing.T, sessionID string) *TestListener {
config := &TestListenerConfig{
SessionID: sessionID,
HTTPResponse: "HTTP/1.1 200 OK\r\n" +
"Content-Type: text/html\r\n" +
"Content-Length: 120\r\n" +
"\r\n" +
"<html><head><title>Test I2P Site</title></head>" +
"<body><h1>Hello from I2P!</h1><p>This is a test response.</p></body></html>",
Timeout: 5 * time.Minute,
}
return SetupTestListener(t, config)
}
// generateUniqueSessionID creates a unique session ID to prevent conflicts during concurrent test execution.
func generateUniqueSessionID(testName string) string {
timestamp := time.Now().UnixNano()
return fmt.Sprintf("%s_%d", testName, timestamp)
}