Reticulum-Go/pkg/controlapi/link_test.go

419 lines
15 KiB
Go

// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2024-2026 Quad4.io
package controlapi
import (
"bytes"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"quad4/reticulum-go/pkg/common"
"quad4/reticulum-go/pkg/identity"
"quad4/reticulum-go/pkg/transport"
)
// TestRequestResponseBridgeDirect exercises wireRequestHandler's blocking
// bridge in isolation, invoking the registered handler directly instead of
// through a real link, to make failures easy to attribute.
func TestRequestResponseBridgeDirect(t *testing.T) {
srv, key := newTestServer(t)
ts := httptest.NewServer(srv.httpServer.Handler)
defer ts.Close()
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": "bridge_test"})
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("register request handler status = %d", resp.StatusCode)
}
ws := dialControlAPIWS(t, ts.URL, fmt.Sprintf("/v1/sessions/%s/events", sessionID), authKey)
defer ws.conn.Close()
sess, ok := srv.session(sessionID)
if !ok {
t.Fatal("session not found")
}
dest, ok := sess.destination(destHash)
if !ok {
t.Fatal("destination not found")
}
pathHash := identity.TruncatedHash([]byte("/ping"))
handler := dest.GetRequestHandler(pathHash)
if handler == nil {
t.Fatal("no handler registered for /ping")
}
requestID := []byte{1, 2, 3, 4}
linkID := []byte{5, 6, 7, 8}
resultCh := make(chan any, 1)
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 {
t.Fatalf("decode event %q: %v", raw, err)
}
if incoming.Type != "request.incoming" || incoming.Path != "/ping" {
t.Fatalf("request.incoming = %+v", incoming)
}
if incoming.RequestID != hex.EncodeToString(requestID) {
t.Fatalf("request_id = %q, want %q", incoming.RequestID, hex.EncodeToString(requestID))
}
select {
case <-resultCh:
t.Fatal("handler returned before request.respond was sent")
case <-time.After(200 * time.Millisecond):
}
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" {
t.Fatalf("handler result = %#v, want []byte(\"pong\")", result)
}
case <-time.After(2 * time.Second):
t.Fatal("handler never returned after request.respond")
}
}
func TestParseAllowMode(t *testing.T) {
if allow, list, err := parseAllowMode("", nil); err != nil || allow != 0x01 || list != nil {
t.Errorf("default allow = (%v, %v, %v), want (AllowAll, nil, nil)", allow, list, err)
}
if allow, _, err := parseAllowMode("all", nil); err != nil || allow != 0x01 {
t.Errorf("allow=all -> (%v, %v)", allow, err)
}
if allow, _, err := parseAllowMode("none", nil); err != nil || allow != 0x00 {
t.Errorf("allow=none -> (%v, %v)", allow, err)
}
if _, _, err := parseAllowMode("list", nil); err == nil {
t.Error("allow=list with no identities should error")
}
hashHex := hex.EncodeToString(bytes.Repeat([]byte{0xAA}, 16))
allow, list, err := parseAllowMode("list", []string{hashHex})
if err != nil || allow != 0x02 || len(list) != 1 {
t.Errorf("allow=list -> (%v, %v, %v)", allow, list, err)
}
if _, _, err := parseAllowMode("list", []string{"not-hex"}); err == nil {
t.Error("allow=list with invalid hex should error")
}
if _, _, err := parseAllowMode("bogus", nil); err == nil {
t.Error("unknown allow mode should error")
}
}
func TestLinkOpenValidation(t *testing.T) {
srv, key := newTestServer(t)
ts := httptest.NewServer(srv.httpServer.Handler)
defer ts.Close()
authKey := hex.EncodeToString(key)
_, session := doJSON(t, http.MethodPost, ts.URL+"/v1/sessions", authKey, map[string]any{})
sessionID, _ := session["session_id"].(string)
ws := dialControlAPIWS(t, ts.URL, fmt.Sprintf("/v1/sessions/%s/events", sessionID), authKey)
defer ws.conn.Close()
ws.sendText(t, []byte(`{"type":"link.open","destination_hash":"not-hex"}`))
raw := ws.recvText(t, 2*time.Second)
var evt linkFailedEvent
if err := json.Unmarshal(raw, &evt); err != nil {
t.Fatalf("decode event %q: %v", raw, err)
}
if evt.Type != "link.failed" {
t.Errorf("event type = %q, want link.failed", evt.Type)
}
unknown := hex.EncodeToString(bytes.Repeat([]byte{0xCD}, 16))
ws.sendText(t, fmt.Appendf(nil, `{"type":"link.open","destination_hash":%q}`, unknown))
raw = ws.recvText(t, 2*time.Second)
if err := json.Unmarshal(raw, &evt); err != nil {
t.Fatalf("decode event %q: %v", raw, err)
}
if evt.Type != "link.failed" || evt.DestinationHash != unknown {
t.Errorf("event = %+v, want link.failed for %q", evt, unknown)
}
}
func TestRegisterRequestHandlerValidation(t *testing.T) {
srv, key := newTestServer(t)
ts := httptest.NewServer(srv.httpServer.Handler)
defer ts.Close()
authKey := hex.EncodeToString(key)
_, session := doJSON(t, http.MethodPost, ts.URL+"/v1/sessions", authKey, map[string]any{})
sessionID, _ := session["session_id"].(string)
_, dest := doJSON(t, http.MethodPost, fmt.Sprintf("%s/v1/sessions/%s/destinations", ts.URL, sessionID), authKey, map[string]any{
"app_name": "controlapi_test",
})
destHash, _ := dest["destination_hash"].(string)
reqURL := fmt.Sprintf("%s/v1/sessions/%s/destinations/%s/requests", ts.URL, sessionID, destHash)
resp, _ := doJSON(t, http.MethodPost, reqURL, authKey, map[string]any{})
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("missing path status = %d, want %d", resp.StatusCode, http.StatusBadRequest)
}
resp, _ = doJSON(t, http.MethodPost, reqURL, authKey, map[string]any{"path": "/ping", "allow": "bogus"})
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("bad allow mode status = %d, want %d", resp.StatusCode, http.StatusBadRequest)
}
resp, _ = doJSON(t, http.MethodPost, reqURL, authKey, map[string]any{"path": "/ping"})
if resp.StatusCode != http.StatusCreated {
t.Errorf("valid registration status = %d, want %d", resp.StatusCode, http.StatusCreated)
}
unknownDestURL := fmt.Sprintf("%s/v1/sessions/%s/destinations/deadbeefdeadbeefdeadbeefdeadbeef/requests", ts.URL, sessionID)
resp, _ = doJSON(t, http.MethodPost, unknownDestURL, authKey, map[string]any{"path": "/ping"})
if resp.StatusCode != http.StatusNotFound {
t.Errorf("unknown destination status = %d, want %d", resp.StatusCode, http.StatusNotFound)
}
}
// pipeInterface simulates a direct, lossless connection between two nodes'
// transports so link establishment can be exercised without real sockets.
type pipeInterface struct {
common.BaseInterface
peer *pipeInterface
tr *transport.Transport
}
// pipeTestBitrate models an in-process lossless pipe, matching the guess
// used by the real Pipe interface (pkg/interfaces/pipe.go). Using
// common.BitrateMinimum here would make AwaitPath treat the loopback as an
// uninitialized slow radio and scale its path-response window to minutes.
const pipeTestBitrate = 1_000_000
func newPipeInterface(name string) *pipeInterface {
return &pipeInterface{
BaseInterface: common.BaseInterface{
Name: name,
Type: common.IFTypeUDP,
Mode: common.IFModeFull,
Enabled: true,
Online: true,
MTU: common.DefaultMTU,
Bitrate: pipeTestBitrate,
},
}
}
func (p *pipeInterface) Send(data []byte, address string) error {
if p.peer == nil || p.peer.tr == nil {
return nil
}
dataCopy := make([]byte, len(data))
copy(dataCopy, data)
p.peer.tr.HandlePacket(dataCopy, p.peer)
return nil
}
func (p *pipeInterface) IsEnabled() bool { return true }
func (p *pipeInterface) IsOnline() bool { return true }
func (p *pipeInterface) GetName() string { return p.Name }
func (p *pipeInterface) Start() error { return nil }
func (p *pipeInterface) Stop() error { return nil }
func (p *pipeInterface) Detach() {}
// newLinkTestServer is like newTestServer but returns the underlying
// Transport too, so tests can wire a pipeInterface between two nodes.
func newLinkTestServer(t *testing.T) (*Server, *transport.Transport, []byte) {
t.Helper()
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
t.Fatalf("generate key: %v", err)
}
cfg := common.DefaultConfig()
cfg.RPCKey = key
tr := transport.NewTransport(cfg)
t.Cleanup(func() { _ = tr.Close() })
srv, err := New(tr, nil, cfg)
if err != nil {
t.Fatalf("New: %v", err)
}
return srv, tr, key
}
// TestLinkAndRequestLifecycle drives two independent control API servers,
// connected by an in-process pipe interface, through the full phase-2
// surface: an inbound-accepting destination on node A, an outbound
// link.open from node B, bidirectional link.send/link.data, a
// request.incoming/request.respond round trip, and link.close/link.closed
// on both ends.
func TestLinkAndRequestLifecycle(t *testing.T) {
srvA, trA, keyA := newLinkTestServer(t)
srvB, trB, keyB := newLinkTestServer(t)
pipeA := newPipeInterface("pipeA")
pipeB := newPipeInterface("pipeB")
pipeA.peer = pipeB
pipeB.peer = pipeA
pipeA.tr = trA
pipeB.tr = trB
if err := trA.RegisterInterface("pipeA", pipeA); err != nil {
t.Fatalf("register pipeA: %v", err)
}
if err := trB.RegisterInterface("pipeB", pipeB); err != nil {
t.Fatalf("register pipeB: %v", err)
}
_ = trA.InitializePathRequestHandler()
tsA := httptest.NewServer(srvA.httpServer.Handler)
defer tsA.Close()
tsB := httptest.NewServer(srvB.httpServer.Handler)
defer tsB.Close()
authA := hex.EncodeToString(keyA)
authB := hex.EncodeToString(keyB)
_, sessA := doJSON(t, http.MethodPost, tsA.URL+"/v1/sessions", authA, map[string]any{})
sessionIDA, _ := sessA["session_id"].(string)
_, sessB := doJSON(t, http.MethodPost, tsB.URL+"/v1/sessions", authB, map[string]any{})
sessionIDB, _ := sessB["session_id"].(string)
resp, destA := doJSON(t, http.MethodPost, fmt.Sprintf("%s/v1/sessions/%s/destinations", tsA.URL, sessionIDA), authA, map[string]any{
"app_name": "controlapi_test",
"aspects": []string{"link"},
"accepts_links": true,
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("register destination A status = %d", resp.StatusCode)
}
destHashA, _ := destA["destination_hash"].(string)
const pingResponse = "pong"
resp, _ = doJSON(t, http.MethodPost, fmt.Sprintf("%s/v1/sessions/%s/destinations/%s/requests", tsA.URL, sessionIDA, destHashA), authA, map[string]any{
"path": "/ping",
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("register request handler status = %d", resp.StatusCode)
}
wsA := dialControlAPIWS(t, tsA.URL, fmt.Sprintf("/v1/sessions/%s/events", sessionIDA), authA)
defer wsA.conn.Close()
resp, _ = doJSON(t, http.MethodPost, fmt.Sprintf("%s/v1/sessions/%s/destinations/%s/announce", tsA.URL, sessionIDA, destHashA), authA, nil)
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("announce status = %d", resp.StatusCode)
}
destHashABytes, err := hex.DecodeString(destHashA)
if err != nil {
t.Fatalf("decode dest hash: %v", err)
}
deadline := time.Now().Add(5 * time.Second)
for !trB.HasPath(destHashABytes) {
if time.Now().After(deadline) {
t.Fatal("node B never learned a path to node A's destination")
}
time.Sleep(20 * time.Millisecond)
}
wsB := dialControlAPIWS(t, tsB.URL, fmt.Sprintf("/v1/sessions/%s/events", sessionIDB), authB)
defer wsB.conn.Close()
wsB.sendText(t, fmt.Appendf(nil, `{"type":"link.open","destination_hash":%q}`, destHashA))
establishedB := decodeEvent[linkEstablishedEvent](t, wsB, 5*time.Second)
if establishedB.Type != "link.established" || establishedB.LinkID == "" {
t.Fatalf("node B link.established = %+v", establishedB)
}
establishedA := decodeEvent[linkEstablishedEvent](t, wsA, 5*time.Second)
if establishedA.Type != "link.established" || establishedA.LinkID == "" {
t.Fatalf("node A link.established = %+v", establishedA)
}
internalSessB, ok := srvB.session(sessionIDB)
if !ok {
t.Fatal("session B not found")
}
lsB, ok := internalSessB.getLink(establishedB.LinkID)
if !ok {
t.Fatalf("link %s not tracked in session B", establishedB.LinkID)
}
receipt, err := lsB.link.Request("/ping", []byte("hello"), 5*time.Second)
if err != nil {
t.Fatalf("Request: %v", err)
}
incoming := decodeEvent[requestIncomingEvent](t, wsA, 5*time.Second)
if incoming.Type != "request.incoming" || incoming.Path != "/ping" {
t.Fatalf("request.incoming = %+v", incoming)
}
incomingData, err := base64.StdEncoding.DecodeString(incoming.Data)
if err != nil || string(incomingData) != "hello" {
t.Fatalf("request.incoming data = %q (err %v), want %q", incoming.Data, err, "hello")
}
wsA.sendText(t, fmt.Appendf(nil, `{"type":"request.respond","request_id":%q,"data":%q}`,
incoming.RequestID, base64.StdEncoding.EncodeToString([]byte(pingResponse))))
deadline = time.Now().Add(5 * time.Second)
for !receipt.Concluded() {
if time.Now().After(deadline) {
t.Fatal("request never concluded")
}
time.Sleep(20 * time.Millisecond)
}
if got := string(receipt.GetResponse()); got != pingResponse {
t.Errorf("response = %q, want %q", got, pingResponse)
}
payload := []byte("link-data-payload")
wsB.sendText(t, fmt.Appendf(nil, `{"type":"link.send","link_id":%q,"data":%q}`,
establishedB.LinkID, base64.StdEncoding.EncodeToString(payload)))
dataEvt := decodeEvent[linkDataEvent](t, wsA, 5*time.Second)
dataBytes, err := base64.StdEncoding.DecodeString(dataEvt.Data)
if err != nil || string(dataBytes) != string(payload) {
t.Fatalf("link.data = %q (err %v), want %q", dataEvt.Data, err, payload)
}
wsB.sendText(t, fmt.Appendf(nil, `{"type":"link.close","link_id":%q}`, establishedB.LinkID))
closedB := decodeEvent[linkClosedEvent](t, wsB, 5*time.Second)
if closedB.LinkID != establishedB.LinkID {
t.Errorf("node B link.closed link_id = %q, want %q", closedB.LinkID, establishedB.LinkID)
}
closedA := decodeEvent[linkClosedEvent](t, wsA, 5*time.Second)
if closedA.LinkID != establishedA.LinkID {
t.Errorf("node A link.closed link_id = %q, want %q", closedA.LinkID, establishedA.LinkID)
}
}
func decodeEvent[T any](t testing.TB, ws *testWSClient, timeout time.Duration) T {
t.Helper()
raw := ws.recvText(t, timeout)
var v T
if err := json.Unmarshal(raw, &v); err != nil {
t.Fatalf("decode event %q: %v", raw, err)
}
return v
}