mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-11-11 12:37:33 +00:00
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.
272 lines
11 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|