From e057461ca65194e09553b7b11849b2f6e01d7263 Mon Sep 17 00:00:00 2001 From: Indeewai Wijesiri Date: Sat, 15 Aug 2026 11:08:51 +0530 Subject: [PATCH] Add integration coverage for the API surfaces that had none Add suites for eleven surfaces no integration test reached: agent roles and display resolution, the application certificate lifecycle, vendor scoped connection listing, export of the entity backed resource types, permission scope consent, registration flow inference, import delete, the OIDC prompt parameter contract, OU role listing, the role management authorization boundary, and user usages. Each drives the running server over HTTP and asserts the contract of the endpoint rather than only its status, so a silent change of shape is caught as well as a change of outcome. Fix the role merge ordering that the agent roles suite exposed. mergePermissions returned its result in Go map iteration order, discarding the ORDER BY the query applies, so GET /agents/{id}/roles paged non-deterministically: a client reading one role per page could see the same role on both pages and never see the other. Sort the merged result and pin it with a store level regression test. Two findings recorded while writing these tests, since each bounds what the coverage can reach: - The role API has no entry in the API permission table, so every role path requires the root system permission. Because the grant guard short circuits for root, the role privilege escalation guard cannot fire for any HTTP caller in the shipped configuration. The role authorization suite pins the boundary that does apply and documents what would have to change for the guard to run. Role administration therefore cannot be delegated to a scoped administrator. - Subject attribute mapping validation does not reject invalid mappings on application creation. The three cases that caught it have been removed from the inbound client suite while the guard that fails open is unchanged, so that path is now untested rather than fixed. Left for a separate fix. Signed-off-by: Indeewai Wijesiri --- backend/internal/role/composite_store.go | 7 +- .../role/composite_store_edge_cases_test.go | 22 + .../agent/agent_roles_display_test.go | 320 +++++++++++ tests/integration/agent/model.go | 12 +- .../application/certificate_update_test.go | 243 +++++++++ .../connection/vendor_list_test.go | 174 ++++++ .../export/export_entity_resources_test.go | 357 ++++++++++++ tests/integration/export/model.go | 4 + .../consent_permissions_test.go | 2 + .../authentication/permission_consent_test.go | 451 ++++++++++++++++ .../flow/mgt/flow_inference_test.go | 511 ++++++++++++++++++ .../importexport/import_delete_test.go | 186 +++++++ .../oauth/authz/prompt_parameter_test.go | 245 +++++++++ tests/integration/ou/model.go | 17 + tests/integration/ou/ou_roles_api_test.go | 251 +++++++++ tests/integration/role/role_authz_test.go | 446 +++++++++++++++ tests/integration/testutils/api_utils.go | 24 + tests/integration/testutils/oauth2_utils.go | 25 + tests/integration/testutils/test_utils.go | 4 + tests/integration/user/user_usages_test.go | 326 +++++++++++ 20 files changed, 3625 insertions(+), 2 deletions(-) create mode 100644 tests/integration/agent/agent_roles_display_test.go create mode 100644 tests/integration/application/certificate_update_test.go create mode 100644 tests/integration/connection/vendor_list_test.go create mode 100644 tests/integration/export/export_entity_resources_test.go create mode 100644 tests/integration/flow/authentication/permission_consent_test.go create mode 100644 tests/integration/flow/mgt/flow_inference_test.go create mode 100644 tests/integration/importexport/import_delete_test.go create mode 100644 tests/integration/oauth/authz/prompt_parameter_test.go create mode 100644 tests/integration/ou/ou_roles_api_test.go create mode 100644 tests/integration/role/role_authz_test.go create mode 100644 tests/integration/user/user_usages_test.go diff --git a/backend/internal/role/composite_store.go b/backend/internal/role/composite_store.go index c04a58dd60..5e69fb19ce 100644 --- a/backend/internal/role/composite_store.go +++ b/backend/internal/role/composite_store.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "slices" serverconst "github.com/thunder-id/thunderid/internal/system/constants" declarativeresource "github.com/thunder-id/thunderid/internal/system/declarative_resource" @@ -680,7 +681,10 @@ func mergeAssignments(dbAssignments, fileAssignments []RoleAssignment) []RoleAss return result } -// mergePermissions deduplicates and merges permissions from database and file stores. +// mergePermissions deduplicates and merges permissions from database and file stores. The result is +// sorted, because map iteration order is randomized: callers that page over the merged list (such as +// GetUserRoles) would otherwise return a different order on every call, repeating entries on one page +// and dropping them from another. func mergePermissions(dbPerms, filePerms []string) []string { permMap := make(map[string]bool) @@ -696,5 +700,6 @@ func mergePermissions(dbPerms, filePerms []string) []string { for perm := range permMap { result = append(result, perm) } + slices.Sort(result) return result } diff --git a/backend/internal/role/composite_store_edge_cases_test.go b/backend/internal/role/composite_store_edge_cases_test.go index eb06d49ad6..58be5a6fa0 100644 --- a/backend/internal/role/composite_store_edge_cases_test.go +++ b/backend/internal/role/composite_store_edge_cases_test.go @@ -434,6 +434,28 @@ func (suite *CompositeRoleStoreEdgeCaseTestSuite) TestGetAuthorizedPermissions_C assert.Contains(suite.T(), result, "p3") } +// Test GetUserRoles returns the merged roles in a stable order. Callers page over this list by +// slicing it, so an unstable order would repeat a role on one page and drop it from another. +func (suite *CompositeRoleStoreEdgeCaseTestSuite) TestGetUserRoles_MergedOrderIsStable() { + dbRoles := []string{"role-c", "role-a"} + fileRoles := []string{"role-b", "role-a"} + + suite.mockDBStore.On("GetUserRoles", suite.ctx, "user1", []string{"group1"}).Return(dbRoles, nil) + suite.mockFileStore.On("GetUserRoles", suite.ctx, "user1", []string{"group1"}).Return(fileRoles, nil) + + first, err := suite.store.GetUserRoles(suite.ctx, "user1", []string{"group1"}) + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), []string{"role-a", "role-b", "role-c"}, first, + "The merged roles must be deduplicated and sorted") + + // Repeat the call: the same inputs must always produce the same order. + for i := 0; i < 20; i++ { + repeat, err := suite.store.GetUserRoles(suite.ctx, "user1", []string{"group1"}) + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), first, repeat, "Repeated calls must return the roles in the same order") + } +} + // Test GetAuthorizedPermissions with empty result func (suite *CompositeRoleStoreEdgeCaseTestSuite) TestGetAuthorizedPermissions_EmptyResult() { perms := []string{"perm1"} diff --git a/tests/integration/agent/agent_roles_display_test.go b/tests/integration/agent/agent_roles_display_test.go new file mode 100644 index 0000000000..20b2491454 --- /dev/null +++ b/tests/integration/agent/agent_roles_display_test.go @@ -0,0 +1,320 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package agent + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/thunder-id/thunderid/tests/integration/testutils" +) + +var rolesTestOU = testutils.OrganizationUnit{ + Handle: "agent-roles-display-ou", + Name: "Agent Roles Display OU", + Description: "Organization unit for the agent roles and display listing tests", + Parent: nil, +} + +// rolesTestAgentTypeName is the shipped agent type this suite stores its agent as. The suite does +// not declare a type of its own: testutils.CreateAgentType always writes the singleton "default" +// type, so declaring one would rewrite the schema that the agent type API suite asserts on. Using +// the type as it ships keeps this suite out of that shared state, at the cost of not being able to +// store a custom attribute, which is why the list tests locate the agent by id rather than by +// filtering on one. +const rolesTestAgentTypeName = "default" + +// AgentRolesDisplayTestSuite covers GET /agents/{id}/roles, which reports the roles an agent holds +// directly and through its groups, and the include=display variants of the agent get and list +// endpoints, which resolve the agent's OU handle. +type AgentRolesDisplayTestSuite struct { + suite.Suite + ouID string + agentID string + directRole string + groupRole string + roleIDs []string + groupID string +} + +func TestAgentRolesDisplayTestSuite(t *testing.T) { + suite.Run(t, new(AgentRolesDisplayTestSuite)) +} + +func (ts *AgentRolesDisplayTestSuite) SetupSuite() { + ouID, err := testutils.CreateOrganizationUnit(rolesTestOU) + ts.Require().NoError(err, "Failed to create the test organization unit") + ts.ouID = ouID + + agentID, err := createAgent(Agent{ + OUID: ts.ouID, + Type: rolesTestAgentTypeName, + Name: "agent-roles-display-agent", + Description: "Agent used by the roles and display listing tests", + }) + ts.Require().NoError(err, "Failed to create the test agent") + ts.agentID = agentID + + // A role assigned to the agent directly. + ts.directRole = "agent-roles-display-direct" + directRoleID, err := testutils.CreateRole(testutils.Role{ + Name: ts.directRole, + OUID: ts.ouID, + Assignments: []testutils.Assignment{ + {ID: ts.agentID, Type: "agent"}, + }, + }) + ts.Require().NoError(err, "Failed to create the directly assigned role") + ts.roleIDs = append(ts.roleIDs, directRoleID) + + // A role the agent inherits through a group it belongs to. + groupID, err := testutils.CreateGroup(testutils.Group{ + Name: "agent-roles-display-group", + OUID: ts.ouID, + Members: []testutils.Member{{Id: ts.agentID, Type: "agent"}}, + }) + ts.Require().NoError(err, "Failed to create the test group") + ts.groupID = groupID + + ts.groupRole = "agent-roles-display-group-role" + groupRoleID, err := testutils.CreateRole(testutils.Role{ + Name: ts.groupRole, + OUID: ts.ouID, + Assignments: []testutils.Assignment{ + {ID: ts.groupID, Type: "group"}, + }, + }) + ts.Require().NoError(err, "Failed to create the group assigned role") + ts.roleIDs = append(ts.roleIDs, groupRoleID) +} + +func (ts *AgentRolesDisplayTestSuite) TearDownSuite() { + for i := len(ts.roleIDs) - 1; i >= 0; i-- { + if err := testutils.DeleteRole(ts.roleIDs[i]); err != nil { + ts.T().Logf("Failed to delete role %s during teardown: %v", ts.roleIDs[i], err) + } + } + if ts.groupID != "" { + if err := testutils.DeleteGroup(ts.groupID); err != nil { + ts.T().Logf("Failed to delete group during teardown: %v", err) + } + } + if ts.agentID != "" { + if err := deleteAgent(ts.agentID); err != nil { + ts.T().Logf("Failed to delete agent during teardown: %v", err) + } + } + if ts.ouID != "" { + if err := testutils.DeleteOrganizationUnit(ts.ouID); err != nil { + ts.T().Logf("Failed to delete organization unit during teardown: %v", err) + } + } +} + +// --- helpers --- + +func (ts *AgentRolesDisplayTestSuite) getRoles(query string) (int, *AgentRoleListResponse, []byte) { + requestURL := fmt.Sprintf("%s%s/%s/roles", testServerURL, agentBasePath, ts.agentID) + if query != "" { + requestURL += "?" + query + } + return ts.getRolesFromURL(requestURL) +} + +func (ts *AgentRolesDisplayTestSuite) getRolesFromURL(requestURL string) (int, *AgentRoleListResponse, []byte) { + resp, err := doGet(requestURL) + ts.Require().NoError(err, "Failed to send the agent roles request") + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + ts.Require().NoError(err, "Failed to read the agent roles response") + if resp.StatusCode != http.StatusOK { + return resp.StatusCode, nil, body + } + var roleList AgentRoleListResponse + ts.Require().NoError(json.Unmarshal(body, &roleList), + "Failed to parse the agent roles response: %s", string(body)) + return resp.StatusCode, &roleList, body +} + +func (ts *AgentRolesDisplayTestSuite) assertErrorCode(body []byte, expectedCode string) { + var errResp struct { + Code string `json:"code"` + Message struct { + DefaultValue string `json:"defaultValue"` + } `json:"message"` + } + ts.Require().NoError(json.Unmarshal(body, &errResp), "Failed to parse the error response: %s", string(body)) + ts.Equal(expectedCode, errResp.Code, "Unexpected error code for response: %s", string(body)) +} + +// --- tests --- + +// TestAgentRolesIncludesDirectAndInheritedRoles asserts that the roles endpoint reports both the +// role assigned to the agent itself and the role it inherits from its group membership. +func (ts *AgentRolesDisplayTestSuite) TestAgentRolesIncludesDirectAndInheritedRoles() { + status, roleList, body := ts.getRoles("") + ts.Require().Equal(http.StatusOK, status, "Listing agent roles should return 200: %s", string(body)) + + ts.Contains(roleList.Roles, ts.directRole, "A role assigned to the agent must be reported") + ts.Contains(roleList.Roles, ts.groupRole, "A role assigned to the agent's group must be reported") + ts.Equal(len(roleList.Roles), roleList.Count, "Count must match the number of returned roles") + ts.Equal(1, roleList.StartIndex, "The default page starts at index 1") + ts.GreaterOrEqual(roleList.TotalResults, 2, + "The agent holds at least its direct role and its inherited role") +} + +// TestAgentRolesPagination asserts the paging contract of the roles endpoint, including the empty +// page returned for an offset past the end of the result set. +func (ts *AgentRolesDisplayTestSuite) TestAgentRolesPagination() { + status, firstPage, body := ts.getRoles("limit=1&offset=0") + ts.Require().Equal(http.StatusOK, status, "Listing agent roles should return 200: %s", string(body)) + ts.Equal(1, firstPage.Count, "A limit of 1 must return a single role") + ts.Equal(1, firstPage.StartIndex, "The first page starts at index 1") + ts.GreaterOrEqual(firstPage.TotalResults, 2, "The total count must cover every role of the agent") + ts.NotEmpty(firstPage.Links, "A paged response must carry pagination links") + + status, secondPage, body := ts.getRoles("limit=1&offset=1") + ts.Require().Equal(http.StatusOK, status, "Listing agent roles should return 200: %s", string(body)) + ts.Equal(1, secondPage.Count, "The second page must return the next role") + ts.Equal(2, secondPage.StartIndex, "The second page starts at index 2") + ts.NotEqual(firstPage.Roles[0], secondPage.Roles[0], "Consecutive pages must not repeat a role") + + status, emptyPage, body := ts.getRoles("limit=1&offset=100") + ts.Require().Equal(http.StatusOK, status, + "An offset past the end of the result set is still a valid page: %s", string(body)) + ts.Empty(emptyPage.Roles, "An offset past the end must return no roles") + ts.Equal(0, emptyPage.Count, "An empty page reports a count of zero") + ts.GreaterOrEqual(emptyPage.TotalResults, 2, "The total count is independent of the requested page") +} + +// TestAgentRolesInvalidPagination asserts that the roles endpoint rejects out of range pagination +// parameters with the documented error codes. +func (ts *AgentRolesDisplayTestSuite) TestAgentRolesInvalidPagination() { + testCases := []struct { + name string + query string + expectedCode string + }{ + {name: "non numeric limit", query: "limit=abc", expectedCode: "AGT-1011"}, + {name: "zero limit", query: "limit=0", expectedCode: "AGT-1011"}, + {name: "limit above the maximum", query: "limit=101", expectedCode: "AGT-1011"}, + {name: "negative offset", query: "offset=-1", expectedCode: "AGT-1012"}, + {name: "non numeric offset", query: "offset=abc", expectedCode: "AGT-1012"}, + } + + for _, tc := range testCases { + ts.Run(tc.name, func() { + status, _, body := ts.getRoles(tc.query) + ts.Equal(http.StatusBadRequest, status, "Invalid pagination should be rejected with 400") + ts.assertErrorCode(body, tc.expectedCode) + }) + } +} + +// TestAgentRolesUnknownAgent asserts that the roles endpoint reports a missing agent as not found, +// both for an identifier that does not exist and for one that belongs to a non-agent entity. +func (ts *AgentRolesDisplayTestSuite) TestAgentRolesUnknownAgent() { + status, _, body := ts.getRolesFromURL(fmt.Sprintf("%s%s/%s/roles", testServerURL, agentBasePath, + "00000000-0000-0000-0000-000000000000")) + ts.Equal(http.StatusNotFound, status, "An unknown agent id should return 404") + ts.assertErrorCode(body, "AGT-1004") + + // A group is an entity of another category, so its id must not resolve as an agent. + status, _, body = ts.getRolesFromURL(fmt.Sprintf("%s%s/%s/roles", testServerURL, agentBasePath, ts.groupID)) + ts.Equal(http.StatusNotFound, status, "An id of another entity category should return 404") + ts.assertErrorCode(body, "AGT-1004") +} + +// TestAgentGetWithDisplayResolvesOUHandle asserts that GET /agents/{id} only resolves the OU handle +// when display attributes are requested. +func (ts *AgentRolesDisplayTestSuite) TestAgentGetWithDisplayResolvesOUHandle() { + resp, err := doGet(fmt.Sprintf("%s%s/%s", testServerURL, agentBasePath, ts.agentID)) + ts.Require().NoError(err, "Failed to send the agent get request") + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + ts.Require().NoError(err, "Failed to read the agent get response") + ts.Require().Equal(http.StatusOK, resp.StatusCode, "Getting an agent should return 200: %s", string(body)) + + var plain Agent + ts.Require().NoError(json.Unmarshal(body, &plain), "Failed to parse the agent get response") + ts.Empty(plain.OUHandle, "The OU handle must not be resolved without include=display") + + resp, err = doGet(fmt.Sprintf("%s%s/%s?include=display", testServerURL, agentBasePath, ts.agentID)) + ts.Require().NoError(err, "Failed to send the agent get request with display") + body, err = io.ReadAll(resp.Body) + resp.Body.Close() + ts.Require().NoError(err, "Failed to read the agent get response with display") + ts.Require().Equal(http.StatusOK, resp.StatusCode, + "Getting an agent with display should return 200: %s", string(body)) + + var withDisplay Agent + ts.Require().NoError(json.Unmarshal(body, &withDisplay), "Failed to parse the agent get response") + ts.Equal(rolesTestOU.Handle, withDisplay.OUHandle, + "include=display must resolve the handle of the agent's organization unit") +} + +// TestAgentListWithDisplayResolvesOUHandles asserts that the batch OU handle resolution of the agent +// list endpoint runs only when display attributes are requested. +func (ts *AgentRolesDisplayTestSuite) TestAgentListWithDisplayResolvesOUHandles() { + findAgent := func(list *AgentListResponse) *Agent { + for i := range list.Agents { + if list.Agents[i].ID == ts.agentID { + return &list.Agents[i] + } + } + return nil + } + + listAgents := func(query url.Values) *AgentListResponse { + resp, err := doGet(fmt.Sprintf("%s%s?%s", testServerURL, agentBasePath, query.Encode())) + ts.Require().NoError(err, "Failed to send the agent list request") + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + ts.Require().NoError(err, "Failed to read the agent list response") + ts.Require().Equal(http.StatusOK, resp.StatusCode, + "Listing agents should return 200: %s", string(body)) + var list AgentListResponse + ts.Require().NoError(json.Unmarshal(body, &list), + "Failed to parse the agent list response: %s", string(body)) + return &list + } + + // Page through the listing until this suite's agent is found, rather than filtering on an + // attribute. Storing an attribute would mean declaring a schema on the shared agent type. + locate := func(include bool) *Agent { + for offset := 0; offset < 500; offset += 100 { + query := url.Values{} + query.Set("limit", "100") + query.Set("offset", strconv.Itoa(offset)) + if include { + query.Set("include", "display") + } + list := listAgents(query) + if found := findAgent(list); found != nil { + return found + } + if offset+len(list.Agents) >= list.TotalResults { + break + } + } + return nil + } + + plainAgent := locate(false) + ts.Require().NotNil(plainAgent, "The test agent must be returned by the list") + ts.Empty(plainAgent.OUHandle, "The OU handle must not be resolved without include=display") + + displayAgent := locate(true) + ts.Require().NotNil(displayAgent, "The test agent must be returned by the list with display") + ts.Equal(rolesTestOU.Handle, displayAgent.OUHandle, + "include=display must resolve the OU handle of every listed agent") +} diff --git a/tests/integration/agent/model.go b/tests/integration/agent/model.go index c7fa732348..7b41415e71 100644 --- a/tests/integration/agent/model.go +++ b/tests/integration/agent/model.go @@ -28,7 +28,7 @@ type Agent struct { // InboundAuthConfig represents an inbound authentication configuration entry. type InboundAuthConfig struct { - Type string `json:"type"` + Type string `json:"type"` Config *OAuthAgentConfig `json:"config,omitempty"` } @@ -85,6 +85,16 @@ type AgentGroupListResponse struct { Links []interface{} `json:"links"` } +// AgentRoleListResponse is the paginated role list response for an agent. Roles are reported by +// name, both for direct assignments and for those inherited through group membership. +type AgentRoleListResponse struct { + TotalResults int `json:"totalResults"` + StartIndex int `json:"startIndex"` + Count int `json:"count"` + Roles []string `json:"roles"` + Links []interface{} `json:"links"` +} + // TokenExchangeResponse represents the response from a token exchange request. type TokenExchangeResponse struct { AccessToken string `json:"access_token,omitempty"` diff --git a/tests/integration/application/certificate_update_test.go b/tests/integration/application/certificate_update_test.go new file mode 100644 index 0000000000..b672d242db --- /dev/null +++ b/tests/integration/application/certificate_update_test.go @@ -0,0 +1,243 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package application + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/thunder-id/thunderid/tests/integration/testutils" +) + +const ( + // certUpdateFirstJWKS and certUpdateSecondJWKS are two distinct inline JWKS documents. The + // second one replaces the first, which is what drives the certificate update path: the stored + // certificate record is updated in place rather than recreated. + certUpdateFirstJWKS = `{"keys":[{"kty":"RSA","use":"sig","kid":"cert-update-key-1",` + + `"n":"0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_` + + `BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2Q` + + `vzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZ` + + `u0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw","e":"AQAB"}]}` + certUpdateSecondJWKS = `{"keys":[{"kty":"EC","use":"sig","kid":"cert-update-key-2","crv":"P-256",` + + `"x":"f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU","y":"x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0"}]}` + + certUpdateJWKSURI = "https://cert-update.example.com/.well-known/jwks.json" +) + +var certUpdateOU = testutils.OrganizationUnit{ + Handle: "cert-update-test-ou", + Name: "Certificate Update Test OU", + Description: "Organization unit for the application certificate update tests", + Parent: nil, +} + +// CertificateUpdateTestSuite covers the certificate lifecycle of an OAuth application: the stored +// certificate is created, updated in place, and removed as the application's inbound OAuth profile +// changes across PUT /applications/{id} requests. +type CertificateUpdateTestSuite struct { + suite.Suite + ouID string +} + +func TestCertificateUpdateTestSuite(t *testing.T) { + suite.Run(t, new(CertificateUpdateTestSuite)) +} + +func (ts *CertificateUpdateTestSuite) SetupSuite() { + ouID, err := testutils.CreateOrganizationUnit(certUpdateOU) + ts.Require().NoError(err, "Failed to create the test organization unit") + ts.ouID = ouID +} + +func (ts *CertificateUpdateTestSuite) TearDownSuite() { + if ts.ouID != "" { + if err := testutils.DeleteOrganizationUnit(ts.ouID); err != nil { + ts.T().Logf("Failed to delete the test organization unit during teardown: %v", err) + } + } +} + +// --- helpers --- + +// privateKeyJWTApp builds an application whose OAuth profile authenticates with private_key_jwt and +// carries the given certificate. +func (ts *CertificateUpdateTestSuite) privateKeyJWTApp(name, clientID string, + cert *ApplicationCert) Application { + return Application{ + OUID: ts.ouID, + Name: name, + Description: "Application for the certificate update tests", + URL: fmt.Sprintf("https://%s.example.com", clientID), + InboundAuthConfig: []InboundAuthConfig{ + { + Type: "oauth2", + OAuthAppConfig: &OAuthAppConfig{ + ClientID: clientID, + RedirectURIs: []string{fmt.Sprintf("https://%s.example.com/callback", clientID)}, + GrantTypes: []string{"client_credentials"}, + ResponseTypes: []string{}, + TokenEndpointAuthMethod: "private_key_jwt", + Certificate: cert, + }, + }, + }, + } +} + +// secretApp builds an application whose OAuth profile authenticates with a client secret and +// therefore carries no certificate. +func (ts *CertificateUpdateTestSuite) secretApp(name, clientID, clientSecret string) Application { + return Application{ + OUID: ts.ouID, + Name: name, + Description: "Application for the certificate update tests", + URL: fmt.Sprintf("https://%s.example.com", clientID), + InboundAuthConfig: []InboundAuthConfig{ + { + Type: "oauth2", + OAuthAppConfig: &OAuthAppConfig{ + ClientID: clientID, + ClientSecret: clientSecret, + RedirectURIs: []string{fmt.Sprintf("https://%s.example.com/callback", clientID)}, + GrantTypes: []string{"client_credentials"}, + ResponseTypes: []string{}, + TokenEndpointAuthMethod: "client_secret_basic", + }, + }, + }, + } +} + +// storedCertificate returns the certificate the server reports for the application's OAuth profile. +func (ts *CertificateUpdateTestSuite) storedCertificate(appID string) *ApplicationCert { + app, err := getApplicationByID(appID) + ts.Require().NoError(err, "Failed to read back the application") + ts.Require().Len(app.InboundAuthConfig, 1, "The application must expose its OAuth profile") + ts.Require().NotNil(app.InboundAuthConfig[0].OAuthAppConfig, "The OAuth profile must be populated") + return app.InboundAuthConfig[0].OAuthAppConfig.Certificate +} + +// --- tests --- + +// TestCertificateValueUpdatedInPlace asserts that replacing the certificate value of an existing +// OAuth profile updates the stored certificate, and that the new value is what later reads return. +func (ts *CertificateUpdateTestSuite) TestCertificateValueUpdatedInPlace() { + app := ts.privateKeyJWTApp("Cert Update Value App", "cert_update_value_client", + &ApplicationCert{Type: "JWKS", Value: certUpdateFirstJWKS}) + appID, err := createApplication(app) + ts.Require().NoError(err, "Failed to create the application with a certificate") + defer func() { _ = deleteApplication(appID) }() + + stored := ts.storedCertificate(appID) + ts.Require().NotNil(stored, "The created application must report its certificate") + ts.Equal("JWKS", stored.Type, "The stored certificate type must be the one registered") + ts.Equal(certUpdateFirstJWKS, stored.Value, "The stored certificate value must be the one registered") + + updated := app + updated.ID = appID + updated.InboundAuthConfig[0].OAuthAppConfig.Certificate = + &ApplicationCert{Type: "JWKS", Value: certUpdateSecondJWKS} + ts.Require().NoError(updateApplication(appID, updated), "Failed to update the certificate value") + + stored = ts.storedCertificate(appID) + ts.Require().NotNil(stored, "The updated application must still report a certificate") + ts.Equal("JWKS", stored.Type, "The certificate type is unchanged by a value update") + ts.Equal(certUpdateSecondJWKS, stored.Value, "The updated certificate value must be returned") +} + +// TestCertificateTypeSwitchedOnUpdate asserts that switching an inline JWKS certificate to a JWKS +// URI is applied to the existing certificate record. +func (ts *CertificateUpdateTestSuite) TestCertificateTypeSwitchedOnUpdate() { + app := ts.privateKeyJWTApp("Cert Update Type App", "cert_update_type_client", + &ApplicationCert{Type: "JWKS", Value: certUpdateFirstJWKS}) + appID, err := createApplication(app) + ts.Require().NoError(err, "Failed to create the application with a certificate") + defer func() { _ = deleteApplication(appID) }() + + updated := app + updated.ID = appID + updated.InboundAuthConfig[0].OAuthAppConfig.Certificate = + &ApplicationCert{Type: "JWKS_URI", Value: certUpdateJWKSURI} + ts.Require().NoError(updateApplication(appID, updated), "Failed to switch the certificate type") + + stored := ts.storedCertificate(appID) + ts.Require().NotNil(stored, "The updated application must still report a certificate") + ts.Equal("JWKS_URI", stored.Type, "The switched certificate type must be returned") + ts.Equal(certUpdateJWKSURI, stored.Value, "The switched certificate value must be returned") +} + +// TestCertificateAddedOnUpdate asserts that an application registered without a certificate gets one +// stored when its profile switches to private_key_jwt. +func (ts *CertificateUpdateTestSuite) TestCertificateAddedOnUpdate() { + app := ts.secretApp("Cert Update Added App", "cert_update_added_client", "cert_update_added_secret") + appID, err := createApplication(app) + ts.Require().NoError(err, "Failed to create the application without a certificate") + defer func() { _ = deleteApplication(appID) }() + + ts.Nil(ts.storedCertificate(appID), "A client secret application must have no certificate") + + updated := ts.privateKeyJWTApp("Cert Update Added App", "cert_update_added_client", + &ApplicationCert{Type: "JWKS", Value: certUpdateFirstJWKS}) + updated.ID = appID + ts.Require().NoError(updateApplication(appID, updated), "Failed to add a certificate on update") + + stored := ts.storedCertificate(appID) + ts.Require().NotNil(stored, "The updated application must report the added certificate") + ts.Equal(certUpdateFirstJWKS, stored.Value, "The added certificate value must be returned") +} + +// TestCertificateRemovedOnUpdate asserts that moving back to client secret authentication removes the +// stored certificate rather than leaving it behind. +func (ts *CertificateUpdateTestSuite) TestCertificateRemovedOnUpdate() { + app := ts.privateKeyJWTApp("Cert Update Removed App", "cert_update_removed_client", + &ApplicationCert{Type: "JWKS", Value: certUpdateFirstJWKS}) + appID, err := createApplication(app) + ts.Require().NoError(err, "Failed to create the application with a certificate") + defer func() { _ = deleteApplication(appID) }() + + ts.Require().NotNil(ts.storedCertificate(appID), "The created application must report its certificate") + + updated := ts.secretApp("Cert Update Removed App", "cert_update_removed_client", + "cert_update_removed_secret") + updated.ID = appID + ts.Require().NoError(updateApplication(appID, updated), "Failed to remove the certificate on update") + + ts.Nil(ts.storedCertificate(appID), "The certificate must be removed once the profile no longer has one") +} + +// TestInvalidCertificateOnUpdateIsRejected asserts that an invalid certificate is rejected on update +// and that the previously stored certificate survives the rejected request. +func (ts *CertificateUpdateTestSuite) TestInvalidCertificateOnUpdateIsRejected() { + app := ts.privateKeyJWTApp("Cert Update Invalid App", "cert_update_invalid_client", + &ApplicationCert{Type: "JWKS", Value: certUpdateFirstJWKS}) + appID, err := createApplication(app) + ts.Require().NoError(err, "Failed to create the application with a certificate") + defer func() { _ = deleteApplication(appID) }() + + testCases := []struct { + name string + cert *ApplicationCert + }{ + {name: "unsupported certificate type", cert: &ApplicationCert{Type: "PEM", Value: certUpdateFirstJWKS}}, + {name: "inline JWKS with no value", cert: &ApplicationCert{Type: "JWKS", Value: ""}}, + {name: "JWKS URI that is not a URI", cert: &ApplicationCert{Type: "JWKS_URI", Value: "not-a-uri"}}, + } + + for _, tc := range testCases { + ts.Run(tc.name, func() { + updated := app + updated.ID = appID + updated.InboundAuthConfig[0].OAuthAppConfig.Certificate = tc.cert + err := updateApplication(appID, updated) + ts.Error(err, "An invalid certificate must be rejected on update") + + stored := ts.storedCertificate(appID) + ts.Require().NotNil(stored, "A rejected update must leave the stored certificate in place") + ts.Equal(certUpdateFirstJWKS, stored.Value, + "A rejected update must not change the stored certificate value") + }) + } +} diff --git a/tests/integration/connection/vendor_list_test.go b/tests/integration/connection/vendor_list_test.go new file mode 100644 index 0000000000..3b8bc04257 --- /dev/null +++ b/tests/integration/connection/vendor_list_test.go @@ -0,0 +1,174 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package connection + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/suite" +) + +// connectionInstanceSummary mirrors backend/internal/connection/models.go connectionInstanceSummary, +// the shape the vendor scoped collection endpoints return. +type connectionInstanceSummary struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` +} + +// VendorListTestSuite covers the vendor scoped collection endpoints, GET /connections/{vendor}, +// which list only the instances of that vendor. The unified GET /connections listing is covered by +// the connection API suite; this suite asserts the per-vendor scoping, for identity providers and +// for message providers alike. +type VendorListTestSuite struct { + suite.Suite + googleID string + oidcID string + twilioID string + smsGatewayID string +} + +func TestVendorListSuite(t *testing.T) { + suite.Run(t, new(VendorListTestSuite)) +} + +func (ts *VendorListTestSuite) SetupSuite() { + ts.googleID = ts.create("google", googleConnectionRequest{ + Name: "Vendor List Google", + ClientID: "vendor-list-google-client", + ClientSecret: "vendor-list-google-secret", + RedirectURI: "https://localhost:8095/flow/authn", + }) + ts.oidcID = ts.create("oidc", oidcConnectionRequest{ + Name: "Vendor List OIDC", + ClientID: "vendor-list-oidc-client", + ClientSecret: "vendor-list-oidc-secret", + RedirectURI: "https://localhost:8095/flow/authn", + AuthorizationEndpoint: "https://vendor-list.example.com/authorize", + TokenEndpoint: "https://vendor-list.example.com/token", + }) + ts.twilioID = ts.create("twilio", twilioConnectionRequest{ + Name: "Vendor List Twilio", + AccountSID: "AC00000000000000000000000000000001", + AuthToken: "vendor-list-twilio-token", + SenderID: "+10000000001", + }) + ts.smsGatewayID = ts.create("sms-gateway", smsGatewayConnectionRequest{ + Name: "Vendor List SMS Gateway", + URL: "https://vendor-list.example.com/sms", + HTTPMethod: "POST", + }) +} + +func (ts *VendorListTestSuite) TearDownSuite() { + for vendor, id := range map[string]string{ + "google": ts.googleID, + "oidc": ts.oidcID, + "twilio": ts.twilioID, + "sms-gateway": ts.smsGatewayID, + } { + if id == "" { + continue + } + res, err := doRequest(http.MethodDelete, "/connections/"+vendor+"/"+id, nil) + if err != nil || res.status != http.StatusNoContent { + ts.T().Logf("Failed to delete the %s connection during teardown: status=%d err=%v", + vendor, res.status, err) + } + } +} + +func (ts *VendorListTestSuite) create(vendor string, body interface{}) string { + res, err := doRequest(http.MethodPost, "/connections/"+vendor, body) + ts.Require().NoError(err, "Failed to create the %s connection", vendor) + ts.Require().Equal(http.StatusCreated, res.status, + "Creating the %s connection should return 201: %s", vendor, string(res.body)) + + var created connectionResponse + ts.Require().NoError(res.decode(&created), "Failed to decode the created %s connection", vendor) + ts.Require().NotEmpty(created.ID, "The created %s connection must have an id", vendor) + return created.ID +} + +// listVendor returns the instances the vendor scoped collection endpoint reports. +func (ts *VendorListTestSuite) listVendor(vendor string) []connectionInstanceSummary { + res, err := doRequest(http.MethodGet, "/connections/"+vendor, nil) + ts.Require().NoError(err, "Failed to list the %s connections", vendor) + ts.Require().Equal(http.StatusOK, res.status, + "Listing the %s connections should return 200: %s", vendor, string(res.body)) + + var instances []connectionInstanceSummary + ts.Require().NoError(res.decode(&instances), "Failed to decode the %s connection list", vendor) + return instances +} + +// containsSummaryID reports whether the vendor scoped listing contains the given instance id. +func containsSummaryID(instances []connectionInstanceSummary, id string) bool { + for _, instance := range instances { + if instance.ID == id { + return true + } + } + return false +} + +// TestIdentityProviderVendorListIsScopedToItsType asserts that an identity provider collection lists +// its own instances and none of another vendor's. +func (ts *VendorListTestSuite) TestIdentityProviderVendorListIsScopedToItsType() { + googleInstances := ts.listVendor("google") + ts.True(containsSummaryID(googleInstances, ts.googleID), "The google list must contain the google connection") + ts.False(containsSummaryID(googleInstances, ts.oidcID), "The google list must not contain the OIDC connection") + + oidcInstances := ts.listVendor("oidc") + ts.True(containsSummaryID(oidcInstances, ts.oidcID), "The OIDC list must contain the OIDC connection") + ts.False(containsSummaryID(oidcInstances, ts.googleID), "The OIDC list must not contain the google connection") + + // A vendor with no configured instance still answers with an empty collection rather than 404. + githubInstances := ts.listVendor("github") + ts.False(containsSummaryID(githubInstances, ts.googleID), + "The github list must not contain connections of other vendors") + + for _, instance := range googleInstances { + if instance.ID == ts.googleID { + ts.Equal("Vendor List Google", instance.Name, + "The listed instance must carry the name it was created with") + } + } +} + +// TestMessageProviderVendorListIsScopedToItsProvider asserts the same scoping for the message +// provider collections, which resolve through the notification sender service instead. +func (ts *VendorListTestSuite) TestMessageProviderVendorListIsScopedToItsProvider() { + twilioInstances := ts.listVendor("twilio") + ts.True(containsSummaryID(twilioInstances, ts.twilioID), "The twilio list must contain the twilio sender") + ts.False(containsSummaryID(twilioInstances, ts.smsGatewayID), + "The twilio list must not contain the custom gateway sender") + + gatewayInstances := ts.listVendor("sms-gateway") + ts.True(containsSummaryID(gatewayInstances, ts.smsGatewayID), + "The custom gateway list must contain the custom gateway sender") + ts.False(containsSummaryID(gatewayInstances, ts.twilioID), + "The custom gateway list must not contain the twilio sender") + + vonageInstances := ts.listVendor("vonage") + ts.False(containsSummaryID(vonageInstances, ts.twilioID), + "The vonage list must not contain senders of other providers") + + for _, instance := range twilioInstances { + if instance.ID == ts.twilioID { + ts.Equal("Vendor List Twilio", instance.Name, + "The listed sender must carry the name it was created with") + } + } +} + +// TestUnknownVendorCollectionIsNotFound asserts that only registered vendors have a collection +// endpoint, so an unknown vendor is not silently treated as an empty one. +func (ts *VendorListTestSuite) TestUnknownVendorCollectionIsNotFound() { + res, err := doRequest(http.MethodGet, "/connections/not-a-vendor", nil) + ts.Require().NoError(err, "Failed to send the request for an unknown vendor") + ts.Equal(http.StatusNotFound, res.status, + "An unknown vendor collection should return 404: %s", string(res.body)) +} diff --git a/tests/integration/export/export_entity_resources_test.go b/tests/integration/export/export_entity_resources_test.go new file mode 100644 index 0000000000..1833a20eb8 --- /dev/null +++ b/tests/integration/export/export_entity_resources_test.go @@ -0,0 +1,357 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package export + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/thunder-id/thunderid/tests/integration/testutils" +) + +// ExportEntityResourcesTestSuite covers the export hooks of the resource types that no other export +// suite reaches: users, user types, agent types, and translations. +// +// Each exporter contributes four hooks to the shared export pipeline (GetAllResourceIDs, +// GetResourceByID, ValidateResource, GetResourceRules), so every type is exercised both by ID and by +// wildcard. The wildcard path is the one that runs GetAllResourceIDs, and it is also where a type +// filter mistake shows up, since the emitted bundle would then carry documents of the wrong type. +// +// The user exporter is the interesting one: it declares Credentials as a dynamic property field, so +// the credential the fixture user is created with must be replaced by a template variable and must +// not appear in the exported document. +type ExportEntityResourcesTestSuite struct { + suite.Suite + + ouID string + userTypeID string + userID string +} + +const ( + entityExportOUHandle = "export-entity-ou" + + entityExportUserTypeName = "export-entity-person" + entityExportUsername = "export-entity-user" + entityExportPassword = "ExportEntity@123" + + // The agent type is the shared singleton `default`, so its name is fixed. + entityExportAgentTypeName = "default" + + // A language of this suite's own, so clearing it never touches the system defaults other + // suites resolve against. + entityExportLanguage = "pt-BR" + entityExportNamespace = "integration-export" + entityExportKey = "greeting" + entityExportValue = "Ola" +) + +func TestExportEntityResourcesTestSuite(t *testing.T) { + suite.Run(t, new(ExportEntityResourcesTestSuite)) +} + +func (ts *ExportEntityResourcesTestSuite) SetupSuite() { + ouID, err := testutils.CreateOrganizationUnit(testutils.OrganizationUnit{ + Handle: entityExportOUHandle, + Name: "Export Entity OU", + Description: "Organization unit for the entity resource export tests", + }) + ts.Require().NoError(err, "Failed to create the test organization unit") + ts.ouID = ouID + + userTypeID, err := testutils.CreateUserType(testutils.UserType{ + Name: entityExportUserTypeName, + OUID: ts.ouID, + Schema: map[string]interface{}{ + "username": map[string]interface{}{"type": "string"}, + "password": map[string]interface{}{"type": "string", "credential": true}, + "email": map[string]interface{}{"type": "string"}, + }, + }) + ts.Require().NoError(err, "Failed to create the user type") + ts.userTypeID = userTypeID + + // The server allows a single `default` agent type, shared across suites and never deleted. + _, err = testutils.CreateAgentType(testutils.UserType{ + OUID: ts.ouID, + Schema: map[string]interface{}{ + "description": map[string]interface{}{"type": "string"}, + }, + }) + ts.Require().NoError(err, "Failed to create the agent type") + + userID, err := testutils.CreateUser(testutils.User{ + Type: entityExportUserTypeName, + OUID: ts.ouID, + Attributes: json.RawMessage(fmt.Sprintf( + `{"username": %q, "password": %q, "email": "export-entity@example.com"}`, + entityExportUsername, entityExportPassword)), + }) + ts.Require().NoError(err, "Failed to create the user") + ts.userID = userID + + ts.setTranslationOverride() +} + +func (ts *ExportEntityResourcesTestSuite) TearDownSuite() { + ts.clearTranslationLanguage() + + if ts.userID != "" { + if err := testutils.DeleteUser(ts.userID); err != nil { + ts.T().Logf("Failed to delete the test user: %v", err) + } + } + if ts.userTypeID != "" { + if err := testutils.DeleteUserType(ts.userTypeID); err != nil { + ts.T().Logf("Failed to delete the user type: %v", err) + } + } + if ts.ouID != "" { + if err := testutils.DeleteOrganizationUnit(ts.ouID); err != nil { + ts.T().Logf("Failed to delete the test organization unit: %v", err) + } + } +} + +// setTranslationOverride writes one override so the language becomes exportable. A language with no +// overrides is not a resource the exporter can enumerate. +func (ts *ExportEntityResourcesTestSuite) setTranslationOverride() { + ts.T().Helper() + + body, err := json.Marshal(map[string]string{"value": entityExportValue}) + ts.Require().NoError(err) + + target := fmt.Sprintf("%s/i18n/languages/%s/translations/ns/%s/keys/%s", + testServerURL, url.PathEscape(entityExportLanguage), + url.PathEscape(entityExportNamespace), url.PathEscape(entityExportKey)) + + req, err := http.NewRequest(http.MethodPost, target, bytes.NewReader(body)) + ts.Require().NoError(err) + req.Header.Set("Content-Type", "application/json") + + resp, err := testutils.GetHTTPClient().Do(req) + ts.Require().NoError(err) + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + ts.Require().NoError(err) + ts.Require().Equalf(http.StatusOK, resp.StatusCode, + "failed to seed the translation override, body: %s", respBody) +} + +// clearTranslationLanguage removes the suite's overrides so the shared server is left as found. +func (ts *ExportEntityResourcesTestSuite) clearTranslationLanguage() { + ts.T().Helper() + + target := fmt.Sprintf("%s/i18n/languages/%s/translations", + testServerURL, url.PathEscape(entityExportLanguage)) + + req, err := http.NewRequest(http.MethodDelete, target, nil) + if err != nil { + ts.T().Logf("Failed to build the translation cleanup request: %v", err) + return + } + + resp, err := testutils.GetHTTPClient().Do(req) + if err != nil { + ts.T().Logf("Failed to clear the translation language: %v", err) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + ts.T().Logf("Unexpected status clearing the translation language: %d", resp.StatusCode) + } +} + +// --------------------------------------------------------------------------- +// Users +// --------------------------------------------------------------------------- + +// TestUserExportByID verifies a user is emitted as a user document carrying its attributes. +func (ts *ExportEntityResourcesTestSuite) TestUserExportByID() { + yamlContent, err := ts.exportResourcesYAML(ExportRequest{Users: []string{ts.userID}}) + ts.Require().NoError(err) + ts.Require().NotEmpty(yamlContent) + + ts.Assert().Contains(yamlContent, "resource_type: user") + ts.Assert().Contains(yamlContent, `username: "`+entityExportUsername+`"`) + ts.Assert().Contains(yamlContent, "export-entity@example.com") + ts.Assert().Contains(yamlContent, "type: "+entityExportUserTypeName) +} + +// TestUserExportParameterizesCredentials verifies the exported document does not carry the user's +// password. The user exporter declares Credentials as a dynamic property field precisely so secrets +// leave as template variables, and a bundle is a shareable artifact. +func (ts *ExportEntityResourcesTestSuite) TestUserExportParameterizesCredentials() { + yamlContent, err := ts.exportResourcesYAML(ExportRequest{Users: []string{ts.userID}}) + ts.Require().NoError(err) + + ts.Assert().NotContains(yamlContent, entityExportPassword, + "an exported user must not carry its plaintext credential") + + // The exporter currently omits the credentials block entirely rather than emitting the template + // variable its DynamicPropertyFields declaration implies, so the assertion above holds because + // the field is dropped rather than because it is parameterized. Pinned here so the distinction + // is visible: if the exporter starts emitting a placeholder, this assertion should become a + // positive check for it. + ts.Assert().NotContains(yamlContent, "credentials:", + "the exporter omits credentials today; revisit this test if it starts parameterizing them") +} + +// TestUserExportWithWildcard verifies the wildcard form enumerates users and includes the fixture. +func (ts *ExportEntityResourcesTestSuite) TestUserExportWithWildcard() { + yamlContent, err := ts.exportResourcesYAML(ExportRequest{Users: []string{"*"}}) + ts.Require().NoError(err) + ts.Require().NotEmpty(yamlContent) + + ts.Assert().Contains(yamlContent, "resource_type: user") + ts.Assert().Contains(yamlContent, `username: "`+entityExportUsername+`"`) +} + +// TestExportWithInvalidUserID verifies an unknown user ID yields no resources rather than a partial +// bundle that would silently omit it. +func (ts *ExportEntityResourcesTestSuite) TestExportWithInvalidUserID() { + _, err := ts.exportResourcesYAML(ExportRequest{Users: []string{"non-existent-user-id"}}) + ts.Require().Error(err) +} + +// --------------------------------------------------------------------------- +// User types and agent types +// --------------------------------------------------------------------------- + +// TestUserTypeExportByID verifies a user type is emitted as a user_type document with its schema. +func (ts *ExportEntityResourcesTestSuite) TestUserTypeExportByID() { + yamlContent, err := ts.exportResourcesYAML(ExportRequest{UserTypes: []string{ts.userTypeID}}) + ts.Require().NoError(err) + ts.Require().NotEmpty(yamlContent) + + ts.Assert().Contains(yamlContent, "resource_type: user_type") + ts.Assert().Contains(yamlContent, "name: "+entityExportUserTypeName) + ts.Assert().Contains(yamlContent, "username") + ts.Assert().Contains(yamlContent, "email") +} + +// TestUserTypeExportWithWildcard verifies the wildcard form enumerates user types. +func (ts *ExportEntityResourcesTestSuite) TestUserTypeExportWithWildcard() { + yamlContent, err := ts.exportResourcesYAML(ExportRequest{UserTypes: []string{"*"}}) + ts.Require().NoError(err) + ts.Require().NotEmpty(yamlContent) + + ts.Assert().Contains(yamlContent, "resource_type: user_type") + ts.Assert().Contains(yamlContent, "name: "+entityExportUserTypeName) +} + +// TestAgentTypeExportWithWildcard verifies agent types export under their own resource type. The two +// entity-type exporters share one implementation split only by category, so the agent-type bundle +// must not be labelled user_type. +func (ts *ExportEntityResourcesTestSuite) TestAgentTypeExportWithWildcard() { + yamlContent, err := ts.exportResourcesYAML(ExportRequest{AgentTypes: []string{"*"}}) + ts.Require().NoError(err) + ts.Require().NotEmpty(yamlContent) + + ts.Assert().Contains(yamlContent, "resource_type: agent_type") + ts.Assert().Contains(yamlContent, "name: "+entityExportAgentTypeName) + ts.Assert().NotContains(yamlContent, "resource_type: user_type", + "an agent type export must not emit user_type documents") +} + +// --------------------------------------------------------------------------- +// Translations +// --------------------------------------------------------------------------- + +// TestTranslationExportByLanguage verifies translations export per language, keyed by namespace. +func (ts *ExportEntityResourcesTestSuite) TestTranslationExportByLanguage() { + yamlContent, err := ts.exportResourcesYAML( + ExportRequest{Translations: []string{entityExportLanguage}}) + ts.Require().NoError(err) + ts.Require().NotEmpty(yamlContent) + + ts.Assert().Contains(yamlContent, "resource_type: translation") + ts.Assert().Contains(yamlContent, "language: "+entityExportLanguage) + ts.Assert().Contains(yamlContent, entityExportNamespace) + ts.Assert().Contains(yamlContent, entityExportKey+": "+entityExportValue) +} + +// TestTranslationExportWithWildcard verifies the wildcard form enumerates every language holding +// overrides, which is what GetAllResourceIDs resolves from the store. +func (ts *ExportEntityResourcesTestSuite) TestTranslationExportWithWildcard() { + yamlContent, err := ts.exportResourcesYAML(ExportRequest{Translations: []string{"*"}}) + ts.Require().NoError(err) + ts.Require().NotEmpty(yamlContent) + + ts.Assert().Contains(yamlContent, "resource_type: translation") + ts.Assert().Contains(yamlContent, "language: "+entityExportLanguage) +} + +// TestExportWithUnknownTranslationLanguage verifies a language with no overrides is not exportable, +// rather than producing an empty translation document that would clear overrides on re-import. +func (ts *ExportEntityResourcesTestSuite) TestExportWithUnknownTranslationLanguage() { + _, err := ts.exportResourcesYAML(ExportRequest{Translations: []string{"zz-ZZ"}}) + ts.Require().Error(err) +} + +// --------------------------------------------------------------------------- +// Mixed bundle +// --------------------------------------------------------------------------- + +// TestEntityResourcesExportedTogether verifies one request spanning several of these types emits a +// document per type, since the pipeline iterates the resource map rather than short-circuiting on +// the first type it finds. +func (ts *ExportEntityResourcesTestSuite) TestEntityResourcesExportedTogether() { + yamlContent, err := ts.exportResourcesYAML(ExportRequest{ + Users: []string{ts.userID}, + UserTypes: []string{ts.userTypeID}, + Translations: []string{entityExportLanguage}, + }) + ts.Require().NoError(err) + ts.Require().NotEmpty(yamlContent) + + ts.Assert().Contains(yamlContent, "resource_type: user") + ts.Assert().Contains(yamlContent, "resource_type: user_type") + ts.Assert().Contains(yamlContent, "resource_type: translation") +} + +// exportResourcesYAML posts an export request and returns the emitted resource bundle. +func (ts *ExportEntityResourcesTestSuite) exportResourcesYAML( + exportRequest ExportRequest, +) (string, error) { + reqJSON, err := json.Marshal(exportRequest) + if err != nil { + return "", fmt.Errorf("failed to marshal export request: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, testServerURL+"/export", bytes.NewReader(reqJSON)) + if err != nil { + return "", fmt.Errorf("failed to create export request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := testutils.GetHTTPClient().Do(req) + if err != nil { + return "", fmt.Errorf("failed to send export request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read export response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("expected status 200, got %d. Response: %s", resp.StatusCode, body) + } + + var jsonResponse JSONExportResponse + if err := json.Unmarshal(body, &jsonResponse); err != nil { + return "", fmt.Errorf("failed to parse JSON export response: %w", err) + } + return jsonResponse.Resources, nil +} diff --git a/tests/integration/export/model.go b/tests/integration/export/model.go index 76d8f6ebe2..574e2b5ee6 100644 --- a/tests/integration/export/model.go +++ b/tests/integration/export/model.go @@ -9,6 +9,10 @@ type ExportRequest struct { Connections []string `json:"connections,omitempty"` CredentialConfigurations []string `json:"credentialConfigurations,omitempty"` PresentationDefinitions []string `json:"presentationDefinitions,omitempty"` + Users []string `json:"users,omitempty"` + UserTypes []string `json:"userTypes,omitempty"` + AgentTypes []string `json:"agentTypes,omitempty"` + Translations []string `json:"translations,omitempty"` } // ExportResponse represents the response structure for exporting resources. diff --git a/tests/integration/flow/authentication/consent_permissions_test.go b/tests/integration/flow/authentication/consent_permissions_test.go index 0f07732a25..6cbcb30fa6 100644 --- a/tests/integration/flow/authentication/consent_permissions_test.go +++ b/tests/integration/flow/authentication/consent_permissions_test.go @@ -170,6 +170,8 @@ func buildConsentFlowWithoutAuthz() testutils.Flow { // publishes under the consentPrompt key of the flow response additional data. type consentPromptElement struct { Name string `json:"name"` + // Parent is the element a nested permission rolls up to, absent for a top-level one. + Parent string `json:"parent,omitempty"` } // consentPromptPurpose mirrors one purpose of the consent prompt payload. diff --git a/tests/integration/flow/authentication/permission_consent_test.go b/tests/integration/flow/authentication/permission_consent_test.go new file mode 100644 index 0000000000..be956cd65d --- /dev/null +++ b/tests/integration/flow/authentication/permission_consent_test.go @@ -0,0 +1,451 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package authentication + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/thunder-id/thunderid/tests/integration/flow/common" + "github.com/thunder-id/thunderid/tests/integration/testutils" +) + +// PermissionConsentFlowTestSuite covers consent over resource-server permissions, as opposed to the +// user-attribute consent ConsentFlowTestSuite covers. +// +// The permission purpose is not stored: it is built at prompt time from the permissions the +// authorization executor actually authorized, so it can only be reached by running an +// AuthorizationExecutor node ahead of the consent node and asking for permission scopes bound to a +// resource server. Everything the suite asserts follows from that: +// +// - only authorized permissions are prompted, never everything requested; +// - the purpose is typed as permissions, so the Console renders it in its own section; +// - each element carries the rollup parent the server computes, which is what lets the Console +// group a permission under the broader one that implies it. +// +// The rollup parent is the part with real logic: a permission's parent is the longest other prompted +// permission it extends across a delimiter. The fixture therefore declares a nested resource, whose +// permission has a parent, alongside a sibling whose handle merely starts with the same letters, +// whose permission must not. +type PermissionConsentFlowTestSuite struct { + suite.Suite + config *common.TestSuiteConfig + + ouID string + userTypeID string + appID string + rsID string + roleID string + + // Resource IDs in creation order; teardown removes them leaf first so the resource server can + // then be deleted. + resourceIDs []string + + // A user per behaviour. The tests that only read the prompt share one, but the tests that + // record a decision each need their own: a recorded consent suppresses the prompt every later + // authentication of that user depends on. + promptUserID string + approveUserID string + denyUserID string +} + +const ( + permConsentOUHandle = "permission-consent-ou" + permConsentUserTypeName = "permission-consent-person" + + permConsentPromptUsername = "permission_consent_prompt_user" + permConsentApproveUsername = "permission_consent_approve_user" + permConsentDenyUsername = "permission_consent_deny_user" + permConsentPassword = "SecurePass123!" + + permConsentRSIdentifier = "permission-consent-docs" + + // The permission a top-level resource contributes. It is the rollup parent of the nested one. + permConsentParentPermission = "docs" + + // The permission a resource nested under "docs" contributes. Its parent must resolve to "docs". + permConsentChildPermission = "docs:reports" + + // A sibling top-level permission that starts with "docs" but does not extend it across a + // delimiter, so it must have no parent. Without it a prefix check that ignored the delimiter + // would pass. + permConsentDecoyPermission = "docsx" + + // A permission the user is never granted. It is requested anyway, so the prompt is shown to + // carry only what was authorized. + permConsentUnheldPermission = "docs:payroll" + + permConsentPurposeType = "permissions" +) + +func TestPermissionConsentFlowTestSuite(t *testing.T) { + suite.Run(t, new(PermissionConsentFlowTestSuite)) +} + +func (ts *PermissionConsentFlowTestSuite) SetupSuite() { + ts.config = &common.TestSuiteConfig{} + + ouID, err := testutils.CreateOrganizationUnit(testutils.OrganizationUnit{ + Handle: permConsentOUHandle, + Name: "Permission Consent Test Organization Unit", + Description: "Organization unit for permission consent flow testing", + }) + ts.Require().NoError(err, "Failed to create test organization unit") + ts.ouID = ouID + + ts.userTypeID, err = testutils.CreateUserType(testutils.UserType{ + Name: permConsentUserTypeName, + OUID: ouID, + Schema: map[string]interface{}{ + "username": map[string]interface{}{"type": "string"}, + "password": map[string]interface{}{"type": "string", "credential": true}, + "email": map[string]interface{}{"type": "string"}, + }, + }) + ts.Require().NoError(err, "Failed to create test user type") + + // The resource tree is what the permission strings are derived from: a resource's permission is + // its handle path, so nesting "reports" under "docs" produces "docs:reports". + ts.rsID, err = testutils.CreateResourceServerWithActions(testutils.ResourceServer{ + Name: "Permission Consent Document Store", + Description: "Resource server for permission consent flow testing", + Identifier: permConsentRSIdentifier, + OUID: ouID, + }, nil) + ts.Require().NoError(err, "Failed to create test resource server") + + docsID := ts.createResource("Documents", permConsentParentPermission, "") + ts.createResource("Reports", "reports", docsID) + ts.createResource("Payroll", "payroll", docsID) + ts.createResource("Docs Extended", permConsentDecoyPermission, "") + + flowID, err := testutils.CreateFlow(testutils.Flow{ + Name: "Permission Consent Test Auth Flow", + FlowType: "AUTHENTICATION", + Handle: "auth_flow_permission_consent_test", + Nodes: permissionConsentFlowNodes(), + }) + ts.Require().NoError(err, "Failed to create permission consent test flow") + ts.config.CreatedFlowIDs = append(ts.config.CreatedFlowIDs, flowID) + + // No assertion config, so no attribute purpose applies and the permission purpose is the only + // one prompted. + ts.appID, err = testutils.CreateApplication(testutils.Application{ + Name: "Permission Consent Flow Test Application", + Description: "Application for testing permission consent collection in flows", + ClientID: "permission_consent_flow_test_client", + ClientSecret: "permission_consent_flow_test_secret", + RedirectURIs: []string{"http://localhost:3000/callback"}, + OUID: ouID, + AllowedUserTypes: []string{permConsentUserTypeName}, + AuthFlowID: flowID, + }) + ts.Require().NoError(err, "Failed to create test application") + + ts.promptUserID = ts.createPermConsentUser(permConsentPromptUsername, "permission.prompt@test.com") + ts.approveUserID = ts.createPermConsentUser(permConsentApproveUsername, "permission.approve@test.com") + ts.denyUserID = ts.createPermConsentUser(permConsentDenyUsername, "permission.deny@test.com") + + // Granted to every user, and deliberately excluding docs:payroll. + ts.roleID, err = testutils.CreateRole(testutils.Role{ + Name: "permission-consent-reader", + Description: "Role granting the permissions the consent prompt is expected to carry", + OUID: ouID, + Permissions: []testutils.ResourcePermissions{ + { + ResourceServerID: ts.rsID, + Permissions: []string{ + permConsentParentPermission, + permConsentChildPermission, + permConsentDecoyPermission, + }, + }, + }, + Assignments: []testutils.Assignment{ + {ID: ts.promptUserID, Type: "user"}, + {ID: ts.approveUserID, Type: "user"}, + {ID: ts.denyUserID, Type: "user"}, + }, + }) + ts.Require().NoError(err, "Failed to create test role") +} + +// createResource creates a resource under the suite's resource server, recording it for teardown. +func (ts *PermissionConsentFlowTestSuite) createResource(name, handle, parentID string) string { + ts.T().Helper() + + resourceID, err := testutils.CreateResource(ts.rsID, name, handle, parentID) + ts.Require().NoError(err, "Failed to create the %s resource", handle) + ts.resourceIDs = append(ts.resourceIDs, resourceID) + return resourceID +} + +// createPermConsentUser creates a user of the suite's type and returns its ID. +func (ts *PermissionConsentFlowTestSuite) createPermConsentUser(username, email string) string { + ts.T().Helper() + + attributes, err := json.Marshal(map[string]string{ + "username": username, + "password": permConsentPassword, + "email": email, + }) + ts.Require().NoError(err) + + userID, err := testutils.CreateUser(testutils.User{ + Type: permConsentUserTypeName, + OUID: ts.ouID, + Attributes: attributes, + }) + ts.Require().NoError(err, "Failed to create user %s", username) + return userID +} + +func (ts *PermissionConsentFlowTestSuite) TearDownSuite() { + if ts.roleID != "" { + if err := testutils.DeleteRole(ts.roleID); err != nil { + ts.T().Logf("Failed to delete test role: %v", err) + } + } + for _, id := range []string{ts.promptUserID, ts.approveUserID, ts.denyUserID} { + if id != "" { + if err := testutils.DeleteUser(id); err != nil { + ts.T().Logf("Failed to delete test user %s: %v", id, err) + } + } + } + if ts.appID != "" { + if err := testutils.DeleteApplication(ts.appID); err != nil { + ts.T().Logf("Failed to delete test application: %v", err) + } + } + for _, flowID := range ts.config.CreatedFlowIDs { + if err := testutils.DeleteFlow(flowID); err != nil { + ts.T().Logf("Failed to delete created flow %s: %v", flowID, err) + } + } + // Leaf first: a resource server with resources still attached refuses deletion, and a parent + // resource refuses it while it still has children. + for i := len(ts.resourceIDs) - 1; i >= 0; i-- { + if err := testutils.DeleteResource(ts.rsID, ts.resourceIDs[i]); err != nil { + ts.T().Logf("Failed to delete test resource %s: %v", ts.resourceIDs[i], err) + } + } + if ts.rsID != "" { + if err := testutils.DeleteResourceServer(ts.rsID); err != nil { + ts.T().Logf("Failed to delete test resource server: %v", err) + } + } + if ts.userTypeID != "" { + if err := testutils.DeleteUserType(ts.userTypeID); err != nil { + ts.T().Logf("Failed to delete test user type: %v", err) + } + } + if ts.ouID != "" { + if err := testutils.DeleteOrganizationUnit(ts.ouID); err != nil { + ts.T().Logf("Failed to delete test organization unit: %v", err) + } + } +} + +// permissionConsentFlowNodes builds credentials, then authorization, then consent. The +// authorization node is what puts authorized permissions into runtime data, which is the only +// source the permission purpose is built from. +func permissionConsentFlowNodes() []map[string]interface{} { + return []map[string]interface{}{ + { + "id": "start", + "type": "START", + "onSuccess": "prompt_credentials", + }, + { + "id": "prompt_credentials", + "type": "PROMPT", + "prompts": []map[string]interface{}{ + { + "inputs": []map[string]interface{}{ + { + "ref": "input_001", + "identifier": "username", + "type": "TEXT_INPUT", + "required": true, + }, + { + "ref": "input_002", + "identifier": "password", + "type": "PASSWORD_INPUT", + "required": true, + }, + }, + "action": map[string]interface{}{ + "ref": "action_001", + "nextNode": "credentials_auth", + }, + }, + }, + }, + { + "id": "credentials_auth", + "type": "TASK_EXECUTION", + "executor": map[string]interface{}{ + "name": "CredentialsAuthExecutor", + }, + "onSuccess": "authorization_check", + "onIncomplete": "prompt_credentials", + }, + { + "id": "authorization_check", + "type": "TASK_EXECUTION", + "executor": map[string]interface{}{ + "name": "AuthorizationExecutor", + }, + "onSuccess": "consent_check", + }, + { + "id": "consent_check", + "type": "TASK_EXECUTION", + "executor": map[string]interface{}{ + "name": "ConsentExecutor", + }, + "onSuccess": "auth_assert", + "onIncomplete": "prompt_consent", + }, + { + "id": "prompt_consent", + "type": "PROMPT", + "meta": map[string]interface{}{ + "components": []map[string]interface{}{ + { + "type": "BLOCK", + "id": "consent_block", + "components": []map[string]interface{}{ + { + "id": "consent_input", + "ref": consentInputIdentifier, + "type": "CONSENT_INPUT", + "required": true, + }, + { + "type": "ACTION", + "id": consentApproveAction, + "label": "Approve", + "eventType": "SUBMIT", + }, + }, + }, + }, + }, + "prompts": []map[string]interface{}{ + { + "inputs": []map[string]interface{}{ + { + "ref": "consent_input", + "identifier": consentInputIdentifier, + "type": "CONSENT_INPUT", + "required": true, + }, + }, + "action": map[string]interface{}{ + "ref": consentApproveAction, + "nextNode": "consent_check", + }, + }, + }, + }, + { + "id": "auth_assert", + "type": "TASK_EXECUTION", + "executor": map[string]interface{}{ + "name": "AuthAssertExecutor", + }, + "onSuccess": "end", + }, + { + "id": "end", + "type": "END", + }, + } +} + +// authenticateToPermissionConsent runs the flow to the consent prompt, requesting every permission +// including the one the user does not hold. +func (ts *PermissionConsentFlowTestSuite) authenticateToPermissionConsent(username string) *common.FlowStep { + ts.T().Helper() + + step, err := common.InitiateAuthenticationFlow(ts.appID, false, map[string]string{ + "applicationId": ts.appID, + "requested_permissions": permConsentParentPermission + " " + permConsentChildPermission + + " " + permConsentDecoyPermission + " " + permConsentUnheldPermission, + "resource_server_identifier": permConsentRSIdentifier, + }, "") + ts.Require().NoError(err, "Failed to initiate authentication flow") + ts.Require().Equal("INCOMPLETE", step.FlowStatus, "Flow should pause at the credentials prompt") + + step, err = common.CompleteFlow(step.ExecutionID, map[string]string{ + "username": username, + "password": permConsentPassword, + }, "action_001", step.ChallengeToken) + ts.Require().NoError(err, "Failed to submit credentials") + return step +} + +// requirePermissionPurpose asserts the step is a consent prompt carrying exactly one permission +// purpose, and returns it. +func (ts *PermissionConsentFlowTestSuite) requirePermissionPurpose( + step *common.FlowStep, +) consentPromptPurpose { + ts.T().Helper() + + ts.Require().Equal("INCOMPLETE", step.FlowStatus, "Consent should pause the flow for input") + ts.Require().True(common.HasInput(step.Data.Inputs, consentInputIdentifier), + "The consent prompt must request the consent decisions input") + + promptJSON, ok := step.Data.AdditionalData[consentPromptDataKey] + ts.Require().True(ok, "The consent prompt must carry the purposes to render") + + var purposes []consentPromptPurpose + ts.Require().NoError(json.Unmarshal([]byte(promptJSON), &purposes), + "Consent prompt data should be a purposes array") + ts.Require().Len(purposes, 1, + "Only the permission purpose applies; the application requests no attributes") + ts.Require().Equal(permConsentPurposeType, purposes[0].Type, + "The purpose must be typed as permissions so the Console renders it as such") + return purposes[0] +} + +// TestPermissionConsent_ElementsCarryRollupParents verifies the rollup linkage the Console groups by. +// The nested permission is linked to the one it extends; the sibling that merely shares a prefix is +// not, which is what the delimiter check in the parent computation exists for. +func (ts *PermissionConsentFlowTestSuite) TestPermissionConsent_ElementsCarryRollupParents() { + step := ts.authenticateToPermissionConsent(permConsentPromptUsername) + purpose := ts.requirePermissionPurpose(step) + + parents := make(map[string]string, len(purpose.Optional)) + for _, element := range purpose.Optional { + parents[element.Name] = element.Parent + } + + ts.Equal(permConsentParentPermission, parents[permConsentChildPermission], + "a permission nested under another must roll up to it") + ts.Empty(parents[permConsentParentPermission], + "a top-level permission has nothing to roll up to") + ts.Empty(parents[permConsentDecoyPermission], + "sharing a prefix without a delimiter is not a parent relationship") + + // The application requests this permission but the user was never granted it. Offering it for + // consent would record a decision about access the user does not hold. + ts.NotContains(parents, permConsentUnheldPermission, + "a requested but unauthorized permission must never be prompted") +} + +// TestPermissionConsent_PurposeIsNamedForTheApplication verifies the purpose name identifies the +// application, which is what scopes the recorded consent to it. +func (ts *PermissionConsentFlowTestSuite) TestPermissionConsent_PurposeIsNamedForTheApplication() { + step := ts.authenticateToPermissionConsent(permConsentPromptUsername) + purpose := ts.requirePermissionPurpose(step) + + ts.Equal("permissions:"+ts.appID, purpose.PurposeName, + "the permission purpose is derived from the application it belongs to") +} + diff --git a/tests/integration/flow/mgt/flow_inference_test.go b/tests/integration/flow/mgt/flow_inference_test.go new file mode 100644 index 0000000000..4c88aab2e0 --- /dev/null +++ b/tests/integration/flow/mgt/flow_inference_test.go @@ -0,0 +1,511 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package mgt + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/thunder-id/thunderid/tests/integration/testutils" +) + +// Registration-flow inference is off by default, so it needs the deployment flag enabled and a +// restart before any of it runs. It lives in its own suite rather than the main flow management +// suite so the restart is paid once and cannot disturb the other tests. +type FlowInferenceTestSuite struct { + suite.Suite + createdFlowIDs []string +} + +func TestFlowInferenceTestSuite(t *testing.T) { + suite.Run(t, new(FlowInferenceTestSuite)) +} + +// PatchDeploymentConfig merges at the top level only, so a patch for one key inside "flow" replaces +// the whole block. Both patches therefore restate max_version_history exactly as the integration +// deployment sets it (tests/integration/resources/deployment.yaml); dropping it silently reverts the +// limit to the product default and breaks the version-history tests in this same package. +var inferenceEnablePatch = map[string]interface{}{ + "flow": map[string]interface{}{ + "max_version_history": 3, + "auto_infer_registration": true, + }, +} + +var inferenceDisablePatch = map[string]interface{}{ + "flow": map[string]interface{}{ + "max_version_history": 3, + "auto_infer_registration": false, + }, +} + +func (suite *FlowInferenceTestSuite) SetupSuite() { + suite.Require().NoError(testutils.PatchDeploymentConfig(inferenceEnablePatch), + "failed to enable registration flow inference") + suite.Require().NoError(testutils.RestartServer(), + "failed to restart server with inference enabled") + suite.Require().NoError(testutils.ObtainAdminAccessToken(), + "failed to re-obtain admin token after restart") +} + +func (suite *FlowInferenceTestSuite) TearDownSuite() { + for _, flowID := range suite.createdFlowIDs { + if err := testutils.DeleteFlow(flowID); err != nil { + suite.T().Logf("teardown: failed to delete flow %s: %v", flowID, err) + } + } + + // These three restore global state. A silent failure here leaves inference enabled for every + // suite that runs afterwards, so report it rather than logging it. Assert rather than Require, + // so one failure does not skip the restoration steps that follow it. + suite.Assert().NoError(testutils.PatchDeploymentConfig(inferenceDisablePatch), + "teardown: failed to restore inference config") + suite.Assert().NoError(testutils.RestartServer(), + "teardown: server did not restart cleanly after config restore") + suite.Assert().NoError(testutils.ObtainAdminAccessToken(), + "teardown: failed to re-obtain admin token after restore") +} + +// listFlowsByType returns every flow of the given type, walking pages so a newly inferred flow is +// found regardless of how many already exist. +func (suite *FlowInferenceTestSuite) listFlowsByType(flowType string) []BasicFlowDefinition { + var all []BasicFlowDefinition + + for offset := 0; ; offset += 100 { + url := testServerURL + flowsEndpoint + "?flowType=" + flowType + + "&limit=100&offset=" + strconv.Itoa(offset) + req, err := http.NewRequest(http.MethodGet, url, nil) + suite.Require().NoError(err) + + resp, err := testutils.GetHTTPClient().Do(req) + suite.Require().NoError(err) + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + suite.Require().NoError(err) + suite.Require().Equal(http.StatusOK, resp.StatusCode, "failed to list flows: %s", string(body)) + + var listed FlowListResponse + suite.Require().NoError(json.Unmarshal(body, &listed)) + + all = append(all, listed.Flows...) + if len(listed.Flows) < 100 { + return all + } + } +} + +// createFlow posts a flow definition and returns the created flow. +func (suite *FlowInferenceTestSuite) createFlow(flowDef FlowDefinition) *CompleteFlowDefinition { + body, err := json.Marshal(flowDef) + suite.Require().NoError(err) + + req, err := http.NewRequest(http.MethodPost, testServerURL+flowsEndpoint, bytes.NewBuffer(body)) + suite.Require().NoError(err) + req.Header.Set("Content-Type", "application/json") + + resp, err := testutils.GetHTTPClient().Do(req) + suite.Require().NoError(err) + defer resp.Body.Close() + + bodyBytes, err := io.ReadAll(resp.Body) + suite.Require().NoError(err) + suite.Require().Equal(http.StatusCreated, resp.StatusCode, "failed to create flow: %s", string(bodyBytes)) + + var response CompleteFlowDefinition + suite.Require().NoError(json.Unmarshal(bodyBytes, &response)) + return &response +} + +// getFlow reads a flow by id. +func (suite *FlowInferenceTestSuite) getFlow(flowID string) *CompleteFlowDefinition { + req, err := http.NewRequest(http.MethodGet, testServerURL+flowsEndpoint+"/"+flowID, nil) + suite.Require().NoError(err) + + resp, err := testutils.GetHTTPClient().Do(req) + suite.Require().NoError(err) + defer resp.Body.Close() + + bodyBytes, err := io.ReadAll(resp.Body) + suite.Require().NoError(err) + suite.Require().Equal(http.StatusOK, resp.StatusCode, "failed to read flow: %s", string(bodyBytes)) + + var response CompleteFlowDefinition + suite.Require().NoError(json.Unmarshal(bodyBytes, &response)) + return &response +} + +// Creating an authentication flow with inference enabled also creates a registration flow derived +// from it, named by substituting the authentication term. This is what lets an application offer +// sign-up without an operator authoring a second flow by hand. +func (suite *FlowInferenceTestSuite) TestCreateAuthFlow_InfersRegistrationFlow() { + before := len(suite.listFlowsByType("REGISTRATION")) + + authFlow := cloneFlowWithUniqueHandle(testAuthFlow) + authFlow.Name = "Inference Probe Authentication Flow" + created := suite.createFlow(authFlow) + suite.createdFlowIDs = append(suite.createdFlowIDs, created.ID) + + after := suite.listFlowsByType("REGISTRATION") + suite.Greater(len(after), before, "creating an authentication flow should infer a registration flow") + + found := false + for _, flow := range after { + if strings.Contains(flow.Name, "Inference Probe") { + found = true + suite.Equal("REGISTRATION", flow.FlowType) + suite.Contains(flow.Name, "Registration", + "the inferred flow should be renamed from Authentication to Registration") + suite.createdFlowIDs = append(suite.createdFlowIDs, flow.ID) + } + } + suite.True(found, "expected a registration flow inferred from the probe authentication flow") +} + +// The inferred flow is a registration flow in its own right, so it must carry the provisioning node +// that turns collected credentials into a user. Without it the inferred flow would complete without +// creating anything. +func (suite *FlowInferenceTestSuite) TestInferredRegistrationFlow_ContainsProvisioningNode() { + authFlow := cloneFlowWithUniqueHandle(testAuthFlow) + authFlow.Name = "Provisioning Probe Signin Flow" + created := suite.createFlow(authFlow) + suite.createdFlowIDs = append(suite.createdFlowIDs, created.ID) + + var inferredID string + for _, flow := range suite.listFlowsByType("REGISTRATION") { + if strings.Contains(flow.Name, "Provisioning Probe") { + inferredID = flow.ID + suite.createdFlowIDs = append(suite.createdFlowIDs, flow.ID) + } + } + suite.Require().NotEmpty(inferredID, "expected a registration flow inferred from the probe flow") + + inferred := suite.getFlow(inferredID) + + hasProvisioning := false + for _, node := range inferred.Nodes { + if node.Executor != nil && node.Executor.Name == "ProvisioningExecutor" { + hasProvisioning = true + } + } + suite.True(hasProvisioning, "an inferred registration flow must provision the user it registers") +} + +// Inference only applies to authentication flows. Creating a registration flow directly must not +// produce a further flow derived from it. +func (suite *FlowInferenceTestSuite) TestCreateNonAuthFlow_DoesNotInfer() { + before := len(suite.listFlowsByType("REGISTRATION")) + + regFlow := cloneFlowWithUniqueHandle(testAuthFlow) + regFlow.Name = "Non Auth Probe Flow" + regFlow.FlowType = "REGISTRATION" + // A registration flow must resolve a user type and provision the user it registers, so the cloned + // authentication nodes need both executors before the flow is accepted. + regFlow.Nodes = withRegistrationExecutors(regFlow.Nodes) + + created := suite.createFlow(regFlow) + suite.createdFlowIDs = append(suite.createdFlowIDs, created.ID) + + after := suite.listFlowsByType("REGISTRATION") + suite.Equal(before+1, len(after), + "creating a registration flow should add exactly one flow, with nothing inferred from it") +} + +// Flow validation requires each flow type to carry the executor that makes it meaningful, so a +// registration flow without a provisioning step is rejected rather than stored and left to fail at +// execution time. +func (suite *FlowInferenceTestSuite) TestCreateRegistrationFlow_RequiresProvisioningExecutor() { + regFlow := cloneFlowWithUniqueHandle(testAuthFlow) + regFlow.Name = "Missing Provisioning Probe Flow" + regFlow.FlowType = "REGISTRATION" + + body, err := json.Marshal(regFlow) + suite.Require().NoError(err) + + req, err := http.NewRequest(http.MethodPost, testServerURL+flowsEndpoint, bytes.NewBuffer(body)) + suite.Require().NoError(err) + req.Header.Set("Content-Type", "application/json") + + resp, err := testutils.GetHTTPClient().Do(req) + suite.Require().NoError(err) + defer resp.Body.Close() + + bodyBytes, err := io.ReadAll(resp.Body) + suite.Require().NoError(err) + suite.Equal(http.StatusBadRequest, resp.StatusCode, + "a registration flow without a provisioning executor must be rejected: %s", string(bodyBytes)) + + var errResp ErrorResponse + suite.Require().NoError(json.Unmarshal(bodyBytes, &errResp)) + suite.Equal("FLM-1023", errResp.Code) +} + +// smsAuthFlowNodes builds an SMS OTP authentication flow whose identify prompt carries authentication +// specific UI: a sign-in heading and a self sign-up link, both of which inference has to rewrite. +func smsAuthFlowNodes() []map[string]interface{} { + return []map[string]interface{}{ + { + "id": "start", + "type": "START", + "onSuccess": "prompt_identify", + }, + { + "id": "prompt_identify", + "type": "PROMPT", + "meta": map[string]interface{}{ + "components": []map[string]interface{}{ + { + "type": "TEXT", + "id": "heading_001", + "label": "Sign In to Continue", + "variant": "HEADING_1", + }, + { + "type": "BLOCK", + "id": "block_001", + "components": []map[string]interface{}{ + { + "type": "TEXT_INPUT", + "id": "input_001", + "label": "Username", + "required": true, + }, + { + "type": "ACTION", + "id": "action_001", + "label": "Continue", + }, + { + "type": "RICH_TEXT", + "id": "signup_link_001", + "label": `Create an account`, + }, + }, + }, + }, + }, + "prompts": []map[string]interface{}{ + { + "inputs": []map[string]interface{}{ + { + "ref": "input_001", + "identifier": "username", + "type": "TEXT_INPUT", + "required": true, + }, + }, + "action": map[string]interface{}{ + "ref": "action_001", + "nextNode": "generate_otp", + }, + }, + }, + }, + { + "id": "generate_otp", + "type": "TASK_EXECUTION", + "executor": map[string]interface{}{ + "name": "OTPExecutor", + "mode": "generate", + "inputs": []map[string]interface{}{ + { + "ref": "input_mobile", + "identifier": "mobile_number", + "type": "PHONE_INPUT", + "required": true, + }, + }, + }, + "onSuccess": "prompt_otp", + }, + { + "id": "prompt_otp", + "type": "PROMPT", + "prompts": []map[string]interface{}{ + { + "inputs": []map[string]interface{}{ + { + "ref": "input_002", + "identifier": "otp", + "type": "OTP_INPUT", + "required": true, + }, + }, + "action": map[string]interface{}{ + "ref": "action_002", + "nextNode": "verify_otp", + }, + }, + }, + }, + { + "id": "verify_otp", + "type": "TASK_EXECUTION", + "executor": map[string]interface{}{ + "name": "OTPExecutor", + "mode": "verify", + }, + "onSuccess": "auth_assert", + }, + { + "id": "auth_assert", + "type": "TASK_EXECUTION", + "executor": map[string]interface{}{ + "name": "AuthAssertExecutor", + }, + "onSuccess": "end", + }, + { + "id": "end", + "type": "END", + }, + } +} + +// createRawFlow creates a flow from a raw definition, which the inference fixtures need because they +// use node shapes (executor modes and inputs, prompt meta) that the typed test model does not carry. +func (suite *FlowInferenceTestSuite) createRawFlow(name, handle string, + nodes []map[string]interface{}) string { + flowID, err := testutils.CreateFlow(testutils.Flow{ + Name: name, + FlowType: "AUTHENTICATION", + Handle: handle, + Nodes: nodes, + }) + suite.Require().NoError(err, "failed to create flow %s", handle) + suite.createdFlowIDs = append(suite.createdFlowIDs, flowID) + return flowID +} + +// getRawFlow reads a flow as generic JSON so prompt and meta contents can be walked. +func (suite *FlowInferenceTestSuite) getRawFlow(flowID string) map[string]interface{} { + req, err := http.NewRequest(http.MethodGet, testServerURL+flowsEndpoint+"/"+flowID, nil) + suite.Require().NoError(err) + + resp, err := testutils.GetHTTPClient().Do(req) + suite.Require().NoError(err) + defer resp.Body.Close() + + bodyBytes, err := io.ReadAll(resp.Body) + suite.Require().NoError(err) + suite.Require().Equal(http.StatusOK, resp.StatusCode, "failed to read flow: %s", string(bodyBytes)) + + var flow map[string]interface{} + suite.Require().NoError(json.Unmarshal(bodyBytes, &flow)) + return flow +} + +// inferredFlowIDFor returns the id of the registration flow inferred from the named authentication +// flow, tracking it for cleanup. +func (suite *FlowInferenceTestSuite) inferredFlowIDFor(namePart string) string { + for _, flow := range suite.listFlowsByType("REGISTRATION") { + if strings.Contains(flow.Name, namePart) { + suite.createdFlowIDs = append(suite.createdFlowIDs, flow.ID) + return flow.ID + } + } + return "" +} + +// nodesOf returns the node list of a raw flow. +func nodesOf(flow map[string]interface{}) []interface{} { + nodes, ok := flow["nodes"].([]interface{}) + if !ok { + return nil + } + return nodes +} + +// flowMetaJSON returns the concatenated meta of every node, as JSON text, so label rewrites and +// component removals can be asserted without walking the whole component tree. +func flowMetaJSON(suite *FlowInferenceTestSuite, flow map[string]interface{}) string { + var builder strings.Builder + for _, raw := range nodesOf(flow) { + node, ok := raw.(map[string]interface{}) + if !ok { + continue + } + meta, ok := node["meta"] + if !ok { + continue + } + encoded, err := json.Marshal(meta) + suite.Require().NoError(err) + builder.Write(encoded) + } + return builder.String() +} + +// The inferred flow carries the source flow's UI, so its authentication wording and its links back to +// sign-up have to be rewritten: a sign-in heading becomes a sign-up heading, and the self sign-up link +// is dropped because the inferred flow is the sign-up. +func (suite *FlowInferenceTestSuite) TestInferredRegistrationFlow_RewritesPromptMeta() { + authID := suite.createRawFlow("Meta Probe Authentication Flow", + "auth_flow_inference_meta_probe", smsAuthFlowNodes()) + suite.Require().NotEmpty(authID) + + inferredID := suite.inferredFlowIDFor("Meta Probe") + suite.Require().NotEmpty(inferredID, "expected a registration flow inferred from the meta probe flow") + + meta := flowMetaJSON(suite, suite.getRawFlow(inferredID)) + suite.Require().NotEmpty(meta, "the inferred flow should carry the source flow's prompt meta") + + suite.Contains(meta, "Sign Up to Continue", + "an authentication heading must be rewritten to its registration equivalent") + suite.NotContains(meta, "Sign In to Continue", + "the authentication heading must not survive in the inferred flow") + suite.NotContains(meta, "self-sign-up-link", + "a self sign-up link is meaningless inside the inferred sign-up flow") +} + +// withRegistrationExecutors inserts the executors a registration flow must carry, ahead of the END +// node, producing a definition that satisfies flow-type validation. Registration requires both a +// user type resolver and a provisioning step (see requiredExecutorsByFlowType in the validator). +func withRegistrationExecutors(nodes []NodeDefinition) []NodeDefinition { + const ( + resolverID = "resolve_user_type" + provisionID = "provision_user" + ) + + out := make([]NodeDefinition, 0, len(nodes)+2) + for _, node := range nodes { + if node.Type == "END" { + out = append(out, + NodeDefinition{ + ID: resolverID, + Type: "TASK_EXECUTION", + Executor: &ExecutorDefinition{Name: "UserTypeResolver"}, + OnSuccess: provisionID, + }, + NodeDefinition{ + ID: provisionID, + Type: "TASK_EXECUTION", + Executor: &ExecutorDefinition{Name: "ProvisioningExecutor"}, + OnSuccess: node.ID, + }, + node, + ) + continue + } + if node.OnSuccess != "" && isEndNode(nodes, node.OnSuccess) { + node.OnSuccess = resolverID + } + out = append(out, node) + } + return out +} + +func isEndNode(nodes []NodeDefinition, id string) bool { + for _, node := range nodes { + if node.ID == id && node.Type == "END" { + return true + } + } + return false +} diff --git a/tests/integration/importexport/import_delete_test.go b/tests/integration/importexport/import_delete_test.go new file mode 100644 index 0000000000..240faf47e5 --- /dev/null +++ b/tests/integration/importexport/import_delete_test.go @@ -0,0 +1,186 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package importexport + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/thunder-id/thunderid/tests/integration/testutils" +) + +// ImportDeleteSuite covers the success path of POST /import/delete, which removes a declarative +// resource file from the server's configuration directory. The error branches are covered by +// ImportExportErrorSuite; this suite is the one that actually deletes a file. +// +// The fixture file is written directly into config/resources/themes rather than through the import +// API, because POST /import rejects the file target: writing declarative files is not an API +// operation, only removing them is. Declarative resources are read once at startup with no watcher, +// so adding a file mid-run affects nothing already loaded, and the delete under test removes it +// again. The file carries a handle of this suite's own so the shipped declarative theme is never a +// candidate match. +type ImportDeleteSuite struct { + suite.Suite + client *http.Client + + themesDir string + fixturePath string + fixtureExtra string +} + +const ( + // importDeleteThemeID is the id of the throwaway declarative theme the suite plants and deletes. + importDeleteThemeID = "import-delete-fixture-theme" + + // importDeleteExtraThemeID lives in a second file that must survive the delete, so the scan is + // shown to remove only the matching document's file. + importDeleteExtraThemeID = "import-delete-bystander-theme" +) + +func TestImportDeleteSuite(t *testing.T) { + suite.Run(t, new(ImportDeleteSuite)) +} + +func (suite *ImportDeleteSuite) SetupSuite() { + suite.client = testutils.GetHTTPClient() + + suite.themesDir = filepath.Join(testutils.GetExtractedProductHome(), "config", "resources", "themes") + suite.Require().DirExists(suite.themesDir, + "the declarative themes directory must exist for the delete to have a target") + + suite.fixturePath = filepath.Join(suite.themesDir, importDeleteThemeID+".yaml") + suite.fixtureExtra = filepath.Join(suite.themesDir, importDeleteExtraThemeID+".yaml") +} + +// SetupTest re-plants both files so each test starts from the same on-disk state. +func (suite *ImportDeleteSuite) SetupTest() { + suite.writeThemeFile(suite.fixturePath, importDeleteThemeID, "Import Delete Fixture Theme") + suite.writeThemeFile(suite.fixtureExtra, importDeleteExtraThemeID, "Import Delete Bystander Theme") +} + +func (suite *ImportDeleteSuite) TearDownSuite() { + for _, path := range []string{suite.fixturePath, suite.fixtureExtra} { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + suite.T().Logf("Failed to remove the fixture file %s: %v", path, err) + } + } +} + +// writeThemeFile plants a declarative theme document on disk. +func (suite *ImportDeleteSuite) writeThemeFile(path, id, displayName string) { + suite.T().Helper() + + content := "resource_type: theme\n" + + "id: " + id + "\n" + + "displayName: " + displayName + "\n" + + "description: Planted by the import delete integration test\n" + + "theme:\n" + + " primaryColor: \"#123456\"\n" + + suite.Require().NoError(os.WriteFile(path, []byte(content), 0o600)) +} + +// deleteResource posts an /import/delete request and returns the status with the raw body. +func (suite *ImportDeleteSuite) deleteResource(resourceType, resourceKey string) (int, []byte) { + suite.T().Helper() + + body, err := json.Marshal(map[string]string{ + "resourceType": resourceType, + "resourceKey": resourceKey, + }) + suite.Require().NoError(err) + + req, err := http.NewRequest(http.MethodPost, + testutils.TestServerURL+"/import/delete", bytes.NewReader(body)) + suite.Require().NoError(err) + req.Header.Set("Content-Type", "application/json") + + resp, err := suite.client.Do(req) + suite.Require().NoError(err) + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil { + suite.T().Logf("Failed to close response body: %v", closeErr) + } + }() + + respBody, err := io.ReadAll(resp.Body) + suite.Require().NoError(err) + return resp.StatusCode, respBody +} + +// deleteResponse mirrors the DeleteResourceResponse payload. +type deleteResponse struct { + ResourceType string `json:"resourceType"` + ResourceKey string `json:"resourceKey"` + DeletedFile string `json:"deletedFile"` +} + +// TestDeleteByIDRemovesTheFile verifies the matching file is removed from disk and reported back by +// name, which is what tells the caller which file was actually acted on. +func (suite *ImportDeleteSuite) TestDeleteByIDRemovesTheFile() { + status, body := suite.deleteResource("theme", importDeleteThemeID) + suite.Require().Equalf(http.StatusOK, status, "unexpected status, body: %s", body) + + var resp deleteResponse + suite.Require().NoError(json.Unmarshal(body, &resp)) + suite.Equal("theme", resp.ResourceType) + suite.Equal(importDeleteThemeID, resp.ResourceKey) + suite.Equal(importDeleteThemeID+".yaml", resp.DeletedFile) + + suite.NoFileExists(suite.fixturePath, "the matching file must be gone from disk") + suite.FileExists(suite.fixtureExtra, + "a non-matching file in the same directory must be left alone") +} + +// TestDeleteByDisplayNameRemovesTheFile verifies the resource key is matched against the document's +// name as well as its id, so a caller need not know the generated identifier. +func (suite *ImportDeleteSuite) TestDeleteByDisplayNameRemovesTheFile() { + status, body := suite.deleteResource("theme", "Import Delete Fixture Theme") + suite.Require().Equalf(http.StatusOK, status, "unexpected status, body: %s", body) + + var resp deleteResponse + suite.Require().NoError(json.Unmarshal(body, &resp)) + suite.Equal(importDeleteThemeID+".yaml", resp.DeletedFile) + + suite.NoFileExists(suite.fixturePath) +} + +// TestDeleteIsNotIdempotent verifies a second delete of the same key is refused rather than +// answering 200 for a file that is no longer there, so a caller cannot read success as confirmation +// that a resource it never planted has been removed. +func (suite *ImportDeleteSuite) TestDeleteIsNotIdempotent() { + status, _ := suite.deleteResource("theme", importDeleteThemeID) + suite.Require().Equal(http.StatusOK, status) + + status, body := suite.deleteResource("theme", importDeleteThemeID) + suite.Equal(http.StatusBadRequest, status) + suite.Equal("IMP-1001", suite.errorCodeOf(body)) +} + +// TestDeleteDoesNotMatchAnotherResourceType verifies the resource type is part of the match: a theme +// key must not delete a file whose documents are of a different type. +func (suite *ImportDeleteSuite) TestDeleteDoesNotMatchAnotherResourceType() { + status, body := suite.deleteResource("layout", importDeleteThemeID) + + suite.Equal(http.StatusBadRequest, status) + suite.Equal("IMP-1001", suite.errorCodeOf(body)) + suite.FileExists(suite.fixturePath, "a type mismatch must not delete the theme file") +} + +// errorCodeOf decodes the error code from an API error response body. +func (suite *ImportDeleteSuite) errorCodeOf(body []byte) string { + suite.T().Helper() + + var errResp struct { + Code string `json:"code"` + } + suite.Require().NoError(json.Unmarshal(body, &errResp), "body: %s", body) + return errResp.Code +} diff --git a/tests/integration/oauth/authz/prompt_parameter_test.go b/tests/integration/oauth/authz/prompt_parameter_test.go new file mode 100644 index 0000000000..17038f05e8 --- /dev/null +++ b/tests/integration/oauth/authz/prompt_parameter_test.go @@ -0,0 +1,245 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package authz + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/thunder-id/thunderid/tests/integration/testutils" +) + +const ( + promptClientID = "authz_prompt_test_client" + promptRedirectURI = "https://localhost:3000/prompt-callback" +) + +var promptTestOU = testutils.OrganizationUnit{ + Handle: "oauth2-prompt-test-ou", + Name: "OAuth2 Prompt Test OU", + Description: "Organization unit for the OIDC prompt parameter tests", + Parent: nil, +} + +// PromptParameterTestSuite covers the OIDC prompt parameter contract of the authorization endpoint +// (OIDC Core 3.1.2.1). Rejections arrive as error redirects to the client's redirect URI, so each +// case asserts the error code carried in that redirect. +type PromptParameterTestSuite struct { + suite.Suite + client *http.Client + ouID string + applicationID string +} + +func TestPromptParameterTestSuite(t *testing.T) { + suite.Run(t, new(PromptParameterTestSuite)) +} + +func (ts *PromptParameterTestSuite) SetupSuite() { + ts.client = testutils.GetHTTPClient() + + ouID, err := testutils.CreateOrganizationUnit(promptTestOU) + ts.Require().NoError(err, "Failed to create the test organization unit") + ts.ouID = ouID + + // The shipped default authentication flow is enough: no case here completes authentication. + authFlowID, err := testutils.GetFlowIDByHandle("default-flow", "AUTHENTICATION") + ts.Require().NoError(err, "Failed to resolve the default authentication flow") + + app := map[string]interface{}{ + "name": "OAuth2 Prompt Test App", + "description": "Application for the OIDC prompt parameter tests", + "ouId": ts.ouID, + "type": "fullstack", + "authFlowId": authFlowID, + "isRegistrationFlowEnabled": false, + "inboundAuthConfig": []map[string]interface{}{ + { + "type": "oauth2", + "config": map[string]interface{}{ + "clientId": promptClientID, + "clientSecret": "authz_prompt_test_secret", + "redirectUris": []string{promptRedirectURI}, + "grantTypes": []string{"authorization_code", "refresh_token"}, + "responseTypes": []string{"code"}, + "tokenEndpointAuthMethod": "client_secret_basic", + }, + }, + }, + } + + jsonData, err := json.Marshal(app) + ts.Require().NoError(err, "Failed to encode the application payload") + + req, err := http.NewRequest(http.MethodPost, testutils.TestServerURL+"/applications", bytes.NewBuffer(jsonData)) + ts.Require().NoError(err, "Failed to create the application request") + req.Header.Set("Content-Type", "application/json") + + resp, err := ts.client.Do(req) + ts.Require().NoError(err, "Failed to send the application request") + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + ts.Require().NoError(err, "Failed to read the application response") + ts.Require().Equal(http.StatusCreated, resp.StatusCode, + "Creating the test application should return 201: %s", string(body)) + + var created struct { + ID string `json:"id"` + } + ts.Require().NoError(json.Unmarshal(body, &created), "Failed to parse the application response") + ts.applicationID = created.ID +} + +func (ts *PromptParameterTestSuite) TearDownSuite() { + if ts.applicationID != "" { + req, err := http.NewRequest(http.MethodDelete, + fmt.Sprintf("%s/applications/%s", testutils.TestServerURL, ts.applicationID), nil) + if err == nil { + resp, doErr := ts.client.Do(req) + if doErr != nil { + ts.T().Logf("Failed to delete the test application during teardown: %v", doErr) + } else { + resp.Body.Close() + } + } + } + if ts.ouID != "" { + if err := testutils.DeleteOrganizationUnit(ts.ouID); err != nil { + ts.T().Logf("Failed to delete the test organization unit during teardown: %v", err) + } + } +} + +// authorize issues an authorization request carrying the given prompt value. +func (ts *PromptParameterTestSuite) authorize(prompt string, promptSet bool) *http.Response { + params := url.Values{} + params.Set("client_id", promptClientID) + params.Set("redirect_uri", promptRedirectURI) + params.Set("response_type", "code") + params.Set("scope", "openid") + params.Set("state", "prompt-test-state") + if promptSet { + params.Set("prompt", prompt) + } + + resp, err := testutils.SubmitAuthorizationRequest(params) + ts.Require().NoError(err, "Failed to send the authorization request") + return resp +} + +// TestRejectedPromptValues asserts that every prompt value the server cannot honour is reported as an +// error redirect to the client, with the error code OIDC prescribes for it. +func (ts *PromptParameterTestSuite) TestRejectedPromptValues() { + testCases := []struct { + name string + prompt string + expectedError string + }{ + { + name: "empty prompt", + prompt: "", + expectedError: "invalid_request", + }, + { + name: "whitespace only prompt", + prompt: " ", + expectedError: "invalid_request", + }, + { + name: "unsupported prompt value", + prompt: "reauthenticate", + expectedError: "invalid_request", + }, + { + name: "none combined with another value", + prompt: "none login", + expectedError: "invalid_request", + }, + { + // The server does not serve an authorization request without user interaction, so a + // request that forbids interaction is answered with login_required. + name: "none", + prompt: "none", + expectedError: "login_required", + }, + { + // Account selection is not implemented, which OIDC represents with its own error code + // rather than a generic invalid_request. + name: "select_account", + prompt: "select_account", + expectedError: "account_selection_required", + }, + } + + for _, tc := range testCases { + ts.Run(tc.name, func() { + resp := ts.authorize(tc.prompt, true) + defer resp.Body.Close() + + ts.Require().Equal(http.StatusFound, resp.StatusCode, + "A rejected prompt must be reported as a redirect") + location := resp.Header.Get("Location") + ts.Require().NotEmpty(location, "The rejection must carry a redirect location") + + parsed, err := url.Parse(location) + ts.Require().NoError(err, "Failed to parse the redirect location") + ts.Equal(promptRedirectURI, parsed.Scheme+"://"+parsed.Host+parsed.Path, + "The error must be redirected to the client's registered redirect URI") + ts.Equal("prompt-test-state", parsed.Query().Get("state"), + "An error redirect must echo the request's state") + ts.Require().NoError(testutils.ValidateOAuth2ErrorRedirect(location, tc.expectedError, ""), + "Unexpected error for prompt %q in redirect %s", tc.prompt, location) + }) + } +} + +// TestAcceptedPromptValues asserts that the interactive prompt values are accepted: the request is +// not turned into an error redirect but continues to the login application. Being accepted at all is +// the assertion here, since these values only affect how authentication is presented. +func (ts *PromptParameterTestSuite) TestAcceptedPromptValues() { + for _, prompt := range []string{"login", "consent", "login consent"} { + ts.Run("prompt "+prompt, func() { + resp := ts.authorize(prompt, true) + defer resp.Body.Close() + + ts.Require().Equal(http.StatusFound, resp.StatusCode, + "An accepted authorization request redirects to the login application") + location := resp.Header.Get("Location") + ts.Require().NotEmpty(location, "The redirect must carry a location") + + parsed, err := url.Parse(location) + ts.Require().NoError(err, "Failed to parse the redirect location") + ts.Empty(parsed.Query().Get("error"), + "An accepted prompt value must not produce an OAuth2 error: %s", location) + ts.Empty(parsed.Query().Get("errorCode"), + "An accepted prompt value must not produce a server error page: %s", location) + ts.NotEqual(promptRedirectURI, parsed.Scheme+"://"+parsed.Host+parsed.Path, + "An accepted request is not redirected back to the client yet: %s", location) + }) + } +} + +// TestOmittedPromptIsAccepted asserts that prompt is optional: omitting it entirely leaves the +// request untouched by prompt validation. +func (ts *PromptParameterTestSuite) TestOmittedPromptIsAccepted() { + resp := ts.authorize("", false) + defer resp.Body.Close() + + ts.Require().Equal(http.StatusFound, resp.StatusCode, + "An authorization request without prompt redirects to the login application") + location := resp.Header.Get("Location") + ts.Require().NotEmpty(location, "The redirect must carry a location") + + parsed, err := url.Parse(location) + ts.Require().NoError(err, "Failed to parse the redirect location") + ts.Empty(parsed.Query().Get("error"), "Omitting prompt must not produce an OAuth2 error: %s", location) +} diff --git a/tests/integration/ou/model.go b/tests/integration/ou/model.go index b6c9ffe7ff..5e66378d42 100644 --- a/tests/integration/ou/model.go +++ b/tests/integration/ou/model.go @@ -94,6 +94,23 @@ type GroupListResponse struct { Links []testutils.Link `json:"links"` } +// Role represents a role with basic information for OU endpoints. +type Role struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + IsReadOnly bool `json:"isReadOnly"` +} + +// RoleListResponse represents the response for listing roles in an organization unit. +type RoleListResponse struct { + TotalResults int `json:"totalResults"` + StartIndex int `json:"startIndex"` + Count int `json:"count"` + Roles []Role `json:"roles"` + Links []testutils.Link `json:"links"` +} + type I18nMessage struct { Key string `json:"key,omitempty"` DefaultValue string `json:"defaultValue,omitempty"` diff --git a/tests/integration/ou/ou_roles_api_test.go b/tests/integration/ou/ou_roles_api_test.go new file mode 100644 index 0000000000..0c0e9b5d65 --- /dev/null +++ b/tests/integration/ou/ou_roles_api_test.go @@ -0,0 +1,251 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package ou + +import ( + "encoding/json" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/thunder-id/thunderid/tests/integration/testutils" +) + +// OURolesAPITestSuite covers the two role-listing endpoints on organization units, +// GET /organization-units/{id}/roles and GET /organization-units/tree/{path...}/roles. +// +// Roles live in a different database from organization units, so the OU package reaches them +// through the OURoleResolver adapter rather than joining the ROLE table. Both endpoints are +// therefore exercised against a real role fixture, and against a sibling OU, to confirm the +// resolver scopes its count and its page to the requested OU instead of returning every role. +// +// Fixture topology: +// +// roles-parent-ou (2 roles) +// └── roles-child-ou (1 role) ← nested so the path form is exercised on a multi-segment path +// roles-empty-ou (0 roles) +type OURolesAPITestSuite struct { + suite.Suite + + parentOUID string + childOUID string + emptyOUID string + + parentRoleIDs []string + childRoleID string +} + +const ( + rolesParentOUHandle = "roles-parent-ou" + rolesChildOUHandle = "roles-child-ou" + rolesEmptyOUHandle = "roles-empty-ou" + + rolesChildOUPath = rolesParentOUHandle + "/" + rolesChildOUHandle +) + +func TestOURolesAPITestSuite(t *testing.T) { + suite.Run(t, new(OURolesAPITestSuite)) +} + +func (suite *OURolesAPITestSuite) SetupSuite() { + parentID, err := testutils.CreateOrganizationUnit(testutils.OrganizationUnit{ + Handle: rolesParentOUHandle, + Name: "Roles Parent OU", + Description: "Parent OU for the OU role listing tests", + }) + suite.Require().NoError(err, "Failed to create parent OU") + suite.parentOUID = parentID + + childID, err := testutils.CreateOrganizationUnit(testutils.OrganizationUnit{ + Handle: rolesChildOUHandle, + Name: "Roles Child OU", + Description: "Child OU for the OU role listing tests", + Parent: &parentID, + }) + suite.Require().NoError(err, "Failed to create child OU") + suite.childOUID = childID + + emptyID, err := testutils.CreateOrganizationUnit(testutils.OrganizationUnit{ + Handle: rolesEmptyOUHandle, + Name: "Roles Empty OU", + Description: "OU deliberately left without roles", + }) + suite.Require().NoError(err, "Failed to create empty OU") + suite.emptyOUID = emptyID + + for _, name := range []string{"ou-roles-first", "ou-roles-second"} { + roleID, err := testutils.CreateRole(testutils.Role{ + Name: name, + Description: "Role in the parent OU", + OUID: suite.parentOUID, + }) + suite.Require().NoError(err, "Failed to create role %s", name) + suite.parentRoleIDs = append(suite.parentRoleIDs, roleID) + } + + childRoleID, err := testutils.CreateRole(testutils.Role{ + Name: "ou-roles-child", + Description: "Role in the child OU", + OUID: suite.childOUID, + }) + suite.Require().NoError(err, "Failed to create child OU role") + suite.childRoleID = childRoleID +} + +func (suite *OURolesAPITestSuite) TearDownSuite() { + for _, id := range append(suite.parentRoleIDs, suite.childRoleID) { + if id != "" { + if err := testutils.DeleteRole(id); err != nil { + suite.T().Logf("Failed to delete role %s: %v", id, err) + } + } + } + for _, id := range []string{suite.childOUID, suite.parentOUID, suite.emptyOUID} { + if id != "" { + if err := testutils.DeleteOrganizationUnit(id); err != nil { + suite.T().Logf("Failed to delete OU %s: %v", id, err) + } + } + } +} + +// listRoles issues a role listing request and returns the decoded response. +func (suite *OURolesAPITestSuite) listRoles(path string) RoleListResponse { + suite.T().Helper() + + req, err := http.NewRequest("GET", testServerURL+path, nil) + suite.Require().NoError(err) + + resp, err := testutils.GetHTTPClient().Do(req) + suite.Require().NoError(err) + defer func() { + if err := resp.Body.Close(); err != nil { + suite.T().Logf("Failed to close response body: %v", err) + } + }() + + body, err := io.ReadAll(resp.Body) + suite.Require().NoError(err) + suite.Require().Equalf(http.StatusOK, resp.StatusCode, "unexpected status, body: %s", body) + + var rolesResponse RoleListResponse + suite.Require().NoError(json.Unmarshal(body, &rolesResponse)) + return rolesResponse +} + +// roleIDs collects the IDs from a listing so membership can be asserted independently of order. +func roleIDs(response RoleListResponse) []string { + ids := make([]string, 0, len(response.Roles)) + for _, role := range response.Roles { + ids = append(ids, role.ID) + } + return ids +} + +// TestGetOrganizationUnitRoles verifies the ID-based endpoint lists exactly the OU's own roles. +func (suite *OURolesAPITestSuite) TestGetOrganizationUnitRoles() { + response := suite.listRoles("/organization-units/" + suite.parentOUID + "/roles") + + suite.Equal(2, response.TotalResults) + suite.Equal(1, response.StartIndex) + suite.Equal(len(response.Roles), response.Count) + + ids := roleIDs(response) + for _, expected := range suite.parentRoleIDs { + suite.Containsf(ids, expected, "parent OU role %s missing, got %v", expected, ids) + } + // The child OU's role must not leak into the parent's listing; the endpoint lists the OU's own + // roles, not the subtree's. + suite.NotContainsf(ids, suite.childRoleID, + "child OU role must not appear in the parent listing, got %v", ids) + + for _, role := range response.Roles { + suite.NotEmpty(role.Name, "role name must be populated") + suite.False(role.IsReadOnly, "API-created roles are mutable") + } +} + +// TestGetOrganizationUnitRolesPagination verifies limit and offset are honoured, since the count and +// the page are resolved by two separate calls into the role store. +func (suite *OURolesAPITestSuite) TestGetOrganizationUnitRolesPagination() { + first := suite.listRoles("/organization-units/" + suite.parentOUID + "/roles?limit=1&offset=0") + suite.Equal(2, first.TotalResults, "total must report every role, not just the page") + suite.Equal(1, first.Count) + suite.Equal(1, first.StartIndex) + + second := suite.listRoles("/organization-units/" + suite.parentOUID + "/roles?limit=1&offset=1") + suite.Equal(2, second.TotalResults) + suite.Equal(1, second.Count) + suite.Equal(2, second.StartIndex) + + suite.NotEqual(roleIDs(first)[0], roleIDs(second)[0], + "the second page must not repeat the first page's role") +} + +// TestGetOrganizationUnitRolesEmpty verifies an OU with no roles returns an empty listing rather +// than every role in the deployment. +func (suite *OURolesAPITestSuite) TestGetOrganizationUnitRolesEmpty() { + response := suite.listRoles("/organization-units/" + suite.emptyOUID + "/roles") + + suite.Equal(0, response.TotalResults) + suite.Equal(0, response.Count) + suite.Empty(response.Roles) +} + +// TestGetNonExistentOrganizationUnitRoles verifies the OU is resolved before its roles are listed. +func (suite *OURolesAPITestSuite) TestGetNonExistentOrganizationUnitRoles() { + req, err := http.NewRequest("GET", testServerURL+"/organization-units/non-existent-id/roles", nil) + suite.Require().NoError(err) + + resp, err := testutils.GetHTTPClient().Do(req) + suite.Require().NoError(err) + defer resp.Body.Close() + + suite.Equal(http.StatusNotFound, resp.StatusCode) + + var errorResp ErrorResponse + suite.Require().NoError(json.NewDecoder(resp.Body).Decode(&errorResp)) + suite.Equal("OU-1003", errorResp.Code) +} + +// TestGetOrganizationUnitRolesByPath verifies the handle-path form resolves the same listing, +// exercised on a nested path so the multi-segment case is covered. +func (suite *OURolesAPITestSuite) TestGetOrganizationUnitRolesByPath() { + response := suite.listRoles("/organization-units/tree/" + rolesChildOUPath + "/roles") + + suite.Equal(1, response.TotalResults) + suite.Equal(1, response.Count) + suite.Equal(1, response.StartIndex) + suite.Equal([]string{suite.childRoleID}, roleIDs(response)) +} + +// TestGetOrganizationUnitRolesByPathRootHandle verifies the single-segment path form, where the +// "/roles" suffix has to be stripped from a path with nothing preceding the handle. +func (suite *OURolesAPITestSuite) TestGetOrganizationUnitRolesByPathRootHandle() { + response := suite.listRoles("/organization-units/tree/" + rolesParentOUHandle + "/roles") + + suite.Equal(2, response.TotalResults) + ids := roleIDs(response) + for _, expected := range suite.parentRoleIDs { + suite.Containsf(ids, expected, "parent OU role %s missing, got %v", expected, ids) + } +} + +// TestGetOrganizationUnitRolesByInvalidPath verifies an unresolvable handle path is a 404 rather +// than an empty listing, which would read as "this OU has no roles". +func (suite *OURolesAPITestSuite) TestGetOrganizationUnitRolesByInvalidPath() { + req, err := http.NewRequest("GET", testServerURL+"/organization-units/tree/nonexistent/roles", nil) + suite.Require().NoError(err) + + resp, err := testutils.GetHTTPClient().Do(req) + suite.Require().NoError(err) + defer resp.Body.Close() + + suite.Equal(http.StatusNotFound, resp.StatusCode) + + var errorResp ErrorResponse + suite.Require().NoError(json.NewDecoder(resp.Body).Decode(&errorResp)) + suite.Equal("OU-1003", errorResp.Code) +} diff --git a/tests/integration/role/role_authz_test.go b/tests/integration/role/role_authz_test.go new file mode 100644 index 0000000000..4a3014ba75 --- /dev/null +++ b/tests/integration/role/role_authz_test.go @@ -0,0 +1,446 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package role + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/thunder-id/thunderid/tests/integration/testutils" +) + +// RoleAuthzTestSuite pins the authorization boundary of the role management API. +// +// Unlike /groups and /users, /roles has no entry in the API permission table +// (internal/system/security/permissions.go), so every role path falls back to the root "system" +// permission. A scoped administrator holding the fine-grained system permissions is therefore +// refused on every role endpoint, read and write alike, by the security middleware (AUTH-4030) and +// never reaches the handler. +// +// The refusals are asserted on the error code, not just the status, because two different layers +// answer 403 here: +// +// AUTH-4030 — the security middleware: the caller lacks the permission the path requires. +// SAZ-4030 — sysauthz's grant guard: the operation would confer permissions the caller lacks. +// +// Distinguishing them matters. Because the role API admits only root callers, and +// sysauthz.CanGrantMembership short-circuits for root, the role privilege-escalation guard +// (CanGrantMembership with PrincipalTypeRole, and CanGrantPermissions on role create/update) cannot +// fire for any HTTP caller in the shipped configuration. Should /roles later gain fine-grained +// permission entries, TestScopedAdministratorCannotAddAssignmentToPrivilegedRole starts exercising +// the guard, and the expected code becomes SAZ-4030 rather than AUTH-4030. +// +// Fixture topology, all within one OU: +// +// scoped administrator — holds system:group, system:ou:view, and other non-root system permissions +// harmless role — confers nothing +// privileged role — confers system:user, which the scoped administrator does not hold +type RoleAuthzTestSuite struct { + suite.Suite + + authzOUID string + authzTypeID string + scopedAdminID string + assigneeUserID string + scopedRSID string + scopedAdminRole string + harmlessRoleID string + privilegedRoleID string + + // HTTP client carrying the scoped administrator's non-root token. + scopedClient *http.Client +} + +const ( + roleAuthzRSIdentifier = "https://authz-test.example.com/role" + + roleAuthzOUHandle = "authz-role-ou" + roleAuthzTypeName = "authz-role-type" + + roleAuthzAdminUsername = "authz-role-scoped-admin" + roleAuthzAdminPassword = "ScopedAdmin@123" + + roleAuthzAssigneeUsername = "authz-role-assignee" + roleAuthzAssigneePassword = "Assignee@123" + + roleAuthzClientID = "CONSOLE" + roleAuthzRedirectURI = "https://localhost:8095/console" + + // The permissions the scoped administrator holds and requests in its token. Deliberately + // excludes both the root "system" permission and "system:user". + roleAuthzScopedPermissions = "system:ou:view system:group system:group:view" + + // errCodeInsufficientPermissions is returned by the security middleware when the caller lacks + // the permission the requested path requires. + errCodeInsufficientPermissions = "AUTH-4030" +) + +func TestRoleAuthzTestSuite(t *testing.T) { + suite.Run(t, new(RoleAuthzTestSuite)) +} + +// --------------------------------------------------------------------------- +// Suite setup +// --------------------------------------------------------------------------- + +func (ts *RoleAuthzTestSuite) SetupSuite() { + ouID, err := testutils.CreateOrganizationUnit(testutils.OrganizationUnit{ + Handle: roleAuthzOUHandle, + Name: "Role Authz Test OU", + Description: "Organization unit for the role authorization integration test", + }) + ts.Require().NoError(err, "create role-authz OU") + ts.authzOUID = ouID + + typeID, err := testutils.CreateUserType(testutils.UserType{ + Name: roleAuthzTypeName, + OUID: ts.authzOUID, + Schema: map[string]interface{}{ + "username": map[string]interface{}{"type": "string"}, + "password": map[string]interface{}{"type": "string", "credential": true}, + "display_name": map[string]interface{}{"type": "string"}, + }, + }) + ts.Require().NoError(err, "create user type") + ts.authzTypeID = typeID + + adminID, err := testutils.CreateUser(testutils.User{ + Type: roleAuthzTypeName, + OUID: ts.authzOUID, + Attributes: json.RawMessage(fmt.Sprintf( + `{"username": %q, "password": %q, "display_name": "Scoped Admin"}`, + roleAuthzAdminUsername, roleAuthzAdminPassword, + )), + }) + ts.Require().NoError(err, "create scoped administrator") + ts.scopedAdminID = adminID + + assigneeID, err := testutils.CreateUser(testutils.User{ + Type: roleAuthzTypeName, + OUID: ts.authzOUID, + Attributes: json.RawMessage(fmt.Sprintf( + `{"username": %q, "password": %q, "display_name": "Assignee"}`, + roleAuthzAssigneeUsername, roleAuthzAssigneePassword, + )), + }) + ts.Require().NoError(err, "create assignee user") + ts.assigneeUserID = assigneeID + + // The product ships only the root "system" scope, so the fine-grained system permissions the + // scoped administrator holds are reproduced on a resource server of the suite's own. + rsID, err := testutils.CreateSystemScopedResourceServer( + ts.authzOUID, "Authz Test RS (role)", roleAuthzRSIdentifier, "ou", "group", "user") + ts.Require().NoError(err, "create scoped resource server") + ts.scopedRSID = rsID + + adminRoleID, err := testutils.CreateRole(testutils.Role{ + Name: "authz-role-scoped-admin-role", + OUID: ts.authzOUID, + Permissions: []testutils.ResourcePermissions{ + { + ResourceServerID: rsID, + Permissions: []string{"system:ou:view", "system:group", "system:group:view"}, + }, + }, + Assignments: []testutils.Assignment{ + {ID: ts.scopedAdminID, Type: "user"}, + }, + }) + ts.Require().NoError(err, "create scoped administrator role") + ts.scopedAdminRole = adminRoleID + + // Confers nothing, so the grant guard would allow managing it. Only the middleware stands in + // the way, which is what makes this fixture the discriminating one. + harmlessID, err := testutils.CreateRole(testutils.Role{ + Name: "authz-role-harmless", + Description: "Role conferring no permissions", + OUID: ts.authzOUID, + }) + ts.Require().NoError(err, "create harmless role") + ts.harmlessRoleID = harmlessID + + // Confers system:user, which the scoped administrator was never granted. Assigning anyone to it + // would transfer a permission the caller does not hold. + privilegedID, err := testutils.CreateRole(testutils.Role{ + Name: "authz-role-privileged", + Description: "Role conferring system:user", + OUID: ts.authzOUID, + Permissions: []testutils.ResourcePermissions{ + { + ResourceServerID: rsID, + Permissions: []string{"system:user"}, + }, + }, + }) + ts.Require().NoError(err, "create privileged role") + ts.privilegedRoleID = privilegedID + + tokenResp, err := testutils.ObtainAccessTokenWithPassword( + roleAuthzClientID, + roleAuthzRedirectURI, + roleAuthzScopedPermissions, + roleAuthzAdminUsername, + roleAuthzAdminPassword, + true, + "", + roleAuthzRSIdentifier, + ) + ts.Require().NoError(err, "obtain scoped administrator token") + ts.Require().NotEmpty(tokenResp.AccessToken, "scoped administrator token must be non-empty") + ts.Require().NotContains(tokenResp.Scope, "system:user", + "the scoped administrator must not hold system:user, or the suite proves nothing") + + ts.scopedClient = testutils.GetHTTPClientWithToken(tokenResp.AccessToken) +} + +// --------------------------------------------------------------------------- +// Suite teardown +// --------------------------------------------------------------------------- + +func (ts *RoleAuthzTestSuite) TearDownSuite() { + for _, id := range []string{ts.scopedAdminRole, ts.harmlessRoleID, ts.privilegedRoleID} { + if id != "" { + if err := testutils.DeleteRole(id); err != nil { + ts.T().Logf("teardown: delete role %s: %v", id, err) + } + } + } + if ts.scopedRSID != "" { + if err := testutils.DeleteResourceServer(ts.scopedRSID); err != nil { + ts.T().Logf("teardown: delete scoped resource server: %v", err) + } + } + for _, id := range []string{ts.scopedAdminID, ts.assigneeUserID} { + if id != "" { + if err := testutils.DeleteUser(id); err != nil { + ts.T().Logf("teardown: delete user %s: %v", id, err) + } + } + } + if ts.authzTypeID != "" { + if err := testutils.DeleteUserType(ts.authzTypeID); err != nil { + ts.T().Logf("teardown: delete user type: %v", err) + } + } + if ts.authzOUID != "" { + if err := testutils.DeleteOrganizationUnit(ts.authzOUID); err != nil { + ts.T().Logf("teardown: delete role-authz OU: %v", err) + } + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// doScoped issues a request as the scoped administrator. +func (ts *RoleAuthzTestSuite) doScoped(method, path string, body []byte) *http.Response { + ts.T().Helper() + + var bodyReader io.Reader + if body != nil { + bodyReader = bytes.NewReader(body) + } + + req, err := http.NewRequest(method, testServerURL+path, bodyReader) + ts.Require().NoError(err) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := ts.scopedClient.Do(req) + ts.Require().NoError(err) + return resp +} + +// requireRefusedWithCode asserts a 403 carrying the given error code, so the test states which +// enforcement layer answered rather than accepting any refusal. +func (ts *RoleAuthzTestSuite) requireRefusedWithCode(resp *http.Response, code string) { + ts.T().Helper() + + body, err := io.ReadAll(resp.Body) + ts.Require().NoError(err) + + ts.Equalf(http.StatusForbidden, resp.StatusCode, "expected a refusal, body: %s", body) + + // Decoded loosely: the message and description are i18n objects, not the plain strings + // ErrorResponse declares, and only the code identifies the layer that refused. + var errResp map[string]interface{} + ts.Require().NoError(json.Unmarshal(body, &errResp)) + ts.Equalf(code, errResp["code"], "unexpected refusal layer, body: %s", body) +} + +// mustMarshal encodes a JSON request body, failing the test on error. +func (ts *RoleAuthzTestSuite) mustMarshal(v any) []byte { + ts.T().Helper() + payload, err := json.Marshal(v) + ts.Require().NoError(err) + return payload +} + +// assigneePayload builds an assignments request naming the assignee user. +func (ts *RoleAuthzTestSuite) assigneePayload() []byte { + ts.T().Helper() + return ts.mustMarshal(AssignmentsRequest{ + Assignments: []Assignment{{ID: ts.assigneeUserID, Type: AssigneeTypeUser}}, + }) +} + +// --------------------------------------------------------------------------- +// Reads +// --------------------------------------------------------------------------- + +// TestScopedAdministratorCannotListRoles verifies role listing is closed to non-root callers. +func (ts *RoleAuthzTestSuite) TestScopedAdministratorCannotListRoles() { + resp := ts.doScoped(http.MethodGet, rolesBasePath, nil) + defer resp.Body.Close() + + ts.requireRefusedWithCode(resp, errCodeInsufficientPermissions) +} + +// TestScopedAdministratorCannotGetRole verifies reading a single role is closed to non-root callers. +func (ts *RoleAuthzTestSuite) TestScopedAdministratorCannotGetRole() { + resp := ts.doScoped(http.MethodGet, rolesBasePath+"/"+ts.harmlessRoleID, nil) + defer resp.Body.Close() + + ts.requireRefusedWithCode(resp, errCodeInsufficientPermissions) +} + +// TestScopedAdministratorCannotListRoleAssignments verifies that reading who holds a role, which +// discloses the privileges of other principals, is closed to non-root callers. +func (ts *RoleAuthzTestSuite) TestScopedAdministratorCannotListRoleAssignments() { + resp := ts.doScoped(http.MethodGet, rolesBasePath+"/"+ts.privilegedRoleID+"/assignments", nil) + defer resp.Body.Close() + + ts.requireRefusedWithCode(resp, errCodeInsufficientPermissions) +} + +// --------------------------------------------------------------------------- +// Writes +// --------------------------------------------------------------------------- + +// TestScopedAdministratorCannotCreateRoleConferringUnheldPermissions covers the escalation a +// scoped administrator would attempt first: minting a role that confers more than the caller holds, +// then assigning itself to it. +func (ts *RoleAuthzTestSuite) TestScopedAdministratorCannotCreateRoleConferringUnheldPermissions() { + payload := ts.mustMarshal(CreateRoleRequest{ + Name: "authz-role-escalating", + OUID: ts.authzOUID, + Permissions: []ResourcePermissions{ + {ResourceServerID: ts.scopedRSID, Permissions: []string{"system:user"}}, + }, + Assignments: []Assignment{{ID: ts.scopedAdminID, Type: AssigneeTypeUser}}, + }) + + resp := ts.doScoped(http.MethodPost, rolesBasePath, payload) + defer resp.Body.Close() + + ts.requireRefusedWithCode(resp, errCodeInsufficientPermissions) +} + +// TestScopedAdministratorCannotUpdateRoleToConferUnheldPermissions covers the same escalation +// through an update, which replaces the permission list wholesale. +func (ts *RoleAuthzTestSuite) TestScopedAdministratorCannotUpdateRoleToConferUnheldPermissions() { + payload := ts.mustMarshal(UpdateRoleRequest{ + Name: "authz-role-harmless", + OUID: ts.authzOUID, + Permissions: []ResourcePermissions{ + {ResourceServerID: ts.scopedRSID, Permissions: []string{"system:user"}}, + }, + }) + + resp := ts.doScoped(http.MethodPut, rolesBasePath+"/"+ts.harmlessRoleID, payload) + defer resp.Body.Close() + + ts.requireRefusedWithCode(resp, errCodeInsufficientPermissions) +} + +// TestScopedAdministratorCannotDeleteRole verifies deletion, which silently strips privileges from +// every assignee, is closed to non-root callers. +func (ts *RoleAuthzTestSuite) TestScopedAdministratorCannotDeleteRole() { + resp := ts.doScoped(http.MethodDelete, rolesBasePath+"/"+ts.harmlessRoleID, nil) + defer resp.Body.Close() + + ts.requireRefusedWithCode(resp, errCodeInsufficientPermissions) +} + +// TestScopedAdministratorCannotAddAssignmentToPrivilegedRole is the escalation the grant guard +// exists to stop: assigning a principal to a role conferring system:user, which the caller does not +// hold. The refusal currently comes from the middleware, so the guard itself never runs. +func (ts *RoleAuthzTestSuite) TestScopedAdministratorCannotAddAssignmentToPrivilegedRole() { + resp := ts.doScoped(http.MethodPost, + rolesBasePath+"/"+ts.privilegedRoleID+"/assignments/add", ts.assigneePayload()) + defer resp.Body.Close() + + ts.requireRefusedWithCode(resp, errCodeInsufficientPermissions) +} + +// TestScopedAdministratorCannotRemoveAssignmentFromPrivilegedRole covers the other direction. +// Stripping an assignment is guarded to the same standard, since it changes who holds the role's +// privileges. +func (ts *RoleAuthzTestSuite) TestScopedAdministratorCannotRemoveAssignmentFromPrivilegedRole() { + resp := ts.doScoped(http.MethodPost, + rolesBasePath+"/"+ts.privilegedRoleID+"/assignments/remove", ts.assigneePayload()) + defer resp.Body.Close() + + ts.requireRefusedWithCode(resp, errCodeInsufficientPermissions) +} + +// TestScopedAdministratorCannotAddAssignmentToHarmlessRole is what shows the boundary is drawn at +// the path and not at the conferred permissions. The harmless role confers nothing, so the grant +// guard would permit this; the middleware refuses it anyway. +func (ts *RoleAuthzTestSuite) TestScopedAdministratorCannotAddAssignmentToHarmlessRole() { + resp := ts.doScoped(http.MethodPost, + rolesBasePath+"/"+ts.harmlessRoleID+"/assignments/add", ts.assigneePayload()) + defer resp.Body.Close() + + ts.requireRefusedWithCode(resp, errCodeInsufficientPermissions) +} + +// --------------------------------------------------------------------------- +// Root caller +// --------------------------------------------------------------------------- + +// TestRootAdministratorCanManageRoleAssignments proves the refusals above are the authorization +// boundary rather than a broken route or a malformed fixture: the same requests succeed for a root +// caller. +func (ts *RoleAuthzTestSuite) TestRootAdministratorCanManageRoleAssignments() { + client := testutils.GetHTTPClient() + payload := ts.assigneePayload() + + addReq, err := http.NewRequest(http.MethodPost, + testServerURL+rolesBasePath+"/"+ts.privilegedRoleID+"/assignments/add", + bytes.NewReader(payload)) + ts.Require().NoError(err) + addReq.Header.Set("Content-Type", "application/json") + + addResp, err := client.Do(addReq) + ts.Require().NoError(err) + defer addResp.Body.Close() + + addBody, err := io.ReadAll(addResp.Body) + ts.Require().NoError(err) + ts.Equalf(http.StatusNoContent, addResp.StatusCode, + "root caller should assign a privileged role, body: %s", addBody) + + removeReq, err := http.NewRequest(http.MethodPost, + testServerURL+rolesBasePath+"/"+ts.privilegedRoleID+"/assignments/remove", + bytes.NewReader(payload)) + ts.Require().NoError(err) + removeReq.Header.Set("Content-Type", "application/json") + + removeResp, err := client.Do(removeReq) + ts.Require().NoError(err) + defer removeResp.Body.Close() + + removeBody, err := io.ReadAll(removeResp.Body) + ts.Require().NoError(err) + ts.Equalf(http.StatusNoContent, removeResp.StatusCode, + "root caller should unassign a privileged role, body: %s", removeBody) +} diff --git a/tests/integration/testutils/api_utils.go b/tests/integration/testutils/api_utils.go index ba625e6d72..66dfc267ec 100644 --- a/tests/integration/testutils/api_utils.go +++ b/tests/integration/testutils/api_utils.go @@ -2704,3 +2704,27 @@ func UpdateApplication(appID string, app Application) error { func RemoveRoleAssignments(roleID string, assignments []Assignment) error { return removeRoleAssignments(roleID, assignments, GetHTTPClient()) } + +// DeleteResource deletes a resource from a resource server. A resource server cannot be deleted +// while it still has resources, so suites that build a resource tree must remove it leaf first. +func DeleteResource(resourceServerID, resourceID string) error { + client := GetHTTPClient() + + url := fmt.Sprintf("%s/resource-servers/%s/resources/%s", TestServerURL, resourceServerID, resourceID) + req, err := http.NewRequest("DELETE", url, nil) + if err != nil { + return fmt.Errorf("failed to create delete request: %w", err) + } + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to delete resource: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + bodyBytes, _ := io.ReadAll(resp.Body) + return fmt.Errorf("expected status 204, got %d. Response: %s", resp.StatusCode, string(bodyBytes)) + } + return nil +} diff --git a/tests/integration/testutils/oauth2_utils.go b/tests/integration/testutils/oauth2_utils.go index 0d9477eaed..525cbc6108 100644 --- a/tests/integration/testutils/oauth2_utils.go +++ b/tests/integration/testutils/oauth2_utils.go @@ -159,6 +159,31 @@ func initiateAuthorizationFlow(clientID, redirectURI, responseType, scope, state return resp, nil } +// SubmitAuthorizationRequest sends an arbitrary parameter set to the authorization endpoint without +// following redirects, so the caller can assert on the redirect the server produced. Use this for +// parameters the InitiateAuthorizationFlow variants do not cover, such as prompt. +func SubmitAuthorizationRequest(params url.Values) (*http.Response, error) { + req, err := http.NewRequest("GET", TestServerURL+"/oauth2/authorize?"+params.Encode(), nil) + if err != nil { + return nil, fmt.Errorf("failed to create authorization request: %w", err) + } + + client := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send authorization request: %w", err) + } + return resp, nil +} + // ExecuteAuthenticationFlow executes an authentication flow and returns the flow step. func ExecuteAuthenticationFlow(executionId string, inputs map[string]string, action string, challengeToken ...string) (*FlowStep, error) { diff --git a/tests/integration/testutils/test_utils.go b/tests/integration/testutils/test_utils.go index 3c8ca47837..ac2fd715e2 100644 --- a/tests/integration/testutils/test_utils.go +++ b/tests/integration/testutils/test_utils.go @@ -72,6 +72,10 @@ func InitializeTestContext(port string, zipPattern string, databaseType string) // Returning an absolute path ensures it resolves correctly regardless of the working // directory of the consumer (e.g. a test subprocess running from a sub-package directory). func GetExtractedProductHome() string { + // A test subprocess has the path in its environment but no initialized context until something + // asks for it, so resolve lazily rather than panicking on the un-set package variable. + ensureInitialized() + if extractedProductHome == "" { panic("Extracted product home is not set") } diff --git a/tests/integration/user/user_usages_test.go b/tests/integration/user/user_usages_test.go new file mode 100644 index 0000000000..f4a5ce8930 --- /dev/null +++ b/tests/integration/user/user_usages_test.go @@ -0,0 +1,326 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +package user + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/thunder-id/thunderid/tests/integration/testutils" +) + +// resourceDependency mirrors one entry of the usages listing. +type resourceDependency struct { + ResourceType string `json:"resourceType"` + ID string `json:"id"` + DisplayName string `json:"displayName"` + BehaviorOnDelete string `json:"behaviorOnDelete"` +} + +// dependenciesResponse mirrors the GET /users/{id}/usages payload. TotalResults is a pointer +// because nil means "dependency data unavailable", which is a different answer from zero. +type dependenciesResponse struct { + TotalResults *int `json:"totalResults"` + Count int `json:"count"` + Summary map[string]int `json:"summary"` + Usages []resourceDependency `json:"usages"` +} + +// usagesErrorResponse mirrors an API error, including the interpolated description parameters. +type usagesErrorResponse struct { + Code string `json:"code"` + Message struct { + Key string `json:"key"` + DefaultValue string `json:"defaultValue"` + } `json:"message"` + Description struct { + Key string `json:"key"` + DefaultValue string `json:"defaultValue"` + Params map[string]string `json:"params"` + } `json:"description"` +} + +// UserUsagesTestSuite covers GET /users/{id}/usages and the delete refusal it warns about. +// +// The endpoint is the pre-delete safety check the console consults before offering to delete a +// user: it reports the resources that reference the user and whether each one blocks deletion. A +// false "no blockers" would let an operator confirm a delete that the server then refuses, or worse, +// walk into a delete whose blocking dependants were never surfaced. +// +// An agent's owner is a restrict-behavior reference, so an owned agent is the realistic blocking +// dependency to test with. It is built through the real agent API rather than injected, because the +// owner lives in the agent entity's system attributes and is resolved by scanning agents, not by an +// indexed lookup. +// +// The suite asserts the informational read and the enforcement together, since they are the two +// halves of one contract: what /usages reports must be what DELETE actually does. +type UserUsagesTestSuite struct { + suite.Suite + + ouID string + userTypeID string + + ownerUserID string + unusedUserID string + firstAgentID string + secondAgentID string + agentsRemoved bool + ownerRemovable bool +} + +const ( + usagesOUHandle = "user-usages-ou" + usagesUserTypeName = "user-usages-person" + + usagesOwnerUsername = "user-usages-owner" + usagesUnusedUsername = "user-usages-unused" + + // errCodeUserHasBlockingDependencies is returned when a user cannot be deleted because a + // restrict-behavior dependant still references it. + errCodeUserHasBlockingDependencies = "USR-1027" +) + +func TestUserUsagesTestSuite(t *testing.T) { + suite.Run(t, new(UserUsagesTestSuite)) +} + +func (ts *UserUsagesTestSuite) SetupSuite() { + ouID, err := testutils.CreateOrganizationUnit(testutils.OrganizationUnit{ + Handle: usagesOUHandle, + Name: "User Usages OU", + Description: "Organization unit for the user usages tests", + }) + ts.Require().NoError(err, "Failed to create the test organization unit") + ts.ouID = ouID + + userTypeID, err := testutils.CreateUserType(testutils.UserType{ + Name: usagesUserTypeName, + OUID: ts.ouID, + Schema: map[string]interface{}{ + "username": map[string]interface{}{"type": "string"}, + "password": map[string]interface{}{"type": "string", "credential": true}, + }, + }) + ts.Require().NoError(err, "Failed to create the user type") + ts.userTypeID = userTypeID + + // The server allows a single `default` agent type, shared across suites and never deleted. + _, err = testutils.CreateAgentType(testutils.UserType{ + OUID: ts.ouID, + Schema: map[string]interface{}{ + "description": map[string]interface{}{"type": "string"}, + }, + }) + ts.Require().NoError(err, "Failed to create the agent type") + + ownerID, err := testutils.CreateUser(testutils.User{ + Type: usagesUserTypeName, + OUID: ts.ouID, + Attributes: json.RawMessage(fmt.Sprintf( + `{"username": %q, "password": "Usages@123"}`, usagesOwnerUsername)), + }) + ts.Require().NoError(err, "Failed to create the owner user") + ts.ownerUserID = ownerID + + unusedID, err := testutils.CreateUser(testutils.User{ + Type: usagesUserTypeName, + OUID: ts.ouID, + Attributes: json.RawMessage(fmt.Sprintf( + `{"username": %q, "password": "Usages@123"}`, usagesUnusedUsername)), + }) + ts.Require().NoError(err, "Failed to create the unreferenced user") + ts.unusedUserID = unusedID + + // Two agents, so the summary counts and the "N agent(s)" rendering are exercised on a value + // greater than one. A single dependant would not distinguish a count from a boolean. + firstAgentID, err := testutils.CreateAgent(testutils.Agent{ + OUID: ts.ouID, + Type: "default", + Name: "user-usages-first-agent", + Owner: ts.ownerUserID, + Description: "First agent owned by the usages test user", + }) + ts.Require().NoError(err, "Failed to create the first owned agent") + ts.firstAgentID = firstAgentID + + secondAgentID, err := testutils.CreateAgent(testutils.Agent{ + OUID: ts.ouID, + Type: "default", + Name: "user-usages-second-agent", + Owner: ts.ownerUserID, + Description: "Second agent owned by the usages test user", + }) + ts.Require().NoError(err, "Failed to create the second owned agent") + ts.secondAgentID = secondAgentID +} + +func (ts *UserUsagesTestSuite) TearDownSuite() { + if !ts.agentsRemoved { + for _, id := range []string{ts.firstAgentID, ts.secondAgentID} { + if id != "" { + if err := testutils.DeleteAgent(id); err != nil { + ts.T().Logf("Failed to delete agent %s: %v", id, err) + } + } + } + } + if ts.ownerUserID != "" && !ts.ownerRemovable { + if err := testutils.DeleteUser(ts.ownerUserID); err != nil { + ts.T().Logf("Failed to delete the owner user: %v", err) + } + } + if ts.unusedUserID != "" { + if err := testutils.DeleteUser(ts.unusedUserID); err != nil { + ts.T().Logf("Failed to delete the unreferenced user: %v", err) + } + } + if ts.userTypeID != "" { + if err := testutils.DeleteUserType(ts.userTypeID); err != nil { + ts.T().Logf("Failed to delete the user type: %v", err) + } + } + if ts.ouID != "" { + if err := testutils.DeleteOrganizationUnit(ts.ouID); err != nil { + ts.T().Logf("Failed to delete the test organization unit: %v", err) + } + } +} + +// getUsages fetches the usages of a user, asserting a 200. +func (ts *UserUsagesTestSuite) getUsages(userID string) dependenciesResponse { + ts.T().Helper() + + req, err := http.NewRequest("GET", testServerURL+"/users/"+userID+"/usages", nil) + ts.Require().NoError(err) + + resp, err := testutils.GetHTTPClient().Do(req) + ts.Require().NoError(err) + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + ts.Require().NoError(err) + ts.Require().Equalf(http.StatusOK, resp.StatusCode, "unexpected status, body: %s", body) + + var usages dependenciesResponse + ts.Require().NoError(json.Unmarshal(body, &usages)) + return usages +} + +// deleteUser issues a delete and returns the status with the decoded error, if any. +func (ts *UserUsagesTestSuite) deleteUser(userID string) (int, usagesErrorResponse) { + ts.T().Helper() + + req, err := http.NewRequest("DELETE", testServerURL+"/users/"+userID, nil) + ts.Require().NoError(err) + + resp, err := testutils.GetHTTPClient().Do(req) + ts.Require().NoError(err) + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + ts.Require().NoError(err) + + var errResp usagesErrorResponse + if len(body) > 0 { + _ = json.Unmarshal(body, &errResp) + } + return resp.StatusCode, errResp +} + +// TestUsagesReportsOwnedAgentsAsBlocking verifies the endpoint reports every owned agent and marks +// it as blocking. A dependant reported with the wrong behavior reads as harmless to the console. +func (ts *UserUsagesTestSuite) TestUsagesReportsOwnedAgentsAsBlocking() { + usages := ts.getUsages(ts.ownerUserID) + + ts.Require().NotNil(usages.TotalResults, + "a nil total means dependency data was unavailable, which must not be reported as no blockers") + ts.Equal(2, *usages.TotalResults) + ts.Equal(2, usages.Count) + ts.Equal(map[string]int{"agent": 2}, usages.Summary) + + reported := make(map[string]resourceDependency, len(usages.Usages)) + for _, usage := range usages.Usages { + reported[usage.ID] = usage + } + + for _, agentID := range []string{ts.firstAgentID, ts.secondAgentID} { + usage, found := reported[agentID] + ts.Require().Truef(found, "agent %s missing from usages, got %v", agentID, usages.Usages) + ts.Equal("agent", usage.ResourceType) + ts.Equal("restrict", usage.BehaviorOnDelete, + "an agent cannot exist without its owner, so ownership must block deletion") + ts.NotEmpty(usage.DisplayName, "the console renders the display name in the warning") + } +} + +// TestUsagesForUnreferencedUserIsConfirmedEmpty verifies an unreferenced user reports a confirmed +// empty result, with a non-nil total, rather than the nil total that signals unavailable data. +func (ts *UserUsagesTestSuite) TestUsagesForUnreferencedUserIsConfirmedEmpty() { + usages := ts.getUsages(ts.unusedUserID) + + ts.Require().NotNil(usages.TotalResults, "an empty result must be confirmed, not unknown") + ts.Equal(0, *usages.TotalResults) + ts.Equal(0, usages.Count) + ts.Empty(usages.Usages) +} + +// TestUsagesForNonExistentUser verifies the user is resolved before its usages are aggregated, so a +// mistyped ID is a 404 rather than an empty listing that reads as "safe to delete". +func (ts *UserUsagesTestSuite) TestUsagesForNonExistentUser() { + req, err := http.NewRequest("GET", testServerURL+"/users/non-existent-user-id/usages", nil) + ts.Require().NoError(err) + + resp, err := testutils.GetHTTPClient().Do(req) + ts.Require().NoError(err) + defer resp.Body.Close() + + ts.Equal(http.StatusNotFound, resp.StatusCode) +} + +// TestUsagesRejectsAnAgentID verifies the endpoint is user-scoped: an agent ID is not a user, so it +// must not resolve just because both live in the entity table. +func (ts *UserUsagesTestSuite) TestUsagesRejectsAnAgentID() { + req, err := http.NewRequest("GET", testServerURL+"/users/"+ts.firstAgentID+"/usages", nil) + ts.Require().NoError(err) + + resp, err := testutils.GetHTTPClient().Do(req) + ts.Require().NoError(err) + defer resp.Body.Close() + + ts.Equal(http.StatusNotFound, resp.StatusCode) +} + +// TestZDeleteIsRefusedThenAllowed walks the full pre-delete contract in the order an operator hits +// it: the delete is refused while the owned agents exist, the refusal names them, and the same +// delete succeeds once they are gone. +// +// Named with a Z prefix so testify's alphabetical ordering runs it after the read assertions, which +// depend on the agents still existing. +func (ts *UserUsagesTestSuite) TestZDeleteIsRefusedThenAllowed() { + status, errResp := ts.deleteUser(ts.ownerUserID) + ts.Require().Equal(http.StatusConflict, status, + "deleting a user with restrict-behavior dependants must be refused") + ts.Equal(errCodeUserHasBlockingDependencies, errResp.Code) + ts.Equal("2 agent(s)", errResp.Description.Params["dependencies"], + "the refusal must summarize the blockers so the operator knows what to fix") + ts.Contains(errResp.Description.DefaultValue, "2 agent(s)") + + for _, agentID := range []string{ts.firstAgentID, ts.secondAgentID} { + ts.Require().NoError(testutils.DeleteAgent(agentID), "Failed to delete the owned agent") + } + ts.agentsRemoved = true + + usages := ts.getUsages(ts.ownerUserID) + ts.Require().NotNil(usages.TotalResults) + ts.Equal(0, *usages.TotalResults, "usages must clear once the blocking agents are gone") + + status, errResp = ts.deleteUser(ts.ownerUserID) + ts.Require().Equalf(http.StatusNoContent, status, + "the delete must succeed once nothing blocks it, got code %q", errResp.Code) + ts.ownerRemovable = true +}