From 984581b93f0dddd16d579a199a0729257066d287 Mon Sep 17 00:00:00 2001 From: ykaiboussiSO Date: Mon, 17 Aug 2026 20:16:10 -0500 Subject: [PATCH 01/11] chore: Testing OpenCypher Components in BDD format Resolves: BED-9180 --- .github/workflows/go-test.yml | 79 +++++++++++++-- Makefile | 8 +- README.md | 5 + bdd/features/matching.feature | 28 ++++++ bdd/steps.go | 177 ++++++++++++++++++++++++++++++++++ bdd/steps_test.go | 84 ++++++++++++++++ go.mod | 7 ++ go.sum | 14 +++ 8 files changed, 393 insertions(+), 9 deletions(-) create mode 100644 bdd/features/matching.feature create mode 100644 bdd/steps.go create mode 100644 bdd/steps_test.go diff --git a/.github/workflows/go-test.yml b/.github/workflows/go-test.yml index f00ef3d6..c66a45d2 100644 --- a/.github/workflows/go-test.yml +++ b/.github/workflows/go-test.yml @@ -38,7 +38,7 @@ jobs: run: echo "Artifact ID from previous job is $COV_FILE_ID" - name: Checkout source code for this repository - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # ratchet:actions/checkout@v5 + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # ratchet:actions/checkout@v5 - name: Install Go uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # ratchet:actions/setup-go@v5 @@ -60,7 +60,7 @@ jobs: printf 'Coverage Value: %s\n' "$current_coverage" - name: Download Main Coverage - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # ratchet:actions/download-artifact@v8 with: artifact-ids: ${{ needs.call-run-upload-main-coverage-files.outputs.coverage-artifact-id }} path: tmp/ @@ -89,7 +89,7 @@ jobs: runs-on: ubuntu-latest services: postgres: - image: postgres:18 + image: index.docker.io/library/postgres@sha256:06cad38a5d9f5d24b4d83d86def30795d5e4b757fedbf5281172b576dedcd941 # ratchet:postgres:18 env: POSTGRES_USER: dawgs POSTGRES_PASSWORD: weneedbetterpasswords @@ -103,7 +103,7 @@ jobs: --health-retries 5 steps: - name: Checkout source code for this repository - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # ratchet:actions/checkout@v5 + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # ratchet:actions/checkout@v5 - name: Install Go uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # ratchet:actions/setup-go@v5 @@ -123,7 +123,7 @@ jobs: runs-on: ubuntu-latest services: neo4j: - image: neo4j:4.4.42 + image: index.docker.io/library/neo4j@sha256:73b5a47eb9952f47fcc42fcc80bdd6bd0d6b71eea9a1a345c216675498c790e4 # ratchet:neo4j:4.4.42 env: NEO4J_AUTH: neo4j/weneedbetterpasswords ports: @@ -136,7 +136,7 @@ jobs: --health-start-period 40s steps: - name: Checkout source code for this repository - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # ratchet:actions/checkout@v5 + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # ratchet:actions/checkout@v5 - name: Install Go uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # ratchet:actions/setup-go@v5 @@ -150,3 +150,70 @@ jobs: CONNECTION_STRING: neo4j://neo4j:weneedbetterpasswords@localhost:7687 run: | make test_integration + + bdd-integration-test-pg: + name: Run BDD integration tests (pg) + runs-on: ubuntu-latest + services: + postgres: + image: index.docker.io/library/postgres@sha256:06cad38a5d9f5d24b4d83d86def30795d5e4b757fedbf5281172b576dedcd941 # ratchet:postgres:18 + env: + POSTGRES_USER: dawgs + POSTGRES_PASSWORD: weneedbetterpasswords + POSTGRES_DB: dawgs + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U dawgs -d dawgs" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - name: Checkout source code for this repository + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # ratchet:actions/checkout@v5 + + - name: Install Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # ratchet:actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + check-latest: true + + - name: Run BDD integration tests + env: + CONNECTION_STRING: postgres://dawgs:weneedbetterpasswords@localhost:5432/dawgs?sslmode=disable + run: | + make test_bdd_integration + + bdd-integration-test-neo4j: + name: Run BDD integration tests (neo4j) + runs-on: ubuntu-latest + services: + neo4j: + image: index.docker.io/library/neo4j@sha256:73b5a47eb9952f47fcc42fcc80bdd6bd0d6b71eea9a1a345c216675498c790e4 # ratchet:neo4j:4.4.42 + env: + NEO4J_AUTH: neo4j/weneedbetterpasswords + ports: + - 7687:7687 + options: >- + --health-cmd "wget -q -O /dev/null http://localhost:7474 || exit 1" + --health-interval 15s + --health-timeout 10s + --health-retries 10 + --health-start-period 40s + steps: + - name: Checkout source code for this repository + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # ratchet:actions/checkout@v5 + + - name: Install Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # ratchet:actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + check-latest: true + + - name: Run BDD integration tests + env: + CONNECTION_STRING: neo4j://neo4j:weneedbetterpasswords@localhost:7687 + run: | + make test_bdd_integration diff --git a/Makefile b/Makefile index 31cad4a9..c7e08dd7 100644 --- a/Makefile +++ b/Makefile @@ -56,7 +56,7 @@ QUALITY_INPUTS += -mutation-report $(MUTATION_REPORT) endif QUALITY_INPUTS += -benchmark-regression $(BENCHMARK_REGRESSION) -.PHONY: default all build deps tidy lint format test test_all test_integration test_neo4j test_pg test_update plan_corpus complexity complexity_check crap crap_check quality quality_check quality_backend quality_bench metrics metrics_check generate clean help +.PHONY: default all build deps tidy lint format test test_all test_integration test_bdd test_neo4j test_pg test_update plan_corpus complexity complexity_check crap crap_check quality quality_check quality_backend quality_bench metrics metrics_check generate clean help # Default target default: help @@ -92,12 +92,14 @@ test: $(METRICS_DIR) @$(GO_CMD) tool cover -func=$(COVERAGE_PROFILE) > $(COVERAGE_FUNC_REPORT) @echo "Coverage report written to $(COVERAGE_FUNC_REPORT)" -test_all: test test_integration +test_all: test test_integration test_bdd test_integration: @echo "Running all integration tests..." @$(GO_CMD) test -tags 'manual_integration integration' -race -cover -count=1 -p=1 -parallel=1 $(MAIN_PACKAGES) - +test_bdd_integration: + @echo "Running all BDD integration tests..." + @$(GO_CMD) test -v -tags 'bdd_integration' -count=1 ./bdd test_bench: @echo "Running benchmarks..." @$(GO_CMD) test -run '^$$' -bench '$(BENCH)' -benchmem -count=$(BENCH_COUNT) -benchtime=$(BENCH_TIME) $(MAIN_PACKAGES) diff --git a/README.md b/README.md index 39fec353..7a704b84 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,11 @@ export CONNECTION_STRING="postgresql://dawgs:weneedbetterpasswords@localhost:654 make test_integration ``` +```bash +export CONNECTION_STRING="postgresql://dawgs:weneedbetterpasswords@localhost:65432/dawgs" +make test_bdd_integration +``` + Use this module from another Go project: ```bash diff --git a/bdd/features/matching.feature b/bdd/features/matching.feature new file mode 100644 index 00000000..00e8a93a --- /dev/null +++ b/bdd/features/matching.feature @@ -0,0 +1,28 @@ +Feature: Match nodes + + Scenario: Match non existed nodes + Given an empty graph + When executing query: + """ + MATCH (n) + RETURN n + """ + Then the result should be: + | n | + + Scenario: Matching all nodes + Given an empty graph + And having executed: + """ + CREATE (:A), (:B {prefix: 'c', name: 'b'}), ({name: 'c'}) + """ + When executing query: + """ + MATCH (n) + RETURN n + """ + Then the result should be: + | n | + | (:A) | + | (:B{name: 'b', prefix: 'c'}) | + | ({name: 'c'}) | diff --git a/bdd/steps.go b/bdd/steps.go new file mode 100644 index 00000000..34ef7fab --- /dev/null +++ b/bdd/steps.go @@ -0,0 +1,177 @@ +// 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 bdd + +import ( + "context" + "fmt" + "slices" + "strings" + + "github.com/cucumber/godog" + "github.com/specterops/dawgs/graph" +) + +type dbContext struct { + db graph.Database + rowCount int + actualRows []string + nodes []graph.Node +} + +// anEmptyGraph deletes graph data +func (c *dbContext) anEmptyGraph(ctx context.Context) error { + err := c.db.WriteTransaction(ctx, func(tx graph.Transaction) error { + if err := tx.Nodes().Delete(); err != nil { + return err + } + return nil + }) + if err != nil { + return err + } + return nil +} + +// executingQuery read cypher queries statement +func (c *dbContext) executingQuery(ctx context.Context, input *godog.DocString) error { + err := c.db.ReadTransaction(ctx, func(tx graph.Transaction) error { + var ( + node graph.Node + nodes []graph.Node + rowCount int64 + ) + result := tx.Query(input.Content, nil) + + defer result.Close() + + for result.Next() { + rowCount++ + + for _, value := range result.Values() { + mapper := result.Mapper() + mapper.Map(value, &node) + nodes = append(c.nodes, node) + } + + // format graph nodes and their properties into a cypher query + formatted, err := formatGraphResults(nodes) + if err != nil { + return fmt.Errorf("Failed to format graph results: %w", err) + } + c.actualRows = append(c.actualRows, formatted...) + } + + c.rowCount = int(rowCount) + if result.Error() != nil { + return result.Error() + } + return nil + }) + + if err != nil { + return err + } + + return nil +} + +// havingExecuted seeds data +func (c *dbContext) havingExecuted(ctx context.Context, input *godog.DocString) error { + err := c.db.WriteTransaction(ctx, func(tx graph.Transaction) error { + result := tx.Query(input.Content, nil) + + defer result.Close() + + if result.Error() != nil { + return result.Error() + } + return nil + }) + if err != nil { + return err + } + return nil +} + +func (c *dbContext) theResultShouldBe(expectedTable *godog.Table) error { + var expectedRows []string + for _, value := range expectedTable.Rows { + for _, cell := range value.Cells { + if cell.Value != "n" { + expectedRows = append(expectedRows, cell.Value) + } + } + } + + if c.rowCount != len(expectedRows) { + return fmt.Errorf("Invalid row count expected %d actual %d", c.rowCount, len(expectedTable.Rows)) + } + + for i := 0; i < len(expectedRows); i++ { + // TODO normalize exptected and actual rows by removing empty spaces and sorting kinds and their properties + if expectedRows[i] != c.actualRows[i] { + return fmt.Errorf("Detected a drift expected %s, actual %s", expectedRows[i], c.actualRows[i]) + } + } + + return nil +} + +func formatGraphResults(nodes []graph.Node) ([]string, error) { + sb := strings.Builder{} + for _, node := range nodes { + for _, kind := range node.Kinds { + sb.WriteString("(:") + sb.WriteString(kind.String()) + } + if len(node.Kinds) == 0 { + sb.WriteString("(") + } + if node.Properties.Len() != 0 { + // TODO sort node properties of feature files + slices.Sort(node.Properties.Keys(nil)) + sb.WriteString("{") + for index, key := range node.Properties.Keys(nil) { + if index > 0 { + sb.WriteString(", ") + } + value := node.Properties.Get(key) + sb.WriteString(key) + sb.WriteString(": ") + strValue, _ := value.String() + sb.WriteString("'") + sb.WriteString(strValue) + sb.WriteString("'") + } + sb.WriteString("}") + } + if node.Properties != nil { + sb.WriteString(")\n") + } + } + + var result []string + list := strings.Split(sb.String(), "\n") + for _, item := range list { + if item != "" { + result = append(result, item) + } + } + + return result, nil +} diff --git a/bdd/steps_test.go b/bdd/steps_test.go new file mode 100644 index 00000000..69d4b87c --- /dev/null +++ b/bdd/steps_test.go @@ -0,0 +1,84 @@ +// 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 +// + +//go:build bdd_integration + +package bdd + +import ( + "context" + "log" + "testing" + + "github.com/cucumber/godog" + "github.com/specterops/dawgs/graph" + "github.com/specterops/dawgs/integration" +) + +func InitializeTestSuite(ctxtestsuite *godog.TestSuiteContext, ctx context.Context, c *dbContext) { + err := c.db.WriteTransaction(ctx, func(tx graph.Transaction) error { + if err := tx.Nodes().Delete(); err != nil { + return err + } + return nil + }) + if err != nil { + log.Fatalf("Failed to clear database %v", err) + } +} + +func InitializeScenario(ctx *godog.ScenarioContext, dbCtx *dbContext) { + ctx.Step(`^an empty graph$`, dbCtx.anEmptyGraph) + ctx.Step(`^having executed:$`, dbCtx.havingExecuted) + ctx.Step(`^executing query:$`, dbCtx.executingQuery) + ctx.Step(`^the result should be:$`, dbCtx.theResultShouldBe) +} + +func TestFeatures(t *testing.T) { + backgroundCtx := context.Background() + + // establish database connection + session := integration.Open(t, integration.Options{ + Schema: &graph.Schema{ + DefaultGraph: graph.Graph{ + Name: "dawgs-bdd", + }, + }, + }) + + dbCtx := &dbContext{ + db: session.DB, + } + suite := godog.TestSuite{ + Name: "DAWGS-BDD", + TestSuiteInitializer: func(ctx *godog.TestSuiteContext) { + InitializeTestSuite(ctx, backgroundCtx, dbCtx) + }, + ScenarioInitializer: func(ctx *godog.ScenarioContext) { + InitializeScenario(ctx, dbCtx) + }, + Options: &godog.Options{ + Format: "pretty", + Paths: []string{"features"}, + TestingT: t, + }, + } + + if num := suite.Run(); num != 0 { + log.Fatalf("TestSuite execution failed") + } +} diff --git a/go.mod b/go.mod index b51bb4c6..c54273ed 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/axiomhq/hyperloglog v0.2.6 github.com/bits-and-blooms/bitset v1.24.5 github.com/cespare/xxhash/v2 v2.3.0 + github.com/cucumber/godog v0.16.0 github.com/fzipp/gocyclo v0.6.0 github.com/gammazero/deque v1.2.1 github.com/jackc/pgtype v1.14.4 @@ -82,6 +83,8 @@ require ( github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/cockroachdb/apd/v3 v3.2.3 // indirect + github.com/cucumber/gherkin/go/v42 v42.0.0 // indirect + github.com/cucumber/messages/go/v34 v34.2.0 // indirect github.com/curioswitch/go-reassign v0.3.0 // indirect github.com/daixiang0/gci v0.13.7 // indirect github.com/dave/dst v0.27.3 // indirect @@ -120,13 +123,17 @@ require ( github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/gordonklaus/ineffassign v0.2.0 // indirect github.com/gostaticanalysis/analysisutil v0.7.1 // indirect github.com/gostaticanalysis/comment v1.5.0 // indirect github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect github.com/gostaticanalysis/nilerr v0.1.2 // indirect + github.com/hashicorp/go-immutable-radix v1.3.1 // indirect github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect + github.com/hashicorp/go-memdb v1.3.5 // indirect github.com/hashicorp/go-version v1.8.0 // indirect + github.com/hashicorp/golang-lru v0.5.4 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hexops/gotextdiff v1.0.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect diff --git a/go.sum b/go.sum index c910d000..38a9251c 100644 --- a/go.sum +++ b/go.sum @@ -121,6 +121,12 @@ github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7 github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= +github.com/cucumber/gherkin/go/v42 v42.0.0 h1:Ulh3E2awUUSSja+wonP/IOQ+ycmiZwZbgmzqk5H8JNI= +github.com/cucumber/gherkin/go/v42 v42.0.0/go.mod h1:CsaumaO2dR9XvBc6ZyiGLMhWCKtTRDxgoxqJigSjSSg= +github.com/cucumber/godog v0.16.0 h1:ezQbgItuWqZrjPUQwLJ3muwIlvzXBOfZso5QZfG7efE= +github.com/cucumber/godog v0.16.0/go.mod h1:EDUX9yCqANK+GpbftMDeu61sUDtdLuo1JJgXD2n3bbM= +github.com/cucumber/messages/go/v34 v34.2.0 h1:VCbcNOMz+f8ccjjOOx1NLBNhwvE7/X49Atc8klJa+i8= +github.com/cucumber/messages/go/v34 v34.2.0/go.mod h1:LYUPjqlTS1kS0pdkdf6sS5uirnjwiIzEGyXPezXNhL8= github.com/curioswitch/go-reassign v0.3.0 h1:dh3kpQHuADL3cobV/sSGETA8DOv457dwl+fbBAhrQPs= github.com/curioswitch/go-reassign v0.3.0/go.mod h1:nApPCCTtqLJN/s8HfItCcKV0jIPwluBOvZP+dsJGA88= github.com/daixiang0/gci v0.13.7 h1:+0bG5eK9vlI08J+J/NWGbWPTNiXPG4WhNLJOkSxWITQ= @@ -250,13 +256,21 @@ github.com/gostaticanalysis/nilerr v0.1.2/go.mod h1:A19UHhoY3y8ahoL7YKz6sdjDtduw github.com/gostaticanalysis/testutil v0.3.1-0.20210208050101-bfb5c8eec0e4/go.mod h1:D+FIZ+7OahH3ePw/izIEeH5I06eKs1IKI4Xr64/Am3M= github.com/gostaticanalysis/testutil v0.5.0 h1:Dq4wT1DdTwTGCQQv3rl3IvD5Ld0E6HiY+3Zh0sUGqw8= github.com/gostaticanalysis/testutil v0.5.0/go.mod h1:OLQSbuM6zw2EvCcXTz1lVq5unyoNft372msDY0nY5Hs= +github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= +github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-immutable-radix/v2 v2.1.0 h1:CUW5RYIcysz+D3B+l1mDeXrQ7fUvGGCwJfdASSzbrfo= github.com/hashicorp/go-immutable-radix/v2 v2.1.0/go.mod h1:hgdqLXA4f6NIjRVisM1TJ9aOJVNRqKZj+xDGF6m7PBw= +github.com/hashicorp/go-memdb v1.3.5 h1:b3taDMxCBCBVgyRrS1AZVHO14ubMYZB++QpNhBg+Nyo= +github.com/hashicorp/go-memdb v1.3.5/go.mod h1:8IVKKBkVe+fxFgdFOYxzQQNjz+sWCyHCdIC/+5+Vy1Y= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= From 5ae29ae0449ec257de7d14a8e66614aee84c0a61 Mon Sep 17 00:00:00 2001 From: ykaiboussiSO Date: Tue, 18 Aug 2026 09:21:58 -0500 Subject: [PATCH 02/11] Update Makefile --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index c7e08dd7..0ef14db5 100644 --- a/Makefile +++ b/Makefile @@ -56,7 +56,7 @@ QUALITY_INPUTS += -mutation-report $(MUTATION_REPORT) endif QUALITY_INPUTS += -benchmark-regression $(BENCHMARK_REGRESSION) -.PHONY: default all build deps tidy lint format test test_all test_integration test_bdd test_neo4j test_pg test_update plan_corpus complexity complexity_check crap crap_check quality quality_check quality_backend quality_bench metrics metrics_check generate clean help +.PHONY: default all build deps tidy lint format test test_all test_integration test_bdd_integration test_neo4j test_pg test_update plan_corpus complexity complexity_check crap crap_check quality quality_check quality_backend quality_bench metrics metrics_check generate clean help # Default target default: help From 3079918376d1c022353b6eb4a3c4e62a698f8604 Mon Sep 17 00:00:00 2001 From: ykaiboussiSO Date: Tue, 18 Aug 2026 09:51:06 -0500 Subject: [PATCH 03/11] Update steps_test.go --- bdd/steps_test.go | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/bdd/steps_test.go b/bdd/steps_test.go index 69d4b87c..d14938a9 100644 --- a/bdd/steps_test.go +++ b/bdd/steps_test.go @@ -27,6 +27,7 @@ import ( "github.com/cucumber/godog" "github.com/specterops/dawgs/graph" "github.com/specterops/dawgs/integration" + "github.com/stretchr/testify/assert" ) func InitializeTestSuite(ctxtestsuite *godog.TestSuiteContext, ctx context.Context, c *dbContext) { @@ -82,3 +83,36 @@ func TestFeatures(t *testing.T) { log.Fatalf("TestSuite execution failed") } } + +func TestFormatGraphResults(t *testing.T) { + nodes := []graph.Node{ + { + ID: 1, + Kinds: graph.Kinds{graph.StringKind("A")}, + Properties: &graph.Properties{ + Map: map[string]any{"name": "a"}, + }, + }, + { + ID: 2, + Kinds: graph.Kinds{graph.StringKind("B")}, + Properties: &graph.Properties{ + Map: map[string]any{"name": "b"}, + }, + }, + { + ID: 3, + Properties: &graph.Properties{ + Map: map[string]any{"name": "c"}, + }, + }, + } + actualList, err := formatGraphResults(nodes) + assert.Nil(t, err) + + expectedList := []string{"(:A{name: 'a'})", "(:B{name: 'b'})", "({name: 'c'})"} + + for i := 0; i < len(expectedList); i++ { + assert.Equal(t, actualList[i], expectedList[i]) + } +} From a73bad5d4d04f91d4638559c2e005f07e8cf041f Mon Sep 17 00:00:00 2001 From: ykaiboussiSO Date: Tue, 18 Aug 2026 10:38:07 -0500 Subject: [PATCH 04/11] Create context_test.go --- util/context_test.go | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 util/context_test.go diff --git a/util/context_test.go b/util/context_test.go new file mode 100644 index 00000000..031b85f3 --- /dev/null +++ b/util/context_test.go @@ -0,0 +1,22 @@ +package util + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsContextLive(t *testing.T) { + ctx := context.Background() + actual := IsContextLive(ctx) + assert.Equal(t, true, actual) +} + +func TestIsContextLiveCanceled(t *testing.T) { + // create a child context from background and cancel to force an error + ctx, cancel := context.WithCancel(context.Background()) + cancel() + actual := IsContextLive(ctx) + assert.Equal(t, false, actual) +} From 6e060dcc011d30294acecf92323178031d3428be Mon Sep 17 00:00:00 2001 From: ykaiboussiSO Date: Tue, 18 Aug 2026 18:15:13 -0500 Subject: [PATCH 05/11] update docs --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 7a704b84..59f128e6 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ make test Run integration tests when a backend is available: +export the CONNECTION_STRING environment variable for the Postgres or Neo4j driver + ```bash export CONNECTION_STRING="postgresql://dawgs:weneedbetterpasswords@localhost:65432/dawgs" make test_integration From 26b655c967cb3006a2304f5f81af1c92ac4420ef Mon Sep 17 00:00:00 2001 From: ykaiboussiSO Date: Tue, 18 Aug 2026 18:15:29 -0500 Subject: [PATCH 06/11] Increase test coverage --- bdd/steps_test.go | 34 ----------------------- bdd/{steps.go => utils.go} | 2 +- bdd/utils_test.go | 56 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 35 deletions(-) rename bdd/{steps.go => utils.go} (98%) create mode 100644 bdd/utils_test.go diff --git a/bdd/steps_test.go b/bdd/steps_test.go index d14938a9..69d4b87c 100644 --- a/bdd/steps_test.go +++ b/bdd/steps_test.go @@ -27,7 +27,6 @@ import ( "github.com/cucumber/godog" "github.com/specterops/dawgs/graph" "github.com/specterops/dawgs/integration" - "github.com/stretchr/testify/assert" ) func InitializeTestSuite(ctxtestsuite *godog.TestSuiteContext, ctx context.Context, c *dbContext) { @@ -83,36 +82,3 @@ func TestFeatures(t *testing.T) { log.Fatalf("TestSuite execution failed") } } - -func TestFormatGraphResults(t *testing.T) { - nodes := []graph.Node{ - { - ID: 1, - Kinds: graph.Kinds{graph.StringKind("A")}, - Properties: &graph.Properties{ - Map: map[string]any{"name": "a"}, - }, - }, - { - ID: 2, - Kinds: graph.Kinds{graph.StringKind("B")}, - Properties: &graph.Properties{ - Map: map[string]any{"name": "b"}, - }, - }, - { - ID: 3, - Properties: &graph.Properties{ - Map: map[string]any{"name": "c"}, - }, - }, - } - actualList, err := formatGraphResults(nodes) - assert.Nil(t, err) - - expectedList := []string{"(:A{name: 'a'})", "(:B{name: 'b'})", "({name: 'c'})"} - - for i := 0; i < len(expectedList); i++ { - assert.Equal(t, actualList[i], expectedList[i]) - } -} diff --git a/bdd/steps.go b/bdd/utils.go similarity index 98% rename from bdd/steps.go rename to bdd/utils.go index 34ef7fab..b8e01040 100644 --- a/bdd/steps.go +++ b/bdd/utils.go @@ -122,7 +122,7 @@ func (c *dbContext) theResultShouldBe(expectedTable *godog.Table) error { return fmt.Errorf("Invalid row count expected %d actual %d", c.rowCount, len(expectedTable.Rows)) } - for i := 0; i < len(expectedRows); i++ { + for i := range len(expectedRows) { // TODO normalize exptected and actual rows by removing empty spaces and sorting kinds and their properties if expectedRows[i] != c.actualRows[i] { return fmt.Errorf("Detected a drift expected %s, actual %s", expectedRows[i], c.actualRows[i]) diff --git a/bdd/utils_test.go b/bdd/utils_test.go new file mode 100644 index 00000000..d54e39e9 --- /dev/null +++ b/bdd/utils_test.go @@ -0,0 +1,56 @@ +// 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 bdd + +import ( + "testing" + + "github.com/specterops/dawgs/graph" + "github.com/stretchr/testify/assert" +) + +func TestFormatGraphResults(t *testing.T) { + nodes := []graph.Node{ + { + ID: 1, + Kinds: graph.Kinds{graph.StringKind("A")}, + Properties: &graph.Properties{ + Map: map[string]any{"name": "a"}, + }, + }, + { + ID: 2, + Kinds: graph.Kinds{graph.StringKind("B")}, + Properties: &graph.Properties{ + Map: map[string]any{"name": "b"}, + }, + }, + { + ID: 3, + Properties: &graph.Properties{ + Map: map[string]any{"name": "c"}, + }, + }, + } + actualList, err := formatGraphResults(nodes) + assert.Nil(t, err) + + expectedList := []string{"(:A{name: 'a'})", "(:B{name: 'b'})", "({name: 'c'})"} + + for i := range len(expectedList) { + assert.Equal(t, actualList[i], expectedList[i]) + } +} From dbe7839dce716347ba07c1269784fff3480c9c9c Mon Sep 17 00:00:00 2001 From: ykaiboussiSO Date: Tue, 18 Aug 2026 19:40:41 -0500 Subject: [PATCH 07/11] format strings --- bdd/utils.go | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/bdd/utils.go b/bdd/utils.go index b8e01040..bf2668b4 100644 --- a/bdd/utils.go +++ b/bdd/utils.go @@ -113,7 +113,7 @@ func (c *dbContext) theResultShouldBe(expectedTable *godog.Table) error { for _, value := range expectedTable.Rows { for _, cell := range value.Cells { if cell.Value != "n" { - expectedRows = append(expectedRows, cell.Value) + expectedRows = append(expectedRows, formatString(cell.Value)) } } } @@ -122,9 +122,9 @@ func (c *dbContext) theResultShouldBe(expectedTable *godog.Table) error { return fmt.Errorf("Invalid row count expected %d actual %d", c.rowCount, len(expectedTable.Rows)) } - for i := range len(expectedRows) { - // TODO normalize exptected and actual rows by removing empty spaces and sorting kinds and their properties - if expectedRows[i] != c.actualRows[i] { + for i := 0; i < len(expectedRows); i++ { + // TODO normalize exptected actual rows by sorting kinds and their properties + if formatString(expectedRows[i]) != formatString(c.actualRows[i]) { return fmt.Errorf("Detected a drift expected %s, actual %s", expectedRows[i], c.actualRows[i]) } } @@ -132,6 +132,14 @@ func (c *dbContext) theResultShouldBe(expectedTable *godog.Table) error { return nil } +func formatString(s string) string { + removeSpace := strings.ReplaceAll(s, " ", "") + if strings.Contains(s, `"`) { + return strings.ReplaceAll(removeSpace, `"`, `'`) + } + return removeSpace +} + func formatGraphResults(nodes []graph.Node) ([]string, error) { sb := strings.Builder{} for _, node := range nodes { From 9ac68ff8e8b830543406fc98759377b6addf3e81 Mon Sep 17 00:00:00 2001 From: ykaiboussiSO Date: Wed, 19 Aug 2026 13:24:06 -0500 Subject: [PATCH 08/11] - Update jobs permissions - Disable local token presistence - Add test --- .github/workflows/go-test.yml | 16 ++++++++++++++++ bdd/utils_test.go | 31 ++++++++++++++++++++++++++++--- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/.github/workflows/go-test.yml b/.github/workflows/go-test.yml index c66a45d2..661b5ac9 100644 --- a/.github/workflows/go-test.yml +++ b/.github/workflows/go-test.yml @@ -87,6 +87,8 @@ jobs: integration-test-pg: name: Run integration tests (pg) runs-on: ubuntu-latest + permissions: + contents: read services: postgres: image: index.docker.io/library/postgres@sha256:06cad38a5d9f5d24b4d83d86def30795d5e4b757fedbf5281172b576dedcd941 # ratchet:postgres:18 @@ -104,6 +106,8 @@ jobs: steps: - name: Checkout source code for this repository uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # ratchet:actions/checkout@v5 + with: + persist-credentials: false - name: Install Go uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # ratchet:actions/setup-go@v5 @@ -121,6 +125,8 @@ jobs: integration-test-neo4j: name: Run integration tests (neo4j) runs-on: ubuntu-latest + permissions: + contents: read services: neo4j: image: index.docker.io/library/neo4j@sha256:73b5a47eb9952f47fcc42fcc80bdd6bd0d6b71eea9a1a345c216675498c790e4 # ratchet:neo4j:4.4.42 @@ -137,6 +143,8 @@ jobs: steps: - name: Checkout source code for this repository uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # ratchet:actions/checkout@v5 + with: + persist-credentials: false - name: Install Go uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # ratchet:actions/setup-go@v5 @@ -154,6 +162,8 @@ jobs: bdd-integration-test-pg: name: Run BDD integration tests (pg) runs-on: ubuntu-latest + permissions: + contents: read services: postgres: image: index.docker.io/library/postgres@sha256:06cad38a5d9f5d24b4d83d86def30795d5e4b757fedbf5281172b576dedcd941 # ratchet:postgres:18 @@ -171,6 +181,8 @@ jobs: steps: - name: Checkout source code for this repository uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # ratchet:actions/checkout@v5 + with: + persist-credentials: false - name: Install Go uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # ratchet:actions/setup-go@v5 @@ -188,6 +200,8 @@ jobs: bdd-integration-test-neo4j: name: Run BDD integration tests (neo4j) runs-on: ubuntu-latest + permissions: + contents: read services: neo4j: image: index.docker.io/library/neo4j@sha256:73b5a47eb9952f47fcc42fcc80bdd6bd0d6b71eea9a1a345c216675498c790e4 # ratchet:neo4j:4.4.42 @@ -204,6 +218,8 @@ jobs: steps: - name: Checkout source code for this repository uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # ratchet:actions/checkout@v5 + with: + persist-credentials: false - name: Install Go uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # ratchet:actions/setup-go@v5 diff --git a/bdd/utils_test.go b/bdd/utils_test.go index d54e39e9..f30aa0b4 100644 --- a/bdd/utils_test.go +++ b/bdd/utils_test.go @@ -19,7 +19,7 @@ import ( "testing" "github.com/specterops/dawgs/graph" - "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestFormatGraphResults(t *testing.T) { @@ -46,11 +46,36 @@ func TestFormatGraphResults(t *testing.T) { }, } actualList, err := formatGraphResults(nodes) - assert.Nil(t, err) + require.Nil(t, err) expectedList := []string{"(:A{name: 'a'})", "(:B{name: 'b'})", "({name: 'c'})"} for i := range len(expectedList) { - assert.Equal(t, actualList[i], expectedList[i]) + require.Equal(t, actualList[i], expectedList[i]) + } +} + +func TestFormatString(t *testing.T) { + tests := []struct { + input string + expectedOutput string + }{ + { + input: `(:B {prefix: 'c', name: 'b'})`, + expectedOutput: `(:B{prefix:'c',name:'b'})`, + }, + { + input: `(:B {prefix: "c", name: "b"})`, + expectedOutput: `(:B{prefix:'c',name:'b'})`, + }, + { + input: `(:B {prefix:"c",name:"b"})`, + expectedOutput: `(:B{prefix:'c',name:'b'})`, + }, + } + + for _, test := range tests { + actual := formatString(test.input) + require.Equal(t, test.expectedOutput, actual) } } From d3d1703f47553a9c7db218c7b5337ab75189bf25 Mon Sep 17 00:00:00 2001 From: ykaiboussiSO Date: Wed, 19 Aug 2026 23:42:24 -0500 Subject: [PATCH 09/11] update docs --- Makefile | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 0ef14db5..af713148 100644 --- a/Makefile +++ b/Makefile @@ -92,7 +92,7 @@ test: $(METRICS_DIR) @$(GO_CMD) tool cover -func=$(COVERAGE_PROFILE) > $(COVERAGE_FUNC_REPORT) @echo "Coverage report written to $(COVERAGE_FUNC_REPORT)" -test_all: test test_integration test_bdd +test_all: test test_integration test_bdd_integration test_integration: @echo "Running all integration tests..." diff --git a/README.md b/README.md index 59f128e6..bd9d1731 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ make test Run integration tests when a backend is available: -export the CONNECTION_STRING environment variable for the Postgres or Neo4j driver +Export the `CONNECTION_STRING` environment variable for the PostgreSQL or Neo4j driver. ```bash export CONNECTION_STRING="postgresql://dawgs:weneedbetterpasswords@localhost:65432/dawgs" From 15a3a66ca402387f364b142c67f1db8bda14e9b3 Mon Sep 17 00:00:00 2001 From: ykaiboussiSO Date: Thu, 20 Aug 2026 00:21:33 -0500 Subject: [PATCH 10/11] Add comments and update tests --- bdd/steps_test.go | 8 ++++---- bdd/utils.go | 11 +++++++---- bdd/utils_test.go | 6 +++++- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/bdd/steps_test.go b/bdd/steps_test.go index 69d4b87c..9842d626 100644 --- a/bdd/steps_test.go +++ b/bdd/steps_test.go @@ -50,7 +50,6 @@ func InitializeScenario(ctx *godog.ScenarioContext, dbCtx *dbContext) { func TestFeatures(t *testing.T) { backgroundCtx := context.Background() - // establish database connection session := integration.Open(t, integration.Options{ Schema: &graph.Schema{ @@ -64,7 +63,7 @@ func TestFeatures(t *testing.T) { db: session.DB, } suite := godog.TestSuite{ - Name: "DAWGS-BDD", + Name: "OpenCypher-TCK", TestSuiteInitializer: func(ctx *godog.TestSuiteContext) { InitializeTestSuite(ctx, backgroundCtx, dbCtx) }, @@ -72,13 +71,14 @@ func TestFeatures(t *testing.T) { InitializeScenario(ctx, dbCtx) }, Options: &godog.Options{ - Format: "pretty", + Format: "pretty", + // TODO create env variable pointing to the TCK features via CI Paths: []string{"features"}, TestingT: t, }, } if num := suite.Run(); num != 0 { - log.Fatalf("TestSuite execution failed") + t.Fatalf("TestSuite execution failed with status %d", num) } } diff --git a/bdd/utils.go b/bdd/utils.go index bf2668b4..0af53879 100644 --- a/bdd/utils.go +++ b/bdd/utils.go @@ -33,7 +33,7 @@ type dbContext struct { nodes []graph.Node } -// anEmptyGraph deletes graph data +// anEmptyGraph removes all nodes to give each scenario a clean graph. func (c *dbContext) anEmptyGraph(ctx context.Context) error { err := c.db.WriteTransaction(ctx, func(tx graph.Transaction) error { if err := tx.Nodes().Delete(); err != nil { @@ -47,8 +47,10 @@ func (c *dbContext) anEmptyGraph(ctx context.Context) error { return nil } -// executingQuery read cypher queries statement +// executingQuery runs a read query and records its rows for later comparison. func (c *dbContext) executingQuery(ctx context.Context, input *godog.DocString) error { + c.actualRows = nil + err := c.db.ReadTransaction(ctx, func(tx graph.Transaction) error { var ( node graph.Node @@ -71,7 +73,7 @@ func (c *dbContext) executingQuery(ctx context.Context, input *godog.DocString) // format graph nodes and their properties into a cypher query formatted, err := formatGraphResults(nodes) if err != nil { - return fmt.Errorf("Failed to format graph results: %w", err) + return fmt.Errorf("failed to format graph result: %w", err) } c.actualRows = append(c.actualRows, formatted...) } @@ -90,7 +92,7 @@ func (c *dbContext) executingQuery(ctx context.Context, input *godog.DocString) return nil } -// havingExecuted seeds data +// havingExecuted runs a write query to seed the graph for a scenario. func (c *dbContext) havingExecuted(ctx context.Context, input *godog.DocString) error { err := c.db.WriteTransaction(ctx, func(tx graph.Transaction) error { result := tx.Query(input.Content, nil) @@ -108,6 +110,7 @@ func (c *dbContext) havingExecuted(ctx context.Context, input *godog.DocString) return nil } +// theResultShouldBe compares the recorded query rows with the expected table. func (c *dbContext) theResultShouldBe(expectedTable *godog.Table) error { var expectedRows []string for _, value := range expectedTable.Rows { diff --git a/bdd/utils_test.go b/bdd/utils_test.go index f30aa0b4..7386f0c2 100644 --- a/bdd/utils_test.go +++ b/bdd/utils_test.go @@ -69,7 +69,11 @@ func TestFormatString(t *testing.T) { expectedOutput: `(:B{prefix:'c',name:'b'})`, }, { - input: `(:B {prefix:"c",name:"b"})`, + input: `(:B{prefix:"c",name:"b"})`, + expectedOutput: `(:B{prefix:'c',name:'b'})`, + }, + { + input: `(:B{prefix:'c',name:'b'})`, expectedOutput: `(:B{prefix:'c',name:'b'})`, }, } From bc973d6791075cd40286a9d4a5ee86854f367922 Mon Sep 17 00:00:00 2001 From: ykaiboussiSO Date: Thu, 20 Aug 2026 09:18:55 -0500 Subject: [PATCH 11/11] increase bdd test coverage --- bdd/utils.go | 15 +-- bdd/utils_test.go | 322 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 329 insertions(+), 8 deletions(-) diff --git a/bdd/utils.go b/bdd/utils.go index 0af53879..b1e7badf 100644 --- a/bdd/utils.go +++ b/bdd/utils.go @@ -30,7 +30,6 @@ type dbContext struct { db graph.Database rowCount int actualRows []string - nodes []graph.Node } // anEmptyGraph removes all nodes to give each scenario a clean graph. @@ -52,22 +51,22 @@ func (c *dbContext) executingQuery(ctx context.Context, input *godog.DocString) c.actualRows = nil err := c.db.ReadTransaction(ctx, func(tx graph.Transaction) error { - var ( - node graph.Node - nodes []graph.Node - rowCount int64 - ) + var rowCount int64 result := tx.Query(input.Content, nil) defer result.Close() for result.Next() { + var nodes []graph.Node + rowCount++ for _, value := range result.Values() { + var node graph.Node + mapper := result.Mapper() mapper.Map(value, &node) - nodes = append(c.nodes, node) + nodes = append(nodes, node) } // format graph nodes and their properties into a cypher query @@ -122,7 +121,7 @@ func (c *dbContext) theResultShouldBe(expectedTable *godog.Table) error { } if c.rowCount != len(expectedRows) { - return fmt.Errorf("Invalid row count expected %d actual %d", c.rowCount, len(expectedTable.Rows)) + return fmt.Errorf("Invalid row count expected %d actual %d", len(expectedRows), c.rowCount) } for i := 0; i < len(expectedRows); i++ { diff --git a/bdd/utils_test.go b/bdd/utils_test.go index 7386f0c2..ffeda0df 100644 --- a/bdd/utils_test.go +++ b/bdd/utils_test.go @@ -16,12 +16,334 @@ package bdd import ( + "context" + "errors" "testing" + "github.com/cucumber/godog" + "github.com/cucumber/messages/go/v34" "github.com/specterops/dawgs/graph" "github.com/stretchr/testify/require" ) +type stubDatabase struct { + graph.Database + + transaction graph.Transaction + readTransactionCalled bool + readTransactionContext context.Context + writeTransactionErr error + writeTransactionCalled bool + writeTransactionContext context.Context +} + +func (s *stubDatabase) ReadTransaction(ctx context.Context, delegate graph.TransactionDelegate, _ ...graph.TransactionOption) error { + s.readTransactionCalled = true + s.readTransactionContext = ctx + + return delegate(s.transaction) +} + +func (s *stubDatabase) WriteTransaction(ctx context.Context, delegate graph.TransactionDelegate, _ ...graph.TransactionOption) error { + s.writeTransactionCalled = true + s.writeTransactionContext = ctx + + if s.writeTransactionErr != nil { + return s.writeTransactionErr + } + + return delegate(s.transaction) +} + +type stubTransaction struct { + graph.Transaction + + nodeQuery graph.NodeQuery + result graph.Result + queryCalled bool + query string + queryParameters map[string]any +} + +func (s *stubTransaction) Nodes() graph.NodeQuery { + return s.nodeQuery +} + +func (s *stubTransaction) Query(query string, parameters map[string]any) graph.Result { + s.queryCalled = true + s.query = query + s.queryParameters = parameters + + return s.result +} + +type stubResult struct { + graph.Result + + rows [][]any + currentRow int + err error + closed bool +} + +func (s *stubResult) Next() bool { + if s.currentRow+1 >= len(s.rows) { + return false + } + + s.currentRow++ + return true +} + +func (s *stubResult) Values() []any { + return s.rows[s.currentRow] +} + +func (s *stubResult) Mapper() graph.ValueMapper { + return graph.NewValueMapper(func(value, target any) bool { + node, valueIsNode := value.(graph.Node) + targetNode, targetIsNode := target.(*graph.Node) + if !valueIsNode || !targetIsNode { + return false + } + + *targetNode = node + return true + }) +} + +func (s *stubResult) Error() error { + return s.err +} + +func (s *stubResult) Close() { + s.closed = true +} + +type stubNodeQuery struct { + graph.NodeQuery + + deleteErr error + deleteCalled bool +} + +func (s *stubNodeQuery) Delete() error { + s.deleteCalled = true + return s.deleteErr +} + +func TestDBContextAnEmptyGraph(t *testing.T) { + deleteErr := errors.New("failed to delete nodes") + writeTransactionErr := errors.New("failed to open write transaction") + + tests := []struct { + name string + deleteErr error + writeTransactionErr error + expectDelete bool + expectedErr error + }{ + { + name: "deletes all nodes", + expectDelete: true, + }, + { + name: "returns node deletion error", + deleteErr: deleteErr, + expectDelete: true, + expectedErr: deleteErr, + }, + { + name: "returns write transaction error", + writeTransactionErr: writeTransactionErr, + expectedErr: writeTransactionErr, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + testContext := context.WithValue(context.Background(), struct{}{}, test.name) + nodeQuery := &stubNodeQuery{deleteErr: test.deleteErr} + database := &stubDatabase{ + transaction: &stubTransaction{nodeQuery: nodeQuery}, + writeTransactionErr: test.writeTransactionErr, + } + context := &dbContext{db: database} + + err := context.anEmptyGraph(testContext) + + if test.expectedErr == nil { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, test.expectedErr) + } + require.True(t, database.writeTransactionCalled) + require.Equal(t, testContext, database.writeTransactionContext) + require.Equal(t, test.expectDelete, nodeQuery.deleteCalled) + }) + } +} + +func TestDBContextExecutingQuery(t *testing.T) { + nodeA := *graph.NewNode(1, graph.NewProperties(), graph.StringKind("A")) + nodeB := *graph.NewNode(2, graph.NewProperties(), graph.StringKind("B")) + nodeC := *graph.NewNode(3, graph.NewProperties(), graph.StringKind("C")) + result := &stubResult{ + rows: [][]any{{nodeA, nodeB}, {nodeC}}, + currentRow: -1, + } + transaction := &stubTransaction{result: result} + database := &stubDatabase{transaction: transaction} + databaseContext := &dbContext{ + db: database, + rowCount: 99, + actualRows: []string{"stale result"}, + } + testContext := context.Background() + query := "MATCH (n) RETURN n" + + err := databaseContext.executingQuery(testContext, &godog.DocString{Content: query}) + + require.NoError(t, err) + require.True(t, database.readTransactionCalled) + require.Equal(t, testContext, database.readTransactionContext) + require.True(t, transaction.queryCalled) + require.Equal(t, query, transaction.query) + require.Nil(t, transaction.queryParameters) + require.True(t, result.closed) + require.Equal(t, 2, databaseContext.rowCount) + require.Equal(t, []string{"(:A)", "(:B)", "(:C)"}, databaseContext.actualRows) +} + +func TestDBContextHavingExecuted(t *testing.T) { + resultErr := errors.New("query failed") + writeTransactionErr := errors.New("write transaction failed") + + tests := []struct { + name string + resultErr error + writeTransactionErr error + expectQuery bool + expectClose bool + expectedErr error + }{ + { + name: "executes write query", + expectQuery: true, + expectClose: true, + }, + { + name: "returns result error", + resultErr: resultErr, + expectQuery: true, + expectClose: true, + expectedErr: resultErr, + }, + { + name: "returns write transaction error", + writeTransactionErr: writeTransactionErr, + expectedErr: writeTransactionErr, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := &stubResult{err: test.resultErr} + transaction := &stubTransaction{result: result} + database := &stubDatabase{ + transaction: transaction, + writeTransactionErr: test.writeTransactionErr, + } + databaseContext := &dbContext{db: database} + testContext := context.Background() + query := "CREATE (:A)" + + err := databaseContext.havingExecuted(testContext, &godog.DocString{Content: query}) + + if test.expectedErr == nil { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, test.expectedErr) + } + require.True(t, database.writeTransactionCalled) + require.Equal(t, testContext, database.writeTransactionContext) + require.Equal(t, test.expectQuery, transaction.queryCalled) + require.Equal(t, test.expectClose, result.closed) + + if test.expectQuery { + require.Equal(t, query, transaction.query) + require.Nil(t, transaction.queryParameters) + } + }) + } +} + +func TestDBContextTheResultShouldBe(t *testing.T) { + tests := []struct { + name string + context dbContext + expectedTable *godog.Table + expectedError string + }{ + { + name: "matches normalized rows", + context: dbContext{ + rowCount: 2, + actualRows: []string{"(:A{name:'a'})", "(:B)"}, + }, + expectedTable: newResultTable(`(:A {name: "a"})`, "(:B)"), + }, + { + name: "matches an empty result", + expectedTable: newResultTable(), + }, + { + name: "returns row count mismatch", + context: dbContext{ + rowCount: 1, + actualRows: []string{"(:A)"}, + }, + expectedTable: newResultTable("(:A)", "(:B)"), + expectedError: "Invalid row count expected 2 actual 1", + }, + { + name: "returns row content drift", + context: dbContext{ + rowCount: 1, + actualRows: []string{"(:B)"}, + }, + expectedTable: newResultTable("(:A)"), + expectedError: "Detected a drift expected (:A), actual (:B)", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := test.context.theResultShouldBe(test.expectedTable) + + if test.expectedError == "" { + require.NoError(t, err) + } else { + require.EqualError(t, err, test.expectedError) + } + }) + } +} + +func newResultTable(values ...string) *godog.Table { + rows := []*messages.PickleTableRow{ + {Cells: []*messages.PickleTableCell{{Value: "n"}}}, + } + + for _, value := range values { + rows = append(rows, &messages.PickleTableRow{ + Cells: []*messages.PickleTableCell{{Value: value}}, + }) + } + + return &godog.Table{Rows: rows} +} + func TestFormatGraphResults(t *testing.T) { nodes := []graph.Node{ {