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 +}