package records

import (
	"context"
	"strings"
	"testing"

	featureauth "github.com/nexuscrm/auth"
	pkgauth "github.com/nexuscrm/kernel/pkg/auth"
	"github.com/nexuscrm/shared/pkg/constants"
	"github.com/nexuscrm/shared/pkg/contract"
	sharedErrors "github.com/nexuscrm/shared/pkg/errors"
	"github.com/nexuscrm/shared/pkg/models"
)

const testPublicConnectedAppScope = "external.records.read"

const (
	connectedAppPolicyCanonicalizationCapabilityForTest = "platform.security.write.connected-app-policy-canonicalization"
	connectedAppPolicyValidationCapabilityForTest       = "platform.security.write.connected-app-policy"
)

type connectedAppScopePolicyStub map[string]bool

func (s connectedAppScopePolicyStub) AllowsPublicOAuthScope(scope string) bool {
	return s[scope]
}

func (s connectedAppScopePolicyStub) AllowsPublicOAuthScopeForPack(scope, packName string) bool {
	return strings.TrimSpace(packName) != "" && s[scope]
}

type connectedAppOwnerScopedPolicyStub struct {
	ownerPack string
	scope     string
}

func (s connectedAppOwnerScopedPolicyStub) AllowsPublicOAuthScope(string) bool {
	return false
}

func (s connectedAppOwnerScopedPolicyStub) AllowsPublicOAuthScopeForPack(scope, packName string) bool {
	return scope == s.scope && strings.TrimSpace(packName) == s.ownerPack
}

type connectedAppSessionIssuerPolicyStub map[string]map[string]bool

func (s connectedAppSessionIssuerPolicyStub) AllowsConnectedAppSessionIssuerClassForPack(class, packName string) bool {
	packs := s[class]
	if packs == nil {
		return false
	}
	return packs[packName]
}

var allowTestPublicConnectedAppScope = connectedAppScopePolicyStub{testPublicConnectedAppScope: true}

func TestConnectedAppPublicScopePolicyResolverUsesOneRequestBoundOwner(t *testing.T) {
	type contextKey struct{}
	bound := connectedAppScopePolicyStub{testPublicConnectedAppScope: true}
	static := connectedAppScopePolicyStub{testPublicConnectedAppScope: false}
	ps := &PersistenceService{}
	ps.SetOAuthPublicScopePolicyProvider(static)
	ps.SetOAuthPublicScopePolicyResolver(func(ctx context.Context) (ConnectedAppPublicScopePolicyProvider, error) {
		if got := ctx.Value(contextKey{}); got != "catalog-revision-7" {
			t.Fatalf("resolver context value = %#v", got)
		}
		return bound, nil
	})
	if ps.oauthPublicScopePolicy != nil {
		t.Fatal("installing request-bound resolver retained the static policy owner")
	}
	ctx := context.WithValue(context.Background(), contextKey{}, "catalog-revision-7")
	policy, err := ps.connectedAppPublicScopePolicyForRequest(ctx)
	if err != nil {
		t.Fatal(err)
	}
	if !policy.AllowsPublicOAuthScope(testPublicConnectedAppScope) {
		t.Fatal("request-bound policy was not returned")
	}

	ps.SetOAuthPublicScopePolicyProvider(static)
	if ps.oauthPublicScopePolicyResolver != nil {
		t.Fatal("installing static provider retained the request-bound policy owner")
	}
	policy, err = ps.connectedAppPublicScopePolicyForRequest(context.Background())
	if err != nil {
		t.Fatal(err)
	}
	if policy.AllowsPublicOAuthScope(testPublicConnectedAppScope) {
		t.Fatal("static policy was not restored as the sole owner")
	}
}

func connectedAppWriteHookSchema(accessPolicy *models.ObjectAccessPolicy) *models.ObjectMetadata {
	schema := &models.ObjectMetadata{
		ID:           "obj_connected_app",
		APIName:      constants.TableConnectedApp,
		Label:        "Connected App",
		SharingModel: constants.SharingModelPublicReadWrite,
		AccessPolicy: accessPolicy,
		Fields: []models.FieldMetadata{
			{APIName: constants.FieldID, Type: constants.FieldTypeString, Createable: false, Updateable: false},
			{APIName: constants.ColSysConnectedApp_Name, Type: constants.FieldTypeString, Createable: true, Updateable: true},
			{APIName: constants.ColSysConnectedApp_ConsumerKey, Type: constants.FieldTypeString, Createable: true, Updateable: true},
			{APIName: constants.ColSysConnectedApp_ConsumerSecret, Type: constants.FieldTypeString, Createable: true, Updateable: true},
			{APIName: constants.ColSysConnectedApp_ConsumerSecretHash, Type: constants.FieldTypeString, Createable: true, Updateable: true},
			{APIName: constants.ColSysConnectedApp_CallbackURL, Type: constants.FieldTypeString, Createable: true, Updateable: true},
			{APIName: constants.ColSysConnectedApp_RedirectURIs, Type: constants.FieldTypeString, Createable: true, Updateable: true},
			{APIName: constants.ColSysConnectedApp_Scopes, Type: constants.FieldTypeString, Createable: true, Updateable: true},
			{APIName: constants.ColSysConnectedApp_IsActive, Type: constants.FieldTypeBoolean, Createable: true, Updateable: true},
			{APIName: constants.ColSysConnectedApp_ClientType, Type: constants.FieldTypeString, Createable: true, Updateable: true},
			{APIName: constants.ColSysConnectedApp_RequirePkce, Type: constants.FieldTypeBoolean, Createable: true, Updateable: true},
			{APIName: constants.ColSysConnectedApp_TokenEndpointAuthMethod, Type: constants.FieldTypeString, Createable: true, Updateable: true},
			{APIName: constants.ColSysConnectedApp_AllowedGrantTypes, Type: constants.FieldTypeString, Createable: true, Updateable: true},
			{APIName: constants.ColSysConnectedApp_ConsentPolicy, Type: constants.FieldTypeString, Createable: true, Updateable: true},
			{APIName: constants.ColSysConnectedApp_ConsentVersion, Type: constants.FieldTypeDouble, Createable: true, Updateable: true},
			{APIName: constants.ColSysConnectedApp_IssueRefreshTokens, Type: constants.FieldTypeBoolean, Createable: true, Updateable: true},
			{APIName: constants.ColSysConnectedApp_RunAsUserID, Type: constants.FieldTypeString, Createable: true, Updateable: true},
			{APIName: constants.ColSysConnectedApp_PermittedUsers, Type: constants.FieldTypeString, Createable: true, Updateable: true},
			{APIName: constants.ColSysConnectedApp_SessionIssuerClass, Type: constants.FieldTypeString, Createable: true, Updateable: true},
		},
	}
	makeFieldsMap(schema)
	return schema
}

func connectedAppWriteHookAccessPolicy(includeCanonicalizer, includeValidator bool) *models.ObjectAccessPolicy {
	policy := &models.ObjectAccessPolicy{
		WriteValidation: &models.RecordWriteValidationAccessPolicy{},
	}
	if includeCanonicalizer {
		policy.WriteValidation.Canonicalizers = []models.RecordWriteCanonicalizationPolicy{
			{Phase: string(contract.RecordWriteValidationBeforeCreate), Capability: connectedAppPolicyCanonicalizationCapabilityForTest},
			{Phase: string(contract.RecordWriteValidationBeforeUpdate), Capability: connectedAppPolicyCanonicalizationCapabilityForTest},
		}
	}
	if includeValidator {
		policy.WriteValidation.Validators = []models.RecordWriteValidationPolicy{
			{Phase: string(contract.RecordWriteValidationBeforeCreate), Capability: connectedAppPolicyValidationCapabilityForTest},
			{Phase: string(contract.RecordWriteValidationBeforeUpdate), Capability: connectedAppPolicyValidationCapabilityForTest},
		}
	}
	return policy
}

func validConfidentialConnectedAppRecord() models.SObject {
	return models.SObject{
		constants.ColSysConnectedApp_Name:           "Integration Client",
		constants.ColSysConnectedApp_ConsumerKey:    "integration-client",
		constants.ColSysConnectedApp_ConsumerSecret: "super-secret",
	}
}

func TestConnectedAppPolicyCanonicalizationFailsClosedWhenDeclarationDropped(t *testing.T) {
	repo := &captureRecordRepo{}
	ps := newRecordEntrypointTestService(connectedAppWriteHookSchema(nil), repo)

	_, err := ps.Insert(context.Background(), constants.TableConnectedApp, validConfidentialConnectedAppRecord(), recordEntrypointUser())
	if err == nil {
		t.Fatal("Insert succeeded without ConnectedApp policy canonicalization metadata, want fail-closed error")
	}
	if !strings.Contains(err.Error(), "missing record write canonicalization metadata") ||
		!strings.Contains(err.Error(), connectedAppPolicyCanonicalizationCapabilityForTest) {
		t.Fatalf("Insert error = %v, want missing ConnectedApp policy canonicalization metadata", err)
	}
	if repo.inserted != nil {
		t.Fatalf("Insert persisted ConnectedApp after missing canonicalization metadata: %#v", repo.inserted)
	}
}

func TestConnectedAppPolicyValidationFailsClosedWhenDeclarationDropped(t *testing.T) {
	repo := &captureRecordRepo{}
	schema := connectedAppWriteHookSchema(connectedAppWriteHookAccessPolicy(true, false))
	ps := newRecordEntrypointTestService(schema, repo)

	_, err := ps.Insert(context.Background(), constants.TableConnectedApp, validConfidentialConnectedAppRecord(), recordEntrypointUser())
	if err == nil {
		t.Fatal("Insert succeeded without ConnectedApp policy validation metadata, want fail-closed error")
	}
	if !strings.Contains(err.Error(), "missing record write validation metadata") ||
		!strings.Contains(err.Error(), connectedAppPolicyValidationCapabilityForTest) {
		t.Fatalf("Insert error = %v, want missing ConnectedApp policy validation metadata", err)
	}
	if repo.inserted != nil {
		t.Fatalf("Insert persisted ConnectedApp after missing validation metadata: %#v", repo.inserted)
	}
}

func TestConnectedAppPolicyHooksDefaultAndHashBeforeCreateSQL(t *testing.T) {
	repo := &captureRecordRepo{}
	schema := connectedAppWriteHookSchema(connectedAppWriteHookAccessPolicy(true, true))
	ps := newRecordEntrypointTestService(schema, repo)

	_, err := ps.Insert(context.Background(), constants.TableConnectedApp, validConfidentialConnectedAppRecord(), recordEntrypointUser())
	if err != nil {
		t.Fatalf("Insert returned error: %v", err)
	}
	if repo.inserted == nil {
		t.Fatal("Insert did not reach repo.Insert")
	}
	if got := repo.inserted[constants.ColSysConnectedApp_ClientType]; got != connectedAppClientTypeConfidential {
		t.Fatalf("ClientType = %#v, want confidential default", got)
	}
	if got := repo.inserted[constants.ColSysConnectedApp_TokenEndpointAuthMethod]; got != connectedAppTokenAuthClientSecretPost {
		t.Fatalf("TokenEndpointAuthMethod = %#v, want client_secret_post default", got)
	}
	if got := repo.inserted[constants.ColSysConnectedApp_AllowedGrantTypes]; got != "authorization_code password client_credentials" {
		t.Fatalf("AllowedGrantTypes = %#v, want confidential default grant set", got)
	}
	if got := repo.inserted[constants.ColSysConnectedApp_ConsentPolicy]; got != connectedAppConsentPrompt {
		t.Fatalf("ConsentPolicy = %#v, want prompt default", got)
	}
	if got := repo.inserted[constants.ColSysConnectedApp_ConsumerSecret]; got != "" {
		t.Fatalf("ConsumerSecret = %#v, want cleared after hashing", got)
	}
	hash, _ := repo.inserted[constants.ColSysConnectedApp_ConsumerSecretHash].(string)
	if hash == "" {
		t.Fatal("ConsumerSecretHash is empty; want hook to hash before SQL")
	}
	if !pkgauth.VerifyPassword("super-secret", hash) {
		t.Fatal("ConsumerSecretHash does not verify original secret")
	}
}

func TestConnectedAppPolicyHooksUseTrustedScopeOwnerContextBeforeInsert(t *testing.T) {
	repo := &captureRecordRepo{}
	schema := connectedAppWriteHookSchema(connectedAppWriteHookAccessPolicy(true, true))
	ps := newRecordEntrypointTestService(schema, repo)
	ps.SetOAuthPublicScopePolicyProvider(connectedAppOwnerScopedPolicyStub{
		ownerPack: "crm-pack",
		scope:     testPublicConnectedAppScope,
	})

	ctx := WithConnectedAppScopeOwnerPack(context.Background(), "crm-pack")
	_, err := ps.Insert(ctx, constants.TableConnectedApp, models.SObject{
		constants.ColSysConnectedApp_Name:                    "CRM public client",
		constants.ColSysConnectedApp_ConsumerKey:             "crm-public-client",
		constants.ColSysConnectedApp_ClientType:              connectedAppClientTypePublic,
		constants.ColSysConnectedApp_RedirectURIs:            "https://client.example/callback",
		constants.ColSysConnectedApp_Scopes:                  testPublicConnectedAppScope,
		constants.ColSysConnectedApp_RequirePkce:             true,
		constants.ColSysConnectedApp_TokenEndpointAuthMethod: connectedAppTokenAuthNone,
		constants.ColSysConnectedApp_AllowedGrantTypes:       connectedAppGrantAuthorizationCode,
		constants.ColSysConnectedApp_ConsentPolicy:           connectedAppConsentAdminPreapproved,
		constants.ColSysConnectedApp_PermittedUsers:          "AllUsers",
		constants.ColSysConnectedApp_IsActive:                true,
	}, recordEntrypointUser())
	if err != nil {
		t.Fatalf("source-owned Connected App Insert: %v", err)
	}
	if repo.inserted == nil {
		t.Fatal("source-owned Connected App did not reach persistence")
	}
}

func TestConnectedAppPolicyHooksRejectMismatchedScopeOwnerContext(t *testing.T) {
	repo := &captureRecordRepo{}
	schema := connectedAppWriteHookSchema(connectedAppWriteHookAccessPolicy(true, true))
	ps := newRecordEntrypointTestService(schema, repo)
	ps.SetOAuthPublicScopePolicyProvider(connectedAppOwnerScopedPolicyStub{
		ownerPack: "crm-pack",
		scope:     testPublicConnectedAppScope,
	})

	ctx := WithConnectedAppScopeOwnerPack(context.Background(), "other-pack")
	_, err := ps.Insert(ctx, constants.TableConnectedApp, models.SObject{
		constants.ColSysConnectedApp_Name:                    "Wrong owner public client",
		constants.ColSysConnectedApp_ConsumerKey:             "wrong-owner-public-client",
		constants.ColSysConnectedApp_ClientType:              connectedAppClientTypePublic,
		constants.ColSysConnectedApp_RedirectURIs:            "https://client.example/callback",
		constants.ColSysConnectedApp_Scopes:                  testPublicConnectedAppScope,
		constants.ColSysConnectedApp_RequirePkce:             true,
		constants.ColSysConnectedApp_TokenEndpointAuthMethod: connectedAppTokenAuthNone,
		constants.ColSysConnectedApp_AllowedGrantTypes:       connectedAppGrantAuthorizationCode,
		constants.ColSysConnectedApp_ConsentPolicy:           connectedAppConsentAdminPreapproved,
		constants.ColSysConnectedApp_PermittedUsers:          "AllUsers",
		constants.ColSysConnectedApp_IsActive:                true,
	}, recordEntrypointUser())
	if err == nil {
		t.Fatal("Connected App Insert succeeded with mismatched source pack")
	}
	if repo.inserted != nil {
		t.Fatalf("mismatched source pack reached persistence: %#v", repo.inserted)
	}
}

func TestConnectedAppPolicyHooksDefaultAndHashBeforeBulkCreateSQL(t *testing.T) {
	repo := &captureRecordRepo{}
	schema := connectedAppWriteHookSchema(connectedAppWriteHookAccessPolicy(true, true))
	ps := newRecordEntrypointTestService(schema, repo)

	result, err := ps.BulkInsert(context.Background(), constants.TableConnectedApp, []models.SObject{
		validConfidentialConnectedAppRecord(),
	}, recordEntrypointUser(), BulkInsertOptions{})
	if err != nil {
		t.Fatalf("BulkInsert returned error: %v", err)
	}
	if result.SuccessCount != 1 || result.FailedCount != 0 {
		t.Fatalf("BulkInsert result = %+v, want one successful ConnectedApp row", result)
	}
	if len(repo.bulkInserted) != 1 {
		t.Fatalf("repo.BulkInsert rows = %d, want one row", len(repo.bulkInserted))
	}
	row := repo.bulkInserted[0]
	if got := row[constants.ColSysConnectedApp_ClientType]; got != connectedAppClientTypeConfidential {
		t.Fatalf("ClientType = %#v, want confidential default", got)
	}
	if got := row[constants.ColSysConnectedApp_ConsumerSecret]; got != "" {
		t.Fatalf("ConsumerSecret = %#v, want cleared after hashing", got)
	}
	hash, _ := row[constants.ColSysConnectedApp_ConsumerSecretHash].(string)
	if hash == "" {
		t.Fatal("ConsumerSecretHash is empty; want hook to hash before bulk SQL")
	}
	if !pkgauth.VerifyPassword("super-secret", hash) {
		t.Fatal("ConsumerSecretHash does not verify original secret")
	}
}

func TestConnectedAppPolicyHooksHashBeforeUpdateSQL(t *testing.T) {
	existingHash, err := pkgauth.HashPassword("old-secret")
	if err != nil {
		t.Fatalf("HashPassword: %v", err)
	}
	repo := &captureRecordRepo{
		old: models.SObject{
			constants.FieldID:                                    "0CA000000000001",
			constants.ColSysConnectedApp_Name:                    "Integration Client",
			constants.ColSysConnectedApp_ConsumerKey:             "integration-client",
			constants.ColSysConnectedApp_ClientType:              connectedAppClientTypeConfidential,
			constants.ColSysConnectedApp_TokenEndpointAuthMethod: connectedAppTokenAuthClientSecretPost,
			constants.ColSysConnectedApp_AllowedGrantTypes:       "authorization_code password client_credentials",
			constants.ColSysConnectedApp_ConsentPolicy:           connectedAppConsentPrompt,
			constants.ColSysConnectedApp_ConsumerSecretHash:      existingHash,
		},
	}
	schema := connectedAppWriteHookSchema(connectedAppWriteHookAccessPolicy(true, true))
	ps := newRecordEntrypointTestService(schema, repo)

	if err := ps.Update(context.Background(), constants.TableConnectedApp, "0CA000000000001", models.SObject{
		constants.ColSysConnectedApp_ConsumerSecret: "rotated-secret",
	}, recordEntrypointUser()); err != nil {
		t.Fatalf("Update returned error: %v", err)
	}
	if repo.updated == nil {
		t.Fatal("Update did not reach repo.Update")
	}
	if got := repo.updated[constants.ColSysConnectedApp_ConsumerSecret]; got != "" {
		t.Fatalf("updated ConsumerSecret = %#v, want cleared after hashing", got)
	}
	hash, _ := repo.updated[constants.ColSysConnectedApp_ConsumerSecretHash].(string)
	if hash == "" {
		t.Fatal("updated ConsumerSecretHash is empty; want hook to hash before SQL")
	}
	if hash == existingHash {
		t.Fatal("updated ConsumerSecretHash reused old hash after secret rotation")
	}
	if !pkgauth.VerifyPassword("rotated-secret", hash) {
		t.Fatal("updated ConsumerSecretHash does not verify rotated secret")
	}
}

func TestConnectedAppPolicyHooksDoNotApplyCreateDefaultsToRawUpdatePatch(t *testing.T) {
	repo := &captureRecordRepo{
		old: models.SObject{
			constants.FieldID:                                    "0CA000000000002",
			constants.ColSysConnectedApp_Name:                    "Public Client",
			constants.ColSysConnectedApp_ConsumerKey:             "public-client",
			constants.ColSysConnectedApp_ClientType:              connectedAppClientTypePublic,
			constants.ColSysConnectedApp_RedirectURIs:            "https://client.example/callback",
			constants.ColSysConnectedApp_Scopes:                  testPublicConnectedAppScope,
			constants.ColSysConnectedApp_RequirePkce:             true,
			constants.ColSysConnectedApp_TokenEndpointAuthMethod: connectedAppTokenAuthNone,
			constants.ColSysConnectedApp_AllowedGrantTypes:       connectedAppGrantAuthorizationCode,
			constants.ColSysConnectedApp_ConsentPolicy:           connectedAppConsentTrusted,
		},
	}
	schema := connectedAppWriteHookSchema(connectedAppWriteHookAccessPolicy(true, true))
	ps := newRecordEntrypointTestService(schema, repo)
	ps.SetOAuthPublicScopePolicyProvider(allowTestPublicConnectedAppScope)

	if err := ps.Update(context.Background(), constants.TableConnectedApp, "0CA000000000002", models.SObject{
		constants.ColSysConnectedApp_Name: "Renamed Public Client",
	}, recordEntrypointUser()); err != nil {
		t.Fatalf("Update returned error: %v", err)
	}
	if repo.updated == nil {
		t.Fatal("Update did not reach repo.Update")
	}
	if got := repo.updated[constants.ColSysConnectedApp_Name]; got != "Renamed Public Client" {
		t.Fatalf("updated Name = %#v, want rename only; updates=%#v", got, repo.updated)
	}
	for _, field := range []string{
		constants.ColSysConnectedApp_ClientType,
		constants.ColSysConnectedApp_TokenEndpointAuthMethod,
		constants.ColSysConnectedApp_AllowedGrantTypes,
		constants.ColSysConnectedApp_ConsentPolicy,
		constants.ColSysConnectedApp_ConsumerSecret,
		constants.ColSysConnectedApp_ConsumerSecretHash,
	} {
		if _, ok := repo.updated[field]; ok {
			t.Fatalf("updated %s unexpectedly present after rename-only patch; updates=%#v", field, repo.updated)
		}
	}
}

func TestConnectedAppPolicyHooksAllowSourceOwnedLifecycleUpdateWithoutOwnerContext(t *testing.T) {
	repo := &captureRecordRepo{
		old: models.SObject{
			constants.FieldID:                                    "0CA000000000003",
			constants.ColSysConnectedApp_Name:                    "Source-owned public client",
			constants.ColSysConnectedApp_ConsumerKey:             "source-owned-public-client",
			constants.ColSysConnectedApp_ClientType:              connectedAppClientTypePublic,
			constants.ColSysConnectedApp_RedirectURIs:            "https://client.example/callback",
			constants.ColSysConnectedApp_Scopes:                  testPublicConnectedAppScope,
			constants.ColSysConnectedApp_RequirePkce:             true,
			constants.ColSysConnectedApp_TokenEndpointAuthMethod: connectedAppTokenAuthNone,
			constants.ColSysConnectedApp_AllowedGrantTypes:       connectedAppGrantAuthorizationCode,
			constants.ColSysConnectedApp_ConsentPolicy:           connectedAppConsentAdminPreapproved,
			constants.ColSysConnectedApp_PermittedUsers:          "AllUsers",
			constants.ColSysConnectedApp_IsActive:                true,
		},
	}
	ps := newRecordEntrypointTestService(
		connectedAppWriteHookSchema(connectedAppWriteHookAccessPolicy(true, true)),
		repo,
	)

	if err := ps.Update(context.Background(), constants.TableConnectedApp, "0CA000000000003", models.SObject{
		constants.ColSysConnectedApp_IsActive: false,
	}, recordEntrypointUser()); err != nil {
		t.Fatalf("lifecycle Update returned error: %v", err)
	}
	if got := repo.updated[constants.ColSysConnectedApp_IsActive]; got != false {
		t.Fatalf("updated IsActive = %#v, want false", got)
	}
}

func TestConnectedAppPolicyFieldsUnchangedIncludesCallbackURL(t *testing.T) {
	oldRecord := models.SObject{
		constants.ColSysConnectedApp_CallbackURL: "https://client.example/callback",
	}
	unchangedRecord := models.SObject{
		constants.ColSysConnectedApp_CallbackURL: "https://client.example/callback",
	}
	if !connectedAppPolicyFieldsUnchanged(oldRecord, unchangedRecord) {
		t.Fatal("identical CallbackURL values were treated as a security-policy change")
	}
	changedRecord := models.SObject{
		constants.ColSysConnectedApp_CallbackURL: "https://other.example/callback",
	}
	if connectedAppPolicyFieldsUnchanged(oldRecord, changedRecord) {
		t.Fatal("CallbackURL change bypassed Connected App security-policy validation")
	}
}

func TestNormalizeSourceBackedConnectedAppPolicyRequiresExplicitRuntimeFields(t *testing.T) {
	rec := sourceBackedConnectedAppRecord()
	delete(rec, constants.ColSysConnectedApp_TokenEndpointAuthMethod)

	if err := NormalizeSourceBackedConnectedAppPolicy(rec, SourceBackedConnectedAppPolicyOptions{PackName: "oauth-client-pack"}); err == nil {
		t.Fatal("NormalizeSourceBackedConnectedAppPolicy succeeded without TokenEndpointAuthMethod")
	}
	if _, ok := rec[constants.ColSysConnectedApp_TokenEndpointAuthMethod]; ok {
		t.Fatal("NormalizeSourceBackedConnectedAppPolicy invented TokenEndpointAuthMethod")
	}
}

func TestNormalizeSourceBackedConnectedAppPolicyAllowsSamePackProductSessionIssuer(t *testing.T) {
	rec := sourceBackedConnectedAppRecord()
	rec[constants.ColSysConnectedApp_SessionIssuerClass] = "Synthetic-Session"

	if err := NormalizeSourceBackedConnectedAppPolicy(rec, SourceBackedConnectedAppPolicyOptions{
		PackName: "oauth-client-pack",
		SessionIssuerClassPolicy: connectedAppSessionIssuerPolicyStub{
			"synthetic_session": {"oauth-client-pack": true},
		},
	}); err != nil {
		t.Fatalf("NormalizeSourceBackedConnectedAppPolicy: %v", err)
	}
	if got := rec[constants.ColSysConnectedApp_SessionIssuerClass]; got != "synthetic_session" {
		t.Fatalf("SessionIssuerClass = %#v, want synthetic_session", got)
	}
	if got := rec[constants.ColSysConnectedApp_ConsumerSecret]; got != "" {
		t.Fatalf("ConsumerSecret = %#v, want cleared", got)
	}
	if hash, _ := rec[constants.ColSysConnectedApp_ConsumerSecretHash].(string); hash == "" {
		t.Fatal("ConsumerSecretHash is empty")
	}
}

func TestNormalizeConnectedAppCreateStillRejectsProductSessionIssuerClass(t *testing.T) {
	trustedCtx := constants.WithSecurityContext(context.Background(), constants.SystemContext, "connected app bootstrap test")
	rec := sourceBackedConnectedAppRecord()
	rec[constants.ColSysConnectedApp_SessionIssuerClass] = "synthetic_session"

	if err := normalizeConnectedAppCreateWithContext(trustedCtx, rec, nil); err == nil {
		t.Fatal("normalizeConnectedAppCreateWithContext accepted product SessionIssuerClass")
	}
}

func TestNormalizeConnectedAppSessionIssuerClassClassifiesGenericWrites(t *testing.T) {
	for _, tc := range []struct {
		name             string
		record           models.SObject
		effectiveUpdates models.SObject
	}{
		{name: "create", record: models.SObject{constants.ColSysConnectedApp_SessionIssuerClass: "synthetic_session"}},
		{name: "update", record: models.SObject{}, effectiveUpdates: models.SObject{constants.ColSysConnectedApp_SessionIssuerClass: ""}},
	} {
		t.Run(tc.name, func(t *testing.T) {
			err := normalizeConnectedAppSessionIssuerClass(tc.record, tc.effectiveUpdates, false)
			fields, _, ok := sharedErrors.InvalidFieldForWriteDetails(err)
			if !ok || len(fields) != 1 || fields[0] != constants.ColSysConnectedApp_SessionIssuerClass {
				t.Fatalf("SessionIssuerClass classification = (%v, %t), want invalid-field-for-write", fields, ok)
			}
		})
	}
}

func TestNormalizeSourceBackedConnectedAppPolicyRejectsDeferredRuntimePaths(t *testing.T) {
	cases := []struct {
		name string
		edit func(models.SObject)
	}{
		{
			name: "client_credentials",
			edit: func(rec models.SObject) {
				rec[constants.ColSysConnectedApp_AllowedGrantTypes] = "authorization_code client_credentials"
			},
		},
		{
			name: "run_as_user_id",
			edit: func(rec models.SObject) {
				rec[constants.ColSysConnectedApp_RunAsUserID] = "005-system-user"
			},
		},
		{
			name: "admin_approved",
			edit: func(rec models.SObject) {
				rec[constants.ColSysConnectedApp_PermittedUsers] = "AdminApproved"
			},
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			rec := sourceBackedConnectedAppRecord()
			tc.edit(rec)
			if err := NormalizeSourceBackedConnectedAppPolicy(rec, SourceBackedConnectedAppPolicyOptions{PackName: "oauth-client-pack"}); err == nil {
				t.Fatalf("NormalizeSourceBackedConnectedAppPolicy accepted deferred path %s", tc.name)
			}
		})
	}
}

func TestNormalizeSourceBackedConnectedAppPolicyAllowsRefreshTokensWithGrant(t *testing.T) {
	// Refresh tokens are no longer deferred: rotation + family replay detection
	// exist. A source-backed app may opt in as long as it declares the grant.
	rec := sourceBackedConnectedAppRecord()
	rec[constants.ColSysConnectedApp_AllowedGrantTypes] = "authorization_code refresh_token"
	rec[constants.ColSysConnectedApp_IssueRefreshTokens] = true

	if err := NormalizeSourceBackedConnectedAppPolicy(rec, SourceBackedConnectedAppPolicyOptions{PackName: "oauth-client-pack"}); err != nil {
		t.Fatalf("expected refresh_token opt-in to be accepted, got: %v", err)
	}
}

func TestNormalizeSourceBackedConnectedAppPolicyRejectsRefreshTokensWithoutGrant(t *testing.T) {
	// Opting into issuing refresh tokens without declaring the refresh_token
	// grant is a misconfiguration and must be rejected.
	rec := sourceBackedConnectedAppRecord()
	rec[constants.ColSysConnectedApp_AllowedGrantTypes] = "authorization_code"
	rec[constants.ColSysConnectedApp_IssueRefreshTokens] = true

	if err := NormalizeSourceBackedConnectedAppPolicy(rec, SourceBackedConnectedAppPolicyOptions{PackName: "oauth-client-pack"}); err == nil {
		t.Fatal("expected rejection when IssueRefreshTokens is set without the refresh_token grant")
	}
}

func TestNormalizeConnectedAppCreatePublicDefaultsAndValidation(t *testing.T) {
	rec := models.SObject{
		constants.ColSysConnectedApp_ClientType:   "public",
		constants.ColSysConnectedApp_RedirectURIs: "http://127.0.0.1:8080/callback",
		constants.ColSysConnectedApp_Scopes:       testPublicConnectedAppScope,
	}

	if err := normalizeConnectedAppCreate(rec, allowTestPublicConnectedAppScope); err != nil {
		t.Fatalf("normalizeConnectedAppCreate: %v", err)
	}
	if rec[constants.ColSysConnectedApp_RequirePkce] != true {
		t.Fatalf("RequirePkce = %#v, want true", rec[constants.ColSysConnectedApp_RequirePkce])
	}
	if rec[constants.ColSysConnectedApp_TokenEndpointAuthMethod] != connectedAppTokenAuthNone {
		t.Fatalf("TokenEndpointAuthMethod = %#v", rec[constants.ColSysConnectedApp_TokenEndpointAuthMethod])
	}
	if rec[constants.ColSysConnectedApp_AllowedGrantTypes] != connectedAppGrantAuthorizationCode {
		t.Fatalf("AllowedGrantTypes = %#v", rec[constants.ColSysConnectedApp_AllowedGrantTypes])
	}
	if rec[constants.ColSysConnectedApp_ConsentPolicy] != connectedAppConsentTrusted {
		t.Fatalf("ConsentPolicy = %#v, want trusted P1 default", rec[constants.ColSysConnectedApp_ConsentPolicy])
	}
}

func TestNormalizeConnectedAppCreatePublicAcceptsJSONRedirectURIs(t *testing.T) {
	rec := models.SObject{
		constants.ColSysConnectedApp_ClientType:   "public",
		constants.ColSysConnectedApp_RedirectURIs: `["https://client.example/callback","http://127.0.0.1:8080/callback"]`,
		constants.ColSysConnectedApp_Scopes:       testPublicConnectedAppScope,
	}

	if err := normalizeConnectedAppCreate(rec, allowTestPublicConnectedAppScope); err != nil {
		t.Fatalf("normalizeConnectedAppCreate: %v", err)
	}
}

func TestNormalizeConnectedAppCreateCanonicalizesPublicScopeAndGrantTokens(t *testing.T) {
	rec := models.SObject{
		constants.ColSysConnectedApp_ClientType:        "public",
		constants.ColSysConnectedApp_RedirectURIs:      "https://client.example/callback",
		constants.ColSysConnectedApp_Scopes:            "External.Records.Read",
		constants.ColSysConnectedApp_AllowedGrantTypes: "Authorization_Code",
	}

	if err := normalizeConnectedAppCreate(rec, allowTestPublicConnectedAppScope); err != nil {
		t.Fatalf("normalizeConnectedAppCreate: %v", err)
	}
	if rec[constants.ColSysConnectedApp_Scopes] != testPublicConnectedAppScope {
		t.Fatalf("Scopes = %#v, want canonical lower-case scope", rec[constants.ColSysConnectedApp_Scopes])
	}
	if rec[constants.ColSysConnectedApp_AllowedGrantTypes] != connectedAppGrantAuthorizationCode {
		t.Fatalf("AllowedGrantTypes = %#v, want canonical authorization_code", rec[constants.ColSysConnectedApp_AllowedGrantTypes])
	}
}

func TestNormalizeConnectedAppCreateCanonicalizesConfidentialSessionIssuerClass(t *testing.T) {
	trustedCtx := constants.WithSecurityContext(context.Background(), constants.SystemContext, "connected app bootstrap test")
	rec := models.SObject{
		constants.ColSysConnectedApp_ClientType:         "confidential",
		constants.ColSysConnectedApp_ConsumerSecret:     "secret",
		constants.ColSysConnectedApp_SessionIssuerClass: "Platform_Browser",
	}

	if err := normalizeConnectedAppCreateWithContext(trustedCtx, rec, allowTestPublicConnectedAppScope); err != nil {
		t.Fatalf("normalizeConnectedAppCreate: %v", err)
	}
	if rec[constants.ColSysConnectedApp_SessionIssuerClass] != connectedAppSessionIssuerPlatformBrowser {
		t.Fatalf("SessionIssuerClass = %#v, want platform_browser", rec[constants.ColSysConnectedApp_SessionIssuerClass])
	}
}

func TestNormalizeConnectedAppCreateRejectsUntrustedSessionIssuerClass(t *testing.T) {
	rec := models.SObject{
		constants.ColSysConnectedApp_ClientType:         "confidential",
		constants.ColSysConnectedApp_ConsumerSecret:     "secret",
		constants.ColSysConnectedApp_SessionIssuerClass: "platform_browser",
	}

	if err := normalizeConnectedAppCreate(rec, allowTestPublicConnectedAppScope); err == nil {
		t.Fatal("normalizeConnectedAppCreate succeeded with untrusted SessionIssuerClass, want error")
	}
}

func TestNormalizeConnectedAppCreatePublicRejectsSecretAndBroadScope(t *testing.T) {
	tests := []struct {
		name string
		rec  models.SObject
	}{
		{
			name: "secret",
			rec: models.SObject{
				constants.ColSysConnectedApp_ClientType:     "public",
				constants.ColSysConnectedApp_RedirectURIs:   "https://client.example/callback",
				constants.ColSysConnectedApp_Scopes:         testPublicConnectedAppScope,
				constants.ColSysConnectedApp_ConsumerSecret: "secret",
			},
		},
		{
			name: "broad scope",
			rec: models.SObject{
				constants.ColSysConnectedApp_ClientType:   "public",
				constants.ColSysConnectedApp_RedirectURIs: "https://client.example/callback",
				constants.ColSysConnectedApp_Scopes:       "api",
			},
		},
		{
			name: "password grant",
			rec: models.SObject{
				constants.ColSysConnectedApp_ClientType:        "public",
				constants.ColSysConnectedApp_RedirectURIs:      "https://client.example/callback",
				constants.ColSysConnectedApp_Scopes:            testPublicConnectedAppScope,
				constants.ColSysConnectedApp_AllowedGrantTypes: "authorization_code password",
			},
		},
		{
			name: "prompt consent",
			rec: models.SObject{
				constants.ColSysConnectedApp_ClientType:    "public",
				constants.ColSysConnectedApp_RedirectURIs:  "https://client.example/callback",
				constants.ColSysConnectedApp_Scopes:        testPublicConnectedAppScope,
				constants.ColSysConnectedApp_ConsentPolicy: "prompt",
			},
		},
		{
			name: "missing redirect",
			rec: models.SObject{
				constants.ColSysConnectedApp_ClientType: "public",
				constants.ColSysConnectedApp_Scopes:     testPublicConnectedAppScope,
			},
		},
		{
			name: "session issuer class",
			rec: models.SObject{
				constants.ColSysConnectedApp_ClientType:         "public",
				constants.ColSysConnectedApp_RedirectURIs:       "https://client.example/callback",
				constants.ColSysConnectedApp_Scopes:             testPublicConnectedAppScope,
				constants.ColSysConnectedApp_SessionIssuerClass: "platform_browser",
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if err := normalizeConnectedAppCreate(tt.rec, allowTestPublicConnectedAppScope); err == nil {
				t.Fatal("normalizeConnectedAppCreate succeeded, want error")
			}
		})
	}
}

func TestNormalizeConnectedAppCreatePublicRejectsProductOAuthRouteScope(t *testing.T) {
	reset := featureauth.SetProductOAuthRouteScopeOwners([]featureauth.ProductOAuthRouteScopeOwner{{Scope: "vendor.builtin", OwningAppPacks: []string{"crm-pack"}}})
	t.Cleanup(reset)

	rec := models.SObject{
		constants.ColSysConnectedApp_ClientType:   "public",
		constants.ColSysConnectedApp_RedirectURIs: "https://client.example/callback",
		constants.ColSysConnectedApp_Scopes:       "vendor.builtin",
	}
	if err := normalizeConnectedAppCreate(rec, connectedAppScopePolicyStub{"vendor.builtin": true}); err == nil {
		t.Fatal("normalizeConnectedAppCreate accepted product-owned OAuth route scope as public custom scope")
	} else if !strings.Contains(err.Error(), "Public Connected Apps cannot declare reserved OAuth scopes") {
		t.Fatalf("error = %v, want reserved OAuth scope rejection", err)
	}
}

func TestNormalizeConnectedAppCreatePublicRejectsScopeWithoutActivePolicy(t *testing.T) {
	rec := models.SObject{
		constants.ColSysConnectedApp_ClientType:   "public",
		constants.ColSysConnectedApp_RedirectURIs: "https://client.example/callback",
		constants.ColSysConnectedApp_Scopes:       testPublicConnectedAppScope,
	}

	if err := normalizeConnectedAppCreate(rec, nil); err == nil {
		t.Fatal("normalizeConnectedAppCreate succeeded without scope provider, want error")
	}

	if err := normalizeConnectedAppCreate(rec, connectedAppScopePolicyStub{}); err == nil {
		t.Fatal("normalizeConnectedAppCreate succeeded without active scope policy, want error")
	}
}

func TestNormalizeConnectedAppCreatePublicRejectsAnyScopeWithoutActivePolicy(t *testing.T) {
	rec := models.SObject{
		constants.ColSysConnectedApp_ClientType:   "public",
		constants.ColSysConnectedApp_RedirectURIs: "https://client.example/callback",
		constants.ColSysConnectedApp_Scopes:       testPublicConnectedAppScope + " external.records.write",
	}

	if err := normalizeConnectedAppCreate(rec, allowTestPublicConnectedAppScope); err == nil {
		t.Fatal("normalizeConnectedAppCreate succeeded with one unbacked scope, want error")
	}
}

func TestNormalizeConnectedAppUpdateRejectsPublicWithoutRedirect(t *testing.T) {
	final := models.SObject{
		constants.ColSysConnectedApp_ClientType:              "public",
		constants.ColSysConnectedApp_Scopes:                  testPublicConnectedAppScope,
		constants.ColSysConnectedApp_RequirePkce:             true,
		constants.ColSysConnectedApp_TokenEndpointAuthMethod: "none",
		constants.ColSysConnectedApp_AllowedGrantTypes:       "authorization_code",
		constants.ColSysConnectedApp_ConsentPolicy:           "trusted",
	}
	updates := models.SObject{
		constants.ColSysConnectedApp_ClientType: "public",
	}

	if err := normalizeConnectedAppUpdate(final, updates, allowTestPublicConnectedAppScope); err == nil {
		t.Fatal("normalizeConnectedAppUpdate succeeded, want missing redirect error")
	}
}

func TestNormalizeConnectedAppUpdatePublicAcceptsExistingJSONRedirectURIs(t *testing.T) {
	final := models.SObject{
		constants.ColSysConnectedApp_ClientType:              "public",
		constants.ColSysConnectedApp_RedirectURIs:            `["https://client.example/callback","http://127.0.0.1:8080/callback"]`,
		constants.ColSysConnectedApp_Scopes:                  testPublicConnectedAppScope,
		constants.ColSysConnectedApp_RequirePkce:             true,
		constants.ColSysConnectedApp_TokenEndpointAuthMethod: "none",
		constants.ColSysConnectedApp_AllowedGrantTypes:       "authorization_code",
		constants.ColSysConnectedApp_ConsentPolicy:           "trusted",
	}
	updates := models.SObject{
		constants.FieldName: "External Client",
	}

	if err := normalizeConnectedAppUpdate(final, updates, allowTestPublicConnectedAppScope); err != nil {
		t.Fatalf("normalizeConnectedAppUpdate: %v", err)
	}
}

func TestNormalizeConnectedAppUpdateCanonicalizesPublicScopeAndGrantTokens(t *testing.T) {
	final := models.SObject{
		constants.ColSysConnectedApp_ClientType:              "public",
		constants.ColSysConnectedApp_RedirectURIs:            "https://client.example/callback",
		constants.ColSysConnectedApp_Scopes:                  "External.Records.Read",
		constants.ColSysConnectedApp_RequirePkce:             true,
		constants.ColSysConnectedApp_TokenEndpointAuthMethod: "none",
		constants.ColSysConnectedApp_AllowedGrantTypes:       "Authorization_Code",
		constants.ColSysConnectedApp_ConsentPolicy:           "trusted",
	}
	updates := models.SObject{
		constants.FieldName: "External Client",
	}

	if err := normalizeConnectedAppUpdate(final, updates, allowTestPublicConnectedAppScope); err != nil {
		t.Fatalf("normalizeConnectedAppUpdate: %v", err)
	}
	if final[constants.ColSysConnectedApp_Scopes] != testPublicConnectedAppScope {
		t.Fatalf("final Scopes = %#v, want canonical lower-case scope", final[constants.ColSysConnectedApp_Scopes])
	}
	if updates[constants.ColSysConnectedApp_Scopes] != testPublicConnectedAppScope {
		t.Fatalf("updated Scopes = %#v, want canonical lower-case scope", updates[constants.ColSysConnectedApp_Scopes])
	}
	if final[constants.ColSysConnectedApp_AllowedGrantTypes] != connectedAppGrantAuthorizationCode {
		t.Fatalf("final AllowedGrantTypes = %#v, want canonical authorization_code", final[constants.ColSysConnectedApp_AllowedGrantTypes])
	}
	if updates[constants.ColSysConnectedApp_AllowedGrantTypes] != connectedAppGrantAuthorizationCode {
		t.Fatalf("updated AllowedGrantTypes = %#v, want canonical authorization_code", updates[constants.ColSysConnectedApp_AllowedGrantTypes])
	}
}

func TestNormalizeConnectedAppUpdateRejectsUntrustedSessionIssuerClass(t *testing.T) {
	final := models.SObject{
		constants.ColSysConnectedApp_ClientType:              connectedAppClientTypeConfidential,
		constants.ColSysConnectedApp_TokenEndpointAuthMethod: connectedAppTokenAuthClientSecretPost,
		constants.ColSysConnectedApp_ConsumerSecretHash:      "$2a$10$existinghash",
		constants.ColSysConnectedApp_SessionIssuerClass:      connectedAppSessionIssuerPlatformBrowser,
	}
	updates := models.SObject{
		constants.ColSysConnectedApp_SessionIssuerClass: "",
	}

	err := normalizeConnectedAppUpdate(final, updates, allowTestPublicConnectedAppScope)
	if err == nil {
		t.Fatal("normalizeConnectedAppUpdate succeeded with untrusted SessionIssuerClass update, want error")
	}
	fields, _, ok := sharedErrors.InvalidFieldForWriteDetails(err)
	if !ok || len(fields) != 1 || fields[0] != constants.ColSysConnectedApp_SessionIssuerClass {
		t.Fatalf("SessionIssuerClass classification = (%v, %t), want invalid-field-for-write", fields, ok)
	}
}

func TestNormalizeConnectedAppUpdatePreservesExistingTrustedSessionIssuerClass(t *testing.T) {
	final := models.SObject{
		constants.ColSysConnectedApp_ClientType:              connectedAppClientTypeConfidential,
		constants.ColSysConnectedApp_TokenEndpointAuthMethod: connectedAppTokenAuthClientSecretPost,
		constants.ColSysConnectedApp_AllowedGrantTypes:       "authorization_code password client_credentials",
		constants.ColSysConnectedApp_ConsumerSecretHash:      "$2a$10$existinghash",
		constants.ColSysConnectedApp_SessionIssuerClass:      connectedAppSessionIssuerPlatformBrowser,
	}
	updates := models.SObject{
		constants.FieldName: "Bootstrap UI",
	}

	if err := normalizeConnectedAppUpdate(final, updates, allowTestPublicConnectedAppScope); err != nil {
		t.Fatalf("normalizeConnectedAppUpdate: %v", err)
	}
	if _, ok := updates[constants.ColSysConnectedApp_SessionIssuerClass]; ok {
		t.Fatal("SessionIssuerClass was added to unrelated effective updates")
	}
	if final[constants.ColSysConnectedApp_SessionIssuerClass] != connectedAppSessionIssuerPlatformBrowser {
		t.Fatalf("SessionIssuerClass = %#v, want existing trusted value preserved", final[constants.ColSysConnectedApp_SessionIssuerClass])
	}
}

func TestNormalizeConnectedAppCreateHashesConfidentialSecret(t *testing.T) {
	rec := models.SObject{
		constants.ColSysConnectedApp_ClientType:     "confidential",
		constants.ColSysConnectedApp_RedirectURIs:   "https://client.example/callback",
		constants.ColSysConnectedApp_ConsumerSecret: "super-secret",
	}

	if err := normalizeConnectedAppCreate(rec, nil); err != nil {
		t.Fatalf("normalizeConnectedAppCreate: %v", err)
	}
	hash, _ := rec[constants.ColSysConnectedApp_ConsumerSecretHash].(string)
	if hash == "" {
		t.Fatal("ConsumerSecretHash was not set")
	}
	if hash == "super-secret" {
		t.Fatal("ConsumerSecretHash stored plaintext")
	}
	if got := rec[constants.ColSysConnectedApp_ConsumerSecret]; got != "" {
		t.Fatalf("ConsumerSecret = %#v, want cleared after hashing", got)
	}
	if !pkgauth.VerifyPassword("super-secret", hash) {
		t.Fatal("ConsumerSecretHash does not verify original secret")
	}
}

func TestNormalizeConnectedAppCreateRejectsConfidentialWithoutSecret(t *testing.T) {
	rec := models.SObject{
		constants.ColSysConnectedApp_ClientType:              "confidential",
		constants.ColSysConnectedApp_RedirectURIs:            "https://client.example/callback",
		constants.ColSysConnectedApp_TokenEndpointAuthMethod: connectedAppTokenAuthClientSecretPost,
	}

	if err := normalizeConnectedAppCreate(rec, nil); err == nil {
		t.Fatal("normalizeConnectedAppCreate succeeded without confidential secret, want error")
	}
}

func TestNormalizeConnectedAppUpdateRejectsConfidentialSecretBlanking(t *testing.T) {
	final := models.SObject{
		constants.ColSysConnectedApp_ClientType:              "confidential",
		constants.ColSysConnectedApp_RedirectURIs:            "https://client.example/callback",
		constants.ColSysConnectedApp_TokenEndpointAuthMethod: connectedAppTokenAuthClientSecretPost,
		constants.ColSysConnectedApp_ConsumerSecret:          "",
		constants.ColSysConnectedApp_ConsumerSecretHash:      "",
	}
	updates := models.SObject{
		constants.ColSysConnectedApp_ConsumerSecret:     "",
		constants.ColSysConnectedApp_ConsumerSecretHash: "",
	}

	if err := normalizeConnectedAppUpdate(final, updates, nil); err == nil {
		t.Fatal("normalizeConnectedAppUpdate succeeded after blanking confidential secret, want error")
	}
}

func TestNormalizeConnectedAppUpdateHashesSecretAndSanitizesFinalRecord(t *testing.T) {
	final := models.SObject{
		constants.ColSysConnectedApp_ClientType:              "confidential",
		constants.ColSysConnectedApp_RedirectURIs:            "https://client.example/callback",
		constants.ColSysConnectedApp_TokenEndpointAuthMethod: connectedAppTokenAuthClientSecretPost,
		constants.ColSysConnectedApp_ConsumerSecret:          "rotated-secret",
		constants.ColSysConnectedApp_ConsumerSecretHash:      "$2a$10$oldhash",
	}
	updates := models.SObject{
		constants.ColSysConnectedApp_ConsumerSecret: "rotated-secret",
	}

	if err := normalizeConnectedAppUpdate(final, updates, nil); err != nil {
		t.Fatalf("normalizeConnectedAppUpdate: %v", err)
	}
	hash, _ := updates[constants.ColSysConnectedApp_ConsumerSecretHash].(string)
	if hash == "" {
		t.Fatal("ConsumerSecretHash was not set in updates")
	}
	if !pkgauth.VerifyPassword("rotated-secret", hash) {
		t.Fatal("ConsumerSecretHash does not verify rotated secret")
	}
	if updates[constants.ColSysConnectedApp_ConsumerSecret] != "" {
		t.Fatalf("updates ConsumerSecret = %#v, want cleared", updates[constants.ColSysConnectedApp_ConsumerSecret])
	}
	if final[constants.ColSysConnectedApp_ConsumerSecret] != "" {
		t.Fatalf("final ConsumerSecret = %#v, want cleared before recalculateEffectiveUpdates", final[constants.ColSysConnectedApp_ConsumerSecret])
	}
	if final[constants.ColSysConnectedApp_ConsumerSecretHash] != hash {
		t.Fatal("final ConsumerSecretHash does not match update hash")
	}
}

func TestNormalizeConnectedAppUpdateKeepsUnchangedSecretHashIdempotent(t *testing.T) {
	existingHash, err := pkgauth.HashPassword("bootstrap-secret")
	if err != nil {
		t.Fatalf("HashPassword: %v", err)
	}
	final := models.SObject{
		constants.ColSysConnectedApp_ClientType:              connectedAppClientTypeConfidential,
		constants.ColSysConnectedApp_RedirectURIs:            "https://client.example/callback",
		constants.ColSysConnectedApp_TokenEndpointAuthMethod: connectedAppTokenAuthClientSecretPost,
		constants.ColSysConnectedApp_AllowedGrantTypes:       "authorization_code password client_credentials",
		constants.ColSysConnectedApp_ConsentPolicy:           connectedAppConsentPrompt,
		constants.ColSysConnectedApp_ConsumerSecret:          "bootstrap-secret",
		constants.ColSysConnectedApp_ConsumerSecretHash:      existingHash,
	}
	updates := models.SObject{
		constants.FieldName:                         "NexusCRM UI",
		constants.ColSysConnectedApp_ConsumerSecret: "bootstrap-secret",
	}

	if err := normalizeConnectedAppUpdate(final, updates, nil); err != nil {
		t.Fatalf("normalizeConnectedAppUpdate: %v", err)
	}
	if _, ok := updates[constants.ColSysConnectedApp_ConsumerSecret]; ok {
		t.Fatal("ConsumerSecret remained in effective updates for unchanged secret")
	}
	if _, ok := updates[constants.ColSysConnectedApp_ConsumerSecretHash]; ok {
		t.Fatal("ConsumerSecretHash remained in effective updates for unchanged secret")
	}
	if updates[constants.FieldName] != "NexusCRM UI" {
		t.Fatalf("Name update = %#v, want unrelated update preserved", updates[constants.FieldName])
	}
	if _, ok := final[constants.ColSysConnectedApp_ConsumerSecret]; ok {
		t.Fatal("final ConsumerSecret remained for unchanged secret")
	}
	if final[constants.ColSysConnectedApp_ConsumerSecretHash] != existingHash {
		t.Fatal("final ConsumerSecretHash changed for unchanged secret")
	}
}

func TestNormalizeConnectedAppUpdateClearsSecretsWhenSwitchingToPublic(t *testing.T) {
	final := models.SObject{
		constants.ColSysConnectedApp_ClientType:              "public",
		constants.ColSysConnectedApp_RedirectURIs:            "https://client.example/callback",
		constants.ColSysConnectedApp_Scopes:                  testPublicConnectedAppScope,
		constants.ColSysConnectedApp_ConsumerSecret:          "legacy-secret",
		constants.ColSysConnectedApp_ConsumerSecretHash:      "$2a$10$legacyhash",
		constants.ColSysConnectedApp_RequirePkce:             true,
		constants.ColSysConnectedApp_TokenEndpointAuthMethod: "none",
		constants.ColSysConnectedApp_AllowedGrantTypes:       "authorization_code",
	}
	updates := models.SObject{
		constants.ColSysConnectedApp_ClientType: "public",
	}

	if err := normalizeConnectedAppUpdate(final, updates, allowTestPublicConnectedAppScope); err != nil {
		t.Fatalf("normalizeConnectedAppUpdate: %v", err)
	}
	if final[constants.ColSysConnectedApp_ConsumerSecret] != "" {
		t.Fatalf("ConsumerSecret = %#v, want cleared", final[constants.ColSysConnectedApp_ConsumerSecret])
	}
	if final[constants.ColSysConnectedApp_ConsumerSecretHash] != "" {
		t.Fatalf("ConsumerSecretHash = %#v, want cleared", final[constants.ColSysConnectedApp_ConsumerSecretHash])
	}
	if updates[constants.ColSysConnectedApp_ConsumerSecret] != "" {
		t.Fatalf("updated ConsumerSecret = %#v, want cleared", updates[constants.ColSysConnectedApp_ConsumerSecret])
	}
	if updates[constants.ColSysConnectedApp_ConsumerSecretHash] != "" {
		t.Fatalf("updated ConsumerSecretHash = %#v, want cleared", updates[constants.ColSysConnectedApp_ConsumerSecretHash])
	}
}

func sourceBackedConnectedAppRecord() models.SObject {
	return models.SObject{
		constants.ColSysConnectedApp_ID:                      "synthetic-client",
		constants.ColSysConnectedApp_Name:                    "Synthetic Client",
		constants.ColSysConnectedApp_ConsumerKey:             "synthetic-client-key",
		constants.ColSysConnectedApp_ConsumerSecret:          "synthetic-secret",
		constants.ColSysConnectedApp_CallbackURL:             "https://client.example/callback",
		constants.ColSysConnectedApp_RedirectURIs:            `["https://client.example/callback"]`,
		constants.ColSysConnectedApp_Scopes:                  "api",
		constants.ColSysConnectedApp_IsActive:                true,
		constants.ColSysConnectedApp_ClientType:              "confidential",
		constants.ColSysConnectedApp_RequirePkce:             false,
		constants.ColSysConnectedApp_TokenEndpointAuthMethod: "client_secret_post",
		constants.ColSysConnectedApp_AllowedGrantTypes:       "authorization_code password",
		constants.ColSysConnectedApp_ConsentPolicy:           "trusted",
		constants.ColSysConnectedApp_ConsentVersion:          1,
		constants.ColSysConnectedApp_IssueRefreshTokens:      false,
		constants.ColSysConnectedApp_PermittedUsers:          "AllUsers",
	}
}
