Repository: SpecterOps/BloodHound
Pull request #1418: Bed 5737
Author: login: LawsonWillard
Labels: name: api
description: A pull request containing changes affecting the API code.
Created: 2025-04-30T16:00:14Z | Merged: 2025-04-30T19:51:35Z

<!-- README: https://github.com/SpecterOps/BloodHound/issues/672 -->
<!-- All pull requests require either an associated -->
<!-- Jira ticket or GitHub issue. PRs opened without -->
<!-- an associated discussion item will be closed! -->

## Description

- Updated filter_internal_test.go to use table driven tests and assert instead of require.
- Move counts parameter to ignored filters as this filter should not be included in the standard query parameter filters.
- Updated ParseQueryParameterFilter to return the equals operator if the provided query parameter value does not match the 'predicate_operator:value' format.
- Removed the now unused "ErrNotFiltered" error

The above changes have been made to ensure that the default eq operator is used in the event that the provided API call does not supply a predicate in the query parameter value.

## Motivation and Context

<!-- Please replace "<TICKET_OR_ISSUE_NUMBER>" with the associated ticket or issue number -->
Resolves BED-5737

If a call to any API endpoint includes a string param without a predicate qualifier prefix (eq:, neq:), the call ignores the filter value rather than assume the default operation of “equals” (eq:).

## How Has This Been Tested?

- Added unit test to cover scenario + regression unit testing to ensure the change did not break any other tests
- Manual testing against API on local dev setup

## Types of changes

<!-- Please remove any items that do not apply. -->

- Bug fix (non-breaking change which fixes an issue)

## Checklist:

<!-- Please make sure you have completed all following checks. -->
- [x] I have met the contributing prerequisites
  - Assigned myself to this PR
  - Added the appropriate labels
  - Associated an issue: https://github.com/SpecterOps/BloodHound/issues/672
  - Read the Contributing guide: https://github.com/SpecterOps/BloodHound/wiki/Contributing
- [x] I have ensured that related documentation is up-to-date
  - Open API docs
  - Code comments (GoDocs / JSDocs)
- [x] I have followed proper test practices
  - Added/updated tests to cover my changes
  - All new and existing tests passed



Diff:
diff --git a/cmd/api/src/api/v2/assetgrouptags.go b/cmd/api/src/api/v2/assetgrouptags.go
index fa611e03c24..d536b1d77c8 100644
--- a/cmd/api/src/api/v2/assetgrouptags.go
+++ b/cmd/api/src/api/v2/assetgrouptags.go
@@ -68,7 +68,7 @@ func (s Resources) GetAssetGroupTags(response http.ResponseWriter, request *http
 	var rCtx = request.Context()
 
 	if paramIncludeCounts, err := api.ParseOptionalBool(request.URL.Query().Get(api.QueryParameterIncludeCounts), false); err != nil {
-		api.WriteErrorResponse(rCtx, api.BuildErrorResponse(http.StatusBadRequest, "Invalid value specifed for include counts", request), response)
+		api.WriteErrorResponse(rCtx, api.BuildErrorResponse(http.StatusBadRequest, "Invalid value specified for include counts", request), response)
 	} else if queryFilters, err := model.NewQueryParameterFilterParser().ParseQueryParameterFilters(request); err != nil {
 		api.WriteErrorResponse(rCtx, api.BuildErrorResponse(http.StatusBadRequest, api.ErrorResponseDetailsBadQueryParameterFilters, request), response)
 	} else {
diff --git a/cmd/api/src/model/filter.go b/cmd/api/src/model/filter.go
index ee40fdde138..e9de0ef7d90 100644
--- a/cmd/api/src/model/filter.go
+++ b/cmd/api/src/model/filter.go
@@ -17,7 +17,6 @@
 package model
 
 import (
-	"errors"
 	"fmt"
 	"net/http"
 	"regexp"
@@ -56,8 +55,6 @@ const (
 	ObjectIdString = "objectid"
 )
 
-var ErrNotFiltered = errors.New("parameter value is not filtered")
-
 type Filtered interface {
 	ValidFilters() map[string][]FilterOperator
 }
@@ -324,8 +321,11 @@ func (s QueryParameterFilterParser) ParseQueryParameterFilter(name, value string
 			}, nil
 		}
 	}
-
-	return QueryParameterFilter{}, ErrNotFiltered
+	return QueryParameterFilter{
+		Name:     name,
+		Operator: Equals,
+		Value:    value,
+	}, nil
 }
 
 func (s QueryParameterFilterParser) ParseQueryParameterFilters(request *http.Request) (QueryParameterFilterMap, error) {
@@ -343,9 +343,7 @@ func (s QueryParameterFilterParser) ParseQueryParameterFilters(request *http.Req
 
 		for _, value := range values {
 			if filter, err := s.ParseQueryParameterFilter(name, value); err != nil {
-				if !errors.Is(err, ErrNotFiltered) {
-					return nil, err
-				}
+				return nil, err
 			} else {
 				filters.AddFilter(filter)
 			}
diff --git a/cmd/api/src/model/filter_internal_test.go b/cmd/api/src/model/filter_internal_test.go
index cccb477b295..dd09cd0786a 100644
--- a/cmd/api/src/model/filter_internal_test.go
+++ b/cmd/api/src/model/filter_internal_test.go
@@ -19,42 +19,117 @@ package model
 import (
 	"testing"
 
-	"github.com/stretchr/testify/require"
+	"github.com/stretchr/testify/assert"
 )
 
 func TestQueryParameterFilterParser_ParseQueryParameterFilter(t *testing.T) {
+	t.Parallel()
+
 	parser := NewQueryParameterFilterParser()
 
-	t.Run("parser should parse a parameter filter", func(t *testing.T) {
-		if filter, err := parser.ParseQueryParameterFilter("parameter", "eq:auth.value"); err != nil {
-			t.Fatalf("Failed parsing query parameter: %v", err)
-		} else {
-			require.Equal(t, filter.Name, "parameter")
-			require.Equal(t, "auth.value", filter.Value)
-		}
-	})
-
-	t.Run("parser should parse a parameter with ~", func(t *testing.T) {
-		if filter, err := parser.ParseQueryParameterFilter("parameter", "~eq:auth.value"); err != nil {
-			t.Fatalf("Failed parsing query parameter: %v", err)
-		} else {
-			require.Equal(t, filter.Name, "parameter")
-			require.Equal(t, "auth.value", filter.Value)
-		}
-	})
-
-	t.Run("parser should parse a parameter filter with spacing", func(t *testing.T) {
-		if filter, err := parser.ParseQueryParameterFilter("parameter", "eq:hello world"); err != nil {
-			t.Fatalf("Failed parsing query parameter: %v", err)
-		} else {
-			require.Equal(t, filter.Name, "parameter")
-			require.Equal(t, "hello world", filter.Value)
-		}
-	})
-
-	t.Run("error when parsing an invalid parameter", func(t *testing.T) {
-		_, err := parser.ParseQueryParameterFilter("parameter", "eq : hello world")
-		require.Error(t, err)
-	})
+	type args struct {
+		name  string
+		value string
+	}
+
+	type expected struct {
+		name     string
+		value    string
+		operator FilterOperator
+	}
+
+	tests := []struct {
+		name     string
+		args     args
+		expected expected
+		wantErr  assert.ErrorAssertionFunc
+	}{
+		{
+			name: "success - parser should parse a parameter filter",
+			args: args{
+				name:  "parameter",
+				value: "eq:auth.value",
+			},
+			expected: expected{
+				name:     "parameter",
+				value:    "auth.value",
+				operator: Equals,
+			},
+			wantErr: assert.NoError,
+		},
+		{
+			name: "success - parser should parse a parameter with ~ filter",
+			args: args{
+				name:  "parameter",
+				value: "~eq:auth.value",
+			},
+			expected: expected{
+				name:     "parameter",
+				value:    "auth.value",
+				operator: ApproximatelyEquals,
+			},
+			wantErr: assert.NoError,
+		},
+		{
+			name: "success - parser should parse a parameter filter with spacing",
+			args: args{
+				name:  "parameter",
+				value: "eq:hello world",
+			},
+			expected: expected{
+				name:     "parameter",
+				value:    "hello world",
+				operator: Equals,
+			},
+			wantErr: assert.NoError,
+		},
+		{
+			name: "success - default to eq when parsing a non predicate parameter",
+			args: args{
+				name:  "parameter",
+				value: "hello world",
+			},
+			expected: expected{
+				name:     "parameter",
+				value:    "hello world",
+				operator: Equals,
+			},
+			wantErr: assert.NoError,
+		},
+		{
+			name: "success - colon is included in value if predicate is omitted",
+			args: args{
+				name:  "parameter",
+				value: ":hello_world",
+			},
+			expected: expected{
+				name:     "parameter",
+				value:    ":hello_world",
+				operator: Equals,
+			},
+			wantErr: assert.NoError,
+		},
+		{
+			name: "fail - unknown operator",
+			args: args{
+				name:  "parameter",
+				value: "foo:hello world",
+			},
+			wantErr: assert.Error,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
 
+			if res, err := parser.ParseQueryParameterFilter(tt.args.name, tt.args.value); !tt.wantErr(t, err) {
+				t.Errorf("ParseQueryParameterFilter() returned an unexpected error = %v", err)
+			} else {
+				assert.Equal(t, tt.expected.name, res.Name)
+				assert.Equal(t, tt.expected.value, res.Value)
+				assert.Equal(t, tt.expected.operator, res.Operator)
+			}
+		})
+	}
 }
diff --git a/cmd/api/src/model/saved_queries.go b/cmd/api/src/model/saved_queries.go
index e2ee4d2ab1c..dd3beded558 100644
--- a/cmd/api/src/model/saved_queries.go
+++ b/cmd/api/src/model/saved_queries.go
@@ -62,6 +62,7 @@ func (s SavedQueries) ValidFilters() map[string][]FilterOperator {
 func IgnoreFilters() []string {
 	return []string{
 		"scope",
+		"counts",
 	}
 }
 


Reviews:
user: login: definitelynotagoblin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T16:42:12Z
user: login: definitelynotagoblin
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T16:42:23Z
user: login: LawsonWillard
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T16:58:16Z
user: login: LawsonWillard
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T17:44:11Z

Review comments:
diff_hunk: @@ -19,42 +19,104 @@ package model
 import (
 	"testing"
 
-	"github.com/stretchr/testify/require"
+	"github.com/stretchr/testify/assert"
 )
 
 func TestQueryParameterFilterParser_ParseQueryParameterFilter(t *testing.T) {
+	t.Parallel()
+
 	parser := NewQueryParameterFilterParser()
 
-	t.Run("parser should parse a parameter filter", func(t *testing.T) {
-		if filter, err := parser.ParseQueryParameterFilter("parameter", "eq:auth.value"); err != nil {
-			t.Fatalf("Failed parsing query parameter: %v", err)
-		} else {
-			require.Equal(t, filter.Name, "parameter")
-			require.Equal(t, "auth.value", filter.Value)
-		}
-	})
-
-	t.Run("parser should parse a parameter with ~", func(t *testing.T) {
-		if filter, err := parser.ParseQueryParameterFilter("parameter", "~eq:auth.value"); err != nil {
-			t.Fatalf("Failed parsing query parameter: %v", err)
-		} else {
-			require.Equal(t, filter.Name, "parameter")
-			require.Equal(t, "auth.value", filter.Value)
-		}
-	})
-
-	t.Run("parser should parse a parameter filter with spacing", func(t *testing.T) {
-		if filter, err := parser.ParseQueryParameterFilter("parameter", "eq:hello world"); err != nil {
-			t.Fatalf("Failed parsing query parameter: %v", err)
-		} else {
-			require.Equal(t, filter.Name, "parameter")
-			require.Equal(t, "hello world", filter.Value)
-		}
-	})
-
-	t.Run("error when parsing an invalid parameter", func(t *testing.T) {
-		_, err := parser.ParseQueryParameterFilter("parameter", "eq : hello world")
-		require.Error(t, err)
-	})
+	type args struct {
+		name  string
+		value string
+	}
+
+	type expected struct {
+		name     string
+		value    string
+		operator FilterOperator
+	}
+
+	tests := []struct {
+		name     string
+		args     args
+		expected expected
+		wantErr  assert.ErrorAssertionFunc
+	}{
+		{
+			name: "success - parser should parse a parameter filter",
+			args: args{
+				name:  "parameter",
+				value: "eq:auth.value",
+			},
+			expected: expected{
+				name:     "parameter",
+				value:    "auth.value",
+				operator: Equals,
+			},
+			wantErr: assert.NoError,
+		},
+		{
+			name: "success - parser should parse a parameter with ~ filter",
+			args: args{
+				name:  "parameter",
+				value: "~eq:auth.value",
+			},
+			expected: expected{
+				name:     "parameter",
+				value:    "auth.value",
+				operator: ApproximatelyEquals,
+			},
+			wantErr: assert.NoError,
+		},
+		{
+			name: "success - parser should parse a parameter filter with spacing",
+			args: args{
+				name:  "parameter",
+				value: "eq:hello world",
+			},
+			expected: expected{
+				name:     "parameter",
+				value:    "hello world",
+				operator: Equals,
+			},
+			wantErr: assert.NoError,
+		},
+		{
+			name: "success - default to eq when parsing a non predicate parameter",
+			args: args{
+				name:  "parameter",
+				value: "hello world",
+			},
+			expected: expected{
+				name:     "parameter",
+				value:    "hello world",
+				operator: Equals,
+			},
+			wantErr: assert.NoError,
+		},
+		{
+			name: "fail - unknown operator",
+			args: args{
+				name:  "parameter",
+				value: "foo:hello world",
+			},
+			wantErr: assert.Error,
+		},
path: cmd/api/src/model/filter_internal_test.go
user: login: definitelynotagoblin
body: Nit: I'd be kinda curious to test what happens when value is ":some value"
I believe it will resolve to `equals: ':some value'`
Which is probably correct, but maybe we instead want to treat this as an error 🤷 
created_at: 2025-04-30T16:42:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -19,42 +19,104 @@ package model
 import (
 	"testing"
 
-	"github.com/stretchr/testify/require"
+	"github.com/stretchr/testify/assert"
 )
 
 func TestQueryParameterFilterParser_ParseQueryParameterFilter(t *testing.T) {
+	t.Parallel()
+
 	parser := NewQueryParameterFilterParser()
 
-	t.Run("parser should parse a parameter filter", func(t *testing.T) {
-		if filter, err := parser.ParseQueryParameterFilter("parameter", "eq:auth.value"); err != nil {
-			t.Fatalf("Failed parsing query parameter: %v", err)
-		} else {
-			require.Equal(t, filter.Name, "parameter")
-			require.Equal(t, "auth.value", filter.Value)
-		}
-	})
-
-	t.Run("parser should parse a parameter with ~", func(t *testing.T) {
-		if filter, err := parser.ParseQueryParameterFilter("parameter", "~eq:auth.value"); err != nil {
-			t.Fatalf("Failed parsing query parameter: %v", err)
-		} else {
-			require.Equal(t, filter.Name, "parameter")
-			require.Equal(t, "auth.value", filter.Value)
-		}
-	})
-
-	t.Run("parser should parse a parameter filter with spacing", func(t *testing.T) {
-		if filter, err := parser.ParseQueryParameterFilter("parameter", "eq:hello world"); err != nil {
-			t.Fatalf("Failed parsing query parameter: %v", err)
-		} else {
-			require.Equal(t, filter.Name, "parameter")
-			require.Equal(t, "hello world", filter.Value)
-		}
-	})
-
-	t.Run("error when parsing an invalid parameter", func(t *testing.T) {
-		_, err := parser.ParseQueryParameterFilter("parameter", "eq : hello world")
-		require.Error(t, err)
-	})
+	type args struct {
+		name  string
+		value string
+	}
+
+	type expected struct {
+		name     string
+		value    string
+		operator FilterOperator
+	}
+
+	tests := []struct {
+		name     string
+		args     args
+		expected expected
+		wantErr  assert.ErrorAssertionFunc
+	}{
+		{
+			name: "success - parser should parse a parameter filter",
+			args: args{
+				name:  "parameter",
+				value: "eq:auth.value",
+			},
+			expected: expected{
+				name:     "parameter",
+				value:    "auth.value",
+				operator: Equals,
+			},
+			wantErr: assert.NoError,
+		},
+		{
+			name: "success - parser should parse a parameter with ~ filter",
+			args: args{
+				name:  "parameter",
+				value: "~eq:auth.value",
+			},
+			expected: expected{
+				name:     "parameter",
+				value:    "auth.value",
+				operator: ApproximatelyEquals,
+			},
+			wantErr: assert.NoError,
+		},
+		{
+			name: "success - parser should parse a parameter filter with spacing",
+			args: args{
+				name:  "parameter",
+				value: "eq:hello world",
+			},
+			expected: expected{
+				name:     "parameter",
+				value:    "hello world",
+				operator: Equals,
+			},
+			wantErr: assert.NoError,
+		},
+		{
+			name: "success - default to eq when parsing a non predicate parameter",
+			args: args{
+				name:  "parameter",
+				value: "hello world",
+			},
+			expected: expected{
+				name:     "parameter",
+				value:    "hello world",
+				operator: Equals,
+			},
+			wantErr: assert.NoError,
+		},
+		{
+			name: "fail - unknown operator",
+			args: args{
+				name:  "parameter",
+				value: "foo:hello world",
+			},
+			wantErr: assert.Error,
+		},
path: cmd/api/src/model/filter_internal_test.go
user: login: LawsonWillard
body: Just tested and thats the result, was also wondering the same. Would it be best to ask that in the ticket? 

Also semi side note: When testing against the /api/v2/available-domains endpoint and using the collected parameter collected=eq:true - correctly returns all results where collected is true, however, collected=neq:true - returns an empty array. I would expect this to return all values which are false, unsure if this is intended or not
created_at: 2025-04-30T16:58:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -19,42 +19,104 @@ package model
 import (
 	"testing"
 
-	"github.com/stretchr/testify/require"
+	"github.com/stretchr/testify/assert"
 )
 
 func TestQueryParameterFilterParser_ParseQueryParameterFilter(t *testing.T) {
+	t.Parallel()
+
 	parser := NewQueryParameterFilterParser()
 
-	t.Run("parser should parse a parameter filter", func(t *testing.T) {
-		if filter, err := parser.ParseQueryParameterFilter("parameter", "eq:auth.value"); err != nil {
-			t.Fatalf("Failed parsing query parameter: %v", err)
-		} else {
-			require.Equal(t, filter.Name, "parameter")
-			require.Equal(t, "auth.value", filter.Value)
-		}
-	})
-
-	t.Run("parser should parse a parameter with ~", func(t *testing.T) {
-		if filter, err := parser.ParseQueryParameterFilter("parameter", "~eq:auth.value"); err != nil {
-			t.Fatalf("Failed parsing query parameter: %v", err)
-		} else {
-			require.Equal(t, filter.Name, "parameter")
-			require.Equal(t, "auth.value", filter.Value)
-		}
-	})
-
-	t.Run("parser should parse a parameter filter with spacing", func(t *testing.T) {
-		if filter, err := parser.ParseQueryParameterFilter("parameter", "eq:hello world"); err != nil {
-			t.Fatalf("Failed parsing query parameter: %v", err)
-		} else {
-			require.Equal(t, filter.Name, "parameter")
-			require.Equal(t, "hello world", filter.Value)
-		}
-	})
-
-	t.Run("error when parsing an invalid parameter", func(t *testing.T) {
-		_, err := parser.ParseQueryParameterFilter("parameter", "eq : hello world")
-		require.Error(t, err)
-	})
+	type args struct {
+		name  string
+		value string
+	}
+
+	type expected struct {
+		name     string
+		value    string
+		operator FilterOperator
+	}
+
+	tests := []struct {
+		name     string
+		args     args
+		expected expected
+		wantErr  assert.ErrorAssertionFunc
+	}{
+		{
+			name: "success - parser should parse a parameter filter",
+			args: args{
+				name:  "parameter",
+				value: "eq:auth.value",
+			},
+			expected: expected{
+				name:     "parameter",
+				value:    "auth.value",
+				operator: Equals,
+			},
+			wantErr: assert.NoError,
+		},
+		{
+			name: "success - parser should parse a parameter with ~ filter",
+			args: args{
+				name:  "parameter",
+				value: "~eq:auth.value",
+			},
+			expected: expected{
+				name:     "parameter",
+				value:    "auth.value",
+				operator: ApproximatelyEquals,
+			},
+			wantErr: assert.NoError,
+		},
+		{
+			name: "success - parser should parse a parameter filter with spacing",
+			args: args{
+				name:  "parameter",
+				value: "eq:hello world",
+			},
+			expected: expected{
+				name:     "parameter",
+				value:    "hello world",
+				operator: Equals,
+			},
+			wantErr: assert.NoError,
+		},
+		{
+			name: "success - default to eq when parsing a non predicate parameter",
+			args: args{
+				name:  "parameter",
+				value: "hello world",
+			},
+			expected: expected{
+				name:     "parameter",
+				value:    "hello world",
+				operator: Equals,
+			},
+			wantErr: assert.NoError,
+		},
+		{
+			name: "fail - unknown operator",
+			args: args{
+				name:  "parameter",
+				value: "foo:hello world",
+			},
+			wantErr: assert.Error,
+		},
path: cmd/api/src/model/filter_internal_test.go
user: login: LawsonWillard
body: @definitelynotagoblin added the new test case, will ask in ticket about its expected outcome
created_at: 2025-04-30T17:44:10Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-30T16:00:31Z
body: ****CLA Assistant Lite bot**** All contributors have signed the CLA  ✍️ ✅
author_association: NONE
user: login: LawsonWillard
created_at: 2025-04-30T16:01:02Z
body: I have read the CLA Document and I hereby sign the CLA
author_association: CONTRIBUTOR
user: login: LawsonWillard
created_at: 2025-04-30T16:01:05Z
body: recheck
author_association: CONTRIBUTOR