Repository: Dynatrace/dynatrace-configuration-as-code
Pull request #1828: refactor(account): Transform account persistence types early
Author: login: arthurpitman
Labels: name: run-iam-test
description: Manually trigger Account Management E2E tests for PRs
Created: 2025-04-02T10:34:11Z | Merged: 2025-04-02T14:11:51Z

This PR:
- transforms persistence types to more universal account types much earlier making the code easier to work with
- improves error messages produced during validation


Diff:
diff --git a/pkg/account/persistence/internal/types/types.go b/pkg/account/persistence/internal/types/types.go
index 76ea11d47..dbc85fa1d 100644
--- a/pkg/account/persistence/internal/types/types.go
+++ b/pkg/account/persistence/internal/types/types.go
@@ -33,13 +33,6 @@ const (
 )
 
 type (
-	Resources struct {
-		Policies     map[string]Policy
-		Groups       map[string]Group
-		Users        map[string]User
-		ServiceUsers map[string]ServiceUser
-	}
-
 	File struct {
 		Policies     []Policy      `yaml:"policies,omitempty" json:"policies,omitempty" jsonschema:"description=Policies to configure for this account."`
 		Groups       []Group       `yaml:"groups,omitempty" json:"groups,omitempty" jsonschema:"description=Groups to configure for this account."`
diff --git a/pkg/account/persistence/loader/errors.go b/pkg/account/persistence/loader/errors.go
deleted file mode 100644
index aa7cb06b8..000000000
--- a/pkg/account/persistence/loader/errors.go
+++ /dev/null
@@ -1,24 +0,0 @@
-/*
- * @license
- * Copyright 2023 Dynatrace LLC
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package loader
-
-import "errors"
-
-var ErrRefMissing = errors.New("no referenced target found")
-var ErrIdFieldMissing = errors.New("no ref id field found")
-var ErrMixingConfigs = errors.New("mixing both configurations and account resources is not allowed")
-var ErrMixingDelete = errors.New("mixing account resources with a delete file definition is not allowed")
diff --git a/pkg/account/persistence/loader/load.go b/pkg/account/persistence/loader/load.go
index 3b26279b4..f20eb1d1e 100644
--- a/pkg/account/persistence/loader/load.go
+++ b/pkg/account/persistence/loader/load.go
@@ -17,6 +17,7 @@
 package loader
 
 import (
+	"errors"
 	"fmt"
 	"path"
 
@@ -32,6 +33,9 @@ import (
 	"github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/manifest"
 )
 
+var ErrMixingConfigs = errors.New("mixing both configurations and account resources is not allowed")
+var ErrMixingDelete = errors.New("mixing account resources with a delete file definition is not allowed")
+
 // LoadResources loads and merges resources from the specified projects assumed to be located within the specified working directory.
 func LoadResources(fs afero.Fs, workingDir string, projects manifest.ProjectDefinitionByProjectID) (*account.Resources, error) {
 	resources := account.NewAccountManagementResources()
@@ -77,20 +81,20 @@ func LoadResources(fs afero.Fs, workingDir string, projects manifest.ProjectDefi
 // Load loads account management resources from YAML configuration files
 // located within the specified root directory path.
 // It:
-//  1. parses YAML files found under rootPath, extracts policies, groups, and users data
+//  1. parses YAML files found under rootPath, extracts policies, groups, users and service users
 //  2. validates the loaded data for correct syntax
 //  3. returns the data in the in-memory account.Resources representation
 func Load(fs afero.Fs, rootPath string) (*account.Resources, error) {
-	persisted, err := findAndLoadResources(fs, rootPath)
+	resources, err := findAndLoadResources(fs, rootPath)
 	if err != nil {
 		return nil, fmt.Errorf("failed to load account management resources from %q: %w", rootPath, err)
 	}
 
-	if err := validateReferences(persisted); err != nil {
+	if err := validateReferences(resources); err != nil {
 		return nil, fmt.Errorf("account management resources from %q are invalid: %w", rootPath, err)
 	}
 
-	return transformToAccountResources(persisted), nil
+	return resources, nil
 }
 
 // HasAnyAccountKeyDefined checks whether the map has any AM key defined.
@@ -103,12 +107,12 @@ func HasAnyAccountKeyDefined(m map[string]any) bool {
 	return m[persistence.KeyUsers] != nil || m[persistence.KeyServiceUsers] != nil || m[persistence.KeyGroups] != nil || m[persistence.KeyPolicies] != nil
 }
 
-func findAndLoadResources(fs afero.Fs, rootPath string) (*persistence.Resources, error) {
-	resources := persistence.Resources{
-		Policies:     make(map[string]persistence.Policy),
-		Groups:       make(map[string]persistence.Group),
-		Users:        make(map[string]persistence.User),
-		ServiceUsers: make(map[string]persistence.ServiceUser),
+func findAndLoadResources(fs afero.Fs, rootPath string) (*account.Resources, error) {
+	resources := account.Resources{
+		Policies:     make(map[string]account.Policy),
+		Groups:       make(map[string]account.Group),
+		Users:        make(map[string]account.User),
+		ServiceUsers: make(map[string]account.ServiceUser),
 	}
 
 	yamlFilePaths, err := files.FindYamlFiles(fs, rootPath)
@@ -129,7 +133,7 @@ func findAndLoadResources(fs afero.Fs, rootPath string) (*persistence.Resources,
 			return nil, fmt.Errorf("invalid file %q: %w", yamlFilePath, err)
 		}
 
-		err = addResourcesFromFile(resources, *file)
+		err = addResourcesFromFile(&resources, *file)
 		if err != nil {
 			return nil, fmt.Errorf("failed to add resources from file %q: %w", yamlFilePath, err)
 		}
@@ -177,26 +181,26 @@ func loadFile(fs afero.Fs, yamlFilePath string) (*persistence.File, error) {
 	return &file, err
 }
 
-func addResourcesFromFile(res persistence.Resources, file persistence.File) error {
+func addResourcesFromFile(res *account.Resources, file persistence.File) error {
 	for _, p := range file.Policies {
 		if _, exists := res.Policies[p.ID]; exists {
 			return fmt.Errorf("found duplicate policy with id %q", p.ID)
 		}
-		res.Policies[p.ID] = p
+		res.Policies[p.ID] = transformPolicy(p)
 	}
 
 	for _, g := range file.Groups {
 		if _, exists := res.Groups[g.ID]; exists {
 			return fmt.Errorf("found duplicate group with id %q", g.ID)
 		}
-		res.Groups[g.ID] = g
+		res.Groups[g.ID] = transformGroup(g)
 	}
 
 	for _, u := range file.Users {
 		if _, exists := res.Users[u.Email.Value()]; exists {
 			return fmt.Errorf("found duplicate user with email %q", u.Email)
 		}
-		res.Users[u.Email.Value()] = u
+		res.Users[u.Email.Value()] = transformUser(u)
 	}
 
 	if featureflags.ServiceUsers.Enabled() {
@@ -204,7 +208,7 @@ func addResourcesFromFile(res persistence.Resources, file persistence.File) erro
 			if _, exists := res.ServiceUsers[su.Name]; exists {
 				return fmt.Errorf("found duplicate service user with name %q", su.Name)
 			}
-			res.ServiceUsers[su.Name] = su
+			res.ServiceUsers[su.Name] = transformServiceUser(su)
 		}
 	}
 
diff --git a/pkg/account/persistence/loader/load_test.go b/pkg/account/persistence/loader/load_test.go
index 41c7b1f69..936720740 100644
--- a/pkg/account/persistence/loader/load_test.go
+++ b/pkg/account/persistence/loader/load_test.go
@@ -278,6 +278,12 @@ func TestLoad(t *testing.T) {
 		assert.Error(t, err)
 	})
 
+	t.Run("User definition with group reference with missing id field produces error", func(t *testing.T) {
+		_, err := Load(afero.NewOsFs(), "testdata/no-id-field-group-ref.yaml")
+		assert.Error(t, err)
+		assert.ErrorContains(t, err, "missing required field 'id' for reference")
+	})
+
 	t.Run("Partial group definition produces error", func(t *testing.T) {
 		_, err := Load(afero.NewOsFs(), "testdata/partial-group.yaml")
 		assert.Error(t, err)
@@ -299,66 +305,34 @@ func TestLoad(t *testing.T) {
 		}, result)
 		assert.NoError(t, err)
 	})
-}
 
-func TestValidateReferences(t *testing.T) {
-	testCases := []struct {
-		name           string
-		path           string
-		expected       error
-		expectedErrMsg string
-	}{
-		{
+	t.Run("valid file produces no error", func(t *testing.T) {
+		_, err := Load(afero.NewOsFs(), "testdata/valid.yaml")
+		assert.NoError(t, err)
+	})
 
-			name:           "group reference not found",
-			path:           "testdata/no-ref-group.yaml",
-			expected:       ErrRefMissing,
-			expectedErrMsg: `error validating account resources with id "non-existing-group-ref": no referenced target found`,
-		},
-		{
-			name:           "environment level policy reference not found",
-			path:           "testdata/no-ref-policy-env.yaml",
-			expected:       ErrRefMissing,
-			expectedErrMsg: `error validating account resources with id "non-existing-policy-ref": no referenced target found`,
-		},
-		{
-			name:           "account level policy reference not found",
-			path:           "testdata/no-ref-policy-account.yaml",
-			expected:       ErrRefMissing,
-			expectedErrMsg: `error validating account resources with id "non-existing-policy-ref": no referenced target found`,
-		},
-		{
-			name:           "group reference with missing id field",
-			path:           "testdata/no-id-field-group-ref.yaml",
-			expected:       ErrIdFieldMissing,
-			expectedErrMsg: `error validating account resources: no ref id field found`,
-		},
-		{
-			name:     "mixing configs and account resources",
-			path:     "testdata/configs-accounts-mixed.yaml",
-			expected: ErrMixingConfigs,
-		},
-		{
-			name:     "mixing delete-file and account resources",
-			path:     "testdata/deletefile-accounts-mixed.yaml",
-			expected: ErrMixingDelete,
-		},
-		{
-			name:     "valid",
-			path:     "testdata/valid.yaml",
-			expected: nil,
-		},
-	}
+	t.Run("referencing a missing environment level policy produces an error.", func(t *testing.T) {
+		_, err := Load(afero.NewOsFs(), "testdata/no-ref-group.yaml")
+		assert.ErrorContains(t, err, "references missing group")
+	})
 
-	for _, tc := range testCases {
-		t.Run(tc.name, func(t *testing.T) {
-			_, err := Load(afero.NewOsFs(), tc.path)
-			if tc.expected != nil {
-				assert.ErrorIs(t, err, tc.expected)
-				assert.ErrorContains(t, err, tc.expectedErrMsg)
-			} else {
-				assert.NoError(t, err)
-			}
-		})
-	}
+	t.Run("environment level policy reference not found", func(t *testing.T) {
+		_, err := Load(afero.NewOsFs(), "testdata/no-ref-policy-env.yaml")
+		assert.ErrorContains(t, err, "references missing policy")
+	})
+
+	t.Run("referencing a missing account level policy produces an error", func(t *testing.T) {
+		_, err := Load(afero.NewOsFs(), "testdata/no-ref-policy-account.yaml")
+		assert.ErrorContains(t, err, "references missing policy")
+	})
+
+	t.Run("mixed configs and account resources produces an error", func(t *testing.T) {
+		_, err := Load(afero.NewOsFs(), "testdata/configs-accounts-mixed.yaml")
+		assert.ErrorIs(t, err, ErrMixingConfigs)
+	})
+
+	t.Run("mixed delete and account resources produces an error", func(t *testing.T) {
+		_, err := Load(afero.NewOsFs(), "testdata/deletefile-accounts-mixed.yaml")
+		assert.ErrorIs(t, err, ErrMixingDelete)
+	})
 }
diff --git a/pkg/account/persistence/loader/transform.go b/pkg/account/persistence/loader/transform.go
index 33f6160b7..5929aee1a 100644
--- a/pkg/account/persistence/loader/transform.go
+++ b/pkg/account/persistence/loader/transform.go
@@ -21,30 +21,17 @@ import (
 	persistence "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/account/persistence/internal/types"
 )
 
-func transformToAccountResources(resources *persistence.Resources) *account.Resources {
-	return &account.Resources{
-		Policies:     transformPolicies(resources.Policies),
-		Groups:       transformGroups(resources.Groups),
-		Users:        transformUsers(resources.Users),
-		ServiceUsers: transformServiceUsers(resources.ServiceUsers),
+func transformPolicy(pPolicy persistence.Policy) account.Policy {
+	return account.Policy{
+		ID:             pPolicy.ID,
+		Name:           pPolicy.Name,
+		Level:          transformLevel(pPolicy.Level),
+		Description:    pPolicy.Description,
+		Policy:         pPolicy.Policy,
+		OriginObjectID: pPolicy.OriginObjectID,
 	}
 }
 
-func transformPolicies(pPolicies map[string]persistence.Policy) map[account.PolicyId]account.Policy {
-	policies := make(map[account.PolicyId]account.Policy, len(pPolicies))
-	for id, v := range pPolicies {
-		policies[id] = account.Policy{
-			ID:             v.ID,
-			Name:           v.Name,
-			Level:          transformLevel(v.Level),
-			Description:    v.Description,
-			Policy:         v.Policy,
-			OriginObjectID: v.OriginObjectID,
-		}
-	}
-	return policies
-}
-
 func transformLevel(pLevel persistence.PolicyLevel) any {
 	switch pLevel.Type {
 	case persistence.PolicyLevelAccount:
@@ -56,21 +43,17 @@ func transformLevel(pLevel persistence.PolicyLevel) any {
 	}
 }
 
-func transformGroups(pGroups map[string]persistence.Group) map[account.GroupId]account.Group {
-	groups := make(map[account.GroupId]account.Group, len(pGroups))
-	for id, v := range pGroups {
-		groups[id] = account.Group{
-			ID:                       v.ID,
-			Name:                     v.Name,
-			Description:              v.Description,
-			FederatedAttributeValues: v.FederatedAttributeValues,
-			Account:                  transformAccount(v.Account),
-			Environment:              transformEnvironments(v.Environment),
-			ManagementZone:           transformManagementZones(v.ManagementZone),
-			OriginObjectID:           v.OriginObjectID,
-		}
+func transformGroup(pGroup persistence.Group) account.Group {
+	return account.Group{
+		ID:                       pGroup.ID,
+		Name:                     pGroup.Name,
+		Description:              pGroup.Description,
+		FederatedAttributeValues: pGroup.FederatedAttributeValues,
+		Account:                  transformAccount(pGroup.Account),
+		Environment:              transformEnvironments(pGroup.Environment),
+		ManagementZone:           transformManagementZones(pGroup.ManagementZone),
+		OriginObjectID:           pGroup.OriginObjectID,
 	}
-	return groups
 }
 
 func transformAccount(pAccount *persistence.Account) *account.Account {
@@ -108,27 +91,19 @@ func transformManagementZones(pManagementZones []persistence.ManagementZone) []a
 	return managementZones
 }
 
-func transformUsers(pUsers map[string]persistence.User) map[account.UserId]account.User {
-	users := make(map[account.UserId]account.User, len(pUsers))
-	for id, v := range pUsers {
-		users[id] = account.User{
-			Email:  v.Email,
-			Groups: transformReferences(v.Groups),
-		}
+func transformUser(pUser persistence.User) account.User {
+	return account.User{
+		Email:  pUser.Email,
+		Groups: transformReferences(pUser.Groups),
 	}
-	return users
 }
 
-func transformServiceUsers(pUsers map[string]persistence.ServiceUser) map[account.ServiceUserId]account.ServiceUser {
-	serviceUsers := make(map[account.ServiceUserId]account.ServiceUser, len(pUsers))
-	for id, su := range pUsers {
-		serviceUsers[id] = account.ServiceUser{
-			Name:        su.Name,
-			Description: su.Description,
-			Groups:      transformReferences(su.Groups),
-		}
+func transformServiceUser(pServiceUser persistence.ServiceUser) account.ServiceUser {
+	return account.ServiceUser{
+		Name:        pServiceUser.Name,
+		Description: pServiceUser.Description,
+		Groups:      transformReferences(pServiceUser.Groups),
 	}
-	return serviceUsers
 }
 
 func transformReferences(pReferences []persistence.Reference) []account.Ref {
diff --git a/pkg/account/persistence/loader/validate.go b/pkg/account/persistence/loader/validate.go
index 802c22928..a35878a38 100644
--- a/pkg/account/persistence/loader/validate.go
+++ b/pkg/account/persistence/loader/validate.go
@@ -21,71 +21,104 @@ import (
 	"fmt"
 
 	"github.com/dynatrace/dynatrace-configuration-as-code/v2/internal/featureflags"
+	"github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/account"
 	"github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/account/persistence/internal/types"
-	persistence "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/account/persistence/internal/types"
 )
 
 // validateReferences checks the references in the provided AMResources instance to ensure
 // that all referenced groups and policies exist. It iterates through the users,
 // environment policies, and account policies, validating their references.
-func validateReferences(res *types.Resources) error {
+func validateReferences(res *account.Resources) error {
 	for _, user := range res.Users {
-		for _, groupRef := range user.Groups {
-			if err := refCheck(res, groupRef, groupExists); err != nil {
-				return err
-			}
+		if err := validateUserReferences(res, user); err != nil {
+			return err
+		}
+	}
+
+	for _, serviceUser := range res.ServiceUsers {
+		if err := validateServiceUserReferences(res, serviceUser); err != nil {
+			return err
 		}
 	}
 
 	for _, group := range res.Groups {
-		// check references in environment policies
-		for _, env := range group.Environment {
-			for _, policyRef := range env.Policies {
-				if err := refCheck(res, policyRef, policyExists); err != nil {
-					return err
-				}
-			}
+		if err := validateGroupReferences(res, group); err != nil {
+			return err
 		}
-		if group.Account != nil {
-			// check references in account policies
-			for _, policyRef := range group.Account.Policies {
-				if err := refCheck(res, policyRef, policyExists); err != nil {
-					return err
-				}
-			}
+	}
+	return nil
+}
+
+func validateUserReferences(res *account.Resources, user account.User) error {
+	for _, groupRef := range user.Groups {
+		groupReference, ok := groupRef.(account.Reference)
+		if !ok {
+			continue
+		}
+
+		if !groupExists(res, groupReference.ID()) {
+			return fmt.Errorf("user '%s' references missing group '%s'", user.Email, groupReference.ID())
 		}
 	}
 	return nil
 }
 
-func refCheck(res *types.Resources, reference types.Reference, refCheckFn func(*types.Resources, string) bool) error {
-	if reference.Type == types.ReferenceType {
-		if reference.Id == "" {
-			return fmt.Errorf("error validating account resources: %w", ErrIdFieldMissing)
+func validateServiceUserReferences(res *account.Resources, serviceUser account.ServiceUser) error {
+	for _, groupRef := range serviceUser.Groups {
+		groupReference, ok := groupRef.(account.Reference)
+		if !ok {
+			continue
 		}
 
-		refExists := refCheckFn(res, reference.Id)
-		if !refExists {
-			return fmt.Errorf("error validating account resources with id %q: %w", reference.Id, ErrRefMissing)
+		if !groupExists(res, groupReference.ID()) {
+			return fmt.Errorf("service user '%s' references missing group '%s'", serviceUser.Name, groupReference.ID())
 		}
-	} else if reference.Value == "" {
-		return fmt.Errorf("error validating account resources: %w", errors.New("value is missing"))
 	}
 	return nil
 }
 
-func groupExists(a *types.Resources, id string) bool {
+func validateGroupReferences(res *account.Resources, group account.Group) error {
+	// check references in environment policies
+	for _, env := range group.Environment {
+		for _, policyRef := range env.Policies {
+			policyReference, ok := policyRef.(account.Reference)
+			if !ok {
+				continue
+			}
+
+			if !policyExists(res, policyReference.ID()) {
+				return fmt.Errorf("group '%s' environment '%s' references missing policy '%s'", group.Name, env.Name, policyReference.ID())
+			}
+		}
+	}
+	if group.Account != nil {
+		// check references in account policies
+		for _, policyRef := range group.Account.Policies {
+			policyReference, ok := policyRef.(account.Reference)
+			if !ok {
+				continue
+			}
+
+			if !policyExists(res, policyReference.ID()) {
+				return fmt.Errorf("group '%s' account references missing policy '%s'", group.Name, policyReference.ID())
+			}
+		}
+	}
+	return nil
+}
+
+func groupExists(a *account.Resources, id string) bool {
 	_, exists := a.Groups[id]
 	return exists
 }
 
-func policyExists(a *types.Resources, id string) bool {
+func policyExists(a *account.Resources, id string) bool {
 	_, exists := a.Policies[id]
 	return exists
 
 }
 
-func validateFile(file persistence.File) error {
+func validateFile(file types.File) error {
 	for _, p := range file.Policies {
 		if err := validatePolicy(p); err != nil {
 			return err
@@ -119,6 +152,12 @@ func validateUser(u types.User) error {
 	if u.Email == "" {
 		return errors.New("missing required field 'email' for user")
 	}
+
+	for _, groupRef := range u.Groups {
+		if err := validateReference(groupRef); err != nil {
+			return err
+		}
+	}
 	return nil
 }
 
@@ -126,6 +165,12 @@ func validateServiceUser(su types.ServiceUser) error {
 	if su.Name == "" {
 		return errors.New("missing required field 'name' for service user")
 	}
+
+	for _, groupRef := range su.Groups {
+		if err := validateReference(groupRef); err != nil {
+			return err
+		}
+	}
 	return nil
 }
 
@@ -136,7 +181,24 @@ func validateGroup(g types.Group) error {
 	if g.Name == "" {
 		return fmt.Errorf("missing required field 'name' for group %q", g.ID)
 	}
-	// groups don't necessarily need to define any permissions/policies
+
+	for _, env := range g.Environment {
+		for _, policyRef := range env.Policies {
+			if err := validateReference(policyRef); err != nil {
+				return err
+			}
+		}
+	}
+
+	if g.Account != nil {
+		// check references in account policies
+		for _, policyRef := range g.Account.Policies {
+			if err := validateReference(policyRef); err != nil {
+				return err
+			}
+		}
+	}
+
 	return nil
 }
 
@@ -161,3 +223,14 @@ func validatePolicy(p types.Policy) error {
 	}
 	return nil
 }
+
+func validateReference(reference types.Reference) error {
+	if reference.Type == types.ReferenceType {
+		if reference.Id == "" {
+			return errors.New("missing required field 'id' for reference")
+		}
+	} else if reference.Value == "" {
+		return errors.New("missing reference value")
+	}
+	return nil
+}


Reviews:
user: login: Kirdock
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T12:01:58Z
user: login: arthurpitman
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T12:20:13Z
user: login: arthurpitman
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T12:22:01Z
user: login: Kirdock
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T12:49:54Z
user: login: tomazpu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T13:39:44Z
user: login: arthurpitman
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T13:55:20Z
user: login: Kirdock
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T14:11:01Z

Review comments:
diff_hunk: @@ -278,6 +278,12 @@ func TestLoad(t *testing.T) {
 		assert.Error(t, err)
 	})
 
+	t.Run("User user definition with group reference with missing id field produces error", func(t *testing.T) {
path: pkg/account/persistence/loader/load_test.go
user: login: Kirdock
body: ```suggestion
	t.Run("User definition with group reference with missing id field produces error", func(t *testing.T) {
```
created_at: 2025-04-02T11:51:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -299,66 +305,35 @@ func TestLoad(t *testing.T) {
 		}, result)
 		assert.NoError(t, err)
 	})
-}
 
-func TestValidateReferences(t *testing.T) {
-	testCases := []struct {
-		name           string
-		path           string
-		expected       error
-		expectedErrMsg string
-	}{
-		{
+	t.Run("valid file produces no error", func(t *testing.T) {
+		_, err := Load(afero.NewOsFs(), "testdata/valid.yaml")
+		assert.NoError(t, err)
+	})
 
-			name:           "group reference not found",
-			path:           "testdata/no-ref-group.yaml",
-			expected:       ErrRefMissing,
-			expectedErrMsg: `error validating account resources with id "non-existing-group-ref": no referenced target found`,
-		},
-		{
-			name:           "environment level policy reference not found",
-			path:           "testdata/no-ref-policy-env.yaml",
-			expected:       ErrRefMissing,
-			expectedErrMsg: `error validating account resources with id "non-existing-policy-ref": no referenced target found`,
-		},
-		{
-			name:           "account level policy reference not found",
-			path:           "testdata/no-ref-policy-account.yaml",
-			expected:       ErrRefMissing,
-			expectedErrMsg: `error validating account resources with id "non-existing-policy-ref": no referenced target found`,
-		},
-		{
-			name:           "group reference with missing id field",
-			path:           "testdata/no-id-field-group-ref.yaml",
-			expected:       ErrIdFieldMissing,
-			expectedErrMsg: `error validating account resources: no ref id field found`,
-		},
-		{
-			name:     "mixing configs and account resources",
-			path:     "testdata/configs-accounts-mixed.yaml",
-			expected: ErrMixingConfigs,
-		},
-		{
-			name:     "mixing delete-file and account resources",
-			path:     "testdata/deletefile-accounts-mixed.yaml",
-			expected: ErrMixingDelete,
-		},
-		{
-			name:     "valid",
-			path:     "testdata/valid.yaml",
-			expected: nil,
-		},
-	}
+	t.Run("referencing a missing enviroment level policy produces an error.", func(t *testing.T) {
path: pkg/account/persistence/loader/load_test.go
user: login: Kirdock
body: ```suggestion
	t.Run("referencing a missing environment level policy produces an error.", func(t *testing.T) {
```
created_at: 2025-04-02T11:53:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -299,66 +305,35 @@ func TestLoad(t *testing.T) {
 		}, result)
 		assert.NoError(t, err)
 	})
-}
 
-func TestValidateReferences(t *testing.T) {
-	testCases := []struct {
-		name           string
-		path           string
-		expected       error
-		expectedErrMsg string
-	}{
-		{
+	t.Run("valid file produces no error", func(t *testing.T) {
+		_, err := Load(afero.NewOsFs(), "testdata/valid.yaml")
+		assert.NoError(t, err)
+	})
 
-			name:           "group reference not found",
-			path:           "testdata/no-ref-group.yaml",
-			expected:       ErrRefMissing,
-			expectedErrMsg: `error validating account resources with id "non-existing-group-ref": no referenced target found`,
-		},
-		{
-			name:           "environment level policy reference not found",
-			path:           "testdata/no-ref-policy-env.yaml",
-			expected:       ErrRefMissing,
-			expectedErrMsg: `error validating account resources with id "non-existing-policy-ref": no referenced target found`,
-		},
-		{
-			name:           "account level policy reference not found",
-			path:           "testdata/no-ref-policy-account.yaml",
-			expected:       ErrRefMissing,
-			expectedErrMsg: `error validating account resources with id "non-existing-policy-ref": no referenced target found`,
-		},
-		{
-			name:           "group reference with missing id field",
-			path:           "testdata/no-id-field-group-ref.yaml",
-			expected:       ErrIdFieldMissing,
-			expectedErrMsg: `error validating account resources: no ref id field found`,
-		},
-		{
-			name:     "mixing configs and account resources",
-			path:     "testdata/configs-accounts-mixed.yaml",
-			expected: ErrMixingConfigs,
-		},
-		{
-			name:     "mixing delete-file and account resources",
-			path:     "testdata/deletefile-accounts-mixed.yaml",
-			expected: ErrMixingDelete,
-		},
-		{
-			name:     "valid",
-			path:     "testdata/valid.yaml",
-			expected: nil,
-		},
-	}
+	t.Run("referencing a missing enviroment level policy produces an error.", func(t *testing.T) {
+		_, err := Load(afero.NewOsFs(), "testdata/no-ref-group.yaml")
+		assert.ErrorContains(t, err, "references missing group")
+	})
 
-	for _, tc := range testCases {
-		t.Run(tc.name, func(t *testing.T) {
-			_, err := Load(afero.NewOsFs(), tc.path)
-			if tc.expected != nil {
-				assert.ErrorIs(t, err, tc.expected)
-				assert.ErrorContains(t, err, tc.expectedErrMsg)
-			} else {
-				assert.NoError(t, err)
-			}
-		})
-	}
+	// TestLoadMissingEnvironmentLevelPolicyReferenceProducesError tests that loading account resources that
+	t.Run("", func(t *testing.T) {
path: pkg/account/persistence/loader/load_test.go
user: login: Kirdock
body: ```suggestion
	t.Run("environment level policy reference not found", func(t *testing.T) {
```
created_at: 2025-04-02T11:54:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -299,66 +305,35 @@ func TestLoad(t *testing.T) {
 		}, result)
 		assert.NoError(t, err)
 	})
-}
 
-func TestValidateReferences(t *testing.T) {
-	testCases := []struct {
-		name           string
-		path           string
-		expected       error
-		expectedErrMsg string
-	}{
-		{
+	t.Run("valid file produces no error", func(t *testing.T) {
+		_, err := Load(afero.NewOsFs(), "testdata/valid.yaml")
+		assert.NoError(t, err)
+	})
 
-			name:           "group reference not found",
-			path:           "testdata/no-ref-group.yaml",
-			expected:       ErrRefMissing,
-			expectedErrMsg: `error validating account resources with id "non-existing-group-ref": no referenced target found`,
-		},
-		{
-			name:           "environment level policy reference not found",
-			path:           "testdata/no-ref-policy-env.yaml",
-			expected:       ErrRefMissing,
-			expectedErrMsg: `error validating account resources with id "non-existing-policy-ref": no referenced target found`,
-		},
-		{
-			name:           "account level policy reference not found",
-			path:           "testdata/no-ref-policy-account.yaml",
-			expected:       ErrRefMissing,
-			expectedErrMsg: `error validating account resources with id "non-existing-policy-ref": no referenced target found`,
-		},
-		{
-			name:           "group reference with missing id field",
-			path:           "testdata/no-id-field-group-ref.yaml",
-			expected:       ErrIdFieldMissing,
-			expectedErrMsg: `error validating account resources: no ref id field found`,
-		},
-		{
-			name:     "mixing configs and account resources",
-			path:     "testdata/configs-accounts-mixed.yaml",
-			expected: ErrMixingConfigs,
-		},
-		{
-			name:     "mixing delete-file and account resources",
-			path:     "testdata/deletefile-accounts-mixed.yaml",
-			expected: ErrMixingDelete,
-		},
-		{
-			name:     "valid",
-			path:     "testdata/valid.yaml",
-			expected: nil,
-		},
-	}
+	t.Run("referencing a missing enviroment level policy produces an error.", func(t *testing.T) {
+		_, err := Load(afero.NewOsFs(), "testdata/no-ref-group.yaml")
+		assert.ErrorContains(t, err, "references missing group")
+	})
 
-	for _, tc := range testCases {
-		t.Run(tc.name, func(t *testing.T) {
-			_, err := Load(afero.NewOsFs(), tc.path)
-			if tc.expected != nil {
-				assert.ErrorIs(t, err, tc.expected)
-				assert.ErrorContains(t, err, tc.expectedErrMsg)
-			} else {
-				assert.NoError(t, err)
-			}
-		})
-	}
+	// TestLoadMissingEnvironmentLevelPolicyReferenceProducesError tests that loading account resources that
path: pkg/account/persistence/loader/load_test.go
user: login: Kirdock
body: We can remove this comment I guess?
created_at: 2025-04-02T11:57:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -299,66 +305,35 @@ func TestLoad(t *testing.T) {
 		}, result)
 		assert.NoError(t, err)
 	})
-}
 
-func TestValidateReferences(t *testing.T) {
-	testCases := []struct {
-		name           string
-		path           string
-		expected       error
-		expectedErrMsg string
-	}{
-		{
+	t.Run("valid file produces no error", func(t *testing.T) {
+		_, err := Load(afero.NewOsFs(), "testdata/valid.yaml")
+		assert.NoError(t, err)
+	})
 
-			name:           "group reference not found",
-			path:           "testdata/no-ref-group.yaml",
-			expected:       ErrRefMissing,
-			expectedErrMsg: `error validating account resources with id "non-existing-group-ref": no referenced target found`,
-		},
-		{
-			name:           "environment level policy reference not found",
-			path:           "testdata/no-ref-policy-env.yaml",
-			expected:       ErrRefMissing,
-			expectedErrMsg: `error validating account resources with id "non-existing-policy-ref": no referenced target found`,
-		},
-		{
-			name:           "account level policy reference not found",
-			path:           "testdata/no-ref-policy-account.yaml",
-			expected:       ErrRefMissing,
-			expectedErrMsg: `error validating account resources with id "non-existing-policy-ref": no referenced target found`,
-		},
-		{
-			name:           "group reference with missing id field",
-			path:           "testdata/no-id-field-group-ref.yaml",
-			expected:       ErrIdFieldMissing,
-			expectedErrMsg: `error validating account resources: no ref id field found`,
-		},
-		{
-			name:     "mixing configs and account resources",
-			path:     "testdata/configs-accounts-mixed.yaml",
-			expected: ErrMixingConfigs,
-		},
-		{
-			name:     "mixing delete-file and account resources",
-			path:     "testdata/deletefile-accounts-mixed.yaml",
-			expected: ErrMixingDelete,
-		},
-		{
-			name:     "valid",
-			path:     "testdata/valid.yaml",
-			expected: nil,
-		},
-	}
+	t.Run("referencing a missing enviroment level policy produces an error.", func(t *testing.T) {
+		_, err := Load(afero.NewOsFs(), "testdata/no-ref-group.yaml")
+		assert.ErrorContains(t, err, "references missing group")
+	})
 
-	for _, tc := range testCases {
-		t.Run(tc.name, func(t *testing.T) {
-			_, err := Load(afero.NewOsFs(), tc.path)
-			if tc.expected != nil {
-				assert.ErrorIs(t, err, tc.expected)
-				assert.ErrorContains(t, err, tc.expectedErrMsg)
-			} else {
-				assert.NoError(t, err)
-			}
-		})
-	}
+	// TestLoadMissingEnvironmentLevelPolicyReferenceProducesError tests that loading account resources that
path: pkg/account/persistence/loader/load_test.go
user: login: arthurpitman
body: Thanks, yes 👍 
created_at: 2025-04-02T12:20:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -299,66 +305,35 @@ func TestLoad(t *testing.T) {
 		}, result)
 		assert.NoError(t, err)
 	})
-}
 
-func TestValidateReferences(t *testing.T) {
-	testCases := []struct {
-		name           string
-		path           string
-		expected       error
-		expectedErrMsg string
-	}{
-		{
+	t.Run("valid file produces no error", func(t *testing.T) {
+		_, err := Load(afero.NewOsFs(), "testdata/valid.yaml")
+		assert.NoError(t, err)
+	})
 
-			name:           "group reference not found",
-			path:           "testdata/no-ref-group.yaml",
-			expected:       ErrRefMissing,
-			expectedErrMsg: `error validating account resources with id "non-existing-group-ref": no referenced target found`,
-		},
-		{
-			name:           "environment level policy reference not found",
-			path:           "testdata/no-ref-policy-env.yaml",
-			expected:       ErrRefMissing,
-			expectedErrMsg: `error validating account resources with id "non-existing-policy-ref": no referenced target found`,
-		},
-		{
-			name:           "account level policy reference not found",
-			path:           "testdata/no-ref-policy-account.yaml",
-			expected:       ErrRefMissing,
-			expectedErrMsg: `error validating account resources with id "non-existing-policy-ref": no referenced target found`,
-		},
-		{
-			name:           "group reference with missing id field",
-			path:           "testdata/no-id-field-group-ref.yaml",
-			expected:       ErrIdFieldMissing,
-			expectedErrMsg: `error validating account resources: no ref id field found`,
-		},
-		{
-			name:     "mixing configs and account resources",
-			path:     "testdata/configs-accounts-mixed.yaml",
-			expected: ErrMixingConfigs,
-		},
-		{
-			name:     "mixing delete-file and account resources",
-			path:     "testdata/deletefile-accounts-mixed.yaml",
-			expected: ErrMixingDelete,
-		},
-		{
-			name:     "valid",
-			path:     "testdata/valid.yaml",
-			expected: nil,
-		},
-	}
+	t.Run("referencing a missing environment level policy produces an error.", func(t *testing.T) {
+		_, err := Load(afero.NewOsFs(), "testdata/no-ref-group.yaml")
+		assert.ErrorContains(t, err, "references missing group")
+	})
 
-	for _, tc := range testCases {
-		t.Run(tc.name, func(t *testing.T) {
-			_, err := Load(afero.NewOsFs(), tc.path)
-			if tc.expected != nil {
-				assert.ErrorIs(t, err, tc.expected)
-				assert.ErrorContains(t, err, tc.expectedErrMsg)
-			} else {
-				assert.NoError(t, err)
-			}
-		})
-	}
+	// TestLoadMissingEnvironmentLevelPolicyReferenceProducesError tests that loading account resources that
path: pkg/account/persistence/loader/load_test.go
user: login: arthurpitman
body: ```suggestion
```
created_at: 2025-04-02T12:22:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -18,7 +18,5 @@ package loader
 
 import "errors"
 
-var ErrRefMissing = errors.New("no referenced target found")
-var ErrIdFieldMissing = errors.New("no ref id field found")
 var ErrMixingConfigs = errors.New("mixing both configurations and account resources is not allowed")
path: pkg/account/persistence/loader/errors.go
user: login: tomazpu
body: Would it make sense to move it to `load.go`? They only occur in that file.
created_at: 2025-04-02T12:49:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -81,16 +81,16 @@ func LoadResources(fs afero.Fs, workingDir string, projects manifest.ProjectDefi
 //  2. validates the loaded data for correct syntax
path: pkg/account/persistence/loader/load.go
user: login: tomazpu
body: At the point 1. of the comment, ServiceUser data is missing. Or we just remove the comment and reference to the `resources` variable at line 107
created_at: 2025-04-02T12:52:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -177,34 +177,34 @@ func loadFile(fs afero.Fs, yamlFilePath string) (*persistence.File, error) {
 	return &file, err
 }
 
-func addResourcesFromFile(res persistence.Resources, file persistence.File) error {
+func addResourcesFromFile(res account.Resources, file persistence.File) error {
path: pkg/account/persistence/loader/load.go
user: login: tomazpu
body: Should `res` be passed by reference? I feel im missing something :) 
created_at: 2025-04-02T12:55:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -177,34 +177,34 @@ func loadFile(fs afero.Fs, yamlFilePath string) (*persistence.File, error) {
 	return &file, err
 }
 
-func addResourcesFromFile(res persistence.Resources, file persistence.File) error {
+func addResourcesFromFile(res account.Resources, file persistence.File) error {
 	for _, p := range file.Policies {
 		if _, exists := res.Policies[p.ID]; exists {
 			return fmt.Errorf("found duplicate policy with id %q", p.ID)
 		}
-		res.Policies[p.ID] = p
+		res.Policies[p.ID] = transformPolicy(p)
path: pkg/account/persistence/loader/load.go
user: login: tomazpu
body: It's just an NIT, but to me, the transform methods look a lot like constructors. Would `New()` be a better name for it?
created_at: 2025-04-02T12:58:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -21,30 +21,17 @@ import (
 	persistence "github.com/dynatrace/dynatrace-configuration-as-code/v2/pkg/account/persistence/internal/types"
 )
 
-func transformToAccountResources(resources *persistence.Resources) *account.Resources {
-	return &account.Resources{
-		Policies:     transformPolicies(resources.Policies),
-		Groups:       transformGroups(resources.Groups),
-		Users:        transformUsers(resources.Users),
-		ServiceUsers: transformServiceUsers(resources.ServiceUsers),
+func transformPolicy(pPolicy persistence.Policy) account.Policy {
path: pkg/account/persistence/loader/transform.go
user: login: tomazpu
body: And then, looking from this angle, I guess I would prefer a `policy` package with all the code in it. And if so we would do it in a refactoring.
created_at: 2025-04-02T13:37:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -177,34 +177,34 @@ func loadFile(fs afero.Fs, yamlFilePath string) (*persistence.File, error) {
 	return &file, err
 }
 
-func addResourcesFromFile(res persistence.Resources, file persistence.File) error {
+func addResourcesFromFile(res account.Resources, file persistence.File) error {
path: pkg/account/persistence/loader/load.go
user: login: arthurpitman
body: This comes in the next PR, where it is really needed, but let's do it here.
created_at: 2025-04-02T13:55:20Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-02T10:45:55Z
body: ## E2E Test Results
1 files   -     2  1 suites   - 268   4m 21s ⏱️ - 17m 41s
2 tests  - 2 096  2 ✅  - 2 094  0 💤  - 2  0 ❌ ±0 
2 runs   - 2 289  2 ✅  - 2 287  0 💤  - 2  0 ❌ ±0 

Results for commit 6965749e. ± Comparison against base commit 326ffe12.

<details>
  <summary>This pull request <b>removes</b> 2098 and <b>adds</b> 2 tests. <i>Note that renamed tests count towards both.</i></summary>

```
github.com/dynatrace/dynatrace-configuration-as-code/v2/cmd/monaco/deploy ‑ Test_DoDeploy
github.com/dynatrace/dynatrace-configuration-as-code/v2/cmd/monaco/deploy ‑ Test_DoDeploy/Wrong_environment_group
github.com/dynatrace/dynatrace-configuration-as-code/v2/cmd/monaco/deploy ‑ Test_DoDeploy/Wrong_environment_name
github.com/dynatrace/dynatrace-configuration-as-code/v2/cmd/monaco/deploy ‑ Test_DoDeploy/Wrong_project_name
github.com/dynatrace/dynatrace-configuration-as-code/v2/cmd/monaco/deploy ‑ Test_DoDeploy/correct_parameters
github.com/dynatrace/dynatrace-configuration-as-code/v2/cmd/monaco/deploy ‑ Test_DoDeploy/no_parameters
github.com/dynatrace/dynatrace-configuration-as-code/v2/cmd/monaco/deploy ‑ Test_DoDeploy_InvalidManifest
github.com/dynatrace/dynatrace-configuration-as-code/v2/cmd/monaco/deploy ‑ Test_ValidateAuthenticationWithProjectConfigs
github.com/dynatrace/dynatrace-configuration-as-code/v2/cmd/monaco/deploy ‑ Test_ValidateAuthenticationWithProjectConfigs/OAuth_manifest_with_settings_api
github.com/dynatrace/dynatrace-configuration-as-code/v2/cmd/monaco/deploy ‑ Test_ValidateAuthenticationWithProjectConfigs/OAuth_manifest_with_settings_api_and_permissions
…
```

```
github.com/dynatrace/dynatrace-configuration-as-code/v2/cmd/monaco/integrationtest/account ‑ TestDeployAndDelete_AllResources
github.com/dynatrace/dynatrace-configuration-as-code/v2/cmd/monaco/integrationtest/account ‑ TestIdempotenceOfDeployment
```
</details>

[test-results]:data:application/gzip;base64,H4sIABkj7WcC/1WMyw7CIBBFf6Vh7UIaXvVnzGQYEmJbDI+V8d+Faivd3XNucl7M+ZkSuw38MrBUfD7AlgjZh7XiqJqoV27nuO97Kohn8fDPKq6HcODnk6AYQ/yZWNa912af+/K/tnEX27hvYVgWnyswNSmpxURCEkhuQKEloclo0GC5kU6RRIeavT9H2qf9/QAAAA==

:recycle: This comment has been updated with latest results.
author_association: NONE
user: login: sonarqubecloud[bot]
created_at: 2025-04-02T14:08:37Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=Dynatrace_dynatrace-configuration-as-code&pullRequest=1828) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [1 New issue](https://sonarcloud.io/project/issues?id=Dynatrace_dynatrace-configuration-as-code&pullRequest=1828&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=Dynatrace_dynatrace-configuration-as-code&pullRequest=1828&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=Dynatrace_dynatrace-configuration-as-code&pullRequest=1828&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [93.5% Coverage on New Code](https://sonarcloud.io/component_measures?id=Dynatrace_dynatrace-configuration-as-code&pullRequest=1828&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=Dynatrace_dynatrace-configuration-as-code&pullRequest=1828&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=Dynatrace_dynatrace-configuration-as-code&pullRequest=1828)


author_association: NONE