package auth

import (
	"context"
	"testing"
	"time"

	"github.com/nexuscrm/shared/pkg/models"
)

// refreshTokenTestApp is a public (PKCE, no-secret) Connected App that opts into
// refresh tokens — the shape the CLI/MCP connector uses. A real public app always
// declares an explicit allowed-scope set (an empty set can never issue a token),
// so the test app declares one too.
func refreshTokenTestApp() *models.SystemConnectedApp {
	publicType := OAuthClientTypePublic
	tokenAuth := OAuthTokenAuthNone
	grants := OAuthGrantRefreshToken
	scopes := "external.records.read"
	return &models.SystemConnectedApp{
		ID:                      "app-1",
		Name:                    "Connector",
		ConsumerKey:             "client-1",
		IsActive:                true,
		ClientType:              &publicType,
		TokenEndpointAuthMethod: &tokenAuth,
		AllowedGrantTypes:       &grants,
		Scopes:                  &scopes,
		IssueRefreshTokens:      true,
	}
}

// allowAllPublicScopePolicy permits any public OAuth scope; production wires a
// metadata-backed provider, but tests only need a permissive stub so the public
// scope re-validation on refresh does not fail closed for lack of a provider.
type allowAllPublicScopePolicy struct{}

func (allowAllPublicScopePolicy) AllowsPublicOAuthScope(string) bool { return true }

func newRefreshTokenTestService(app *models.SystemConnectedApp) (*OAuthService, *oauthRepoStub) {
	authSvc := NewAuthService(&userRepoStub{user: &models.User{ID: "user-1", IsActive: true}}, &sessionRepoStub{}, nil)
	oauthRepo := &oauthRepoStub{}
	svc := NewOAuthService(nil, &systemRepoStub{app: app, permitted: true}, &sessionRepoStub{}, authSvc, oauthRepo)
	svc.SetPublicScopePolicyProvider(allowAllPublicScopePolicy{})
	return svc, oauthRepo
}

func seedRefreshToken(repo *oauthRepoStub, plaintext, appID, scope string, expiresAt time.Time, revoked bool) {
	sc := scope
	id := appID
	family := "fam-1"
	repo.refreshByHash = map[string]*models.SystemOAuthRefreshToken{
		hashRefreshToken(plaintext): {
			ID:             "rt-1",
			TokenHash:      hashRefreshToken(plaintext),
			UserID:         "user-1",
			ConnectedAppID: &id,
			FamilyID:       &family,
			Scope:          &sc,
			ExpiresAt:      expiresAt,
			IsRevoked:      revoked,
		},
	}
}

func TestExchangeRefreshTokenRotatesAndIssuesSession(t *testing.T) {
	app := refreshTokenTestApp()
	svc, repo := newRefreshTokenTestService(app)
	t.Setenv(publicOAuthPKCEEnabledEnv, "true")
	t.Setenv(publicOAuthIssuerEnv, "https://connector.test")

	plaintext := OAuthRefreshTokenPrefix + "seed-refresh-token-value"
	seedRefreshToken(repo, plaintext, app.ID, "external.records.read", time.Now().Add(time.Hour), false)

	token, err := svc.ExchangeRefreshToken(context.Background(), RefreshTokenExchangeRequest{Issuer: "https://connector.test",
		ClientID:     "client-1",
		RefreshToken: plaintext,
	})
	if err != nil {
		t.Fatalf("ExchangeRefreshToken: %v", err)
	}
	if token.AccessToken == "" {
		t.Fatal("expected a new access token")
	}
	if token.Scope != "external.records.read" {
		t.Fatalf("scope = %q", token.Scope)
	}
	// The presented token is single-use: it must be revoked (rotated).
	if len(repo.refreshRevoked) != 1 || repo.refreshRevoked[0] != "rt-1" {
		t.Fatalf("expected rt-1 to be revoked, got %v", repo.refreshRevoked)
	}
	// A fresh, different refresh token must be issued.
	if token.RefreshToken == "" || token.RefreshToken == plaintext {
		t.Fatalf("expected a rotated refresh token, got %q", token.RefreshToken)
	}
	if len(repo.refreshStored) != 1 {
		t.Fatalf("expected one new refresh token stored, got %d", len(repo.refreshStored))
	}
	// The rotated token must stay in the original token family so a later replay
	// of any member can revoke the whole chain.
	if fam := repo.refreshStored[0].FamilyID; fam == nil || *fam != "fam-1" {
		t.Fatalf("rotated token left the family: got %v, want fam-1", fam)
	}
}

func TestExchangeRefreshTokenRechecksPermissionUnderCredentialIssuanceLock(t *testing.T) {
	app := refreshTokenTestApp()
	adminApproved := "AdminApproved"
	app.PermittedUsers = &adminApproved
	systemRepo := &systemRepoStub{app: app, permitted: true}
	sessionRepo := &sessionRepoStub{}
	oauthRepo := &oauthRepoStub{}
	authSvc := NewAuthService(&userRepoStub{user: &models.User{ID: "user-1", IsActive: true}}, sessionRepo, nil)
	svc := NewOAuthService(nil, systemRepo, sessionRepo, authSvc, oauthRepo)
	svc.SetPublicScopePolicyProvider(allowAllPublicScopePolicy{})
	t.Setenv(publicOAuthPKCEEnabledEnv, "true")
	t.Setenv(publicOAuthIssuerEnv, "https://connector.test")

	plaintext := OAuthRefreshTokenPrefix + "permission-revoked-at-lock"
	seedRefreshToken(oauthRepo, plaintext, app.ID, "external.records.read", time.Now().Add(time.Hour), false)
	// Simulate access being revoked after the fast pre-lock check but before the
	// callback executes under the Connected App issuance lock. The locked check
	// must reject before rotating the token or persisting an OAuth session.
	systemRepo.beforeCredentialIssuance = func() { systemRepo.permitted = false }

	_, err := svc.ExchangeRefreshToken(context.Background(), RefreshTokenExchangeRequest{
		Issuer:       "https://connector.test",
		ClientID:     "client-1",
		RefreshToken: plaintext,
	})
	if err == nil {
		t.Fatal("ExchangeRefreshToken succeeded after app access was revoked while waiting for issuance lock")
	}
	if len(oauthRepo.refreshRevoked) != 0 || len(oauthRepo.refreshStored) != 0 {
		t.Fatalf("access-revoked refresh rotated token: revoked=%v stored=%v", oauthRepo.refreshRevoked, oauthRepo.refreshStored)
	}
	if sessionRepo.inserted != nil {
		t.Fatal("access-revoked refresh persisted an OAuth session")
	}
}

func TestExchangeRefreshTokenRechecksRefreshEnabledUnderCredentialIssuanceLock(t *testing.T) {
	app := refreshTokenTestApp()
	systemRepo := &systemRepoStub{app: app, permitted: true}
	sessionRepo := &sessionRepoStub{}
	oauthRepo := &oauthRepoStub{}
	authSvc := NewAuthService(&userRepoStub{user: &models.User{ID: "user-1", IsActive: true}}, sessionRepo, nil)
	svc := NewOAuthService(nil, systemRepo, sessionRepo, authSvc, oauthRepo)
	svc.SetPublicScopePolicyProvider(allowAllPublicScopePolicy{})
	t.Setenv(publicOAuthPKCEEnabledEnv, "true")
	t.Setenv(publicOAuthIssuerEnv, "https://connector.test")

	plaintext := OAuthRefreshTokenPrefix + "refresh-disabled-at-lock"
	seedRefreshToken(oauthRepo, plaintext, app.ID, "external.records.read", time.Now().Add(time.Hour), false)
	// Disabling refresh issuance after the fast pre-lock check must still block
	// the callback that constructs and persists new credentials.
	systemRepo.beforeCredentialIssuance = func() { app.IssueRefreshTokens = false }

	_, err := svc.ExchangeRefreshToken(context.Background(), RefreshTokenExchangeRequest{
		Issuer:       "https://connector.test",
		ClientID:     "client-1",
		RefreshToken: plaintext,
	})
	if err == nil {
		t.Fatal("ExchangeRefreshToken succeeded after refresh issuance was disabled while waiting for issuance lock")
	}
	if len(oauthRepo.refreshRevoked) != 0 || len(oauthRepo.refreshStored) != 0 {
		t.Fatalf("refresh-disabled exchange rotated token: revoked=%v stored=%v", oauthRepo.refreshRevoked, oauthRepo.refreshStored)
	}
	if sessionRepo.inserted != nil {
		t.Fatal("refresh-disabled exchange persisted an OAuth session")
	}
	oauthErr, ok := OAuthProtocolErrorFrom(err)
	if !ok || oauthErr.Reason != "refresh_tokens_disabled" {
		t.Fatalf("error = %#v, want refresh_tokens_disabled", err)
	}
}

func TestExchangeRefreshTokenRejectsInactiveAuthorizationGrantBeforeRotation(t *testing.T) {
	app := refreshTokenTestApp()
	svc, repo := newRefreshTokenTestService(app)
	t.Setenv(publicOAuthPKCEEnabledEnv, "true")
	t.Setenv(publicOAuthIssuerEnv, "https://connector.test")
	plaintext := OAuthRefreshTokenPrefix + "grant-revoked-refresh-token"
	seedRefreshToken(repo, plaintext, app.ID, "external.records.read", time.Now().Add(time.Hour), false)
	grantID := "grant-1"
	repo.refreshByHash[hashRefreshToken(plaintext)].ConsentID = &grantID
	repo.grantInactive = true

	_, err := svc.ExchangeRefreshToken(context.Background(), RefreshTokenExchangeRequest{
		Issuer:       "https://connector.test",
		ClientID:     "client-1",
		RefreshToken: plaintext,
	})
	if err == nil {
		t.Fatal("ExchangeRefreshToken succeeded with a revoked authorization grant")
	}
	if len(repo.refreshRevoked) != 0 || len(repo.refreshStored) != 0 {
		t.Fatalf("revoked authorization grant rotated token: revoked=%v stored=%v", repo.refreshRevoked, repo.refreshStored)
	}
	oauthErr, ok := OAuthProtocolErrorFrom(err)
	if !ok || oauthErr.Code != "invalid_grant" {
		t.Fatalf("error = %#v, want invalid_grant", err)
	}
}

func TestRevokeRefreshTokenByValueRevokesFamily(t *testing.T) {
	app := refreshTokenTestApp()
	svc, repo := newRefreshTokenTestService(app)
	t.Setenv(publicOAuthPKCEEnabledEnv, "true")
	t.Setenv(publicOAuthIssuerEnv, "https://connector.test")
	plaintext := OAuthRefreshTokenPrefix + "client-initiated-revoke"
	seedRefreshToken(repo, plaintext, app.ID, "external.records.read", time.Now().Add(time.Hour), false)

	if !LooksLikeRefreshToken(plaintext) {
		t.Fatalf("LooksLikeRefreshToken false for %q", plaintext)
	}
	if err := svc.RevokeRefreshTokenByValue(context.Background(), plaintext); err != nil {
		t.Fatalf("RevokeRefreshTokenByValue: %v", err)
	}
	if len(repo.refreshFamilyRevoked) == 0 || repo.refreshFamilyRevoked[len(repo.refreshFamilyRevoked)-1] != "fam-1" {
		t.Fatalf("expected the family to be revoked, got %v", repo.refreshFamilyRevoked)
	}
	// A subsequent exchange of the revoked token must now fail.
	if _, err := svc.ExchangeRefreshToken(context.Background(), RefreshTokenExchangeRequest{Issuer: "https://connector.test",
		ClientID: "client-1", RefreshToken: plaintext,
	}); err == nil {
		t.Fatal("expected exchange of a revoked refresh token to fail")
	}
}

func TestRevokeRefreshTokensForUserRevokesAll(t *testing.T) {
	app := refreshTokenTestApp()
	svc, repo := newRefreshTokenTestService(app)
	t.Setenv(publicOAuthPKCEEnabledEnv, "true")
	t.Setenv(publicOAuthIssuerEnv, "https://connector.test")
	plaintext := OAuthRefreshTokenPrefix + "user-cred-revoke"
	seedRefreshToken(repo, plaintext, app.ID, "external.records.read", time.Now().Add(time.Hour), false)

	// Credential revocation (password change/reset) must kill the user's refresh
	// tokens so a stolen one cannot mint a new session afterward.
	if err := svc.RevokeRefreshTokensForUser(context.Background(), "user-1"); err != nil {
		t.Fatalf("RevokeRefreshTokensForUser: %v", err)
	}
	if _, err := svc.ExchangeRefreshToken(context.Background(), RefreshTokenExchangeRequest{Issuer: "https://connector.test",
		ClientID: "client-1", RefreshToken: plaintext,
	}); err == nil {
		t.Fatal("expected the user's refresh token to be dead after credential revocation")
	}
}

func TestRevokeRefreshTokenByValueUnknownIsNoOp(t *testing.T) {
	app := refreshTokenTestApp()
	svc, _ := newRefreshTokenTestService(app)
	t.Setenv(publicOAuthPKCEEnabledEnv, "true")
	t.Setenv(publicOAuthIssuerEnv, "https://connector.test")
	// RFC 7009: revoking an unknown token is a successful no-op, not an error.
	if err := svc.RevokeRefreshTokenByValue(context.Background(), OAuthRefreshTokenPrefix+"never-existed"); err != nil {
		t.Fatalf("expected no error for unknown token revoke, got %v", err)
	}
}

func TestExchangeRefreshTokenReplayRevokesFamily(t *testing.T) {
	app := refreshTokenTestApp()
	svc, repo := newRefreshTokenTestService(app)
	t.Setenv(publicOAuthPKCEEnabledEnv, "true")
	t.Setenv(publicOAuthIssuerEnv, "https://connector.test")
	plaintext := OAuthRefreshTokenPrefix + "family-replay-token"
	seedRefreshToken(repo, plaintext, app.ID, "external.records.read", time.Now().Add(time.Hour), false)

	// First use rotates successfully.
	if _, err := svc.ExchangeRefreshToken(context.Background(), RefreshTokenExchangeRequest{Issuer: "https://connector.test",
		ClientID: "client-1", RefreshToken: plaintext,
	}); err != nil {
		t.Fatalf("first exchange: %v", err)
	}

	// Replaying the now-rotated (revoked) token is a reuse signal: it must be
	// rejected AND revoke the entire token family.
	if _, err := svc.ExchangeRefreshToken(context.Background(), RefreshTokenExchangeRequest{Issuer: "https://connector.test",
		ClientID: "client-1", RefreshToken: plaintext,
	}); err == nil {
		t.Fatal("expected replay of a rotated refresh token to be rejected")
	}
	if len(repo.refreshFamilyRevoked) == 0 || repo.refreshFamilyRevoked[len(repo.refreshFamilyRevoked)-1] != "fam-1" {
		t.Fatalf("expected the token family to be revoked on replay, got %v", repo.refreshFamilyRevoked)
	}
	// The child token minted by the first (legitimate) rotation must now be dead.
	for _, stored := range repo.refreshStored {
		if !stored.IsRevoked {
			t.Fatalf("expected family member %q revoked after replay", stored.ID)
		}
	}
}

func TestExchangeRefreshTokenRejectsRevoked(t *testing.T) {
	app := refreshTokenTestApp()
	svc, repo := newRefreshTokenTestService(app)
	t.Setenv(publicOAuthPKCEEnabledEnv, "true")
	t.Setenv(publicOAuthIssuerEnv, "https://connector.test")
	plaintext := OAuthRefreshTokenPrefix + "revoked-token"
	seedRefreshToken(repo, plaintext, app.ID, "", time.Now().Add(time.Hour), true)

	if _, err := svc.ExchangeRefreshToken(context.Background(), RefreshTokenExchangeRequest{Issuer: "https://connector.test",
		ClientID: "client-1", RefreshToken: plaintext,
	}); err == nil {
		t.Fatal("expected error for a revoked refresh token")
	}
}

func TestExchangeRefreshTokenRejectsExpired(t *testing.T) {
	app := refreshTokenTestApp()
	svc, repo := newRefreshTokenTestService(app)
	t.Setenv(publicOAuthPKCEEnabledEnv, "true")
	t.Setenv(publicOAuthIssuerEnv, "https://connector.test")
	plaintext := OAuthRefreshTokenPrefix + "expired-token"
	seedRefreshToken(repo, plaintext, app.ID, "", time.Now().Add(-time.Hour), false)

	if _, err := svc.ExchangeRefreshToken(context.Background(), RefreshTokenExchangeRequest{Issuer: "https://connector.test",
		ClientID: "client-1", RefreshToken: plaintext,
	}); err == nil {
		t.Fatal("expected error for an expired refresh token")
	}
}

func TestExchangeRefreshTokenRejectsLostRotationRace(t *testing.T) {
	app := refreshTokenTestApp()
	svc, repo := newRefreshTokenTestService(app)
	t.Setenv(publicOAuthPKCEEnabledEnv, "true")
	t.Setenv(publicOAuthIssuerEnv, "https://connector.test")
	plaintext := OAuthRefreshTokenPrefix + "race-token"
	seedRefreshToken(repo, plaintext, app.ID, "external.records.read", time.Now().Add(time.Hour), false)
	// The read sees an active token, but the single-use revoke CAS returns false
	// (a concurrent caller already consumed it). The grant must be rejected and
	// must NOT issue a second session/refresh token.
	repo.refreshRevokeForceFalse = true

	if _, err := svc.ExchangeRefreshToken(context.Background(), RefreshTokenExchangeRequest{Issuer: "https://connector.test",
		ClientID: "client-1", RefreshToken: plaintext,
	}); err == nil {
		t.Fatal("expected rejection when the single-use rotation CAS is lost (concurrent reuse)")
	}
	if len(repo.refreshStored) != 0 {
		t.Fatalf("expected no new refresh token issued on lost race, got %d", len(repo.refreshStored))
	}
	// Losing the CAS is a reuse signal — the family must be revoked, not just this request.
	if len(repo.refreshFamilyRevoked) == 0 {
		t.Fatal("expected the token family to be revoked when the rotation CAS is lost")
	}
}

func TestExchangeRefreshTokenRejectedWhenPublicOAuthDisabled(t *testing.T) {
	// The public-OAuth PKCE kill switch must also stop existing public refresh
	// tokens from rotating, exactly as it fails closed on the authorization-code
	// path. Here the flag is explicitly off.
	t.Setenv(publicOAuthPKCEEnabledEnv, "false")
	t.Setenv(publicOAuthIssuerEnv, "https://connector.test")
	app := refreshTokenTestApp()
	svc, repo := newRefreshTokenTestService(app)
	plaintext := OAuthRefreshTokenPrefix + "kill-switch-token"
	seedRefreshToken(repo, plaintext, app.ID, "external.records.read", time.Now().Add(time.Hour), false)

	if _, err := svc.ExchangeRefreshToken(context.Background(), RefreshTokenExchangeRequest{Issuer: "https://connector.test",
		ClientID: "client-1", RefreshToken: plaintext,
	}); err == nil {
		t.Fatal("expected rejection when public OAuth PKCE is disabled")
	}
	if len(repo.refreshStored) != 0 {
		t.Fatalf("expected no rotation when public OAuth is disabled, got %d stored", len(repo.refreshStored))
	}
}

func TestExchangeRefreshTokenRejectedWhenIssueRefreshTokensDisabled(t *testing.T) {
	// Disabling IssueRefreshTokens must immediately stop existing tokens from
	// rotating, even though the refresh_token grant is still in the allowlist.
	app := refreshTokenTestApp()
	app.IssueRefreshTokens = false
	svc, repo := newRefreshTokenTestService(app)
	t.Setenv(publicOAuthPKCEEnabledEnv, "true")
	t.Setenv(publicOAuthIssuerEnv, "https://connector.test")
	plaintext := OAuthRefreshTokenPrefix + "disabled-issue-token"
	seedRefreshToken(repo, plaintext, app.ID, "external.records.read", time.Now().Add(time.Hour), false)

	if _, err := svc.ExchangeRefreshToken(context.Background(), RefreshTokenExchangeRequest{Issuer: "https://connector.test",
		ClientID: "client-1", RefreshToken: plaintext,
	}); err == nil {
		t.Fatal("expected rejection when IssueRefreshTokens is disabled")
	}
	if len(repo.refreshStored) != 0 {
		t.Fatalf("expected no rotation when IssueRefreshTokens is disabled, got %d stored", len(repo.refreshStored))
	}
}

func TestExchangeRefreshTokenRejectsUnknown(t *testing.T) {
	app := refreshTokenTestApp()
	svc, _ := newRefreshTokenTestService(app)
	t.Setenv(publicOAuthPKCEEnabledEnv, "true")
	t.Setenv(publicOAuthIssuerEnv, "https://connector.test")

	if _, err := svc.ExchangeRefreshToken(context.Background(), RefreshTokenExchangeRequest{Issuer: "https://connector.test",
		ClientID: "client-1", RefreshToken: OAuthRefreshTokenPrefix + "never-issued",
	}); err == nil {
		t.Fatal("expected error for an unknown refresh token")
	}
}

func TestExchangeRefreshTokenRejectsScopeWidening(t *testing.T) {
	app := refreshTokenTestApp()
	svc, repo := newRefreshTokenTestService(app)
	t.Setenv(publicOAuthPKCEEnabledEnv, "true")
	t.Setenv(publicOAuthIssuerEnv, "https://connector.test")
	plaintext := OAuthRefreshTokenPrefix + "narrow-scope-token"
	seedRefreshToken(repo, plaintext, app.ID, "external.records.read", time.Now().Add(time.Hour), false)

	// Requesting a scope beyond the original grant must be rejected.
	if _, err := svc.ExchangeRefreshToken(context.Background(), RefreshTokenExchangeRequest{Issuer: "https://connector.test",
		ClientID:       "client-1",
		RefreshToken:   plaintext,
		RequestedScope: "external.records.read external.records.write",
	}); err == nil {
		t.Fatal("expected error when requested scope exceeds the original grant")
	}
}

// confidentialRefreshTokenApp is a confidential (secret) app that opts into
// refresh tokens and declares a concrete allowed-scope set. Confidential apps
// exercise the app-scope ceiling without the public-policy re-validation.
func confidentialRefreshTokenApp(appScopes string) *models.SystemConnectedApp {
	confidential := OAuthClientTypeConfidential
	tokenAuth := OAuthTokenAuthClientSecretPost
	grants := OAuthGrantRefreshToken
	sc := appScopes
	return &models.SystemConnectedApp{
		ID:                      "app-1",
		Name:                    "Server Connector",
		ConsumerKey:             "client-1",
		ConsumerSecret:          "secret-1",
		IsActive:                true,
		ClientType:              &confidential,
		TokenEndpointAuthMethod: &tokenAuth,
		AllowedGrantTypes:       &grants,
		Scopes:                  &sc,
		IssueRefreshTokens:      true,
	}
}

func TestExchangeRefreshTokenCapsScopeToCurrentAppScopes(t *testing.T) {
	// The app was granted two scopes at issuance but has since been narrowed to
	// one. The refreshed session must not retain the scope the app no longer allows.
	app := confidentialRefreshTokenApp("external.records.read")
	svc, repo := newRefreshTokenTestService(app)
	t.Setenv(publicOAuthPKCEEnabledEnv, "true")
	t.Setenv(publicOAuthIssuerEnv, "https://connector.test")
	plaintext := OAuthRefreshTokenPrefix + "over-broad-stored-token"
	seedRefreshToken(repo, plaintext, app.ID, "external.records.read external.records.write", time.Now().Add(time.Hour), false)

	token, err := svc.ExchangeRefreshToken(context.Background(), RefreshTokenExchangeRequest{Issuer: "https://connector.test",
		ClientID:     "client-1",
		ClientSecret: "secret-1",
		RefreshToken: plaintext,
	})
	if err != nil {
		t.Fatalf("ExchangeRefreshToken: %v", err)
	}
	if token.Scope != "external.records.read" {
		t.Fatalf("scope not capped to current app scopes: got %q, want %q", token.Scope, "external.records.read")
	}
}

func TestExchangeRefreshTokenRejectsWhenAppScopesCleared(t *testing.T) {
	// The token was issued with a concrete scope, but the app's scopes have since
	// been cleared entirely. That can only mean the authorization was withdrawn
	// (a legacy full-access app would have issued an empty-scoped token), so the
	// refresh must fail closed rather than fall back to legacy full access.
	app := confidentialRefreshTokenApp("")
	svc, repo := newRefreshTokenTestService(app)
	t.Setenv(publicOAuthPKCEEnabledEnv, "true")
	t.Setenv(publicOAuthIssuerEnv, "https://connector.test")
	plaintext := OAuthRefreshTokenPrefix + "cleared-app-scope-token"
	seedRefreshToken(repo, plaintext, app.ID, "external.records.read", time.Now().Add(time.Hour), false)

	if _, err := svc.ExchangeRefreshToken(context.Background(), RefreshTokenExchangeRequest{Issuer: "https://connector.test",
		ClientID:     "client-1",
		ClientSecret: "secret-1",
		RefreshToken: plaintext,
	}); err == nil {
		t.Fatal("expected rejection when the app's scopes were cleared after issuance")
	}
}

func TestExchangeRefreshTokenRejectsWhenAppRevokedAllGrantedScopes(t *testing.T) {
	// The app's current scopes no longer intersect the original grant at all —
	// the grant now permits nothing, so refresh must be rejected rather than
	// silently upgraded to an unrestricted (full-access) session.
	app := confidentialRefreshTokenApp("external.reports.read")
	svc, repo := newRefreshTokenTestService(app)
	t.Setenv(publicOAuthPKCEEnabledEnv, "true")
	t.Setenv(publicOAuthIssuerEnv, "https://connector.test")
	plaintext := OAuthRefreshTokenPrefix + "fully-revoked-scope-token"
	seedRefreshToken(repo, plaintext, app.ID, "external.records.read", time.Now().Add(time.Hour), false)

	if _, err := svc.ExchangeRefreshToken(context.Background(), RefreshTokenExchangeRequest{Issuer: "https://connector.test",
		ClientID:     "client-1",
		ClientSecret: "secret-1",
		RefreshToken: plaintext,
	}); err == nil {
		t.Fatal("expected rejection when the app no longer allows any originally-granted scope")
	}
}
