Files
Enot (ded) Skelly 0ca28cc6a8 feat: node prefix search
pubkeyPrefix takes any even length prefix string case-insensitive
and returns all matches
2026-07-23 10:14:47 -07:00

303 lines
10 KiB
Go

// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package handlers
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
func TestGetNode_InvalidUUID(t *testing.T) {
r := chi.NewRouter()
r.Get("/nodes/{nodeId}", getNode(stubReader{}))
req := httptest.NewRequest(http.MethodGet, "/nodes/not-a-uuid", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestListNodeObservations_InvalidUUID(t *testing.T) {
r := chi.NewRouter()
r.Get("/nodes/{nodeId}/observations", listNodeObservations(stubReader{}))
req := httptest.NewRequest(http.MethodGet, "/nodes/bad/observations", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestListNodeObservations_InvalidCursor(t *testing.T) {
r := chi.NewRouter()
r.Get("/nodes/{nodeId}/observations", listNodeObservations(stubReader{}))
req := httptest.NewRequest(http.MethodGet, "/nodes/00000000-0000-0000-0000-000000000001/observations?cursor=notanint", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestListNodeObservations_InvalidLimit(t *testing.T) {
r := chi.NewRouter()
r.Get("/nodes/{nodeId}/observations", listNodeObservations(stubReader{}))
req := httptest.NewRequest(http.MethodGet, "/nodes/00000000-0000-0000-0000-000000000001/observations?limit=notanint", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestListNodes_InvalidType(t *testing.T) {
r := chi.NewRouter()
r.Get("/nodes", listNodes(stubReader{}))
req := httptest.NewRequest(http.MethodGet, "/nodes?type=notanint", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestListNodes_InvalidLimit(t *testing.T) {
r := chi.NewRouter()
r.Get("/nodes", listNodes(stubReader{}))
req := httptest.NewRequest(http.MethodGet, "/nodes?limit=notanint", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestListNodes_InvalidCursor(t *testing.T) {
r := chi.NewRouter()
r.Get("/nodes", listNodes(stubReader{}))
req := httptest.NewRequest(http.MethodGet, "/nodes?cursor=notanint", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestListNodes_InvalidPubkey(t *testing.T) {
r := chi.NewRouter()
r.Get("/nodes", listNodes(stubReader{}))
req := httptest.NewRequest(http.MethodGet, "/nodes?pubkey=nothex!!", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestListNodes_InvalidSupportsMultibytePaths(t *testing.T) {
r := chi.NewRouter()
r.Get("/nodes", listNodes(stubReader{}))
req := httptest.NewRequest(http.MethodGet, "/nodes?supportsMultibytePaths=notabool", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestListNodes_InvalidSupportsMultibyteTraces(t *testing.T) {
r := chi.NewRouter()
r.Get("/nodes", listNodes(stubReader{}))
req := httptest.NewRequest(http.MethodGet, "/nodes?supportsMultibyteTraces=notabool", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestListNodes_OK(t *testing.T) {
nodeID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
r := chi.NewRouter()
r.Get("/nodes", listNodes(stubReader{
listNodes: func(_ context.Context, _ int16, _ []string, _, _ *bool, _ []byte, _, _, _ string, _ int64, _ int32, _ bool) (api.Page[api.NodeSummary], error) {
return api.Page[api.NodeSummary]{Items: []api.NodeSummary{{ID: nodeID}}}, nil
},
}))
req := httptest.NewRequest(http.MethodGet, "/nodes", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
}
func TestListNodes_NeighborsParam_PassedThrough(t *testing.T) {
nodeID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
neighborID := uuid.MustParse("00000000-0000-0000-0000-000000000002")
var gotIncludeNeighbors bool
r := chi.NewRouter()
r.Get("/nodes", listNodes(stubReader{
listNodes: func(_ context.Context, _ int16, _ []string, _, _ *bool, _ []byte, _, _, _ string, _ int64, _ int32, includeNeighbors bool) (api.Page[api.NodeSummary], error) {
gotIncludeNeighbors = includeNeighbors
return api.Page[api.NodeSummary]{Items: []api.NodeSummary{{ID: nodeID, NeighborIDs: []uuid.UUID{neighborID}}}}, nil
},
}))
req := httptest.NewRequest(http.MethodGet, "/nodes?neighbors=true", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
if !gotIncludeNeighbors {
t.Error("expected neighbors=true query param to be passed through as includeNeighbors=true")
}
if !strings.Contains(w.Body.String(), "neighborIds") {
t.Errorf("expected response body to include neighborIds, got %s", w.Body.String())
}
}
func TestListNodes_NeighborsParam_InvalidValue(t *testing.T) {
r := chi.NewRouter()
r.Get("/nodes", listNodes(stubReader{}))
req := httptest.NewRequest(http.MethodGet, "/nodes?neighbors=notabool", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestListNodes_NeighborsParam_BareFlagMeansTrue(t *testing.T) {
var gotIncludeNeighbors bool
r := chi.NewRouter()
r.Get("/nodes", listNodes(stubReader{
listNodes: func(_ context.Context, _ int16, _ []string, _, _ *bool, _ []byte, _, _, _ string, _ int64, _ int32, includeNeighbors bool) (api.Page[api.NodeSummary], error) {
gotIncludeNeighbors = includeNeighbors
return api.Page[api.NodeSummary]{}, nil
},
}))
req := httptest.NewRequest(http.MethodGet, "/nodes?neighbors", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
if !gotIncludeNeighbors {
t.Error("expected bare ?neighbors (no value) to be treated as true")
}
}
func TestListNodes_PubkeyPrefixParam_PassedThrough(t *testing.T) {
var gotPubkeyPrefix string
r := chi.NewRouter()
r.Get("/nodes", listNodes(stubReader{
listNodes: func(_ context.Context, _ int16, _ []string, _, _ *bool, _ []byte, pubkeyPrefix, _, _ string, _ int64, _ int32, _ bool) (api.Page[api.NodeSummary], error) {
gotPubkeyPrefix = pubkeyPrefix
return api.Page[api.NodeSummary]{}, nil
},
}))
req := httptest.NewRequest(http.MethodGet, "/nodes?pubkeyPrefix=AB12", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
if gotPubkeyPrefix != "ab12" {
t.Errorf("expected pubkeyPrefix to be lowercased and passed through, got %q", gotPubkeyPrefix)
}
}
func TestListNodes_PubkeyPrefixParam_InvalidHex(t *testing.T) {
r := chi.NewRouter()
r.Get("/nodes", listNodes(stubReader{}))
req := httptest.NewRequest(http.MethodGet, "/nodes?pubkeyPrefix=zz", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestListNodes_PubkeyPrefixParam_RejectsWildcards(t *testing.T) {
// isHexString should reject ILIKE wildcard characters, since pubkeyPrefix is matched as
// raw text rather than decoded to bytes -- % or _ getting through would let a caller
// widen the match pattern beyond a literal prefix.
r := chi.NewRouter()
r.Get("/nodes", listNodes(stubReader{}))
req := httptest.NewRequest(http.MethodGet, "/nodes?pubkeyPrefix=ab%25", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestGetNode_OK(t *testing.T) {
nodeID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
r := chi.NewRouter()
r.Get("/nodes/{nodeId}", getNode(stubReader{
getNode: func(_ context.Context, id uuid.UUID) (*api.Node, error) {
return &api.Node{NodeSummary: api.NodeSummary{ID: id}}, nil
},
}))
req := httptest.NewRequest(http.MethodGet, "/nodes/"+nodeID.String(), nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
}
func TestListNodeNeighbors_OK(t *testing.T) {
nodeID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
r := chi.NewRouter()
r.Get("/nodes/{nodeId}/neighbors", listNodeNeighbors(stubReader{
getNodeNeighbors: func(_ context.Context, _ uuid.UUID) ([]api.NodeNeighbor, error) {
return []api.NodeNeighbor{{ID: nodeID}}, nil
},
}))
req := httptest.NewRequest(http.MethodGet, "/nodes/"+nodeID.String()+"/neighbors", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
}
func TestListNodeNeighbors_InvalidUUID(t *testing.T) {
r := chi.NewRouter()
r.Get("/nodes/{nodeId}/neighbors", listNodeNeighbors(stubReader{}))
req := httptest.NewRequest(http.MethodGet, "/nodes/not-a-uuid/neighbors", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestListNodeObservations_OK(t *testing.T) {
nodeID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
r := chi.NewRouter()
r.Get("/nodes/{nodeId}/observations", listNodeObservations(stubReader{
listNodeObservations: func(_ context.Context, _ uuid.UUID, _ int64, _ int32) (api.Page[api.PacketObservationSummary], error) {
return api.Page[api.PacketObservationSummary]{Items: []api.PacketObservationSummary{{ID: 1}}}, nil
},
}))
req := httptest.NewRequest(http.MethodGet, "/nodes/"+nodeID.String()+"/observations", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
}