Files
meshcore-analyzer/cmd/server/openapi_diagnostics_test.go
n30nex 041daecd1e docs(api): describe performance and clock diagnostics (#2303)
Partial follow-up to #1670.

Six existing read-only diagnostics endpoints lacked usable OpenAPI
descriptions: process I/O, SQLite metrics, ingestor write counters, and
fleet/node/observer clock skew. This documents their parameters,
empty/error behavior and response root shapes. The known-gap list falls
from 24 to 18.

Descriptions preserve current behavior, including unknown/empty area
fallback, the PacketStore cache ratio, stale write-counter snapshots,
and the single-node 404. Schemas remain minimal; the existing generator
still declares only the generic 200 response, with 404 explained in
prose.

No runtime handler, authentication, configuration or customizer changes.
No added generic response maps.

Validation: regression tests exercise the served spec and real router,
including missing/malformed data, area selection and fallback, and
calibration responses. Full server and ingestor tests pass locally;
post-push Chromium verifies the served spec, six routes and Swagger
rendering. Three independent reviews passed.

Test-only RED `218a3bf3` produced the [six expected documentation
failures](https://github.com/Kpa-clawbot/CoreScope/actions/runs/40212159661/job/121225534416).
GREEN `1e2d8c08` adds the documentation and [passes
CI](https://github.com/Kpa-clawbot/CoreScope/actions/runs/40212678062),
including all browser shards and architecture builds.
2026-10-26 15:41:05 +01:00

272 lines
11 KiB
Go

package main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gorilla/mux"
)
// Partial #1670 backfill: pin the served spec, not only the metadata map.
func TestOpenAPIDiagnosticsDocumentation(t *testing.T) {
spec := fetchSpec(t)
paths := asMap(t, spec["paths"], "paths")
schemas := asMap(t, asMap(t, spec["components"], "components")["schemas"], "schemas")
gaps := loadKnownGaps(t)
for _, tc := range []struct {
path, tag, shape string
terms []string
params map[string]string
}{
{"/api/perf/io", "admin", "object", []string{"/proc/self/io", "ingestor"}, nil},
{"/api/perf/sqlite", "admin", "object", []string{"cacheHitRate", "PacketStore", "zero"}, nil},
{"/api/perf/write-sources", "admin", "object", []string{"sources", "sampleAt", "writer_perf", "unreadable"}, nil},
{"/api/nodes/clock-skew", "nodes", "array", []string{"samples", "recentHashEvidence", "calibrationSummary"}, map[string]string{"query:area": "string"}},
{"/api/nodes/{pubkey}/clock-skew", "nodes", "object", []string{"pubkey", "404"}, map[string]string{"path:pubkey": "string"}},
{"/api/observers/clock-skew", "observers", "array", []string{"observerID", "offsetSec", "samples"}, nil},
} {
t.Run(tc.path, func(t *testing.T) {
path := asMap(t, paths[tc.path], tc.path)
if len(path) != 1 {
t.Errorf("expected only GET, got %v", path)
}
op := asMap(t, path["get"], "get")
if summary, _ := op["summary"].(string); summary == "" || summary == tc.path {
t.Errorf("expected a descriptive summary, got %q", summary)
}
desc, _ := op["description"].(string)
for _, term := range tc.terms {
if !strings.Contains(desc, term) {
t.Errorf("description should explain %q, got %q", term, desc)
}
}
tags, _ := op["tags"].([]interface{})
if len(tags) != 1 || tags[0] != tc.tag {
t.Errorf("expected tag %q, got %v", tc.tag, tags)
}
if _, protected := op["security"]; protected {
t.Error("read-only diagnostics must not advertise required credentials")
}
params, _ := op["parameters"].([]interface{})
if len(params) != len(tc.params) {
t.Errorf("expected %d parameters, got %v", len(tc.params), params)
}
seen := make(map[string]bool)
for _, raw := range params {
p := asMap(t, raw, "parameter")
key := fmt.Sprint(p["in"], ":", p["name"])
wantType, exists := tc.params[key]
if !exists || seen[key] {
t.Errorf("unexpected or repeated parameter %q", key)
}
seen[key] = true
if p["required"] != (p["in"] == "path") || asMap(t, p["schema"], "parameter schema")["type"] != wantType {
t.Errorf("wrong required/type for %q: %v", key, p)
}
if key == "query:area" {
areaDesc, _ := p["description"].(string)
for _, term := range []string{"unknown", "empty", "unfiltered"} {
if !strings.Contains(areaDesc, term) {
t.Errorf("area description must explain %q: %q", term, areaDesc)
}
}
}
}
response := asMap(t, asMap(t, op["responses"], "responses")["200"], "200")
media := asMap(t, asMap(t, response["content"], "content")["application/json"], "JSON")
schema := asMap(t, media["schema"], "response schema")
if ref, ok := schema["$ref"].(string); ok {
schema = asMap(t, schemas[strings.TrimPrefix(ref, "#/components/schemas/")], ref)
}
if schema["type"] != tc.shape {
t.Errorf("expected %s response, got %v", tc.shape, schema)
}
if tc.shape == "array" && schema["type"] == "array" && asMap(t, schema["items"], "items")["type"] != "object" {
t.Error("diagnostics array items must be objects")
}
if gaps[tc.path] {
t.Error("documented route must be removed from the known-gap list")
}
})
}
}
// Configuring an admin key must not protect these GETs. Missing data is a
// successful empty diagnostic response, except the single-node lookup (404).
func TestDiagnosticsRoutesWithoutData(t *testing.T) {
t.Setenv("CORESCOPE_INGESTOR_STATS", filepath.Join(t.TempDir(), "missing.json"))
s := NewServer(nil, &Config{APIKey: "test-diagnostics-key"}, NewHub())
router := mux.NewRouter()
s.RegisterRoutes(router)
for _, tc := range []struct {
path string
status int
fields []string
}{
{"/api/perf/io", 200, []string{"readBytesPerSec", "writeBytesPerSec", "cancelledWriteBytesPerSec", "syscallsRead", "syscallsWrite"}},
{"/api/perf/sqlite", 200, []string{"walSize", "walSizeMB", "pageCount", "pageSize", "cacheSize", "cacheHitRate"}},
{"/api/perf/write-sources", 200, []string{"sources", "sampleAt"}},
{"/api/nodes/clock-skew", 200, nil},
{"/api/nodes/missing/clock-skew", 404, []string{"error"}},
{"/api/observers/clock-skew", 200, nil},
} {
t.Run(tc.path, func(t *testing.T) {
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, tc.path, nil))
if w.Code != tc.status {
t.Fatalf("status = %d, want %d: %s", w.Code, tc.status, w.Body.String())
}
if tc.fields == nil {
if strings.TrimSpace(w.Body.String()) != "[]" {
t.Errorf("expected empty array, got %s", w.Body.String())
}
return
}
var body map[string]json.RawMessage
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if len(body) != len(tc.fields) {
t.Errorf("unexpected fields: %s", w.Body.String())
}
for _, field := range tc.fields {
if _, ok := body[field]; !ok {
t.Errorf("missing field %q", field)
}
}
if tc.path == "/api/perf/sqlite" {
var got PerfSqliteResponse
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil || got != (PerfSqliteResponse{}) {
t.Errorf("unavailable database should report zero metrics: %s", w.Body.String())
}
}
if tc.path == "/api/perf/write-sources" && (string(body["sources"]) != "{}" || string(body["sampleAt"]) != `""`) {
t.Errorf("missing file should report empty sources and timestamp: %s", w.Body.String())
}
})
}
}
func TestDiagnosticsClockSkewRoutes(t *testing.T) {
db := setupTestDBv2(t)
cfg := &Config{APIKey: "test-diagnostics-key", Areas: map[string]AreaEntry{
"inside": {Polygon: [][2]float64{{0, 0}, {0, 2}, {2, 2}, {2, 0}}},
"empty": {Polygon: [][2]float64{{30, 30}, {30, 32}, {32, 32}, {32, 30}}},
}}
mustExecDB(t, db, `INSERT INTO nodes (public_key, name, role, lat, lon) VALUES ('aabb', 'Inside', 'repeater', 1, 1), ('ccdd', 'Outside', 'repeater', 10, 10)`)
s := NewServer(db, cfg, NewHub())
s.store = NewPacketStore(db, nil)
s.store.config = cfg
pt := PayloadADVERT
for _, pubkey := range []string{"aabb", "ccdd"} {
tx := &StoreTx{Hash: pubkey, PayloadType: &pt,
DecodedJSON: fmt.Sprintf(`{"payload":{"timestamp":1700002320},"pubKey":%q}`, pubkey),
Observations: []*StoreObs{
{ObserverID: "observer-a", Timestamp: "2023-11-14T22:50:00Z"},
{ObserverID: "observer-b", Timestamp: "2023-11-14T22:50:20Z"},
},
}
s.store.byNode[pubkey] = []*StoreTx{tx}
s.store.byPayloadType[pt] = append(s.store.byPayloadType[pt], tx)
}
router := mux.NewRouter()
s.RegisterRoutes(router)
for _, tc := range []struct {
query string
count int
}{{"", 2}, {"?area=inside", 1}, {"?area=unknown", 2}, {"?area=empty", 2}} {
t.Run("fleet"+tc.query, func(t *testing.T) {
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/nodes/clock-skew"+tc.query, nil))
var rows []NodeClockSkew
if w.Code != http.StatusOK || json.Unmarshal(w.Body.Bytes(), &rows) != nil || len(rows) != tc.count {
t.Fatalf("fleet status/count = %d/%d, want 200/%d: %s", w.Code, len(rows), tc.count, w.Body.String())
}
if tc.count == 1 && rows[0].Pubkey != "aabb" {
t.Errorf("area should select the inside node, got %q", rows[0].Pubkey)
}
for _, row := range rows {
if row.NodeName == "" || row.NodeRole != "repeater" || row.Samples != nil || row.RecentHashEvidence != nil || row.CalibrationSummary != nil {
t.Errorf("fleet should enrich identity and omit detail evidence: %+v", row)
}
}
})
}
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/nodes/aabb/clock-skew", nil))
var node NodeClockSkew
if w.Code != http.StatusOK || json.Unmarshal(w.Body.Bytes(), &node) != nil || node.Pubkey != "aabb" || len(node.Samples) == 0 || len(node.RecentHashEvidence) == 0 || node.CalibrationSummary == nil {
t.Fatalf("node detail must include clock evidence: %d %s", w.Code, w.Body.String())
}
w = httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/nodes/missing/clock-skew", nil))
if w.Code != http.StatusNotFound {
t.Errorf("missing node status = %d, want 404", w.Code)
}
w = httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/observers/clock-skew", nil))
var observers []ObserverCalibration
if w.Code != http.StatusOK || json.Unmarshal(w.Body.Bytes(), &observers) != nil || len(observers) != 2 {
t.Fatalf("observer calibrations must be an array: %d %s", w.Code, w.Body.String())
}
for _, observer := range observers {
if observer.Samples != 2 ||
(observer.ObserverID != "observer-a" && observer.ObserverID != "observer-b") ||
(observer.ObserverID == "observer-a" && observer.OffsetSec >= 0) ||
(observer.ObserverID == "observer-b" && observer.OffsetSec <= 0) {
t.Errorf("unexpected observer calibration: %+v", observer)
}
}
// SQLite reports the in-process row cache hit ratio, not a SQLite cache counter.
s.store.cacheHits, s.store.cacheMisses = 3, 1
w = httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/perf/sqlite", nil))
var sqlite PerfSqliteResponse
if w.Code != http.StatusOK || json.Unmarshal(w.Body.Bytes(), &sqlite) != nil || sqlite.CacheHitRate != 0.75 || sqlite.PageSize <= 0 {
t.Errorf("expected SQLite page stats and PacketStore cache ratio: %d %s", w.Code, w.Body.String())
}
}
func TestDiagnosticsWriteSourcesFileResponses(t *testing.T) {
path := filepath.Join(t.TempDir(), "ingestor-stats.json")
t.Setenv("CORESCOPE_INGESTOR_STATS", path)
s := NewServer(nil, &Config{APIKey: "test-diagnostics-key"}, NewHub())
router := mux.NewRouter()
s.RegisterRoutes(router)
for _, tc := range []struct {
name, file, sampleAt string
wantSources int
wantWriterPerf bool
}{
{"malformed", "{invalid", "", 0, false},
{"published", `{"sampledAt":"2023-01-01T00:00:00Z","tx_inserted":7,"backfillUpdates":{"example":3}}`, "2023-01-01T00:00:00Z", 10, true},
} {
t.Run(tc.name, func(t *testing.T) {
if err := os.WriteFile(path, []byte(tc.file), 0o600); err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/perf/write-sources", nil))
var body struct {
Sources map[string]int64 `json:"sources"`
SampleAt string `json:"sampleAt"`
WriterPerf map[string]WriterStatsSnapshot `json:"writer_perf"`
}
if w.Code != http.StatusOK || json.Unmarshal(w.Body.Bytes(), &body) != nil || len(body.Sources) != tc.wantSources || body.SampleAt != tc.sampleAt || (body.WriterPerf != nil) != tc.wantWriterPerf {
t.Fatalf("unexpected write-source response: %d %s", w.Code, w.Body.String())
}
if tc.name == "published" && (body.Sources["tx_inserted"] != 7 || body.Sources["backfill_example"] != 3) {
t.Errorf("published counters missing: %+v", body.Sources)
}
})
}
}