mirror of
https://github.com/Quad4-Software/Reticulum-Go
synced 2026-08-29 23:48:44 -04:00
Rename builtin shadows and case-colliding helpers, and tidy return/context/empty-block rules so lint is only add-constant noise.
376 lines
13 KiB
Go
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
|
|
}
|