From 59d9480279473bcb6381c8a9d5d146be33a69d1f Mon Sep 17 00:00:00 2001 From: Wes <169498386+wes-mil@users.noreply.github.com> Date: Tue, 14 Apr 2026 12:04:21 -0400 Subject: [PATCH 01/11] Fix bug with throwing away trailing data Change schema name Add trusted domains info to readme Clean up ParseAndValidate --- README.md | 8 +- .../{opengraph.json => payload-schema.json} | 0 pkg/validator/validator.go | 74 +++++++++++++++---- pkg/validator/validator_test.go | 11 +++ 4 files changed, 79 insertions(+), 14 deletions(-) rename pkg/validator/jsonschema/{opengraph.json => payload-schema.json} (100%) diff --git a/README.md b/README.md index f63c839..f0949ff 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,12 @@ Want to add the OpenGraph schema to your JSON document? ```json { - "$schema": "https://raw.githubusercontent.com/SpecterOps/chow/refs/heads/main/pkg/validator/jsonschema/opengraph.json" + "$schema": "https://raw.githubusercontent.com/SpecterOps/chow/refs/heads/main/pkg/validator/jsonschema/payload-schema.json" } ``` + +Most editors will ask you to trust the schema's source. Be sure to add the following URL to your trusted domains + +```text +https://raw.githubusercontent.com/SpecterOps/chow/refs/heads/main/pkg/validator/jsonschema/ +``` diff --git a/pkg/validator/jsonschema/opengraph.json b/pkg/validator/jsonschema/payload-schema.json similarity index 100% rename from pkg/validator/jsonschema/opengraph.json rename to pkg/validator/jsonschema/payload-schema.json diff --git a/pkg/validator/validator.go b/pkg/validator/validator.go index e4eedb9..c98bbd1 100644 --- a/pkg/validator/validator.go +++ b/pkg/validator/validator.go @@ -157,6 +157,11 @@ func (v *Validator) buildValidationReport() ValidationReport { } } +// result() is a helper for returning the current parsed data, validation report, and provided error. +func (v *Validator) result(err error) (ParsedData, ValidationReport, error) { + return v.buildParsedData(), v.buildValidationReport(), err +} + // Error Helper functions ------------------------------------------------------------------------- // reportCriticalError() is a helper function for adding a critical error @@ -245,29 +250,57 @@ func (v *Validator) finalFileConfigCheck() error { func (v *Validator) ParseAndValidate() (ParsedData, ValidationReport, error) { if err := v.enterObject(); err != nil { v.reportCriticalError("failed to enter json object", err) - return v.buildParsedData(), v.buildValidationReport(), err + return v.result(err) } valLoopErr := v.validationLoop() + + if err := v.readToEnd(valLoopErr); err != nil { + return v.result(err) + } + + return v.result(v.finalizeParse()) +} + +// readToEnd() checks for trailing input if validation succeeded, then consumes all remaining bytes from the decoder +// buffer and reader while preserving any existing loop error. +func (v *Validator) readToEnd(loopErr error) error { + errToReturn := loopErr + if errToReturn == nil { + if err := v.expectEOF(); err != nil { + v.reportCriticalError("expected to hit the end of the file", err) + errToReturn = err + } + } + // This multireader ensures that bytes included in the json decoder's buffer. This guarantees that ALL bytes are read from the io.Reader _, readToEndErr := io.Copy(io.Discard, io.MultiReader(v.decoder.Buffered(), v.reader)) - if valLoopErr != nil && readToEndErr != nil { + if readToEndErr != nil { v.reportCriticalError("failed to read file to end", readToEndErr) - return v.buildParsedData(), v.buildValidationReport(), errors.Join(valLoopErr, readToEndErr) - } else if valLoopErr == nil && readToEndErr != nil { - v.reportCriticalError("failed to read file to end", readToEndErr) - return v.buildParsedData(), v.buildValidationReport(), readToEndErr - } else if valLoopErr != nil { - return v.buildParsedData(), v.buildValidationReport(), valLoopErr } + if errToReturn != nil && readToEndErr != nil { + return errors.Join(errToReturn, readToEndErr) + } + + if readToEndErr != nil { + return readToEndErr + } + + return errToReturn +} + +// finalizeParse() performs the final post-parse validation checks and collapses validation errors into a single error. +func (v *Validator) finalizeParse() error { if err := v.finalFileConfigCheck(); err != nil { - return v.buildParsedData(), v.buildValidationReport(), err - } else if len(v.validationErrors) > 0 { - return v.buildParsedData(), v.buildValidationReport(), ErrValidationErrors - } else { - return v.buildParsedData(), v.buildValidationReport(), nil + return err + } + + if len(v.validationErrors) > 0 { + return ErrValidationErrors } + + return nil } // Validation Loop functions ---------------------------------------------------------------------- @@ -642,3 +675,18 @@ func (v *Validator) nextToken() (json.Token, error) { return tok, nil } + +// expectEOF() reads the next JSON token and expects to hit the end of the file. Returns an error otherwise +func (v *Validator) expectEOF() error { + tok, err := v.nextToken() + + if err == io.EOF { + return nil + } + + if err != nil { + return err + } + + return fmt.Errorf("expected EOF, instead got token: %v", tok) +} diff --git a/pkg/validator/validator_test.go b/pkg/validator/validator_test.go index acaf591..f352373 100644 --- a/pkg/validator/validator_test.go +++ b/pkg/validator/validator_test.go @@ -376,6 +376,17 @@ func Test_ParseAndValidate(t *testing.T) { assert.ElementsMatch(t, report.CriticalErrors, []validator.CriticalError{{Message: "unrecognized top level tag: pants", Error: validator.ErrInvalidFileConfiguration}}) }, }, + { + name: "unsuccessful payload, trailing data after object", + payload: `{"graph":{"nodes":[]}}{}`, + expectedParsedData: validator.ParsedData{PayloadType: ingest.DataTypeOpenGraph}, + errValidationFunc: func(t *testing.T, report validator.ValidationReport, err error) { + assert.ErrorContains(t, err, "expected EOF, instead got token: {") + require.Len(t, report.CriticalErrors, 1) + assert.Equal(t, "expected to hit the end of the file", report.CriticalErrors[0].Message) + assert.ErrorContains(t, report.CriticalErrors[0].Error, "expected EOF, instead got token: {") + }, + }, } schema, err := validator.LoadIngestSchema() From 0b8b2ba3e080ed7dd71694ee511bd1be6d388dbd Mon Sep 17 00:00:00 2001 From: Wes <169498386+wes-mil@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:18:31 -0400 Subject: [PATCH 02/11] update go version --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 6430e78..df22e5a 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/specterops/chow -go 1.25.3 +go 1.26.2 require ( github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 From 03700a7dc35ddd741f6408df2ee39ff94ad735b0 Mon Sep 17 00:00:00 2001 From: Wes <169498386+wes-mil@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:20:24 -0400 Subject: [PATCH 03/11] Add ParseMetadata --- pkg/validator/validator.go | 79 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 75 insertions(+), 4 deletions(-) diff --git a/pkg/validator/validator.go b/pkg/validator/validator.go index c98bbd1..c833c84 100644 --- a/pkg/validator/validator.go +++ b/pkg/validator/validator.go @@ -122,8 +122,11 @@ type ParsedOpenGraphData struct { EdgesValidated int } -// buildParsedData() aggregates data collected during ParseAndValidate() into the ParsedData struct -func (v *Validator) buildParsedData() ParsedData { +// buildValidatedData() aggregates data collected during ParseAndValidate() into the ParsedData struct. +// It is specific to the validation path and relies on signals (GraphFound, NodesValidated, etc.) +// that are only populated by validationLoop. ParseMetadata() builds its result inline rather than +// using this helper. +func (v *Validator) buildValidatedData() ParsedData { p := ParsedData{} if (v.opengraphData.GraphFound || v.opengraphData.MetadataFound) && (v.originalData.MetadataFound || v.originalData.DataFound) { @@ -159,7 +162,7 @@ func (v *Validator) buildValidationReport() ValidationReport { // result() is a helper for returning the current parsed data, validation report, and provided error. func (v *Validator) result(err error) (ParsedData, ValidationReport, error) { - return v.buildParsedData(), v.buildValidationReport(), err + return v.buildValidatedData(), v.buildValidationReport(), err } // Error Helper functions ------------------------------------------------------------------------- @@ -262,6 +265,34 @@ func (v *Validator) ParseAndValidate() (ParsedData, ValidationReport, error) { return v.result(v.finalizeParse()) } +// ParseMetadata() walks the top-level JSON object and extracts metadata (either legacy "meta" or +// opengraph "metadata") without performing schema validation of the payload body. It returns as soon +// as a metadata tag is successfully decoded; the remainder of the reader is not consumed. +// +// The returned ParsedData only contains fields derivable from the metadata header — PayloadType, +// LegacyMetadata, and OpengraphData.Metadata. Validation-only fields (NodesValidated, +// EdgesValidated) are always zero. PayloadType is empty when no top-level metadata tag is found; +// callers handling opengraph payloads with no metadata block should default appropriately. +func (v *Validator) ParseMetadata() (ParsedData, error) { + if err := v.enterObject(); err != nil { + v.reportCriticalError("failed to enter json object", err) + return ParsedData{}, err + } + + err := v.parseLoop() + + p := ParsedData{} + switch { + case v.originalData.MetadataFound: + p.PayloadType = v.originalData.Metadata.Type + p.LegacyMetadata = v.originalData.Metadata + case v.opengraphData.MetadataFound: + p.PayloadType = ingest.DataTypeOpenGraph + p.OpengraphData.Metadata = v.opengraphData.Metadata + } + return p, err +} + // readToEnd() checks for trailing input if validation succeeded, then consumes all remaining bytes from the decoder // buffer and reader while preserving any existing loop error. func (v *Validator) readToEnd(loopErr error) error { @@ -303,7 +334,7 @@ func (v *Validator) finalizeParse() error { return nil } -// Validation Loop functions ---------------------------------------------------------------------- +// Loop functions ---------------------------------------------------------------------- // validationLoop() is the primary driver behind the file validation. It walks through the file and directs to // child validation functions @@ -383,6 +414,46 @@ func (v *Validator) validationLoop() error { } } +// parseLoop() walks the top-level object looking for tags that identify the payload shape +// ("meta", "metadata", or "graph"), decoding any metadata tag into the Validator's internal state. +// All other tags have their values skipped via token streaming so that arbitrarily large payload +// bodies are not buffered. It returns as soon as a tag that uniquely identifies the payload type +// is found or the top-level object is exited. +func (v *Validator) parseLoop() error { + for { + if tag, exitedBlock, err := v.nextTagAtDepth(1); err != nil { + v.reportCriticalError("failed parsing top level tag", err) + return err + } else if exitedBlock { + return nil + } else { + switch tag { + case "meta": + var metadata ingest.OriginalMetadata + if err := v.decoder.Decode(&metadata); err != nil { + v.reportCriticalError("failed to decode original metadata", err) + return err + } + + v.originalData.MetadataFound = true + v.originalData.Metadata = metadata + return nil + case "metadata": + var metadata ingest.OpengraphMetadata + if err := v.decoder.Decode(&metadata); err != nil { + v.reportCriticalError("failed to decode opengraph metadata", err) + return err + } + + v.opengraphData.MetadataFound = true + v.opengraphData.Metadata = metadata + return nil + default: + } + } + } +} + // handleOriginalMetadata() parses and validates original metadata after a "meta" tag is found at the top level func (v *Validator) handleOriginalMetadata() (ingest.OriginalMetadata, error) { var originalMetadata ingest.OriginalMetadata From adb56406d8c8bbd6d0bdbd54700012d3d202b6a5 Mon Sep 17 00:00:00 2001 From: Wes <169498386+wes-mil@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:39:30 -0400 Subject: [PATCH 04/11] go mod tidy --- go.mod | 5 ++++- go.sum | 8 +++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index df22e5a..a2e7b10 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,10 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/kr/pretty v0.3.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - golang.org/x/text v0.34.0 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + golang.org/x/text v0.35.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 6116b92..ca794bc 100644 --- a/go.sum +++ b/go.sum @@ -2,15 +2,17 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From a12efb9c2d245f1c15b8c6e10b9a64c9276847cc Mon Sep 17 00:00:00 2001 From: Wes <169498386+wes-mil@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:49:42 -0400 Subject: [PATCH 05/11] Clean up comments --- pkg/validator/validator.go | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/pkg/validator/validator.go b/pkg/validator/validator.go index c833c84..894a8ea 100644 --- a/pkg/validator/validator.go +++ b/pkg/validator/validator.go @@ -268,11 +268,6 @@ func (v *Validator) ParseAndValidate() (ParsedData, ValidationReport, error) { // ParseMetadata() walks the top-level JSON object and extracts metadata (either legacy "meta" or // opengraph "metadata") without performing schema validation of the payload body. It returns as soon // as a metadata tag is successfully decoded; the remainder of the reader is not consumed. -// -// The returned ParsedData only contains fields derivable from the metadata header — PayloadType, -// LegacyMetadata, and OpengraphData.Metadata. Validation-only fields (NodesValidated, -// EdgesValidated) are always zero. PayloadType is empty when no top-level metadata tag is found; -// callers handling opengraph payloads with no metadata block should default appropriately. func (v *Validator) ParseMetadata() (ParsedData, error) { if err := v.enterObject(); err != nil { v.reportCriticalError("failed to enter json object", err) @@ -415,10 +410,9 @@ func (v *Validator) validationLoop() error { } // parseLoop() walks the top-level object looking for tags that identify the payload shape -// ("meta", "metadata", or "graph"), decoding any metadata tag into the Validator's internal state. -// All other tags have their values skipped via token streaming so that arbitrarily large payload -// bodies are not buffered. It returns as soon as a tag that uniquely identifies the payload type -// is found or the top-level object is exited. +// ("meta", "metadata"), decoding any metadata tag into the Validator's internal state and +// returning as soon as a tag that uniquely identifies the payload type is found or the +// top-level object is exited. func (v *Validator) parseLoop() error { for { if tag, exitedBlock, err := v.nextTagAtDepth(1); err != nil { From 8bc1dd8ea6d10b60c442bda3bcf38d50c6fcc956 Mon Sep 17 00:00:00 2001 From: Wes <169498386+wes-mil@users.noreply.github.com> Date: Thu, 30 Apr 2026 17:59:07 -0400 Subject: [PATCH 06/11] it's the greatest commit to ever commit, delivering high quality code and spiritual enlightenment. mostly renaming the package --- README.md | 4 +- go.sum | 11 + main.go | 12 +- .../jsonschema/edge.json | 0 .../jsonschema/metadata.json | 0 .../jsonschema/node.json | 0 .../jsonschema/schema.json} | 0 pkg/{validator => payload}/schema.go | 8 +- pkg/{validator => payload}/validator.go | 11 +- pkg/payload/validator_test.go | 404 ++++++++++++++++++ pkg/validator/validator_test.go | 404 ------------------ 11 files changed, 435 insertions(+), 419 deletions(-) rename pkg/{validator => payload}/jsonschema/edge.json (100%) rename pkg/{validator => payload}/jsonschema/metadata.json (100%) rename pkg/{validator => payload}/jsonschema/node.json (100%) rename pkg/{validator/jsonschema/payload-schema.json => payload/jsonschema/schema.json} (100%) rename pkg/{validator => payload}/schema.go (94%) rename pkg/{validator => payload}/validator.go (99%) create mode 100644 pkg/payload/validator_test.go delete mode 100644 pkg/validator/validator_test.go diff --git a/README.md b/README.md index f0949ff..7b3bc0d 100644 --- a/README.md +++ b/README.md @@ -28,12 +28,12 @@ Want to add the OpenGraph schema to your JSON document? ```json { - "$schema": "https://raw.githubusercontent.com/SpecterOps/chow/refs/heads/main/pkg/validator/jsonschema/payload-schema.json" + "$schema": "https://raw.githubusercontent.com/SpecterOps/chow/refs/heads/main/pkg/payload/jsonschema/schema.json" } ``` Most editors will ask you to trust the schema's source. Be sure to add the following URL to your trusted domains ```text -https://raw.githubusercontent.com/SpecterOps/chow/refs/heads/main/pkg/validator/jsonschema/ +https://raw.githubusercontent.com/SpecterOps/chow/refs/heads/main/pkg/payload/jsonschema/ ``` diff --git a/go.sum b/go.sum index ca794bc..b5a1cf9 100644 --- a/go.sum +++ b/go.sum @@ -1,18 +1,29 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index da5c228..fb8451f 100644 --- a/main.go +++ b/main.go @@ -10,7 +10,7 @@ import ( "os" "strings" - "github.com/specterops/chow/pkg/validator" + "github.com/specterops/chow/pkg/payload" ) var ( @@ -40,13 +40,13 @@ func main() { } defer reader.Close() - jsonSchema, err := validator.LoadIngestSchema() + jsonSchema, err := payload.LoadSchema() if err != nil { slog.Error("Failed to load ingest schema", slog.String("err", err.Error())) os.Exit(1) } - v := validator.NewValidator(reader, jsonSchema) + v := payload.NewValidator(reader, jsonSchema) _, report, err := v.ParseAndValidate() validationFailed := err != nil @@ -76,7 +76,7 @@ func main() { } } -func outputReport(w io.WriteCloser, report validator.ValidationReport) error { +func outputReport(w io.WriteCloser, report payload.ValidationReport) error { for _, e := range report.CriticalErrors { _, err := w.Write([]byte(formatCriticalError(e))) if err != nil { @@ -108,11 +108,11 @@ func outputReport(w io.WriteCloser, report validator.ValidationReport) error { return nil } -func formatCriticalError(e validator.CriticalError) string { +func formatCriticalError(e payload.CriticalError) string { return fmt.Sprintf("CRITICAL ERROR:\n%s\n%v", e.Message, e.Error) } -func formatValidationError(valErr validator.ValidationError) (string, error) { +func formatValidationError(valErr payload.ValidationError) (string, error) { var ( sb strings.Builder objBytes bytes.Buffer diff --git a/pkg/validator/jsonschema/edge.json b/pkg/payload/jsonschema/edge.json similarity index 100% rename from pkg/validator/jsonschema/edge.json rename to pkg/payload/jsonschema/edge.json diff --git a/pkg/validator/jsonschema/metadata.json b/pkg/payload/jsonschema/metadata.json similarity index 100% rename from pkg/validator/jsonschema/metadata.json rename to pkg/payload/jsonschema/metadata.json diff --git a/pkg/validator/jsonschema/node.json b/pkg/payload/jsonschema/node.json similarity index 100% rename from pkg/validator/jsonschema/node.json rename to pkg/payload/jsonschema/node.json diff --git a/pkg/validator/jsonschema/payload-schema.json b/pkg/payload/jsonschema/schema.json similarity index 100% rename from pkg/validator/jsonschema/payload-schema.json rename to pkg/payload/jsonschema/schema.json diff --git a/pkg/validator/schema.go b/pkg/payload/schema.go similarity index 94% rename from pkg/validator/schema.go rename to pkg/payload/schema.go index bfa5709..171a121 100644 --- a/pkg/validator/schema.go +++ b/pkg/payload/schema.go @@ -13,7 +13,7 @@ // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 -package validator +package payload import ( "bytes" @@ -26,14 +26,14 @@ import ( //go:embed jsonschema var schemaFiles embed.FS -type IngestSchema struct { +type Schema struct { NodeSchema *jsonschema.Schema EdgeSchema *jsonschema.Schema MetaSchema *jsonschema.Schema } -func LoadIngestSchema() (IngestSchema, error) { - var schema IngestSchema +func LoadSchema() (Schema, error) { + var schema Schema if nodeSchema, err := loadSchema("node.json"); err != nil { return schema, err } else if edgeSchema, err := loadSchema("edge.json"); err != nil { diff --git a/pkg/validator/validator.go b/pkg/payload/validator.go similarity index 99% rename from pkg/validator/validator.go rename to pkg/payload/validator.go index 894a8ea..a647d50 100644 --- a/pkg/validator/validator.go +++ b/pkg/payload/validator.go @@ -13,7 +13,7 @@ // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 -package validator +package payload import ( "encoding/json" @@ -43,7 +43,7 @@ type Validator struct { decoder *json.Decoder depth int - schema IngestSchema + schema Schema originalData originalData opengraphData opengraphData @@ -72,7 +72,7 @@ type opengraphData struct { EdgesValidated int } -func NewValidator(reader io.Reader, schema IngestSchema) Validator { +func NewValidator(reader io.Reader, schema Schema) Validator { return Validator{ reader: reader, decoder: json.NewDecoder(reader), @@ -284,7 +284,10 @@ func (v *Validator) ParseMetadata() (ParsedData, error) { case v.opengraphData.MetadataFound: p.PayloadType = ingest.DataTypeOpenGraph p.OpengraphData.Metadata = v.opengraphData.Metadata + case v.opengraphData.GraphFound: + p.PayloadType = ingest.DataTypeOpenGraph } + return p, err } @@ -442,6 +445,8 @@ func (v *Validator) parseLoop() error { v.opengraphData.MetadataFound = true v.opengraphData.Metadata = metadata return nil + case "graph": + v.opengraphData.GraphFound = true default: } } diff --git a/pkg/payload/validator_test.go b/pkg/payload/validator_test.go new file mode 100644 index 0000000..362fa6c --- /dev/null +++ b/pkg/payload/validator_test.go @@ -0,0 +1,404 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +package payload_test + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/specterops/chow/pkg/ingest" + "github.com/specterops/chow/pkg/payload" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var emptyValidationReport = payload.ValidationReport{CriticalErrors: []payload.CriticalError{}, ValidationErrors: []payload.ValidationError{}} + +type parseAndValidateAssertion struct { + name string + payload string + expectedParsedData payload.ParsedData + errValidationFunc func(t *testing.T, report payload.ValidationReport, err error) +} + +func Test_ParseAndValidate(t *testing.T) { + assertions := []parseAndValidateAssertion{ + // OpenGraph payload tests + { + name: "successful opengraph payload", + payload: `{"metadata":{},"graph":{"nodes":[]}}`, + expectedParsedData: payload.ParsedData{PayloadType: ingest.DataTypeOpenGraph}, + errValidationFunc: func(t *testing.T, report payload.ValidationReport, err error) { + assert.Equal(t, emptyValidationReport, report) + assert.NoError(t, err) + }, + }, + { + name: "successful opengraph payload with no metadata", + payload: `{"graph":{"nodes":[]}}`, + expectedParsedData: payload.ParsedData{PayloadType: ingest.DataTypeOpenGraph}, + errValidationFunc: func(t *testing.T, report payload.ValidationReport, err error) { + assert.Equal(t, emptyValidationReport, report) + assert.NoError(t, err) + }, + }, + { + name: "successful opengraph metadata", + payload: `{"metadata":{"source_kind":"hellobase"},"graph":{"nodes":[]}}`, + expectedParsedData: payload.ParsedData{PayloadType: ingest.DataTypeOpenGraph, OpengraphData: payload.ParsedOpenGraphData{Metadata: ingest.OpengraphMetadata{SourceKind: "hellobase"}}}, + errValidationFunc: func(t *testing.T, report payload.ValidationReport, err error) { + assert.Equal(t, emptyValidationReport, report) + assert.NoError(t, err) + }, + }, + { + name: "successful opengraph payload with $schema", + payload: `{"$schema":"test","metadata":{"source_kind":"hellobase"},"graph":{"nodes":[]}}`, + expectedParsedData: payload.ParsedData{PayloadType: ingest.DataTypeOpenGraph, OpengraphData: payload.ParsedOpenGraphData{Metadata: ingest.OpengraphMetadata{SourceKind: "hellobase"}}}, + errValidationFunc: func(t *testing.T, report payload.ValidationReport, err error) { + assert.Equal(t, emptyValidationReport, report) + assert.NoError(t, err) + }, + }, + { + name: "successful opengraph payload with node", + payload: `{"metadata":{"source_kind":"hellobase"},"graph":{"nodes":[{"id":"TESTNODE","kinds":["User"],"properties":{"items":["hi"]}}]}}`, + expectedParsedData: payload.ParsedData{PayloadType: ingest.DataTypeOpenGraph, OpengraphData: payload.ParsedOpenGraphData{Metadata: ingest.OpengraphMetadata{SourceKind: "hellobase"}, NodesValidated: 1}}, + errValidationFunc: func(t *testing.T, report payload.ValidationReport, err error) { + assert.Equal(t, emptyValidationReport, report) + assert.NoError(t, err) + }, + }, + { + name: "unsuccessful opengraph payload, node id validation error", + payload: `{"metadata":{"source_kind":"hellobase"},"graph":{"nodes":[{"id":1,"kinds":["User"]}]}}`, + expectedParsedData: payload.ParsedData{PayloadType: ingest.DataTypeOpenGraph, OpengraphData: payload.ParsedOpenGraphData{Metadata: ingest.OpengraphMetadata{SourceKind: "hellobase"}, NodesValidated: 1}}, + errValidationFunc: func(t *testing.T, report payload.ValidationReport, err error) { + assert.ErrorIs(t, err, payload.ErrValidationErrors) + + assert.ElementsMatch(t, report.ValidationErrors, []payload.ValidationError{ + { + Location: "/graph/nodes[0]", + RawObject: `{"id":1,"kinds":["User"]}`, + Errors: []payload.ValidationErrorDetail{{Location: "/id", Error: "got number, want string"}}, + }, + }) + }, + }, + { + name: "unsuccessful opengraph payload, node kinds validation error", + payload: `{"metadata":{"source_kind":"hellobase"},"graph":{"nodes":[{"id":"TESTNODE","kinds":["User", 1]}]}}`, + expectedParsedData: payload.ParsedData{PayloadType: ingest.DataTypeOpenGraph, OpengraphData: payload.ParsedOpenGraphData{Metadata: ingest.OpengraphMetadata{SourceKind: "hellobase"}, NodesValidated: 1}}, + errValidationFunc: func(t *testing.T, report payload.ValidationReport, err error) { + assert.ErrorIs(t, err, payload.ErrValidationErrors) + + assert.ElementsMatch(t, report.ValidationErrors, []payload.ValidationError{ + { + Location: "/graph/nodes[0]", + RawObject: `{"id":"TESTNODE","kinds":["User", 1]}`, + Errors: []payload.ValidationErrorDetail{{Location: "/kinds/1", Error: "got number, want string"}}, + }, + }) + }, + }, + { + name: "unsuccessful opengraph payload, node properties validation error", + payload: `{"metadata":{"source_kind":"hellobase"},"graph":{"nodes":[{"id":"TESTNODE","kinds":["User"],"properties":{"items":{}}}]}}`, + expectedParsedData: payload.ParsedData{PayloadType: ingest.DataTypeOpenGraph, OpengraphData: payload.ParsedOpenGraphData{Metadata: ingest.OpengraphMetadata{SourceKind: "hellobase"}, NodesValidated: 1}}, + errValidationFunc: func(t *testing.T, report payload.ValidationReport, err error) { + assert.ErrorIs(t, err, payload.ErrValidationErrors) + + assert.ElementsMatch(t, report.ValidationErrors, []payload.ValidationError{ + { + Location: "/graph/nodes[0]", + RawObject: `{"id":"TESTNODE","kinds":["User"],"properties":{"items":{}}}`, + Errors: []payload.ValidationErrorDetail{{Location: "/properties/items", Error: "invalid type"}}, + }, + }) + }, + }, + { + name: "unsuccessful opengraph payload, node multiple validation errors", + payload: `{"metadata":{"source_kind":"hellobase"},"graph":{"nodes":[{"id":1,"kinds":["User"],"properties":{"items":{}}}]}}`, + expectedParsedData: payload.ParsedData{PayloadType: ingest.DataTypeOpenGraph, OpengraphData: payload.ParsedOpenGraphData{Metadata: ingest.OpengraphMetadata{SourceKind: "hellobase"}, NodesValidated: 1}}, + errValidationFunc: func(t *testing.T, report payload.ValidationReport, err error) { + assert.ErrorIs(t, err, payload.ErrValidationErrors) + + require.Len(t, report.ValidationErrors, 1) + require.Equal(t, "/graph/nodes[0]", report.ValidationErrors[0].Location) + require.Equal(t, `{"id":1,"kinds":["User"],"properties":{"items":{}}}`, report.ValidationErrors[0].RawObject) + assert.ElementsMatch(t, report.ValidationErrors[0].Errors, []payload.ValidationErrorDetail{{Location: "/id", Error: "got number, want string"}, {Location: "/properties/items", Error: "invalid type"}}) + }, + }, + { + name: "unsuccessful opengraph payload, exceeds max validation errors", + payload: `{"metadata":{"source_kind":"hellobase"},"graph":{"nodes":[{"id":"1","kinds":["A","A","A","A"]},` + + `{"id":"1","kinds":["A","A","A","A"]},{"id":"1","kinds":["A","A","A","A"]},{"id":"1","kinds":["A","A","A","A"]},{"id":"1","kinds":["A","A","A","A"]},` + + `{"id":"1","kinds":["A","A","A","A"]},{"id":"1","kinds":["A","A","A","A"]},{"id":"1","kinds":["A","A","A","A"]},{"id":"1","kinds":["A","A","A","A"]},` + + `{"id":"1","kinds":["A","A","A","A"]},{"id":"1","kinds":["A","A","A","A"]},{"id":"1","kinds":["A","A","A","A"]},{"id":"1","kinds":["A","A","A","A"]},` + + `{"id":"1","kinds":["A","A","A","A"]},{"id":"1","kinds":["A","A","A","A"]},{"id":"1","kinds":["A","A","A","A"]},{"id":"1","kinds":["A","A","A","A"]}]}}`, + expectedParsedData: payload.ParsedData{PayloadType: ingest.DataTypeOpenGraph, OpengraphData: payload.ParsedOpenGraphData{Metadata: ingest.OpengraphMetadata{SourceKind: "hellobase"}, NodesValidated: 15}}, + errValidationFunc: func(t *testing.T, report payload.ValidationReport, err error) { + assert.ErrorIs(t, err, payload.ErrMaxValidationErrors) + + assert.ElementsMatch(t, report.ValidationErrors, []payload.ValidationError{ + {Location: "/graph/nodes[0]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []payload.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, + {Location: "/graph/nodes[1]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []payload.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, + {Location: "/graph/nodes[2]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []payload.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, + {Location: "/graph/nodes[3]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []payload.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, + {Location: "/graph/nodes[4]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []payload.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, + {Location: "/graph/nodes[5]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []payload.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, + {Location: "/graph/nodes[6]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []payload.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, + {Location: "/graph/nodes[7]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []payload.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, + {Location: "/graph/nodes[8]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []payload.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, + {Location: "/graph/nodes[9]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []payload.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, + {Location: "/graph/nodes[10]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []payload.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, + {Location: "/graph/nodes[11]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []payload.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, + {Location: "/graph/nodes[12]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []payload.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, + {Location: "/graph/nodes[13]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []payload.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, + {Location: "/graph/nodes[14]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []payload.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, + }) + }, + }, + { + name: "successful opengraph payload with edge", + payload: `{"metadata":{"source_kind":"hellobase"},"graph":{"nodes":[],"edges":[{"start":{"value":"TESTNODE"},"end":{"value":"TESTNODE2"},"kind":"RELATED","properties":{"items":["hi"]}}]}}`, + expectedParsedData: payload.ParsedData{PayloadType: ingest.DataTypeOpenGraph, OpengraphData: payload.ParsedOpenGraphData{Metadata: ingest.OpengraphMetadata{SourceKind: "hellobase"}, EdgesValidated: 1}}, + errValidationFunc: func(t *testing.T, report payload.ValidationReport, err error) { + assert.Equal(t, emptyValidationReport, report) + assert.NoError(t, err) + }, + }, + { + name: "successful opengraph payload with edge property matching", + payload: `{"metadata":{"source_kind":"hellobase"},"graph":{"nodes":[],"edges":[{"start":{"match_by":"property","property_matchers":[{"key":"prop_1","operator":"equals","value":"ROHAN"}]},"end":{"match_by":"property","property_matchers":[{"key":"prop_1","operator":"equals","value":"WES"}]},"kind":"RELATED","properties":{"items":["hi"]}}]}}`, + expectedParsedData: payload.ParsedData{PayloadType: ingest.DataTypeOpenGraph, OpengraphData: payload.ParsedOpenGraphData{Metadata: ingest.OpengraphMetadata{SourceKind: "hellobase"}, EdgesValidated: 1}}, + errValidationFunc: func(t *testing.T, report payload.ValidationReport, err error) { + assert.Equal(t, emptyValidationReport, report) + assert.NoError(t, err) + }, + }, + { + name: "unsuccessful opengraph payload, edge properties validation error", + payload: `{"metadata":{"source_kind":"hellobase"},"graph":{"nodes":[],"edges":[{"start":{"value":"TESTNODE"},"end":{"value":"TESTNODE2"},"kind":"RELATED","properties":{"items":{}}}]}}`, + expectedParsedData: payload.ParsedData{PayloadType: ingest.DataTypeOpenGraph, OpengraphData: payload.ParsedOpenGraphData{Metadata: ingest.OpengraphMetadata{SourceKind: "hellobase"}, EdgesValidated: 1}}, + errValidationFunc: func(t *testing.T, report payload.ValidationReport, err error) { + assert.ErrorIs(t, err, payload.ErrValidationErrors) + + assert.ElementsMatch(t, report.ValidationErrors, []payload.ValidationError{ + { + Location: "/graph/edges[0]", + RawObject: `{"start":{"value":"TESTNODE"},"end":{"value":"TESTNODE2"},"kind":"RELATED","properties":{"items":{}}}`, + Errors: []payload.ValidationErrorDetail{{Location: "/properties/items", Error: "invalid type"}}, + }, + }) + }, + }, + { + name: "unsuccessful opengraph payload, edge id validation error", + payload: `{"metadata":{"source_kind":"hellobase"},"graph":{"nodes":[],"edges":[{"start":{"value":1},"end":{"value":"TESTNODE2"},"kind":"RELATED","properties":{"items":["hi"]}}]}}`, + expectedParsedData: payload.ParsedData{PayloadType: ingest.DataTypeOpenGraph, OpengraphData: payload.ParsedOpenGraphData{Metadata: ingest.OpengraphMetadata{SourceKind: "hellobase"}, EdgesValidated: 1}}, + errValidationFunc: func(t *testing.T, report payload.ValidationReport, err error) { + assert.ErrorIs(t, err, payload.ErrValidationErrors) + + assert.ElementsMatch(t, report.ValidationErrors, []payload.ValidationError{ + { + Location: "/graph/edges[0]", + RawObject: `{"start":{"value":1},"end":{"value":"TESTNODE2"},"kind":"RELATED","properties":{"items":["hi"]}}`, + Errors: []payload.ValidationErrorDetail{{Location: "/start/value", Error: "got number, want string"}}, + }, + }) + }, + }, + { + name: "unsuccessful opengraph payload, invalid edge property matching", + payload: `{"metadata":{"source_kind":"hellobase"},"graph":{"nodes":[],"edges":[{"start":{"match_by":"property","property_matchers":{"key":"prop_1","operator":"equals","value":"ROHAN"}},"end":{"match_by":"property","property_matchers":[{"key":"prop_1","operator":"equals","value":"WES"}]},"kind":"RELATED","properties":{"items":["hi"]}}]}}`, + expectedParsedData: payload.ParsedData{PayloadType: ingest.DataTypeOpenGraph, OpengraphData: payload.ParsedOpenGraphData{Metadata: ingest.OpengraphMetadata{SourceKind: "hellobase"}, EdgesValidated: 1}}, + errValidationFunc: func(t *testing.T, report payload.ValidationReport, err error) { + assert.ErrorIs(t, err, payload.ErrValidationErrors) + + assert.ElementsMatch(t, report.ValidationErrors, []payload.ValidationError{ + { + Location: "/graph/edges[0]", + RawObject: `{"start":{"match_by":"property","property_matchers":{"key":"prop_1","operator":"equals","value":"ROHAN"}},"end":{"match_by":"property","property_matchers":[{"key":"prop_1","operator":"equals","value":"WES"}]},"kind":"RELATED","properties":{"items":["hi"]}}`, + Errors: []payload.ValidationErrorDetail{{Location: "/start/property_matchers", Error: "got object, want array"}}, + }, + }) + }, + }, + { + name: "unsuccessful opengraph metadata", + payload: `{"metadata":{"source_kind":1},"graph":{"nodes":[]}}`, + expectedParsedData: payload.ParsedData{}, + errValidationFunc: func(t *testing.T, report payload.ValidationReport, err error) { + assert.ErrorIs(t, err, payload.ErrOpengraphMetadataValidation) + + assert.ElementsMatch(t, report.CriticalErrors, []payload.CriticalError{{Message: "opengraph metadata failed validation", Error: payload.ErrOpengraphMetadataValidation}}) + }, + }, + { + name: "unsuccessful opengraph no child tags", + payload: `{"graph":{}}`, + expectedParsedData: payload.ParsedData{PayloadType: ingest.DataTypeOpenGraph}, + errValidationFunc: func(t *testing.T, report payload.ValidationReport, err error) { + assert.ErrorIs(t, err, payload.ErrInvalidFileConfiguration) + + assert.ElementsMatch(t, report.CriticalErrors, []payload.CriticalError{{Message: "graph tag requires child nodes or edges tag", Error: payload.ErrInvalidFileConfiguration}}) + }, + }, + { + name: "unsuccessful opengraph metadata, invalid field", + payload: `{"metadata":{"random field":"hello"},"graph":{"nodes":[]}}`, + expectedParsedData: payload.ParsedData{}, + errValidationFunc: func(t *testing.T, report payload.ValidationReport, err error) { + assert.ErrorIs(t, err, payload.ErrOpengraphMetadataValidation) + + assert.ElementsMatch(t, report.CriticalErrors, []payload.CriticalError{{Message: "opengraph metadata failed validation", Error: payload.ErrOpengraphMetadataValidation}}) + }, + }, + // Original payload tests + { + name: "successful original payload", + payload: `{"meta":{"methods": 0,"type":"sessions","count": 0,"version": 5},"data":[]}`, + expectedParsedData: payload.ParsedData{PayloadType: ingest.DataTypeSession, LegacyMetadata: ingest.OriginalMetadata{Type: ingest.DataTypeSession, Methods: 0, Version: 5}}, + errValidationFunc: func(t *testing.T, report payload.ValidationReport, err error) { + assert.Equal(t, emptyValidationReport, report) + assert.NoError(t, err) + }, + }, + { + name: "unsuccessful original payload, no data tag", + payload: `{"meta":{"methods": 0,"type":"sessions","count": 0,"version":5}}`, + expectedParsedData: payload.ParsedData{PayloadType: ingest.DataTypeSession, LegacyMetadata: ingest.OriginalMetadata{Type: ingest.DataTypeSession, Methods: 0, Version: 5}}, + errValidationFunc: func(t *testing.T, report payload.ValidationReport, err error) { + assert.ErrorIs(t, err, payload.ErrInvalidFileConfiguration) + + assert.ElementsMatch(t, report.CriticalErrors, []payload.CriticalError{{Message: "no data tag found to match original metadata tag", Error: payload.ErrInvalidFileConfiguration}}) + }, + }, + { + name: "unsuccessful original payload, no meta tag", + payload: `{"data":[]}`, + expectedParsedData: payload.ParsedData{}, + errValidationFunc: func(t *testing.T, report payload.ValidationReport, err error) { + assert.ErrorIs(t, err, payload.ErrInvalidFileConfiguration) + + assert.ElementsMatch(t, report.CriticalErrors, []payload.CriticalError{{Message: "no meta tag found to match original data tag", Error: payload.ErrInvalidFileConfiguration}}) + }, + }, + { + name: "unsuccessful original payload, duplicate meta tag", + payload: `{"meta":{"methods":0,"type":"sessions","count":0,"version":5},"meta":0,"data":[]}`, + expectedParsedData: payload.ParsedData{PayloadType: ingest.DataTypeSession, LegacyMetadata: ingest.OriginalMetadata{Type: ingest.DataTypeSession, Methods: 0, Version: 5}}, + errValidationFunc: func(t *testing.T, report payload.ValidationReport, err error) { + assert.ErrorIs(t, err, payload.ErrInvalidFileConfiguration) + + assert.ElementsMatch(t, report.CriticalErrors, []payload.CriticalError{{Message: "duplicate top level meta tag found", Error: payload.ErrInvalidFileConfiguration}}) + }, + }, + { + name: "unsuccessful original payload, invalid meta", + payload: `{"data":[],"meta":0}`, + expectedParsedData: payload.ParsedData{}, + errValidationFunc: func(t *testing.T, report payload.ValidationReport, err error) { + require.Len(t, report.CriticalErrors, 1) + var ( + criticalError = report.CriticalErrors[0] + unmarshalErr = &json.UnmarshalTypeError{} + ) + + assert.Equal(t, "failed to decode original metadata", criticalError.Message) + assert.ErrorAs(t, criticalError.Error, &unmarshalErr) + assert.ErrorAs(t, err, &unmarshalErr) + }, + }, + { + name: "swapped order", + payload: `{"data":[],"meta":{"methods":0,"type":"sessions","count":0,"version":5}}`, + expectedParsedData: payload.ParsedData{PayloadType: ingest.DataTypeSession, LegacyMetadata: ingest.OriginalMetadata{Type: ingest.DataTypeSession, Methods: 0, Version: 5}}, + errValidationFunc: func(t *testing.T, report payload.ValidationReport, err error) { + assert.Equal(t, emptyValidationReport, report) + assert.NoError(t, err) + }, + }, + { + name: "unsuccessful original payload, invalid type", + payload: `{"data":[],"meta":{"methods":0,"type":"invalid","count":0,"version":5}}`, + expectedParsedData: payload.ParsedData{}, + errValidationFunc: func(t *testing.T, report payload.ValidationReport, err error) { + assert.ErrorIs(t, err, payload.ErrInvalidDataType) + + assert.ElementsMatch(t, report.CriticalErrors, []payload.CriticalError{{Message: "invalid original metadata data type", Error: payload.ErrInvalidDataType}}) + }, + }, + // Invalid payload tests + { + name: "unsuccessful payload, no valid tags", + payload: `{}`, + expectedParsedData: payload.ParsedData{}, + errValidationFunc: func(t *testing.T, report payload.ValidationReport, err error) { + assert.ErrorIs(t, err, payload.ErrInvalidFileConfiguration) + + assert.ElementsMatch(t, report.CriticalErrors, []payload.CriticalError{{Message: "no tags found", Error: payload.ErrInvalidFileConfiguration}}) + }, + }, + { + name: "enforce mutual exclusivity", + payload: `{"data":[],"graph":{}}`, + expectedParsedData: payload.ParsedData{}, + errValidationFunc: func(t *testing.T, report payload.ValidationReport, err error) { + assert.ErrorIs(t, err, payload.ErrInvalidFileConfiguration) + + assert.ElementsMatch(t, report.CriticalErrors, []payload.CriticalError{{Message: "cannot have both original data tag and opengraph graph tag", Error: payload.ErrInvalidFileConfiguration}}) + }, + }, + { + name: "unsuccessful payload, unrecognized top level tag", + payload: `{"graph":{"nodes":[]},"pants":{}}`, + expectedParsedData: payload.ParsedData{PayloadType: ingest.DataTypeOpenGraph}, + errValidationFunc: func(t *testing.T, report payload.ValidationReport, err error) { + assert.ErrorIs(t, err, payload.ErrInvalidFileConfiguration) + + assert.ElementsMatch(t, report.CriticalErrors, []payload.CriticalError{{Message: "unrecognized top level tag: pants", Error: payload.ErrInvalidFileConfiguration}}) + }, + }, + { + name: "unsuccessful payload, trailing data after object", + payload: `{"graph":{"nodes":[]}}{}`, + expectedParsedData: payload.ParsedData{PayloadType: ingest.DataTypeOpenGraph}, + errValidationFunc: func(t *testing.T, report payload.ValidationReport, err error) { + assert.ErrorContains(t, err, "expected EOF, instead got token: {") + require.Len(t, report.CriticalErrors, 1) + assert.Equal(t, "expected to hit the end of the file", report.CriticalErrors[0].Message) + assert.ErrorContains(t, report.CriticalErrors[0].Error, "expected EOF, instead got token: {") + }, + }, + } + + schema, err := payload.LoadSchema() + require.NoError(t, err) + + for _, assertion := range assertions { + t.Run(assertion.name, func(t *testing.T) { + v := payload.NewValidator(strings.NewReader(assertion.payload), schema) + + parsedData, validationReport, err := v.ParseAndValidate() + assert.Equal(t, assertion.expectedParsedData, parsedData) + assertion.errValidationFunc(t, validationReport, err) + }) + } +} diff --git a/pkg/validator/validator_test.go b/pkg/validator/validator_test.go deleted file mode 100644 index f352373..0000000 --- a/pkg/validator/validator_test.go +++ /dev/null @@ -1,404 +0,0 @@ -// Copyright 2026 Specter Ops, Inc. -// -// Licensed under the Apache License, Version 2.0 -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// SPDX-License-Identifier: Apache-2.0 -package validator_test - -import ( - "encoding/json" - "strings" - "testing" - - "github.com/specterops/chow/pkg/ingest" - validator "github.com/specterops/chow/pkg/validator" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -var emptyValidationReport = validator.ValidationReport{CriticalErrors: []validator.CriticalError{}, ValidationErrors: []validator.ValidationError{}} - -type parseAndValidateAssertion struct { - name string - payload string - expectedParsedData validator.ParsedData - errValidationFunc func(t *testing.T, report validator.ValidationReport, err error) -} - -func Test_ParseAndValidate(t *testing.T) { - assertions := []parseAndValidateAssertion{ - // OpenGraph payload tests - { - name: "successful opengraph payload", - payload: `{"metadata":{},"graph":{"nodes":[]}}`, - expectedParsedData: validator.ParsedData{PayloadType: ingest.DataTypeOpenGraph}, - errValidationFunc: func(t *testing.T, report validator.ValidationReport, err error) { - assert.Equal(t, emptyValidationReport, report) - assert.NoError(t, err) - }, - }, - { - name: "successful opengraph payload with no metadata", - payload: `{"graph":{"nodes":[]}}`, - expectedParsedData: validator.ParsedData{PayloadType: ingest.DataTypeOpenGraph}, - errValidationFunc: func(t *testing.T, report validator.ValidationReport, err error) { - assert.Equal(t, emptyValidationReport, report) - assert.NoError(t, err) - }, - }, - { - name: "successful opengraph metadata", - payload: `{"metadata":{"source_kind":"hellobase"},"graph":{"nodes":[]}}`, - expectedParsedData: validator.ParsedData{PayloadType: ingest.DataTypeOpenGraph, OpengraphData: validator.ParsedOpenGraphData{Metadata: ingest.OpengraphMetadata{SourceKind: "hellobase"}}}, - errValidationFunc: func(t *testing.T, report validator.ValidationReport, err error) { - assert.Equal(t, emptyValidationReport, report) - assert.NoError(t, err) - }, - }, - { - name: "successful opengraph payload with $schema", - payload: `{"$schema":"test","metadata":{"source_kind":"hellobase"},"graph":{"nodes":[]}}`, - expectedParsedData: validator.ParsedData{PayloadType: ingest.DataTypeOpenGraph, OpengraphData: validator.ParsedOpenGraphData{Metadata: ingest.OpengraphMetadata{SourceKind: "hellobase"}}}, - errValidationFunc: func(t *testing.T, report validator.ValidationReport, err error) { - assert.Equal(t, emptyValidationReport, report) - assert.NoError(t, err) - }, - }, - { - name: "successful opengraph payload with node", - payload: `{"metadata":{"source_kind":"hellobase"},"graph":{"nodes":[{"id":"TESTNODE","kinds":["User"],"properties":{"items":["hi"]}}]}}`, - expectedParsedData: validator.ParsedData{PayloadType: ingest.DataTypeOpenGraph, OpengraphData: validator.ParsedOpenGraphData{Metadata: ingest.OpengraphMetadata{SourceKind: "hellobase"}, NodesValidated: 1}}, - errValidationFunc: func(t *testing.T, report validator.ValidationReport, err error) { - assert.Equal(t, emptyValidationReport, report) - assert.NoError(t, err) - }, - }, - { - name: "unsuccessful opengraph payload, node id validation error", - payload: `{"metadata":{"source_kind":"hellobase"},"graph":{"nodes":[{"id":1,"kinds":["User"]}]}}`, - expectedParsedData: validator.ParsedData{PayloadType: ingest.DataTypeOpenGraph, OpengraphData: validator.ParsedOpenGraphData{Metadata: ingest.OpengraphMetadata{SourceKind: "hellobase"}, NodesValidated: 1}}, - errValidationFunc: func(t *testing.T, report validator.ValidationReport, err error) { - assert.ErrorIs(t, err, validator.ErrValidationErrors) - - assert.ElementsMatch(t, report.ValidationErrors, []validator.ValidationError{ - { - Location: "/graph/nodes[0]", - RawObject: `{"id":1,"kinds":["User"]}`, - Errors: []validator.ValidationErrorDetail{{Location: "/id", Error: "got number, want string"}}, - }, - }) - }, - }, - { - name: "unsuccessful opengraph payload, node kinds validation error", - payload: `{"metadata":{"source_kind":"hellobase"},"graph":{"nodes":[{"id":"TESTNODE","kinds":["User", 1]}]}}`, - expectedParsedData: validator.ParsedData{PayloadType: ingest.DataTypeOpenGraph, OpengraphData: validator.ParsedOpenGraphData{Metadata: ingest.OpengraphMetadata{SourceKind: "hellobase"}, NodesValidated: 1}}, - errValidationFunc: func(t *testing.T, report validator.ValidationReport, err error) { - assert.ErrorIs(t, err, validator.ErrValidationErrors) - - assert.ElementsMatch(t, report.ValidationErrors, []validator.ValidationError{ - { - Location: "/graph/nodes[0]", - RawObject: `{"id":"TESTNODE","kinds":["User", 1]}`, - Errors: []validator.ValidationErrorDetail{{Location: "/kinds/1", Error: "got number, want string"}}, - }, - }) - }, - }, - { - name: "unsuccessful opengraph payload, node properties validation error", - payload: `{"metadata":{"source_kind":"hellobase"},"graph":{"nodes":[{"id":"TESTNODE","kinds":["User"],"properties":{"items":{}}}]}}`, - expectedParsedData: validator.ParsedData{PayloadType: ingest.DataTypeOpenGraph, OpengraphData: validator.ParsedOpenGraphData{Metadata: ingest.OpengraphMetadata{SourceKind: "hellobase"}, NodesValidated: 1}}, - errValidationFunc: func(t *testing.T, report validator.ValidationReport, err error) { - assert.ErrorIs(t, err, validator.ErrValidationErrors) - - assert.ElementsMatch(t, report.ValidationErrors, []validator.ValidationError{ - { - Location: "/graph/nodes[0]", - RawObject: `{"id":"TESTNODE","kinds":["User"],"properties":{"items":{}}}`, - Errors: []validator.ValidationErrorDetail{{Location: "/properties/items", Error: "invalid type"}}, - }, - }) - }, - }, - { - name: "unsuccessful opengraph payload, node multiple validation errors", - payload: `{"metadata":{"source_kind":"hellobase"},"graph":{"nodes":[{"id":1,"kinds":["User"],"properties":{"items":{}}}]}}`, - expectedParsedData: validator.ParsedData{PayloadType: ingest.DataTypeOpenGraph, OpengraphData: validator.ParsedOpenGraphData{Metadata: ingest.OpengraphMetadata{SourceKind: "hellobase"}, NodesValidated: 1}}, - errValidationFunc: func(t *testing.T, report validator.ValidationReport, err error) { - assert.ErrorIs(t, err, validator.ErrValidationErrors) - - require.Len(t, report.ValidationErrors, 1) - require.Equal(t, "/graph/nodes[0]", report.ValidationErrors[0].Location) - require.Equal(t, `{"id":1,"kinds":["User"],"properties":{"items":{}}}`, report.ValidationErrors[0].RawObject) - assert.ElementsMatch(t, report.ValidationErrors[0].Errors, []validator.ValidationErrorDetail{{Location: "/id", Error: "got number, want string"}, {Location: "/properties/items", Error: "invalid type"}}) - }, - }, - { - name: "unsuccessful opengraph payload, exceeds max validation errors", - payload: `{"metadata":{"source_kind":"hellobase"},"graph":{"nodes":[{"id":"1","kinds":["A","A","A","A"]},` + - `{"id":"1","kinds":["A","A","A","A"]},{"id":"1","kinds":["A","A","A","A"]},{"id":"1","kinds":["A","A","A","A"]},{"id":"1","kinds":["A","A","A","A"]},` + - `{"id":"1","kinds":["A","A","A","A"]},{"id":"1","kinds":["A","A","A","A"]},{"id":"1","kinds":["A","A","A","A"]},{"id":"1","kinds":["A","A","A","A"]},` + - `{"id":"1","kinds":["A","A","A","A"]},{"id":"1","kinds":["A","A","A","A"]},{"id":"1","kinds":["A","A","A","A"]},{"id":"1","kinds":["A","A","A","A"]},` + - `{"id":"1","kinds":["A","A","A","A"]},{"id":"1","kinds":["A","A","A","A"]},{"id":"1","kinds":["A","A","A","A"]},{"id":"1","kinds":["A","A","A","A"]}]}}`, - expectedParsedData: validator.ParsedData{PayloadType: ingest.DataTypeOpenGraph, OpengraphData: validator.ParsedOpenGraphData{Metadata: ingest.OpengraphMetadata{SourceKind: "hellobase"}, NodesValidated: 15}}, - errValidationFunc: func(t *testing.T, report validator.ValidationReport, err error) { - assert.ErrorIs(t, err, validator.ErrMaxValidationErrors) - - assert.ElementsMatch(t, report.ValidationErrors, []validator.ValidationError{ - {Location: "/graph/nodes[0]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []validator.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, - {Location: "/graph/nodes[1]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []validator.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, - {Location: "/graph/nodes[2]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []validator.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, - {Location: "/graph/nodes[3]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []validator.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, - {Location: "/graph/nodes[4]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []validator.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, - {Location: "/graph/nodes[5]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []validator.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, - {Location: "/graph/nodes[6]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []validator.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, - {Location: "/graph/nodes[7]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []validator.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, - {Location: "/graph/nodes[8]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []validator.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, - {Location: "/graph/nodes[9]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []validator.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, - {Location: "/graph/nodes[10]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []validator.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, - {Location: "/graph/nodes[11]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []validator.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, - {Location: "/graph/nodes[12]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []validator.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, - {Location: "/graph/nodes[13]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []validator.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, - {Location: "/graph/nodes[14]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []validator.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, - }) - }, - }, - { - name: "successful opengraph payload with edge", - payload: `{"metadata":{"source_kind":"hellobase"},"graph":{"nodes":[],"edges":[{"start":{"value":"TESTNODE"},"end":{"value":"TESTNODE2"},"kind":"RELATED","properties":{"items":["hi"]}}]}}`, - expectedParsedData: validator.ParsedData{PayloadType: ingest.DataTypeOpenGraph, OpengraphData: validator.ParsedOpenGraphData{Metadata: ingest.OpengraphMetadata{SourceKind: "hellobase"}, EdgesValidated: 1}}, - errValidationFunc: func(t *testing.T, report validator.ValidationReport, err error) { - assert.Equal(t, emptyValidationReport, report) - assert.NoError(t, err) - }, - }, - { - name: "successful opengraph payload with edge property matching", - payload: `{"metadata":{"source_kind":"hellobase"},"graph":{"nodes":[],"edges":[{"start":{"match_by":"property","property_matchers":[{"key":"prop_1","operator":"equals","value":"ROHAN"}]},"end":{"match_by":"property","property_matchers":[{"key":"prop_1","operator":"equals","value":"WES"}]},"kind":"RELATED","properties":{"items":["hi"]}}]}}`, - expectedParsedData: validator.ParsedData{PayloadType: ingest.DataTypeOpenGraph, OpengraphData: validator.ParsedOpenGraphData{Metadata: ingest.OpengraphMetadata{SourceKind: "hellobase"}, EdgesValidated: 1}}, - errValidationFunc: func(t *testing.T, report validator.ValidationReport, err error) { - assert.Equal(t, emptyValidationReport, report) - assert.NoError(t, err) - }, - }, - { - name: "unsuccessful opengraph payload, edge properties validation error", - payload: `{"metadata":{"source_kind":"hellobase"},"graph":{"nodes":[],"edges":[{"start":{"value":"TESTNODE"},"end":{"value":"TESTNODE2"},"kind":"RELATED","properties":{"items":{}}}]}}`, - expectedParsedData: validator.ParsedData{PayloadType: ingest.DataTypeOpenGraph, OpengraphData: validator.ParsedOpenGraphData{Metadata: ingest.OpengraphMetadata{SourceKind: "hellobase"}, EdgesValidated: 1}}, - errValidationFunc: func(t *testing.T, report validator.ValidationReport, err error) { - assert.ErrorIs(t, err, validator.ErrValidationErrors) - - assert.ElementsMatch(t, report.ValidationErrors, []validator.ValidationError{ - { - Location: "/graph/edges[0]", - RawObject: `{"start":{"value":"TESTNODE"},"end":{"value":"TESTNODE2"},"kind":"RELATED","properties":{"items":{}}}`, - Errors: []validator.ValidationErrorDetail{{Location: "/properties/items", Error: "invalid type"}}, - }, - }) - }, - }, - { - name: "unsuccessful opengraph payload, edge id validation error", - payload: `{"metadata":{"source_kind":"hellobase"},"graph":{"nodes":[],"edges":[{"start":{"value":1},"end":{"value":"TESTNODE2"},"kind":"RELATED","properties":{"items":["hi"]}}]}}`, - expectedParsedData: validator.ParsedData{PayloadType: ingest.DataTypeOpenGraph, OpengraphData: validator.ParsedOpenGraphData{Metadata: ingest.OpengraphMetadata{SourceKind: "hellobase"}, EdgesValidated: 1}}, - errValidationFunc: func(t *testing.T, report validator.ValidationReport, err error) { - assert.ErrorIs(t, err, validator.ErrValidationErrors) - - assert.ElementsMatch(t, report.ValidationErrors, []validator.ValidationError{ - { - Location: "/graph/edges[0]", - RawObject: `{"start":{"value":1},"end":{"value":"TESTNODE2"},"kind":"RELATED","properties":{"items":["hi"]}}`, - Errors: []validator.ValidationErrorDetail{{Location: "/start/value", Error: "got number, want string"}}, - }, - }) - }, - }, - { - name: "unsuccessful opengraph payload, invalid edge property matching", - payload: `{"metadata":{"source_kind":"hellobase"},"graph":{"nodes":[],"edges":[{"start":{"match_by":"property","property_matchers":{"key":"prop_1","operator":"equals","value":"ROHAN"}},"end":{"match_by":"property","property_matchers":[{"key":"prop_1","operator":"equals","value":"WES"}]},"kind":"RELATED","properties":{"items":["hi"]}}]}}`, - expectedParsedData: validator.ParsedData{PayloadType: ingest.DataTypeOpenGraph, OpengraphData: validator.ParsedOpenGraphData{Metadata: ingest.OpengraphMetadata{SourceKind: "hellobase"}, EdgesValidated: 1}}, - errValidationFunc: func(t *testing.T, report validator.ValidationReport, err error) { - assert.ErrorIs(t, err, validator.ErrValidationErrors) - - assert.ElementsMatch(t, report.ValidationErrors, []validator.ValidationError{ - { - Location: "/graph/edges[0]", - RawObject: `{"start":{"match_by":"property","property_matchers":{"key":"prop_1","operator":"equals","value":"ROHAN"}},"end":{"match_by":"property","property_matchers":[{"key":"prop_1","operator":"equals","value":"WES"}]},"kind":"RELATED","properties":{"items":["hi"]}}`, - Errors: []validator.ValidationErrorDetail{{Location: "/start/property_matchers", Error: "got object, want array"}}, - }, - }) - }, - }, - { - name: "unsuccessful opengraph metadata", - payload: `{"metadata":{"source_kind":1},"graph":{"nodes":[]}}`, - expectedParsedData: validator.ParsedData{}, - errValidationFunc: func(t *testing.T, report validator.ValidationReport, err error) { - assert.ErrorIs(t, err, validator.ErrOpengraphMetadataValidation) - - assert.ElementsMatch(t, report.CriticalErrors, []validator.CriticalError{{Message: "opengraph metadata failed validation", Error: validator.ErrOpengraphMetadataValidation}}) - }, - }, - { - name: "unsuccessful opengraph no child tags", - payload: `{"graph":{}}`, - expectedParsedData: validator.ParsedData{PayloadType: ingest.DataTypeOpenGraph}, - errValidationFunc: func(t *testing.T, report validator.ValidationReport, err error) { - assert.ErrorIs(t, err, validator.ErrInvalidFileConfiguration) - - assert.ElementsMatch(t, report.CriticalErrors, []validator.CriticalError{{Message: "graph tag requires child nodes or edges tag", Error: validator.ErrInvalidFileConfiguration}}) - }, - }, - { - name: "unsuccessful opengraph metadata, invalid field", - payload: `{"metadata":{"random field":"hello"},"graph":{"nodes":[]}}`, - expectedParsedData: validator.ParsedData{}, - errValidationFunc: func(t *testing.T, report validator.ValidationReport, err error) { - assert.ErrorIs(t, err, validator.ErrOpengraphMetadataValidation) - - assert.ElementsMatch(t, report.CriticalErrors, []validator.CriticalError{{Message: "opengraph metadata failed validation", Error: validator.ErrOpengraphMetadataValidation}}) - }, - }, - // Original payload tests - { - name: "successful original payload", - payload: `{"meta":{"methods": 0,"type":"sessions","count": 0,"version": 5},"data":[]}`, - expectedParsedData: validator.ParsedData{PayloadType: ingest.DataTypeSession, LegacyMetadata: ingest.OriginalMetadata{Type: ingest.DataTypeSession, Methods: 0, Version: 5}}, - errValidationFunc: func(t *testing.T, report validator.ValidationReport, err error) { - assert.Equal(t, emptyValidationReport, report) - assert.NoError(t, err) - }, - }, - { - name: "unsuccessful original payload, no data tag", - payload: `{"meta":{"methods": 0,"type":"sessions","count": 0,"version":5}}`, - expectedParsedData: validator.ParsedData{PayloadType: ingest.DataTypeSession, LegacyMetadata: ingest.OriginalMetadata{Type: ingest.DataTypeSession, Methods: 0, Version: 5}}, - errValidationFunc: func(t *testing.T, report validator.ValidationReport, err error) { - assert.ErrorIs(t, err, validator.ErrInvalidFileConfiguration) - - assert.ElementsMatch(t, report.CriticalErrors, []validator.CriticalError{{Message: "no data tag found to match original metadata tag", Error: validator.ErrInvalidFileConfiguration}}) - }, - }, - { - name: "unsuccessful original payload, no meta tag", - payload: `{"data":[]}`, - expectedParsedData: validator.ParsedData{}, - errValidationFunc: func(t *testing.T, report validator.ValidationReport, err error) { - assert.ErrorIs(t, err, validator.ErrInvalidFileConfiguration) - - assert.ElementsMatch(t, report.CriticalErrors, []validator.CriticalError{{Message: "no meta tag found to match original data tag", Error: validator.ErrInvalidFileConfiguration}}) - }, - }, - { - name: "unsuccessful original payload, duplicate meta tag", - payload: `{"meta":{"methods":0,"type":"sessions","count":0,"version":5},"meta":0,"data":[]}`, - expectedParsedData: validator.ParsedData{PayloadType: ingest.DataTypeSession, LegacyMetadata: ingest.OriginalMetadata{Type: ingest.DataTypeSession, Methods: 0, Version: 5}}, - errValidationFunc: func(t *testing.T, report validator.ValidationReport, err error) { - assert.ErrorIs(t, err, validator.ErrInvalidFileConfiguration) - - assert.ElementsMatch(t, report.CriticalErrors, []validator.CriticalError{{Message: "duplicate top level meta tag found", Error: validator.ErrInvalidFileConfiguration}}) - }, - }, - { - name: "unsuccessful original payload, invalid meta", - payload: `{"data":[],"meta":0}`, - expectedParsedData: validator.ParsedData{}, - errValidationFunc: func(t *testing.T, report validator.ValidationReport, err error) { - require.Len(t, report.CriticalErrors, 1) - var ( - criticalError = report.CriticalErrors[0] - unmarshalErr = &json.UnmarshalTypeError{} - ) - - assert.Equal(t, "failed to decode original metadata", criticalError.Message) - assert.ErrorAs(t, criticalError.Error, &unmarshalErr) - assert.ErrorAs(t, err, &unmarshalErr) - }, - }, - { - name: "swapped order", - payload: `{"data":[],"meta":{"methods":0,"type":"sessions","count":0,"version":5}}`, - expectedParsedData: validator.ParsedData{PayloadType: ingest.DataTypeSession, LegacyMetadata: ingest.OriginalMetadata{Type: ingest.DataTypeSession, Methods: 0, Version: 5}}, - errValidationFunc: func(t *testing.T, report validator.ValidationReport, err error) { - assert.Equal(t, emptyValidationReport, report) - assert.NoError(t, err) - }, - }, - { - name: "unsuccessful original payload, invalid type", - payload: `{"data":[],"meta":{"methods":0,"type":"invalid","count":0,"version":5}}`, - expectedParsedData: validator.ParsedData{}, - errValidationFunc: func(t *testing.T, report validator.ValidationReport, err error) { - assert.ErrorIs(t, err, validator.ErrInvalidDataType) - - assert.ElementsMatch(t, report.CriticalErrors, []validator.CriticalError{{Message: "invalid original metadata data type", Error: validator.ErrInvalidDataType}}) - }, - }, - // Invalid payload tests - { - name: "unsuccessful payload, no valid tags", - payload: `{}`, - expectedParsedData: validator.ParsedData{}, - errValidationFunc: func(t *testing.T, report validator.ValidationReport, err error) { - assert.ErrorIs(t, err, validator.ErrInvalidFileConfiguration) - - assert.ElementsMatch(t, report.CriticalErrors, []validator.CriticalError{{Message: "no tags found", Error: validator.ErrInvalidFileConfiguration}}) - }, - }, - { - name: "enforce mutual exclusivity", - payload: `{"data":[],"graph":{}}`, - expectedParsedData: validator.ParsedData{}, - errValidationFunc: func(t *testing.T, report validator.ValidationReport, err error) { - assert.ErrorIs(t, err, validator.ErrInvalidFileConfiguration) - - assert.ElementsMatch(t, report.CriticalErrors, []validator.CriticalError{{Message: "cannot have both original data tag and opengraph graph tag", Error: validator.ErrInvalidFileConfiguration}}) - }, - }, - { - name: "unsuccessful payload, unrecognized top level tag", - payload: `{"graph":{"nodes":[]},"pants":{}}`, - expectedParsedData: validator.ParsedData{PayloadType: ingest.DataTypeOpenGraph}, - errValidationFunc: func(t *testing.T, report validator.ValidationReport, err error) { - assert.ErrorIs(t, err, validator.ErrInvalidFileConfiguration) - - assert.ElementsMatch(t, report.CriticalErrors, []validator.CriticalError{{Message: "unrecognized top level tag: pants", Error: validator.ErrInvalidFileConfiguration}}) - }, - }, - { - name: "unsuccessful payload, trailing data after object", - payload: `{"graph":{"nodes":[]}}{}`, - expectedParsedData: validator.ParsedData{PayloadType: ingest.DataTypeOpenGraph}, - errValidationFunc: func(t *testing.T, report validator.ValidationReport, err error) { - assert.ErrorContains(t, err, "expected EOF, instead got token: {") - require.Len(t, report.CriticalErrors, 1) - assert.Equal(t, "expected to hit the end of the file", report.CriticalErrors[0].Message) - assert.ErrorContains(t, report.CriticalErrors[0].Error, "expected EOF, instead got token: {") - }, - }, - } - - schema, err := validator.LoadIngestSchema() - require.NoError(t, err) - - for _, assertion := range assertions { - t.Run(assertion.name, func(t *testing.T) { - v := validator.NewValidator(strings.NewReader(assertion.payload), schema) - - parsedData, validationReport, err := v.ParseAndValidate() - assert.Equal(t, assertion.expectedParsedData, parsedData) - assertion.errValidationFunc(t, validationReport, err) - }) - } -} From 9ffc316fa3ec1befc8b47aac23999ba43e1edf1d Mon Sep 17 00:00:00 2001 From: Wes <169498386+wes-mil@users.noreply.github.com> Date: Fri, 1 May 2026 12:12:34 -0400 Subject: [PATCH 07/11] Add an Error string to the validation error Add ParseMetadata tests --- pkg/payload/validator.go | 25 +++++++++ pkg/payload/validator_test.go | 103 ++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+) diff --git a/pkg/payload/validator.go b/pkg/payload/validator.go index a647d50..fc9c533 100644 --- a/pkg/payload/validator.go +++ b/pkg/payload/validator.go @@ -104,6 +104,31 @@ type ValidationError struct { Errors []ValidationErrorDetail } +func (s ValidationError) Error() string { + var ( + details = make([]string, 0, len(s.Errors)) + message = "validation error" + ) + + if s.Location != "" { + message = fmt.Sprintf("%s at %s", message, s.Location) + } + + for _, validationErrorDetail := range s.Errors { + if validationErrorDetail.Location != "" { + details = append(details, fmt.Sprintf("%s: %s", validationErrorDetail.Location, validationErrorDetail.Error)) + } else if validationErrorDetail.Error != "" { + details = append(details, validationErrorDetail.Error) + } + } + + if len(details) > 0 { + message = fmt.Sprintf("%s: %s", message, strings.Join(details, "; ")) + } + + return message +} + type ValidationErrorDetail struct { Location string Error string diff --git a/pkg/payload/validator_test.go b/pkg/payload/validator_test.go index 362fa6c..ae0076c 100644 --- a/pkg/payload/validator_test.go +++ b/pkg/payload/validator_test.go @@ -402,3 +402,106 @@ func Test_ParseAndValidate(t *testing.T) { }) } } + +type parseMetadataAssertion struct { + name string + payload string + expectedParsedData payload.ParsedData + errValidationFunc func(t *testing.T, err error) +} + +func Test_ParseMetadata(t *testing.T) { + assertions := []parseMetadataAssertion{ + { + name: "legacy metadata", + payload: `{"meta":{"methods":0,"type":"sessions","count":0,"version":5},"data":[]}`, + expectedParsedData: payload.ParsedData{ + PayloadType: ingest.DataTypeSession, + LegacyMetadata: ingest.OriginalMetadata{Type: ingest.DataTypeSession, Methods: 0, Version: 5}, + }, + errValidationFunc: func(t *testing.T, err error) { + assert.NoError(t, err) + }, + }, + { + name: "legacy metadata after data", + payload: `{"data":[],"meta":{"methods":0,"type":"sessions","count":0,"version":5}}`, + expectedParsedData: payload.ParsedData{ + PayloadType: ingest.DataTypeSession, + LegacyMetadata: ingest.OriginalMetadata{Type: ingest.DataTypeSession, Methods: 0, Version: 5}, + }, + errValidationFunc: func(t *testing.T, err error) { + assert.NoError(t, err) + }, + }, + { + name: "opengraph metadata", + payload: `{"metadata":{"source_kind":"hellobase"},"graph":{"nodes":[]}}`, + expectedParsedData: payload.ParsedData{ + PayloadType: ingest.DataTypeOpenGraph, + OpengraphData: payload.ParsedOpenGraphData{ + Metadata: ingest.OpengraphMetadata{SourceKind: "hellobase"}, + }, + }, + errValidationFunc: func(t *testing.T, err error) { + assert.NoError(t, err) + }, + }, + { + name: "opengraph graph only", + payload: `{"graph":{"nodes":[]}}`, + expectedParsedData: payload.ParsedData{ + PayloadType: ingest.DataTypeOpenGraph, + }, + errValidationFunc: func(t *testing.T, err error) { + assert.NoError(t, err) + }, + }, + { + name: "opengraph metadata after graph", + payload: `{"graph":{"nodes":[]},"metadata":{"source_kind":"hellobase"}}`, + expectedParsedData: payload.ParsedData{ + PayloadType: ingest.DataTypeOpenGraph, + OpengraphData: payload.ParsedOpenGraphData{ + Metadata: ingest.OpengraphMetadata{SourceKind: "hellobase"}, + }, + }, + errValidationFunc: func(t *testing.T, err error) { + assert.NoError(t, err) + }, + }, + { + name: "invalid top level json", + payload: `[]`, + expectedParsedData: payload.ParsedData{}, + errValidationFunc: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "expected open bracket") + }, + }, + } + + schema, err := payload.LoadSchema() + require.NoError(t, err) + + for _, assertion := range assertions { + t.Run(assertion.name, func(t *testing.T) { + v := payload.NewValidator(strings.NewReader(assertion.payload), schema) + + parsedData, err := v.ParseMetadata() + assert.Equal(t, assertion.expectedParsedData, parsedData) + assertion.errValidationFunc(t, err) + }) + } +} + +func TestValidationError_Error(t *testing.T) { + validationErr := payload.ValidationError{ + Location: "/graph/nodes[0]", + Errors: []payload.ValidationErrorDetail{ + {Location: "/id", Error: "got number, want string"}, + {Location: "/properties/items", Error: "invalid type"}, + }, + } + + assert.Equal(t, "validation error at /graph/nodes[0]: /id: got number, want string; /properties/items: invalid type", validationErr.Error()) +} From e256102241f06fcb60f36747d3a513af4eaafa73 Mon Sep 17 00:00:00 2001 From: Wes <169498386+wes-mil@users.noreply.github.com> Date: Mon, 4 May 2026 17:16:05 -0400 Subject: [PATCH 08/11] Reject payloads with kinds that start with "Tag_" --- pkg/payload/jsonschema/edge.json | 8 +++- pkg/payload/jsonschema/node.json | 7 ++- pkg/payload/validator_test.go | 80 ++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 2 deletions(-) diff --git a/pkg/payload/jsonschema/edge.json b/pkg/payload/jsonschema/edge.json index 8e9937e..417b1ae 100644 --- a/pkg/payload/jsonschema/edge.json +++ b/pkg/payload/jsonschema/edge.json @@ -57,6 +57,9 @@ }, "kind": { "type": "string", + "not": { + "pattern": "^[Tt][Aa][Gg](?:_|$)" + }, "description": "Optional kind filter; the referenced node must have this kind." } }, @@ -105,7 +108,10 @@ "kind": { "type": "string", "description": "Edge kind name must contain only alphanumeric characters and underscores.", - "pattern": "^[A-Za-z0-9_]+$" + "pattern": "^[A-Za-z0-9_]+$", + "not": { + "pattern": "^[Tt][Aa][Gg](?:_|$)" + } }, "properties": { "$ref": "#/$defs/property_map" diff --git a/pkg/payload/jsonschema/node.json b/pkg/payload/jsonschema/node.json index ba87bbc..e347abd 100644 --- a/pkg/payload/jsonschema/node.json +++ b/pkg/payload/jsonschema/node.json @@ -36,7 +36,12 @@ }, "kinds": { "type": ["array"], - "items": { "type": "string" }, + "items": { + "type": "string", + "not": { + "pattern": "^[Tt][Aa][Gg](?:_|$)" + } + }, "minItems": 0, "maxItems": 3, "description": "An array of kind labels for the node. The first element is treated as the node's primary kind and is used to determine which icon to display in the graph UI. This primary kind is only used for visual representation and has no semantic significance for data processing." diff --git a/pkg/payload/validator_test.go b/pkg/payload/validator_test.go index ae0076c..7d772ec 100644 --- a/pkg/payload/validator_test.go +++ b/pkg/payload/validator_test.go @@ -115,6 +115,38 @@ func Test_ParseAndValidate(t *testing.T) { }) }, }, + { + name: "unsuccessful opengraph payload, node kind tag prefix validation error", + payload: `{"metadata":{"source_kind":"hellobase"},"graph":{"nodes":[{"id":"TESTNODE","kinds":["Tag_Admin"]}]}}`, + expectedParsedData: payload.ParsedData{PayloadType: ingest.DataTypeOpenGraph, OpengraphData: payload.ParsedOpenGraphData{Metadata: ingest.OpengraphMetadata{SourceKind: "hellobase"}, NodesValidated: 1}}, + errValidationFunc: func(t *testing.T, report payload.ValidationReport, err error) { + assert.ErrorIs(t, err, payload.ErrValidationErrors) + + assert.ElementsMatch(t, report.ValidationErrors, []payload.ValidationError{ + { + Location: "/graph/nodes[0]", + RawObject: `{"id":"TESTNODE","kinds":["Tag_Admin"]}`, + Errors: []payload.ValidationErrorDetail{{Location: "/kinds/0", Error: "'not' failed"}}, + }, + }) + }, + }, + { + name: "unsuccessful opengraph payload, node kind standalone tag validation error", + payload: `{"metadata":{"source_kind":"hellobase"},"graph":{"nodes":[{"id":"TESTNODE","kinds":["tAg"]}]}}`, + expectedParsedData: payload.ParsedData{PayloadType: ingest.DataTypeOpenGraph, OpengraphData: payload.ParsedOpenGraphData{Metadata: ingest.OpengraphMetadata{SourceKind: "hellobase"}, NodesValidated: 1}}, + errValidationFunc: func(t *testing.T, report payload.ValidationReport, err error) { + assert.ErrorIs(t, err, payload.ErrValidationErrors) + + assert.ElementsMatch(t, report.ValidationErrors, []payload.ValidationError{ + { + Location: "/graph/nodes[0]", + RawObject: `{"id":"TESTNODE","kinds":["tAg"]}`, + Errors: []payload.ValidationErrorDetail{{Location: "/kinds/0", Error: "'not' failed"}}, + }, + }) + }, + }, { name: "unsuccessful opengraph payload, node properties validation error", payload: `{"metadata":{"source_kind":"hellobase"},"graph":{"nodes":[{"id":"TESTNODE","kinds":["User"],"properties":{"items":{}}}]}}`, @@ -224,6 +256,54 @@ func Test_ParseAndValidate(t *testing.T) { }) }, }, + { + name: "unsuccessful opengraph payload, edge kind tag prefix validation error", + payload: `{"metadata":{"source_kind":"hellobase"},"graph":{"edges":[{"start":{"value":"TESTNODE"},"end":{"value":"TESTNODE2"},"kind":"TAG_Admin"}]}}`, + expectedParsedData: payload.ParsedData{PayloadType: ingest.DataTypeOpenGraph, OpengraphData: payload.ParsedOpenGraphData{Metadata: ingest.OpengraphMetadata{SourceKind: "hellobase"}, EdgesValidated: 1}}, + errValidationFunc: func(t *testing.T, report payload.ValidationReport, err error) { + assert.ErrorIs(t, err, payload.ErrValidationErrors) + + assert.ElementsMatch(t, report.ValidationErrors, []payload.ValidationError{ + { + Location: "/graph/edges[0]", + RawObject: `{"start":{"value":"TESTNODE"},"end":{"value":"TESTNODE2"},"kind":"TAG_Admin"}`, + Errors: []payload.ValidationErrorDetail{{Location: "/kind", Error: "'not' failed"}}, + }, + }) + }, + }, + { + name: "unsuccessful opengraph payload, edge kind standalone tag validation error", + payload: `{"metadata":{"source_kind":"hellobase"},"graph":{"edges":[{"start":{"value":"TESTNODE"},"end":{"value":"TESTNODE2"},"kind":"TaG"}]}}`, + expectedParsedData: payload.ParsedData{PayloadType: ingest.DataTypeOpenGraph, OpengraphData: payload.ParsedOpenGraphData{Metadata: ingest.OpengraphMetadata{SourceKind: "hellobase"}, EdgesValidated: 1}}, + errValidationFunc: func(t *testing.T, report payload.ValidationReport, err error) { + assert.ErrorIs(t, err, payload.ErrValidationErrors) + + assert.ElementsMatch(t, report.ValidationErrors, []payload.ValidationError{ + { + Location: "/graph/edges[0]", + RawObject: `{"start":{"value":"TESTNODE"},"end":{"value":"TESTNODE2"},"kind":"TaG"}`, + Errors: []payload.ValidationErrorDetail{{Location: "/kind", Error: "'not' failed"}}, + }, + }) + }, + }, + { + name: "unsuccessful opengraph payload, edge endpoint kind tag prefix validation error", + payload: `{"metadata":{"source_kind":"hellobase"},"graph":{"edges":[{"start":{"value":"TESTNODE","kind":"tag_Admin"},"end":{"value":"TESTNODE2"},"kind":"RELATED"}]}}`, + expectedParsedData: payload.ParsedData{PayloadType: ingest.DataTypeOpenGraph, OpengraphData: payload.ParsedOpenGraphData{Metadata: ingest.OpengraphMetadata{SourceKind: "hellobase"}, EdgesValidated: 1}}, + errValidationFunc: func(t *testing.T, report payload.ValidationReport, err error) { + assert.ErrorIs(t, err, payload.ErrValidationErrors) + + assert.ElementsMatch(t, report.ValidationErrors, []payload.ValidationError{ + { + Location: "/graph/edges[0]", + RawObject: `{"start":{"value":"TESTNODE","kind":"tag_Admin"},"end":{"value":"TESTNODE2"},"kind":"RELATED"}`, + Errors: []payload.ValidationErrorDetail{{Location: "/start/kind", Error: "'not' failed"}}, + }, + }) + }, + }, { name: "unsuccessful opengraph payload, invalid edge property matching", payload: `{"metadata":{"source_kind":"hellobase"},"graph":{"nodes":[],"edges":[{"start":{"match_by":"property","property_matchers":{"key":"prop_1","operator":"equals","value":"ROHAN"}},"end":{"match_by":"property","property_matchers":[{"key":"prop_1","operator":"equals","value":"WES"}]},"kind":"RELATED","properties":{"items":["hi"]}}]}}`, From e93bf81f732e3cae8cde22746ed5ee7537ba301e Mon Sep 17 00:00:00 2001 From: Wes <169498386+wes-mil@users.noreply.github.com> Date: Tue, 26 May 2026 12:56:44 -0400 Subject: [PATCH 09/11] Improve testing coverage Enforce lowercase property keys --- pkg/payload/jsonschema/edge.json | 5 +- pkg/payload/jsonschema/node.json | 7 +- pkg/payload/schema.go | 7 +- pkg/payload/schema_contract_test.go | 249 +++++++++++++++ pkg/payload/schema_test.go | 134 +++++++++ pkg/payload/validator_test.go | 452 ++++++++++++++++++++++++---- 6 files changed, 784 insertions(+), 70 deletions(-) create mode 100644 pkg/payload/schema_contract_test.go create mode 100644 pkg/payload/schema_test.go diff --git a/pkg/payload/jsonschema/edge.json b/pkg/payload/jsonschema/edge.json index 417b1ae..60c8323 100644 --- a/pkg/payload/jsonschema/edge.json +++ b/pkg/payload/jsonschema/edge.json @@ -5,7 +5,10 @@ "$defs": { "property_map": { "type": ["object", "null"], - "description": "A key-value map of edge attributes. Values must not be objects. If a value is an array, it must contain only primitive types (e.g., strings, numbers, booleans) and must be homogeneous (all items must be of the same type).", + "description": "A key-value map of edge attributes. Property names must contain only lowercase letters, numbers, and underscores. Values must not be objects. If a value is an array, it must contain only primitive types (e.g., strings, numbers, booleans) and must be homogeneous (all items must be of the same type).", + "propertyNames": { + "pattern": "^[a-z0-9_]+$" + }, "additionalProperties": { "anyOf": [ { "type": "string" }, diff --git a/pkg/payload/jsonschema/node.json b/pkg/payload/jsonschema/node.json index e347abd..03430a7 100644 --- a/pkg/payload/jsonschema/node.json +++ b/pkg/payload/jsonschema/node.json @@ -5,7 +5,10 @@ "$defs": { "property_map": { "type": ["object", "null"], - "description": "A key-value map of entity attributes. Values must not be objects. If a value is an array, it must contain only primitive types (e.g., strings, numbers, booleans) and must be homogeneous (all items must be of the same type).", + "description": "A key-value map of entity attributes. Property names must contain only lowercase letters, numbers, and underscores. Values must not be objects. If a value is an array, it must contain only primitive types (e.g., strings, numbers, booleans) and must be homogeneous (all items must be of the same type).", + "propertyNames": { + "pattern": "^[a-z0-9_]+$" + }, "additionalProperties": { "anyOf": [ { "type": "string" }, @@ -21,8 +24,8 @@ } ] }, - "not": { + "type": "object", "required": ["objectid"] } } diff --git a/pkg/payload/schema.go b/pkg/payload/schema.go index 171a121..1cb4c16 100644 --- a/pkg/payload/schema.go +++ b/pkg/payload/schema.go @@ -19,6 +19,7 @@ import ( "bytes" "embed" "fmt" + "io/fs" "github.com/santhosh-tekuri/jsonschema/v6" ) @@ -49,6 +50,10 @@ func LoadSchema() (Schema, error) { } func loadSchema(filename string) (*jsonschema.Schema, error) { + return loadSchemaFromFS(schemaFiles, filename) +} + +func loadSchemaFromFS(schemaFS fs.FS, filename string) (*jsonschema.Schema, error) { var ( schemaDir = "jsonschema" compiler = jsonschema.NewCompiler() @@ -56,7 +61,7 @@ func loadSchema(filename string) (*jsonschema.Schema, error) { // Read the raw JSON schema file from embed.FS path := fmt.Sprintf("%s/%s", schemaDir, filename) - if data, err := schemaFiles.ReadFile(path); err != nil { + if data, err := fs.ReadFile(schemaFS, path); err != nil { return nil, fmt.Errorf("failed to read schema %q: %w", path, err) } else if document, err := jsonschema.UnmarshalJSON(bytes.NewReader(data)); err != nil { return nil, fmt.Errorf("failed to unmarshal schema %q: %w", path, err) diff --git a/pkg/payload/schema_contract_test.go b/pkg/payload/schema_contract_test.go new file mode 100644 index 0000000..85716f9 --- /dev/null +++ b/pkg/payload/schema_contract_test.go @@ -0,0 +1,249 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +package payload_test + +import ( + "encoding/json" + "testing" + + "github.com/santhosh-tekuri/jsonschema/v6" + "github.com/specterops/chow/pkg/payload" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type jsonSchemaAssertion struct { + name string + raw string + valid bool +} + +func assertJSONSchema(t *testing.T, schema *jsonschema.Schema, assertions []jsonSchemaAssertion) { + t.Helper() + + for _, assertion := range assertions { + t.Run(assertion.name, func(t *testing.T) { + var document any + require.NoError(t, json.Unmarshal([]byte(assertion.raw), &document)) + + err := schema.Validate(document) + if assertion.valid { + assert.NoError(t, err) + } else { + assert.Error(t, err) + } + }) + } +} + +func TestNodeJSONSchemaContract(t *testing.T) { + schema, err := payload.LoadSchema() + require.NoError(t, err) + + assertJSONSchema(t, schema.NodeSchema, []jsonSchemaAssertion{ + { + name: "minimal node", + raw: `{"id":"node-1","kinds":["User"]}`, + valid: true, + }, + { + name: "primitive property values", + raw: `{"id":"node-1","kinds":["Device","Asset"],"properties":{"name":"alpha","score":1.5,"enabled":true,"labels":["a","b"],"ports":[1,2],"flags":[true,false]}}`, + valid: true, + }, + { + name: "null properties", + raw: `{"id":"node-1","kinds":["Location"],"properties":null}`, + valid: true, + }, + { + name: "missing id", + raw: `{"kinds":["User"]}`, + valid: false, + }, + { + name: "missing kinds", + raw: `{"id":"node-1"}`, + valid: false, + }, + { + name: "too many kinds", + raw: `{"id":"node-1","kinds":["A","B","C","D"]}`, + valid: false, + }, + { + name: "kind cannot use tag prefix", + raw: `{"id":"node-1","kinds":["Tag_Admin"]}`, + valid: false, + }, + { + name: "property name must be lowercase", + raw: `{"id":"node-1","kinds":["User"],"properties":{"DisplayName":"Alice"}}`, + valid: false, + }, + { + name: "objectid property is reserved", + raw: `{"id":"node-1","kinds":["User"],"properties":{"objectid":"node-1"}}`, + valid: false, + }, + { + name: "property value cannot be an object", + raw: `{"id":"node-1","kinds":["User"],"properties":{"profile":{"name":"Alice"}}}`, + valid: false, + }, + { + name: "array property values must be homogeneous", + raw: `{"id":"node-1","kinds":["User"],"properties":{"items":["one",2]}}`, + valid: false, + }, + }) +} + +func TestEdgeJSONSchemaContract(t *testing.T) { + schema, err := payload.LoadSchema() + require.NoError(t, err) + + assertJSONSchema(t, schema.EdgeSchema, []jsonSchemaAssertion{ + { + name: "id endpoints", + raw: `{"start":{"value":"node-1"},"end":{"value":"node-2"},"kind":"RELATED","properties":{"since":"today","weight":1,"active":true,"labels":["a"]}}`, + valid: true, + }, + { + name: "property matching endpoints", + raw: `{"start":{"match_by":"property","property_matchers":[{"key":"name","operator":"equals","value":"Alice"}]},"end":{"match_by":"property","property_matchers":[{"key":"name","operator":"equals","value":"Bob"}]},"kind":"connected_to","properties":null}`, + valid: true, + }, + { + name: "endpoint kind filter", + raw: `{"start":{"value":"node-1","kind":"User"},"end":{"value":"node-2","kind":"Computer"},"kind":"admin_to"}`, + valid: true, + }, + { + name: "missing start", + raw: `{"end":{"value":"node-2"},"kind":"RELATED"}`, + valid: false, + }, + { + name: "missing end", + raw: `{"start":{"value":"node-1"},"kind":"RELATED"}`, + valid: false, + }, + { + name: "missing kind", + raw: `{"start":{"value":"node-1"},"end":{"value":"node-2"}}`, + valid: false, + }, + { + name: "kind must be alphanumeric or underscore", + raw: `{"start":{"value":"node-1"},"end":{"value":"node-2"},"kind":"bad-kind"}`, + valid: false, + }, + { + name: "kind cannot use tag prefix", + raw: `{"start":{"value":"node-1"},"end":{"value":"node-2"},"kind":"TAG_Admin"}`, + valid: false, + }, + { + name: "endpoint kind cannot use tag prefix", + raw: `{"start":{"value":"node-1","kind":"tag_Admin"},"end":{"value":"node-2"},"kind":"RELATED"}`, + valid: false, + }, + { + name: "id endpoint requires value", + raw: `{"start":{"match_by":"id"},"end":{"value":"node-2"},"kind":"RELATED"}`, + valid: false, + }, + { + name: "property endpoint requires matchers", + raw: `{"start":{"match_by":"property"},"end":{"value":"node-2"},"kind":"RELATED"}`, + valid: false, + }, + { + name: "property endpoint cannot use value", + raw: `{"start":{"match_by":"property","value":"node-1","property_matchers":[{"key":"name","operator":"equals","value":"Alice"}]},"end":{"value":"node-2"},"kind":"RELATED"}`, + valid: false, + }, + { + name: "non-property endpoint cannot use matchers", + raw: `{"start":{"match_by":"id","value":"node-1","property_matchers":[{"key":"name","operator":"equals","value":"Alice"}]},"end":{"value":"node-2"},"kind":"RELATED"}`, + valid: false, + }, + { + name: "property matchers cannot be empty", + raw: `{"start":{"match_by":"property","property_matchers":[]},"end":{"value":"node-2"},"kind":"RELATED"}`, + valid: false, + }, + { + name: "property matcher operator must be equals", + raw: `{"start":{"match_by":"property","property_matchers":[{"key":"name","operator":"contains","value":"Alice"}]},"end":{"value":"node-2"},"kind":"RELATED"}`, + valid: false, + }, + { + name: "property matcher value must be primitive", + raw: `{"start":{"match_by":"property","property_matchers":[{"key":"name","operator":"equals","value":{"first":"Alice"}}]},"end":{"value":"node-2"},"kind":"RELATED"}`, + valid: false, + }, + { + name: "property name must be lowercase", + raw: `{"start":{"value":"node-1"},"end":{"value":"node-2"},"kind":"RELATED","properties":{"DisplayName":"Alice"}}`, + valid: false, + }, + { + name: "property value cannot be an object", + raw: `{"start":{"value":"node-1"},"end":{"value":"node-2"},"kind":"RELATED","properties":{"profile":{"name":"Alice"}}}`, + valid: false, + }, + { + name: "array property values must be homogeneous", + raw: `{"start":{"value":"node-1"},"end":{"value":"node-2"},"kind":"RELATED","properties":{"items":["one",2]}}`, + valid: false, + }, + }) +} + +func TestMetadataJSONSchemaContract(t *testing.T) { + schema, err := payload.LoadSchema() + require.NoError(t, err) + + assertJSONSchema(t, schema.MetaSchema, []jsonSchemaAssertion{ + { + name: "empty metadata", + raw: `{}`, + valid: true, + }, + { + name: "source kind", + raw: `{"source_kind":"hellobase"}`, + valid: true, + }, + { + name: "null source kind", + raw: `{"source_kind":null}`, + valid: true, + }, + { + name: "source kind must be string or null", + raw: `{"source_kind":1}`, + valid: false, + }, + { + name: "additional properties are not allowed", + raw: `{"source_kind":"hellobase","extra":"value"}`, + valid: false, + }, + }) +} diff --git a/pkg/payload/schema_test.go b/pkg/payload/schema_test.go new file mode 100644 index 0000000..66a78a6 --- /dev/null +++ b/pkg/payload/schema_test.go @@ -0,0 +1,134 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +package payload + +import ( + "bytes" + "testing" + "testing/fstest" + + "github.com/santhosh-tekuri/jsonschema/v6" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadSchema(t *testing.T) { + schema, err := LoadSchema() + require.NoError(t, err) + + assert.NotNil(t, schema.NodeSchema) + assert.NotNil(t, schema.EdgeSchema) + assert.NotNil(t, schema.MetaSchema) +} + +func TestLoadSchemaFromFS(t *testing.T) { + assertions := []struct { + name string + schemaFS fstest.MapFS + filename string + errContains string + }{ + { + name: "missing file", + schemaFS: fstest.MapFS{}, + filename: "missing.json", + errContains: `failed to read schema "jsonschema/missing.json"`, + }, + { + name: "invalid JSON", + schemaFS: fstest.MapFS{ + "jsonschema/schema.json": {Data: []byte(`{`)}, + }, + filename: "schema.json", + errContains: `failed to unmarshal schema "jsonschema/schema.json"`, + }, + { + name: "invalid resource URL", + schemaFS: fstest.MapFS{ + "jsonschema/%zz.json": {Data: []byte(`{"type":"object"}`)}, + }, + filename: "%zz.json", + errContains: `failed to add resource for schema "%zz.json"`, + }, + { + name: "invalid schema definition", + schemaFS: fstest.MapFS{ + "jsonschema/schema.json": {Data: []byte(`{"type":"definitely_not_a_json_schema_type"}`)}, + }, + filename: "schema.json", + errContains: `failed to compile schema "schema.json"`, + }, + } + + for _, assertion := range assertions { + t.Run(assertion.name, func(t *testing.T) { + compiledSchema, err := loadSchemaFromFS(assertion.schemaFS, assertion.filename) + + assert.Nil(t, compiledSchema) + assert.ErrorContains(t, err, assertion.errContains) + }) + } + + t.Run("valid schema", func(t *testing.T) { + compiledSchema, err := loadSchemaFromFS(fstest.MapFS{ + "jsonschema/schema.json": {Data: []byte(`{"type":"object"}`)}, + }, "schema.json") + + require.NoError(t, err) + assert.NotNil(t, compiledSchema) + }) +} + +func TestEmbeddedTopLevelJSONSchemaCompilesWithReferences(t *testing.T) { + compiler := jsonschema.NewCompiler() + for _, filename := range []string{"schema.json", "metadata.json", "node.json", "edge.json"} { + data, err := schemaFiles.ReadFile("jsonschema/" + filename) + require.NoError(t, err) + + document, err := jsonschema.UnmarshalJSON(bytes.NewReader(data)) + require.NoError(t, err) + require.NoError(t, compiler.AddResource(filename, document)) + } + + schema, err := compiler.Compile("schema.json") + require.NoError(t, err) + require.NoError(t, schema.Validate(map[string]any{ + "metadata": map[string]any{"source_kind": "hellobase"}, + "graph": map[string]any{ + "nodes": []any{ + map[string]any{"id": "node-1", "kinds": []any{"User"}}, + }, + "edges": []any{ + map[string]any{ + "start": map[string]any{"value": "node-1"}, + "end": map[string]any{"value": "node-2"}, + "kind": "RELATED", + }, + }, + }, + })) + require.Error(t, schema.Validate(map[string]any{ + "graph": map[string]any{ + "nodes": []any{ + map[string]any{"kinds": []any{"User"}}, + }, + }, + })) + require.Error(t, schema.Validate(map[string]any{ + "graph": map[string]any{"nodes": []any{}}, + "extra": true, + })) +} diff --git a/pkg/payload/validator_test.go b/pkg/payload/validator_test.go index 7d772ec..9538005 100644 --- a/pkg/payload/validator_test.go +++ b/pkg/payload/validator_test.go @@ -17,6 +17,7 @@ package payload_test import ( "encoding/json" + "fmt" "strings" "testing" @@ -35,9 +36,34 @@ type parseAndValidateAssertion struct { errValidationFunc func(t *testing.T, report payload.ValidationReport, err error) } -func Test_ParseAndValidate(t *testing.T) { - assertions := []parseAndValidateAssertion{ - // OpenGraph payload tests +func repeatedInvalidNodesPayload(count int) string { + invalidNodes := make([]string, count) + for i := range invalidNodes { + invalidNodes[i] = `{"id":"1","kinds":["A","A","A","A"]}` + } + + return `{"metadata":{"source_kind":"hellobase"},"graph":{"nodes":[` + strings.Join(invalidNodes, ",") + `]}}` +} + +func runParseAndValidateAssertions(t *testing.T, assertions []parseAndValidateAssertion) { + t.Helper() + + schema, err := payload.LoadSchema() + require.NoError(t, err) + + for _, assertion := range assertions { + t.Run(assertion.name, func(t *testing.T) { + v := payload.NewValidator(strings.NewReader(assertion.payload), schema) + + parsedData, validationReport, err := v.ParseAndValidate() + assert.Equal(t, assertion.expectedParsedData, parsedData) + assertion.errValidationFunc(t, validationReport, err) + }) + } +} + +func Test_ParseAndValidateOpenGraphPayloads(t *testing.T) { + runParseAndValidateAssertions(t, []parseAndValidateAssertion{ { name: "successful opengraph payload", payload: `{"metadata":{},"graph":{"nodes":[]}}`, @@ -74,6 +100,41 @@ func Test_ParseAndValidate(t *testing.T) { assert.NoError(t, err) }, }, + { + name: "unsuccessful opengraph metadata", + payload: `{"metadata":{"source_kind":1},"graph":{"nodes":[]}}`, + expectedParsedData: payload.ParsedData{}, + errValidationFunc: func(t *testing.T, report payload.ValidationReport, err error) { + assert.ErrorIs(t, err, payload.ErrOpengraphMetadataValidation) + + assert.ElementsMatch(t, report.CriticalErrors, []payload.CriticalError{{Message: "opengraph metadata failed validation", Error: payload.ErrOpengraphMetadataValidation}}) + }, + }, + { + name: "unsuccessful opengraph no child tags", + payload: `{"graph":{}}`, + expectedParsedData: payload.ParsedData{PayloadType: ingest.DataTypeOpenGraph}, + errValidationFunc: func(t *testing.T, report payload.ValidationReport, err error) { + assert.ErrorIs(t, err, payload.ErrInvalidFileConfiguration) + + assert.ElementsMatch(t, report.CriticalErrors, []payload.CriticalError{{Message: "graph tag requires child nodes or edges tag", Error: payload.ErrInvalidFileConfiguration}}) + }, + }, + { + name: "unsuccessful opengraph metadata, invalid field", + payload: `{"metadata":{"random field":"hello"},"graph":{"nodes":[]}}`, + expectedParsedData: payload.ParsedData{}, + errValidationFunc: func(t *testing.T, report payload.ValidationReport, err error) { + assert.ErrorIs(t, err, payload.ErrOpengraphMetadataValidation) + + assert.ElementsMatch(t, report.CriticalErrors, []payload.CriticalError{{Message: "opengraph metadata failed validation", Error: payload.ErrOpengraphMetadataValidation}}) + }, + }, + }) +} + +func Test_ParseAndValidateOpenGraphNodes(t *testing.T) { + runParseAndValidateAssertions(t, []parseAndValidateAssertion{ { name: "successful opengraph payload with node", payload: `{"metadata":{"source_kind":"hellobase"},"graph":{"nodes":[{"id":"TESTNODE","kinds":["User"],"properties":{"items":["hi"]}}]}}`, @@ -83,6 +144,19 @@ func Test_ParseAndValidate(t *testing.T) { assert.NoError(t, err) }, }, + { + name: "unsuccessful opengraph payload, node property name validation error", + payload: `{"metadata":{"source_kind":"hellobase"},"graph":{"nodes":[{"id":"TESTNODE","kinds":["User"],"properties":{"DisplayName":"Alice"}}]}}`, + expectedParsedData: payload.ParsedData{PayloadType: ingest.DataTypeOpenGraph, OpengraphData: payload.ParsedOpenGraphData{Metadata: ingest.OpengraphMetadata{SourceKind: "hellobase"}, NodesValidated: 1}}, + errValidationFunc: func(t *testing.T, report payload.ValidationReport, err error) { + assert.ErrorIs(t, err, payload.ErrValidationErrors) + + require.Len(t, report.ValidationErrors, 1) + assert.Equal(t, "/graph/nodes[0]", report.ValidationErrors[0].Location) + assert.Equal(t, `{"id":"TESTNODE","kinds":["User"],"properties":{"DisplayName":"Alice"}}`, report.ValidationErrors[0].RawObject) + assert.NotEmpty(t, report.ValidationErrors[0].Errors) + }, + }, { name: "unsuccessful opengraph payload, node id validation error", payload: `{"metadata":{"source_kind":"hellobase"},"graph":{"nodes":[{"id":1,"kinds":["User"]}]}}`, @@ -177,35 +251,25 @@ func Test_ParseAndValidate(t *testing.T) { }, }, { - name: "unsuccessful opengraph payload, exceeds max validation errors", - payload: `{"metadata":{"source_kind":"hellobase"},"graph":{"nodes":[{"id":"1","kinds":["A","A","A","A"]},` + - `{"id":"1","kinds":["A","A","A","A"]},{"id":"1","kinds":["A","A","A","A"]},{"id":"1","kinds":["A","A","A","A"]},{"id":"1","kinds":["A","A","A","A"]},` + - `{"id":"1","kinds":["A","A","A","A"]},{"id":"1","kinds":["A","A","A","A"]},{"id":"1","kinds":["A","A","A","A"]},{"id":"1","kinds":["A","A","A","A"]},` + - `{"id":"1","kinds":["A","A","A","A"]},{"id":"1","kinds":["A","A","A","A"]},{"id":"1","kinds":["A","A","A","A"]},{"id":"1","kinds":["A","A","A","A"]},` + - `{"id":"1","kinds":["A","A","A","A"]},{"id":"1","kinds":["A","A","A","A"]},{"id":"1","kinds":["A","A","A","A"]},{"id":"1","kinds":["A","A","A","A"]}]}}`, + name: "unsuccessful opengraph payload, exceeds max validation errors", + payload: repeatedInvalidNodesPayload(17), expectedParsedData: payload.ParsedData{PayloadType: ingest.DataTypeOpenGraph, OpengraphData: payload.ParsedOpenGraphData{Metadata: ingest.OpengraphMetadata{SourceKind: "hellobase"}, NodesValidated: 15}}, errValidationFunc: func(t *testing.T, report payload.ValidationReport, err error) { assert.ErrorIs(t, err, payload.ErrMaxValidationErrors) - assert.ElementsMatch(t, report.ValidationErrors, []payload.ValidationError{ - {Location: "/graph/nodes[0]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []payload.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, - {Location: "/graph/nodes[1]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []payload.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, - {Location: "/graph/nodes[2]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []payload.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, - {Location: "/graph/nodes[3]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []payload.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, - {Location: "/graph/nodes[4]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []payload.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, - {Location: "/graph/nodes[5]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []payload.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, - {Location: "/graph/nodes[6]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []payload.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, - {Location: "/graph/nodes[7]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []payload.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, - {Location: "/graph/nodes[8]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []payload.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, - {Location: "/graph/nodes[9]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []payload.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, - {Location: "/graph/nodes[10]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []payload.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, - {Location: "/graph/nodes[11]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []payload.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, - {Location: "/graph/nodes[12]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []payload.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, - {Location: "/graph/nodes[13]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []payload.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, - {Location: "/graph/nodes[14]", RawObject: `{"id":"1","kinds":["A","A","A","A"]}`, Errors: []payload.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}}, - }) + require.Len(t, report.ValidationErrors, 15) + for i, validationErr := range report.ValidationErrors { + assert.Equal(t, fmt.Sprintf("/graph/nodes[%d]", i), validationErr.Location) + assert.Equal(t, `{"id":"1","kinds":["A","A","A","A"]}`, validationErr.RawObject) + assert.Equal(t, []payload.ValidationErrorDetail{{Location: "/kinds", Error: "maxItems: got 4, want 3"}}, validationErr.Errors) + } }, }, + }) +} + +func Test_ParseAndValidateOpenGraphEdges(t *testing.T) { + runParseAndValidateAssertions(t, []parseAndValidateAssertion{ { name: "successful opengraph payload with edge", payload: `{"metadata":{"source_kind":"hellobase"},"graph":{"nodes":[],"edges":[{"start":{"value":"TESTNODE"},"end":{"value":"TESTNODE2"},"kind":"RELATED","properties":{"items":["hi"]}}]}}`, @@ -215,6 +279,19 @@ func Test_ParseAndValidate(t *testing.T) { assert.NoError(t, err) }, }, + { + name: "unsuccessful opengraph payload, edge property name validation error", + payload: `{"metadata":{"source_kind":"hellobase"},"graph":{"nodes":[],"edges":[{"start":{"value":"TESTNODE"},"end":{"value":"TESTNODE2"},"kind":"RELATED","properties":{"DisplayName":"Alice"}}]}}`, + expectedParsedData: payload.ParsedData{PayloadType: ingest.DataTypeOpenGraph, OpengraphData: payload.ParsedOpenGraphData{Metadata: ingest.OpengraphMetadata{SourceKind: "hellobase"}, EdgesValidated: 1}}, + errValidationFunc: func(t *testing.T, report payload.ValidationReport, err error) { + assert.ErrorIs(t, err, payload.ErrValidationErrors) + + require.Len(t, report.ValidationErrors, 1) + assert.Equal(t, "/graph/edges[0]", report.ValidationErrors[0].Location) + assert.Equal(t, `{"start":{"value":"TESTNODE"},"end":{"value":"TESTNODE2"},"kind":"RELATED","properties":{"DisplayName":"Alice"}}`, report.ValidationErrors[0].RawObject) + assert.NotEmpty(t, report.ValidationErrors[0].Errors) + }, + }, { name: "successful opengraph payload with edge property matching", payload: `{"metadata":{"source_kind":"hellobase"},"graph":{"nodes":[],"edges":[{"start":{"match_by":"property","property_matchers":[{"key":"prop_1","operator":"equals","value":"ROHAN"}]},"end":{"match_by":"property","property_matchers":[{"key":"prop_1","operator":"equals","value":"WES"}]},"kind":"RELATED","properties":{"items":["hi"]}}]}}`, @@ -320,37 +397,11 @@ func Test_ParseAndValidate(t *testing.T) { }) }, }, - { - name: "unsuccessful opengraph metadata", - payload: `{"metadata":{"source_kind":1},"graph":{"nodes":[]}}`, - expectedParsedData: payload.ParsedData{}, - errValidationFunc: func(t *testing.T, report payload.ValidationReport, err error) { - assert.ErrorIs(t, err, payload.ErrOpengraphMetadataValidation) - - assert.ElementsMatch(t, report.CriticalErrors, []payload.CriticalError{{Message: "opengraph metadata failed validation", Error: payload.ErrOpengraphMetadataValidation}}) - }, - }, - { - name: "unsuccessful opengraph no child tags", - payload: `{"graph":{}}`, - expectedParsedData: payload.ParsedData{PayloadType: ingest.DataTypeOpenGraph}, - errValidationFunc: func(t *testing.T, report payload.ValidationReport, err error) { - assert.ErrorIs(t, err, payload.ErrInvalidFileConfiguration) - - assert.ElementsMatch(t, report.CriticalErrors, []payload.CriticalError{{Message: "graph tag requires child nodes or edges tag", Error: payload.ErrInvalidFileConfiguration}}) - }, - }, - { - name: "unsuccessful opengraph metadata, invalid field", - payload: `{"metadata":{"random field":"hello"},"graph":{"nodes":[]}}`, - expectedParsedData: payload.ParsedData{}, - errValidationFunc: func(t *testing.T, report payload.ValidationReport, err error) { - assert.ErrorIs(t, err, payload.ErrOpengraphMetadataValidation) + }) +} - assert.ElementsMatch(t, report.CriticalErrors, []payload.CriticalError{{Message: "opengraph metadata failed validation", Error: payload.ErrOpengraphMetadataValidation}}) - }, - }, - // Original payload tests +func Test_ParseAndValidateOriginalPayloads(t *testing.T) { + runParseAndValidateAssertions(t, []parseAndValidateAssertion{ { name: "successful original payload", payload: `{"meta":{"methods": 0,"type":"sessions","count": 0,"version": 5},"data":[]}`, @@ -425,7 +476,11 @@ func Test_ParseAndValidate(t *testing.T) { assert.ElementsMatch(t, report.CriticalErrors, []payload.CriticalError{{Message: "invalid original metadata data type", Error: payload.ErrInvalidDataType}}) }, }, - // Invalid payload tests + }) +} + +func Test_ParseAndValidateTopLevelPayloadErrors(t *testing.T) { + runParseAndValidateAssertions(t, []parseAndValidateAssertion{ { name: "unsuccessful payload, no valid tags", payload: `{}`, @@ -467,6 +522,195 @@ func Test_ParseAndValidate(t *testing.T) { assert.ErrorContains(t, report.CriticalErrors[0].Error, "expected EOF, instead got token: {") }, }, + }) +} + +func Test_ParseAndValidateConfigurationErrors(t *testing.T) { + assertions := []struct { + name string + payload string + expectedErr error + expectedCritical payload.CriticalError + errContains string + }{ + { + name: "invalid top level json", + payload: `[]`, + errContains: "expected open bracket", + expectedCritical: payload.CriticalError{ + Message: "failed to enter json object", + }, + }, + { + name: "empty input", + payload: ``, + errContains: "EOF", + expectedCritical: payload.CriticalError{ + Message: "failed to enter json object", + }, + }, + { + name: "malformed top level object", + payload: `{"graph":{"nodes":[]},`, + errContains: "EOF", + expectedCritical: payload.CriticalError{ + Message: "failed parsing top level tag", + }, + }, + { + name: "schema tag missing value", + payload: `{"$schema":`, + errContains: "EOF", + expectedCritical: payload.CriticalError{ + Message: "failed to consume $schema value", + }, + }, + { + name: "metadata tag missing value", + payload: `{"metadata":`, + errContains: "EOF", + expectedCritical: payload.CriticalError{ + Message: "failed decoding opengraph metadata to raw object", + }, + }, + { + name: "data must be an array", + payload: `{"data":{},"meta":{"methods":0,"type":"sessions","count":0,"version":5}}`, + errContains: "expected open square bracket", + expectedCritical: payload.CriticalError{ + Message: "failed to enter data array", + }, + }, + { + name: "data tag missing value", + payload: `{"data":`, + errContains: "EOF", + expectedCritical: payload.CriticalError{ + Message: "failed to enter data array", + }, + }, + { + name: "graph must be an object", + payload: `{"graph":[]}`, + errContains: "expected open bracket", + expectedCritical: payload.CriticalError{ + Message: "failed to enter graph object", + }, + }, + { + name: "graph nodes must be an array", + payload: `{"graph":{"nodes":{}}}`, + errContains: "expected open square bracket", + expectedCritical: payload.CriticalError{ + Message: "failed to enter graph nodes array", + }, + }, + { + name: "malformed graph node object", + payload: `{"graph":{"nodes":[{"id":"node-1"`, + errContains: "unexpected EOF", + expectedCritical: payload.CriticalError{ + Message: "failed to decode nodes array object", + }, + }, + { + name: "graph edges must be an array", + payload: `{"graph":{"edges":{}}}`, + errContains: "expected open square bracket", + expectedCritical: payload.CriticalError{ + Message: "failed to enter graph edges array", + }, + }, + { + name: "unrecognized graph child tag", + payload: `{"graph":{"nodes":[],"strays":[]}}`, + expectedErr: payload.ErrInvalidFileConfiguration, + expectedCritical: payload.CriticalError{ + Message: "unrecognized graph child tag: strays", + Error: payload.ErrInvalidFileConfiguration, + }, + }, + { + name: "duplicate data tag", + payload: `{"data":[],"data":[],"meta":{"methods":0,"type":"sessions","count":0,"version":5}}`, + expectedErr: payload.ErrInvalidFileConfiguration, + expectedCritical: payload.CriticalError{ + Message: "duplicate top level data tag found", + Error: payload.ErrInvalidFileConfiguration, + }, + }, + { + name: "duplicate opengraph metadata tag", + payload: `{"metadata":{},"metadata":{},"graph":{"nodes":[]}}`, + expectedErr: payload.ErrInvalidFileConfiguration, + expectedCritical: payload.CriticalError{ + Message: "duplicate top level metadata tag found", + Error: payload.ErrInvalidFileConfiguration, + }, + }, + { + name: "duplicate graph tag", + payload: `{"graph":{"nodes":[]},"graph":{"nodes":[]}}`, + expectedErr: payload.ErrInvalidFileConfiguration, + expectedCritical: payload.CriticalError{ + Message: "duplicate top level graph tag found", + Error: payload.ErrInvalidFileConfiguration, + }, + }, + { + name: "duplicate graph nodes tag", + payload: `{"graph":{"nodes":[],"nodes":[]}}`, + expectedErr: payload.ErrInvalidFileConfiguration, + expectedCritical: payload.CriticalError{ + Message: "duplicate graph nodes tag found", + Error: payload.ErrInvalidFileConfiguration, + }, + }, + { + name: "duplicate graph edges tag", + payload: `{"graph":{"edges":[],"edges":[]}}`, + expectedErr: payload.ErrInvalidFileConfiguration, + expectedCritical: payload.CriticalError{ + Message: "duplicate graph edges tag found", + Error: payload.ErrInvalidFileConfiguration, + }, + }, + { + name: "legacy meta with opengraph metadata", + payload: `{"meta":{"methods":0,"type":"sessions","count":0,"version":5},"metadata":{},"data":[]}`, + expectedErr: payload.ErrInvalidFileConfiguration, + expectedCritical: payload.CriticalError{ + Message: "cannot have both original meta tag and opengraph metadata tag", + Error: payload.ErrInvalidFileConfiguration, + }, + }, + { + name: "legacy meta with opengraph graph", + payload: `{"meta":{"methods":0,"type":"sessions","count":0,"version":5},"graph":{"nodes":[]},"data":[]}`, + expectedErr: payload.ErrInvalidFileConfiguration, + expectedCritical: payload.CriticalError{ + Message: "cannot have both original meta tag and opengraph graph tag", + Error: payload.ErrInvalidFileConfiguration, + }, + }, + { + name: "legacy data with opengraph metadata", + payload: `{"data":[],"metadata":{},"meta":{"methods":0,"type":"sessions","count":0,"version":5}}`, + expectedErr: payload.ErrInvalidFileConfiguration, + expectedCritical: payload.CriticalError{ + Message: "cannot have both original data tag and opengraph metadata tag", + Error: payload.ErrInvalidFileConfiguration, + }, + }, + { + name: "opengraph metadata without graph", + payload: `{"metadata":{"source_kind":"hellobase"}}`, + expectedErr: payload.ErrInvalidFileConfiguration, + expectedCritical: payload.CriticalError{ + Message: "no graph tag found to match opengraph metadata tag", + Error: payload.ErrInvalidFileConfiguration, + }, + }, } schema, err := payload.LoadSchema() @@ -476,9 +720,19 @@ func Test_ParseAndValidate(t *testing.T) { t.Run(assertion.name, func(t *testing.T) { v := payload.NewValidator(strings.NewReader(assertion.payload), schema) - parsedData, validationReport, err := v.ParseAndValidate() - assert.Equal(t, assertion.expectedParsedData, parsedData) - assertion.errValidationFunc(t, validationReport, err) + _, report, err := v.ParseAndValidate() + if assertion.expectedErr != nil { + assert.ErrorIs(t, err, assertion.expectedErr) + } + if assertion.errContains != "" { + assert.ErrorContains(t, err, assertion.errContains) + } + + require.Len(t, report.CriticalErrors, 1) + assert.Equal(t, assertion.expectedCritical.Message, report.CriticalErrors[0].Message) + if assertion.expectedCritical.Error != nil { + assert.ErrorIs(t, report.CriticalErrors[0].Error, assertion.expectedCritical.Error) + } }) } } @@ -550,6 +804,44 @@ func Test_ParseMetadata(t *testing.T) { assert.NoError(t, err) }, }, + { + name: "no recognizable metadata", + payload: `{}`, + expectedParsedData: payload.ParsedData{}, + errValidationFunc: func(t *testing.T, err error) { + assert.NoError(t, err) + }, + }, + { + name: "invalid legacy metadata", + payload: `{"meta":0}`, + expectedParsedData: payload.ParsedData{}, + errValidationFunc: func(t *testing.T, err error) { + var unmarshalErr *json.UnmarshalTypeError + + assert.ErrorAs(t, err, &unmarshalErr) + }, + }, + { + name: "invalid opengraph metadata", + payload: `{"metadata":0}`, + expectedParsedData: payload.ParsedData{}, + errValidationFunc: func(t *testing.T, err error) { + var unmarshalErr *json.UnmarshalTypeError + + assert.ErrorAs(t, err, &unmarshalErr) + }, + }, + { + name: "malformed payload after graph tag", + payload: `{"graph":`, + expectedParsedData: payload.ParsedData{ + PayloadType: ingest.DataTypeOpenGraph, + }, + errValidationFunc: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "EOF") + }, + }, { name: "invalid top level json", payload: `[]`, @@ -575,13 +867,41 @@ func Test_ParseMetadata(t *testing.T) { } func TestValidationError_Error(t *testing.T) { - validationErr := payload.ValidationError{ - Location: "/graph/nodes[0]", - Errors: []payload.ValidationErrorDetail{ - {Location: "/id", Error: "got number, want string"}, - {Location: "/properties/items", Error: "invalid type"}, + assertions := []struct { + name string + validationErr payload.ValidationError + expected string + }{ + { + name: "location and details", + validationErr: payload.ValidationError{ + Location: "/graph/nodes[0]", + Errors: []payload.ValidationErrorDetail{ + {Location: "/id", Error: "got number, want string"}, + {Location: "/properties/items", Error: "invalid type"}, + }, + }, + expected: "validation error at /graph/nodes[0]: /id: got number, want string; /properties/items: invalid type", + }, + { + name: "detail without location", + validationErr: payload.ValidationError{ + Errors: []payload.ValidationErrorDetail{ + {Error: "invalid type"}, + }, + }, + expected: "validation error: invalid type", + }, + { + name: "no details", + validationErr: payload.ValidationError{}, + expected: "validation error", }, } - assert.Equal(t, "validation error at /graph/nodes[0]: /id: got number, want string; /properties/items: invalid type", validationErr.Error()) + for _, assertion := range assertions { + t.Run(assertion.name, func(t *testing.T) { + assert.Equal(t, assertion.expected, assertion.validationErr.Error()) + }) + } } From edcccfbc40774abc0df287005566f9d6b65f62a2 Mon Sep 17 00:00:00 2001 From: Wes <169498386+wes-mil@users.noreply.github.com> Date: Tue, 26 May 2026 13:42:00 -0400 Subject: [PATCH 10/11] Fix property name rejection add chowbench --- .gitignore | 1 + README.md | 16 +++ cmd/chowbench/main.go | 202 ++++++++++++++++++++++++++++ cmd/chowbench/main_test.go | 122 +++++++++++++++++ pkg/payload/jsonschema/edge.json | 6 +- pkg/payload/jsonschema/node.json | 6 +- pkg/payload/schema_contract_test.go | 14 +- 7 files changed, 361 insertions(+), 6 deletions(-) create mode 100644 .gitignore create mode 100644 cmd/chowbench/main.go create mode 100644 cmd/chowbench/main_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e88dd5d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +fixtures/ \ No newline at end of file diff --git a/README.md b/README.md index 7b3bc0d..b90fa1c 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,22 @@ Or you can clone the repo and run the following command from the top level: go install . ``` +# Benchmarking + +Use `chowbench` when you want to measure validation performance across one or more files without changing the normal `chow` CLI. + +```bash +go run ./cmd/chowbench -runs 5 -warmup 1 payload-one.json payload-two.json +``` + +The harness loads the JSON schemas once, validates each file for the requested number of runs, and prints a table with byte size, status, average duration, min/max duration, and error counts. + +By default, invalid payloads are still measured and reported. Add `-strict` if invalid payloads should make the command exit non-zero: + +```bash +go run ./cmd/chowbench -runs 5 -strict payload-one.json payload-two.json +``` + # JSON Schema Want to add the OpenGraph schema to your JSON document? diff --git a/cmd/chowbench/main.go b/cmd/chowbench/main.go new file mode 100644 index 0000000..88726a4 --- /dev/null +++ b/cmd/chowbench/main.go @@ -0,0 +1,202 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "io" + "os" + "text/tabwriter" + "time" + + "github.com/specterops/chow/pkg/payload" +) + +type durationSummary struct { + Avg time.Duration + Min time.Duration + Max time.Duration +} + +type benchmarkResult struct { + File string + Bytes int64 + Runs int + Status string + Error string + CriticalErrors int + ValidationErrors int + Durations durationSummary +} + +func main() { + var ( + runs int + warmup int + strict bool + ) + + flag.IntVar(&runs, "runs", 3, "number of measured validation runs per file") + flag.IntVar(&warmup, "warmup", 1, "number of unmeasured warmup validation runs per file") + flag.BoolVar(&strict, "strict", false, "exit non-zero when a file fails validation") + flag.Parse() + + if err := run(os.Stdout, flag.Args(), runs, warmup, strict); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(w io.Writer, files []string, runs int, warmup int, strict bool) error { + if runs < 1 { + return fmt.Errorf("-runs must be greater than 0") + } + if warmup < 0 { + return fmt.Errorf("-warmup must be 0 or greater") + } + if len(files) == 0 { + return fmt.Errorf("usage: chowbench [-runs N] [-warmup N] [-strict] file [file...]") + } + + schema, err := payload.LoadSchema() + if err != nil { + return fmt.Errorf("load schema: %w", err) + } + + results := make([]benchmarkResult, 0, len(files)) + for _, file := range files { + result := benchmarkFile(file, schema, runs, warmup) + results = append(results, result) + } + + writeResults(w, results) + return exitErrorForResults(results, strict) +} + +func benchmarkFile(file string, schema payload.Schema, runs int, warmup int) benchmarkResult { + result := benchmarkResult{ + File: file, + Runs: runs, + } + + if stat, err := os.Stat(file); err != nil { + result.Status = "error" + result.Error = err.Error() + return result + } else { + result.Bytes = stat.Size() + } + + for i := 0; i < warmup; i++ { + _, _ = validateFile(file, schema) + } + + durations := make([]time.Duration, 0, runs) + for i := 0; i < runs; i++ { + start := time.Now() + report, err := validateFile(file, schema) + durations = append(durations, time.Since(start)) + + result.Status, result.Error = statusForValidationResult(report, err) + result.CriticalErrors = len(report.CriticalErrors) + result.ValidationErrors = len(report.ValidationErrors) + } + + result.Durations = summarizeDurations(durations) + return result +} + +func validateFile(file string, schema payload.Schema) (payload.ValidationReport, error) { + reader, err := os.Open(file) + if err != nil { + return payload.ValidationReport{}, err + } + defer reader.Close() + + validator := payload.NewValidator(reader, schema) + _, report, err := validator.ParseAndValidate() + return report, err +} + +func summarizeDurations(durations []time.Duration) durationSummary { + if len(durations) == 0 { + return durationSummary{} + } + + var total time.Duration + summary := durationSummary{ + Min: durations[0], + Max: durations[0], + } + + for _, duration := range durations { + total += duration + if duration < summary.Min { + summary.Min = duration + } + if duration > summary.Max { + summary.Max = duration + } + } + + summary.Avg = total / time.Duration(len(durations)) + return summary +} + +func statusForValidationResult(report payload.ValidationReport, err error) (string, string) { + if err == nil { + return "ok", "" + } + + if len(report.CriticalErrors) > 0 { + return "critical_error", err.Error() + } + + if len(report.ValidationErrors) > 0 || + errors.Is(err, payload.ErrValidationErrors) || + errors.Is(err, payload.ErrMaxValidationErrors) { + return "validation_error", err.Error() + } + + return "error", err.Error() +} + +func exitErrorForResults(results []benchmarkResult, strict bool) error { + var hasValidationFailure bool + for _, result := range results { + switch result.Status { + case "error": + return fmt.Errorf("one or more files could not be benchmarked") + case "validation_error", "critical_error": + hasValidationFailure = true + } + } + + if strict && hasValidationFailure { + return fmt.Errorf("one or more files failed validation") + } + + return nil +} + +func writeResults(w io.Writer, results []benchmarkResult) { + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "file\tbytes\truns\tstatus\tavg\tmin\tmax\tcritical\tvalidation\terror") + for _, result := range results { + fmt.Fprintf( + tw, + "%s\t%d\t%d\t%s\t%s\t%s\t%s\t%d\t%d\t%s\n", + result.File, + result.Bytes, + result.Runs, + result.Status, + result.Durations.Avg, + result.Durations.Min, + result.Durations.Max, + result.CriticalErrors, + result.ValidationErrors, + result.Error, + ) + } + tw.Flush() +} diff --git a/cmd/chowbench/main_test.go b/cmd/chowbench/main_test.go new file mode 100644 index 0000000..ba842ae --- /dev/null +++ b/cmd/chowbench/main_test.go @@ -0,0 +1,122 @@ +package main + +import ( + "errors" + "testing" + "time" + + "github.com/specterops/chow/pkg/payload" + "github.com/stretchr/testify/assert" +) + +func TestSummarizeDurations(t *testing.T) { + summary := summarizeDurations([]time.Duration{ + 3 * time.Millisecond, + 1 * time.Millisecond, + 2 * time.Millisecond, + }) + + assert.Equal(t, 2*time.Millisecond, summary.Avg) + assert.Equal(t, time.Millisecond, summary.Min) + assert.Equal(t, 3*time.Millisecond, summary.Max) +} + +func TestSummarizeDurationsEmpty(t *testing.T) { + assert.Equal(t, durationSummary{}, summarizeDurations(nil)) +} + +func TestStatusForValidationResult(t *testing.T) { + assertions := []struct { + name string + report payload.ValidationReport + err error + expectedStatus string + expectedErr string + }{ + { + name: "valid file", + expectedStatus: "ok", + }, + { + name: "validation errors", + report: payload.ValidationReport{ + ValidationErrors: []payload.ValidationError{{Location: "/graph/nodes[0]"}}, + }, + err: payload.ErrValidationErrors, + expectedStatus: "validation_error", + expectedErr: payload.ErrValidationErrors.Error(), + }, + { + name: "critical errors", + report: payload.ValidationReport{ + CriticalErrors: []payload.CriticalError{{Message: "bad file"}}, + }, + err: payload.ErrInvalidFileConfiguration, + expectedStatus: "critical_error", + expectedErr: payload.ErrInvalidFileConfiguration.Error(), + }, + { + name: "harness error", + err: errors.New("open file: permission denied"), + expectedStatus: "error", + expectedErr: "open file: permission denied", + }, + } + + for _, assertion := range assertions { + t.Run(assertion.name, func(t *testing.T) { + status, errText := statusForValidationResult(assertion.report, assertion.err) + + assert.Equal(t, assertion.expectedStatus, status) + assert.Equal(t, assertion.expectedErr, errText) + }) + } +} + +func TestExitErrorForResults(t *testing.T) { + assertions := []struct { + name string + results []benchmarkResult + strict bool + expectError bool + }{ + { + name: "valid files", + results: []benchmarkResult{ + {Status: "ok"}, + }, + }, + { + name: "validation errors are allowed by default", + results: []benchmarkResult{ + {Status: "validation_error"}, + }, + }, + { + name: "validation errors fail in strict mode", + results: []benchmarkResult{ + {Status: "validation_error"}, + }, + strict: true, + expectError: true, + }, + { + name: "harness errors always fail", + results: []benchmarkResult{ + {Status: "error"}, + }, + expectError: true, + }, + } + + for _, assertion := range assertions { + t.Run(assertion.name, func(t *testing.T) { + err := exitErrorForResults(assertion.results, assertion.strict) + if assertion.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/pkg/payload/jsonschema/edge.json b/pkg/payload/jsonschema/edge.json index 60c8323..44c2d28 100644 --- a/pkg/payload/jsonschema/edge.json +++ b/pkg/payload/jsonschema/edge.json @@ -5,9 +5,11 @@ "$defs": { "property_map": { "type": ["object", "null"], - "description": "A key-value map of edge attributes. Property names must contain only lowercase letters, numbers, and underscores. Values must not be objects. If a value is an array, it must contain only primitive types (e.g., strings, numbers, booleans) and must be homogeneous (all items must be of the same type).", + "description": "A key-value map of edge attributes. Property names must not contain uppercase letters. Values must not be objects. If a value is an array, it must contain only primitive types (e.g., strings, numbers, booleans) and must be homogeneous (all items must be of the same type).", "propertyNames": { - "pattern": "^[a-z0-9_]+$" + "not": { + "pattern": "[A-Z]" + } }, "additionalProperties": { "anyOf": [ diff --git a/pkg/payload/jsonschema/node.json b/pkg/payload/jsonschema/node.json index 03430a7..3f9ce13 100644 --- a/pkg/payload/jsonschema/node.json +++ b/pkg/payload/jsonschema/node.json @@ -5,9 +5,11 @@ "$defs": { "property_map": { "type": ["object", "null"], - "description": "A key-value map of entity attributes. Property names must contain only lowercase letters, numbers, and underscores. Values must not be objects. If a value is an array, it must contain only primitive types (e.g., strings, numbers, booleans) and must be homogeneous (all items must be of the same type).", + "description": "A key-value map of entity attributes. Property names must not contain uppercase letters. Values must not be objects. If a value is an array, it must contain only primitive types (e.g., strings, numbers, booleans) and must be homogeneous (all items must be of the same type).", "propertyNames": { - "pattern": "^[a-z0-9_]+$" + "not": { + "pattern": "[A-Z]" + } }, "additionalProperties": { "anyOf": [ diff --git a/pkg/payload/schema_contract_test.go b/pkg/payload/schema_contract_test.go index 85716f9..5c6579c 100644 --- a/pkg/payload/schema_contract_test.go +++ b/pkg/payload/schema_contract_test.go @@ -64,6 +64,11 @@ func TestNodeJSONSchemaContract(t *testing.T) { raw: `{"id":"node-1","kinds":["Device","Asset"],"properties":{"name":"alpha","score":1.5,"enabled":true,"labels":["a","b"],"ports":[1,2],"flags":[true,false]}}`, valid: true, }, + { + name: "punctuation is allowed in property names", + raw: `{"id":"node-1","kinds":["Device"],"properties":{"display.name":"alpha","risk-score":1.5,"source/vendor":"acme","observed@time":"today"}}`, + valid: true, + }, { name: "null properties", raw: `{"id":"node-1","kinds":["Location"],"properties":null}`, @@ -90,7 +95,7 @@ func TestNodeJSONSchemaContract(t *testing.T) { valid: false, }, { - name: "property name must be lowercase", + name: "property name must not contain uppercase letters", raw: `{"id":"node-1","kinds":["User"],"properties":{"DisplayName":"Alice"}}`, valid: false, }, @@ -122,6 +127,11 @@ func TestEdgeJSONSchemaContract(t *testing.T) { raw: `{"start":{"value":"node-1"},"end":{"value":"node-2"},"kind":"RELATED","properties":{"since":"today","weight":1,"active":true,"labels":["a"]}}`, valid: true, }, + { + name: "punctuation is allowed in property names", + raw: `{"start":{"value":"node-1"},"end":{"value":"node-2"},"kind":"RELATED","properties":{"display.name":"alpha","risk-score":1.5,"source/vendor":"acme","observed@time":"today"}}`, + valid: true, + }, { name: "property matching endpoints", raw: `{"start":{"match_by":"property","property_matchers":[{"key":"name","operator":"equals","value":"Alice"}]},"end":{"match_by":"property","property_matchers":[{"key":"name","operator":"equals","value":"Bob"}]},"kind":"connected_to","properties":null}`, @@ -198,7 +208,7 @@ func TestEdgeJSONSchemaContract(t *testing.T) { valid: false, }, { - name: "property name must be lowercase", + name: "property name must not contain uppercase letters", raw: `{"start":{"value":"node-1"},"end":{"value":"node-2"},"kind":"RELATED","properties":{"DisplayName":"Alice"}}`, valid: false, }, From 9fef64615c9bfd2c9312320e8b4d58d4cdebaf8b Mon Sep 17 00:00:00 2001 From: Wes <169498386+wes-mil@users.noreply.github.com> Date: Tue, 26 May 2026 14:11:36 -0400 Subject: [PATCH 11/11] improve write string --- main.go | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/main.go b/main.go index fb8451f..5b8b7ae 100644 --- a/main.go +++ b/main.go @@ -120,18 +120,26 @@ func formatValidationError(valErr payload.ValidationError) (string, error) { sb.WriteString("VALIDATION ERROR:\n") - sb.WriteString("Location: " + valErr.Location + "\n") + sb.WriteString("Location: ") + sb.WriteString(valErr.Location) + sb.WriteString("\n") err := json.Indent(&objBytes, []byte(valErr.RawObject), "", "\t") if err != nil { return "", err } - sb.WriteString("Object:\n" + objBytes.String() + "\n") + sb.WriteString("Object:\n") + sb.WriteString(objBytes.String()) + sb.WriteString("\n") sb.WriteString("Errors:\n") for _, e := range valErr.Errors { - sb.WriteString("at " + e.Location + ": " + e.Error + "\n") + sb.WriteString("at ") + sb.WriteString(e.Location) + sb.WriteString(": ") + sb.WriteString(e.Error) + sb.WriteString("\n") } return sb.String(), nil