| 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 | } |