package iamapi

import (
	"encoding/json"
	"net/url"
	"strings"
	"testing"

	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/arn"
	"github.com/aws/aws-sdk-go/service/iam"
	"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
	"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
	"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
	"github.com/stretchr/testify/assert"
)

// mockIamS3ApiConfig is a mock for testing
type mockIamS3ApiConfig struct {
	policies Policies
}

func (m *mockIamS3ApiConfig) GetS3ApiConfiguration(s3cfg *iam_pb.S3ApiConfiguration) (err error) {
	return nil
}

func (m *mockIamS3ApiConfig) PutS3ApiConfiguration(s3cfg *iam_pb.S3ApiConfiguration) (err error) {
	return nil
}

func (m *mockIamS3ApiConfig) GetPolicies(policies *Policies) (err error) {
	*policies = m.policies
	if m.policies.Policies == nil {
		return filer_pb.ErrNotFound
	}
	return nil
}

func (m *mockIamS3ApiConfig) PutPolicies(policies *Policies) (err error) {
	m.policies = *policies
	return nil
}

func TestGetActionsUserPath(t *testing.T) {

	policyDocument := policy_engine.PolicyDocument{
		Version: "2012-10-17",
		Statement: []policy_engine.PolicyStatement{
			{
				Effect:   policy_engine.PolicyEffectAllow,
				Action:   policy_engine.NewStringOrStringSlice("s3:Put*", "s3:PutBucketAcl", "s3:Get*", "s3:GetBucketAcl", "s3:List*", "s3:Tagging*", "s3:DeleteBucket*"),
				Resource: policy_engine.NewStringOrStringSlicePtr("arn:aws:s3:::shared/user-Alice/*"),
			},
		},
	}

	actions, _ := GetActions(&policyDocument)

	expectedActions := []string{
		"Write:shared/user-Alice/*",
		"WriteAcp:shared/user-Alice/*",
		"Read:shared/user-Alice/*",
		"ReadAcp:shared/user-Alice/*",
		"List:shared/user-Alice/*",
		"Tagging:shared/user-Alice/*",
		"DeleteBucket:shared/user-Alice/*",
	}
	assert.Equal(t, expectedActions, actions)
}

func TestGetActionsWildcardPath(t *testing.T) {

	policyDocument := policy_engine.PolicyDocument{
		Version: "2012-10-17",
		Statement: []policy_engine.PolicyStatement{
			{
				Effect:   policy_engine.PolicyEffectAllow,
				Action:   policy_engine.NewStringOrStringSlice("s3:Get*", "s3:PutBucketAcl"),
				Resource: policy_engine.NewStringOrStringSlicePtr("arn:aws:s3:::*"),
			},
		},
	}

	actions, _ := GetActions(&policyDocument)

	expectedActions := []string{
		"Read",
		"WriteAcp",
	}
	assert.Equal(t, expectedActions, actions)
}

func TestGetActionsInvalidAction(t *testing.T) {
	policyDocument := policy_engine.PolicyDocument{
		Version: "2012-10-17",
		Statement: []policy_engine.PolicyStatement{
			{
				Effect:   policy_engine.PolicyEffectAllow,
				Action:   policy_engine.NewStringOrStringSlice("s3:InvalidAction"),
				Resource: policy_engine.NewStringOrStringSlicePtr("arn:aws:s3:::shared/user-Alice/*"),
			},
		},
	}

	_, err := GetActions(&policyDocument)
	assert.NotNil(t, err)
	assert.Equal(t, "not a valid action: 'InvalidAction'", err.Error())
}

func TestPutGetUserPolicyPreservesStatements(t *testing.T) {
	s3cfg := &iam_pb.S3ApiConfiguration{
		Identities: []*iam_pb.Identity{{Name: "alice"}},
	}
	policyJSON := `{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:ListBucket",
        "s3:GetBucketLocation"
      ],
      "Resource": [
        "arn:aws:s3:::my-bucket/*",
        "arn:aws:s3:::test/*"
      ]
    },
    {
      "Effect": "Allow",
      "Action": [
        "s3:PutObject"
      ],
      "Resource": [
        "arn:aws:s3:::my-bucket/*",
        "arn:aws:s3:::test/*"
      ]
    },
    {
      "Effect": "Allow",
      "Action": [
        "s3:DeleteObject"
      ],
      "Resource": [
        "arn:aws:s3:::my-bucket/*",
        "arn:aws:s3:::test/*"
      ]
    }
  ]
}`

	iama := &IamApiServer{
		s3ApiConfig: &mockIamS3ApiConfig{},
	}
	putValues := url.Values{
		"UserName":       []string{"alice"},
		"PolicyName":     []string{"inline-policy"},
		"PolicyDocument": []string{policyJSON},
	}
	_, iamErr := iama.PutUserPolicy(s3cfg, putValues)
	assert.Nil(t, iamErr)

	getValues := url.Values{
		"UserName":   []string{"alice"},
		"PolicyName": []string{"inline-policy"},
	}
	resp, iamErr := iama.GetUserPolicy(s3cfg, getValues)
	assert.Nil(t, iamErr)

	// Verify that key policy properties are preserved (not merged or lost)
	var got policy_engine.PolicyDocument
	assert.NoError(t, json.Unmarshal([]byte(resp.GetUserPolicyResult.PolicyDocument), &got))

	// Assert we have exactly 3 statements (not merged into 1 or lost)
	assert.Equal(t, 3, len(got.Statement))

	// Assert that DeleteObject statement is present (was lost in the bug)
	deleteObjectFound := false
	for _, stmt := range got.Statement {
		if len(stmt.Action.Strings()) > 0 {
			for _, action := range stmt.Action.Strings() {
				if action == "s3:DeleteObject" {
					deleteObjectFound = true
					break
				}
			}
		}
	}
	assert.True(t, deleteObjectFound, "s3:DeleteObject action was lost")
}

// TestPutGetUserPolicyIssue9008 is a regression test for
// https://github.com/seaweedfs/seaweedfs/issues/9008: put-user-policy followed
// by get-user-policy must return the same policy document that was submitted,
// with Action and Resource lists intact (no duplication, no collapsing).
func TestPutGetUserPolicyIssue9008(t *testing.T) {
	s3cfg := &iam_pb.S3ApiConfiguration{
		Identities: []*iam_pb.Identity{{Name: "steward"}},
	}
	policyJSON := `{
    "Version": "2012-10-17",
    "Statement": [{
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:PutObject", "s3:ListBucket"],
      "Resource": ["arn:aws:s3:::b-le*", "arn:aws:s3:::b-le*/*"]
    }]
  }`

	mockCfg := &mockIamS3ApiConfig{}
	iama := &IamApiServer{s3ApiConfig: mockCfg}
	_, iamErr := iama.PutUserPolicy(s3cfg, url.Values{
		"UserName":       []string{"steward"},
		"PolicyName":     []string{"steward_policy"},
		"PolicyDocument": []string{policyJSON},
	})
	assert.Nil(t, iamErr)

	// Part 1: verbatim round-trip. GetUserPolicy returns the exact document
	// that was persisted, with Action and Resource lists intact.
	resp, iamErr := iama.GetUserPolicy(s3cfg, url.Values{
		"UserName":   []string{"steward"},
		"PolicyName": []string{"steward_policy"},
	})
	assert.Nil(t, iamErr)

	var got policy_engine.PolicyDocument
	assert.NoError(t, json.Unmarshal([]byte(resp.GetUserPolicyResult.PolicyDocument), &got))

	assert.Equal(t, "2012-10-17", got.Version)
	assert.Equal(t, 1, len(got.Statement))
	stmt := got.Statement[0]
	assert.Equal(t, policy_engine.PolicyEffectAllow, stmt.Effect)
	assert.ElementsMatch(t, []string{"s3:GetObject", "s3:PutObject", "s3:ListBucket"}, stmt.Action.Strings())
	assert.ElementsMatch(t, []string{"arn:aws:s3:::b-le*", "arn:aws:s3:::b-le*/*"}, stmt.Resource.Strings())

	// Part 2: fallback reconstruction. Clear the persisted inline policy so
	// GetUserPolicy must rebuild the document from ident.Actions. The fallback
	// is lossy (distinct S3 verbs collapse to wildcards like s3:Get*), but it
	// must not duplicate actions nor conflate bucket-level and object-level
	// resources.
	mockCfg.policies = Policies{}

	resp, iamErr = iama.GetUserPolicy(s3cfg, url.Values{
		"UserName":   []string{"steward"},
		"PolicyName": []string{"steward_policy"},
	})
	assert.Nil(t, iamErr)

	var fallback policy_engine.PolicyDocument
	assert.NoError(t, json.Unmarshal([]byte(resp.GetUserPolicyResult.PolicyDocument), &fallback))
	assert.Equal(t, 1, len(fallback.Statement), "fallback should merge equal-action statements")
	fstmt := fallback.Statement[0]

	// Each coarse verb appears exactly once (no duplication from the
	// Read/Write/List -> s3:Get*/s3:Put*/s3:List* expansion).
	assert.ElementsMatch(t, []string{"s3:Get*", "s3:Put*", "s3:List*"}, fstmt.Action.Strings())

	// Bucket-level and object-level resources stay distinct — the bare bucket
	// pattern must not be rewritten to an object ARN.
	assert.ElementsMatch(t, []string{"arn:aws:s3:::b-le*", "arn:aws:s3:::b-le*/*"}, fstmt.Resource.Strings())
}

func TestMultipleInlinePoliciesAggregateActions(t *testing.T) {
	s3cfg := &iam_pb.S3ApiConfiguration{
		Identities: []*iam_pb.Identity{{Name: "alice"}},
	}

	policy1JSON := `{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:ListBucket"],
      "Resource": ["arn:aws:s3:::bucket-a/*"]
    }
  ]
}`

	policy2JSON := `{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:PutObject"],
      "Resource": ["arn:aws:s3:::bucket-b/*"]
    }
  ]
}`

	iama := &IamApiServer{
		s3ApiConfig: &mockIamS3ApiConfig{},
	}

	// Put first inline policy
	putValues1 := url.Values{
		"UserName":       []string{"alice"},
		"PolicyName":     []string{"policy-read"},
		"PolicyDocument": []string{policy1JSON},
	}
	_, iamErr := iama.PutUserPolicy(s3cfg, putValues1)
	assert.Nil(t, iamErr)

	// Check that alice's actions include read operations
	aliceIdent := s3cfg.Identities[0]
	assert.Greater(t, len(aliceIdent.Actions), 0, "Actions should not be empty after first policy")

	// Put second inline policy
	putValues2 := url.Values{
		"UserName":       []string{"alice"},
		"PolicyName":     []string{"policy-write"},
		"PolicyDocument": []string{policy2JSON},
	}
	_, iamErr = iama.PutUserPolicy(s3cfg, putValues2)
	assert.Nil(t, iamErr)

	// Check that alice now has aggregated actions from both policies
	// Should include Read and List (from policy1) and Write (from policy2)
	// with resource paths indicating which policy they came from

	// Build a set of actual action strings for exact membership checks
	actionSet := make(map[string]bool)
	for _, action := range aliceIdent.Actions {
		actionSet[action] = true
	}

	// Expected actions from both policies:
	// - policy1: GetObject, ListBucket on bucket-a/*  → "Read:bucket-a/*", "List:bucket-a/*"
	// - policy2: PutObject on bucket-b/*  → "Write:bucket-b/*"
	expectedActions := []string{
		"Read:bucket-a/*",
		"List:bucket-a/*",
		"Write:bucket-b/*",
	}

	for _, expectedAction := range expectedActions {
		assert.True(t, actionSet[expectedAction], "Expected action '%s' not found in aggregated actions. Got: %v", expectedAction, aliceIdent.Actions)
	}
}

// newTestIamApiServer creates a minimal IamApiServer for unit testing with only s3ApiConfig set.
// Other fields (iam, masterClient, etc.) are left nil — tests must not call code paths that use them.
func newTestIamApiServer(policies Policies) *IamApiServer {
	return &IamApiServer{
		s3ApiConfig: &mockIamS3ApiConfig{policies: policies},
	}
}

func TestGetPolicy(t *testing.T) {
	policyDoc := policy_engine.PolicyDocument{
		Version: "2012-10-17",
		Statement: []policy_engine.PolicyStatement{
			{
				Effect:   policy_engine.PolicyEffectAllow,
				Action:   policy_engine.NewStringOrStringSlice("s3:GetObject"),
				Resource: policy_engine.NewStringOrStringSlicePtr("arn:aws:s3:::*"),
			},
		},
	}
	iama := newTestIamApiServer(Policies{
		Policies: map[string]policy_engine.PolicyDocument{"my-policy": policyDoc},
	})
	s3cfg := &iam_pb.S3ApiConfiguration{}

	// Success case
	values := url.Values{"PolicyArn": []string{"arn:aws:iam:::policy/my-policy"}}
	resp, iamErr := iama.GetPolicy(s3cfg, values)
	assert.Nil(t, iamErr)
	assert.Equal(t, "my-policy", *resp.GetPolicyResult.Policy.PolicyName)
	assert.Equal(t, "arn:aws:iam:::policy/my-policy", *resp.GetPolicyResult.Policy.Arn)
	policyName := "my-policy"
	expectedId := Hash(&policyName)
	assert.Equal(t, expectedId, *resp.GetPolicyResult.Policy.PolicyId)

	// Not found case
	values = url.Values{"PolicyArn": []string{"arn:aws:iam:::policy/nonexistent"}}
	_, iamErr = iama.GetPolicy(s3cfg, values)
	assert.NotNil(t, iamErr)
	assert.Equal(t, iam.ErrCodeNoSuchEntityException, iamErr.Code)

	// Invalid ARN
	values = url.Values{"PolicyArn": []string{"invalid-arn"}}
	_, iamErr = iama.GetPolicy(s3cfg, values)
	assert.NotNil(t, iamErr)
	assert.Equal(t, iam.ErrCodeInvalidInputException, iamErr.Code)

	// Empty ARN
	values = url.Values{"PolicyArn": []string{""}}
	_, iamErr = iama.GetPolicy(s3cfg, values)
	assert.NotNil(t, iamErr)
	assert.Equal(t, iam.ErrCodeInvalidInputException, iamErr.Code)
}

func TestDeletePolicy(t *testing.T) {
	policyDoc := policy_engine.PolicyDocument{
		Version: "2012-10-17",
		Statement: []policy_engine.PolicyStatement{
			{
				Effect:   policy_engine.PolicyEffectAllow,
				Action:   policy_engine.NewStringOrStringSlice("s3:GetObject"),
				Resource: policy_engine.NewStringOrStringSlicePtr("arn:aws:s3:::*"),
			},
		},
	}
	mock := &mockIamS3ApiConfig{policies: Policies{
		Policies: map[string]policy_engine.PolicyDocument{"my-policy": policyDoc},
	}}
	iama := &IamApiServer{s3ApiConfig: mock}

	// Reject deletion when policy is attached to a user (AWS-compatible behavior)
	s3cfg := &iam_pb.S3ApiConfiguration{
		Identities: []*iam_pb.Identity{{
			Name:        "alice",
			PolicyNames: []string{"my-policy"},
		}},
	}
	values := url.Values{"PolicyArn": []string{"arn:aws:iam:::policy/my-policy"}}
	_, iamErr := iama.DeletePolicy(s3cfg, values)
	assert.NotNil(t, iamErr)
	assert.Equal(t, iam.ErrCodeDeleteConflictException, iamErr.Code)

	// Succeed when no users are attached
	s3cfgEmpty := &iam_pb.S3ApiConfiguration{}
	_, iamErr = iama.DeletePolicy(s3cfgEmpty, values)
	assert.Nil(t, iamErr)

	// Verify deleted
	_, iamErr = iama.GetPolicy(s3cfgEmpty, values)
	assert.NotNil(t, iamErr)
	assert.Equal(t, iam.ErrCodeNoSuchEntityException, iamErr.Code)
}

func TestListPolicies(t *testing.T) {
	iama := newTestIamApiServer(Policies{})
	s3cfg := &iam_pb.S3ApiConfiguration{}

	// Empty case
	resp, iamErr := iama.ListPolicies(s3cfg, url.Values{})
	assert.Nil(t, iamErr)
	assert.Empty(t, resp.ListPoliciesResult.Policies)

	// Populated case
	policyDoc := policy_engine.PolicyDocument{
		Version: "2012-10-17",
		Statement: []policy_engine.PolicyStatement{
			{
				Effect:   policy_engine.PolicyEffectAllow,
				Action:   policy_engine.NewStringOrStringSlice("s3:GetObject"),
				Resource: policy_engine.NewStringOrStringSlicePtr("arn:aws:s3:::*"),
			},
		},
	}
	iama = newTestIamApiServer(Policies{
		Policies: map[string]policy_engine.PolicyDocument{
			"policy-a": policyDoc,
			"policy-b": policyDoc,
		},
	})

	resp, iamErr = iama.ListPolicies(s3cfg, url.Values{})
	assert.Nil(t, iamErr)
	assert.Equal(t, 2, len(resp.ListPoliciesResult.Policies))
	for _, p := range resp.ListPoliciesResult.Policies {
		name := *p.PolicyName
		expectedId := Hash(&name)
		assert.Equal(t, expectedId, *p.PolicyId, "PolicyId should be Hash(policyName) for %s", name)
	}
}

func TestAttachUserPolicy(t *testing.T) {
	policyDoc := policy_engine.PolicyDocument{
		Version: "2012-10-17",
		Statement: []policy_engine.PolicyStatement{
			{
				Effect:   policy_engine.PolicyEffectAllow,
				Action:   policy_engine.NewStringOrStringSlice("s3:GetObject"),
				Resource: policy_engine.NewStringOrStringSlicePtr("arn:aws:s3:::*"),
			},
		},
	}
	iama := newTestIamApiServer(Policies{
		Policies: map[string]policy_engine.PolicyDocument{"my-policy": policyDoc},
	})
	s3cfg := &iam_pb.S3ApiConfiguration{
		Identities: []*iam_pb.Identity{{Name: "alice"}},
	}

	// Success case
	values := url.Values{
		"UserName":  []string{"alice"},
		"PolicyArn": []string{"arn:aws:iam:::policy/my-policy"},
	}
	_, iamErr := iama.AttachUserPolicy(s3cfg, values)
	assert.Nil(t, iamErr)
	assert.Contains(t, s3cfg.Identities[0].PolicyNames, "my-policy")
	// Verify actions were computed from the managed policy
	assert.Greater(t, len(s3cfg.Identities[0].Actions), 0)

	// Idempotent re-attach
	_, iamErr = iama.AttachUserPolicy(s3cfg, values)
	assert.Nil(t, iamErr)
	// Should still have exactly one entry
	count := 0
	for _, name := range s3cfg.Identities[0].PolicyNames {
		if name == "my-policy" {
			count++
		}
	}
	assert.Equal(t, 1, count)

	// Policy not found
	values = url.Values{
		"UserName":  []string{"alice"},
		"PolicyArn": []string{"arn:aws:iam:::policy/nonexistent"},
	}
	_, iamErr = iama.AttachUserPolicy(s3cfg, values)
	assert.NotNil(t, iamErr)
	assert.Equal(t, iam.ErrCodeNoSuchEntityException, iamErr.Code)

	// User not found
	values = url.Values{
		"UserName":  []string{"bob"},
		"PolicyArn": []string{"arn:aws:iam:::policy/my-policy"},
	}
	_, iamErr = iama.AttachUserPolicy(s3cfg, values)
	assert.NotNil(t, iamErr)
	assert.Equal(t, iam.ErrCodeNoSuchEntityException, iamErr.Code)
}

func TestManagedPolicyActionsPreservedAcrossInlineMutations(t *testing.T) {
	managedPolicyDoc := policy_engine.PolicyDocument{
		Version: "2012-10-17",
		Statement: []policy_engine.PolicyStatement{
			{
				Effect:   policy_engine.PolicyEffectAllow,
				Action:   policy_engine.NewStringOrStringSlice("s3:GetObject"),
				Resource: policy_engine.NewStringOrStringSlicePtr("arn:aws:s3:::*"),
			},
		},
	}
	iama := newTestIamApiServer(Policies{
		Policies: map[string]policy_engine.PolicyDocument{"my-policy": managedPolicyDoc},
	})
	s3cfg := &iam_pb.S3ApiConfiguration{
		Identities: []*iam_pb.Identity{{Name: "alice"}},
	}

	// Attach managed policy
	_, iamErr := iama.AttachUserPolicy(s3cfg, url.Values{
		"UserName":  []string{"alice"},
		"PolicyArn": []string{"arn:aws:iam:::policy/my-policy"},
	})
	assert.Nil(t, iamErr)
	assert.Contains(t, s3cfg.Identities[0].Actions, "Read", "Managed policy should grant Read action")

	// Add an inline policy
	inlinePolicyJSON := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObject","Resource":"arn:aws:s3:::bucket-x/*"}]}`
	_, iamErr = iama.PutUserPolicy(s3cfg, url.Values{
		"UserName":       []string{"alice"},
		"PolicyName":     []string{"inline-write"},
		"PolicyDocument": []string{inlinePolicyJSON},
	})
	assert.Nil(t, iamErr)

	// Should have both managed (Read) and inline (Write:bucket-x/*) actions
	actionSet := make(map[string]bool)
	for _, a := range s3cfg.Identities[0].Actions {
		actionSet[a] = true
	}
	assert.True(t, actionSet["Read"], "Managed policy Read action should persist after PutUserPolicy")
	assert.True(t, actionSet["Write:bucket-x/*"], "Inline policy Write action should be present")

	// Delete the inline policy
	_, iamErr = iama.DeleteUserPolicy(s3cfg, url.Values{
		"UserName":   []string{"alice"},
		"PolicyName": []string{"inline-write"},
	})
	assert.Nil(t, iamErr)

	// Managed policy actions should still be present
	assert.Contains(t, s3cfg.Identities[0].PolicyNames, "my-policy", "Managed policy should still be attached")
	assert.Contains(t, s3cfg.Identities[0].Actions, "Read", "Managed policy Read action should persist after DeleteUserPolicy")
}

func TestDetachUserPolicy(t *testing.T) {
	policyDoc := policy_engine.PolicyDocument{
		Version: "2012-10-17",
		Statement: []policy_engine.PolicyStatement{
			{
				Effect:   policy_engine.PolicyEffectAllow,
				Action:   policy_engine.NewStringOrStringSlice("s3:GetObject"),
				Resource: policy_engine.NewStringOrStringSlicePtr("arn:aws:s3:::*"),
			},
		},
	}
	iama := newTestIamApiServer(Policies{
		Policies: map[string]policy_engine.PolicyDocument{"my-policy": policyDoc},
	})
	s3cfg := &iam_pb.S3ApiConfiguration{
		Identities: []*iam_pb.Identity{{Name: "alice", PolicyNames: []string{"my-policy"}}},
	}

	values := url.Values{
		"UserName":  []string{"alice"},
		"PolicyArn": []string{"arn:aws:iam:::policy/my-policy"},
	}
	_, iamErr := iama.DetachUserPolicy(s3cfg, values)
	assert.Nil(t, iamErr)
	assert.Empty(t, s3cfg.Identities[0].PolicyNames)

	// Detach again should fail (not attached)
	_, iamErr = iama.DetachUserPolicy(s3cfg, values)
	assert.NotNil(t, iamErr)
	assert.Equal(t, iam.ErrCodeNoSuchEntityException, iamErr.Code)
}

func TestListAttachedUserPolicies(t *testing.T) {
	iama := newTestIamApiServer(Policies{})
	s3cfg := &iam_pb.S3ApiConfiguration{
		Identities: []*iam_pb.Identity{{Name: "alice", PolicyNames: []string{"policy-a", "policy-b"}}},
	}

	values := url.Values{"UserName": []string{"alice"}}
	resp, iamErr := iama.ListAttachedUserPolicies(s3cfg, values)
	assert.Nil(t, iamErr)
	assert.Equal(t, 2, len(resp.ListAttachedUserPoliciesResult.AttachedPolicies))
	assert.Equal(t, "policy-a", *resp.ListAttachedUserPoliciesResult.AttachedPolicies[0].PolicyName)
	assert.Equal(t, "arn:aws:iam:::policy/policy-a", *resp.ListAttachedUserPoliciesResult.AttachedPolicies[0].PolicyArn)

	// User not found
	values = url.Values{"UserName": []string{"bob"}}
	_, iamErr = iama.ListAttachedUserPolicies(s3cfg, values)
	assert.NotNil(t, iamErr)
	assert.Equal(t, iam.ErrCodeNoSuchEntityException, iamErr.Code)
}

// TestUserArnIsValid is a regression test for issue #9786: the terraform aws
// provider (>= 6.41) reads a user back after creating it and blocks until
// GetUser returns a value that passes arn.IsARN.
func TestUserArnIsValid(t *testing.T) {
	iama := newTestIamApiServer(Policies{})
	s3cfg := &iam_pb.S3ApiConfiguration{}

	createResp := iama.CreateUser(s3cfg, url.Values{"UserName": []string{"alice"}})
	assert.True(t, arn.IsARN(aws.StringValue(createResp.CreateUserResult.User.Arn)),
		"CreateUser must return a valid ARN, got %q", aws.StringValue(createResp.CreateUserResult.User.Arn))

	getResp, iamErr := iama.GetUser(s3cfg, "alice")
	assert.Nil(t, iamErr)
	assert.True(t, arn.IsARN(aws.StringValue(getResp.GetUserResult.User.Arn)),
		"GetUser must return a valid ARN, got %q", aws.StringValue(getResp.GetUserResult.User.Arn))
}

func TestCreateAccessKeyWithCallerSuppliedKeys(t *testing.T) {
	iama := newTestIamApiServer(Policies{})
	s3cfg := &iam_pb.S3ApiConfiguration{
		Identities: []*iam_pb.Identity{{Name: "alice"}},
	}
	values := url.Values{
		"UserName":       []string{"alice"},
		"AccessKeyId":    []string{"myappkey"},
		"SecretAccessKey": []string{"mysecret1234"},
	}
	resp, iamErr := iama.CreateAccessKey(s3cfg, values)
	assert.Nil(t, iamErr)
	assert.Equal(t, "myappkey", *resp.CreateAccessKeyResult.AccessKey.AccessKeyId)
	assert.Equal(t, "mysecret1234", *resp.CreateAccessKeyResult.AccessKey.SecretAccessKey)
	assert.Equal(t, "alice", *resp.CreateAccessKeyResult.AccessKey.UserName)
	assert.Equal(t, "myappkey", s3cfg.Identities[0].Credentials[0].AccessKey)
	assert.Equal(t, "mysecret1234", s3cfg.Identities[0].Credentials[0].SecretKey)
}

func TestCreateAccessKeyRandomGeneration(t *testing.T) {
	iama := newTestIamApiServer(Policies{})
	s3cfg := &iam_pb.S3ApiConfiguration{
		Identities: []*iam_pb.Identity{{Name: "alice"}},
	}
	values := url.Values{
		"UserName": []string{"alice"},
	}
	resp, iamErr := iama.CreateAccessKey(s3cfg, values)
	assert.Nil(t, iamErr)
	assert.NotEmpty(t, *resp.CreateAccessKeyResult.AccessKey.AccessKeyId)
	assert.NotEmpty(t, *resp.CreateAccessKeyResult.AccessKey.SecretAccessKey)
	assert.Len(t, s3cfg.Identities[0].Credentials, 1)
}

func TestCreateAccessKeyRejectsWeakKeys(t *testing.T) {
	iama := newTestIamApiServer(Policies{})
	s3cfg := &iam_pb.S3ApiConfiguration{
		Identities: []*iam_pb.Identity{{Name: "alice"}},
	}
	// Too short
	values := url.Values{
		"UserName":       []string{"alice"},
		"AccessKeyId":    []string{"ab"},
		"SecretAccessKey": []string{"validsecret1"},
	}
	_, iamErr := iama.CreateAccessKey(s3cfg, values)
	assert.NotNil(t, iamErr)
	assert.Equal(t, iam.ErrCodeInvalidInputException, iamErr.Code)

	// Short secret
	values = url.Values{
		"UserName":       []string{"alice"},
		"AccessKeyId":    []string{"validkey"},
		"SecretAccessKey": []string{"short"},
	}
	_, iamErr = iama.CreateAccessKey(s3cfg, values)
	assert.NotNil(t, iamErr)
	assert.Equal(t, iam.ErrCodeInvalidInputException, iamErr.Code)

	// SigV4 delimiters
	values = url.Values{
		"UserName":       []string{"alice"},
		"AccessKeyId":    []string{"foo/bar=baz"},
		"SecretAccessKey": []string{"validsecret1"},
	}
	_, iamErr = iama.CreateAccessKey(s3cfg, values)
	assert.NotNil(t, iamErr)
	assert.Equal(t, iam.ErrCodeInvalidInputException, iamErr.Code)
}

func TestCreateAccessKeyRejectsCollision(t *testing.T) {
	iama := newTestIamApiServer(Policies{})
	// Use a distinctive owner name ("ownerAlpha") that shares no substring
	// with the expected error message so the leak assertion is meaningful.
	const ownerName = "ownerAlpha"

	t.Run("identity credential", func(t *testing.T) {
		s3cfg := &iam_pb.S3ApiConfiguration{
			Identities: []*iam_pb.Identity{
				{
					Name: ownerName,
					Credentials: []*iam_pb.Credential{
						{AccessKey: "takenkey", SecretKey: "existingsecret"},
					},
				},
				{Name: "newuser"},
			},
		}
		values := url.Values{
			"UserName":        []string{"newuser"},
			"AccessKeyId":     []string{"takenkey"},
			"SecretAccessKey": []string{"newsecret123"},
		}
		_, iamErr := iama.CreateAccessKey(s3cfg, values)
		assert.NotNil(t, iamErr)
		assert.Equal(t, iam.ErrCodeEntityAlreadyExistsException, iamErr.Code)
		assert.NotContains(t, iamErr.Error.Error(), ownerName, "should not leak owner name")
		assert.Len(t, s3cfg.Identities[1].Credentials, 0)
	})

	t.Run("service account credential", func(t *testing.T) {
		const saId = "svcAlpha"
		s3cfg := &iam_pb.S3ApiConfiguration{
			Identities: []*iam_pb.Identity{
				{Name: "newuser"},
			},
			ServiceAccounts: []*iam_pb.ServiceAccount{
				{
					Id:         saId,
					Credential: &iam_pb.Credential{AccessKey: "takenkey", SecretKey: "existingsecret"},
				},
			},
		}
		values := url.Values{
			"UserName":        []string{"newuser"},
			"AccessKeyId":     []string{"takenkey"},
			"SecretAccessKey": []string{"newsecret123"},
		}
		_, iamErr := iama.CreateAccessKey(s3cfg, values)
		assert.NotNil(t, iamErr)
		assert.Equal(t, iam.ErrCodeEntityAlreadyExistsException, iamErr.Code)
		assert.NotContains(t, iamErr.Error.Error(), saId, "should not leak owner id")
		// The service account's existing credential must be untouched, and
		// no new credential should be attached to the identity.
		assert.Equal(t, "takenkey", s3cfg.ServiceAccounts[0].Credential.AccessKey)
		assert.Equal(t, "existingsecret", s3cfg.ServiceAccounts[0].Credential.SecretKey)
		assert.Len(t, s3cfg.Identities[0].Credentials, 0)
	})
}

func TestCreateAccessKeyBoundary(t *testing.T) {
	iama := newTestIamApiServer(Policies{})
	s3cfg := &iam_pb.S3ApiConfiguration{
		Identities: []*iam_pb.Identity{{Name: "alice"}},
	}
	// Exactly 4 chars - should pass
	values := url.Values{
		"UserName":        []string{"alice"},
		"AccessKeyId":     []string{"abcd"},
		"SecretAccessKey": []string{"secretkey123"},
	}
	resp, iamErr := iama.CreateAccessKey(s3cfg, values)
	assert.Nil(t, iamErr)
	assert.Equal(t, "abcd", *resp.CreateAccessKeyResult.AccessKey.AccessKeyId)

	// Exactly 3 chars - should fail
	s3cfg.Identities[0].Credentials = nil
	values = url.Values{
		"UserName":        []string{"alice"},
		"AccessKeyId":     []string{"abc"},
		"SecretAccessKey": []string{"secretkey123"},
	}
	_, iamErr = iama.CreateAccessKey(s3cfg, values)
	assert.NotNil(t, iamErr)
	assert.Equal(t, iam.ErrCodeInvalidInputException, iamErr.Code)

	// Exactly 128 chars - should pass
	s3cfg.Identities[0].Credentials = nil
	ak128 := strings.Repeat("a", 128)
	sk128 := strings.Repeat("s", 128)
	values = url.Values{
		"UserName":        []string{"alice"},
		"AccessKeyId":     []string{ak128},
		"SecretAccessKey": []string{sk128},
	}
	resp, iamErr = iama.CreateAccessKey(s3cfg, values)
	assert.Nil(t, iamErr)
	assert.Equal(t, ak128, *resp.CreateAccessKeyResult.AccessKey.AccessKeyId)
	assert.Equal(t, sk128, *resp.CreateAccessKeyResult.AccessKey.SecretAccessKey)

	// 129 chars AccessKeyId - should fail
	s3cfg.Identities[0].Credentials = nil
	values = url.Values{
		"UserName":        []string{"alice"},
		"AccessKeyId":     []string{strings.Repeat("a", 129)},
		"SecretAccessKey": []string{sk128},
	}
	_, iamErr = iama.CreateAccessKey(s3cfg, values)
	assert.NotNil(t, iamErr)
	assert.Equal(t, iam.ErrCodeInvalidInputException, iamErr.Code)

	// 7-char SecretAccessKey - should fail
	s3cfg.Identities[0].Credentials = nil
	values = url.Values{
		"UserName":        []string{"alice"},
		"AccessKeyId":     []string{"validkey"},
		"SecretAccessKey": []string{"1234567"},
	}
	_, iamErr = iama.CreateAccessKey(s3cfg, values)
	assert.NotNil(t, iamErr)
	assert.Equal(t, iam.ErrCodeInvalidInputException, iamErr.Code)

	// Exactly 8-char SecretAccessKey - should pass (lower boundary)
	s3cfg.Identities[0].Credentials = nil
	values = url.Values{
		"UserName":        []string{"alice"},
		"AccessKeyId":     []string{"validkey"},
		"SecretAccessKey": []string{"12345678"},
	}
	resp, iamErr = iama.CreateAccessKey(s3cfg, values)
	assert.Nil(t, iamErr)
	assert.Equal(t, "12345678", *resp.CreateAccessKeyResult.AccessKey.SecretAccessKey)

	// 129-char SecretAccessKey - should fail
	s3cfg.Identities[0].Credentials = nil
	values = url.Values{
		"UserName":        []string{"alice"},
		"AccessKeyId":     []string{"validkey"},
		"SecretAccessKey": []string{strings.Repeat("s", 129)},
	}
	_, iamErr = iama.CreateAccessKey(s3cfg, values)
	assert.NotNil(t, iamErr)
	assert.Equal(t, iam.ErrCodeInvalidInputException, iamErr.Code)
}

func TestCreateAccessKeyRejectsPartialSupply(t *testing.T) {
	iama := newTestIamApiServer(Policies{})
	s3cfg := &iam_pb.S3ApiConfiguration{
		Identities: []*iam_pb.Identity{{Name: "alice"}},
	}
	// AccessKeyId supplied, SecretAccessKey omitted
	values := url.Values{
		"UserName":    []string{"alice"},
		"AccessKeyId": []string{"myappkey"},
	}
	_, iamErr := iama.CreateAccessKey(s3cfg, values)
	assert.NotNil(t, iamErr)
	assert.Equal(t, iam.ErrCodeInvalidInputException, iamErr.Code)
	assert.Len(t, s3cfg.Identities[0].Credentials, 0)

	// SecretAccessKey supplied, AccessKeyId omitted
	values = url.Values{
		"UserName":        []string{"alice"},
		"SecretAccessKey": []string{"secretkey123"},
	}
	_, iamErr = iama.CreateAccessKey(s3cfg, values)
	assert.NotNil(t, iamErr)
	assert.Equal(t, iam.ErrCodeInvalidInputException, iamErr.Code)
	assert.Len(t, s3cfg.Identities[0].Credentials, 0)

	// Partial supply wins over collision: only AccessKeyId supplied, and
	// it matches an existing credential. We must see InvalidInput, not
	// EntityAlreadyExists — the both-or-none rule is more fundamental.
	s3cfg = &iam_pb.S3ApiConfiguration{
		Identities: []*iam_pb.Identity{
			{
				Name: "ownerAlpha",
				Credentials: []*iam_pb.Credential{
					{AccessKey: "takenkey", SecretKey: "s"},
				},
			},
			{Name: "alice"},
		},
	}
	values = url.Values{
		"UserName":    []string{"alice"},
		"AccessKeyId": []string{"takenkey"},
	}
	_, iamErr = iama.CreateAccessKey(s3cfg, values)
	assert.NotNil(t, iamErr)
	assert.Equal(t, iam.ErrCodeInvalidInputException, iamErr.Code)
}
