mirror of
https://github.com/Quad4-Software/Reticulum-Go
synced 2026-08-29 23:48:44 -04:00
215 lines
5.8 KiB
Go
215 lines
5.8 KiB
Go
// SPDX-License-Identifier: Apache-2.0
|
|
// Copyright (c) 2024-2026 Quad4.io
|
|
|
|
package controlapi
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"quad4/reticulum-go/pkg/identity"
|
|
)
|
|
|
|
// recordConn is a non-blocking net.Conn that captures Write bytes for
|
|
// handshake-gate assertions without the deadlock risk of net.Pipe.
|
|
type recordConn struct {
|
|
mu sync.Mutex
|
|
buf bytes.Buffer
|
|
closed chan struct{}
|
|
}
|
|
|
|
func newRecordConn() *recordConn {
|
|
return &recordConn{closed: make(chan struct{})}
|
|
}
|
|
|
|
func (c *recordConn) Read([]byte) (int, error) {
|
|
select {
|
|
case <-c.closed:
|
|
return 0, io.EOF
|
|
case <-time.After(50 * time.Millisecond):
|
|
return 0, fmt.Errorf("recordConn: no inbound data")
|
|
}
|
|
}
|
|
|
|
func (c *recordConn) Write(b []byte) (int, error) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
select {
|
|
case <-c.closed:
|
|
return 0, net.ErrClosed
|
|
default:
|
|
}
|
|
return c.buf.Write(b)
|
|
}
|
|
|
|
func (c *recordConn) Close() error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
select {
|
|
case <-c.closed:
|
|
default:
|
|
close(c.closed)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *recordConn) LocalAddr() net.Addr { return recordAddr("local") }
|
|
func (c *recordConn) RemoteAddr() net.Addr { return recordAddr("remote") }
|
|
func (c *recordConn) SetDeadline(time.Time) error { return nil }
|
|
func (c *recordConn) SetReadDeadline(time.Time) error { return nil }
|
|
func (c *recordConn) SetWriteDeadline(time.Time) error { return nil }
|
|
|
|
func (c *recordConn) snapshot() []byte {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return append([]byte(nil), c.buf.Bytes()...)
|
|
}
|
|
|
|
type recordAddr string
|
|
|
|
func (a recordAddr) Network() string { return "record" }
|
|
func (a recordAddr) String() string { return string(a) }
|
|
|
|
// TestAcceptance_WriteLoopWaitsUntilEnableWrites locks the contract that
|
|
// startWriter may run before the HTTP 101 is flushed, but writeLoop must
|
|
// not emit WebSocket frames until enableWrites.
|
|
func TestAcceptance_WriteLoopWaitsUntilEnableWrites(t *testing.T) {
|
|
srv, _ := newTestServer(t)
|
|
ident, err := identity.NewIdentity()
|
|
if err != nil {
|
|
t.Fatalf("new identity: %v", err)
|
|
}
|
|
sess := newSession("acceptance-writable", ident)
|
|
|
|
rc := newRecordConn()
|
|
c := newWSClient(srv, sess, &wsConn{conn: rc, reader: bufio.NewReader(rc)})
|
|
c.startWriter()
|
|
defer c.close()
|
|
|
|
payload := []byte(`{"type":"probe","n":1}`)
|
|
select {
|
|
case c.outbox <- payload:
|
|
default:
|
|
t.Fatal("outbox rejected probe frame")
|
|
}
|
|
|
|
deadline := time.Now().Add(100 * time.Millisecond)
|
|
for time.Now().Before(deadline) {
|
|
if got := rc.snapshot(); len(got) != 0 {
|
|
t.Fatalf("wrote %q before enableWrites", got)
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
|
|
c.enableWrites()
|
|
|
|
deadline = time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
got := rc.snapshot()
|
|
if len(got) > 0 {
|
|
if !bytes.Contains(got, payload) {
|
|
t.Fatalf("wrote %q, want payload %q", got, payload)
|
|
}
|
|
return
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
t.Fatal("no frame written after enableWrites")
|
|
}
|
|
|
|
// TestAcceptance_EventBroadcastDuringDialIsDelivered stresses the
|
|
// register-before-flush plus writeLoop-before-flush path: a peer that
|
|
// triggers a broadcast as soon as the socket is ready must still receive
|
|
// the event.
|
|
func TestAcceptance_EventBroadcastDuringDialIsDelivered(t *testing.T) {
|
|
const rounds = 40
|
|
|
|
for round := range rounds {
|
|
srv, key := newTestServer(t)
|
|
ts := httptest.NewServer(srv.httpServer.Handler)
|
|
authKey := hex.EncodeToString(key)
|
|
|
|
_, sessResp := doJSON(t, http.MethodPost, ts.URL+"/v1/sessions", authKey, map[string]any{})
|
|
sessionID, _ := sessResp["session_id"].(string)
|
|
|
|
_, destResp := doJSON(t, http.MethodPost, fmt.Sprintf("%s/v1/sessions/%s/destinations", ts.URL, sessionID), authKey, map[string]any{
|
|
"app_name": "acceptance_ws_race",
|
|
})
|
|
destHash, _ := destResp["destination_hash"].(string)
|
|
|
|
resp, _ := doJSON(t, http.MethodPost, fmt.Sprintf("%s/v1/sessions/%s/destinations/%s/requests", ts.URL, sessionID, destHash), authKey, map[string]any{
|
|
"path": "/ping",
|
|
})
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("round %d: register request handler status = %d", round, resp.StatusCode)
|
|
}
|
|
|
|
sess, ok := srv.session(sessionID)
|
|
if !ok {
|
|
t.Fatalf("round %d: session not found", round)
|
|
}
|
|
dest, ok := sess.destination(destHash)
|
|
if !ok {
|
|
t.Fatalf("round %d: destination not found", round)
|
|
}
|
|
pathHash := identity.TruncatedHash([]byte("/ping"))
|
|
handler := dest.GetRequestHandler(pathHash)
|
|
if handler == nil {
|
|
t.Fatalf("round %d: no handler for /ping", round)
|
|
}
|
|
|
|
requestID := []byte{byte(round), 2, 3, 4}
|
|
linkID := []byte{5, 6, 7, 8}
|
|
resultCh := make(chan any, 1)
|
|
|
|
ws := dialControlAPIWS(t, ts.URL, fmt.Sprintf("/v1/sessions/%s/events", sessionID), authKey)
|
|
|
|
go func() {
|
|
resultCh <- handler(pathHash, []byte("hello"), requestID, linkID, nil, time.Now())
|
|
}()
|
|
|
|
raw := ws.recvText(t, 5*time.Second)
|
|
var incoming requestIncomingEvent
|
|
if err := json.Unmarshal(raw, &incoming); err != nil {
|
|
_ = ws.conn.Close()
|
|
ts.Close()
|
|
t.Fatalf("round %d: decode event %q: %v", round, raw, err)
|
|
}
|
|
if incoming.Type != "request.incoming" || incoming.Path != "/ping" {
|
|
_ = ws.conn.Close()
|
|
ts.Close()
|
|
t.Fatalf("round %d: request.incoming = %+v", round, incoming)
|
|
}
|
|
|
|
ws.sendText(t, fmt.Appendf(nil, `{"type":"request.respond","request_id":%q,"data":%q}`,
|
|
incoming.RequestID, base64.StdEncoding.EncodeToString([]byte("pong"))))
|
|
|
|
select {
|
|
case result := <-resultCh:
|
|
b, ok := result.([]byte)
|
|
if !ok || string(b) != "pong" {
|
|
_ = ws.conn.Close()
|
|
ts.Close()
|
|
t.Fatalf("round %d: handler result = %#v, want []byte(\"pong\")", round, result)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
_ = ws.conn.Close()
|
|
ts.Close()
|
|
t.Fatalf("round %d: handler never returned after request.respond", round)
|
|
}
|
|
|
|
_ = ws.conn.Close()
|
|
ts.Close()
|
|
}
|
|
}
|