Reticulum-Go/pkg/controlapi/parity_test.go
Ivan 5818dc8ba5
refactor: clear revive naming and control-flow warnings
Rename builtin shadows and case-colliding helpers, and tidy return/context/empty-block rules so lint is only add-constant noise.
2026-08-13 10:10:04 -05:00

376 lines
13 KiB
Go

// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2024-2026 Quad4.io
package controlapi
import (
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// waitEventType reads WS events until one matches typ or timeout.
func waitEventType(t testing.TB, ws *testWSClient, typ string, timeout time.Duration) map[string]any {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
remain := time.Until(deadline)
if remain <= 0 {
break
}
raw := ws.recvText(t, remain)
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatalf("decode event %q: %v", raw, err)
}
if got, _ := m["type"].(string); got == typ {
return m
}
}
t.Fatalf("timed out waiting for event type %q", typ)
return nil
}
func setupLinkedSessions(t *testing.T) (
srvA, srvB *Server,
tsA, tsB *httptest.Server,
authA, authB, sessionIDA, sessionIDB, destHashA string,
wsA, wsB *testWSClient,
) {
t.Helper()
srvA, trA, keyA := newLinkTestServer(t)
srvB, trBReal, keyB := newLinkTestServer(t)
pipeA := newPipeInterface("pipeA")
pipeB := newPipeInterface("pipeB")
pipeA.peer = pipeB
pipeB.peer = pipeA
pipeA.tr = trA
pipeB.tr = trBReal
if err := trA.RegisterInterface("pipeA", pipeA); err != nil {
t.Fatalf("register pipeA: %v", err)
}
if err := trBReal.RegisterInterface("pipeB", pipeB); err != nil {
t.Fatalf("register pipeB: %v", err)
}
_ = trA.InitializePathRequestHandler()
tsA = httptest.NewServer(srvA.httpServer.Handler)
t.Cleanup(tsA.Close)
tsB = httptest.NewServer(srvB.httpServer.Handler)
t.Cleanup(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_parity",
"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)
wsA = dialControlAPIWS(t, tsA.URL, fmt.Sprintf("/v1/sessions/%s/events", sessionIDA), authA)
t.Cleanup(func() { _ = 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 !trBReal.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)
t.Cleanup(func() { _ = wsB.conn.Close() })
return srvA, srvB, tsA, tsB, authA, authB, sessionIDA, sessionIDB, destHashA, wsA, wsB
}
func establishLinkAB(t *testing.T, wsA, wsB *testWSClient, destHashA string) (linkIDA, linkIDB string) {
t.Helper()
wsB.sendText(t, fmt.Appendf(nil, `{"type":"link.open","destination_hash":%q}`, destHashA))
establishedB := decodeEvent[linkEstablishedEvent](t, wsB, 5*time.Second)
establishedA := decodeEvent[linkEstablishedEvent](t, wsA, 5*time.Second)
if establishedB.LinkID == "" || establishedA.LinkID == "" {
t.Fatalf("links not established A=%+v B=%+v", establishedA, establishedB)
}
return establishedA.LinkID, establishedB.LinkID
}
func TestOutboundLinkRequest(t *testing.T) {
_, _, tsA, _, authA, _, sessionIDA, _, destHashA, wsA, wsB := setupLinkedSessions(t)
resp, _ := doJSON(t, http.MethodPost, fmt.Sprintf("%s/v1/sessions/%s/destinations/%s/requests", tsA.URL, sessionIDA, destHashA), authA, map[string]any{
"path": "/echo",
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("register handler status = %d", resp.StatusCode)
}
_, linkIDB := establishLinkAB(t, wsA, wsB, destHashA)
wsB.sendText(t, fmt.Appendf(nil, `{"type":"link.request","link_id":%q,"path":"/echo","data":%q,"timeout_ms":5000}`,
linkIDB, base64.StdEncoding.EncodeToString([]byte("ping"))))
incoming := waitEventType(t, wsA, "request.incoming", 5*time.Second)
wsA.sendText(t, fmt.Appendf(nil, `{"type":"request.respond","request_id":%q,"data":%q}`,
incoming["request_id"], base64.StdEncoding.EncodeToString([]byte("pong"))))
out := waitEventType(t, wsB, "request.response", 5*time.Second)
data, err := base64.StdEncoding.DecodeString(out["data"].(string))
if err != nil || string(data) != "pong" {
t.Fatalf("request.response data = %#v", out["data"])
}
}
func TestOutboundLinkRequestErrors(t *testing.T) {
_, _, _, _, _, _, _, _, destHashA, wsA, wsB := setupLinkedSessions(t)
_, linkIDB := establishLinkAB(t, wsA, wsB, destHashA)
wsB.sendText(t, []byte(`{"type":"link.request","link_id":"deadbeef","path":"/x"}`))
errEvt := waitEventType(t, wsB, "command.error", 2*time.Second)
if errEvt["command"] != "link.request" {
t.Fatalf("command.error = %#v", errEvt)
}
wsB.sendText(t, fmt.Appendf(nil, `{"type":"link.request","link_id":%q,"path":""}`, linkIDB))
errEvt = waitEventType(t, wsB, "command.error", 2*time.Second)
if errEvt["error"] == nil {
t.Fatal("expected error for empty path")
}
wsB.sendText(t, fmt.Appendf(nil, `{"type":"link.request","link_id":%q,"path":"/x","data":%q}`, linkIDB, "%%%"))
errEvt = waitEventType(t, wsB, "command.error", 2*time.Second)
if errEvt["command"] != "link.request" {
t.Fatalf("bad base64 command.error = %#v", errEvt)
}
}
func TestFileRespond(t *testing.T) {
_, _, tsA, _, authA, _, sessionIDA, _, destHashA, wsA, wsB := setupLinkedSessions(t)
resp, _ := doJSON(t, http.MethodPost, fmt.Sprintf("%s/v1/sessions/%s/destinations/%s/requests", tsA.URL, sessionIDA, destHashA), authA, map[string]any{
"path": "/file",
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("register handler status = %d", resp.StatusCode)
}
_, linkIDB := establishLinkAB(t, wsA, wsB, destHashA)
wsB.sendText(t, fmt.Appendf(nil, `{"type":"link.request","link_id":%q,"path":"/file","timeout_ms":5000}`, linkIDB))
incoming := waitEventType(t, wsA, "request.incoming", 5*time.Second)
wsA.sendText(t, fmt.Appendf(nil, `{"type":"request.respond","request_id":%q,"filename":"hi.txt","data":%q}`,
incoming["request_id"], base64.StdEncoding.EncodeToString([]byte("content"))))
out := waitEventType(t, wsB, "request.response", 5*time.Second)
if out["data"] == nil {
t.Fatalf("expected response data, got %#v", out)
}
}
func TestRequestRespondUnknownID(t *testing.T) {
_, _, _, _, _, _, _, _, destHashA, wsA, wsB := setupLinkedSessions(t)
establishLinkAB(t, wsA, wsB, destHashA)
wsA.sendText(t, []byte(`{"type":"request.respond","request_id":"00","data":""}`))
errEvt := waitEventType(t, wsA, "command.error", 2*time.Second)
if errEvt["command"] != "request.respond" {
t.Fatalf("command.error = %#v", errEvt)
}
}
func TestLinkSendResource(t *testing.T) {
_, _, _, _, _, _, _, _, destHashA, wsA, wsB := setupLinkedSessions(t)
_, linkIDB := establishLinkAB(t, wsA, wsB, destHashA)
payload := base64.StdEncoding.EncodeToString([]byte("resource-bytes"))
wsB.sendText(t, fmt.Appendf(nil, `{"type":"link.send_resource","link_id":%q,"data":%q,"name":"note.txt"}`,
linkIDB, payload))
_ = waitEventType(t, wsA, "resource.started", 10*time.Second)
concluded := waitEventType(t, wsA, "resource.concluded", 15*time.Second)
if success, _ := concluded["success"].(bool); !success {
t.Fatalf("resource.concluded = %#v", concluded)
}
if name, _ := concluded["name"].(string); name != "note.txt" {
t.Fatalf("resource name = %q, want note.txt", name)
}
}
func TestLinkSendResourceUnknownLink(t *testing.T) {
_, _, _, _, _, _, _, _, destHashA, wsA, wsB := setupLinkedSessions(t)
establishLinkAB(t, wsA, wsB, destHashA)
wsB.sendText(t, []byte(`{"type":"link.send_resource","link_id":"00","data":"YQ=="}`))
errEvt := waitEventType(t, wsB, "command.error", 2*time.Second)
if errEvt["command"] != "link.send_resource" {
t.Fatalf("command.error = %#v", errEvt)
}
}
func TestLinkIdentify(t *testing.T) {
_, srvB, _, _, _, _, _, sessionIDB, destHashA, wsA, wsB := setupLinkedSessions(t)
linkIDA, linkIDB := establishLinkAB(t, wsA, wsB, destHashA)
wsB.sendText(t, fmt.Appendf(nil, `{"type":"link.identify","link_id":%q}`, linkIDB))
identified := waitEventType(t, wsA, "link.remote_identified", 5*time.Second)
if identified["link_id"] != linkIDA {
t.Fatalf("remote_identified link_id = %#v want %s", identified["link_id"], linkIDA)
}
sessB, ok := srvB.session(sessionIDB)
if !ok {
t.Fatal("session B missing")
}
wantHash := sessB.identity.GetHexHash()
if identified["identity_hash"] != wantHash {
t.Fatalf("identity_hash = %#v want %s", identified["identity_hash"], wantHash)
}
wsB.sendText(t, []byte(`{"type":"link.identify","link_id":"00"}`))
errEvt := waitEventType(t, wsB, "command.error", 2*time.Second)
if errEvt["command"] != "link.identify" {
t.Fatalf("command.error = %#v", errEvt)
}
}
func TestAnnounceFilter(t *testing.T) {
srv, key := newTestServer(t)
ts := httptest.NewServer(srv.httpServer.Handler)
defer ts.Close()
authKey := hex.EncodeToString(key)
_, sess := doJSON(t, http.MethodPost, ts.URL+"/v1/sessions", authKey, map[string]any{})
sessionID, _ := sess["session_id"].(string)
ws := dialControlAPIWS(t, ts.URL, fmt.Sprintf("/v1/sessions/%s/events", sessionID), authKey)
defer ws.conn.Close()
match := hex.EncodeToString(bytesRepeat(0x11, 16))
other := hex.EncodeToString(bytesRepeat(0x22, 16))
ws.sendText(t, []byte(`{"type":"subscribe_announces","filter":"not-hex"}`))
errEvt := waitEventType(t, ws, "command.error", 2*time.Second)
if errEvt["command"] != "subscribe_announces" {
t.Fatalf("command.error = %#v", errEvt)
}
ws.sendText(t, fmt.Appendf(nil, `{"type":"subscribe_announces","filter":%q}`, match))
time.Sleep(50 * time.Millisecond)
srv.broadcastAnnounce(announceEvent{Type: "announce", DestinationHash: other, Hops: 1})
srv.broadcastAnnounce(announceEvent{Type: "announce", DestinationHash: match, Hops: 2})
evt := waitEventType(t, ws, "announce", 2*time.Second)
if evt["destination_hash"] != match {
t.Fatalf("announce = %#v, want only matching hash", evt)
}
}
func TestDeregisterRequestHandler(t *testing.T) {
srv, key := newTestServer(t)
ts := httptest.NewServer(srv.httpServer.Handler)
defer ts.Close()
authKey := hex.EncodeToString(key)
_, sess := doJSON(t, http.MethodPost, ts.URL+"/v1/sessions", authKey, map[string]any{})
sessionID, _ := sess["session_id"].(string)
_, dest := doJSON(t, http.MethodPost, fmt.Sprintf("%s/v1/sessions/%s/destinations", ts.URL, sessionID), authKey, map[string]any{"app_name": "dereg"})
destHash, _ := dest["destination_hash"].(string)
base := fmt.Sprintf("%s/v1/sessions/%s/destinations/%s/requests", ts.URL, sessionID, destHash)
resp, _ := doJSON(t, http.MethodPost, base, authKey, map[string]any{"path": "/ping"})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("register status = %d", resp.StatusCode)
}
req, err := http.NewRequest(http.MethodDelete, base+"?path=/ping", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+authKey)
delResp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
delResp.Body.Close()
if delResp.StatusCode != http.StatusNoContent {
t.Fatalf("DELETE status = %d", delResp.StatusCode)
}
req, _ = http.NewRequest(http.MethodDelete, base+"?path=/ping", nil)
req.Header.Set("Authorization", "Bearer "+authKey)
delResp, err = http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
delResp.Body.Close()
if delResp.StatusCode != http.StatusNotFound {
t.Fatalf("second DELETE status = %d, want 404", delResp.StatusCode)
}
resp, _ = doJSON(t, http.MethodPost, base, authKey, map[string]any{"path": "/ping"})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("re-register status = %d", resp.StatusCode)
}
_ = srv
}
func TestCommandErrorUnknownAndMalformed(t *testing.T) {
srv, key := newTestServer(t)
ts := httptest.NewServer(srv.httpServer.Handler)
defer ts.Close()
authKey := hex.EncodeToString(key)
_, sess := doJSON(t, http.MethodPost, ts.URL+"/v1/sessions", authKey, map[string]any{})
sessionID, _ := sess["session_id"].(string)
ws := dialControlAPIWS(t, ts.URL, fmt.Sprintf("/v1/sessions/%s/events", sessionID), authKey)
defer ws.conn.Close()
ws.sendText(t, []byte(`{not-json`))
errEvt := waitEventType(t, ws, "command.error", 2*time.Second)
if errEvt["error"] == nil {
t.Fatal("expected malformed json error")
}
ws.sendText(t, []byte(`{"type":"no.such.command"}`))
errEvt = waitEventType(t, ws, "command.error", 2*time.Second)
if errEvt["command"] != "no.such.command" {
t.Fatalf("command.error = %#v", errEvt)
}
ws.sendText(t, []byte(`{"type":"link.send","link_id":"00","data":"YQ=="}`))
errEvt = waitEventType(t, ws, "command.error", 2*time.Second)
if errEvt["command"] != "link.send" {
t.Fatalf("link.send error = %#v", errEvt)
}
}
func bytesRepeat(b byte, n int) []byte {
out := make([]byte, n)
for i := range out {
out[i] = b
}
return out
}