Files

555 lines
20 KiB
Go

package server_test
import (
"context"
"math"
"testing"
"time"
basetests "github.com/hanzoai/base/tests"
luxlog "github.com/luxfi/log"
zaplib "github.com/luxfi/zap"
zcap "github.com/zap-proto/go/cap"
gen "github.com/hanzoai/dashboards/gen"
"github.com/hanzoai/dashboards/server"
)
const testProject = "proj-test"
// portSeq hands out unique ports per sub-service so parallel-ish tests on the
// same machine never collide on a listener. Base port is high + uncommon.
var portSeq = 19700
func nextPort() int { portSeq++; return portSeq }
// newService spins up a Base test app with the five collections provisioned, a
// ZAP router node listening, and returns the listen address, node id, cleanup.
func newService(t *testing.T) (addr, peerID string, cleanup func()) {
t.Helper()
app, err := basetests.NewTestApp()
if err != nil {
t.Fatalf("new test app: %v", err)
}
if err := server.EnsureCollections(app); err != nil {
t.Fatalf("ensure collections: %v", err)
}
port := nextPort()
id := "dash-test-srv-" + itoa(port)
node := zaplib.NewNode(zaplib.NodeConfig{NodeID: id, Port: port, NoDiscovery: true})
srv := server.NewServer(app, luxlog.New("component", "dash-test"), zcap.Verifier{})
srv.Register(node)
if err := node.Start(); err != nil {
t.Fatalf("node start: %v", err)
}
return "127.0.0.1:" + itoa(port), id, func() {
node.Stop()
app.Cleanup()
}
}
// newClient dials the service with a synthetic CapKindIAMSession cap holding the
// given permissions.
func newClient(t *testing.T, addr, peerID string, perms uint64) (*server.Client, func()) {
t.Helper()
capBuf, err := server.SyntheticCap(perms)
if err != nil {
t.Fatalf("synthetic cap: %v", err)
}
port := nextPort()
cli := zaplib.NewNode(zaplib.NodeConfig{NodeID: "dash-test-cli-" + itoa(port), Port: port, NoDiscovery: true})
if err := cli.Start(); err != nil {
t.Fatalf("client node start: %v", err)
}
c, err := server.Dial(cli, addr, peerID, capBuf)
if err != nil {
t.Fatalf("dial: %v", err)
}
time.Sleep(150 * time.Millisecond) // handshake settle
return c, func() { cli.Stop() }
}
// fullClient is a fully-authorized client (all DashPermissions bits).
func fullClient(t *testing.T, addr, peerID string) (*server.Client, func()) {
return newClient(t, addr, peerID, server.AllDashPermissions)
}
func ctx5(t *testing.T) (context.Context, func()) {
return context.WithTimeout(context.Background(), 5*time.Second)
}
// ─────────────────────────────── dashboardRouter ───────────────────────────
func TestDashboardCRUD(t *testing.T) {
addr, peer, stop := newService(t)
defer stop()
c, stopC := fullClient(t, addr, peer)
defer stopC()
ctx, cancel := ctx5(t)
defer cancel()
// Create
d, err := c.CreateDashboard(ctx, gen.CreateDashReqInput{ProjectId: testProject, Name: "Ops", Description: "ops board"})
if err != nil {
t.Fatalf("CreateDashboard: %v", err)
}
if d.Id() == "" || d.Name() != "Ops" || d.Owner() != "PROJECT" {
t.Fatalf("unexpected created dashboard: id=%q name=%q owner=%q", d.Id(), d.Name(), d.Owner())
}
// Get
got, err := c.GetDashboard(ctx, gen.IdReqInput{ProjectId: testProject, Id: d.Id()})
if err != nil {
t.Fatalf("GetDashboard: %v", err)
}
if got.Name() != "Ops" {
t.Fatalf("GetDashboard name = %q, want Ops", got.Name())
}
// Update metadata
if _, err := c.UpdateDashboardMetadata(ctx, gen.UpdateDashReqInput{
ProjectId: testProject, DashboardId: d.Id(), Name: "Ops v2", Description: "renamed",
}); err != nil {
t.Fatalf("UpdateDashboardMetadata: %v", err)
}
// Update definition + filters (JSON blobs round-trip verbatim)
const def = `{"widgets":[{"type":"widget","id":"w1","widgetId":"x","x":0,"y":0,"x_size":3,"y_size":3}]}`
dd, err := c.UpdateDashboardDefinition(ctx, gen.DashDefReqInput{ProjectId: testProject, DashboardId: d.Id(), Definition: def})
if err != nil {
t.Fatalf("UpdateDashboardDefinition: %v", err)
}
if dd.Definition() != def {
t.Fatalf("definition not round-tripped:\n got %q\nwant %q", dd.Definition(), def)
}
const filt = `[{"column":"name","operator":"=","value":"x","type":"string"}]`
df, err := c.UpdateDashboardFilters(ctx, gen.DashFiltersReqInput{ProjectId: testProject, DashboardId: d.Id(), Filters: filt})
if err != nil {
t.Fatalf("UpdateDashboardFilters: %v", err)
}
if df.Filters() != filt {
t.Fatalf("filters not round-tripped: got %q", df.Filters())
}
// Clone
cl, err := c.CloneDashboard(ctx, gen.IdReqInput{ProjectId: testProject, Id: d.Id()})
if err != nil {
t.Fatalf("CloneDashboard: %v", err)
}
if cl.Name() != "Ops v2 (Clone)" {
t.Fatalf("clone name = %q, want 'Ops v2 (Clone)'", cl.Name())
}
if cl.Definition() != def {
t.Fatalf("clone did not copy definition")
}
// List: original + clone = 2
list, err := c.AllDashboards(ctx, gen.ListReqInput{ProjectId: testProject, Page: 1, Limit: 50})
if err != nil {
t.Fatalf("AllDashboards: %v", err)
}
if list.TotalCount() != 2 || list.Dashboards().Len() != 2 {
t.Fatalf("AllDashboards total=%d len=%d, want 2/2", list.TotalCount(), list.Dashboards().Len())
}
// Delete the clone
del, err := c.DeleteDashboard(ctx, gen.IdReqInput{ProjectId: testProject, Id: cl.Id()})
if err != nil {
t.Fatalf("DeleteDashboard: %v", err)
}
if !del.Success() {
t.Fatalf("delete success=false")
}
if _, err := c.GetDashboard(ctx, gen.IdReqInput{ProjectId: testProject, Id: cl.Id()}); err == nil {
t.Fatalf("expected deleted dashboard to be gone")
}
t.Logf("dashboard CRUD OK: created, got, updated, cloned, listed(2), deleted")
}
// ──────────────────────────── dashboardWidgetRouter ────────────────────────
func TestWidgetCRUD(t *testing.T) {
addr, peer, stop := newService(t)
defer stop()
c, stopC := fullClient(t, addr, peer)
defer stopC()
ctx, cancel := ctx5(t)
defer cancel()
w, err := c.CreateWidget(ctx, gen.WidgetReqInput{
ProjectId: testProject, Name: "Latency", Description: "p95", View: "observations",
Dimensions: `[{"field":"model"}]`, Metrics: `[{"measure":"latency","agg":"p95"}]`,
Filters: `[]`, ChartType: "LINE_TIME_SERIES", ChartConfig: `{"type":"LINE_TIME_SERIES"}`,
})
if err != nil {
t.Fatalf("CreateWidget: %v", err)
}
if w.Id() == "" || w.View() != "observations" || w.Owner() != "PROJECT" {
t.Fatalf("unexpected widget: %+v", w)
}
got, err := c.GetWidget(ctx, gen.IdReqInput{ProjectId: testProject, Id: w.Id()})
if err != nil {
t.Fatalf("GetWidget: %v", err)
}
if got.Metrics() != `[{"measure":"latency","agg":"p95"}]` {
t.Fatalf("widget metrics not round-tripped: %q", got.Metrics())
}
upd, err := c.UpdateWidget(ctx, gen.WidgetReqInput{
ProjectId: testProject, WidgetId: w.Id(), Name: "Latency p99", Description: "p99",
View: "observations", Dimensions: `[]`, Metrics: `[{"measure":"latency","agg":"p99"}]`,
Filters: `[]`, ChartType: "BAR_TIME_SERIES", ChartConfig: `{"type":"BAR_TIME_SERIES"}`,
})
if err != nil {
t.Fatalf("UpdateWidget: %v", err)
}
if upd.Name() != "Latency p99" || upd.ChartType() != "BAR_TIME_SERIES" {
t.Fatalf("widget update not applied: %+v", upd)
}
cp, err := c.CopyWidgetToProject(ctx, gen.CopyWidgetReqInput{
ProjectId: testProject, WidgetId: w.Id(), DashboardId: "d1", PlacementId: "p1",
})
if err != nil {
t.Fatalf("CopyWidgetToProject: %v", err)
}
if !cp.Success() || cp.Id() == "" {
t.Fatalf("copy widget failed: %+v", cp)
}
list, err := c.AllWidgets(ctx, gen.ListReqInput{ProjectId: testProject, Page: 1, Limit: 50})
if err != nil {
t.Fatalf("AllWidgets: %v", err)
}
if list.TotalCount() != 2 {
t.Fatalf("AllWidgets total=%d, want 2 (original + copy)", list.TotalCount())
}
if _, err := c.DeleteWidget(ctx, gen.IdReqInput{ProjectId: testProject, Id: w.Id()}); err != nil {
t.Fatalf("DeleteWidget: %v", err)
}
t.Logf("widget CRUD OK: created, got, updated, copied, listed(2), deleted")
}
// ────────────────────────── TableViewPresetsRouter ─────────────────────────
func TestPresetCRUD(t *testing.T) {
addr, peer, stop := newService(t)
defer stop()
c, stopC := fullClient(t, addr, peer)
defer stopC()
ctx, cancel := ctx5(t)
defer cancel()
p, err := c.CreatePreset(ctx, gen.PresetReqInput{
ProjectId: testProject, Name: "My traces", TableName: "traces",
Filters: `[]`, ColumnOrder: `["id","name"]`, ColumnVisibility: `{"id":true}`,
SearchQuery: "err", OrderByColumn: "timestamp", OrderByOrder: "DESC",
})
if err != nil {
t.Fatalf("CreatePreset: %v", err)
}
if p.Id() == "" || p.TableName() != "traces" || p.ColumnOrder() != `["id","name"]` {
t.Fatalf("unexpected preset: %+v", p)
}
// Duplicate (projectId, tableName, name) → conflict.
if _, err := c.CreatePreset(ctx, gen.PresetReqInput{
ProjectId: testProject, Name: "My traces", TableName: "traces",
Filters: `[]`, ColumnOrder: `[]`, ColumnVisibility: `{}`,
}); err == nil {
t.Fatalf("expected duplicate preset to conflict")
}
got, err := c.GetPresetById(ctx, gen.IdReqInput{ProjectId: testProject, Id: p.Id()})
if err != nil {
t.Fatalf("GetPresetById: %v", err)
}
if got.SearchQuery() != "err" {
t.Fatalf("preset searchQuery = %q, want err", got.SearchQuery())
}
if _, err := c.UpdatePresetName(ctx, gen.PresetNameReqInput{
ProjectId: testProject, PresetId: p.Id(), Name: "My traces v2", TableName: "traces",
}); err != nil {
t.Fatalf("UpdatePresetName: %v", err)
}
byTable, err := c.GetPresetsByTableName(ctx, gen.PresetListReqInput{ProjectId: testProject, TableName: "traces"})
if err != nil {
t.Fatalf("GetPresetsByTableName: %v", err)
}
if byTable.Presets().Len() != 1 {
t.Fatalf("GetPresetsByTableName len=%d, want 1", byTable.Presets().Len())
}
link, err := c.GeneratePermalink(ctx, gen.PermalinkReqInput{
ProjectId: testProject, PresetId: p.Id(), TableName: "traces", BaseUrl: "https://app.hanzo.ai",
})
if err != nil {
t.Fatalf("GeneratePermalink: %v", err)
}
want := "https://app.hanzo.ai/project/" + testProject + "/traces?viewId=" + p.Id()
if link.Value() != want {
t.Fatalf("permalink = %q, want %q", link.Value(), want)
}
if _, err := c.DeletePreset(ctx, gen.IdReqInput{ProjectId: testProject, Id: p.Id()}); err != nil {
t.Fatalf("DeletePreset: %v", err)
}
t.Logf("preset CRUD OK: created, conflict-rejected, got, renamed, by-table(1), permalink, deleted")
}
// ─────────────────────────────── monitorsRouter ────────────────────────────
func TestMonitorCRUD(t *testing.T) {
addr, peer, stop := newService(t)
defer stop()
c, stopC := fullClient(t, addr, peer)
defer stopC()
ctx, cancel := ctx5(t)
defer cancel()
m, err := c.CreateMonitor(ctx, gen.MonitorReqInput{
ProjectId: testProject, Name: "Error rate", View: "traces",
Filters: `[]`, Metric: `{"measure":"count","agg":"count"}`, Window: `{"value":5,"unit":"minutes"}`,
ThresholdOperator: ">", AlertThreshold: 100, WarningThreshold: 50,
NoData: `{"mode":"SILENT"}`, Renotify: `{"mode":"OFF"}`, Tags: `["prod"]`, Status: "active",
})
if err != nil {
t.Fatalf("CreateMonitor: %v", err)
}
if m.Id() == "" || m.AlertThreshold() != 100 || m.WarningThreshold() != 50 || m.Severity() != "unknown" {
t.Fatalf("unexpected monitor: id=%q alert=%v warn=%v sev=%q", m.Id(), m.AlertThreshold(), m.WarningThreshold(), m.Severity())
}
got, err := c.GetMonitor(ctx, gen.IdReqInput{ProjectId: testProject, Id: m.Id()})
if err != nil {
t.Fatalf("GetMonitor: %v", err)
}
if got.Metric() != `{"measure":"count","agg":"count"}` || got.Status() != "active" {
t.Fatalf("monitor not round-tripped: metric=%q status=%q", got.Metric(), got.Status())
}
upd, err := c.UpdateMonitor(ctx, gen.MonitorReqInput{
ProjectId: testProject, MonitorId: m.Id(), Name: "Error rate v2", View: "traces",
Filters: `[]`, Metric: `{"measure":"count","agg":"count"}`, Window: `{"value":10,"unit":"minutes"}`,
ThresholdOperator: ">", AlertThreshold: 200, WarningThreshold: nan(),
NoData: `{"mode":"SILENT"}`, Renotify: `{"mode":"OFF"}`, Tags: `[]`, Status: "paused",
})
if err != nil {
t.Fatalf("UpdateMonitor: %v", err)
}
if upd.AlertThreshold() != 200 || upd.Status() != "paused" {
t.Fatalf("monitor update not applied: %+v", upd)
}
// A null warning threshold must come back as NaN, not 0.
if !nanEq(upd.WarningThreshold()) {
t.Fatalf("expected null warningThreshold (NaN), got %v", upd.WarningThreshold())
}
list, err := c.AllMonitors(ctx, gen.ListReqInput{ProjectId: testProject, Page: 1, Limit: 50})
if err != nil {
t.Fatalf("AllMonitors: %v", err)
}
if list.TotalCount() != 1 {
t.Fatalf("AllMonitors total=%d, want 1", list.TotalCount())
}
if _, err := c.DeleteMonitor(ctx, gen.IdReqInput{ProjectId: testProject, Id: m.Id()}); err != nil {
t.Fatalf("DeleteMonitor: %v", err)
}
t.Logf("monitor CRUD OK: created, got, updated(null-warn), listed(1), deleted")
}
// ───────────────────────────── capability gating ───────────────────────────
// TestPermissionGatingPerCategory proves each method's DashPermissions bit is
// the single chokepoint: a cap holding ONLY one category's read bit can read
// that category but is forbidden everything else (write of the same category,
// other categories).
func TestPermissionGatingPerCategory(t *testing.T) {
addr, peer, stop := newService(t)
defer stop()
ctx, cancel := ctx5(t)
defer cancel()
// Dashboard-read-only cap: AllDashboards ok, CreateDashboard forbidden,
// AllMonitors forbidden, AllWidgets ok (widgets share the dashboard bit).
cRead, stopR := newClient(t, addr, peer, server.PermDashboardRead)
defer stopR()
if _, err := cRead.AllDashboards(ctx, gen.ListReqInput{ProjectId: testProject, Page: 1, Limit: 10}); err != nil {
t.Fatalf("read-only cap should read dashboards: %v", err)
}
if _, err := cRead.AllWidgets(ctx, gen.ListReqInput{ProjectId: testProject, Page: 1, Limit: 10}); err != nil {
t.Fatalf("dashboard-read cap should read widgets (shared bit): %v", err)
}
if _, err := cRead.CreateDashboard(ctx, gen.CreateDashReqInput{ProjectId: testProject, Name: "x"}); err == nil {
t.Fatalf("read-only cap must NOT create dashboards")
}
if _, err := cRead.AllMonitors(ctx, gen.ListReqInput{ProjectId: testProject, Page: 1, Limit: 10}); err == nil {
t.Fatalf("dashboard cap must NOT read monitors")
}
// Monitor-write cap: CreateMonitor ok, CreatePreset forbidden.
cMon, stopM := newClient(t, addr, peer, server.PermMonitorWrite|server.PermMonitorRead)
defer stopM()
if _, err := cMon.CreateMonitor(ctx, gen.MonitorReqInput{
ProjectId: testProject, Name: "m", View: "traces", Filters: `[]`,
Metric: `{}`, Window: `{}`, ThresholdOperator: ">", AlertThreshold: 1, WarningThreshold: nan(),
NoData: `{}`, Renotify: `{}`, Tags: `[]`, Status: "active",
}); err != nil {
t.Fatalf("monitor-write cap should create monitors: %v", err)
}
if _, err := cMon.CreatePreset(ctx, gen.PresetReqInput{
ProjectId: testProject, Name: "p", TableName: "traces", Filters: `[]`, ColumnOrder: `[]`, ColumnVisibility: `{}`,
}); err == nil {
t.Fatalf("monitor cap must NOT create presets")
}
t.Logf("capability gating OK: per-category read/write bits enforced as the single chokepoint")
}
// TestNoCapDenied proves a cap of the WRONG kind / zero permissions is rejected
// before any data access.
func TestZeroPermissionDenied(t *testing.T) {
addr, peer, stop := newService(t)
defer stop()
c, stopC := newClient(t, addr, peer, 0) // no bits
defer stopC()
ctx, cancel := ctx5(t)
defer cancel()
if _, err := c.AllDashboards(ctx, gen.ListReqInput{ProjectId: testProject, Page: 1, Limit: 10}); err == nil {
t.Fatalf("zero-permission cap must be denied")
} else {
t.Logf("correctly denied: %v", err)
}
}
// ──────────────────────── analytics + queue TODOs (501) ────────────────────
// TestAnalyticsNotImplemented locks the contract: analytics methods are wired to
// 501 until the ClickHouse shim lands — they must NOT fabricate data.
func TestAnalyticsNotImplemented(t *testing.T) {
addr, peer, stop := newService(t)
defer stop()
c, stopC := fullClient(t, addr, peer)
defer stopC()
ctx, cancel := ctx5(t)
defer cancel()
for _, m := range []struct {
name string
method uint32
}{
{"chart", server.MethodChart},
{"scoreHistogram", server.MethodScoreHistogram},
{"executeQuery", server.MethodExecuteQuery},
{"isBatchActionInProgress", server.MethodIsBatchActionInProgress},
} {
payload := gen.NewAnalyticsReq(gen.AnalyticsReqInput{ProjectId: testProject})
if m.method == server.MethodIsBatchActionInProgress {
payload = gen.NewBatchActionReq(gen.BatchActionReqInput{ProjectId: testProject, TableName: "traces", ActionId: "a1"})
}
status := c.Probe(ctx, m.method, payload)
if status != server.StatusNotImpl {
t.Fatalf("%s: status=%d, want 501 (not-implemented TODO)", m.name, status)
}
}
t.Logf("analytics + queue methods correctly return 501 (ClickHouse/BullMQ shims pending)")
}
// ─────────────────────────────── pipelining ────────────────────────────────
// TestPipelineDashboardThenWidget is the load-bearing pipelining proof: the
// dependent CreateWidget call (pipelined off CreateDashboard's promise) must ship
// before CreateDashboard's answer resolves. We assert on the instrumented send
// log that BOTH "send" events precede the FIRST "recv" event.
func TestPipelineDashboardThenWidget(t *testing.T) {
addr, peer, stop := newService(t)
defer stop()
ctx, cancel := ctx5(t)
defer cancel()
var log []server.SendEvent
c, stopC := fullClient(t, addr, peer)
defer stopC()
c.WithSendLog(&log)
dep, stopD := fullClient(t, addr, peer)
defer stopD()
dep.WithSendLog(&log)
dash, widget, err := c.PipelineCreateDashboardThenWidget(ctx, dep,
gen.CreateDashReqInput{ProjectId: testProject, Name: "Pipelined", Description: ""},
gen.WidgetReqInput{
ProjectId: testProject, Name: "On board", View: "traces",
Dimensions: `[]`, Metrics: `[]`, Filters: `[]`, ChartType: "NUMBER", ChartConfig: `{"type":"NUMBER"}`,
})
if err != nil {
t.Fatalf("Pipeline: %v", err)
}
if dash.Id() == "" || widget.Id() == "" {
t.Fatalf("pipeline produced empty ids: dash=%q widget=%q", dash.Id(), widget.Id())
}
firstRecv, sendsBefore := -1, 0
for i, e := range log {
if e.Kind == "recv" {
firstRecv = i
break
}
if e.Kind == "send" {
sendsBefore++
}
}
t.Logf("send log: %s", formatLog(log))
if firstRecv == -1 {
t.Fatalf("no recv events recorded")
}
if sendsBefore < 2 {
t.Fatalf("pipelining violated: only %d sends before first answer; want 2", sendsBefore)
}
t.Logf("PIPELINING PROVEN: %d calls (dashboard+widget) shipped before the first answer resolved", sendsBefore)
}
// ─────────────────────────────── tiny helpers ──────────────────────────────
// nan is the wire sentinel for a null float (a null Monitor warningThreshold).
func nan() float64 { return math.NaN() }
// nanEq reports whether f carries the null sentinel.
func nanEq(f float64) bool { return math.IsNaN(f) }
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var b [12]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
b[i] = '-'
}
return string(b[i:])
}
func formatLog(log []server.SendEvent) string {
out := ""
for _, e := range log {
m := "createDashboard"
if e.Method == server.MethodCreateWidget {
m = "createWidget"
}
out += e.Kind + "(" + m + ",p=" + itoa(int(e.PromiseID)) + ",t=" + itoa(int(e.Target)) + ") "
}
return out
}