Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 35 additions & 21 deletions tests/integration/agent/agent_api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,6 @@ var (

var (
testOUID string
agentSchemaID string
defaultAuthFlowID string

// IDs set during SetupSuite for the primary agent used across multiple tests.
Expand All @@ -86,6 +85,7 @@ var (
// OAuth CC token issuance, tree-path endpoints, group membership, and error paths.
type AgentAPITestSuite struct {
suite.Suite
agentTypeSnapshot *testutils.AgentTypeSnapshot
}

func TestAgentAPITestSuite(t *testing.T) {
Expand All @@ -99,10 +99,15 @@ func (ts *AgentAPITestSuite) SetupSuite() {
ts.Require().NoError(err, "Failed to create test organization unit")
testOUID = ouID

// The `default` agent type is a singleton shared with every other suite. Snapshot it before
// pointing it at this suite's OU, so teardown can put it back before that OU is deleted.
snapshot, err := testutils.SnapshotAgentType()
ts.Require().NoError(err, "Failed to snapshot the default agent type")
ts.agentTypeSnapshot = snapshot

agentSchema.OUID = testOUID
schemaID, err := testutils.CreateAgentType(agentSchema)
_, err = testutils.CreateAgentType(agentSchema)
ts.Require().NoError(err, "Failed to create agent schema (user type)")
agentSchemaID = schemaID

defaultAuthFlowID, err = testutils.GetFlowIDByHandle("default-flow", "AUTHENTICATION")
ts.Require().NoError(err, "Failed to get default auth flow ID")
Expand All @@ -123,9 +128,11 @@ func (ts *AgentAPITestSuite) TearDownSuite() {
ts.T().Logf("Failed to delete primary agent during teardown: %v", err)
}
}
if agentSchemaID != "" {
if err := testutils.DeleteAgentType(agentSchemaID); err != nil {
ts.T().Logf("Failed to delete agent schema during teardown: %v", err)
// Restore the shared agent type before deleting the OU it points at, or the singleton is left
// referencing a deleted OU and a later suite's restore fails.
if ts.agentTypeSnapshot != nil {
if err := testutils.RestoreAgentType(ts.agentTypeSnapshot); err != nil {
ts.T().Errorf("teardown: failed to restore the default agent type: %v", err)
}
}
if testOUID != "" {
Expand Down Expand Up @@ -740,8 +747,8 @@ var (
// AgentAttributesTestSuite covers custom attribute CRUD and filter operations on agents.
type AgentAttributesTestSuite struct {
suite.Suite
ouID string
schemaID string
ouID string
agentTypeSnapshot *testutils.AgentTypeSnapshot
}

func TestAgentAttributesTestSuite(t *testing.T) {
Expand All @@ -753,15 +760,24 @@ func (ts *AgentAttributesTestSuite) SetupSuite() {
ts.Require().NoError(err, "Failed to create test organization unit")
ts.ouID = ouID

// The `default` agent type is a singleton shared with every other suite. Snapshot it before
// pointing it at this suite's OU, so teardown can put it back before that OU is deleted.
snapshot, err := testutils.SnapshotAgentType()
ts.Require().NoError(err, "Failed to snapshot the default agent type")
ts.agentTypeSnapshot = snapshot

attrAgentSchema.OUID = ts.ouID
schemaID, err := testutils.CreateAgentType(attrAgentSchema)
_, err = testutils.CreateAgentType(attrAgentSchema)
ts.Require().NoError(err, "Failed to create agent schema")
ts.schemaID = schemaID
}

func (ts *AgentAttributesTestSuite) TearDownSuite() {
if ts.schemaID != "" {
_ = testutils.DeleteAgentType(ts.schemaID)
// Restore the shared agent type before deleting the OU it points at, or the singleton is left
// referencing a deleted OU and a later suite's restore fails.
if ts.agentTypeSnapshot != nil {
if err := testutils.RestoreAgentType(ts.agentTypeSnapshot); err != nil {
ts.T().Errorf("teardown: failed to restore the default agent type: %v", err)
}
}
if ts.ouID != "" {
_ = testutils.DeleteOrganizationUnit(ts.ouID)
Expand Down Expand Up @@ -845,7 +861,8 @@ func (ts *AgentAttributesTestSuite) TestAgentAttributes_UpdateAttributes() {
}

// TestAgentAttributes_FilterByAttribute verifies that GET /agents?filter=attr eq "value"
// returns only matching agents.
// returns exactly the matching agents. The result set is pinned by count and by ID, so a filter
// that silently widened to every agent would fail rather than still finding the expected one.
func (ts *AgentAttributesTestSuite) TestAgentAttributes_FilterByAttribute() {
idA, err := createAgent(Agent{
OUID: ts.ouID,
Expand Down Expand Up @@ -875,14 +892,11 @@ func (ts *AgentAttributesTestSuite) TestAgentAttributes_FilterByAttribute() {
var listResp AgentListResponse
ts.Require().NoError(json.NewDecoder(resp.Body).Decode(&listResp))

found := false
for _, a := range listResp.Agents {
if a.ID == idA {
found = true
}
ts.Assert().NotEqual(idB, a.ID, "Beta agent must not appear in filtered results")
}
ts.Assert().True(found, "Alpha agent must appear in filtered results")
ts.Assert().Equal(1, listResp.TotalResults, "filter must match exactly the alpha agent")
ts.Assert().Equal(1, listResp.Count)
ts.Require().Len(listResp.Agents, 1)
ts.Assert().Equal(idA, listResp.Agents[0].ID,
"the single match must be the alpha agent, which also proves beta was excluded")
}

// TestAgentAttributes_NullifyAttributes verifies that omitting attributes on update
Expand Down
32 changes: 21 additions & 11 deletions tests/integration/agent/agent_client_attributes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,12 @@ const (
// allow-list.
type AgentClientAttributesTestSuite struct {
suite.Suite
client *http.Client
ouID string
agentSchemaID string
entityTypeID string
ownerUserID string
resourceServerID string
client *http.Client
ouID string
agentTypeSnapshot *testutils.AgentTypeSnapshot
entityTypeID string
ownerUserID string
resourceServerID string
}

// TestAgentClientAttributesTestSuite runs the AgentClientAttributesTestSuite.
Expand All @@ -52,7 +52,13 @@ func (s *AgentClientAttributesTestSuite) SetupSuite() {
s.Require().NoError(err)
s.ouID = ouID

schemaID, err := testutils.CreateAgentType(testutils.UserType{
// The `default` agent type is a singleton shared with every other suite. Snapshot it before
// pointing it at this suite's OU, so teardown can put it back before that OU is deleted.
snapshot, err := testutils.SnapshotAgentType()
s.Require().NoError(err)
s.agentTypeSnapshot = snapshot

_, err = testutils.CreateAgentType(testutils.UserType{
Name: "default",
OUID: s.ouID,
Schema: map[string]interface{}{
Expand All @@ -61,7 +67,6 @@ func (s *AgentClientAttributesTestSuite) SetupSuite() {
},
})
s.Require().NoError(err)
s.agentSchemaID = schemaID

entityTypeID, err := testutils.CreateUserType(testutils.UserType{
Name: "agent-client-attrs-owner",
Expand Down Expand Up @@ -100,16 +105,21 @@ func (s *AgentClientAttributesTestSuite) SetupSuite() {
// TearDownSuite deletes the shared resources created in SetupSuite.
func (s *AgentClientAttributesTestSuite) TearDownSuite() {
if s.resourceServerID != "" {
_ = testutils.DeleteResourceServer(s.resourceServerID)
s.NoError(testutils.DeleteResourceServerWithChildren(s.resourceServerID),
"teardown: delete resource server and its actions")
}
if s.ownerUserID != "" {
_ = testutils.DeleteUser(s.ownerUserID)
}
if s.entityTypeID != "" {
_ = testutils.DeleteUserType(s.entityTypeID)
}
if s.agentSchemaID != "" {
_ = testutils.DeleteAgentType(s.agentSchemaID)
// Restore the shared agent type before deleting the OU it points at, or the singleton is left
// referencing a deleted OU and a later suite's restore fails.
if s.agentTypeSnapshot != nil {
if err := testutils.RestoreAgentType(s.agentTypeSnapshot); err != nil {
s.T().Errorf("teardown: failed to restore the default agent type: %v", err)
}
}
if s.ouID != "" {
_ = testutils.DeleteOrganizationUnit(s.ouID)
Expand Down
14 changes: 14 additions & 0 deletions tests/integration/agent/agent_import_export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ type agentImportResponse struct {
type AgentImportExportSuite struct {
suite.Suite
ouID string
agentTypeSnapshot *testutils.AgentTypeSnapshot
handleSuffix string
authFlowID string
registrationFlowID string
Expand All @@ -93,6 +94,12 @@ func (s *AgentImportExportSuite) SetupSuite() {
s.Require().NoError(err)
s.ouID = ouID

// The `default` agent type is a singleton shared with every other suite. Snapshot it before
// pointing it at this suite's OU, so teardown can put it back before that OU is deleted.
snapshot, err := testutils.SnapshotAgentType()
s.Require().NoError(err, "failed to snapshot the default agent type")
s.agentTypeSnapshot = snapshot

_, err = testutils.CreateAgentType(testutils.UserType{
Name: "default",
OUID: s.ouID,
Expand All @@ -112,6 +119,13 @@ func (s *AgentImportExportSuite) SetupSuite() {
}

func (s *AgentImportExportSuite) TearDownSuite() {
// Restore the shared agent type before deleting the OU it points at, or the singleton is left
// referencing a deleted OU and a later suite's restore fails.
if s.agentTypeSnapshot != nil {
if err := testutils.RestoreAgentType(s.agentTypeSnapshot); err != nil {
s.T().Errorf("teardown: failed to restore the default agent type: %v", err)
}
}
if s.ouID != "" {
_ = testutils.DeleteOrganizationUnit(s.ouID)
}
Expand Down
168 changes: 168 additions & 0 deletions tests/integration/agent/agent_list_query_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
// Copyright 2026 The ThunderID Authors
// SPDX-License-Identifier: Apache-2.0

package agent

import (
"encoding/json"
"io"
"net/http"
"net/url"
"testing"

"github.com/stretchr/testify/suite"
"github.com/thunder-id/thunderid/tests/integration/testutils"
)

// I18nMessage is an API message, which the server emits as an object but older surfaces emit as a
// plain string.
type I18nMessage struct {
Key string `json:"key,omitempty"`
DefaultValue string `json:"defaultValue,omitempty"`
}

// UnmarshalJSON accepts either a bare string or the structured form.
func (m *I18nMessage) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err == nil {
m.DefaultValue = s
return nil
}
type alias I18nMessage
var a alias
if err := json.Unmarshal(data, &a); err != nil {
return err
}
*m = I18nMessage(a)
return nil
}

// ErrorResponse represents an API error response.
type ErrorResponse struct {
Code string `json:"code"`
Message I18nMessage `json:"message"`
Description I18nMessage `json:"description,omitempty"`
}

// AgentListQueryTestSuite covers the rejection branches of GET /agents query parsing.
//
// The suite creates no agents. Every case here is rejected inside parsePaginationParams or
// parseFilterParams (`agent/handler.go:219-263`) before the service or the database is reached, so
// fixtures would contribute to no assertion. Asserting the exact code is also what proves the
// server rejected the request rather than silently dropping an unparseable filter and answering
// with an unfiltered list.
type AgentListQueryTestSuite struct {
suite.Suite
}

func TestAgentListQueryTestSuite(t *testing.T) {
suite.Run(t, new(AgentListQueryTestSuite))
}

// getAgents issues a list request with the given raw query string.
func (s *AgentListQueryTestSuite) getAgents(rawQuery string) *http.Response {
s.T().Helper()

req, err := http.NewRequest(http.MethodGet, testServerURL+agentBasePath+"?"+rawQuery, nil)
s.Require().NoError(err)

resp, err := testutils.GetHTTPClient().Do(req)
s.Require().NoError(err)
return resp
}

// TestInvalidListQueryParametersRejected verifies each malformed pagination or filter parameter is
// refused with its own exact status and product error code, rather than being clamped or ignored.
func (s *AgentListQueryTestSuite) TestInvalidListQueryParametersRejected() {
cases := []struct {
name string
query string
code string
message string
description string
}{
{
// Scenario 34: zero is not "unlimited" and not "use the default"; it is invalid.
name: "limit of zero",
query: "limit=0",
code: "AGT-1011",
message: "Invalid pagination parameter",
description: "The limit parameter must be between 1 and 100",
},
{
// Scenario 35: one past the documented maximum.
name: "limit above the maximum",
query: "limit=101",
code: "AGT-1011",
message: "Invalid pagination parameter",
description: "The limit parameter must be between 1 and 100",
},
{
// Scenario 36: non-numeric offset.
name: "offset not a number",
query: "offset=abc",
code: "AGT-1012",
message: "Invalid pagination parameter",
description: "The offset parameter must be a non-negative integer",
},
{
// Scenario 37: negative offset.
name: "negative offset",
query: "offset=-1",
code: "AGT-1012",
message: "Invalid pagination parameter",
description: "The offset parameter must be a non-negative integer",
},
{
// Scenario 39: one representative malformed filter. Every rejection branch of
// parseFilterParams returns this same code, so further permutations add nothing.
name: "filter missing the eq operator",
query: "filter=" + url.QueryEscape("name"),
code: "AGT-1020",
message: "Invalid filter parameter",
description: "The filter format is invalid",
},
}

for _, tc := range cases {
s.Run(tc.name, func() {
resp := s.getAgents(tc.query)
defer func() { _ = resp.Body.Close() }()

s.Require().Equal(http.StatusBadRequest, resp.StatusCode)

body, err := io.ReadAll(resp.Body)
s.Require().NoError(err)

var errResp ErrorResponse
s.Require().NoError(json.Unmarshal(body, &errResp), "error body: %s", string(body))
s.Equal(tc.code, errResp.Code, "error body: %s", string(body))
s.Equal(tc.message, errResp.Message.DefaultValue)
s.Equal(tc.description, errResp.Description.DefaultValue)
})
}
}

// TestListQueryBoundaryValuesAccepted is the control for the rejections above: the values one step
// inside each boundary are accepted. Without it, "limit=101 is rejected" and "offset=-1 is
// rejected" would both be satisfied by a server that rejected every limit and offset.
func (s *AgentListQueryTestSuite) TestListQueryBoundaryValuesAccepted() {
cases := []struct {
name string
query string
}{
{name: "limit at the maximum", query: "limit=100"},
{name: "offset of zero", query: "offset=0"},
}

for _, tc := range cases {
s.Run(tc.name, func() {
resp := s.getAgents(tc.query)
defer func() { _ = resp.Body.Close() }()

body, err := io.ReadAll(resp.Body)
s.Require().NoError(err)
s.Equal(http.StatusOK, resp.StatusCode, "response body: %s", string(body))
})
}
}
Loading
Loading