From f8b4ddb4d4bcf56373a8ebabe918576e20622596 Mon Sep 17 00:00:00 2001 From: ImalshaD Date: Mon, 17 Aug 2026 07:43:04 +0530 Subject: [PATCH] Add Identity domain CRUD, self-service and tree integration tests Cover the /agent-types singleton contracts, agent list query validation, users/me update validation and user tree path and pagination handling, each asserting exact status, exact product error code and persisted state after a rejected write. Add authorization coverage for /agent-types, which had none, and pin the by-path user create route to the root permission it currently requires. Enforce the shared agent type baseline. The default agent type is a singleton that every agent suite upserts, so suites that mutate it now snapshot it in setup and restore it in teardown, restoring before deleting any suite-scoped OU. Restoration verifies that it applied. Add DeleteResourceServerWithChildren for teardown. A resource server that owns resources or actions is refused deletion with RES-1006, so suites were leaving permission trees behind in the shared database while still reporting success. Signed-off-by: ImalshaD --- tests/integration/agent/agent_api_test.go | 56 ++- .../agent/agent_client_attributes_test.go | 32 +- .../agent/agent_import_export_test.go | 14 + .../agent/agent_list_query_test.go | 168 ++++++++ tests/integration/agent/agent_oauth_test.go | 104 +++-- .../agenttype/agenttype_api_test.go | 401 ++++++++++++++++++ .../agenttype/agenttype_authz_test.go | 298 +++++++++++++ tests/integration/agenttype/model.go | 84 ++++ .../composite/composite_mode_api_test.go | 36 +- tests/integration/group/group_authz_test.go | 4 +- tests/integration/ou/ou_authz_test.go | 4 +- tests/integration/testutils/api_utils.go | 365 +++++++++++++++- tests/integration/user/user_authz_test.go | 76 +++- tests/integration/user/user_self_api_test.go | 124 +++++- tests/integration/user/user_tree_api_test.go | 115 ++++- .../usertype/usertype_authz_test.go | 4 +- 16 files changed, 1792 insertions(+), 93 deletions(-) create mode 100644 tests/integration/agent/agent_list_query_test.go create mode 100644 tests/integration/agenttype/agenttype_api_test.go create mode 100644 tests/integration/agenttype/agenttype_authz_test.go create mode 100644 tests/integration/agenttype/model.go diff --git a/tests/integration/agent/agent_api_test.go b/tests/integration/agent/agent_api_test.go index fdcc15b96e..61dedc8f24 100644 --- a/tests/integration/agent/agent_api_test.go +++ b/tests/integration/agent/agent_api_test.go @@ -74,7 +74,6 @@ var ( var ( testOUID string - agentSchemaID string defaultAuthFlowID string // IDs set during SetupSuite for the primary agent used across multiple tests. @@ -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) { @@ -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") @@ -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 != "" { @@ -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) { @@ -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) @@ -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, @@ -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 diff --git a/tests/integration/agent/agent_client_attributes_test.go b/tests/integration/agent/agent_client_attributes_test.go index 6e0bfa1b1a..e7b1d37f09 100644 --- a/tests/integration/agent/agent_client_attributes_test.go +++ b/tests/integration/agent/agent_client_attributes_test.go @@ -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. @@ -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{}{ @@ -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", @@ -100,7 +105,8 @@ 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) @@ -108,8 +114,12 @@ func (s *AgentClientAttributesTestSuite) TearDownSuite() { 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) diff --git a/tests/integration/agent/agent_import_export_test.go b/tests/integration/agent/agent_import_export_test.go index 2be666b2da..d3754bc8e1 100644 --- a/tests/integration/agent/agent_import_export_test.go +++ b/tests/integration/agent/agent_import_export_test.go @@ -72,6 +72,7 @@ type agentImportResponse struct { type AgentImportExportSuite struct { suite.Suite ouID string + agentTypeSnapshot *testutils.AgentTypeSnapshot handleSuffix string authFlowID string registrationFlowID string @@ -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, @@ -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) } diff --git a/tests/integration/agent/agent_list_query_test.go b/tests/integration/agent/agent_list_query_test.go new file mode 100644 index 0000000000..084cefc145 --- /dev/null +++ b/tests/integration/agent/agent_list_query_test.go @@ -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)) + }) + } +} diff --git a/tests/integration/agent/agent_oauth_test.go b/tests/integration/agent/agent_oauth_test.go index fe91374196..f27d6e4f27 100644 --- a/tests/integration/agent/agent_oauth_test.go +++ b/tests/integration/agent/agent_oauth_test.go @@ -48,12 +48,12 @@ const ( // duplicate clientID, and transitioning an entity-only agent to OAuth on update. type AgentOAuthFlowsTestSuite struct { suite.Suite - ouID string - schemaID string - entityTypeID string - userID string - authFlowID string - resourceServerID string + ouID string + agentTypeSnapshot *testutils.AgentTypeSnapshot + entityTypeID string + userID string + authFlowID string + resourceServerID string } func TestAgentOAuthFlowsTestSuite(t *testing.T) { @@ -78,7 +78,13 @@ func (ts *AgentOAuthFlowsTestSuite) SetupSuite() { ts.Require().NoError(err, "Failed to create resource server") ts.resourceServerID = resourceServerID - 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() + ts.Require().NoError(err, "Failed to snapshot the default agent type") + ts.agentTypeSnapshot = snapshot + + _, err = testutils.CreateAgentType(testutils.UserType{ Name: "default", OUID: ts.ouID, Schema: map[string]interface{}{ @@ -86,7 +92,6 @@ func (ts *AgentOAuthFlowsTestSuite) SetupSuite() { }, }) ts.Require().NoError(err, "Failed to create agent schema") - ts.schemaID = schemaID entityTypeID, err := testutils.CreateUserType(testutils.UserType{ Name: "agent-oauth-flow-person", @@ -124,11 +129,16 @@ func (ts *AgentOAuthFlowsTestSuite) TearDownSuite() { if ts.entityTypeID != "" { _ = testutils.DeleteUserType(ts.entityTypeID) } - 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.resourceServerID != "" { - _ = testutils.DeleteResourceServer(ts.resourceServerID) + ts.NoError(testutils.DeleteResourceServerWithChildren(ts.resourceServerID), + "teardown: delete resource server and its actions") } if ts.ouID != "" { _ = testutils.DeleteOrganizationUnit(ts.ouID) @@ -537,14 +547,14 @@ func (ts *AgentOAuthFlowsTestSuite) TestAgentUpdate_AddOAuthProfile() { // and that client_credentials tokens reflect those role-based scope grants. type CCAgentAuthzTestSuite struct { suite.Suite - client *http.Client - ouID string - agentSchemaID string - resourceServerID string - agentID string - roleID string - groupID string - groupRoleID string + client *http.Client + ouID string + agentTypeSnapshot *testutils.AgentTypeSnapshot + resourceServerID string + agentID string + roleID string + groupID string + groupRoleID string } func TestCCAgentAuthzTestSuite(t *testing.T) { @@ -562,7 +572,13 @@ func (s *CCAgentAuthzTestSuite) 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{}{ @@ -570,7 +586,6 @@ func (s *CCAgentAuthzTestSuite) SetupSuite() { }, }) s.Require().NoError(err) - s.agentSchemaID = schemaID rsID, err := testutils.CreateResourceServerWithActions(testutils.ResourceServer{ Name: "CC Agent Authz API", @@ -647,10 +662,15 @@ func (s *CCAgentAuthzTestSuite) TearDownSuite() { _ = deleteAgent(s.agentID) } if s.resourceServerID != "" { - _ = testutils.DeleteResourceServer(s.resourceServerID) + s.NoError(testutils.DeleteResourceServerWithChildren(s.resourceServerID), + "teardown: delete resource server and its actions") } - 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) @@ -799,14 +819,14 @@ func (s *CCAgentAuthzTestSuite) TestAgentCC_AllScopes() { // is a user assertion. type AgentTokenExchangeTestSuite struct { suite.Suite - client *http.Client - ouID string - entityTypeID string - agentSchemaID string - agentID string - userID string - resourceServerID string - assertionToken string + client *http.Client + ouID string + entityTypeID string + agentTypeSnapshot *testutils.AgentTypeSnapshot + agentID string + userID string + resourceServerID string + assertionToken string } func TestAgentTokenExchangeTestSuite(t *testing.T) { @@ -833,7 +853,13 @@ func (s *AgentTokenExchangeTestSuite) SetupSuite() { s.Require().NoError(err) s.resourceServerID = resourceServerID - agentSchemaID, 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{}{ @@ -841,7 +867,6 @@ func (s *AgentTokenExchangeTestSuite) SetupSuite() { }, }) s.Require().NoError(err) - s.agentSchemaID = agentSchemaID entityTypeID, err := testutils.CreateUserType(testutils.UserType{ Name: "agent-te-person", @@ -883,11 +908,16 @@ func (s *AgentTokenExchangeTestSuite) TearDownSuite() { 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.resourceServerID != "" { - _ = testutils.DeleteResourceServer(s.resourceServerID) + s.NoError(testutils.DeleteResourceServerWithChildren(s.resourceServerID), + "teardown: delete resource server and its actions") } if s.ouID != "" { _ = testutils.DeleteOrganizationUnit(s.ouID) diff --git a/tests/integration/agenttype/agenttype_api_test.go b/tests/integration/agenttype/agenttype_api_test.go new file mode 100644 index 0000000000..9d20c198ed --- /dev/null +++ b/tests/integration/agenttype/agenttype_api_test.go @@ -0,0 +1,401 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package agenttype + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/url" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/thunder-id/thunderid/tests/integration/testutils" +) + +const ( + agentTypeBasePath = "/agent-types" + agentBasePath = "/agents" + + // defaultAgentTypeName is the only name an agent type is permitted to carry. + defaultAgentTypeName = "default" +) + +// AgentTypeAPITestSuite covers the /agent-types API, whose contract differs from /user-types in +// three ways that had no integration coverage: exactly one type may exist, it must be named +// `default`, and it cannot be deleted. +// +// The `default` agent type is a singleton shared with every other package. This suite therefore +// snapshots it in setup and restores it in teardown, and every test that mutates the schema +// restores it in a defer rather than relying on teardown — testify orders suite methods +// alphabetically, so a lingering edit would silently become another test's precondition. +type AgentTypeAPITestSuite struct { + suite.Suite + snapshot *testutils.AgentTypeSnapshot + ouID string +} + +func TestAgentTypeAPITestSuite(t *testing.T) { + suite.Run(t, new(AgentTypeAPITestSuite)) +} + +func (s *AgentTypeAPITestSuite) SetupSuite() { + // Require the singleton to exist rather than creating it. Agent types cannot be deleted, so + // creating one here would be unreversible, and an absent `default` is a broken environment + // rather than something a test suite should paper over. + snapshot, err := testutils.SnapshotAgentType() + s.Require().NoError(err, "the default agent type must exist before this suite runs") + s.Require().NotEmpty(snapshot.ID) + s.Require().NotEmpty(snapshot.Schema, "the default agent type must carry a schema") + s.snapshot = snapshot + + ouID, err := testutils.CreateOrganizationUnit(testutils.OrganizationUnit{ + Handle: "agent-type-api-ou", + Name: "Agent Type API OU", + Description: "OU supplying a valid ouId to agent type rejection tests", + }) + s.Require().NoError(err) + s.ouID = ouID +} + +func (s *AgentTypeAPITestSuite) TearDownSuite() { + if s.snapshot != nil { + s.NoError(testutils.RestoreAgentType(s.snapshot), + "the default agent type must be restored for later packages") + } + if s.ouID != "" { + s.NoError(testutils.DeleteOrganizationUnit(s.ouID), "teardown: delete OU") + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func (s *AgentTypeAPITestSuite) do(method, path string, body interface{}) *http.Response { + s.T().Helper() + return s.doWith(testutils.GetHTTPClient(), method, path, body) +} + +func (s *AgentTypeAPITestSuite) doWith( + client *http.Client, method, path string, body interface{}, +) *http.Response { + s.T().Helper() + + var reader io.Reader + if body != nil { + encoded, err := json.Marshal(body) + s.Require().NoError(err) + reader = bytes.NewReader(encoded) + } + + req, err := http.NewRequest(method, testutils.TestServerURL+path, reader) + s.Require().NoError(err) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := client.Do(req) + s.Require().NoError(err) + return resp +} + +// decodeError reads an error response and asserts the exact product error code. +func (s *AgentTypeAPITestSuite) decodeError(resp *http.Response, expectedCode string) ErrorResponse { + s.T().Helper() + + 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(expectedCode, errResp.Code, "error body: %s", string(body)) + return errResp +} + +// listAgentTypes returns the current agent type list. +func (s *AgentTypeAPITestSuite) listAgentTypes() AgentTypeListResponse { + s.T().Helper() + + resp := s.do(http.MethodGet, agentTypeBasePath, nil) + defer closeBody(resp) + s.Require().Equal(http.StatusOK, resp.StatusCode) + + var list AgentTypeListResponse + s.Require().NoError(json.NewDecoder(resp.Body).Decode(&list)) + return list +} + +// getAgentType fetches the singleton from the detail endpoint, which is the only endpoint that +// carries the schema. +func (s *AgentTypeAPITestSuite) getAgentType(id string) AgentType { + s.T().Helper() + + resp := s.do(http.MethodGet, agentTypeBasePath+"/"+id, nil) + defer closeBody(resp) + s.Require().Equal(http.StatusOK, resp.StatusCode) + + var agentType AgentType + s.Require().NoError(json.NewDecoder(resp.Body).Decode(&agentType)) + return agentType +} + +// putSchema replaces the singleton's schema, keeping its name and OU. +func (s *AgentTypeAPITestSuite) putSchema(schema interface{}) { + s.T().Helper() + + encoded, err := json.Marshal(schema) + s.Require().NoError(err) + + resp := s.do(http.MethodPut, agentTypeBasePath+"/"+s.snapshot.ID, AgentTypeRequest{ + Name: defaultAgentTypeName, + OUID: s.snapshot.OUID, + Schema: encoded, + }) + defer closeBody(resp) + + body, err := io.ReadAll(resp.Body) + s.Require().NoError(err) + s.Require().Equal(http.StatusOK, resp.StatusCode, "schema update failed: %s", string(body)) +} + +// restoreSchema puts the snapshot's schema back. Deferred by every test that edits the schema. +func (s *AgentTypeAPITestSuite) restoreSchema() { + s.T().Helper() + s.NoError(testutils.RestoreAgentType(s.snapshot)) +} + +func closeBody(resp *http.Response) { _ = resp.Body.Close() } + +// --------------------------------------------------------------------------- +// Scenario 25 — only `default` may be created +// --------------------------------------------------------------------------- + +// TestCreateNonDefaultAgentTypeRejected verifies that agent types are restricted to the single +// `default` schema: a create with any other name is refused and nothing is persisted. +func (s *AgentTypeAPITestSuite) TestCreateNonDefaultAgentTypeRejected() { + before := s.listAgentTypes() + + resp := s.do(http.MethodPost, agentTypeBasePath, AgentTypeRequest{ + Name: "custom-agent-type", + OUID: s.ouID, + Schema: json.RawMessage(`{"description": {"type": "string"}}`), + }) + defer closeBody(resp) + + s.Require().Equal(http.StatusBadRequest, resp.StatusCode) + s.decodeError(resp, "USRS-1014") + + after := s.listAgentTypes() + s.Equal(before.TotalResults, after.TotalResults, "a rejected create must not persist a type") + for _, t := range after.Types { + s.NotEqual("custom-agent-type", t.Name) + } +} + +// --------------------------------------------------------------------------- +// Scenario 26 — `default` is a singleton +// --------------------------------------------------------------------------- + +// TestCreateDuplicateDefaultAgentTypeRejected verifies that a second `default` agent type is +// refused as a name conflict, so the singleton cannot be duplicated. +func (s *AgentTypeAPITestSuite) TestCreateDuplicateDefaultAgentTypeRejected() { + before := s.listAgentTypes() + + resp := s.do(http.MethodPost, agentTypeBasePath, AgentTypeRequest{ + Name: defaultAgentTypeName, + OUID: s.ouID, + Schema: json.RawMessage(`{"description": {"type": "string"}}`), + }) + defer closeBody(resp) + + s.Require().Equal(http.StatusConflict, resp.StatusCode) + s.decodeError(resp, "USRS-1003") + + after := s.listAgentTypes() + s.Equal(before.TotalResults, after.TotalResults, "a rejected create must not persist a type") +} + +// --------------------------------------------------------------------------- +// Scenario 27 — `default` cannot be renamed +// --------------------------------------------------------------------------- + +// TestRenameDefaultAgentTypeRejected verifies that the singleton cannot be renamed out of the +// `default` name that agent creation depends on, and that the stored name is unchanged. +func (s *AgentTypeAPITestSuite) TestRenameDefaultAgentTypeRejected() { + schema, err := json.Marshal(s.snapshot.Schema) + s.Require().NoError(err) + + resp := s.do(http.MethodPut, agentTypeBasePath+"/"+s.snapshot.ID, AgentTypeRequest{ + Name: "renamed-agent-type", + OUID: s.snapshot.OUID, + Schema: schema, + }) + defer closeBody(resp) + + s.Require().Equal(http.StatusBadRequest, resp.StatusCode) + s.decodeError(resp, "USRS-1014") + + s.Equal(defaultAgentTypeName, s.getAgentType(s.snapshot.ID).Name, + "a rejected rename must not change the stored name") +} + +// --------------------------------------------------------------------------- +// Scenario 28 — `default` cannot be deleted +// --------------------------------------------------------------------------- + +// TestDeleteAgentTypeRejected verifies that the agent type survives a delete attempt. Agent +// creation depends on it, so deletion is refused outright rather than cascading. +func (s *AgentTypeAPITestSuite) TestDeleteAgentTypeRejected() { + resp := s.do(http.MethodDelete, agentTypeBasePath+"/"+s.snapshot.ID, nil) + defer closeBody(resp) + + s.Require().Equal(http.StatusBadRequest, resp.StatusCode) + s.decodeError(resp, "USRS-1015") + + s.Equal(defaultAgentTypeName, s.getAgentType(s.snapshot.ID).Name, + "the agent type must still exist after a refused delete") +} + +// --------------------------------------------------------------------------- +// Scenario 29 — unknown id carries the agent-specific description +// --------------------------------------------------------------------------- + +// TestGetUnknownAgentTypeReturnsAgentSpecificError verifies that /agent-types reports a missing +// type in agent terms. The category shares USRS-1002 with /user-types, so only the description +// distinguishes them. +func (s *AgentTypeAPITestSuite) TestGetUnknownAgentTypeReturnsAgentSpecificError() { + resp := s.do(http.MethodGet, agentTypeBasePath+"/01900000-0000-7000-8000-00000000dead", nil) + defer closeBody(resp) + + s.Require().Equal(http.StatusNotFound, resp.StatusCode) + errResp := s.decodeError(resp, "USRS-1002") + + s.Equal("Agent type not found", errResp.Message.DefaultValue) + s.Equal("The agent type with the specified id does not exist", errResp.Description.DefaultValue) +} + +// --------------------------------------------------------------------------- +// Scenario 31 — the list holds exactly the singleton +// --------------------------------------------------------------------------- + +// TestListAgentTypesReturnsOnlyTheSingleton verifies the list endpoint reports exactly one agent +// type, under the `types` key, and that it is the `default` one. +func (s *AgentTypeAPITestSuite) TestListAgentTypesReturnsOnlyTheSingleton() { + list := s.listAgentTypes() + + s.Equal(1, list.TotalResults) + s.Equal(1, list.Count) + s.Require().Len(list.Types, 1) + s.Equal(defaultAgentTypeName, list.Types[0].Name) + s.Equal(s.snapshot.ID, list.Types[0].ID) +} + +// --------------------------------------------------------------------------- +// Scenario 32 — schema edits persist +// --------------------------------------------------------------------------- + +// TestUpdateAgentTypeSchemaPersists verifies that editing the singleton's schema is the supported +// way to change it, and that the edit is readable back from the detail endpoint. +func (s *AgentTypeAPITestSuite) TestUpdateAgentTypeSchemaPersists() { + defer s.restoreSchema() + + s.putSchema(map[string]interface{}{ + "description": map[string]interface{}{"type": "string"}, + "costCentre": map[string]interface{}{"type": "string"}, + }) + + var stored map[string]interface{} + s.Require().NoError(json.Unmarshal(s.getAgentType(s.snapshot.ID).Schema, &stored)) + + s.Contains(stored, "costCentre", "the edited attribute must be persisted") + s.Contains(stored, "description") + s.Len(stored, 2, "the update replaces the schema rather than merging into it") +} + +// --------------------------------------------------------------------------- +// Scenario 46 — a unique attribute in the schema constrains agent creation +// --------------------------------------------------------------------------- + +// TestUniqueAgentTypeAttributeRejectsDuplicateAgent verifies that a `unique` constraint declared +// in the agent type schema is enforced when agents are created: a second agent reusing the value +// is refused and not persisted. The agents carry distinct names so the conflict can only come +// from the attribute. +func (s *AgentTypeAPITestSuite) TestUniqueAgentTypeAttributeRejectsDuplicateAgent() { + defer s.restoreSchema() + + s.putSchema(map[string]interface{}{ + "serialNumber": map[string]interface{}{"type": "string", "unique": true}, + }) + + firstID := s.createAgent(Agent{ + Type: defaultAgentTypeName, + Name: "agent-type-unique-first", + OUID: s.snapshot.OUID, + Attributes: json.RawMessage(`{"serialNumber": "SN-AGENTTYPE-0001"}`), + }) + defer func() { s.deleteAgent(firstID) }() + + resp := s.do(http.MethodPost, agentBasePath, Agent{ + Type: defaultAgentTypeName, + Name: "agent-type-unique-second", + OUID: s.snapshot.OUID, + Attributes: json.RawMessage(`{"serialNumber": "SN-AGENTTYPE-0001"}`), + }) + defer closeBody(resp) + + s.Require().Equal(http.StatusConflict, resp.StatusCode) + s.decodeError(resp, "AGT-1014") + + holders := s.agentsWithSerial("SN-AGENTTYPE-0001") + s.Require().Len(holders, 1, "the rejected agent must not be persisted") + s.Equal("agent-type-unique-first", holders[0].Name, + "the surviving agent must be the one created first") +} + +// createAgent creates an agent and returns its ID. +func (s *AgentTypeAPITestSuite) createAgent(agent Agent) string { + s.T().Helper() + + resp := s.do(http.MethodPost, agentBasePath, agent) + defer closeBody(resp) + + body, err := io.ReadAll(resp.Body) + s.Require().NoError(err) + s.Require().Equal(http.StatusCreated, resp.StatusCode, "create agent failed: %s", string(body)) + + var created Agent + s.Require().NoError(json.Unmarshal(body, &created)) + s.Require().NotEmpty(created.ID) + return created.ID +} + +// deleteAgent removes an agent and requires the delete to succeed, so a leaked agent cannot pass +// unnoticed and become a later suite's precondition. +func (s *AgentTypeAPITestSuite) deleteAgent(id string) { + s.T().Helper() + + resp := s.do(http.MethodDelete, agentBasePath+"/"+id, nil) + defer closeBody(resp) + + body, err := io.ReadAll(resp.Body) + s.Require().NoError(err) + s.Equal(http.StatusNoContent, resp.StatusCode, "cleanup: delete agent %s: %s", id, string(body)) +} + +// agentsWithSerial returns the agents carrying the given serialNumber, so a uniqueness claim can +// be checked against stored state rather than against a list scan. +func (s *AgentTypeAPITestSuite) agentsWithSerial(serial string) []Agent { + s.T().Helper() + + resp := s.do(http.MethodGet, + agentBasePath+"?filter="+url.QueryEscape(`serialNumber eq "`+serial+`"`), nil) + defer closeBody(resp) + s.Require().Equal(http.StatusOK, resp.StatusCode) + + var list AgentListResponse + s.Require().NoError(json.NewDecoder(resp.Body).Decode(&list)) + return list.Agents +} diff --git a/tests/integration/agenttype/agenttype_authz_test.go b/tests/integration/agenttype/agenttype_authz_test.go new file mode 100644 index 0000000000..fc41862602 --- /dev/null +++ b/tests/integration/agenttype/agenttype_authz_test.go @@ -0,0 +1,298 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package agenttype + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/thunder-id/thunderid/tests/integration/testutils" +) + +// AgentTypeAuthzTestSuite verifies that /agent-types is gated by the `agenttype` category +// permission, which had no integration coverage: entity-type authorization is unit-tested against +// mocks, and integration-tested only for /user-types. +// +// The caller here holds `system:usertype` and nothing else. Every /agent-types route refuses it at +// the security middleware with 403 AUTH-4030 (`system/security/middleware.go:51`), which is a +// route-level permission gate that runs before the handler — so the two categories are separated +// before any OU or resource logic is reached. The same token reads /user-types successfully +// throughout, which is what makes the refusals attributable to the missing category permission +// rather than to an invalid token. +type AgentTypeAuthzTestSuite struct { + suite.Suite + + agentTypeID string + + ouID string + userTypeID string + userID string + roleID string + scopedRSID string + + // scopedClient carries a token with system:usertype but no agenttype permission. + scopedClient *http.Client +} + +const ( + agentTypeAuthzOUHandle = "agent-type-authz-ou" + + agentTypeAuthzUserTypeName = "agent-type-authz-person" + agentTypeAuthzUsername = "agent-type-authz-user" + agentTypeAuthzPassword = "AgentTypeAuthz@123" + + agentTypeAuthzClientID = "CONSOLE" + agentTypeAuthzRedirectURI = "https://localhost:8095/console" + agentTypeAuthzRSIdentity = "https://authz-test.example.com/agenttype" +) + +func TestAgentTypeAuthzTestSuite(t *testing.T) { + suite.Run(t, new(AgentTypeAuthzTestSuite)) +} + +func (s *AgentTypeAuthzTestSuite) SetupSuite() { + // Read-only: the suite needs the singleton's ID to address it, and never mutates it. + snapshot, err := testutils.SnapshotAgentType() + s.Require().NoError(err, "the default agent type must exist before this suite runs") + s.agentTypeID = snapshot.ID + + ouID, err := testutils.CreateOrganizationUnit(testutils.OrganizationUnit{ + Handle: agentTypeAuthzOUHandle, + Name: "Agent Type Authz OU", + Description: "OU holding the scoped user for the agent type authz test", + }) + s.Require().NoError(err) + s.ouID = ouID + + userTypeID, err := testutils.CreateUserType(testutils.UserType{ + Name: agentTypeAuthzUserTypeName, + OUID: s.ouID, + Schema: map[string]interface{}{ + "username": map[string]interface{}{"type": "string", "unique": true}, + "password": map[string]interface{}{"type": "string", "credential": true}, + }, + }) + s.Require().NoError(err) + s.userTypeID = userTypeID + + userID, err := testutils.CreateUser(testutils.User{ + Type: agentTypeAuthzUserTypeName, + OUID: s.ouID, + Attributes: json.RawMessage(fmt.Sprintf( + `{"username": %q, "password": %q}`, agentTypeAuthzUsername, agentTypeAuthzPassword)), + }) + s.Require().NoError(err) + s.userID = userID + + // Declare both category permissions so the tree is complete, then grant only `usertype`. + // Granting nothing at all would not distinguish "no agenttype permission" from "no permissions". + rsID, err := testutils.CreateSystemScopedResourceServer( + s.ouID, "Authz Test RS (agenttype)", agentTypeAuthzRSIdentity, "usertype", "agenttype") + s.Require().NoError(err) + s.scopedRSID = rsID + + roleID, err := testutils.CreateRole(testutils.Role{ + Name: "Agent Type Authz Role", + OUID: s.ouID, + Permissions: []testutils.ResourcePermissions{ + {ResourceServerID: rsID, Permissions: []string{"system:usertype"}}, + }, + Assignments: []testutils.Assignment{{ID: s.userID, Type: "user"}}, + }) + s.Require().NoError(err) + s.roleID = roleID + + tokenResp, err := testutils.ObtainAccessTokenWithPassword( + agentTypeAuthzClientID, + agentTypeAuthzRedirectURI, + "system system:usertype", + agentTypeAuthzUsername, + agentTypeAuthzPassword, + true, + "", + agentTypeAuthzRSIdentity, + ) + s.Require().NoError(err) + s.Require().NotEmpty(tokenResp.AccessToken) + + granted := strings.Fields(tokenResp.Scope) + s.Require().Contains(granted, "system:usertype", "token must carry the usertype scope") + for _, scope := range granted { + s.Require().False(strings.HasPrefix(scope, "system:agenttype"), + "the token must carry no agenttype scope, or the suite proves nothing; got %v", granted) + } + + s.scopedClient = testutils.GetHTTPClientWithToken(tokenResp.AccessToken) +} + +func (s *AgentTypeAuthzTestSuite) TearDownSuite() { + // Cleanup failures are asserted, not swallowed. This suite builds a resource server with a + // nested permission tree, and a discarded error here would leave that tree in the shared + // database while the suite still reported PASS. + if s.roleID != "" { + s.NoError(testutils.DeleteRole(s.roleID), "teardown: delete role") + } + if s.scopedRSID != "" { + // A plain resource-server delete is refused with RES-1006 while it still owns resources. + s.NoError(testutils.DeleteResourceServerWithChildren(s.scopedRSID), + "teardown: delete scoped resource server and its resource tree") + } + if s.userID != "" { + s.NoError(testutils.DeleteUser(s.userID), "teardown: delete scoped user") + } + if s.userTypeID != "" { + s.NoError(testutils.DeleteUserType(s.userTypeID), "teardown: delete user type") + } + if s.ouID != "" { + s.NoError(testutils.DeleteOrganizationUnit(s.ouID), "teardown: delete OU") + } +} + +// doScoped issues a request as the scoped user. +func (s *AgentTypeAuthzTestSuite) doScoped(method, path string, body interface{}) *http.Response { + s.T().Helper() + + var reader io.Reader + if body != nil { + encoded, err := json.Marshal(body) + s.Require().NoError(err) + reader = bytes.NewReader(encoded) + } + + req, err := http.NewRequest(method, testutils.TestServerURL+path, reader) + s.Require().NoError(err) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := s.scopedClient.Do(req) + s.Require().NoError(err) + return resp +} + +// requireForbidden asserts the response is the middleware's permission refusal. +func (s *AgentTypeAuthzTestSuite) requireForbidden(resp *http.Response) { + s.T().Helper() + + s.Require().Equal(http.StatusForbidden, 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("AUTH-4030", errResp.Code) + s.Equal("Forbidden", errResp.Message.DefaultValue) +} + +// --------------------------------------------------------------------------- +// Scenario N1 — the agenttype category permission gates /agent-types +// --------------------------------------------------------------------------- + +// TestReadAgentTypeWithoutCategoryPermissionIsRefused verifies that holding the `usertype` +// permission does not carry over to the `agenttype` category on the detail route. +func (s *AgentTypeAuthzTestSuite) TestReadAgentTypeWithoutCategoryPermissionIsRefused() { + resp := s.doScoped(http.MethodGet, agentTypeBasePath+"/"+s.agentTypeID, nil) + defer closeBody(resp) + + s.requireForbidden(resp) +} + +// TestListAgentTypesWithoutCategoryPermissionIsRefused verifies the list route is gated too, so a +// caller without the permission cannot enumerate agent types. +func (s *AgentTypeAuthzTestSuite) TestListAgentTypesWithoutCategoryPermissionIsRefused() { + resp := s.doScoped(http.MethodGet, agentTypeBasePath, nil) + defer closeBody(resp) + + s.requireForbidden(resp) +} + +// TestUpdateAgentTypeWithoutCategoryPermissionIsRefused verifies the write path is gated, and that +// the refused write leaves the stored schema untouched. +func (s *AgentTypeAuthzTestSuite) TestUpdateAgentTypeWithoutCategoryPermissionIsRefused() { + resp := s.doScoped(http.MethodPut, agentTypeBasePath+"/"+s.agentTypeID, AgentTypeRequest{ + Name: defaultAgentTypeName, + OUID: s.ouID, + Schema: json.RawMessage(`{"injected": {"type": "string"}}`), + }) + defer closeBody(resp) + + s.requireForbidden(resp) + + var schema map[string]interface{} + s.Require().NoError(json.Unmarshal(s.adminGetAgentType().Schema, &schema)) + s.NotContains(schema, "injected", "a refused update must not reach the store") +} + +// TestCreateAgentTypeWithoutCategoryPermissionIsRefused verifies the create route is gated, and +// that the refusal is the permission gate rather than the `default`-only rule — the payload uses a +// non-`default` name, which an authorized caller would be told about via USRS-1014 instead. +func (s *AgentTypeAuthzTestSuite) TestCreateAgentTypeWithoutCategoryPermissionIsRefused() { + resp := s.doScoped(http.MethodPost, agentTypeBasePath, AgentTypeRequest{ + Name: "authz-injected-agent-type", + OUID: s.ouID, + Schema: json.RawMessage(`{"description": {"type": "string"}}`), + }) + defer closeBody(resp) + + s.requireForbidden(resp) +} + +// TestDeleteAgentTypeWithoutCategoryPermissionIsRefused verifies the delete route is gated by the +// permission as well, and that the refusal is the permission gate rather than the never-delete rule. +// DELETE carries its own entry in the permission table, so this is a distinct middleware rule from +// the routes above. An authorized caller receives USRS-1015 for the same request, so the code proves +// which check ran first. +func (s *AgentTypeAuthzTestSuite) TestDeleteAgentTypeWithoutCategoryPermissionIsRefused() { + resp := s.doScoped(http.MethodDelete, agentTypeBasePath+"/"+s.agentTypeID, nil) + defer closeBody(resp) + + s.requireForbidden(resp) + + s.Equal(defaultAgentTypeName, s.adminGetAgentType().Name, + "the agent type must still exist after a refused delete") +} + +// TestListUserTypesRemainsPermitted is the control for the refusals above: the same token reads +// its own category successfully, so those refusals are about the missing agenttype permission and +// not about a broken or unscoped token. +func (s *AgentTypeAuthzTestSuite) TestListUserTypesRemainsPermitted() { + resp := s.doScoped(http.MethodGet, "/user-types", nil) + defer closeBody(resp) + + s.Require().Equal(http.StatusOK, resp.StatusCode) + + var list AgentTypeListResponse + s.Require().NoError(json.NewDecoder(resp.Body).Decode(&list)) + + ids := make([]string, 0, len(list.Types)) + for _, t := range list.Types { + ids = append(ids, t.ID) + } + s.Contains(ids, s.userTypeID, "the scoped user must still read its own category, got: %v", ids) +} + +// adminGetAgentType reads the agent type with the unrestricted client. +func (s *AgentTypeAuthzTestSuite) adminGetAgentType() AgentType { + s.T().Helper() + + req, err := http.NewRequest(http.MethodGet, + testutils.TestServerURL+agentTypeBasePath+"/"+s.agentTypeID, nil) + s.Require().NoError(err) + + resp, err := testutils.GetHTTPClient().Do(req) + s.Require().NoError(err) + defer closeBody(resp) + s.Require().Equal(http.StatusOK, resp.StatusCode) + + var agentType AgentType + s.Require().NoError(json.NewDecoder(resp.Body).Decode(&agentType)) + return agentType +} diff --git a/tests/integration/agenttype/model.go b/tests/integration/agenttype/model.go new file mode 100644 index 0000000000..96e0c57b53 --- /dev/null +++ b/tests/integration/agenttype/model.go @@ -0,0 +1,84 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package agenttype + +import ( + "encoding/json" +) + +// AgentType represents an agent type as returned by the detail endpoint. +type AgentType struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + OUID string `json:"ouId"` + Schema json.RawMessage `json:"schema,omitempty"` +} + +// AgentTypeRequest is the body for creating or updating an agent type. +type AgentTypeRequest struct { + Name string `json:"name"` + OUID string `json:"ouId"` + Schema json.RawMessage `json:"schema"` +} + +// AgentTypeListItem is a list entry. The list endpoint omits the schema. +type AgentTypeListItem struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + OUID string `json:"ouId"` +} + +// AgentTypeListResponse is the response from listing agent types. The entries live under `types`. +type AgentTypeListResponse struct { + TotalResults int `json:"totalResults"` + StartIndex int `json:"startIndex"` + Count int `json:"count"` + Types []AgentTypeListItem `json:"types"` +} + +// 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"` +} + +// Agent is the subset of the agent model this suite needs to exercise schema-driven uniqueness. +type Agent struct { + ID string `json:"id,omitempty"` + OUID string `json:"ouId,omitempty"` + Type string `json:"type,omitempty"` + Name string `json:"name,omitempty"` + Attributes json.RawMessage `json:"attributes,omitempty"` +} + +// AgentListResponse is the response from listing agents. +type AgentListResponse struct { + TotalResults int `json:"totalResults"` + Count int `json:"count"` + Agents []Agent `json:"agents"` +} diff --git a/tests/integration/composite/composite_mode_api_test.go b/tests/integration/composite/composite_mode_api_test.go index 857b887a93..dc60e10b63 100644 --- a/tests/integration/composite/composite_mode_api_test.go +++ b/tests/integration/composite/composite_mode_api_test.go @@ -28,14 +28,21 @@ import ( type CompositeModeSuite struct { suite.Suite - createdResources map[string][]string + createdResources map[string][]string + agentTypeSnapshot *testutils.AgentTypeSnapshot } func (suite *CompositeModeSuite) SetupSuite() { suite.createdResources = make(map[string][]string) + // The "default" agent type is a singleton shared with every other suite. Snapshot it before + // pointing it at the declarative OU, so teardown can put it back. + snapshot, err := testutils.SnapshotAgentType() + suite.Require().NoError(err, "Failed to snapshot the default agent type") + suite.agentTypeSnapshot = snapshot + // Ensure the singleton "default" agent type exists so composite agent tests can create runtime agents. - _, err := testutils.CreateAgentType(testutils.UserType{ + _, err = testutils.CreateAgentType(testutils.UserType{ Name: "default", OUID: "decl-ou-1", Schema: map[string]interface{}{"description": map[string]interface{}{"type": "string"}}, @@ -44,6 +51,13 @@ func (suite *CompositeModeSuite) SetupSuite() { } func (suite *CompositeModeSuite) TearDownSuite() { + // Restore the shared agent type before deleting any runtime resources it may reference. + if suite.agentTypeSnapshot != nil { + if err := testutils.RestoreAgentType(suite.agentTypeSnapshot); err != nil { + suite.T().Errorf("teardown: failed to restore the default agent type: %v", err) + } + } + // Delete only runtime resources (not declarative) for module, ids := range suite.createdResources { for _, id := range ids { @@ -1106,6 +1120,24 @@ func (suite *CompositeModeSuite) TestAgentDeclarativeUpdateReject() { errCode := suite.extractErrorCode(resp) suite.Equal("AGT-1027", errCode, "error code should be AGT-1027 for immutable agent") + + // The rejection must leave nothing behind. The payload above renames the agent and omits + // attributes, and omitting attributes on a successful update clears them, so a partially + // applied write would show up either as the new name or as emptied attributes. + getResp, err := client.Get(fmt.Sprintf("%s/agents/decl-agent-1", testutils.TestServerURL)) + suite.Require().NoError(err) + defer getResp.Body.Close() + suite.Require().Equal(http.StatusOK, getResp.StatusCode, "declarative agent should still be readable") + + var stored map[string]interface{} + suite.Require().NoError(json.NewDecoder(getResp.Body).Decode(&stored)) + + suite.Equal("Declarative Test Agent", stored["name"], "rejected update must not rename the agent") + + attributes, ok := stored["attributes"].(map[string]interface{}) + suite.Require().True(ok, "declarative agent should still carry its attributes: %v", stored) + suite.Equal("engineering", attributes["department"], + "rejected update must not clear attributes the payload omitted") } func (suite *CompositeModeSuite) TestAgentDeclarativeDeleteReject() { diff --git a/tests/integration/group/group_authz_test.go b/tests/integration/group/group_authz_test.go index b376b28bbf..a16c89ae9a 100644 --- a/tests/integration/group/group_authz_test.go +++ b/tests/integration/group/group_authz_test.go @@ -434,8 +434,8 @@ func (ts *GroupAuthzTestSuite) SetupSuite() { func (ts *GroupAuthzTestSuite) TearDownSuite() { if ts.scopedRSID != "" { - if err := testutils.DeleteResourceServer(ts.scopedRSID); err != nil { - ts.T().Logf("teardown: delete scoped resource server: %v", err) + if err := testutils.DeleteResourceServerWithChildren(ts.scopedRSID); err != nil { + ts.T().Errorf("teardown: delete scoped resource server: %v", err) } } if ts.groupMgrRoleID != "" { diff --git a/tests/integration/ou/ou_authz_test.go b/tests/integration/ou/ou_authz_test.go index c86722e68f..5a551dbddd 100644 --- a/tests/integration/ou/ou_authz_test.go +++ b/tests/integration/ou/ou_authz_test.go @@ -202,8 +202,8 @@ func (ts *OUAuthzTestSuite) TearDownSuite() { } } if ts.scopedRSID != "" { - if err := testutils.DeleteResourceServer(ts.scopedRSID); err != nil { - ts.T().Logf("teardown: delete scoped resource server: %v", err) + if err := testutils.DeleteResourceServerWithChildren(ts.scopedRSID); err != nil { + ts.T().Errorf("teardown: delete scoped resource server: %v", err) } } if authzEntityTypeID != "" { diff --git a/tests/integration/testutils/api_utils.go b/tests/integration/testutils/api_utils.go index db91263fe6..ba625e6d72 100644 --- a/tests/integration/testutils/api_utils.go +++ b/tests/integration/testutils/api_utils.go @@ -12,6 +12,7 @@ import ( "io" "net/http" "net/url" + "reflect" "strings" "sync" "time" @@ -135,12 +136,173 @@ func CreateAgentType(schema UserType) (string, error) { return existingID, nil } -// DeleteAgentType is a no-op. The server rejects agent type deletion (USRS-1015) — see -// CreateAgentType for how suites share the singleton `default` schema. -func DeleteAgentType(_ string) error { +// AgentTypeSnapshot captures the singleton `default` agent type so a suite that mutates it can put +// it back. It holds every mutable field, not just the schema: an entity-type update replaces the +// whole record, so any field left out of the restore payload is silently reset to its zero value. +// The schema in particular cannot come from the list endpoint, which omits it. +type AgentTypeSnapshot struct { + ID string + OUID string + AllowSelfRegistration bool + SystemAttributes map[string]interface{} + Schema map[string]interface{} +} + +// SnapshotAgentType reads the current `default` agent type from the detail endpoint. Suites that +// call CreateAgentType mutate a singleton every other suite shares, so they must snapshot before +// and RestoreAgentType after, otherwise the schema and OU they installed leak into later packages. +func SnapshotAgentType() (*AgentTypeSnapshot, error) { + id, err := findDefaultAgentTypeID() + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", fmt.Sprintf("%s/agent-types/%s", TestServerURL, id), nil) + if err != nil { + return nil, fmt.Errorf("failed to create agent type request: %w", err) + } + + resp, err := GetHTTPClient().Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch agent type: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read agent type response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("expected status 200 fetching agent type, got %d: %s", + resp.StatusCode, string(body)) + } + + var detail struct { + ID string `json:"id"` + OUID string `json:"ouId"` + AllowSelfRegistration bool `json:"allowSelfRegistration"` + SystemAttributes map[string]interface{} `json:"systemAttributes"` + Schema map[string]interface{} `json:"schema"` + } + if err := json.Unmarshal(body, &detail); err != nil { + return nil, fmt.Errorf("failed to parse agent type: %w", err) + } + + return &AgentTypeSnapshot{ + ID: detail.ID, + OUID: detail.OUID, + AllowSelfRegistration: detail.AllowSelfRegistration, + SystemAttributes: detail.SystemAttributes, + Schema: detail.Schema, + }, nil +} + +// RestoreAgentType puts a snapshot back, then re-reads the type to confirm its OU resolves. If the +// snapshot's OU no longer exists, because a suite pointed the singleton at an OU it then deleted, +// the type is restored against the bootstrap `default` OU instead so the PUT cannot fail on a +// dangling reference. +func RestoreAgentType(snapshot *AgentTypeSnapshot) error { + if snapshot == nil { + return errors.New("agent type snapshot is nil") + } + + ouID := snapshot.OUID + if _, err := GetOrganizationUnit(ouID); err != nil { + bootstrapOUID, lookupErr := findBootstrapOUID() + if lookupErr != nil { + return fmt.Errorf("snapshot OU %s is gone and the bootstrap OU is unavailable: %w", + ouID, lookupErr) + } + ouID = bootstrapOUID + } + + // Build the payload by hand rather than through UserType: its AllowSelfRegistration carries + // `omitempty`, so a snapshotted `false` would be dropped, and it has no SystemAttributes field at + // all. Either omission makes the server reset that field instead of restoring it. + payload := map[string]interface{}{ + "name": "default", + "ouId": ouID, + "allowSelfRegistration": snapshot.AllowSelfRegistration, + "schema": snapshot.Schema, + } + if snapshot.SystemAttributes != nil { + payload["systemAttributes"] = snapshot.SystemAttributes + } + + if err := putAgentTypeRaw(snapshot.ID, payload); err != nil { + return err + } + + // Confirm the PUT actually applied. A 200 alone does not prove it: a partial or ignored update + // would leave the calling suite's state installed and still look successful here. + restored, err := SnapshotAgentType() + if err != nil { + return fmt.Errorf("failed to re-read the agent type after restoring it: %w", err) + } + if restored.ID != snapshot.ID { + return fmt.Errorf("restored agent type has id %s, want %s", restored.ID, snapshot.ID) + } + if restored.OUID != ouID { + return fmt.Errorf("restored agent type has ouId %s, want %s", restored.OUID, ouID) + } + if restored.AllowSelfRegistration != snapshot.AllowSelfRegistration { + return fmt.Errorf("restored agent type has allowSelfRegistration %t, want %t", + restored.AllowSelfRegistration, snapshot.AllowSelfRegistration) + } + if !reflect.DeepEqual(restored.SystemAttributes, snapshot.SystemAttributes) { + return fmt.Errorf("restored agent type systemAttributes do not match the snapshot: got %v, want %v", + restored.SystemAttributes, snapshot.SystemAttributes) + } + if !reflect.DeepEqual(restored.Schema, snapshot.Schema) { + return fmt.Errorf("restored agent type schema does not match the snapshot: got %v, want %v", + restored.Schema, snapshot.Schema) + } + if _, err := GetOrganizationUnit(restored.OUID); err != nil { + return fmt.Errorf("restored agent type points at unresolvable OU %s: %w", restored.OUID, err) + } return nil } +// findBootstrapOUID resolves the long-lived `default` organization unit seeded at bootstrap. +func findBootstrapOUID() (string, error) { + req, err := http.NewRequest("GET", TestServerURL+"/organization-units?limit=100", nil) + if err != nil { + return "", fmt.Errorf("failed to create OU list request: %w", err) + } + + resp, err := GetHTTPClient().Do(req) + if err != nil { + return "", fmt.Errorf("failed to list organization units: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read OU list response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("expected status 200 listing organization units, got %d: %s", + resp.StatusCode, string(body)) + } + + var list struct { + OrganizationUnits []struct { + ID string `json:"id"` + Handle string `json:"handle"` + } `json:"organizationUnits"` + } + if err := json.Unmarshal(body, &list); err != nil { + return "", fmt.Errorf("failed to parse OU list: %w", err) + } + + for _, ou := range list.OrganizationUnits { + if ou.Handle == "default" { + return ou.ID, nil + } + } + return "", errors.New("bootstrap organization unit with handle 'default' not found") +} + var errAgentTypeNameConflict = errors.New("agent type name conflict") func postAgentType(schema UserType) (string, error) { @@ -185,7 +347,13 @@ func postAgentType(schema UserType) (string, error) { } func putAgentType(schemaID string, schema UserType) error { - payload, err := json.Marshal(schema) + return putAgentTypeRaw(schemaID, schema) +} + +// putAgentTypeRaw updates an agent type from an arbitrary payload, so a caller can send fields that +// the UserType struct drops or does not model. +func putAgentTypeRaw(schemaID string, body interface{}) error { + payload, err := json.Marshal(body) if err != nil { return fmt.Errorf("failed to marshal agent type: %w", err) } @@ -1329,9 +1497,9 @@ func CreateResourceServerWithActions(rs ResourceServer, actions []Action) (strin for i, action := range actions { _, err := createAction(rsID, action) if err != nil { - // Cleanup: delete the resource server on failure - DeleteResourceServer(rsID) - return "", fmt.Errorf("failed to create action %d: %w", i, err) + // Roll back through the child-aware delete: any action created before this one blocks a + // plain resource-server delete, so a partially built server would otherwise survive. + return "", rollbackResourceServer(rsID, fmt.Errorf("failed to create action %d: %w", i, err)) } } @@ -1455,6 +1623,9 @@ func GetResourceServerByName(name string) (string, error) { return "", fmt.Errorf("resource server with name %q not found", name) } +// DeleteResourceServer deletes a resource server that owns no resources or actions. The server +// refuses the delete with RES-1006 while any dependency remains, so a server built with resources or +// actions must go through DeleteResourceServerWithChildren instead. func DeleteResourceServer(rsID string) error { client := GetHTTPClient() @@ -1785,7 +1956,8 @@ func createActionUnderResource(resourceServerID, resourceID string, action Actio // permissions still enforce when configured. It builds a "system" root resource, then one child // resource per handle (each with a "view" action), yielding the permissions "system", // "system:" and "system::view". Returns the resource server ID; delete it with -// DeleteResourceServer during teardown. +// DeleteResourceServerWithChildren during teardown — a plain DeleteResourceServer is refused with +// RES-1006 while the resources and actions built here still exist. func CreateSystemScopedResourceServer(ouID, name, identifier string, childHandles ...string) (string, error) { rsID, err := createResourceServer(ResourceServer{ Name: name, @@ -1817,12 +1989,187 @@ func CreateSystemScopedResourceServer(ouID, name, identifier string, childHandle // rollbackResourceServer deletes a partially built resource server after a setup step failed. It // returns the original cause, wrapping any cleanup failure so neither error is silently discarded. func rollbackResourceServer(rsID string, cause error) error { - if delErr := DeleteResourceServer(rsID); delErr != nil { + if delErr := DeleteResourceServerWithChildren(rsID); delErr != nil { return fmt.Errorf("%w (resource server cleanup also failed: %v)", cause, delErr) } return cause } +// DeleteResourceServerWithChildren removes a resource server together with its resource tree and +// every action it owns. A plain DELETE on a resource server that still has dependencies is refused +// with RES-1006, so any server built by CreateSystemScopedResourceServer or +// CreateResourceServerWithActions must be torn down through here or it survives in the shared +// database. +// +// Actions live at two levels and both block deletion: CreateSystemScopedResourceServer attaches them +// to resources, while CreateResourceServerWithActions attaches them directly to the server. +func DeleteResourceServerWithChildren(rsID string) error { + // Collect the tree depth-first so children are always deleted before their parents. The list + // endpoint returns only one level: without a parentId it yields the top-level resources, so the + // nested ones are invisible unless each level is walked explicitly. + ordered, err := collectResourceIDsDeepestFirst(rsID, "") + if err != nil { + return err + } + + for _, resourceID := range ordered { + actions, actionErr := ListActionIDsAtResource(rsID, resourceID) + if actionErr != nil { + return actionErr + } + for _, actionID := range actions { + if delErr := deleteActionAtResource(rsID, resourceID, actionID); delErr != nil { + return delErr + } + } + if delErr := deleteResource(rsID, resourceID); delErr != nil { + return fmt.Errorf("failed to delete resource %s of resource server %s: %w", + resourceID, rsID, delErr) + } + } + + serverActions, err := ListActionIDsAtResourceServer(rsID) + if err != nil { + return err + } + for _, actionID := range serverActions { + if delErr := DeleteAction(rsID, actionID); delErr != nil { + return fmt.Errorf("failed to delete action %s of resource server %s: %w", + actionID, rsID, delErr) + } + } + + return DeleteResourceServer(rsID) +} + +// ListActionIDsAtResourceServer returns the IDs of actions attached directly to a resource server, +// as opposed to those attached to one of its resources. +func ListActionIDsAtResourceServer(rsID string) ([]string, error) { + body, err := getJSON(fmt.Sprintf("%s/resource-servers/%s/actions?limit=100", TestServerURL, rsID)) + if err != nil { + return nil, fmt.Errorf("failed to list actions of resource server %s: %w", rsID, err) + } + + var list struct { + Actions []struct { + ID string `json:"id"` + } `json:"actions"` + } + if err := json.Unmarshal(body, &list); err != nil { + return nil, fmt.Errorf("failed to parse action list: %w", err) + } + + ids := make([]string, 0, len(list.Actions)) + for _, action := range list.Actions { + ids = append(ids, action.ID) + } + return ids, nil +} + +// collectResourceIDsDeepestFirst returns the resource subtree under parentID, children ahead of +// their parents. +func collectResourceIDsDeepestFirst(rsID, parentID string) ([]string, error) { + children, err := ListResourceIDs(rsID, parentID) + if err != nil { + return nil, err + } + + ordered := make([]string, 0, len(children)) + for _, child := range children { + descendants, descErr := collectResourceIDsDeepestFirst(rsID, child) + if descErr != nil { + return nil, descErr + } + ordered = append(ordered, descendants...) + ordered = append(ordered, child) + } + return ordered, nil +} + +// ListResourceIDs returns the IDs of resources directly under parentID. An empty parentID lists the +// top-level resources, which requires omitting the query parameter entirely: sending `parentId=` +// makes the server look up a resource whose id is the empty string and answer 404. +func ListResourceIDs(rsID, parentID string) ([]string, error) { + requestURL := fmt.Sprintf("%s/resource-servers/%s/resources?limit=100", TestServerURL, rsID) + if parentID != "" { + requestURL += "&parentId=" + url.QueryEscape(parentID) + } + + body, err := getJSON(requestURL) + if err != nil { + return nil, fmt.Errorf("failed to list resources of resource server %s: %w", rsID, err) + } + + var list struct { + Resources []struct { + ID string `json:"id"` + } `json:"resources"` + } + if err := json.Unmarshal(body, &list); err != nil { + return nil, fmt.Errorf("failed to parse resource list: %w", err) + } + + ids := make([]string, 0, len(list.Resources)) + for _, resource := range list.Resources { + ids = append(ids, resource.ID) + } + return ids, nil +} + +// ListActionIDsAtResource returns the IDs of actions defined directly on a resource. +func ListActionIDsAtResource(rsID, resourceID string) ([]string, error) { + body, err := getJSON(fmt.Sprintf("%s/resource-servers/%s/resources/%s/actions?limit=100", + TestServerURL, rsID, resourceID)) + if err != nil { + return nil, fmt.Errorf("failed to list actions of resource %s: %w", resourceID, err) + } + + var list struct { + Actions []struct { + ID string `json:"id"` + } `json:"actions"` + } + if err := json.Unmarshal(body, &list); err != nil { + return nil, fmt.Errorf("failed to parse action list: %w", err) + } + + ids := make([]string, 0, len(list.Actions)) + for _, action := range list.Actions { + ids = append(ids, action.ID) + } + return ids, nil +} + +func deleteActionAtResource(rsID, resourceID, actionID string) error { + return deleteURL(fmt.Sprintf("%s/resource-servers/%s/resources/%s/actions/%s", + TestServerURL, rsID, resourceID, actionID)) +} + +func deleteResource(rsID, resourceID string) error { + return deleteURL(fmt.Sprintf("%s/resource-servers/%s/resources/%s", + TestServerURL, rsID, resourceID)) +} + +// deleteURL issues a DELETE and requires a 204. +func deleteURL(url string) error { + req, err := http.NewRequest("DELETE", url, nil) + if err != nil { + return fmt.Errorf("failed to create delete request: %w", err) + } + + resp, err := GetHTTPClient().Do(req) + if err != nil { + return fmt.Errorf("failed to send delete request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("expected status 204, got %d. Response: %s", resp.StatusCode, string(body)) + } + return nil +} + // CreateFlow creates a flow via API and returns the flow ID func CreateFlow(flowDefinition Flow) (string, error) { flowJSON, err := json.Marshal(flowDefinition) diff --git a/tests/integration/user/user_authz_test.go b/tests/integration/user/user_authz_test.go index cafbc11710..4ab1f08f0e 100644 --- a/tests/integration/user/user_authz_test.go +++ b/tests/integration/user/user_authz_test.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "net/http" + "net/url" "testing" "github.com/stretchr/testify/suite" @@ -214,8 +215,11 @@ func (ts *UserAuthzTestSuite) TearDownSuite() { } } if ts.scopedRSID != "" { - if err := testutils.DeleteResourceServer(ts.scopedRSID); err != nil { - ts.T().Logf("teardown: delete scoped resource server: %v", err) + // The scoped resource server owns a nested resource tree, and a plain delete is refused with + // RES-1006 while those resources exist. Logging that failure left the tree behind in the + // shared database on every run. + if err := testutils.DeleteResourceServerWithChildren(ts.scopedRSID); err != nil { + ts.T().Errorf("teardown: delete scoped resource server: %v", err) } } for _, id := range []string{ts.targetUserOU1ID, ts.deletableUserOU1ID, ts.userMgrUserID} { @@ -424,3 +428,71 @@ func (ts *UserAuthzTestSuite) TestDeleteUserInOtherOU() { ts.Equal(http.StatusForbidden, resp.StatusCode, "user-manager must not delete a user in a different OU") } + +// TestCreateUserByPathRequiresRootPermission pins the authorization boundary of the by-path create +// route, which differs from the direct create above. +// +// `POST /users` is gated by `system:user`, so the user-manager can create in their own OU +// (TestCreateUserInOwnOU). `POST /users/tree/{path...}` has **no entry** in the API permission table +// (`system/security/permissions.go:244-249` lists GET, PUT and DELETE under `/users/**` but no +// POST), and unmatched routes fall back to the **root** system permission +// (`security/service.go:159-166`). The user-manager is therefore refused on this route for their own +// OU as well as for OU2 — the refusal is the root-permission gate, not OU scoping. +// +// Both OUs are asserted deliberately. Testing only OU2 would look like a subtree-scoping test and +// pass for the wrong reason, since the same caller is refused inside their own subtree. +func (ts *UserAuthzTestSuite) TestCreateUserByPathRequiresRootPermission() { + for _, tc := range []struct { + name string + ouHandle string + typeName string + username string + }{ + {name: "own OU", ouHandle: userAuthzOU1Handle, typeName: entityTypeOU1Name, + username: "authz-bypath-own-ou"}, + {name: "other OU", ouHandle: userAuthzOU2Handle, typeName: entityTypeOU2Name, + username: "authz-bypath-other-ou"}, + } { + ts.Run(tc.name, func() { + payload, err := json.Marshal(map[string]interface{}{ + "type": tc.typeName, + "attributes": map[string]interface{}{"username": tc.username}, + }) + ts.Require().NoError(err) + + resp := ts.doUser(http.MethodPost, "/users/tree/"+tc.ouHandle, payload) + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + ts.Require().NoError(err) + ts.Require().Equal(http.StatusForbidden, resp.StatusCode, "error body: %s", string(body)) + + var errResp testutils.ErrorResponse + ts.Require().NoError(json.Unmarshal(body, &errResp), "error body: %s", string(body)) + ts.Equal("AUTH-4030", errResp.Code, + "refusal must come from the route permission gate, not from OU scoping") + + ts.Equal(0, ts.countUsersByUsername(tc.username), + "a refused by-path create must not persist a user") + }) + } +} + +// countUsersByUsername counts users with the given username using the unrestricted admin client, so +// the check is not itself subject to the scoped caller's visibility. +func (ts *UserAuthzTestSuite) countUsersByUsername(username string) int { + ts.T().Helper() + + req, err := http.NewRequest(http.MethodGet, + userAuthzServerURL+"/users?filter="+url.QueryEscape(`username eq "`+username+`"`), nil) + ts.Require().NoError(err) + + resp, err := testutils.GetHTTPClient().Do(req) + ts.Require().NoError(err) + defer resp.Body.Close() + ts.Require().Equal(http.StatusOK, resp.StatusCode) + + var listResp testutils.UserListResponse + ts.Require().NoError(json.NewDecoder(resp.Body).Decode(&listResp)) + return listResp.TotalResults +} diff --git a/tests/integration/user/user_self_api_test.go b/tests/integration/user/user_self_api_test.go index 8183f5f948..89d90cdbf6 100644 --- a/tests/integration/user/user_self_api_test.go +++ b/tests/integration/user/user_self_api_test.go @@ -11,8 +11,8 @@ import ( "net/http" "testing" - "github.com/thunder-id/thunderid/tests/integration/testutils" "github.com/stretchr/testify/suite" + "github.com/thunder-id/thunderid/tests/integration/testutils" ) type SelfUserEndpointsSuite struct { @@ -66,9 +66,9 @@ func (s *SelfUserEndpointsSuite) SetupSuite() { s.Require().NoError(err) userID, err := testutils.CreateUser(testutils.User{ - OUID: ouID, - Type: s.userType, - Attributes: attrs, + OUID: ouID, + Type: s.userType, + Attributes: attrs, }) s.Require().NoError(err) s.userID = userID @@ -205,3 +205,119 @@ func (s *SelfUserEndpointsSuite) TestSelfUserGetMetadata() { s.Require().NotNil(schema["email"]) } +// selfProfileAttributes reads the caller's own attributes, so a rejected update can be checked +// against stored state rather than only against the error response. +func (s *SelfUserEndpointsSuite) selfProfileAttributes() map[string]interface{} { + s.T().Helper() + + resp, err := s.doUserRequest(http.MethodGet, "/users/me", nil) + s.Require().NoError(err) + defer resp.Body.Close() + s.Require().Equal(http.StatusOK, resp.StatusCode) + + var userResp testutils.User + s.Require().NoError(json.NewDecoder(resp.Body).Decode(&userResp)) + + var attrs map[string]interface{} + s.Require().NoError(json.Unmarshal(userResp.Attributes, &attrs)) + return attrs +} + +// requireSelfError asserts a self-service response carries the exact status and product error code. +func (s *SelfUserEndpointsSuite) requireSelfError(resp *http.Response, status int, code string) { + s.T().Helper() + + body, err := io.ReadAll(resp.Body) + s.Require().NoError(err) + s.Require().Equal(status, resp.StatusCode, "error body: %s", string(body)) + + var errResp struct { + Code string `json:"code"` + } + s.Require().NoError(json.Unmarshal(body, &errResp), "error body: %s", string(body)) + s.Equal(code, errResp.Code, "error body: %s", string(body)) +} + +// TestSelfUserUpdateProfileEmptyBodyRejected verifies that a self-update carrying no attributes is +// refused rather than treated as a no-op, and that the profile is untouched. +func (s *SelfUserEndpointsSuite) TestSelfUserUpdateProfileEmptyBodyRejected() { + before := s.selfProfileAttributes() + + resp, err := s.doUserRequest(http.MethodPut, "/users/me", map[string]interface{}{}) + s.Require().NoError(err) + defer resp.Body.Close() + + s.requireSelfError(resp, http.StatusBadRequest, "USR-1001") + + s.Equal(before, s.selfProfileAttributes(), "a rejected update must leave the profile unchanged") +} + +// TestSelfUserUpdateProfileEmptyAttributeObjectRejected verifies that an explicitly empty attribute +// object is refused by schema validation rather than accepted as a wipe. This is a different +// rejection from the empty body above: `{}` leaves Attributes unset and is caught in the handler +// (USR-1001), whereas `{"attributes": {}}` is a present-but-empty object that reaches the service +// and fails the schema's required fields (USR-1019). Both must leave the profile intact. +func (s *SelfUserEndpointsSuite) TestSelfUserUpdateProfileEmptyAttributeObjectRejected() { + before := s.selfProfileAttributes() + + resp, err := s.doUserRequest(http.MethodPut, "/users/me", map[string]interface{}{ + "attributes": map[string]interface{}{}, + }) + s.Require().NoError(err) + defer resp.Body.Close() + + s.requireSelfError(resp, http.StatusBadRequest, "USR-1019") + + s.Equal(before, s.selfProfileAttributes(), + "a rejected empty-attribute update must not clear the stored attributes") +} + +// TestSelfUserUpdateProfileSchemaInvalidAttributeRejected verifies that a self-update is validated +// against the user type schema: email is declared as a string, so a number is refused and the +// stored value survives. +// +// The payload carries the current valid username alongside the bad email. Sending the email alone +// would omit a required attribute — this endpoint replaces the whole attribute set — and the missing +// username produces USR-1019 on its own, so the test could pass without the numeric email ever +// being type-checked. +func (s *SelfUserEndpointsSuite) TestSelfUserUpdateProfileSchemaInvalidAttributeRejected() { + before := s.selfProfileAttributes() + + resp, err := s.doUserRequest(http.MethodPut, "/users/me", map[string]interface{}{ + "attributes": map[string]interface{}{ + "username": s.username, + "email": 12345, + }, + }) + s.Require().NoError(err) + defer resp.Body.Close() + + s.requireSelfError(resp, http.StatusBadRequest, "USR-1019") + + s.Equal(before, s.selfProfileAttributes(), + "a rejected update must leave the whole profile unchanged") +} + +// TestSelfUserUpdateCredentialsMissingAttributesRejected verifies that a credential update with an +// empty attribute set is refused as missing credentials, and that the existing password still +// authenticates afterwards. The endpoint reports this distinctly from a malformed body: an empty +// object is a well-formed request that names no credential to change. +func (s *SelfUserEndpointsSuite) TestSelfUserUpdateCredentialsMissingAttributesRejected() { + resp, err := s.doUserRequest(http.MethodPost, "/users/me/update-credentials", + map[string]interface{}{"attributes": map[string]interface{}{}}) + s.Require().NoError(err) + defer resp.Body.Close() + + s.requireSelfError(resp, http.StatusBadRequest, "USR-1017") + + // The rejected call must not have rotated or cleared the password. + client, err := testutils.GetHTTPClientForUser(s.username, s.password) + s.Require().NoError(err, "the existing password must still authenticate") + + req, err := http.NewRequest(http.MethodGet, testutils.TestServerURL+"/users/me", nil) + s.Require().NoError(err) + verifyResp, err := client.Do(req) + s.Require().NoError(err) + defer verifyResp.Body.Close() + s.Equal(http.StatusOK, verifyResp.StatusCode, "the existing password must still grant access") +} diff --git a/tests/integration/user/user_tree_api_test.go b/tests/integration/user/user_tree_api_test.go index dacb304e70..f194797ae6 100644 --- a/tests/integration/user/user_tree_api_test.go +++ b/tests/integration/user/user_tree_api_test.go @@ -8,10 +8,11 @@ import ( "encoding/json" "io" "net/http" + "net/url" "testing" - "github.com/thunder-id/thunderid/tests/integration/testutils" "github.com/stretchr/testify/suite" + "github.com/thunder-id/thunderid/tests/integration/testutils" ) var ( @@ -233,3 +234,115 @@ func (suite *UserTreeAPITestSuite) TestGetUsersByPathWithPagination() { suite.Equal(userListResponse.StartIndex, 1) suite.LessOrEqual(userListResponse.Count, 5) } + +// doTree issues a request against the tree routes and returns the response. +func (suite *UserTreeAPITestSuite) doTree(method, path string, body io.Reader) *http.Response { + suite.T().Helper() + + req, err := http.NewRequest(method, testServerURL+path, body) + suite.Require().NoError(err) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := testutils.GetHTTPClient().Do(req) + suite.Require().NoError(err) + return resp +} + +// requireTreeError asserts a tree response carries the exact status and product error code. +func (suite *UserTreeAPITestSuite) requireTreeError(resp *http.Response, status int, code string) { + suite.T().Helper() + + body, err := io.ReadAll(resp.Body) + suite.Require().NoError(err) + suite.Require().Equal(status, resp.StatusCode, "error body: %s", string(body)) + + var errorResp testutils.ErrorResponse + suite.Require().NoError(json.Unmarshal(body, &errorResp), "error body: %s", string(body)) + suite.Equal(code, errorResp.Code, "error body: %s", string(body)) +} + +// TestTreePathAndPaginationRejections verifies that a malformed handle path and out-of-range +// pagination parameters are each refused with their own exact status and product error code, rather +// than being clamped or resolved to some default subtree. +func (suite *UserTreeAPITestSuite) TestTreePathAndPaginationRejections() { + cases := []struct { + name string + path string + code string + }{ + // Scenario 58: a path made only of whitespace is not a handle. Repeated-slash paths are + // deliberately not tested — http.NewServeMux canonicalizes them before dispatch, so they + // never reach the validator. + {name: "whitespace only path", path: "/users/tree/%20", code: "USR-1009"}, + {name: "whitespace path segments", path: "/users/tree/%20%20/%20", code: "USR-1009"}, + + // Scenario 59: limit must be a positive integer no greater than MaxPageSize (100). + {name: "limit of zero", path: "/users/tree/" + pathTestOU.Handle + "?limit=0", code: "USR-1011"}, + {name: "limit above the maximum", path: "/users/tree/" + pathTestOU.Handle + "?limit=101", code: "USR-1011"}, + {name: "limit not a number", path: "/users/tree/" + pathTestOU.Handle + "?limit=abc", code: "USR-1011"}, + + // Scenario 60: offset must be a non-negative integer, and carries its own code. + {name: "negative offset", path: "/users/tree/" + pathTestOU.Handle + "?offset=-1", code: "USR-1012"}, + {name: "offset not a number", path: "/users/tree/" + pathTestOU.Handle + "?offset=abc", code: "USR-1012"}, + } + + for _, tc := range cases { + suite.Run(tc.name, func() { + resp := suite.doTree(http.MethodGet, tc.path, nil) + defer func() { _ = resp.Body.Close() }() + + suite.requireTreeError(resp, http.StatusBadRequest, tc.code) + }) + } +} + +// TestGetUsersByPathAtMaximumLimitAccepted is the control for the limit rejections above: the value +// at the top of the accepted range succeeds. Without it, "limit=101 is rejected" would also be +// satisfied by a server that rejected every limit. +func (suite *UserTreeAPITestSuite) TestGetUsersByPathAtMaximumLimitAccepted() { + resp := suite.doTree(http.MethodGet, "/users/tree/"+pathTestOU.Handle+"?limit=100", nil) + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + suite.Require().NoError(err) + suite.Equal(http.StatusOK, resp.StatusCode, "response body: %s", string(body)) +} + +// TestCreateUserByPathNonExistentOURejected verifies that a by-path create naming an unknown +// organization unit is refused and creates nothing. The handle path is the only place the target OU +// comes from on this route, so an unresolvable path must not fall back to a default OU. +func (suite *UserTreeAPITestSuite) TestCreateUserByPathNonExistentOURejected() { + const username = "tree-orphan-user" + + payload, err := json.Marshal(CreateUserByPathRequest{ + Type: "employee", + Attributes: json.RawMessage( + `{"username":"` + username + `","email":"tree-orphan@example.com"}`), + }) + suite.Require().NoError(err) + + resp := suite.doTree(http.MethodPost, "/users/tree/no-such-ou-handle", bytes.NewReader(payload)) + defer func() { _ = resp.Body.Close() }() + + suite.requireTreeError(resp, http.StatusNotFound, "USR-1005") + + suite.Equal(0, suite.countUsersByUsername(username), + "a rejected by-path create must not persist a user anywhere") +} + +// countUsersByUsername returns how many users carry the given username. It filters server-side +// rather than scanning a page of results, so absence cannot be reported merely by paging past a row. +func (suite *UserTreeAPITestSuite) countUsersByUsername(username string) int { + suite.T().Helper() + + resp := suite.doTree(http.MethodGet, + "/users?filter="+url.QueryEscape(`username eq "`+username+`"`), nil) + defer func() { _ = resp.Body.Close() }() + suite.Require().Equal(http.StatusOK, resp.StatusCode) + + var listResp testutils.UserListResponse + suite.Require().NoError(json.NewDecoder(resp.Body).Decode(&listResp)) + return listResp.TotalResults +} diff --git a/tests/integration/usertype/usertype_authz_test.go b/tests/integration/usertype/usertype_authz_test.go index 36aa485d67..a9e8dbf7db 100644 --- a/tests/integration/usertype/usertype_authz_test.go +++ b/tests/integration/usertype/usertype_authz_test.go @@ -221,8 +221,8 @@ func (ts *UserTypeAuthzTestSuite) TearDownSuite() { } } if ts.scopedRSID != "" { - if err := testutils.DeleteResourceServer(ts.scopedRSID); err != nil { - ts.T().Logf("teardown: delete scoped resource server: %v", err) + if err := testutils.DeleteResourceServerWithChildren(ts.scopedRSID); err != nil { + ts.T().Errorf("teardown: delete scoped resource server: %v", err) } } // Delete the test user.