master
go 2,107 lines 59.4 KB
Raw
1 // SPDX-License-Identifier: GPL-3.0-or-later
2
3 package topologyv1
4
5 import (
6 "encoding/json"
7 "os"
8 "path/filepath"
9 "testing"
10 "time"
11
12 "github.com/santhosh-tekuri/jsonschema/v6"
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/require"
15 )
16
17 var testCollectedAt = time.Date(2026, 5, 9, 10, 0, 0, 0, time.UTC)
18
19 func TestResponseValidatesAgainstSchemaAndSemanticChecks(t *testing.T) {
20 payload := NewResponse(Data{
21 Producer: Producer{
22 Source: "test-topology",
23 Instance: "test-instance",
24 Plugin: "go-test",
25 },
26 CollectedAt: time.Date(2026, 5, 9, 10, 0, 0, 0, time.UTC),
27 Dictionaries: Dictionaries{
28 "strings": StringValues("node-a"),
29 },
30 Types: TypeRegistry{
31 ActorTypes: map[string]ActorType{
32 "node": {
33 Layer: "node",
34 Identity: []string{"id"},
35 Presentation: &ActorPresentation{
36 Label: "Node",
37 Role: "actor",
38 Icon: "server",
39 ColorSlot: "primary",
40 Annotation: &AnnotationPresentation{
41 ColorSlot: "warning",
42 Style: "ring",
43 },
44 Border: &BorderPresentation{
45 Enabled: new(true),
46 Style: "solid",
47 },
48 LabelPolicy: &LabelPolicy{
49 Columns: []string{"name"},
50 Fallback: "type_label",
51 MaxLength: 80,
52 Array: "reject",
53 },
54 Ports: &ActorPortsPresentation{
55 ShowBullets: true,
56 Sources: []PortSourcePresentation{
57 {
58 Source: "links",
59 ActorColumn: "src",
60 NameColumn: "port_name",
61 DefaultType: "topology",
62 },
63 },
64 },
65 Hover: &HoverPresentation{
66 Fields: []PresentationField{{Key: "name", Label: "Name"}},
67 },
68 Modal: &ModalPresentation{
69 Enabled: new(true),
70 Labels: &ModalLabelsPresentation{
71 Table: "actor_labels",
72 ActorColumn: "actor",
73 KeyColumn: "key",
74 ValueColumn: "value",
75 },
76 MiniTopology: &ModalMiniTopologyPresentation{
77 Enabled: new(true),
78 Depth: 1,
79 },
80 Sections: []ModalSection{
81 {
82 ID: "ports",
83 Label: "Ports",
84 Source: ModalSource{
85 Kind: "actor_table",
86 Table: "ports",
87 },
88 OwnerFilter: &ModalOwnerFilter{
89 Mode: "actor_column",
90 ActorColumn: "actor",
91 },
92 Columns: []ModalColumn{
93 {
94 ID: "neighbors",
95 Label: "Neighbors",
96 Projection: ModalProjection{
97 Kind: "direct",
98 Column: "neighbors",
99 },
100 Cell: "debug_json",
101 Visibility: "debug",
102 },
103 },
104 },
105 {
106 ID: "paths",
107 Label: "Paths",
108 Source: ModalSource{
109 Kind: "actor_table",
110 Table: "path",
111 },
112 OwnerFilter: &ModalOwnerFilter{
113 Mode: "actor_column",
114 ActorColumn: "actor",
115 },
116 Columns: []ModalColumn{
117 {
118 ID: "path_actor",
119 Label: "Path actor",
120 Projection: ModalProjection{
121 Kind: "actor_ref_label",
122 ActorColumn: "path_actor",
123 },
124 Cell: "actor_link",
125 },
126 {
127 ID: "path_index",
128 Label: "Hop",
129 Projection: ModalProjection{
130 Kind: "direct",
131 Column: "path_index",
132 },
133 Cell: "number",
134 },
135 {
136 ID: "source",
137 Label: "Source",
138 Projection: ModalProjection{
139 Kind: "const",
140 Value: "test",
141 },
142 Cell: "text",
143 Visibility: "expanded",
144 },
145 },
146 Sort: &ModalSort{Column: "path_index", Direction: "asc"},
147 },
148 },
149 },
150 },
151 },
152 },
153 LinkTypes: map[string]LinkType{
154 "dependency": {
155 Orientation: "directed",
156 DirectionRole: "dependency",
157 Aggregation: LinkAggregation{
158 Direction: "preserve",
159 },
160 Presentation: &LinkPresentation{
161 Label: "Dependency",
162 ColorSlot: "primary",
163 LineStyle: "solid",
164 Width: "normal",
165 Curve: "auto",
166 Arrow: "forward",
167 Variable: &LinkVariablePresentation{
168 Channel: "width",
169 ScaleKey: "requests",
170 ValueColumn: "weight",
171 Min: "normal",
172 Max: "emphasis",
173 },
174 Hover: &HoverPresentation{
175 Fields: []PresentationField{{Key: "weight", Label: "Requests"}},
176 },
177 Layout: &LinkLayoutPresentation{
178 Strength: "weaker",
179 Distance: "farther",
180 },
181 },
182 },
183 },
184 PortTypes: map[string]PortType{
185 "topology": {
186 Presentation: &PortPresentation{
187 Label: "Topology",
188 ColorSlot: "primary",
189 },
190 },
191 },
192 TableTypes: map[string]TableType{
193 "actor_labels": {
194 Role: "actor_inventory",
195 Owner: "actor",
196 Aggregation: "set",
197 Columns: []Column{
198 NewColumn("actor", "actor_ref"),
199 NewColumn("key", "string"),
200 NewColumn("value", "string"),
201 NewColumn("source", "string", WithNullable()),
202 NewColumn("kind", "string", WithNullable()),
203 NewColumn("value_index", "uint", WithNullable()),
204 },
205 },
206 "port_detail": {
207 Role: "actor_detail",
208 Owner: "actor",
209 Aggregation: "append",
210 Columns: []Column{
211 NewColumn("actor", "actor_ref"),
212 NewColumn("neighbors", "json", WithNullable()),
213 },
214 },
215 "path_detail": {
216 Role: "actor_detail",
217 Owner: "actor",
218 Aggregation: "append",
219 Columns: []Column{
220 NewColumn("actor", "actor_ref"),
221 NewColumn("path_actor", "actor_ref"),
222 NewColumn("path_index", "uint"),
223 },
224 },
225 },
226 },
227 Presentation: &Presentation{
228 ProfileVersion: "test",
229 Selection: &SelectionPresentation{
230 ActorClick: &ActorClickPresentation{
231 Mode: "highlight_path",
232 PathTable: "path",
233 PathOwnerColumn: "actor",
234 PathActorColumn: "path_actor",
235 PathOrderColumn: "path_index",
236 },
237 },
238 Legend: &PresentationLegend{
239 Actors: []LegendEntry{{Type: "node", Label: "Node"}},
240 Links: []LegendEntry{{Type: "dependency", Label: "Dependency"}},
241 Ports: []LegendEntry{{Type: "topology", Label: "Topology"}},
242 },
243 PortFields: []PresentationField{{Key: "type", Label: "Type"}},
244 ScaleKeys: map[string]ScaleKeyPresentation{
245 "requests": {Label: "Requests", Unit: "count"},
246 },
247 },
248 Correlation: &Correlation{
249 Rules: map[string]CorrelationRule{
250 "node_name": {
251 Action: "link",
252 Priority: 10,
253 KeySpace: "node_name",
254 Key: []CorrelationKeyPart{{Column: "name"}},
255 PointActorTypes: []string{"node"},
256 ClaimActorTypes: []string{"node"},
257 OutputLinkType: "dependency",
258 },
259 },
260 Points: &Table{
261 Rows: 1,
262 Columns: []Column{
263 NewColumn("actor", "actor_ref"),
264 NewColumn("rule", "string"),
265 NewColumn("name", "string_ref", WithDictionary("strings")),
266 },
267 Values: []ColumnEncoding{
268 Values(0),
269 Const("node_name"),
270 Values(0),
271 },
272 },
273 Claims: &Table{
274 Rows: 1,
275 Columns: []Column{
276 NewColumn("actor", "actor_ref"),
277 NewColumn("rule", "string"),
278 NewColumn("name", "string_ref", WithDictionary("strings")),
279 },
280 Values: []ColumnEncoding{
281 Values(0),
282 Const("node_name"),
283 Values(0),
284 },
285 },
286 },
287 Actors: MustTable(1,
288 []Column{
289 NewColumn("id", "string", WithRole("identity")),
290 NewColumn("type", "string"),
291 NewColumn("name", "string_ref", WithDictionary("strings")),
292 },
293 []ColumnEncoding{
294 Values("node-a"),
295 Const("node"),
296 Values(0),
297 },
298 ),
299 Links: MustTable(1,
300 []Column{
301 NewColumn("src", "actor_ref"),
302 NewColumn("dst", "actor_ref"),
303 NewColumn("type", "string"),
304 NewColumn("port_name", "string"),
305 NewColumn("weight", "uint", WithRole("metric"), WithAggregation("sum")),
306 },
307 []ColumnEncoding{
308 Values(0),
309 Values(0),
310 Const("dependency"),
311 Const("node-a"),
312 Values(7),
313 },
314 ),
315 Tables: &DetailTables{
316 Actor: map[string]DetailTable{
317 "actor_labels": {
318 Type: "actor_labels",
319 Table: MustTable(1,
320 []Column{
321 NewColumn("actor", "actor_ref"),
322 NewColumn("key", "string"),
323 NewColumn("value", "string"),
324 NewColumn("source", "string", WithNullable()),
325 NewColumn("kind", "string", WithNullable()),
326 NewColumn("value_index", "uint", WithNullable()),
327 },
328 []ColumnEncoding{
329 Values(0),
330 Const("hostname"),
331 Values("node-a"),
332 Const("producer"),
333 Const("identity"),
334 Values(nil),
335 },
336 ),
337 },
338 "ports": {
339 Type: "port_detail",
340 Table: MustTable(1,
341 []Column{
342 NewColumn("actor", "actor_ref"),
343 NewColumn("neighbors", "json", WithNullable()),
344 },
345 []ColumnEncoding{
346 Values(0),
347 Values(map[string]any{
348 "neighbors": []any{
349 map[string]any{
350 "protocol": "lldp",
351 "remote_port": "eth0",
352 },
353 },
354 }),
355 },
356 ),
357 },
358 "path": {
359 Type: "path_detail",
360 Table: MustTable(1,
361 []Column{
362 NewColumn("actor", "actor_ref"),
363 NewColumn("path_actor", "actor_ref"),
364 NewColumn("path_index", "uint"),
365 },
366 []ColumnEncoding{
367 Values(0),
368 Values(0),
369 Values(0),
370 },
371 ),
372 },
373 },
374 },
375 })
376
377 payloadBytes, err := json.Marshal(payload)
378 require.NoError(t, err)
379 assert.Contains(t, string(payloadBytes), `"schema_version":"netdata.topology.v1"`)
380
381 validateAgainstTopologySchema(t, payloadBytes)
382
383 var decoded any
384 require.NoError(t, json.Unmarshal(payloadBytes, &decoded))
385 require.NoError(t, ValidateDecodedResponse(decoded))
386 }
387
388 func TestValidateDecodedResponseRejectsInvalidPresentationReferences(t *testing.T) {
389 err := validateResponseData(t, minimalValidationData(&ActorPresentation{
390 LabelPolicy: &LabelPolicy{Columns: []string{"missing"}},
391 }))
392
393 require.Error(t, err)
394 assert.Contains(t, err.Error(), "label_policy.columns[0] references unknown actor column")
395 }
396
397 func TestValidateDecodedResponseRejectsNonDisplayLabelColumn(t *testing.T) {
398 actors := minimalActorTable(
399 []Column{NewColumn("metadata", "json", WithNullable())},
400 []ColumnEncoding{Values(map[string]any{"labels": []any{"not-a-label"}})},
401 )
402 err := validateResponseData(t, minimalValidationData(
403 &ActorPresentation{LabelPolicy: &LabelPolicy{Columns: []string{"metadata"}}},
404 withActors(actors),
405 ))
406
407 require.Error(t, err)
408 assert.Contains(t, err.Error(), "references non-display actor column")
409 }
410
411 func TestValidateDecodedResponseRejectsMissingPortSourceTable(t *testing.T) {
412 err := validateResponseData(t, minimalValidationData(
413 &ActorPresentation{
414 Ports: &ActorPortsPresentation{
415 ShowBullets: true,
416 Sources: []PortSourcePresentation{
417 {
418 Source: "actor_table",
419 Table: "missing_ports",
420 ActorColumn: "actor",
421 NameColumn: "name",
422 DefaultType: "topology",
423 },
424 },
425 },
426 },
427 withPortTypes(map[string]PortType{
428 "topology": {Presentation: &PortPresentation{Label: "Topology"}},
429 }),
430 ))
431
432 require.Error(t, err)
433 assert.Contains(t, err.Error(), "references unknown actor table")
434 }
435
436 func TestValidateDecodedResponseRejectsMissingModalActorTable(t *testing.T) {
437 err := validateResponseData(t, minimalValidationData(
438 &ActorPresentation{
439 Modal: &ModalPresentation{
440 Sections: []ModalSection{
441 {
442 ID: "missing",
443 Label: "Missing",
444 Source: ModalSource{
445 Kind: "actor_table",
446 Table: "missing_table",
447 },
448 Columns: []ModalColumn{
449 {
450 ID: "name",
451 Label: "Name",
452 Projection: ModalProjection{
453 Kind: "direct",
454 Column: "name",
455 },
456 },
457 },
458 },
459 },
460 },
461 },
462 withActors(minimalActorTable(
463 []Column{NewColumn("name", "string_ref", WithDictionary("strings"))},
464 []ColumnEncoding{Values(0)},
465 )),
466 withLinks(dependencyLinkTable(0)),
467 ))
468
469 require.Error(t, err)
470 assert.Contains(t, err.Error(), "modal.sections[0].source.table references unknown actor table")
471 }
472
473 func TestValidateDecodedResponseRejectsInvalidModalProjectionShapes(t *testing.T) {
474 cases := map[string]struct {
475 projection ModalProjection
476 want string
477 }{
478 "direct missing column": {
479 projection: ModalProjection{Kind: "direct"},
480 want: "column is required",
481 },
482 "actor label missing actor column": {
483 projection: ModalProjection{Kind: "actor_ref_label"},
484 want: "actor_column is required",
485 },
486 "opposite actor missing actor columns": {
487 projection: ModalProjection{Kind: "opposite_actor"},
488 want: "src_actor_column is required",
489 },
490 "const missing value": {
491 projection: ModalProjection{Kind: "const"},
492 want: "value is required when kind is const",
493 },
494 "selected endpoint missing local side": {
495 projection: ModalProjection{Kind: "selected_side_endpoint", SrcActorColumn: "src", DstActorColumn: "dst", RemoteIPColumn: "remote_ip"},
496 want: "requires local_ip_column or local_port_column",
497 },
498 "selected endpoint missing remote side": {
499 projection: ModalProjection{Kind: "selected_side_endpoint", SrcActorColumn: "src", DstActorColumn: "dst", LocalIPColumn: "local_ip"},
500 want: "requires remote_ip_column or remote_port_column",
501 },
502 "selected endpoint empty local side": {
503 projection: ModalProjection{Kind: "selected_side_endpoint", SrcActorColumn: "src", DstActorColumn: "dst", LocalIPColumn: "", RemoteIPColumn: "remote_ip"},
504 want: "requires local_ip_column or local_port_column",
505 },
506 "selected endpoint missing side actor columns": {
507 projection: ModalProjection{Kind: "selected_side_endpoint", LocalIPColumn: "local_ip", RemoteIPColumn: "remote_ip"},
508 want: "src_actor_column is required",
509 },
510 "label lookup missing key": {
511 projection: ModalProjection{Kind: "label_lookup", ActorColumn: "src"},
512 want: "label_key is required when kind is label_lookup",
513 },
514 "json path missing path": {
515 projection: ModalProjection{Kind: "json_path", Column: "metadata"},
516 want: "path is required when kind is json_path",
517 },
518 "json path missing column": {
519 projection: ModalProjection{Kind: "json_path", Path: "$.state"},
520 want: "column is required",
521 },
522 "coalesce missing columns": {
523 projection: ModalProjection{Kind: "coalesce"},
524 want: "columns is required when kind is coalesce",
525 },
526 "formatted endpoint missing endpoint columns": {
527 projection: ModalProjection{Kind: "formatted_endpoint", ProtocolColumn: "type"},
528 want: "requires ip_column or port_column",
529 },
530 }
531
532 for name, tc := range cases {
533 t.Run(name, func(t *testing.T) {
534 data := minimalValidationData(
535 &ActorPresentation{
536 Modal: &ModalPresentation{
537 Sections: []ModalSection{
538 {
539 ID: "links",
540 Label: "Links",
541 Source: ModalSource{Kind: "links"},
542 Columns: []ModalColumn{
543 {
544 ID: "endpoint",
545 Label: "Endpoint",
546 Projection: tc.projection,
547 },
548 },
549 },
550 },
551 },
552 },
553 withLinks(dependencyLinkTableWith(1,
554 []Column{
555 NewColumn("local_ip", "string", WithNullable()),
556 NewColumn("remote_ip", "string", WithNullable()),
557 NewColumn("metadata", "json", WithNullable()),
558 },
559 []ColumnEncoding{
560 Values("10.0.0.1"),
561 Values("10.0.0.2"),
562 Values(map[string]any{"state": "open"}),
563 },
564 )),
565 )
566
567 payloadBytes, err := json.Marshal(NewResponse(data))
568 require.NoError(t, err)
569 require.Error(t, topologySchemaValidationError(t, payloadBytes))
570
571 var decoded any
572 require.NoError(t, json.Unmarshal(payloadBytes, &decoded))
573
574 err = ValidateDecodedResponse(decoded)
575 require.Error(t, err)
576 assert.Contains(t, err.Error(), tc.want)
577 })
578 }
579 }
580
581 func TestValidateDecodedResponseRejectsInvalidModalPresentationSemantics(t *testing.T) {
582 basePayload := func(actorPresentation map[string]any) map[string]any {
583 return map[string]any{
584 "status": float64(200),
585 "type": "topology",
586 "data": map[string]any{
587 "schema_version": SchemaVersion,
588 "dictionaries": map[string]any{},
589 "types": map[string]any{
590 "actor_types": map[string]any{
591 "node": map[string]any{
592 "layer": "node",
593 "identity": []any{"id"},
594 "presentation": actorPresentation,
595 },
596 },
597 "link_types": map[string]any{
598 "dependency": map[string]any{
599 "orientation": "directed",
600 "direction_role": "dependency",
601 "aggregation": map[string]any{"direction": "preserve"},
602 },
603 },
604 "port_types": map[string]any{},
605 "table_types": map[string]any{},
606 },
607 "actors": map[string]any{
608 "rows": float64(1),
609 "columns": []any{
610 map[string]any{"id": "id", "type": "string", "role": "identity"},
611 map[string]any{"id": "type", "type": "string"},
612 },
613 "values": []any{
614 map[string]any{"codec": "const", "value": "node-a"},
615 map[string]any{"codec": "const", "value": "node"},
616 },
617 },
618 "links": map[string]any{
619 "rows": float64(1),
620 "columns": []any{
621 map[string]any{"id": "src", "type": "actor_ref"},
622 map[string]any{"id": "dst", "type": "actor_ref"},
623 map[string]any{"id": "type", "type": "string"},
624 },
625 "values": []any{
626 map[string]any{"codec": "const", "value": float64(0)},
627 map[string]any{"codec": "const", "value": float64(0)},
628 map[string]any{"codec": "const", "value": "dependency"},
629 },
630 },
631 },
632 }
633 }
634 validSection := func() map[string]any {
635 return map[string]any{
636 "id": "links",
637 "label": "Links",
638 "source": map[string]any{"kind": "links"},
639 "columns": []any{
640 map[string]any{
641 "id": "type",
642 "label": "Type",
643 "projection": map[string]any{"kind": "direct", "column": "type"},
644 },
645 },
646 }
647 }
648
649 cases := map[string]struct {
650 presentation map[string]any
651 want string
652 }{
653 "empty actor type label": {
654 presentation: map[string]any{"label": ""},
655 want: "presentation.label is required",
656 },
657 "mini topology non-integer depth": {
658 presentation: map[string]any{
659 "modal": map[string]any{
660 "mini_topology": map[string]any{"depth": "1"},
661 },
662 },
663 want: "mini_topology.depth is not an integer",
664 },
665 "modal is not an object": {
666 presentation: map[string]any{
667 "modal": "invalid",
668 },
669 want: "modal is not an object",
670 },
671 "missing modal columns": {
672 presentation: map[string]any{
673 "modal": map[string]any{
674 "sections": []any{
675 map[string]any{
676 "id": "links",
677 "label": "Links",
678 "source": map[string]any{"kind": "links"},
679 },
680 },
681 },
682 },
683 want: "columns is not an array",
684 },
685 "empty modal columns": {
686 presentation: map[string]any{
687 "modal": map[string]any{
688 "sections": []any{
689 map[string]any{
690 "id": "links",
691 "label": "Links",
692 "source": map[string]any{"kind": "links"},
693 "columns": []any{},
694 },
695 },
696 },
697 },
698 want: "columns must not be empty",
699 },
700 "duplicate modal section id": {
701 presentation: map[string]any{
702 "modal": map[string]any{
703 "sections": []any{
704 validSection(),
705 validSection(),
706 },
707 },
708 },
709 want: "duplicates modal section id",
710 },
711 "duplicate modal column id": {
712 presentation: map[string]any{
713 "modal": map[string]any{
714 "sections": []any{
715 func() map[string]any {
716 section := validSection()
717 section["columns"] = []any{
718 map[string]any{
719 "id": "type",
720 "label": "Type",
721 "projection": map[string]any{"kind": "direct", "column": "type"},
722 },
723 map[string]any{
724 "id": "type",
725 "label": "Type again",
726 "projection": map[string]any{"kind": "direct", "column": "type"},
727 },
728 }
729 return section
730 }(),
731 },
732 },
733 },
734 want: "duplicates modal column id",
735 },
736 "sort references unknown modal column": {
737 presentation: map[string]any{
738 "modal": map[string]any{
739 "sections": []any{
740 func() map[string]any {
741 section := validSection()
742 section["sort"] = map[string]any{"column": "missing"}
743 return section
744 }(),
745 },
746 },
747 },
748 want: "sort.column references unknown modal column",
749 },
750 }
751
752 for name, tc := range cases {
753 t.Run(name, func(t *testing.T) {
754 err := ValidateDecodedResponse(basePayload(tc.presentation))
755 require.Error(t, err)
756 assert.Contains(t, err.Error(), tc.want)
757 })
758 }
759 }
760
761 func TestValidateDecodedResponseRejectsInvalidZeroHeuristicContract(t *testing.T) {
762 basePayload := func() map[string]any {
763 return map[string]any{
764 "status": float64(200),
765 "type": "topology",
766 "data": map[string]any{
767 "schema_version": SchemaVersion,
768 "dictionaries": map[string]any{},
769 "types": map[string]any{
770 "actor_types": map[string]any{
771 "node": map[string]any{
772 "layer": "node",
773 "identity": []any{"id"},
774 "search": map[string]any{
775 "enabled": true,
776 "columns": []any{"name"},
777 },
778 "presentation": map[string]any{
779 "label": "Node",
780 "size": map[string]any{"mode": "fixed", "scale": "normal"},
781 "layout": map[string]any{"repulsion": "normal"},
782 },
783 },
784 },
785 "link_types": map[string]any{
786 "dependency": map[string]any{
787 "orientation": "directed",
788 "direction_role": "dependency",
789 "semantic_role": "traffic",
790 "aggregation": map[string]any{"direction": "preserve"},
791 },
792 },
793 },
794 "actors": map[string]any{
795 "rows": float64(1),
796 "columns": []any{
797 map[string]any{"id": "id", "type": "string", "role": "identity"},
798 map[string]any{"id": "type", "type": "string"},
799 map[string]any{"id": "name", "type": "string"},
800 },
801 "values": []any{
802 map[string]any{"codec": "const", "value": "node-a"},
803 map[string]any{"codec": "const", "value": "node"},
804 map[string]any{"codec": "const", "value": "Node A"},
805 },
806 },
807 "links": map[string]any{
808 "rows": float64(1),
809 "columns": []any{
810 map[string]any{"id": "src", "type": "actor_ref"},
811 map[string]any{"id": "dst", "type": "actor_ref"},
812 map[string]any{"id": "type", "type": "string"},
813 },
814 "values": []any{
815 map[string]any{"codec": "const", "value": float64(0)},
816 map[string]any{"codec": "const", "value": float64(0)},
817 map[string]any{"codec": "const", "value": "dependency"},
818 },
819 },
820 },
821 }
822 }
823
824 cases := map[string]struct {
825 mutate func(map[string]any)
826 want string
827 }{
828 "invalid search column": {
829 mutate: func(payload map[string]any) {
830 actorType := payload["data"].(map[string]any)["types"].(map[string]any)["actor_types"].(map[string]any)["node"].(map[string]any)
831 actorType["search"].(map[string]any)["columns"] = []any{"missing"}
832 },
833 want: "search.columns[0] references unknown actor column",
834 },
835 "invalid size scale": {
836 mutate: func(payload map[string]any) {
837 actorType := payload["data"].(map[string]any)["types"].(map[string]any)["actor_types"].(map[string]any)["node"].(map[string]any)
838 presentation := actorType["presentation"].(map[string]any)
839 presentation["size"].(map[string]any)["scale"] = "huge"
840 },
841 want: "presentation.size.scale has unsupported value",
842 },
843 "invalid layout repulsion": {
844 mutate: func(payload map[string]any) {
845 actorType := payload["data"].(map[string]any)["types"].(map[string]any)["actor_types"].(map[string]any)["node"].(map[string]any)
846 presentation := actorType["presentation"].(map[string]any)
847 presentation["layout"].(map[string]any)["repulsion"] = "huge"
848 },
849 want: "presentation.layout.repulsion has unsupported value",
850 },
851 "invalid semantic role": {
852 mutate: func(payload map[string]any) {
853 linkType := payload["data"].(map[string]any)["types"].(map[string]any)["link_types"].(map[string]any)["dependency"].(map[string]any)
854 linkType["semantic_role"] = "protocol"
855 },
856 want: "semantic_role has unsupported value",
857 },
858 }
859
860 for name, tc := range cases {
861 t.Run(name, func(t *testing.T) {
862 payload := basePayload()
863 tc.mutate(payload)
864 err := ValidateDecodedResponse(payload)
865 require.Error(t, err)
866 assert.Contains(t, err.Error(), tc.want)
867 })
868 }
869 }
870
871 func TestValidateDecodedResponseRejectsEmptyPresentationLabels(t *testing.T) {
872 basePayload := func(types map[string]any) map[string]any {
873 return map[string]any{
874 "status": float64(200),
875 "type": "topology",
876 "data": map[string]any{
877 "schema_version": SchemaVersion,
878 "dictionaries": map[string]any{},
879 "types": types,
880 "actors": map[string]any{
881 "rows": float64(1),
882 "columns": []any{
883 map[string]any{"id": "id", "type": "string", "role": "identity"},
884 map[string]any{"id": "type", "type": "string"},
885 },
886 "values": []any{
887 map[string]any{"codec": "const", "value": "node-a"},
888 map[string]any{"codec": "const", "value": "node"},
889 },
890 },
891 "links": map[string]any{
892 "rows": float64(0),
893 "columns": []any{
894 map[string]any{"id": "type", "type": "string"},
895 },
896 "values": []any{
897 map[string]any{"codec": "const", "value": "dependency"},
898 },
899 },
900 },
901 }
902 }
903 defaultTypes := func() map[string]any {
904 return map[string]any{
905 "actor_types": map[string]any{
906 "node": map[string]any{"layer": "node", "identity": []any{"id"}},
907 },
908 "link_types": map[string]any{
909 "dependency": map[string]any{
910 "orientation": "directed",
911 "direction_role": "dependency",
912 "aggregation": map[string]any{"direction": "preserve"},
913 },
914 },
915 "port_types": map[string]any{},
916 "table_types": map[string]any{
917 "actor_labels": map[string]any{
918 "role": "actor_inventory",
919 "owner": "actor",
920 "aggregation": "set",
921 "columns": []any{
922 map[string]any{"id": "actor", "type": "actor_ref"},
923 },
924 },
925 },
926 }
927 }
928
929 cases := map[string]func(map[string]any){
930 "actor type label": func(types map[string]any) {
931 types["actor_types"].(map[string]any)["node"].(map[string]any)["presentation"] = map[string]any{"label": ""}
932 },
933 "link type label": func(types map[string]any) {
934 types["link_types"].(map[string]any)["dependency"].(map[string]any)["presentation"] = map[string]any{"label": ""}
935 },
936 "port type label": func(types map[string]any) {
937 types["port_types"].(map[string]any)["port"] = map[string]any{"presentation": map[string]any{"label": ""}}
938 },
939 "table type label": func(types map[string]any) {
940 types["table_types"].(map[string]any)["actor_labels"].(map[string]any)["presentation"] = map[string]any{"label": ""}
941 },
942 }
943
944 for name, mutate := range cases {
945 t.Run(name, func(t *testing.T) {
946 types := defaultTypes()
947 mutate(types)
948
949 err := ValidateDecodedResponse(basePayload(types))
950 require.Error(t, err)
951 assert.Contains(t, err.Error(), "label is required")
952 })
953 }
954 }
955
956 func TestValidateDecodedResponseRejectsMalformedResponseEnvelope(t *testing.T) {
957 cases := map[string]struct {
958 payload any
959 want string
960 }{
961 "not object": {
962 payload: []any{},
963 want: "response is not an object",
964 },
965 "missing data": {
966 payload: map[string]any{"status": float64(200), "type": "topology"},
967 want: "response.data is not an object",
968 },
969 "wrong schema": {
970 payload: map[string]any{
971 "status": float64(200),
972 "type": "topology",
973 "data": map[string]any{"schema_version": "old"},
974 },
975 want: "response.data.schema_version is not",
976 },
977 }
978
979 for name, tc := range cases {
980 t.Run(name, func(t *testing.T) {
981 err := ValidateDecodedResponse(tc.payload)
982 require.Error(t, err)
983 assert.Contains(t, err.Error(), tc.want)
984 })
985 }
986 }
987
988 func TestValidateDecodedResponseRejectsInvalidCompactTableColumns(t *testing.T) {
989 basePayload := func(columns []any, values []any) map[string]any {
990 return map[string]any{
991 "status": float64(200),
992 "type": "topology",
993 "data": map[string]any{
994 "schema_version": SchemaVersion,
995 "dictionaries": map[string]any{},
996 "types": map[string]any{
997 "actor_types": map[string]any{
998 "node": map[string]any{"layer": "node", "identity": []any{"id"}},
999 },
1000 "link_types": map[string]any{
1001 "dependency": map[string]any{
1002 "orientation": "directed",
1003 "direction_role": "dependency",
1004 "aggregation": map[string]any{"direction": "preserve"},
1005 },
1006 },
1007 "port_types": map[string]any{},
1008 "table_types": map[string]any{},
1009 },
1010 "actors": map[string]any{
1011 "rows": float64(1),
1012 "columns": []any{
1013 map[string]any{"id": "id", "type": "string", "role": "identity"},
1014 map[string]any{"id": "type", "type": "string"},
1015 },
1016 "values": []any{
1017 map[string]any{"codec": "const", "value": "node-a"},
1018 map[string]any{"codec": "const", "value": "node"},
1019 },
1020 },
1021 "links": map[string]any{
1022 "rows": float64(1),
1023 "columns": columns,
1024 "values": values,
1025 },
1026 },
1027 }
1028 }
1029
1030 cases := map[string]struct {
1031 columns []any
1032 values []any
1033 want string
1034 }{
1035 "empty id": {
1036 columns: []any{
1037 map[string]any{"id": "", "type": "string"},
1038 },
1039 values: []any{
1040 map[string]any{"codec": "const", "value": "dependency"},
1041 },
1042 want: "columns[0].id is required",
1043 },
1044 "duplicate id": {
1045 columns: []any{
1046 map[string]any{"id": "type", "type": "string"},
1047 map[string]any{"id": "type", "type": "string"},
1048 },
1049 values: []any{
1050 map[string]any{"codec": "const", "value": "dependency"},
1051 map[string]any{"codec": "const", "value": "dependency"},
1052 },
1053 want: "duplicates column",
1054 },
1055 "wrong uint value type": {
1056 columns: []any{
1057 map[string]any{"id": "type", "type": "string"},
1058 map[string]any{"id": "socket_count", "type": "uint"},
1059 },
1060 values: []any{
1061 map[string]any{"codec": "const", "value": "dependency"},
1062 map[string]any{"codec": "const", "value": "not-a-number"},
1063 },
1064 want: "is not a non-negative integer",
1065 },
1066 }
1067
1068 for name, tc := range cases {
1069 t.Run(name, func(t *testing.T) {
1070 err := ValidateDecodedResponse(basePayload(tc.columns, tc.values))
1071 require.Error(t, err)
1072 assert.Contains(t, err.Error(), tc.want)
1073 })
1074 }
1075 }
1076
1077 func TestValidateDecodedResponseValidatesExplicitModalLabelColumns(t *testing.T) {
1078 baseData := func(labels *ModalLabelsPresentation) Data {
1079 return Data{
1080 Producer: Producer{Source: "test-topology", Instance: "test-instance"},
1081 CollectedAt: time.Date(2026, 5, 9, 10, 0, 0, 0, time.UTC),
1082 Dictionaries: Dictionaries{"strings": StringValues("node-a")},
1083 Types: TypeRegistry{
1084 ActorTypes: map[string]ActorType{
1085 "node": {
1086 Layer: "node",
1087 Identity: []string{"id"},
1088 Presentation: &ActorPresentation{
1089 Modal: &ModalPresentation{
1090 Labels: labels,
1091 },
1092 },
1093 },
1094 },
1095 LinkTypes: map[string]LinkType{},
1096 PortTypes: map[string]PortType{},
1097 TableTypes: map[string]TableType{
1098 "actor_labels": {
1099 Role: "actor_inventory",
1100 Owner: "actor",
1101 Aggregation: "set",
1102 Columns: []Column{
1103 NewColumn("actor", "actor_ref"),
1104 NewColumn("key", "string"),
1105 NewColumn("value", "string"),
1106 },
1107 },
1108 },
1109 },
1110 Actors: MustTable(1,
1111 []Column{
1112 NewColumn("id", "string", WithRole("identity")),
1113 NewColumn("type", "string"),
1114 },
1115 []ColumnEncoding{
1116 Values("node-a"),
1117 Const("node"),
1118 },
1119 ),
1120 Links: MustTable(0,
1121 []Column{NewColumn("type", "string")},
1122 []ColumnEncoding{Const("none")},
1123 ),
1124 }
1125 }
1126
1127 t.Run("omitted optional columns are allowed", func(t *testing.T) {
1128 payload := NewResponse(baseData(&ModalLabelsPresentation{Table: "actor_labels"}))
1129 payloadBytes, err := json.Marshal(payload)
1130 require.NoError(t, err)
1131 require.NoError(t, topologySchemaValidationError(t, payloadBytes))
1132
1133 var decoded any
1134 require.NoError(t, json.Unmarshal(payloadBytes, &decoded))
1135 require.NoError(t, ValidateDecodedResponse(decoded))
1136 })
1137
1138 t.Run("identification fields are accepted", func(t *testing.T) {
1139 payload := NewResponse(baseData(&ModalLabelsPresentation{
1140 Table: "actor_labels",
1141 Identification: &ModalLabelIdentificationPresentation{
1142 Fields: []ModalLabelIdentificationField{
1143 {Key: "display_name", Label: "Name", MaxValues: 1},
1144 {Key: "role", Label: "Role", MaxValues: 2},
1145 },
1146 },
1147 }))
1148 payloadBytes, err := json.Marshal(payload)
1149 require.NoError(t, err)
1150 require.NoError(t, topologySchemaValidationError(t, payloadBytes))
1151
1152 var decoded any
1153 require.NoError(t, json.Unmarshal(payloadBytes, &decoded))
1154 require.NoError(t, ValidateDecodedResponse(decoded))
1155 })
1156
1157 t.Run("invalid identification max values is rejected", func(t *testing.T) {
1158 payload := NewResponse(baseData(&ModalLabelsPresentation{Table: "actor_labels"}))
1159 payloadBytes, err := json.Marshal(payload)
1160 require.NoError(t, err)
1161
1162 var decoded any
1163 require.NoError(t, json.Unmarshal(payloadBytes, &decoded))
1164 data := decoded.(map[string]any)["data"].(map[string]any)
1165 actorType := data["types"].(map[string]any)["actor_types"].(map[string]any)["node"].(map[string]any)
1166 modal := actorType["presentation"].(map[string]any)["modal"].(map[string]any)
1167 labels := modal["labels"].(map[string]any)
1168 labels["identification"] = map[string]any{
1169 "fields": []any{
1170 map[string]any{"key": "display_name", "label": "Name", "max_values": float64(0)},
1171 },
1172 }
1173
1174 err = ValidateDecodedResponse(decoded)
1175 require.Error(t, err)
1176 assert.Contains(t, err.Error(), "labels.identification.fields[0].max_values must be a positive integer")
1177 })
1178
1179 t.Run("explicit missing optional column is rejected", func(t *testing.T) {
1180 payload := NewResponse(baseData(&ModalLabelsPresentation{Table: "actor_labels", SourceColumn: "missing"}))
1181 payloadBytes, err := json.Marshal(payload)
1182 require.NoError(t, err)
1183 require.NoError(t, topologySchemaValidationError(t, payloadBytes))
1184
1185 var decoded any
1186 require.NoError(t, json.Unmarshal(payloadBytes, &decoded))
1187
1188 err = ValidateDecodedResponse(decoded)
1189 require.Error(t, err)
1190 assert.Contains(t, err.Error(), "labels.source_column references unknown column \"missing\"")
1191 })
1192 }
1193
1194 func TestValidateDecodedResponseRejectsInvalidModalSectionsShape(t *testing.T) {
1195 payload := map[string]any{
1196 "status": float64(200),
1197 "type": "topology",
1198 "data": map[string]any{
1199 "schema_version": SchemaVersion,
1200 "dictionaries": map[string]any{},
1201 "types": map[string]any{
1202 "actor_types": map[string]any{
1203 "node": map[string]any{
1204 "layer": "node",
1205 "identity": []any{"id"},
1206 "presentation": map[string]any{
1207 "modal": map[string]any{
1208 "sections": map[string]any{"not": "an-array"},
1209 },
1210 },
1211 },
1212 },
1213 "link_types": map[string]any{},
1214 "port_types": map[string]any{},
1215 "table_types": map[string]any{},
1216 },
1217 "actors": map[string]any{
1218 "rows": float64(1),
1219 "columns": []any{
1220 map[string]any{"id": "id", "type": "string", "role": "identity"},
1221 map[string]any{"id": "type", "type": "string"},
1222 },
1223 "values": []any{
1224 map[string]any{"codec": "values", "values": []any{"node-a"}},
1225 map[string]any{"codec": "const", "value": "node"},
1226 },
1227 },
1228 "links": map[string]any{
1229 "rows": float64(0),
1230 "columns": []any{
1231 map[string]any{"id": "type", "type": "string"},
1232 },
1233 "values": []any{
1234 map[string]any{"codec": "const", "value": "none"},
1235 },
1236 },
1237 },
1238 }
1239
1240 err := ValidateDecodedResponse(payload)
1241 require.Error(t, err)
1242 assert.Contains(t, err.Error(), "modal.sections is not an array")
1243 }
1244
1245 func TestValidateDecodedResponseRejectsInvalidModalRowFilters(t *testing.T) {
1246 cases := map[string]struct {
1247 filter ModalRowFilter
1248 want string
1249 }{
1250 "eq missing value": {
1251 filter: ModalRowFilter{Column: "type", Op: "eq"},
1252 want: "value is required when op is eq",
1253 },
1254 "in missing values": {
1255 filter: ModalRowFilter{Column: "type", Op: "in"},
1256 want: "values is required when op is in",
1257 },
1258 }
1259
1260 for name, tc := range cases {
1261 t.Run(name, func(t *testing.T) {
1262 payload := NewResponse(Data{
1263 Producer: Producer{Source: "test-topology", Instance: "test-instance"},
1264 CollectedAt: time.Date(2026, 5, 9, 10, 0, 0, 0, time.UTC),
1265 Dictionaries: Dictionaries{"strings": StringValues("node-a")},
1266 Types: TypeRegistry{
1267 ActorTypes: map[string]ActorType{
1268 "node": {
1269 Layer: "node",
1270 Identity: []string{"id"},
1271 Presentation: &ActorPresentation{
1272 Modal: &ModalPresentation{
1273 Sections: []ModalSection{
1274 {
1275 ID: "links",
1276 Label: "Links",
1277 Source: ModalSource{Kind: "links"},
1278 RowFilters: []ModalRowFilter{tc.filter},
1279 Columns: []ModalColumn{
1280 {
1281 ID: "type",
1282 Label: "Type",
1283 Projection: ModalProjection{
1284 Kind: "direct",
1285 Column: "type",
1286 },
1287 },
1288 },
1289 },
1290 },
1291 },
1292 },
1293 },
1294 },
1295 LinkTypes: map[string]LinkType{
1296 "dependency": {
1297 Orientation: "directed",
1298 DirectionRole: "dependency",
1299 Aggregation: LinkAggregation{Direction: "preserve"},
1300 },
1301 },
1302 },
1303 Actors: MustTable(1,
1304 []Column{
1305 NewColumn("id", "string", WithRole("identity")),
1306 NewColumn("type", "string"),
1307 },
1308 []ColumnEncoding{
1309 Values("node-a"),
1310 Const("node"),
1311 },
1312 ),
1313 Links: MustTable(1,
1314 []Column{
1315 NewColumn("src", "actor_ref"),
1316 NewColumn("dst", "actor_ref"),
1317 NewColumn("type", "string"),
1318 },
1319 []ColumnEncoding{
1320 Const(0),
1321 Const(0),
1322 Const("dependency"),
1323 },
1324 ),
1325 })
1326
1327 payloadBytes, err := json.Marshal(payload)
1328 require.NoError(t, err)
1329 require.Error(t, topologySchemaValidationError(t, payloadBytes))
1330
1331 var decoded any
1332 require.NoError(t, json.Unmarshal(payloadBytes, &decoded))
1333
1334 err = ValidateDecodedResponse(decoded)
1335 require.Error(t, err)
1336 assert.Contains(t, err.Error(), tc.want)
1337 })
1338 }
1339 }
1340
1341 func TestValidateDecodedResponseRejectsCorrelationMissingKeyColumn(t *testing.T) {
1342 correlation := &Correlation{
1343 Rules: map[string]CorrelationRule{
1344 "node_name": {
1345 Action: "link",
1346 Priority: 10,
1347 KeySpace: "node_name",
1348 Key: []CorrelationKeyPart{{Column: "name"}},
1349 PointActorTypes: []string{"node"},
1350 OutputLinkType: "dependency",
1351 },
1352 "node_owner": {
1353 Action: "link",
1354 Priority: 20,
1355 KeySpace: "node_owner",
1356 Key: []CorrelationKeyPart{{Column: "owner"}},
1357 PointActorTypes: []string{"node"},
1358 OutputLinkType: "dependency",
1359 },
1360 },
1361 Points: &Table{
1362 Rows: 1,
1363 Columns: []Column{
1364 NewColumn("actor", "actor_ref"),
1365 NewColumn("rule", "string"),
1366 },
1367 Values: []ColumnEncoding{
1368 Values(0),
1369 Const("node_name"),
1370 },
1371 },
1372 }
1373 err := validateResponseData(t, minimalValidationData(nil,
1374 withCorrelation(correlation),
1375 withLinks(dependencyLinkTable(0)),
1376 ))
1377
1378 require.Error(t, err)
1379 assert.Contains(t, err.Error(), "missing correlation key column")
1380 }
1381
1382 func TestValidateDecodedResponseAllowsUnusedCorrelationRuleColumns(t *testing.T) {
1383 correlation := &Correlation{
1384 Rules: map[string]CorrelationRule{
1385 "node_name": {
1386 Action: "link",
1387 Priority: 10,
1388 KeySpace: "node_name",
1389 Key: []CorrelationKeyPart{{Column: "name"}},
1390 PointActorTypes: []string{"node"},
1391 OutputLinkType: "dependency",
1392 },
1393 },
1394 Points: &Table{
1395 Rows: 1,
1396 Columns: []Column{
1397 NewColumn("actor", "actor_ref"),
1398 NewColumn("rule", "string"),
1399 NewColumn("name", "string_ref", WithDictionary("strings")),
1400 },
1401 Values: []ColumnEncoding{
1402 Values(0),
1403 Const("node_name"),
1404 Values(0),
1405 },
1406 },
1407 }
1408 err := validateResponseData(t, minimalValidationData(nil,
1409 withCorrelation(correlation),
1410 withLinks(dependencyLinkTable(0)),
1411 ))
1412
1413 require.NoError(t, err)
1414 }
1415
1416 func TestValidateDecodedResponseRejectsInvalidOverlaySemantics(t *testing.T) {
1417 cases := map[string]struct {
1418 mutate func(*Data)
1419 want string
1420 }{
1421 "unknown refs template": {
1422 mutate: func(data *Data) {
1423 data.Types.OverlayTemplates = testOverlayTemplates()
1424 data.Overlays = &OverlayRefs{Refs: testOverlayRefsTable(
1425 []Column{
1426 NewColumn(OverlayRefsTemplateColumn, "string"),
1427 NewColumn(OverlayRefsActorColumn, "actor_ref"),
1428 NewColumn("collect_job", "string"),
1429 NewColumn("id", "string"),
1430 },
1431 []ColumnEncoding{
1432 Const("missing"),
1433 Const(0),
1434 Const("job"),
1435 Const("node-a"),
1436 },
1437 )}
1438 },
1439 want: "references unknown overlay template",
1440 },
1441 "missing selector param column": {
1442 mutate: func(data *Data) {
1443 data.Types.OverlayTemplates = testOverlayTemplates()
1444 data.Overlays = &OverlayRefs{Refs: testOverlayRefsTable(
1445 []Column{
1446 NewColumn(OverlayRefsTemplateColumn, "string"),
1447 NewColumn(OverlayRefsActorColumn, "actor_ref"),
1448 NewColumn("id", "string"),
1449 },
1450 []ColumnEncoding{
1451 Const("node_cpu"),
1452 Const(0),
1453 Const("node-a"),
1454 },
1455 )}
1456 },
1457 want: "missing selector param column \"collect_job\"",
1458 },
1459 "reserved selector param column": {
1460 mutate: func(data *Data) {
1461 data.Types.OverlayTemplates = testOverlayTemplates()
1462 template := data.Types.OverlayTemplates["node_cpu"]
1463 template.SelectorParams = []string{OverlayRefsTemplateColumn}
1464 data.Types.OverlayTemplates["node_cpu"] = template
1465 },
1466 want: "uses reserved overlay refs column",
1467 },
1468 "missing template column": {
1469 mutate: func(data *Data) {
1470 data.Types.OverlayTemplates = testOverlayTemplates()
1471 data.Overlays = &OverlayRefs{Refs: testOverlayRefsTable(
1472 []Column{
1473 NewColumn(OverlayRefsActorColumn, "actor_ref"),
1474 NewColumn("collect_job", "string"),
1475 NewColumn("id", "string"),
1476 },
1477 []ColumnEncoding{
1478 Const(0),
1479 Const("job"),
1480 Const("node-a"),
1481 },
1482 )}
1483 },
1484 want: "missing required template column",
1485 },
1486 "template column non-string type": {
1487 mutate: func(data *Data) {
1488 data.Types.OverlayTemplates = testOverlayTemplates()
1489 data.Overlays = &OverlayRefs{Refs: testOverlayRefsTable(
1490 []Column{
1491 NewColumn(OverlayRefsTemplateColumn, "uint"),
1492 NewColumn(OverlayRefsActorColumn, "actor_ref"),
1493 NewColumn("collect_job", "string"),
1494 NewColumn("id", "string"),
1495 },
1496 []ColumnEncoding{
1497 Const(0),
1498 Const(0),
1499 Const("job"),
1500 Const("node-a"),
1501 },
1502 )}
1503 },
1504 want: "data.overlays.refs.template column must be string or string_ref",
1505 },
1506 "missing owner column": {
1507 mutate: func(data *Data) {
1508 data.Types.OverlayTemplates = testOverlayTemplates()
1509 data.Overlays = &OverlayRefs{Refs: testOverlayRefsTable(
1510 []Column{
1511 NewColumn(OverlayRefsTemplateColumn, "string"),
1512 NewColumn("collect_job", "string"),
1513 NewColumn("id", "string"),
1514 },
1515 []ColumnEncoding{
1516 Const("node_cpu"),
1517 Const("job"),
1518 Const("node-a"),
1519 },
1520 )}
1521 },
1522 want: "must contain exactly one owner column: actor actor_ref or link link_ref",
1523 },
1524 "non-convention owner column": {
1525 mutate: func(data *Data) {
1526 data.Types.OverlayTemplates = testOverlayTemplates()
1527 data.Overlays = &OverlayRefs{Refs: testOverlayRefsTable(
1528 []Column{
1529 NewColumn(OverlayRefsTemplateColumn, "string"),
1530 NewColumn("owner", "actor_ref"),
1531 NewColumn("collect_job", "string"),
1532 NewColumn("id", "string"),
1533 },
1534 []ColumnEncoding{
1535 Const("node_cpu"),
1536 Const(0),
1537 Const("job"),
1538 Const("node-a"),
1539 },
1540 )}
1541 },
1542 want: "data.overlays.refs.owner uses non-convention actor_ref owner column",
1543 },
1544 "actor owner column wrong type": {
1545 mutate: func(data *Data) {
1546 data.Types.OverlayTemplates = testOverlayTemplates()
1547 data.Overlays = &OverlayRefs{Refs: testOverlayRefsTable(
1548 []Column{
1549 NewColumn(OverlayRefsTemplateColumn, "string"),
1550 NewColumn(OverlayRefsActorColumn, "link_ref"),
1551 NewColumn("collect_job", "string"),
1552 NewColumn("id", "string"),
1553 },
1554 []ColumnEncoding{
1555 Const("node_cpu"),
1556 Const(0),
1557 Const("job"),
1558 Const("node-a"),
1559 },
1560 )}
1561 },
1562 want: "data.overlays.refs.actor column must be actor_ref",
1563 },
1564 "link owner column wrong type": {
1565 mutate: func(data *Data) {
1566 data.Types.OverlayTemplates = testOverlayTemplates()
1567 data.Overlays = &OverlayRefs{Refs: testOverlayRefsTable(
1568 []Column{
1569 NewColumn(OverlayRefsTemplateColumn, "string"),
1570 NewColumn(OverlayRefsLinkColumn, "actor_ref"),
1571 NewColumn("collect_job", "string"),
1572 NewColumn("id", "string"),
1573 },
1574 []ColumnEncoding{
1575 Const("node_cpu"),
1576 Const(0),
1577 Const("job"),
1578 Const("node-a"),
1579 },
1580 )}
1581 },
1582 want: "data.overlays.refs.link column must be link_ref",
1583 },
1584 "multiple owner columns": {
1585 mutate: func(data *Data) {
1586 data.Types.OverlayTemplates = testOverlayTemplates()
1587 data.Overlays = &OverlayRefs{Refs: testOverlayRefsTable(
1588 []Column{
1589 NewColumn(OverlayRefsTemplateColumn, "string"),
1590 NewColumn(OverlayRefsActorColumn, "actor_ref"),
1591 NewColumn(OverlayRefsLinkColumn, "link_ref"),
1592 NewColumn("collect_job", "string"),
1593 NewColumn("id", "string"),
1594 },
1595 []ColumnEncoding{
1596 Const("node_cpu"),
1597 Const(0),
1598 Const(0),
1599 Const("job"),
1600 Const("node-a"),
1601 },
1602 )}
1603 },
1604 want: "must contain exactly one owner column: actor actor_ref or link link_ref",
1605 },
1606 "null actor owner value": {
1607 mutate: func(data *Data) {
1608 data.Types.OverlayTemplates = testOverlayTemplates()
1609 data.Overlays = &OverlayRefs{Refs: testOverlayRefsTable(
1610 []Column{
1611 NewColumn(OverlayRefsTemplateColumn, "string"),
1612 NewColumn(OverlayRefsActorColumn, "actor_ref", WithNullable()),
1613 NewColumn("collect_job", "string"),
1614 NewColumn("id", "string"),
1615 },
1616 []ColumnEncoding{
1617 Const("node_cpu"),
1618 Const(nil),
1619 Const("job"),
1620 Const("node-a"),
1621 },
1622 )}
1623 },
1624 want: "data.overlays.refs.actor[0] is not a non-null owner reference",
1625 },
1626 "null link owner value": {
1627 mutate: func(data *Data) {
1628 data.Types.OverlayTemplates = testOverlayTemplates()
1629 data.Overlays = &OverlayRefs{Refs: testOverlayRefsTable(
1630 []Column{
1631 NewColumn(OverlayRefsTemplateColumn, "string"),
1632 NewColumn(OverlayRefsLinkColumn, "link_ref", WithNullable()),
1633 NewColumn("collect_job", "string"),
1634 NewColumn("id", "string"),
1635 },
1636 []ColumnEncoding{
1637 Const("node_cpu"),
1638 Const(nil),
1639 Const("job"),
1640 Const("node-a"),
1641 },
1642 )}
1643 },
1644 want: "data.overlays.refs.link[0] is not a non-null owner reference",
1645 },
1646 "selector column non-string type": {
1647 mutate: func(data *Data) {
1648 data.Types.OverlayTemplates = testOverlayTemplates()
1649 data.Overlays = &OverlayRefs{Refs: testOverlayRefsTable(
1650 []Column{
1651 NewColumn(OverlayRefsTemplateColumn, "string"),
1652 NewColumn(OverlayRefsActorColumn, "actor_ref"),
1653 NewColumn("collect_job", "string"),
1654 NewColumn("id", "uint"),
1655 },
1656 []ColumnEncoding{
1657 Const("node_cpu"),
1658 Const(0),
1659 Const("job"),
1660 Const(7),
1661 },
1662 )}
1663 },
1664 want: "data.overlays.refs.id column must be string or string_ref",
1665 },
1666 "empty selector value": {
1667 mutate: func(data *Data) {
1668 data.Types.OverlayTemplates = testOverlayTemplates()
1669 data.Overlays = &OverlayRefs{Refs: testOverlayRefsTable(
1670 []Column{
1671 NewColumn(OverlayRefsTemplateColumn, "string"),
1672 NewColumn(OverlayRefsActorColumn, "actor_ref"),
1673 NewColumn("collect_job", "string"),
1674 NewColumn("id", "string"),
1675 },
1676 []ColumnEncoding{
1677 Const("node_cpu"),
1678 Const(0),
1679 Const("job"),
1680 Const(""),
1681 },
1682 )}
1683 },
1684 want: "data.overlays.refs.id[0] is not a non-empty string",
1685 },
1686 "null selector value": {
1687 mutate: func(data *Data) {
1688 data.Types.OverlayTemplates = testOverlayTemplates()
1689 data.Overlays = &OverlayRefs{Refs: testOverlayRefsTable(
1690 []Column{
1691 NewColumn(OverlayRefsTemplateColumn, "string"),
1692 NewColumn(OverlayRefsActorColumn, "actor_ref"),
1693 NewColumn("collect_job", "string"),
1694 NewColumn("id", "string", WithNullable()),
1695 },
1696 []ColumnEncoding{
1697 Const("node_cpu"),
1698 Const(0),
1699 Const("job"),
1700 Const(nil),
1701 },
1702 )}
1703 },
1704 want: "data.overlays.refs.id[0] is not a non-empty string",
1705 },
1706 "link type references unknown template": {
1707 mutate: func(data *Data) {
1708 data.Types.LinkTypes["dependency"] = LinkType{
1709 Orientation: "directed",
1710 DirectionRole: "dependency",
1711 Aggregation: LinkAggregation{Direction: "preserve"},
1712 OverlayTemplates: []string{"missing"},
1713 }
1714 },
1715 want: "references unknown overlay template",
1716 },
1717 "invalid provider": {
1718 mutate: func(data *Data) {
1719 data.Types.OverlayTemplates = testOverlayTemplates()
1720 template := data.Types.OverlayTemplates["node_cpu"]
1721 template.Provider = "unknown"
1722 data.Types.OverlayTemplates["node_cpu"] = template
1723 },
1724 want: "provider has unsupported value",
1725 },
1726 "invalid merge refs": {
1727 mutate: func(data *Data) {
1728 data.Types.OverlayTemplates = testOverlayTemplates()
1729 template := data.Types.OverlayTemplates["node_cpu"]
1730 template.Merge.Refs = "replace"
1731 data.Types.OverlayTemplates["node_cpu"] = template
1732 },
1733 want: "merge.refs has unsupported value",
1734 },
1735 "invalid merge values": {
1736 mutate: func(data *Data) {
1737 data.Types.OverlayTemplates = testOverlayTemplates()
1738 template := data.Types.OverlayTemplates["node_cpu"]
1739 template.Merge.Values = "median"
1740 data.Types.OverlayTemplates["node_cpu"] = template
1741 },
1742 want: "merge.values has unsupported value",
1743 },
1744 }
1745
1746 for name, tc := range cases {
1747 t.Run(name, func(t *testing.T) {
1748 data := minimalValidationData(nil)
1749 tc.mutate(&data)
1750
1751 err := validateResponseData(t, data)
1752
1753 require.Error(t, err)
1754 assert.Contains(t, err.Error(), tc.want)
1755 })
1756 }
1757 }
1758
1759 func TestValidateDecodedResponseAcceptsValidOverlaySemantics(t *testing.T) {
1760 data := minimalValidationData(nil)
1761 data.Types.OverlayTemplates = testOverlayTemplates()
1762 data.Types.LinkTypes["dependency"] = LinkType{
1763 Orientation: "directed",
1764 DirectionRole: "dependency",
1765 Aggregation: LinkAggregation{Direction: "preserve"},
1766 OverlayTemplates: []string{"node_cpu"},
1767 }
1768 data.Overlays = &OverlayRefs{Refs: testOverlayRefsTable(
1769 []Column{
1770 NewColumn(OverlayRefsTemplateColumn, "string"),
1771 NewColumn(OverlayRefsLinkColumn, "link_ref"),
1772 NewColumn("collect_job", "string"),
1773 NewColumn("id", "string"),
1774 },
1775 []ColumnEncoding{
1776 Const("node_cpu"),
1777 Const(0),
1778 Const("job"),
1779 Const("node-a"),
1780 },
1781 )}
1782
1783 err := validateResponseData(t, data)
1784
1785 require.NoError(t, err)
1786 }
1787
1788 func TestValidateDecodedResponseAcceptsZeroSelectorOverlaySemantics(t *testing.T) {
1789 data := minimalValidationData(nil)
1790 data.Types.OverlayTemplates = map[string]OverlayTemplate{
1791 "node_state": NewOverlayTemplate(
1792 OverlayProviderNetdataMetrics,
1793 NewOverlayMerge(OverlayMergeRefsSet, OverlayMergeValuesLast),
1794 ),
1795 }
1796 data.Overlays = &OverlayRefs{Refs: testOverlayRefsTable(
1797 []Column{
1798 NewColumn(OverlayRefsTemplateColumn, "string"),
1799 NewColumn(OverlayRefsActorColumn, "actor_ref"),
1800 },
1801 []ColumnEncoding{
1802 Const("node_state"),
1803 Const(0),
1804 },
1805 )}
1806
1807 err := validateResponseData(t, data)
1808
1809 require.NoError(t, err)
1810 }
1811
1812 func TestValidateDecodedResponseAcceptsMultiTemplateOverlaySelectorScoping(t *testing.T) {
1813 data := minimalValidationData(nil)
1814 data.Types.OverlayTemplates = map[string]OverlayTemplate{
1815 "node_cpu": NewOverlayTemplate(
1816 OverlayProviderNetdataMetrics,
1817 NewOverlayMerge(OverlayMergeRefsSet, OverlayMergeValuesLast),
1818 WithOverlaySelectorParams("cpu_id"),
1819 ),
1820 "node_mem": NewOverlayTemplate(
1821 OverlayProviderNetdataMetrics,
1822 NewOverlayMerge(OverlayMergeRefsSet, OverlayMergeValuesLast),
1823 WithOverlaySelectorParams("mem_id"),
1824 ),
1825 }
1826 data.Overlays = &OverlayRefs{Refs: testOverlayRefsTableRows(2,
1827 []Column{
1828 NewColumn(OverlayRefsTemplateColumn, "string"),
1829 NewColumn(OverlayRefsActorColumn, "actor_ref"),
1830 NewColumn("cpu_id", "string", WithNullable()),
1831 NewColumn("mem_id", "string", WithNullable()),
1832 },
1833 []ColumnEncoding{
1834 Values("node_cpu", "node_mem"),
1835 Const(0),
1836 Values("cpu0", nil),
1837 Values(nil, "mem0"),
1838 },
1839 )}
1840
1841 err := validateResponseData(t, data)
1842
1843 require.NoError(t, err)
1844 }
1845
1846 func TestPresentationTokenEnumsMatchSchema(t *testing.T) {
1847 schemaDoc := loadTopologySchema(t)
1848
1849 assert.ElementsMatch(t, colorSlotTokens, schemaEnum(t, schemaDoc, "color_slot"))
1850 assert.ElementsMatch(t, opacityTokens, schemaEnum(t, schemaDoc, "opacity_token"))
1851 assert.ElementsMatch(t, widthTokens, schemaEnum(t, schemaDoc, "width_token"))
1852 assert.ElementsMatch(t, layoutStrengthTokens, schemaEnum(t, schemaDoc, "layout_strength_token"))
1853 assert.ElementsMatch(t, layoutDistanceTokens, schemaEnum(t, schemaDoc, "layout_distance_token"))
1854 assert.ElementsMatch(t, actorSizeScaleTokens, schemaEnum(t, schemaDoc, "actor_size_scale_token"))
1855 assert.ElementsMatch(t, linkSemanticRoleTokens, schemaEnum(t, schemaDoc, "link_semantic_role"))
1856 assert.ElementsMatch(t, iconTokens, schemaEnum(t, schemaDoc, "icon_token"))
1857 assert.ElementsMatch(t, overlayProviderTokens, schemaEnumAtPath(t, schemaDoc, "$defs", "overlay_template", "properties", "provider"))
1858 assert.ElementsMatch(t, overlayMergeRefsTokens, schemaEnumAtPath(t, schemaDoc, "$defs", "overlay_merge", "properties", "refs"))
1859 assert.ElementsMatch(t, overlayMergeValuesTokens, schemaEnumAtPath(t, schemaDoc, "$defs", "overlay_merge", "properties", "values"))
1860 }
1861
1862 func TestValidateDecodedResponseRejectsInvalidActorReference(t *testing.T) {
1863 payload := map[string]any{
1864 "status": float64(200),
1865 "type": "topology",
1866 "data": map[string]any{
1867 "schema_version": SchemaVersion,
1868 "dictionaries": map[string]any{
1869 "strings": []any{"node-a"},
1870 },
1871 "types": map[string]any{},
1872 "actors": map[string]any{
1873 "rows": float64(1),
1874 "columns": []any{map[string]any{"id": "id", "type": "string"}},
1875 "values": []any{map[string]any{"codec": "values", "values": []any{"node-a"}}},
1876 },
1877 "links": map[string]any{
1878 "rows": float64(1),
1879 "columns": []any{
1880 map[string]any{"id": "src", "type": "actor_ref"},
1881 map[string]any{"id": "dst", "type": "actor_ref"},
1882 },
1883 "values": []any{
1884 map[string]any{"codec": "values", "values": []any{float64(0)}},
1885 map[string]any{"codec": "values", "values": []any{float64(3)}},
1886 },
1887 },
1888 },
1889 }
1890
1891 err := ValidateDecodedResponse(payload)
1892 require.Error(t, err)
1893 assert.Contains(t, err.Error(), "actor reference out of bounds")
1894 }
1895
1896 func TestValidateDecodedResponseRejectsInvalidEvidenceSectionShape(t *testing.T) {
1897 payload := map[string]any{
1898 "status": float64(200),
1899 "type": "topology",
1900 "data": map[string]any{
1901 "schema_version": SchemaVersion,
1902 "dictionaries": map[string]any{},
1903 "types": map[string]any{},
1904 "actors": map[string]any{
1905 "rows": float64(0),
1906 "columns": []any{},
1907 "values": []any{},
1908 },
1909 "links": map[string]any{
1910 "rows": float64(0),
1911 "columns": []any{},
1912 "values": []any{},
1913 },
1914 "evidence": map[string]any{
1915 "socket": "not-an-object",
1916 },
1917 },
1918 }
1919
1920 err := ValidateDecodedResponse(payload)
1921 require.Error(t, err)
1922 assert.Contains(t, err.Error(), "data.evidence.socket is not an object")
1923 }
1924
1925 func validateResponseData(t *testing.T, data Data) error {
1926 t.Helper()
1927
1928 payloadBytes, err := json.Marshal(NewResponse(data))
1929 require.NoError(t, err)
1930
1931 var decoded any
1932 require.NoError(t, json.Unmarshal(payloadBytes, &decoded))
1933 return ValidateDecodedResponse(decoded)
1934 }
1935
1936 func minimalValidationData(actorPresentation *ActorPresentation, opts ...func(*Data)) Data {
1937 data := Data{
1938 Producer: Producer{Source: "test-topology", Instance: "test-instance"},
1939 CollectedAt: testCollectedAt,
1940 Dictionaries: Dictionaries{"strings": StringValues("node-a")},
1941 Types: TypeRegistry{
1942 ActorTypes: map[string]ActorType{
1943 "node": {
1944 Layer: "node",
1945 Identity: []string{"id"},
1946 Presentation: actorPresentation,
1947 },
1948 },
1949 LinkTypes: map[string]LinkType{
1950 "dependency": {
1951 Orientation: "directed",
1952 DirectionRole: "dependency",
1953 Aggregation: LinkAggregation{Direction: "preserve"},
1954 },
1955 },
1956 },
1957 Actors: minimalActorTable(nil, nil),
1958 Links: dependencyLinkTable(1),
1959 }
1960
1961 for _, opt := range opts {
1962 opt(&data)
1963 }
1964 return data
1965 }
1966
1967 func minimalActorTable(extraColumns []Column, extraValues []ColumnEncoding) Table {
1968 columns := []Column{
1969 NewColumn("id", "string", WithRole("identity")),
1970 NewColumn("type", "string"),
1971 }
1972 values := []ColumnEncoding{
1973 Values("node-a"),
1974 Const("node"),
1975 }
1976 columns = append(columns, extraColumns...)
1977 values = append(values, extraValues...)
1978 return MustTable(1, columns, values)
1979 }
1980
1981 func dependencyLinkTable(rows int) Table {
1982 return dependencyLinkTableWith(rows, nil, nil)
1983 }
1984
1985 func dependencyLinkTableWith(rows int, extraColumns []Column, extraValues []ColumnEncoding) Table {
1986 columns := []Column{
1987 NewColumn("src", "actor_ref"),
1988 NewColumn("dst", "actor_ref"),
1989 NewColumn("type", "string"),
1990 }
1991 values := []ColumnEncoding{
1992 Const(0),
1993 Const(0),
1994 Const("dependency"),
1995 }
1996 columns = append(columns, extraColumns...)
1997 values = append(values, extraValues...)
1998 return MustTable(rows, columns, values)
1999 }
2000
2001 func testOverlayTemplates() map[string]OverlayTemplate {
2002 return map[string]OverlayTemplate{
2003 "node_cpu": NewOverlayTemplate(
2004 OverlayProviderNetdataMetrics,
2005 NewOverlayMerge(OverlayMergeRefsSet, OverlayMergeValuesLast),
2006 WithOverlayContexts("system.cpu"),
2007 WithOverlayDimensions("user"),
2008 WithOverlaySelectorParams("collect_job", "id"),
2009 ),
2010 }
2011 }
2012
2013 func testOverlayRefsTable(columns []Column, values []ColumnEncoding) *Table {
2014 return testOverlayRefsTableRows(1, columns, values)
2015 }
2016
2017 func testOverlayRefsTableRows(rows int, columns []Column, values []ColumnEncoding) *Table {
2018 table := MustTable(rows, columns, values)
2019 return &table
2020 }
2021
2022 func withActors(actors Table) func(*Data) {
2023 return func(data *Data) {
2024 data.Actors = actors
2025 }
2026 }
2027
2028 func withLinks(links Table) func(*Data) {
2029 return func(data *Data) {
2030 data.Links = links
2031 }
2032 }
2033
2034 func withPortTypes(portTypes map[string]PortType) func(*Data) {
2035 return func(data *Data) {
2036 data.Types.PortTypes = portTypes
2037 }
2038 }
2039
2040 func withCorrelation(correlation *Correlation) func(*Data) {
2041 return func(data *Data) {
2042 data.Correlation = correlation
2043 }
2044 }
2045
2046 func validateAgainstTopologySchema(t *testing.T, payload []byte) {
2047 t.Helper()
2048
2049 require.NoError(t, topologySchemaValidationError(t, payload))
2050 }
2051
2052 func topologySchemaValidationError(t *testing.T, payload []byte) error {
2053 t.Helper()
2054
2055 schemaDoc := loadTopologySchema(t)
2056
2057 compiler := jsonschema.NewCompiler()
2058 require.NoError(t, compiler.AddResource("schema.json", schemaDoc))
2059 schema, err := compiler.Compile("schema.json")
2060 require.NoError(t, err)
2061
2062 var decoded any
2063 require.NoError(t, json.Unmarshal(payload, &decoded))
2064 return schema.Validate(decoded)
2065 }
2066
2067 func loadTopologySchema(t *testing.T) map[string]any {
2068 t.Helper()
2069
2070 schemaPath := filepath.Clean(filepath.Join("..", "..", "..", "..", "plugins.d", "FUNCTION_TOPOLOGY_SCHEMA.json"))
2071 schemaBytes, err := os.ReadFile(schemaPath)
2072 require.NoError(t, err)
2073
2074 var schemaDoc map[string]any
2075 require.NoError(t, json.Unmarshal(schemaBytes, &schemaDoc))
2076 return schemaDoc
2077 }
2078
2079 func schemaEnum(t *testing.T, schemaDoc map[string]any, defName string) []string {
2080 t.Helper()
2081
2082 return schemaEnumAtPath(t, schemaDoc, "$defs", defName)
2083 }
2084
2085 func schemaEnumAtPath(t *testing.T, schemaDoc map[string]any, path ...string) []string {
2086 t.Helper()
2087
2088 var current any = schemaDoc
2089 for _, element := range path {
2090 obj, ok := current.(map[string]any)
2091 require.True(t, ok)
2092 current, ok = obj[element]
2093 require.True(t, ok)
2094 }
2095 definition, ok := current.(map[string]any)
2096 require.True(t, ok)
2097 rawEnum, ok := definition["enum"].([]any)
2098 require.True(t, ok)
2099
2100 values := make([]string, 0, len(rawEnum))
2101 for _, raw := range rawEnum {
2102 value, ok := raw.(string)
2103 require.True(t, ok)
2104 values = append(values, value)
2105 }
2106 return values
2107 }