fix: emit required IAM list members when nothing matches

ListUsers, ListRoles, ListInstanceProfiles and ListGroupsForUser built their
results from a nil slice when the filtering loop matched nothing. xmlutil
drops a nil slice entirely rather than emitting an empty element, so each
response omitted a member its output shape marks required.

Each operation's empty-bucket shortcut already returned an empty non-nil
slice, so only the filtering path was affected. Start those slices empty and
non-nil too.

ListAttachedGroupPolicies, ListAttachedRolePolicies, ListPolicies and
ListAttachedUserPolicies declare no required output members, so their nil
slices are legal and are left alone.
This commit is contained in:
Julian Sommer
2026-08-27 11:51:35 +10:00
parent fae6b08acc
commit 347c2b2f80
5 changed files with 102 additions and 4 deletions
+3 -1
View File
@@ -277,7 +277,9 @@ func (s *IAMServiceImpl) ListGroupsForUser(accountID string, input *iam.ListGrou
return nil, err
}
var groups []*iam.Group
// Non-nil: Groups is a required member, and a nil slice marshals to no
// element at all rather than an empty one.
groups := []*iam.Group{}
for _, name := range user.Groups {
group, err := s.getGroup(ctx, accountID, name)
if err != nil {
+3 -1
View File
@@ -111,7 +111,9 @@ func (s *IAMServiceImpl) ListInstanceProfiles(accountID string, input *iam.ListI
}
keyPrefix := accountID + "."
var profiles []*iam.InstanceProfile
// Non-nil: InstanceProfiles is a required member, and a nil slice marshals
// to no element at all rather than an empty one.
profiles := []*iam.InstanceProfile{}
for _, key := range keys {
if key == utils.VersionKey {
continue
+3 -1
View File
@@ -131,7 +131,9 @@ func (s *IAMServiceImpl) ListRoles(accountID string, input *iam.ListRolesInput)
}
keyPrefix := accountID + "."
var roles []*iam.Role
// Non-nil: Roles is a required member, and a nil slice marshals to no
// element at all rather than an empty one.
roles := []*iam.Role{}
for _, key := range keys {
if key == utils.VersionKey {
continue
+3 -1
View File
@@ -416,7 +416,9 @@ func (s *IAMServiceImpl) ListUsers(accountID string, input *iam.ListUsersInput)
}
keyPrefix := accountID + "."
var users []*iam.User
// Non-nil: Users is a required member, and a nil slice marshals to no
// element at all rather than an empty one.
users := []*iam.User{}
for _, key := range keys {
if key == utils.VersionKey {
continue
@@ -140,6 +140,96 @@ func TestListUsers_Empty(t *testing.T) {
assert.Empty(t, out.Users)
}
// TestListRequiredMembersAreEmptyNotNil covers every IAM list operation whose
// output shape marks the list member required. Each seeds a record that cannot
// match, so the filtering loop runs rather than the no-keys shortcut, and the
// result must come back empty but non-nil.
func TestListRequiredMembersAreEmptyNotNil(t *testing.T) {
const otherAccountID = "999988887777"
tests := []struct {
operation string
member string
// seed writes a record the listing must filter out, so an empty result
// is reached through the loop rather than the empty-bucket shortcut.
seed func(t *testing.T, svc *IAMServiceImpl)
list func(t *testing.T, svc *IAMServiceImpl) any
}{
{
operation: "ListUsers",
member: "Users",
seed: func(t *testing.T, svc *IAMServiceImpl) {
_, err := svc.CreateUser(otherAccountID, &iam.CreateUserInput{UserName: aws.String("otheruser")})
require.NoError(t, err)
},
list: func(t *testing.T, svc *IAMServiceImpl) any {
out, err := svc.ListUsers(testAccountID, &iam.ListUsersInput{})
require.NoError(t, err)
return out.Users
},
},
{
operation: "ListRoles",
member: "Roles",
seed: func(t *testing.T, svc *IAMServiceImpl) {
_, err := svc.CreateRole(otherAccountID, &iam.CreateRoleInput{
RoleName: aws.String("otherrole"),
AssumeRolePolicyDocument: aws.String(validTrustPolicy()),
})
require.NoError(t, err)
},
list: func(t *testing.T, svc *IAMServiceImpl) any {
out, err := svc.ListRoles(testAccountID, &iam.ListRolesInput{})
require.NoError(t, err)
return out.Roles
},
},
{
operation: "ListInstanceProfiles",
member: "InstanceProfiles",
seed: func(t *testing.T, svc *IAMServiceImpl) {
_, err := svc.CreateInstanceProfile(otherAccountID, &iam.CreateInstanceProfileInput{
InstanceProfileName: aws.String("otherprofile"),
})
require.NoError(t, err)
},
list: func(t *testing.T, svc *IAMServiceImpl) any {
out, err := svc.ListInstanceProfiles(testAccountID, &iam.ListInstanceProfilesInput{})
require.NoError(t, err)
return out.InstanceProfiles
},
},
{
operation: "ListGroupsForUser",
member: "Groups",
// Membership is read off the user, so a user belonging to no group
// is the empty case; a foreign record would not reach the loop.
seed: func(t *testing.T, svc *IAMServiceImpl) {
createTestUser(t, svc, "grouplessuser")
createTestGroup(t, svc, "unrelatedgroup")
},
list: func(t *testing.T, svc *IAMServiceImpl) any {
out, err := svc.ListGroupsForUser(testAccountID, &iam.ListGroupsForUserInput{
UserName: aws.String("grouplessuser"),
})
require.NoError(t, err)
return out.Groups
},
},
}
for _, tt := range tests {
t.Run(tt.operation, func(t *testing.T) {
svc := setupTestIAMService(t)
tt.seed(t, svc)
got := tt.list(t, svc)
assert.Empty(t, got)
assert.NotNil(t, got, emptyRequiredListMsg(tt.operation, tt.member))
})
}
}
func TestListUsers_PathFilter(t *testing.T) {
svc := setupTestIAMService(t)