Ctrl K
ComunidadGuías…

controller/security_enrollment_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package controller

import (
	"bytes"
	"context"
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"crypto/rsa"
	"crypto/sha256"
	"crypto/x509"
	"encoding/base64"
	"encoding/binary"
	"encoding/json"
	"encoding/pem"
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"os"
	"strconv"
	"strings"
	"testing"
	"time"

	"github.com/QuantumNous/new-api/common"
	"github.com/QuantumNous/new-api/i18n"
	"github.com/QuantumNous/new-api/middleware"
	"github.com/QuantumNous/new-api/model"
	"github.com/QuantumNous/new-api/oauth"
	"github.com/QuantumNous/new-api/service"
	"github.com/QuantumNous/new-api/setting/system_setting"
	"github.com/fxamacker/cbor/v2"
	"github.com/gin-gonic/gin"
	"github.com/glebarez/sqlite"
	"github.com/golang-jwt/jwt/v5"
	"github.com/pquerna/otp/totp"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"gorm.io/gorm"
	"gorm.io/gorm/logger"
)

func setupSecurityEnrollmentTest(t *testing.T) (*model.User, service.AuthIdentity) {
	t.Helper()
	require.NoError(t, i18n.Init())
	gin.SetMode(gin.TestMode)
	previousDB, previousLogDB := model.DB, model.LOG_DB
	previousMain, previousLog := common.MainDatabaseType(), common.LogDatabaseType()
	previousRedis, previousSecret := common.RedisEnabled, common.SessionSecret
	previousEncryption := common.PasswordLoginEncryptionEnabled
	previousSettings := *system_setting.GetPasskeySettings()
	dialect := os.Getenv("TEST_SECURITY_DIALECT")
	if dialect == "" {
		dialect = "sqlite"
	}
	dsn := os.Getenv("TEST_" + strings.ToUpper(dialect) + "_DSN")
	var db *gorm.DB
	if dialect == "sqlite" {
		// Match common.SQLitePath: take the writer lock before transaction reads.
		// Deferred transactions can fail to upgrade their read lock when concurrent
		// proof consumption overlaps account deletion or session revocation.
		var err error
		db, err = gorm.Open(sqlite.Open(t.TempDir()+"/security.db?_pragma=busy_timeout(30000)&_pragma=journal_mode(WAL)&_txlock=immediate"), &gorm.Config{})
		require.NoError(t, err)
	} else {
		db, _ = newAuditTestDatabase(t, dialect, dsn)
	}
	logDB, _ := newAuditTestDatabase(t, dialect, dsn)
	db.Logger = logger.Default.LogMode(logger.Silent)
	logDB.Logger = logger.Default.LogMode(logger.Silent)
	versionQuery := "SELECT VERSION()"
	if dialect == "sqlite" {
		versionQuery = "SELECT sqlite_version()"
	}
	var version string
	require.NoError(t, db.Raw(versionQuery).Scan(&version).Error)
	t.Logf("database: %s %s", dialect, version)
	require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}, &model.TwoFA{}, &model.TwoFABackupCode{}, &model.PasskeyCredential{}, &model.AuthFlow{}, &model.UserOAuthBinding{}, &model.Option{}, &model.UserAccessToken{}))
	require.NoError(t, logDB.AutoMigrate(&model.AuditLog{}))
	model.DB, model.LOG_DB = db, logDB
	require.NoError(t, model.EnsureLegacyAccessTokenRetireAt(time.Now().Unix()))
	dbType := common.DatabaseTypeSQLite
	if dialect == "mysql" {
		dbType = common.DatabaseTypeMySQL
	}
	if dialect == "postgres" {
		dbType = common.DatabaseTypePostgreSQL
	}
	common.SetDatabaseTypes(dbType, dbType)
	common.PasswordLoginEncryptionEnabled = false
	common.RedisEnabled = false
	common.SessionSecret = "security-enrollment-test-secret"
	*system_setting.GetPasskeySettings() = system_setting.PasskeySettings{Enabled: true, RPID: "example.com", Origins: "https://example.com", RPDisplayName: "new-api"}
	t.Cleanup(func() {
		model.DB, model.LOG_DB = previousDB, previousLogDB
		common.SetDatabaseTypes(previousMain, previousLog)
		common.RedisEnabled, common.SessionSecret = previousRedis, previousSecret
		common.PasswordLoginEncryptionEnabled = previousEncryption
		*system_setting.GetPasskeySettings() = previousSettings
		connection, err := db.DB()
		if err == nil {
			_ = connection.Close()
		}
	})
	password, err := common.Password2Hash("enrollment-password")
	require.NoError(t, err)
	user := &model.User{Username: "enrollment-user", Password: password, Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1}
	require.NoError(t, db.Create(user).Error)
	require.NoError(t, model.PublishUserAuthCache(user.Id))
	bundle, err := service.CreateLoginSession(user.Id, "password", "127.0.0.1", "enrollment-test")
	require.NoError(t, err)
	identity, err := service.ParseAccessToken(bundle.AccessToken)
	require.NoError(t, err)
	return user, identity
}

func securityEnrollmentRequest(method, path, body, proof string, identity service.AuthIdentity, handler gin.HandlerFunc) *httptest.ResponseRecorder {
	response := httptest.NewRecorder()
	c, _ := gin.CreateTestContext(response)
	c.Request = httptest.NewRequest(method, path, strings.NewReader(body))
	c.Request.Header.Set("Content-Type", "application/json")
	c.Request.Header.Set("X-Security-Proof", proof)
	c.Set("id", identity.UserID)
	c.Set("role", common.RoleCommonUser)
	c.Set("session_id", identity.SessionID)
	c.Set("auth_version", identity.UserAuthVersion)
	c.Set("session_version", identity.SessionVersion)
	handler(c)
	return response
}

func issueSecurityEnrollmentProof(t *testing.T, identity service.AuthIdentity, operation service.VerificationOperation, method string) string {
	t.Helper()
	binding, err := service.BindVerificationOperation(operation)
	require.NoError(t, err)
	proof, _, err := service.IssueSecurityProof(identity, method, binding)
	require.NoError(t, err)
	return proof
}

// withAccessTokenID sets the :id route parameter the way the router would.
func withAccessTokenID(handler gin.HandlerFunc, id int) gin.HandlerFunc {
	return func(c *gin.Context) {
		c.Params = gin.Params{{Key: "id", Value: strconv.Itoa(id)}}
		handler(c)
	}
}

type accessTokenEndpoint struct {
	name, method, path, body string
	scope                    string
	context                  []byte
	handler                  gin.HandlerFunc
}

// accessTokenMutationEndpoints lists every proof-guarded access token
// operation for one scoped token of the test user.
func accessTokenMutationEndpoints(tokenID int) []accessTokenEndpoint {
	return []accessTokenEndpoint{
		{"create", "POST", "/api/user/access_tokens", `{"name":"ci","scopes":["profile:read"],"expires_at":0}`, service.VerificationScopeAccessTokenGenerate, []byte(`{"scopes":["profile:read"],"expires_at":0}`), CreateAccessToken},
		{"update", "PATCH", fmt.Sprintf("/api/user/access_tokens/%d", tokenID), `{"name":"ci","scopes":["usage:read"]}`, service.VerificationScopeAccessTokenUpdate, []byte(fmt.Sprintf(`{"token_id":%d,"scopes":["usage:read"]}`, tokenID)), withAccessTokenID(UpdateAccessToken, tokenID)},
		{"delete", "DELETE", fmt.Sprintf("/api/user/access_tokens/%d", tokenID), "", service.VerificationScopeAccessTokenRevoke, []byte(fmt.Sprintf(`{"token_id":%d}`, tokenID)), withAccessTokenID(DeleteAccessToken, tokenID)},
		{"legacy", "DELETE", "/api/user/access_tokens/legacy", "", service.VerificationScopeAccessTokenRevoke, []byte(`{"legacy":true}`), RevokeLegacyAccessToken},
	}
}

// assertAccessTokensUnchanged checks that the seeded scoped token keeps its
// name and grant and that the legacy token still authenticates.
func assertAccessTokensUnchanged(t *testing.T, userID int, scopedID int, legacy string) {
	t.Helper()
	tokens, err := model.ListUserAccessTokens(userID)
	require.NoError(t, err)
	require.Len(t, tokens, 1)
	assert.Equal(t, scopedID, tokens[0].Id)
	assert.Equal(t, "test token", tokens[0].Name)
	assert.Equal(t, []string{"profile:read"}, tokens[0].GetScopes())
	stored, err := model.ValidateAccessToken(legacy)
	require.NoError(t, err)
	require.NotNil(t, stored)
	assert.Equal(t, userID, stored.Id)
}

func TestSecurityEnrollmentAccessTokenRequiresProofBeforeMutation(t *testing.T) {
	user, identity := setupSecurityEnrollmentTest(t)
	require.NoError(t, model.DB.Model(user).Update("access_token", "existing-system-token").Error)
	_, scoped := createScopedAccessToken(t, user.Id, 0, "profile:read")
	for _, endpoint := range accessTokenMutationEndpoints(scoped.Id) {
		t.Run(endpoint.name, func(t *testing.T) {
			response := securityEnrollmentRequest(endpoint.method, endpoint.path, endpoint.body, "", identity, endpoint.handler)
			var body securityEnrollmentResponse
			require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
			assert.Equal(t, http.StatusForbidden, response.Code)
			assert.False(t, body.Success)
			assert.Equal(t, "SECURITY_PROOF_REQUIRED", body.Code)
			assertAccessTokensUnchanged(t, user.Id, scoped.Id, "existing-system-token")
		})
	}
}

func TestSecurityEnrollmentAccessTokenMethodPolicy(t *testing.T) {
	for _, test := range []struct {
		name, method                     string
		password, passkey, twoFA, locked bool
		disabledPasskey, oauth, wechat   bool
		available                        bool
	}{
		{name: "password", method: "password", password: true, oauth: true, available: true},
		{name: "existing passkey", method: "passkey", password: true, passkey: true, available: true},
		{name: "existing twofa", method: "2fa", password: true, passkey: true, twoFA: true, available: true},
		{name: "locked twofa blocks fallback", method: "2fa", password: true, twoFA: true, locked: true},
		{name: "disabled passkey blocks fallback", method: "passkey", password: true, passkey: true, disabledPasskey: true},
		{name: "disabled passkey does not block password", method: "password", password: true, disabledPasskey: true, available: true},
		{name: "linked oauth", method: "oauth", oauth: true, available: true},
		{name: "wechat session cannot manage tokens", method: "oauth", wechat: true},
	} {
		t.Run(test.name, func(t *testing.T) {
			user, identity := setupSecurityEnrollmentTest(t)
			if !test.password {
				require.NoError(t, model.DB.Model(user).Update("password", "").Error)
			}
			if test.passkey {
				require.NoError(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id, CredentialID: "existing-key", PublicKey: "public-key"}).Error)
			}
			if test.twoFA {
				twoFA := &model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}
				if test.locked {
					until := time.Now().Add(time.Minute)
					twoFA.LockedUntil = &until
				}
				require.NoError(t, model.DB.Create(twoFA).Error)
			}
			if test.oauth {
				require.NoError(t, model.DB.Model(user).Update("github_id", "linked-user").Error)
				oauth.Register("access-token-oauth", &enrollmentOAuthProvider{externalID: "linked-user"})
				t.Cleanup(func() { oauth.Unregister("access-token-oauth") })
			}
			if test.wechat {
				require.NoError(t, model.DB.Model(user).Update("wechat_id", "wechat-user").Error)
			}
			system_setting.GetPasskeySettings().Enabled = !test.disabledPasskey
			passwordScope := service.VerificationScopePasswordChange
			if !test.password {
				passwordScope = service.VerificationScopePasswordSet
			}
			for _, scope := range []string{service.VerificationScopeAccessTokenGenerate, service.VerificationScopeAccessTokenUpdate, service.VerificationScopeAccessTokenRevoke,
				service.VerificationScopeAccountBind, service.VerificationScopeAccountUnbind, passwordScope} {
				requirements, err := service.GetVerificationRequirements(identity, scope)
				require.NoError(t, err)
				count := 1
				if test.twoFA && test.passkey {
					count = 2
				}
				require.Len(t, requirements.Methods, count)
				assert.Equal(t, test.method, requirements.Methods[0].Method)
				assert.Equal(t, test.available, requirements.Methods[0].Available)
				if count == 2 {
					assert.Equal(t, service.VerificationMethodOption{Method: "passkey", Available: true}, requirements.Methods[1])
				}
				if test.wechat {
					input := service.VerificationInput{Scope: scope, Method: "session"}
					switch scope {
					case service.VerificationScopeAccountBind:
						input.Context = []byte(`{"provider":"email","email":"new@example.com"}`)
					case service.VerificationScopeAccountUnbind:
						input.Context = []byte(`{"provider_id":1}`)
					case service.VerificationScopeAccessTokenGenerate:
						input.Context = []byte(`{"scopes":["profile:read"],"expires_at":0}`)
					case service.VerificationScopeAccessTokenUpdate:
						input.Context = []byte(`{"token_id":1,"scopes":["profile:read"]}`)
					case service.VerificationScopeAccessTokenRevoke:
						input.Context = []byte(`{"token_id":1}`)
					}
					_, err := service.VerifySecurityInput(identity, input)
					assert.ErrorIs(t, err, service.ErrProofMethod)
				}
			}
		})
	}
}

func TestSecurityEnrollmentAccessTokenLifecycleConsumesProofs(t *testing.T) {
	user, identity := setupSecurityEnrollmentTest(t)
	require.NoError(t, model.DB.Model(user).Update("access_token", "previous-token").Error)
	createBody := `{"name":"ci","scopes":["profile:read"],"expires_at":0}`
	var created []string
	for range 2 {
		proof, err := service.VerifySecurityInput(identity, service.VerificationInput{
			Scope: service.VerificationScopeAccessTokenGenerate, Context: []byte(`{"scopes":["profile:read"],"expires_at":0}`), Method: "password", Password: "enrollment-password",
		})
		require.NoError(t, err)
		wrongScope := securityEnrollmentRequest("DELETE", "/api/user/access_tokens/legacy", "", proof.ProofToken, identity, RevokeLegacyAccessToken)
		assert.Contains(t, wrongScope.Body.String(), `"code":"SECURITY_PROOF_SCOPE_MISMATCH"`)
		response := securityEnrollmentRequest("POST", "/api/user/access_tokens", createBody, proof.ProofToken, identity, CreateAccessToken)
		var body securityEnrollmentResponse
		require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
		require.True(t, body.Success, body.Message)
		var result struct {
			Token string `json:"token"`
		}
		require.NoError(t, common.Unmarshal(body.Data, &result))
		require.True(t, strings.HasPrefix(result.Token, model.AccessTokenPrefix))
		stored, err := model.FindUserAccessTokenByHash(model.AccessTokenFingerprint(result.Token))
		require.NoError(t, err)
		require.NotNil(t, stored)
		response = securityEnrollmentRequest("POST", "/api/user/access_tokens", createBody, proof.ProofToken, identity, CreateAccessToken)
		assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_CONSUMED"`)
		created = append(created, result.Token)
	}
	legacyUser, err := model.ValidateAccessToken("previous-token")
	require.NoError(t, err)
	require.NotNil(t, legacyUser, "creating scoped tokens leaves the legacy token alone")
	tokens, err := model.ListUserAccessTokens(user.Id)
	require.NoError(t, err)
	require.Len(t, tokens, 2)
	first, second := tokens[0], tokens[1]

	// A revoke proof is bound to one token ID.
	proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenRevoke, Context: []byte(fmt.Sprintf(`{"token_id":%d}`, first.Id))}, "password")
	response := securityEnrollmentRequest("DELETE", "/api/user/access_tokens/x", "", proof, identity, withAccessTokenID(DeleteAccessToken, second.Id))
	assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_CONTEXT_MISMATCH"`)
	response = securityEnrollmentRequest("DELETE", "/api/user/access_tokens/x", "", proof, identity, withAccessTokenID(DeleteAccessToken, first.Id))
	var body securityEnrollmentResponse
	require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
	require.True(t, body.Success, body.Message)
	deleted, err := model.FindUserAccessTokenByHash(first.TokenHash)
	require.NoError(t, err)
	assert.Nil(t, deleted)
	kept, err := model.FindUserAccessTokenByHash(second.TokenHash)
	require.NoError(t, err)
	assert.NotNil(t, kept)

	legacyProof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenRevoke, Context: []byte(`{"legacy":true}`)}, "password")
	response = securityEnrollmentRequest("DELETE", "/api/user/access_tokens/legacy", "", legacyProof, identity, RevokeLegacyAccessToken)
	require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
	require.True(t, body.Success, body.Message)
	legacyUser, err = model.ValidateAccessToken("previous-token")
	require.NoError(t, err)
	assert.Nil(t, legacyUser)
	response = securityEnrollmentRequest("DELETE", "/api/user/access_tokens/legacy", "", legacyProof, identity, RevokeLegacyAccessToken)
	assert.Equal(t, http.StatusNotFound, response.Code, "there is nothing left to revoke")

	var audits []model.AuditLog
	require.NoError(t, model.LOG_DB.Order("id").Find(&audits).Error)
	actions := make([]string, 0, len(audits))
	for _, audit := range audits {
		actions = append(actions, audit.Action)
	}
	assert.Equal(t, []string{"access_token.generate", "access_token.generate", "access_token.revoke", "access_token.revoke"}, actions)
	encoded, err := common.Marshal(audits)
	require.NoError(t, err)
	for _, secret := range append(created, "previous-token", proof, legacyProof) {
		assert.NotContains(t, string(encoded), secret)
	}
}

func TestSecurityEnrollmentAccessTokenRejectsInvalidProofs(t *testing.T) {
	user, identity := setupSecurityEnrollmentTest(t)
	require.NoError(t, model.DB.Model(user).Update("access_token", "unchanged-token").Error)
	_, scoped := createScopedAccessToken(t, user.Id, 0, "profile:read")
	for _, endpoint := range accessTokenMutationEndpoints(scoped.Id) {
		for _, failure := range []string{"session", "user version", "expired"} {
			t.Run(endpoint.name+"/"+failure, func(t *testing.T) {
				proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: endpoint.scope, Context: endpoint.context}, "password")
				requestIdentity := identity
				code := "SECURITY_PROOF_INVALID"
				switch failure {
				case "session":
					requestIdentity.SessionID = "other-session"
				case "user version":
					requestIdentity.UserAuthVersion++
				case "expired":
					require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposeSecurityProof).Update("expires_at", time.Now().Add(-time.Minute)).Error)
					code = "SECURITY_PROOF_EXPIRED"
				}
				response := securityEnrollmentRequest(endpoint.method, endpoint.path, endpoint.body, proof, requestIdentity, endpoint.handler)
				assert.Equal(t, http.StatusForbidden, response.Code)
				assert.Contains(t, response.Body.String(), code)
				assertAccessTokensUnchanged(t, user.Id, scoped.Id, "unchanged-token")
			})
		}
	}
}

func TestSecurityEnrollmentAccessTokenFailureDoesNotRestoreProof(t *testing.T) {
	user, identity := setupSecurityEnrollmentTest(t)
	require.NoError(t, model.DB.Model(user).Update("access_token", "unchanged-token").Error)
	_, scoped := createScopedAccessToken(t, user.Id, 0, "profile:read")
	failWrite := func(table string) func(tx *gorm.DB) {
		return func(tx *gorm.DB) {
			if tx.Statement.Table == table {
				tx.AddError(errors.New("private database failure"))
			}
		}
	}
	require.NoError(t, model.DB.Callback().Create().Before("gorm:create").Register("access_token_create_failure", failWrite("user_access_tokens")))
	require.NoError(t, model.DB.Callback().Delete().Before("gorm:delete").Register("access_token_delete_failure", failWrite("user_access_tokens")))
	require.NoError(t, model.DB.Callback().Update().Before("gorm:update").Register("legacy_token_update_failure", failWrite("users")))
	require.NoError(t, model.DB.Callback().Update().Before("gorm:update").Register("access_token_update_failure", failWrite("user_access_tokens")))
	for _, endpoint := range accessTokenMutationEndpoints(scoped.Id) {
		t.Run(endpoint.name, func(t *testing.T) {
			proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: endpoint.scope, Context: endpoint.context}, "password")
			response := securityEnrollmentRequest(endpoint.method, endpoint.path, endpoint.body, proof, identity, endpoint.handler)
			assert.Equal(t, http.StatusInternalServerError, response.Code)
			assert.NotContains(t, response.Body.String(), "private database")
			response = securityEnrollmentRequest(endpoint.method, endpoint.path, endpoint.body, proof, identity, endpoint.handler)
			assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_CONSUMED"`)
		})
	}
	assertAccessTokensUnchanged(t, user.Id, scoped.Id, "unchanged-token")
}

func decodeSecurityEnrollmentResponse(t *testing.T, response *httptest.ResponseRecorder) securityEnrollmentResponse {
	t.Helper()
	var body securityEnrollmentResponse
	require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body), response.Body.String())
	return body
}

// A scoped token completes step-up on its own pat:<id> identity. The change it
// makes logs out every browser session and leaves the token usable.
func TestSecurityEnrollmentAccessTokenStepUpDisablesTwoFA(t *testing.T) {
	user, _ := setupSecurityEnrollmentTest(t)
	require.NoError(t, model.DB.Create(&model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}).Error)
	browser, err := service.CreateLoginSession(user.Id, "password", "127.0.0.1", "browser")
	require.NoError(t, err)
	raw, token := createScopedAccessToken(t, user.Id, 0, "account_security:write", "profile:read")
	router := newAccessTokenTestRouter()

	response := accessTokenRequest(router, http.MethodGet, "/api/verify/methods?scope=2fa.disable", raw, "", "")
	body := decodeSecurityEnrollmentResponse(t, response)
	require.True(t, body.Success, body.Message)
	var requirements service.VerificationRequirements
	require.NoError(t, common.Unmarshal(body.Data, &requirements))
	assert.Equal(t, []service.VerificationMethodOption{{Method: service.VerificationMethodTwoFA, Available: true}}, requirements.Methods)

	code, err := totp.GenerateCode("JBSWY3DPEHPK3PXP", time.Now())
	require.NoError(t, err)
	response = accessTokenRequest(router, http.MethodPost, "/api/verify", raw, "", `{"scope":"2fa.disable","method":"2fa","code":"`+code+`"}`)
	body = decodeSecurityEnrollmentResponse(t, response)
	require.True(t, body.Success, body.Message)
	var proof service.SecurityProof
	require.NoError(t, common.Unmarshal(body.Data, &proof))
	response = accessTokenRequest(router, http.MethodPost, "/api/user/2fa/disable", raw, proof.ProofToken, `{}`)
	body = decodeSecurityEnrollmentResponse(t, response)
	require.True(t, body.Success, body.Message)
	assert.JSONEq(t, `{}`, string(body.Data), "a token-initiated change issues no browser session")

	twoFA, err := model.GetTwoFAByUserId(user.Id)
	require.NoError(t, err)
	assert.False(t, twoFA != nil && twoFA.IsEnabled)
	assert.Equal(t, http.StatusUnauthorized, accessTokenRequest(router, http.MethodGet, "/api/user/self", browser.AccessToken, "", "").Code)
	session, err := model.GetUserSessionBySID(browser.Session.SID)
	require.NoError(t, err)
	assert.Equal(t, model.UserSessionStatusRevoked, session.Status)
	assert.Equal(t, http.StatusOK, accessTokenRequest(router, http.MethodGet, "/api/user/self", raw, "", "").Code)

	var audits []model.AuditLog
	require.NoError(t, model.LOG_DB.Where("action IN ?", []string{"user.security_verify", "user.2fa_disable_self"}).Order("id").Find(&audits).Error)
	require.Len(t, audits, 2)
	encoded, err := common.Marshal(audits)
	require.NoError(t, err)
	for _, secret := range []string{raw, proof.ProofToken, code} {
		assert.NotContains(t, string(encoded), secret)
	}
	for _, audit := range audits {
		assert.Equal(t, "access_token", audit.AuthMethod)
		require.NotNil(t, audit.Other.Op)
		params, err := common.Marshal(audit.Other.Op.Params)
		require.NoError(t, err)
		assert.Contains(t, string(params), `"token_ref":"`+token.TokenHash+`"`)
	}
}

func TestSecurityEnrollmentAccessTokenStepUpBoundaries(t *testing.T) {
	t.Run("token scope gates verification", func(t *testing.T) {
		user, _ := setupSecurityEnrollmentTest(t)
		readOnly, _ := createScopedAccessToken(t, user.Id, 0, "profile:read")
		full, _ := createScopedAccessToken(t, user.Id, 0, "account_security:write", "profile:read", "user:write")
		router := newAccessTokenTestRouter()
		for _, request := range []struct {
			name, credential, path, body string
		}{
			{"methods without account security", readOnly, "/api/verify/methods?scope=2fa.setup", ""},
			{"verify without account security", readOnly, "/api/verify", `{"scope":"2fa.setup","method":"password","password":"enrollment-password"}`},
			{"token creation", full, "/api/verify", `{"scope":"access_token.generate","method":"password","password":"enrollment-password","context":{"scopes":["profile:read"],"expires_at":0}}`},
			{"token grant change", full, "/api/verify", `{"scope":"access_token.update","method":"password","password":"enrollment-password","context":{"token_id":1,"scopes":["profile:read"]}}`},
			{"token revocation", full, "/api/verify", `{"scope":"access_token.revoke","method":"password","password":"enrollment-password","context":{"legacy":true}}`},
		} {
			method := http.MethodPost
			if request.body == "" {
				method = http.MethodGet
			}
			response := accessTokenRequest(router, method, request.path, request.credential, "", request.body)
			assert.Equal(t, http.StatusForbidden, response.Code, request.name)
			assert.Equal(t, "SECURITY_ACTION_FORBIDDEN", decodeSecurityEnrollmentResponse(t, response).Code, request.name)
		}
		var proofs int64
		require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposeSecurityProof).Count(&proofs).Error)
		assert.Zero(t, proofs)
	})

	t.Run("OAuth-only accounts have no method for a token", func(t *testing.T) {
		user, identity := setupSecurityEnrollmentTest(t)
		require.NoError(t, model.DB.Model(user).Updates(map[string]any{"password": "", "github_id": "linked-user"}).Error)
		oauth.Register("enrollment-oauth", &enrollmentOAuthProvider{externalID: "linked-user"})
		t.Cleanup(func() { oauth.Unregister("enrollment-oauth") })
		raw, _ := createScopedAccessToken(t, user.Id, 0, "account_security:write")
		router := newAccessTokenTestRouter()
		body := decodeSecurityEnrollmentResponse(t, accessTokenRequest(router, http.MethodGet, "/api/verify/methods?scope=2fa.setup", raw, "", ""))
		require.True(t, body.Success, body.Message)
		var requirements service.VerificationRequirements
		require.NoError(t, common.Unmarshal(body.Data, &requirements))
		require.Len(t, requirements.Methods, 1)
		assert.Equal(t, service.VerificationMethodOAuth, requirements.Methods[0].Method)
		assert.False(t, requirements.Methods[0].Available)
		assert.Empty(t, requirements.OAuthProviders)
		body = decodeSecurityEnrollmentResponse(t, accessTokenRequest(router, http.MethodPost, "/api/verify", raw, "", `{"scope":"2fa.setup","method":"oauth"}`))
		assert.Equal(t, "SECURITY_METHOD_UNAVAILABLE", body.Code)
		// The same account still verifies through OAuth in a browser.
		browser, err := service.GetVerificationRequirements(identity, service.VerificationScopeTwoFASetup)
		require.NoError(t, err)
		assert.True(t, browser.Methods[0].Available)
	})

	t.Run("a proof stays with the token that obtained it", func(t *testing.T) {
		user, _ := setupSecurityEnrollmentTest(t)
		browser, err := service.CreateLoginSession(user.Id, "password", "127.0.0.1", "browser")
		require.NoError(t, err)
		first, _ := createScopedAccessToken(t, user.Id, 0, "account_security:write")
		second, _ := createScopedAccessToken(t, user.Id, 0, "account_security:write")
		router := newAccessTokenTestRouter()
		body := decodeSecurityEnrollmentResponse(t, accessTokenRequest(router, http.MethodPost, "/api/verify", first, "", `{"scope":"2fa.setup","method":"password","password":"enrollment-password"}`))
		require.True(t, body.Success, body.Message)
		var proof service.SecurityProof
		require.NoError(t, common.Unmarshal(body.Data, &proof))
		for _, credential := range []string{second, browser.AccessToken} {
			response := accessTokenRequest(router, http.MethodPost, "/api/user/2fa/setup", credential, proof.ProofToken, `{}`)
			assert.Equal(t, "SECURITY_PROOF_INVALID", decodeSecurityEnrollmentResponse(t, response).Code)
		}
		body = decodeSecurityEnrollmentResponse(t, accessTokenRequest(router, http.MethodPost, "/api/user/2fa/setup", first, proof.ProofToken, `{}`))
		assert.True(t, body.Success, "rejected holders do not burn the proof: %s", body.Message)
	})

	t.Run("revoking the token voids its proof", func(t *testing.T) {
		user, _ := setupSecurityEnrollmentTest(t)
		raw, token := createScopedAccessToken(t, user.Id, 0, "account_security:write")
		router := newAccessTokenTestRouter()
		body := decodeSecurityEnrollmentResponse(t, accessTokenRequest(router, http.MethodPost, "/api/verify", raw, "", `{"scope":"2fa.setup","method":"password","password":"enrollment-password"}`))
		require.True(t, body.Success, body.Message)
		var proof service.SecurityProof
		require.NoError(t, common.Unmarshal(body.Data, &proof))
		_, err := model.DeleteUserAccessToken(user.Id, token.Id)
		require.NoError(t, err)
		assert.Equal(t, http.StatusUnauthorized, accessTokenRequest(router, http.MethodPost, "/api/user/2fa/setup", raw, proof.ProofToken, `{}`).Code)
		// A request that authenticated just before the revocation cannot spend it either.
		identity := service.AuthIdentity{UserID: user.Id, SessionID: model.AccessTokenSessionID(token.Id), UserAuthVersion: user.AuthVersion, SessionVersion: model.AccessTokenSessionVersion}
		_, err = service.ConsumeOperationProof(proof.ProofToken, identity, service.VerificationOperation{Scope: service.VerificationScopeTwoFASetup})
		assert.Error(t, err)
		var consumed int64
		require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ? AND consumed_at IS NOT NULL", model.AuthFlowPurposeSecurityProof).Count(&consumed).Error)
		assert.Zero(t, consumed)
	})
}

func authorizeSecurityEnrollment(t *testing.T, identity service.AuthIdentity) *model.AuthFlowAuthorization {
	t.Helper()
	operation := service.VerificationOperation{Scope: service.VerificationScopeTwoFASetup}
	proof := issueSecurityEnrollmentProof(t, identity, operation, service.VerificationMethodPassword)
	authorization, err := service.ConsumeOperationProof(proof, identity, operation)
	require.NoError(t, err)
	return authorization
}

// securityPasskeyResponse acts as a software authenticator at the browser boundary.
// The handlers still validate the real WebAuthn challenge, origin and signature.
func securityPasskeyResponse(t *testing.T, key *ecdsa.PrivateKey, challenge string, registration bool, counter uint32, userVerified ...bool) json.RawMessage {
	t.Helper()
	ceremony := "webauthn.get"
	if registration {
		ceremony = "webauthn.create"
	}
	clientData, err := common.Marshal(map[string]any{"type": ceremony, "challenge": challenge, "origin": "https://example.com"})
	require.NoError(t, err)
	credentialID := sha256.Sum256(elliptic.Marshal(key.Curve, key.X, key.Y))
	rpIDHash := sha256.Sum256([]byte("example.com"))
	authData := append([]byte{}, rpIDHash[:]...)
	response := map[string]any{"clientDataJSON": base64.RawURLEncoding.EncodeToString(clientData)}
	if registration {
		flags := byte(0x45) // user present, user verified, attested credential
		if len(userVerified) > 0 && !userVerified[0] {
			flags = 0x41
		}
		authData = append(authData, flags)
		authData = append(authData, make([]byte, 4+16)...)
		authData = binary.BigEndian.AppendUint16(authData, uint16(len(credentialID)))
		authData = append(authData, credentialID[:]...)
		publicKey, err := cbor.Marshal(map[int]any{
			1: 2, 3: -7, -1: 1, -2: key.X.FillBytes(make([]byte, 32)), -3: key.Y.FillBytes(make([]byte, 32)),
		})
		require.NoError(t, err)
		authData = append(authData, publicKey...)
		attestation, err := cbor.Marshal(map[string]any{"fmt": "none", "authData": authData, "attStmt": map[string]any{}})
		require.NoError(t, err)
		response["attestationObject"] = base64.RawURLEncoding.EncodeToString(attestation)
	} else {
		flags := byte(0x05) // user present and verified
		if len(userVerified) > 0 && !userVerified[0] {
			flags = 0x01
		}
		authData = append(authData, flags)
		authData = binary.BigEndian.AppendUint32(authData, counter)
		clientHash := sha256.Sum256(clientData)
		signedData := append(append([]byte{}, authData...), clientHash[:]...)
		signedHash := sha256.Sum256(signedData)
		signature, err := ecdsa.SignASN1(rand.Reader, key, signedHash[:])
		require.NoError(t, err)
		response["authenticatorData"] = base64.RawURLEncoding.EncodeToString(authData)
		response["signature"] = base64.RawURLEncoding.EncodeToString(signature)
	}
	id := base64.RawURLEncoding.EncodeToString(credentialID[:])
	credential, err := common.Marshal(map[string]any{"id": id, "rawId": id, "type": "public-key", "response": response})
	require.NoError(t, err)
	return credential
}

func TestSecurityEnrollmentRejectsMissingProofBeforeCreatingCredentials(t *testing.T) {
	user, identity := setupSecurityEnrollmentTest(t)
	for _, test := range []struct {
		path    string
		handler gin.HandlerFunc
	}{
		{"/api/user/2fa/setup", Setup2FA},
		{"/api/user/passkey/register/begin", PasskeyRegisterBegin},
	} {
		t.Run(test.path, func(t *testing.T) {
			response := securityEnrollmentRequest(http.MethodPost, test.path, `{}`, "", identity, test.handler)
			assert.Equal(t, http.StatusForbidden, response.Code)
			assert.Contains(t, response.Body.String(), "SECURITY_PROOF_REQUIRED")
			assert.NotContains(t, response.Body.String(), "qr_code_data")
		})
	}
	pending, err := model.GetTwoFAByUserId(user.Id)
	require.NoError(t, err)
	assert.Nil(t, pending)
}

type securityEnrollmentResponse struct {
	Success    bool            `json:"success"`
	Message    string          `json:"message"`
	MessageKey string          `json:"message_key"`
	Code       string          `json:"code"`
	Data       json.RawMessage `json:"data"`
}

func TestSecurityEnrollmentMethodPolicy(t *testing.T) {
	for _, test := range []struct {
		name                                              string
		password, passkey, twoFA, locked, disabledPasskey bool
		method                                            string
		available                                         bool
	}{
		{name: "first factor uses password", password: true, method: "password", available: true},
		{name: "existing passkey takes precedence", password: true, passkey: true, method: "passkey", available: true},
		{name: "twofa and passkey are alternatives", password: true, passkey: true, twoFA: true, method: "2fa", available: true},
		{name: "locked twofa permits passkey", password: true, passkey: true, twoFA: true, locked: true, method: "2fa"},
		{name: "disabled passkey does not fall back", password: true, passkey: true, disabledPasskey: true, method: "passkey"},
		{name: "disabled registration does not request a password", password: true, disabledPasskey: true, method: "password"},
		{name: "passwordless account without providers is unavailable", method: "oauth"},
	} {
		t.Run(test.name, func(t *testing.T) {
			user, identity := setupSecurityEnrollmentTest(t)
			if !test.password {
				require.NoError(t, model.DB.Model(user).Update("password", "").Error)
			}
			if test.passkey {
				require.NoError(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id, CredentialID: "enrolled-passkey", PublicKey: "public-key"}).Error)
			}
			if test.twoFA {
				twoFA := &model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}
				if test.locked {
					until := time.Now().Add(time.Minute)
					twoFA.LockedUntil = &until
				}
				require.NoError(t, model.DB.Create(twoFA).Error)
			}
			system_setting.GetPasskeySettings().Enabled = !test.disabledPasskey
			requirements, err := service.GetVerificationRequirements(identity, "passkey.register")
			require.NoError(t, err)
			count := 1
			if test.twoFA && test.passkey {
				count = 2
			}
			require.Len(t, requirements.Methods, count)
			assert.Equal(t, test.method, requirements.Methods[0].Method)
			assert.Equal(t, test.available, requirements.Methods[0].Available)
			if count == 2 {
				assert.Equal(t, service.VerificationMethodOption{Method: "passkey", Available: true}, requirements.Methods[1])
			}
			if test.passkey && !test.twoFA {
				requirements, err = service.GetVerificationRequirements(identity, "2fa.setup")
				require.NoError(t, err)
				assert.Equal(t, "passkey", requirements.Methods[0].Method)
			}
		})
	}
}

func TestSecurityEnrollmentPasswordProofIsBoundToSessionAndAction(t *testing.T) {
	user, identity := setupSecurityEnrollmentTest(t)
	response := securityEnrollmentRequest("POST", "/api/verify", `{"method":"password","scope":"2fa.setup","password":"wrong"}`, "", identity, UniversalVerify)
	assert.NotContains(t, response.Body.String(), "proof_token")
	response = securityEnrollmentRequest("POST", "/api/verify", `{"method":"password","scope":"2fa.setup","password":"enrollment-password"}`, "", identity, UniversalVerify)
	var body securityEnrollmentResponse
	require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
	require.True(t, body.Success, body.Message)
	var proof service.SecurityProof
	require.NoError(t, common.Unmarshal(body.Data, &proof))
	_, err := service.ConsumeOperationProof(proof.ProofToken, identity, service.VerificationOperation{Scope: "passkey.register"})
	assert.ErrorIs(t, err, service.ErrProofScope)
	other := identity
	other.SessionID = "another-session"
	_, err = service.ConsumeOperationProof(proof.ProofToken, other, service.VerificationOperation{Scope: "2fa.setup"})
	assert.ErrorIs(t, err, service.ErrAuthTokenInvalid)
	_, err = service.ConsumeOperationProof(proof.ProofToken, identity, service.VerificationOperation{Scope: "2fa.setup"})
	require.NoError(t, err)
	response = securityEnrollmentRequest("POST", "/api/verify", `{"method":"password","scope":"channel.key.read","password":"enrollment-password"}`, "", identity, UniversalVerify)
	assert.NotContains(t, response.Body.String(), "proof_token")
	common.PasswordLoginEncryptionEnabled = true
	response = securityEnrollmentRequest("POST", "/api/verify", `{"method":"password","scope":"2fa.setup","password":"enrollment-password"}`, "", identity, UniversalVerify)
	assert.NotContains(t, response.Body.String(), "proof_token", "encryption-required mode must reject plaintext")
	common.PasswordLoginEncryptionEnabled = false
	require.NoError(t, model.DB.Model(user).Update("auth_version", 2).Error)
	_, err = service.ConsumeOperationProof(proof.ProofToken, identity, service.VerificationOperation{Scope: "2fa.setup"})
	assert.ErrorIs(t, err, service.ErrLoginSessionRevoked)
}

func TestSecurityEnrollmentProofStartsOnlyOneSetup(t *testing.T) {
	_, identity := setupSecurityEnrollmentTest(t)
	proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: "2fa.setup"}, "password")
	first := securityEnrollmentRequest("POST", "/api/user/2fa/setup", `{}`, proof, identity, Setup2FA)
	var body securityEnrollmentResponse
	require.NoError(t, common.Unmarshal(first.Body.Bytes(), &body))
	require.True(t, body.Success, body.Message)
	second := securityEnrollmentRequest("POST", "/api/user/2fa/setup", `{}`, proof, identity, Setup2FA)
	assert.Equal(t, http.StatusForbidden, second.Code)
	require.NoError(t, common.Unmarshal(second.Body.Bytes(), &body))
	assert.False(t, body.Success)
	assert.Equal(t, "SECURITY_PROOF_CONSUMED", body.Code)
}

func TestSecurityEnrollmentOperationContext(t *testing.T) {
	for _, test := range []struct {
		name, scope, context string
		err                  error
	}{
		{"channel", "channel.key.read", `{"channel_id":123}`, nil},
		{"missing channel", "channel.key.read", ``, service.ErrVerificationContextInvalid},
		{"null channel", "channel.key.read", `{"channel_id":null}`, service.ErrVerificationContextInvalid},
		{"string channel", "channel.key.read", `{"channel_id":"123"}`, service.ErrVerificationContextInvalid},
		{"fractional channel", "channel.key.read", `{"channel_id":123.5}`, service.ErrVerificationContextInvalid},
		{"zero channel", "channel.key.read", `{"channel_id":0}`, service.ErrVerificationContextInvalid},
		{"negative channel", "channel.key.read", `{"channel_id":-1}`, service.ErrVerificationContextInvalid},
		{"overflow channel", "channel.key.read", `{"channel_id":18446744073709551615}`, service.ErrVerificationContextInvalid},
		{"extra field", "channel.key.read", `{"channel_id":123,"extra":true}`, service.ErrVerificationContextInvalid},
		{"null context", "passkey.register", `null`, service.ErrVerificationContextInvalid},
		{"array context", "passkey.register", `[]`, service.ErrVerificationContextInvalid},
		{"empty enrollment", "passkey.register", `{}`, nil},
		{"implicit enrollment", "passkey.register", ``, nil},
		{"generate access token", "access_token.generate", `{"scopes":["profile:read"],"expires_at":0}`, nil},
		{"generate access token without grant", "access_token.generate", `{}`, service.ErrVerificationContextInvalid},
		{"generate access token empty grant", "access_token.generate", `{"scopes":[],"expires_at":0}`, service.ErrVerificationContextInvalid},
		{"generate access token negative expiry", "access_token.generate", `{"scopes":["profile:read"],"expires_at":-1}`, service.ErrVerificationContextInvalid},
		{"update access token", "access_token.update", `{"token_id":3,"scopes":["profile:read"]}`, nil},
		{"update access token without target", "access_token.update", `{"scopes":["profile:read"]}`, service.ErrVerificationContextInvalid},
		{"update access token without grant", "access_token.update", `{"token_id":3}`, service.ErrVerificationContextInvalid},
		{"update access token empty grant", "access_token.update", `{"token_id":3,"scopes":[]}`, service.ErrVerificationContextInvalid},
		{"update access token target injection", "access_token.update", `{"token_id":3,"scopes":["profile:read"],"user_id":42}`, service.ErrVerificationContextInvalid},
		{"revoke access token", "access_token.revoke", `{"token_id":3}`, nil},
		{"revoke legacy access token", "access_token.revoke", `{"legacy":true}`, nil},
		{"revoke access token without target", "access_token.revoke", ``, service.ErrVerificationContextInvalid},
		{"revoke access token two targets", "access_token.revoke", `{"token_id":3,"legacy":true}`, service.ErrVerificationContextInvalid},
		{"access token target injection", "access_token.revoke", `{"user_id":42}`, service.ErrVerificationContextInvalid},
		{"enrollment target injection", "passkey.register", `{"user_id":42}`, service.ErrVerificationContextInvalid},
		{"admin user", "admin.user.delete", `{"user_id":7}`, nil},
		{"admin user zero", "admin.user.delete", `{"user_id":0}`, service.ErrVerificationContextInvalid},
		{"admin user extra field", "admin.user.update", `{"user_id":7,"password":"x"}`, service.ErrVerificationContextInvalid},
		{"admin manage", "admin.user.manage", `{"user_id":7,"action":"promote"}`, nil},
		{"admin manage quota", "admin.user.manage", `{"user_id":7,"action":"add_quota"}`, service.ErrVerificationContextInvalid},
		{"admin manage delete", "admin.user.manage", `{"user_id":7,"action":"delete"}`, service.ErrVerificationContextInvalid},
		{"admin binding type", "admin.user.binding.clear", `{"user_id":7,"binding_type":"github"}`, nil},
		{"admin binding provider", "admin.user.binding.clear", `{"user_id":7,"provider_id":3}`, nil},
		{"admin binding both", "admin.user.binding.clear", `{"user_id":7,"binding_type":"github","provider_id":3}`, service.ErrVerificationContextInvalid},
		{"admin binding neither", "admin.user.binding.clear", `{"user_id":7}`, service.ErrVerificationContextInvalid},
		{"admin create administrator", "admin.user.create", `{"role":10}`, nil},
		{"admin create common user", "admin.user.create", `{"role":1}`, service.ErrVerificationContextInvalid},
		{"admin create unknown role", "admin.user.create", `{"role":50}`, service.ErrVerificationContextInvalid},
		{"unknown scope", "user.email.change", `{}`, service.ErrProofScope},
	} {
		t.Run(test.name, func(t *testing.T) {
			_, err := service.BindVerificationOperation(service.VerificationOperation{Scope: test.scope, Context: []byte(test.context)})
			assert.ErrorIs(t, err, test.err)
		})
	}
	var first, reordered service.VerificationOperation
	require.NoError(t, common.UnmarshalJsonStr(`{"scope":"channel.key.read","context":{"channel_id":123}}`, &first))
	require.NoError(t, common.UnmarshalJsonStr(`{"context": { "channel_id": 123 }, "scope":"channel.key.read"}`, &reordered))
	firstBinding, err := service.BindVerificationOperation(first)
	require.NoError(t, err)
	secondBinding, err := service.BindVerificationOperation(reordered)
	require.NoError(t, err)
	assert.Equal(t, firstBinding, secondBinding)
	sortedGrant, err := service.BindVerificationOperation(service.VerificationOperation{Scope: "access_token.generate", Context: []byte(`{"scopes":["profile:read","usage:read"],"expires_at":0}`)})
	require.NoError(t, err)
	unsortedGrant, err := service.BindVerificationOperation(service.VerificationOperation{Scope: "access_token.generate", Context: []byte(`{"expires_at":0,"scopes":["usage:read"," profile:read","usage:read"]}`)})
	require.NoError(t, err)
	assert.Equal(t, sortedGrant, unsortedGrant, "a grant binds as a set")
}

func TestSecurityEnrollmentChannelProofRejectsMismatchesBeforeConsumption(t *testing.T) {
	user, identity := setupSecurityEnrollmentTest(t)
	require.NoError(t, model.DB.Model(user).Update("role", common.RoleRootUser).Error)
	require.NoError(t, model.PublishUserAuthCache(user.Id))
	twoFA := &model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}
	require.NoError(t, model.DB.Create(twoFA).Error)
	operation := service.VerificationOperation{Scope: service.VerificationScopeChannelKeyRead, Context: []byte(`{"channel_id":123}`)}
	code, err := totp.GenerateCode(twoFA.Secret, time.Now())
	require.NoError(t, err)
	proof, err := service.VerifySecurityInput(identity, service.VerificationInput{Method: "2fa", Scope: operation.Scope, Context: operation.Context, Code: code})
	require.NoError(t, err)
	for _, test := range []struct {
		name      string
		identity  service.AuthIdentity
		operation service.VerificationOperation
		err       error
	}{
		{"channel", identity, service.VerificationOperation{Scope: operation.Scope, Context: []byte(`{"channel_id":456}`)}, service.ErrProofContext},
		{"scope", identity, service.VerificationOperation{Scope: "passkey.delete"}, service.ErrProofScope},
		{"user", service.AuthIdentity{UserID: user.Id + 1, SessionID: identity.SessionID, UserAuthVersion: identity.UserAuthVersion, SessionVersion: identity.SessionVersion}, operation, service.ErrAuthTokenInvalid},
		{"session", service.AuthIdentity{UserID: user.Id, SessionID: "other-session", UserAuthVersion: identity.UserAuthVersion, SessionVersion: identity.SessionVersion}, operation, service.ErrAuthTokenInvalid},
		{"session version", service.AuthIdentity{UserID: user.Id, SessionID: identity.SessionID, UserAuthVersion: identity.UserAuthVersion, SessionVersion: identity.SessionVersion + 1}, operation, service.ErrAuthTokenInvalid},
		{"user version", service.AuthIdentity{UserID: user.Id, SessionID: identity.SessionID, UserAuthVersion: identity.UserAuthVersion + 1, SessionVersion: identity.SessionVersion}, operation, service.ErrAuthTokenInvalid},
	} {
		t.Run(test.name, func(t *testing.T) {
			_, err := service.ConsumeOperationProof(proof.ProofToken, test.identity, test.operation)
			assert.ErrorIs(t, err, test.err)
		})
	}
	require.NoError(t, model.DB.Model(twoFA).Update("is_enabled", false).Error)
	_, err = service.ConsumeOperationProof(proof.ProofToken, identity, operation)
	assert.ErrorIs(t, err, service.ErrProofMethod)
	require.NoError(t, model.DB.Model(twoFA).Update("is_enabled", true).Error)
	authorization, err := service.ConsumeOperationProof(proof.ProofToken, identity, operation)
	require.NoError(t, err)
	assert.Positive(t, authorization.ProofID)
	_, err = service.ConsumeOperationProof(proof.ProofToken, identity, operation)
	assert.ErrorIs(t, err, service.ErrProofConsumed)
}

func TestSecurityEnrollmentProofConcurrentConsumption(t *testing.T) {
	_, identity := setupSecurityEnrollmentTest(t)
	operation := service.VerificationOperation{Scope: "passkey.register"}
	proof := issueSecurityEnrollmentProof(t, identity, operation, "password")
	start := make(chan struct{})
	results := make(chan error, 2)
	for range 2 {
		go func() {
			<-start
			_, err := service.ConsumeOperationProof(proof, identity, operation)
			results <- err
		}()
	}
	close(start)
	successes := 0
	for range 2 {
		if err := <-results; err != nil {
			assert.ErrorIs(t, err, service.ErrProofConsumed)
		} else {
			successes++
		}
	}
	assert.Equal(t, 1, successes)
}

func TestSecurityEnrollmentProofRequiresLiveRecordAndExactDeadline(t *testing.T) {
	_, identity := setupSecurityEnrollmentTest(t)
	operation := service.VerificationOperation{Scope: "passkey.register"}
	proof := issueSecurityEnrollmentProof(t, identity, operation, "password")
	claims := jwt.MapClaims{}
	_, _, err := jwt.NewParser().ParseUnverified(proof, claims)
	require.NoError(t, err)
	assert.Equal(t, float64(60), claims["exp"].(float64)-claims["iat"].(float64))
	assert.NotEmpty(t, claims["context_hash"])
	assert.NotContains(t, claims, "context")
	assert.NotContains(t, claims, "channel_id")
	var stored model.AuthFlow
	require.NoError(t, model.DB.Where("purpose = ?", model.AuthFlowPurposeSecurityProof).First(&stored).Error)
	assert.Equal(t, int64(claims["exp"].(float64)), stored.ExpiresAt.Unix())
	assert.NotEqual(t, proof, stored.TokenHash)
	assert.NotEqual(t, claims["jti"], stored.TokenHash)
	require.NoError(t, model.DB.Model(&stored).Update("expires_at", time.Now()).Error)
	_, err = service.ConsumeOperationProof(proof, identity, operation)
	assert.ErrorIs(t, err, service.ErrAuthTokenExpired, "database deadline must reject even while the JWT is within its clock tolerance")
	require.NoError(t, model.DB.Delete(&stored).Error)
	_, err = service.ConsumeOperationProof(proof, identity, operation)
	assert.ErrorIs(t, err, service.ErrAuthTokenInvalid)
}

func TestSecurityEnrollmentProofIsBurnedAfterBusinessFailure(t *testing.T) {
	_, identity := setupSecurityEnrollmentTest(t)
	proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: "passkey.register"}, "password")
	require.NoError(t, model.DB.Callback().Create().Before("gorm:create").Register("security_flow_creation_failure", func(tx *gorm.DB) {
		if tx.Statement.Table == "auth_flows" {
			tx.AddError(errors.New("injected creation failure"))
		}
	}))
	response := securityEnrollmentRequest("POST", "/api/user/passkey/register/begin", "", proof, identity, PasskeyRegisterBegin)
	assert.Equal(t, http.StatusInternalServerError, response.Code)
	require.NoError(t, model.DB.Callback().Create().Remove("security_flow_creation_failure"))
	response = securityEnrollmentRequest("POST", "/api/user/passkey/register/begin", "", proof, identity, PasskeyRegisterBegin)
	assert.Equal(t, http.StatusForbidden, response.Code)
	assert.Contains(t, response.Body.String(), "SECURITY_PROOF_CONSUMED")
}

func TestSecurityEnrollmentProofStorageErrorsFailClosed(t *testing.T) {
	for _, stage := range []string{"issuance", "consumption"} {
		t.Run(stage, func(t *testing.T) {
			_, identity := setupSecurityEnrollmentTest(t)
			proof := ""
			if stage == "consumption" {
				proof = issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: "passkey.register"}, "password")
			}
			failure := func(tx *gorm.DB) {
				if tx.Statement.Table == "auth_flows" {
					tx.AddError(errors.New("private proof database failure"))
				}
			}
			if stage == "issuance" {
				require.NoError(t, model.DB.Callback().Create().Before("gorm:create").Register("proof_storage_failure", failure))
			} else {
				require.NoError(t, model.DB.Callback().Update().Before("gorm:update").Register("proof_storage_failure", failure))
			}
			var response *httptest.ResponseRecorder
			if stage == "issuance" {
				response = securityEnrollmentRequest("POST", "/api/verify", `{"method":"password","scope":"passkey.register","password":"enrollment-password"}`, "", identity, UniversalVerify)
				require.NoError(t, model.DB.Callback().Create().Remove("proof_storage_failure"))
			} else {
				response = securityEnrollmentRequest("POST", "/api/user/passkey/register/begin", "", proof, identity, PasskeyRegisterBegin)
				require.NoError(t, model.DB.Callback().Update().Remove("proof_storage_failure"))
			}
			assert.Equal(t, http.StatusInternalServerError, response.Code)
			assert.Contains(t, response.Body.String(), "AUTH_INTERNAL_ERROR")
			assert.NotContains(t, response.Body.String(), "private")
			assert.NotContains(t, response.Body.String(), "proof_token")
			assert.NotContains(t, response.Body.String(), "flow_token")
			var count int64
			require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposePasskeyRegister).Count(&count).Error)
			assert.Zero(t, count)
			if stage == "consumption" {
				response = securityEnrollmentRequest("POST", "/api/user/passkey/register/begin", "", proof, identity, PasskeyRegisterBegin)
				var result securityEnrollmentResponse
				require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
				assert.True(t, result.Success, "a failed consumption transaction must not burn the proof")
			}
		})
	}
}

func TestSecurityEnrollmentVerificationTransportsRejectInvalidContext(t *testing.T) {
	_, identity := setupSecurityEnrollmentTest(t)
	oauth.Register("enrollment-oauth", &enrollmentOAuthProvider{externalID: "linked-user"})
	t.Cleanup(func() { oauth.Unregister("enrollment-oauth") })
	for _, test := range []struct {
		path, body string
		handler    gin.HandlerFunc
	}{
		{"/api/verify", `{"method":"password","scope":"passkey.register","context":{"user_id":999},"password":"enrollment-password"}`, UniversalVerify},
		{"/api/user/passkey/verify/begin", `{"scope":"passkey.register","context":{"user_id":999}}`, PasskeyVerifyBegin},
		{"/api/oauth/state", `{"provider":"enrollment-oauth","intent":"verify","scope":"passkey.register","context":{"user_id":999}}`, GenerateOAuthCode},
	} {
		t.Run(test.path, func(t *testing.T) {
			response := securityEnrollmentRequest("POST", test.path, test.body, "", identity, test.handler)
			assert.Equal(t, http.StatusBadRequest, response.Code)
			assert.Contains(t, response.Body.String(), "SECURITY_CONTEXT_INVALID")
		})
	}
	var count int64
	require.NoError(t, model.DB.Model(&model.AuthFlow{}).Count(&count).Error)
	assert.Zero(t, count)
}

func TestSecurityEnrollmentPendingPasskeyRejectsChangedAuthorization(t *testing.T) {
	for _, change := range []string{"expired", "revoked", "session version", "user version", "method", "other session"} {
		t.Run(change, func(t *testing.T) {
			user, identity := setupSecurityEnrollmentTest(t)
			proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: "passkey.register"}, "password")
			response := securityEnrollmentRequest("POST", "/api/user/passkey/register/begin", "", proof, identity, PasskeyRegisterBegin)
			var result securityEnrollmentResponse
			require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
			require.True(t, result.Success, result.Message)
			var begin struct {
				FlowToken string `json:"flow_token"`
				Options   struct {
					PublicKey struct {
						Challenge string `json:"challenge"`
					} `json:"publicKey"`
				} `json:"options"`
			}
			require.NoError(t, common.Unmarshal(result.Data, &begin))
			key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
			require.NoError(t, err)
			body, err := common.Marshal(passkeyFinishRequest{
				FlowToken: begin.FlowToken, Credential: securityPasskeyResponse(t, key, begin.Options.PublicKey.Challenge, true, 0),
			})
			require.NoError(t, err)
			switch change {
			case "expired":
				require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposePasskeyRegister).Update("expires_at", time.Now().Add(-time.Minute)).Error)
			case "revoked":
				_, err = model.RevokeUserSession(user.Id, identity.SessionID, "security-test")
				require.NoError(t, err)
			case "session version":
				require.NoError(t, model.DB.Model(&model.UserSession{}).Where("sid = ?", identity.SessionID).Update("version", 2).Error)
			case "user version":
				require.NoError(t, model.DB.Model(user).Update("auth_version", 2).Error)
			case "method":
				require.NoError(t, model.DB.Model(user).Update("password", "").Error)
			case "other session":
				other, err := service.CreateLoginSession(user.Id, "password", "127.0.0.1", "other-session")
				require.NoError(t, err)
				identity, err = service.ParseAccessToken(other.AccessToken)
				require.NoError(t, err)
			}
			response = securityEnrollmentRequest("POST", "/api/user/passkey/register/finish", string(body), "", identity, PasskeyRegisterFinish)
			require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
			assert.False(t, result.Success)
			_, err = model.GetPasskeyByUserID(user.Id)
			assert.ErrorIs(t, err, model.ErrPasskeyNotFound)
		})
	}
}

func TestSecurityEnrollmentPasskeyProofProtectsChannelKeyRead(t *testing.T) {
	user, identity := setupSecurityEnrollmentTest(t)
	require.NoError(t, model.DB.Model(user).Update("role", common.RoleRootUser).Error)
	require.NoError(t, model.PublishUserAuthCache(user.Id))
	require.NoError(t, model.DB.AutoMigrate(&model.Channel{}))
	for _, channel := range []model.Channel{
		{Id: 123, Name: "first", Key: "first-channel-secret", Type: 1, Status: 1},
		{Id: 456, Name: "second", Key: "second-channel-secret", Type: 1, Status: 1},
	} {
		require.NoError(t, model.DB.Create(&channel).Error)
	}
	key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	require.NoError(t, err)
	registrationProof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: "passkey.register"}, "password")
	response := securityEnrollmentRequest("POST", "/api/user/passkey/register/begin", "", registrationProof, identity, PasskeyRegisterBegin)
	var body securityEnrollmentResponse
	require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
	require.True(t, body.Success, body.Message)
	var begin struct {
		FlowToken string `json:"flow_token"`
		Options   struct {
			PublicKey struct {
				Challenge string `json:"challenge"`
			} `json:"publicKey"`
		} `json:"options"`
	}
	require.NoError(t, common.Unmarshal(body.Data, &begin))
	require.NotEmpty(t, begin.Options.PublicKey.Challenge)
	registrationBody, err := common.Marshal(passkeyFinishRequest{
		FlowToken: begin.FlowToken, Credential: securityPasskeyResponse(t, key, begin.Options.PublicKey.Challenge, true, 0),
	})
	require.NoError(t, err)
	// The dedicated registration flow remains authorized after the consumed proof expires.
	require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposeSecurityProof).Update("expires_at", time.Now().Add(-time.Minute)).Error)
	response = securityEnrollmentRequest("POST", "/api/user/passkey/register/finish", string(registrationBody), "", identity, PasskeyRegisterFinish)
	require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
	require.True(t, body.Success, body.Message)
	var rotation struct {
		AccessToken string `json:"access_token"`
	}
	require.NoError(t, common.Unmarshal(body.Data, &rotation))
	identity, err = service.ParseAccessToken(rotation.AccessToken)
	require.NoError(t, err)
	assert.EqualValues(t, 2, identity.UserAuthVersion)
	response = securityEnrollmentRequest("POST", "/api/user/passkey/register/finish", string(registrationBody), "", identity, PasskeyRegisterFinish)
	require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
	assert.False(t, body.Success)

	response = securityEnrollmentRequest("POST", "/api/user/passkey/verify/begin", `{"scope":"channel.key.read","context":{"channel_id":123}}`, "", identity, PasskeyVerifyBegin)
	require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
	require.True(t, body.Success, body.Message)
	require.NoError(t, common.Unmarshal(body.Data, &begin))
	assertionBody, err := common.Marshal(map[string]any{
		"flow_token": begin.FlowToken, "credential": securityPasskeyResponse(t, key, begin.Options.PublicKey.Challenge, false, 1),
		"scope": "passkey.delete", "context": map[string]any{"channel_id": 456},
	})
	require.NoError(t, err)
	response = securityEnrollmentRequest("POST", "/api/user/passkey/verify/finish", string(assertionBody), "", identity, PasskeyVerifyFinish)
	require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
	require.True(t, body.Success, body.Message)
	var proof service.SecurityProof
	require.NoError(t, common.Unmarshal(body.Data, &proof))
	assert.Equal(t, "passkey", proof.Method)
	assert.Equal(t, "channel.key.read", proof.Scope, "finish cannot replace the operation approved at begin")

	router := gin.New()
	router.POST("/api/channel/:id/key", middleware.RootAuth(), middleware.SecureVerificationRequired(), GetChannelKey)
	for _, test := range []struct {
		name, path, proof, code, key string
		status                       int
	}{
		{"login token only", "/api/channel/123/key", "", "SECURITY_PROOF_REQUIRED", "", http.StatusForbidden},
		{"access token as proof", "/api/channel/123/key", rotation.AccessToken, "SECURITY_PROOF_INVALID", "", http.StatusForbidden},
		{"other channel", "/api/channel/456/key", proof.ProofToken, "SECURITY_PROOF_CONTEXT_MISMATCH", "", http.StatusForbidden},
		{"authorized channel", "/api/channel/123/key", proof.ProofToken, "", "first-channel-secret", http.StatusOK},
		{"replay", "/api/channel/123/key", proof.ProofToken, "SECURITY_PROOF_CONSUMED", "", http.StatusForbidden},
	} {
		t.Run(test.name, func(t *testing.T) {
			request := httptest.NewRequest("POST", test.path, nil)
			request.Header.Set("Authorization", "Bearer "+rotation.AccessToken)
			request.Header.Set("X-Security-Proof", test.proof)
			result := httptest.NewRecorder()
			router.ServeHTTP(result, request)
			assert.Equal(t, test.status, result.Code)
			var response securityEnrollmentResponse
			require.NoError(t, common.Unmarshal(result.Body.Bytes(), &response))
			assert.Equal(t, test.code, response.Code)
			if test.key != "" {
				assert.Contains(t, string(response.Data), test.key)
			} else {
				assert.NotContains(t, result.Body.String(), "channel-secret")
			}
		})
	}
	var logs []model.AuditLog
	require.NoError(t, model.LOG_DB.Where("action = ?", "channel.key_view").Find(&logs).Error)
	require.Len(t, logs, 1)
	encodedLogs, err := common.Marshal(logs)
	require.NoError(t, err)
	assert.NotContains(t, string(encodedLogs), "first-channel-secret")
	assert.NotContains(t, string(encodedLogs), proof.ProofToken)

	response = securityEnrollmentRequest("POST", "/api/user/passkey/verify/begin", `{"scope":"passkey.delete"}`, "", identity, PasskeyVerifyBegin)
	require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
	require.True(t, body.Success, body.Message)
	require.NoError(t, common.Unmarshal(body.Data, &begin))
	deleteAssertion, err := common.Marshal(passkeyFinishRequest{
		FlowToken: begin.FlowToken, Credential: securityPasskeyResponse(t, key, begin.Options.PublicKey.Challenge, false, 2),
	})
	require.NoError(t, err)
	response = securityEnrollmentRequest("POST", "/api/user/passkey/verify/finish", string(deleteAssertion), "", identity, PasskeyVerifyFinish)
	require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
	require.True(t, body.Success, body.Message)
	require.NoError(t, common.Unmarshal(body.Data, &proof))
	response = securityEnrollmentRequest("DELETE", "/api/user/passkey", "", proof.ProofToken, identity, PasskeyDelete)
	require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
	require.True(t, body.Success, body.Message)
	_, err = model.GetPasskeyByUserID(user.Id)
	assert.ErrorIs(t, err, model.ErrPasskeyNotFound)
}

func TestSecurityEnrollmentVerifyRequiresDedicatedFlowForInteractiveMethods(t *testing.T) {
	for _, method := range []string{service.VerificationMethodPasskey, service.VerificationMethodOAuth} {
		t.Run(method, func(t *testing.T) {
			user, identity := setupSecurityEnrollmentTest(t)
			if method == service.VerificationMethodPasskey {
				require.NoError(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id, CredentialID: "enrolled-passkey", PublicKey: "public-key"}).Error)
			} else {
				require.NoError(t, model.DB.Model(user).Updates(map[string]any{"password": "", "github_id": "linked-user"}).Error)
				oauth.Register("enrollment-oauth", &enrollmentOAuthProvider{externalID: "linked-user"})
				t.Cleanup(func() { oauth.Unregister("enrollment-oauth") })
			}
			_, err := service.RequireVerificationMethod(identity, service.VerificationScopeTwoFASetup, method)
			require.NoError(t, err)
			payload, err := common.Marshal(service.VerificationInput{Method: method, Scope: service.VerificationScopeTwoFASetup})
			require.NoError(t, err)
			response := securityEnrollmentRequest("POST", "/api/verify", string(payload), "", identity, UniversalVerify)
			var body securityEnrollmentResponse
			require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
			assert.Equal(t, http.StatusBadRequest, response.Code)
			assert.False(t, body.Success)
			assert.Equal(t, "SECURITY_VERIFICATION_FLOW_REQUIRED", body.Code)
			assert.Equal(t, "This verification method requires its dedicated verification flow.", body.Message)
			assert.Empty(t, body.Data)
		})
	}
}

func TestSecurityEnrollmentTwoFAFlowAndSessionRotation(t *testing.T) {
	user, identity := setupSecurityEnrollmentTest(t)
	other, err := service.CreateLoginSession(user.Id, "password", "127.0.0.1", "other-device")
	require.NoError(t, err)
	var proof string
	var setups []service.TwoFASetup
	for range 2 {
		proof = issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: "2fa.setup"}, "password")
		response := securityEnrollmentRequest("POST", "/api/user/2fa/setup", `{}`, proof, identity, Setup2FA)
		var body securityEnrollmentResponse
		require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
		require.True(t, body.Success, body.Message)
		var setup service.TwoFASetup
		require.NoError(t, common.Unmarshal(body.Data, &setup))
		require.NotEmpty(t, setup.FlowToken)
		require.Len(t, setup.BackupCodes, common.BackupCodeCount)
		setups = append(setups, setup)
	}
	oldCode, err := totp.GenerateCode(setups[0].Secret, time.Now())
	require.NoError(t, err)
	assert.ErrorIs(t, service.FinishTwoFASetup(identity, setups[0].FlowToken, oldCode), model.ErrTwoFASetupInvalid)
	assert.ErrorIs(t, service.FinishTwoFASetup(identity, setups[1].FlowToken, "not-a-code"), model.ErrTwoFACodeInvalid)
	_, err = model.GetAuthFlow(setups[1].FlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTwoFASetup})
	require.NoError(t, err, "invalid code must not consume setup")
	code, err := totp.GenerateCode(setups[1].Secret, time.Now())
	require.NoError(t, err)
	require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposeSecurityProof).Update("expires_at", time.Now().Add(-time.Minute)).Error)
	payload, err := common.Marshal(Verify2FARequest{Code: code, FlowToken: setups[1].FlowToken})
	require.NoError(t, err)
	response := securityEnrollmentRequest("POST", "/api/user/2fa/enable", string(payload), "", identity, Enable2FA)
	var body securityEnrollmentResponse
	require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
	require.True(t, body.Success, body.Message)
	var rotated struct {
		AccessToken string `json:"access_token"`
	}
	require.NoError(t, common.Unmarshal(body.Data, &rotated))
	newIdentity, err := service.ParseAccessToken(rotated.AccessToken)
	require.NoError(t, err)
	assert.EqualValues(t, 2, newIdentity.UserAuthVersion)
	assert.Equal(t, identity.SessionID, newIdentity.SessionID)
	otherSession, err := model.GetUserSessionBySID(other.Session.SID)
	require.NoError(t, err)
	assert.Equal(t, model.UserSessionStatusRevoked, otherSession.Status)
	_, err = model.GetAuthFlow(setups[1].FlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTwoFASetup})
	assert.ErrorIs(t, err, model.ErrAuthFlowConsumed)
	_, err = service.ConsumeOperationProof(proof, newIdentity, service.VerificationOperation{Scope: "2fa.setup"})
	assert.Error(t, err)
	enabled, err := model.GetTwoFAByUserId(user.Id)
	require.NoError(t, err)
	assert.True(t, enabled.IsEnabled)
	var logs []model.AuditLog
	require.NoError(t, model.LOG_DB.Find(&logs).Error)
	require.NotEmpty(t, logs)
	encoded, err := common.Marshal(logs)
	require.NoError(t, err)
	for _, secret := range []string{setups[0].Secret, setups[1].Secret, proof, code, setups[1].FlowToken} {
		assert.NotContains(t, string(encoded), secret)
	}
}

func TestSecurityEnrollmentSetupAndEnableRollback(t *testing.T) {
	user, identity := setupSecurityEnrollmentTest(t)
	setup, err := service.StartTwoFASetup(identity, authorizeSecurityEnrollment(t, identity))
	require.NoError(t, err)
	failure := errors.New("injected storage failure")
	authorization := authorizeSecurityEnrollment(t, identity)
	require.NoError(t, model.DB.Callback().Create().Before("gorm:create").Register("security_setup_failure", func(tx *gorm.DB) {
		if tx.Statement.Table == "auth_flows" {
			tx.AddError(failure)
		}
	}))
	_, err = service.StartTwoFASetup(identity, authorization)
	assert.ErrorIs(t, err, failure)
	require.NoError(t, model.DB.Callback().Create().Remove("security_setup_failure"))
	pending, err := model.GetTwoFAByUserId(user.Id)
	require.NoError(t, err)
	assert.Equal(t, setup.Secret, pending.Secret)
	count, err := model.GetUnusedBackupCodeCount(user.Id)
	require.NoError(t, err)
	assert.Equal(t, common.BackupCodeCount, count)
	code, err := totp.GenerateCode(setup.Secret, time.Now())
	require.NoError(t, err)
	require.NoError(t, model.DB.Callback().Update().Before("gorm:update").Register("security_enable_failure", func(tx *gorm.DB) {
		if tx.Statement.Table == "two_fas" {
			tx.AddError(failure)
		}
	}))
	assert.ErrorIs(t, service.FinishTwoFASetup(identity, setup.FlowToken, code), failure)
	require.NoError(t, model.DB.Callback().Update().Remove("security_enable_failure"))
	_, err = model.GetAuthFlow(setup.FlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTwoFASetup})
	require.NoError(t, err)
	storedUser, err := model.GetUserById(user.Id, false)
	require.NoError(t, err)
	assert.Equal(t, identity.UserAuthVersion, storedUser.AuthVersion)
	require.NoError(t, service.FinishTwoFASetup(identity, setup.FlowToken, code))
}

type enrollmentOAuthProvider struct {
	authFlowTestOAuthProvider
	externalID string
	disabled   bool
}

func (*enrollmentOAuthProvider) ProviderUserIDColumn() string { return "github_id" }
func (p *enrollmentOAuthProvider) IsEnabled() bool            { return !p.disabled }
func (p *enrollmentOAuthProvider) GetUserInfo(context.Context, *oauth.OAuthToken) (*oauth.OAuthUser, error) {
	return &oauth.OAuthUser{ProviderUserID: p.externalID, Email: "same@example.com"}, nil
}

func TestSecurityEnrollmentOAuthVerificationNeverChangesLoginOrBindings(t *testing.T) {
	for _, scenario := range []string{"success", "different account", "binding changed", "session changed", "auth version changed", "provider disabled", "cancelled"} {
		t.Run(scenario, func(t *testing.T) {
			user, identity := setupSecurityEnrollmentTest(t)
			require.NoError(t, model.DB.Model(user).Updates(map[string]any{"password": "", "github_id": "linked-user", "email": "same@example.com"}).Error)
			provider := &enrollmentOAuthProvider{externalID: "linked-user"}
			oauth.Register("enrollment-oauth", provider)
			t.Cleanup(func() { oauth.Unregister("enrollment-oauth") })
			response := securityEnrollmentRequest("POST", "/api/oauth/state", `{"provider":"enrollment-oauth","intent":"verify","scope":"2fa.setup"}`, "", identity, GenerateOAuthCode)
			var body securityEnrollmentResponse
			require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
			require.True(t, body.Success, body.Message)
			var started struct {
				FlowToken string `json:"flow_token"`
			}
			require.NoError(t, common.Unmarshal(body.Data, &started))
			callbackIdentity := identity
			query := "&code=authorization-code"
			switch scenario {
			case "different account":
				provider.externalID = "other-user"
			case "binding changed":
				require.NoError(t, model.DB.Model(user).Update("github_id", "replacement-user").Error)
			case "session changed":
				callbackIdentity.SessionID = "different-session"
			case "auth version changed":
				require.NoError(t, model.DB.Model(user).Update("auth_version", 2).Error)
			case "cancelled":
				query = "&error=access_denied"
			case "provider disabled":
				provider.disabled = true
			}
			handler := func(c *gin.Context) {
				c.Params = gin.Params{{Key: "provider", Value: "enrollment-oauth"}}
				HandleOAuth(c)
			}
			path := "/api/oauth/enrollment-oauth?state=" + started.FlowToken + query
			response = securityEnrollmentRequest("GET", path, "", "", callbackIdentity, handler)
			require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
			assert.Equal(t, scenario == "success", body.Success, body.Message)
			assert.NotContains(t, response.Body.String(), "access_token")
			assert.Empty(t, response.Header().Values("Set-Cookie"))
			if scenario == "success" {
				var proof service.SecurityProof
				require.NoError(t, common.Unmarshal(body.Data, &proof))
				_, err := service.ConsumeOperationProof(proof.ProofToken, identity, service.VerificationOperation{Scope: "2fa.setup"})
				require.NoError(t, err)
				replayed := securityEnrollmentRequest("GET", path, "", "", identity, handler)
				assert.Equal(t, http.StatusForbidden, replayed.Code)
			}
			var users, sessions, bindings int64
			require.NoError(t, model.DB.Model(&model.User{}).Count(&users).Error)
			require.NoError(t, model.DB.Model(&model.UserSession{}).Count(&sessions).Error)
			require.NoError(t, model.DB.Model(&model.UserOAuthBinding{}).Count(&bindings).Error)
			assert.EqualValues(t, 1, users)
			assert.EqualValues(t, 1, sessions)
			assert.Zero(t, bindings)
			stored, err := model.GetUserById(user.Id, false)
			require.NoError(t, err)
			expectedBinding := "linked-user"
			if scenario == "binding changed" {
				expectedBinding = "replacement-user"
			}
			assert.Equal(t, expectedBinding, stored.GitHubId)
		})
	}
}

func TestSecurityEnrollmentEncryptedPasswordVerification(t *testing.T) {
	_, identity := setupSecurityEnrollmentTest(t)
	keyID, publicPEM := common.PasswordEncryptionPublicKey()
	if keyID == "" {
		privatePEM, err := common.GeneratePasswordEncryptionPrivateKey()
		require.NoError(t, err)
		require.NoError(t, common.LoadPasswordEncryptionPrivateKey(privatePEM))
		keyID, publicPEM = common.PasswordEncryptionPublicKey()
	}
	block, _ := pem.Decode([]byte(publicPEM))
	require.NotNil(t, block)
	parsed, err := x509.ParsePKIXPublicKey(block.Bytes)
	require.NoError(t, err)
	publicKey, ok := parsed.(*rsa.PublicKey)
	require.True(t, ok)
	ciphertext, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, publicKey, []byte("enrollment-password"), nil)
	require.NoError(t, err)
	common.PasswordLoginEncryptionEnabled = true
	input := service.VerificationInput{Method: "password", Scope: "passkey.register", PasswordEncrypted: base64.StdEncoding.EncodeToString(ciphertext), EncryptionKeyID: keyID}
	proof, err := service.VerifySecurityInput(identity, input)
	require.NoError(t, err)
	_, err = service.ConsumeOperationProof(proof.ProofToken, identity, service.VerificationOperation{Scope: input.Scope})
	require.NoError(t, err)
	input.EncryptionKeyID = "incorrect-key-id"
	_, err = service.VerifySecurityInput(identity, input)
	assert.ErrorIs(t, err, service.ErrVerificationFailed)
}

func TestSecurityEnrollmentTwoFAFailureAccountingAndStorageErrors(t *testing.T) {
	user, identity := setupSecurityEnrollmentTest(t)
	twoFA := &model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}
	require.NoError(t, model.DB.Create(twoFA).Error)
	for _, endpoint := range []struct {
		path    string
		handler gin.HandlerFunc
	}{
		{"/api/user/2fa/disable", Disable2FA},
		{"/api/user/2fa/backup_codes", RegenerateBackupCodes},
		{"/api/user/login/2fa", Verify2FALogin},
	} {
		response := securityEnrollmentRequest("POST", endpoint.path, `{}`, "", identity, endpoint.handler)
		var body securityEnrollmentResponse
		require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
		assert.False(t, body.Success, endpoint.path)
		if endpoint.path == "/api/user/login/2fa" {
			assert.Equal(t, "Invalid parameters", body.Message, endpoint.path)
		} else {
			assert.Equal(t, "SECURITY_PROOF_REQUIRED", body.Code, endpoint.path)
		}
	}
	stored, err := model.GetTwoFAByUserId(user.Id)
	require.NoError(t, err)
	assert.Zero(t, stored.FailedAttempts, "missing required input must not count as a failed verification")
	wrongCode := ""
	for _, candidate := range []string{"000000", "111111", "222222", "333333"} {
		if !common.ValidateTOTPCode(twoFA.Secret, candidate) {
			wrongCode = candidate
			break
		}
	}
	require.NotEmpty(t, wrongCode)
	assert.ErrorIs(t, service.VerifyTwoFactorCode(twoFA, wrongCode), service.ErrVerificationFailed)
	stored, err = model.GetTwoFAByUserId(user.Id)
	require.NoError(t, err)
	assert.Equal(t, 1, stored.FailedAttempts)
	hash, err := common.HashBackupCode("ABCD-1234")
	require.NoError(t, err)
	require.NoError(t, model.DB.Create(&model.TwoFABackupCode{UserId: user.Id, CodeHash: hash}).Error)
	require.NoError(t, service.VerifyTwoFactorCode(stored, "ABCD-1234"))
	assert.ErrorIs(t, service.VerifyTwoFactorCode(stored, "ABCD-1234"), service.ErrVerificationFailed)
	until := time.Now().Add(time.Minute)
	stored.LockedUntil = &until
	assert.ErrorIs(t, service.VerifyTwoFactorCode(stored, "ABCD-1234"), service.ErrVerificationLocked)
	stored.LockedUntil = nil
	code, err := totp.GenerateCode(stored.Secret, time.Now())
	require.NoError(t, err)
	failure := errors.New("usage storage failed")
	require.NoError(t, model.DB.Callback().Update().Before("gorm:update").Register("security_usage_failure", func(tx *gorm.DB) {
		if tx.Statement.Table == "two_fas" {
			tx.AddError(failure)
		}
	}))
	assert.ErrorIs(t, service.VerifyTwoFactorCode(stored, code), failure)
	require.NoError(t, model.DB.Callback().Update().Remove("security_usage_failure"))
}

func TestSecurityEnrollmentDatabaseErrorsAreNotReturned(t *testing.T) {
	for _, test := range []struct {
		name, method, path, body, table string
		create                          bool
		handler                         gin.HandlerFunc
	}{
		{"methods", "GET", "/api/verify/methods?scope=2fa.setup", "", "users", false, GetVerificationMethods},
		{"password", "POST", "/api/verify", `{"method":"password","scope":"2fa.setup","password":"enrollment-password"}`, "users", false, UniversalVerify},
		{"passkey", "POST", "/api/user/passkey/register/begin", "", "users", false, PasskeyRegisterBegin},
		{"2fa status", "GET", "/api/user/2fa/status", "", "two_fas", false, Get2FAStatus},
		{"2fa setup", "POST", "/api/user/2fa/setup", "", "two_fas", true, Setup2FA},
		{"2fa flow", "POST", "/api/user/2fa/setup", "", "auth_flows", true, Setup2FA},
		{"oauth state", "POST", "/api/oauth/state", `{"provider":"enrollment-oauth","intent":"verify","scope":"2fa.setup"}`, "users", false, GenerateOAuthCode},
		{"channel key", "POST", "/api/channel/1/key", "", "channels", false, func(c *gin.Context) {
			c.Params = gin.Params{{Key: "id", Value: "1"}}
			GetChannelKey(c)
		}},
	} {
		t.Run(test.name, func(t *testing.T) {
			_, identity := setupSecurityEnrollmentTest(t)
			oauth.Register("enrollment-oauth", &enrollmentOAuthProvider{externalID: "linked-user"})
			t.Cleanup(func() { oauth.Unregister("enrollment-oauth") })
			proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeTwoFASetup}, service.VerificationMethodPassword)
			privateError := errors.New("database connection failed: private-db-host private_table SELECT secret_column")
			callback := func(tx *gorm.DB) {
				if tx.Statement.Table == test.table {
					tx.AddError(privateError)
				}
			}
			if test.create {
				require.NoError(t, model.DB.Callback().Create().Before("gorm:create").Register("security_private_error", callback))
			} else {
				require.NoError(t, model.DB.Callback().Query().Before("gorm:query").Register("security_private_error", callback))
			}
			response := securityEnrollmentRequest(test.method, test.path, test.body, proof, identity, test.handler)
			var body securityEnrollmentResponse
			require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
			assert.Equal(t, http.StatusInternalServerError, response.Code)
			assert.False(t, body.Success)
			assert.Equal(t, "AUTH_INTERNAL_ERROR", body.Code)
			assert.Equal(t, "Internal Server Error", body.Message)
			assert.NotContains(t, response.Body.String(), "private")
			assert.NotContains(t, response.Body.String(), "SELECT")
		})
	}
}

func TestSecurityEnrollmentPublicErrorsDiscardWrappedDetails(t *testing.T) {
	for _, test := range []struct {
		err     error
		code    string
		message string
	}{
		{service.ErrVerificationFailed, "SECURITY_VERIFICATION_FAILED", service.ErrVerificationFailed.Error()},
		{service.ErrVerificationLocked, "SECURITY_VERIFICATION_LOCKED", service.ErrVerificationLocked.Error()},
		{service.ErrOAuthAccountMismatch, "OAUTH_ACCOUNT_MISMATCH", service.ErrOAuthAccountMismatch.Error()},
		{model.ErrTwoFASetupInvalid, "TWOFA_SETUP_INVALID", model.ErrTwoFASetupInvalid.Error()},
	} {
		t.Run(test.code, func(t *testing.T) {
			response := securityEnrollmentRequest("POST", "/api/verify", "", "", service.AuthIdentity{}, func(c *gin.Context) {
				writeSecurityOperationError(c, fmt.Errorf("private database details: %w", test.err))
			})
			var body securityEnrollmentResponse
			require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
			assert.False(t, body.Success)
			assert.Equal(t, test.code, body.Code)
			assert.Equal(t, test.message, body.Message)
			assert.NotContains(t, response.Body.String(), "private")
		})
	}
}

func TestSecurityEnrollmentExpiredAndCrossSessionSetupsCannotActivate(t *testing.T) {
	user, identity := setupSecurityEnrollmentTest(t)
	setup, err := service.StartTwoFASetup(identity, authorizeSecurityEnrollment(t, identity))
	require.NoError(t, err)
	code, err := totp.GenerateCode(setup.Secret, time.Now())
	require.NoError(t, err)
	other, err := service.CreateLoginSession(user.Id, "password", "127.0.0.1", "other-session")
	require.NoError(t, err)
	otherIdentity, err := service.ParseAccessToken(other.AccessToken)
	require.NoError(t, err)
	assert.ErrorIs(t, service.FinishTwoFASetup(otherIdentity, setup.FlowToken, code), model.ErrTwoFASetupInvalid)
	flow, err := model.GetAuthFlow(setup.FlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTwoFASetup})
	require.NoError(t, err)
	require.NoError(t, model.DB.Model(flow).Update("expires_at", time.Now().Add(-time.Minute)).Error)
	assert.ErrorIs(t, service.FinishTwoFASetup(identity, setup.FlowToken, code), model.ErrTwoFASetupInvalid)
	stored, err := model.GetTwoFAByUserId(user.Id)
	require.NoError(t, err)
	assert.False(t, stored.IsEnabled)
	storedUser, err := model.GetUserById(user.Id, false)
	require.NoError(t, err)
	assert.Equal(t, identity.UserAuthVersion, storedUser.AuthVersion)
}

func TestSecurityEnrollmentOAuthQueriesReachHandlerWithoutLeakingToAccessLogs(t *testing.T) {
	var output bytes.Buffer
	previous := gin.DefaultWriter
	gin.DefaultWriter = &output
	t.Cleanup(func() { gin.DefaultWriter = previous })
	router := gin.New()
	middleware.SetUpLogger(router)
	router.GET("/api/oauth/:provider", func(c *gin.Context) {
		assert.Equal(t, "private-code", c.Query("code"))
		assert.Equal(t, "private-state", c.Query("state"))
		c.Status(http.StatusNoContent)
	})
	response := httptest.NewRecorder()
	router.ServeHTTP(response, httptest.NewRequest("GET", "/api/oauth/github?code=private-code&state=private-state", nil))
	assert.Equal(t, http.StatusNoContent, response.Code)
	assert.Contains(t, output.String(), "/api/oauth/github")
	assert.NotContains(t, output.String(), "private-code")
	assert.NotContains(t, output.String(), "private-state")
}

func TestSecurityEnrollmentCustomOAuthUsesExistingBinding(t *testing.T) {
	user, identity := setupSecurityEnrollmentTest(t)
	require.NoError(t, model.DB.Model(user).Update("password", "").Error)
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		switch r.URL.Path {
		case "/token":
			assert.NoError(t, r.ParseForm())
			assert.Equal(t, "custom-code", r.Form.Get("code"))
			_, _ = w.Write([]byte(`{"access_token":"provider-access-token","token_type":"Bearer"}`))
		case "/userinfo":
			assert.Equal(t, "Bearer provider-access-token", r.Header.Get("Authorization"))
			_, _ = w.Write([]byte(`{"sub":"custom-user","name":"Existing user"}`))
		default:
			w.WriteHeader(http.StatusNotFound)
		}
	}))
	t.Cleanup(upstream.Close)
	provider := oauth.NewGenericOAuthProvider(&model.CustomOAuthProvider{
		Id: 42, Slug: "enrollment-custom", Name: "Custom provider", Enabled: true,
		ClientId: "client", ClientSecret: "secret", UserIdField: "sub",
		TokenEndpoint: upstream.URL + "/token", UserInfoEndpoint: upstream.URL + "/userinfo",
	})
	oauth.RegisterCustom("enrollment-custom", provider)
	t.Cleanup(func() { oauth.Unregister("enrollment-custom") })
	require.NoError(t, model.DB.Create(&model.UserOAuthBinding{UserId: user.Id, ProviderId: 42, ProviderUserId: "custom-user"}).Error)
	response := securityEnrollmentRequest("GET", "/api/verify/methods?scope=2fa.setup", "", "", identity, GetVerificationMethods)
	assert.Contains(t, response.Body.String(), "enrollment-custom")
	assert.NotContains(t, response.Body.String(), "custom-user")
	response = securityEnrollmentRequest("POST", "/api/oauth/state", `{"provider":"enrollment-custom","intent":"verify","scope":"2fa.setup"}`, "", identity, GenerateOAuthCode)
	var body securityEnrollmentResponse
	require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
	require.True(t, body.Success, body.Message)
	var started struct {
		FlowToken string `json:"flow_token"`
	}
	require.NoError(t, common.Unmarshal(body.Data, &started))
	response = securityEnrollmentRequest("GET", "/api/oauth/enrollment-custom?state="+started.FlowToken+"&code=custom-code", "", "", identity, func(c *gin.Context) {
		c.Params = gin.Params{{Key: "provider", Value: "enrollment-custom"}}
		HandleOAuth(c)
	})
	require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
	require.True(t, body.Success, body.Message)
	var proof service.SecurityProof
	require.NoError(t, common.Unmarshal(body.Data, &proof))
	_, err := service.ConsumeOperationProof(proof.ProofToken, identity, service.VerificationOperation{Scope: "2fa.setup"})
	require.NoError(t, err)
	bindings, err := model.GetUserOAuthBindingsByUserId(user.Id)
	require.NoError(t, err)
	require.Len(t, bindings, 1)
	assert.Equal(t, "custom-user", bindings[0].ProviderUserId)
}

func completeFirstSecurityFactor(t *testing.T, identity service.AuthIdentity, proof service.SecurityProof) {
	t.Helper()
	var response *httptest.ResponseRecorder
	if proof.Scope == service.VerificationScopeTwoFASetup {
		response = securityEnrollmentRequest("POST", "/api/user/2fa/setup", "", proof.ProofToken, identity, Setup2FA)
		var body securityEnrollmentResponse
		require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
		require.True(t, body.Success, body.Message)
		var setup service.TwoFASetup
		require.NoError(t, common.Unmarshal(body.Data, &setup))
		code, err := totp.GenerateCode(setup.Secret, time.Now())
		require.NoError(t, err)
		request, err := common.Marshal(Verify2FARequest{FlowToken: setup.FlowToken, Code: code})
		require.NoError(t, err)
		response = securityEnrollmentRequest("POST", "/api/user/2fa/enable", string(request), "", identity, Enable2FA)
	} else {
		response = securityEnrollmentRequest("POST", "/api/user/passkey/register/begin", "", proof.ProofToken, identity, PasskeyRegisterBegin)
		var body securityEnrollmentResponse
		require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
		require.True(t, body.Success, body.Message)
		var begin struct {
			FlowToken string `json:"flow_token"`
			Options   struct {
				PublicKey struct {
					Challenge string `json:"challenge"`
				} `json:"publicKey"`
			} `json:"options"`
		}
		require.NoError(t, common.Unmarshal(body.Data, &begin))
		key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
		require.NoError(t, err)
		request, err := common.Marshal(passkeyFinishRequest{
			FlowToken:  begin.FlowToken,
			Credential: securityPasskeyResponse(t, key, begin.Options.PublicKey.Challenge, true, 0),
		})
		require.NoError(t, err)
		response = securityEnrollmentRequest("POST", "/api/user/passkey/register/finish", string(request), "", identity, PasskeyRegisterFinish)
	}
	var body securityEnrollmentResponse
	require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
	require.True(t, body.Success, response.Body.String())
	var rotation struct {
		AccessToken string `json:"access_token"`
	}
	require.NoError(t, common.Unmarshal(body.Data, &rotation))
	updated, err := service.ParseAccessToken(rotation.AccessToken)
	require.NoError(t, err)
	assert.Equal(t, identity.UserID, updated.UserID)
	assert.Equal(t, identity.UserAuthVersion+1, updated.UserAuthVersion)
	_, err = service.ConsumeOperationProof(proof.ProofToken, updated, service.VerificationOperation{Scope: proof.Scope})
	assert.Error(t, err)
}

func TestSecurityEnrollmentTelegramAndWeChatFirstFactor(t *testing.T) {
	for _, provider := range []string{"telegram", "wechat"} {
		for _, scope := range []string{service.VerificationScopeTwoFASetup, service.VerificationScopePasskeyRegister} {
			t.Run(provider+"/"+scope, func(t *testing.T) {
				var user *model.User
				var identity service.AuthIdentity
				var response *httptest.ResponseRecorder
				method := service.VerificationMethodSession
				if provider == "telegram" {
					fixture := setupTelegramOAuthTest(t)
					user, identity = fixture.user, fixture.identity
					require.NoError(t, model.DB.Model(user).Updates(map[string]any{"password": "", "telegram_id": "42"}).Error)
					state, code := fixture.authorization(t, "verify", identity, scope, telegramIdentityClaims(99))
					mismatch := telegramOAuthCallback(state, code, identity)
					assert.Contains(t, mismatch.Body.String(), "OAUTH_ACCOUNT_MISMATCH")
					assert.NotContains(t, mismatch.Body.String(), "proof_token")
					state, code = fixture.authorization(t, "verify", identity, scope, telegramIdentityClaims(42))
					response = telegramOAuthCallback(state, code, identity)
					method = service.VerificationMethodOAuth
				} else {
					user, identity = setupSecurityEnrollmentTest(t)
					require.NoError(t, model.DB.Model(user).Updates(map[string]any{"password": "", "wechat_id": "wechat-user"}).Error)
					request, err := common.Marshal(service.VerificationInput{Scope: scope, Method: method})
					require.NoError(t, err)
					response = securityEnrollmentRequest("POST", "/api/verify", string(request), "", identity, UniversalVerify)
				}
				var body securityEnrollmentResponse
				require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
				if provider == "wechat" {
					assert.False(t, body.Success)
					assert.NotContains(t, response.Body.String(), "proof_token")
					return
				}
				require.True(t, body.Success, response.Body.String())
				var proof service.SecurityProof
				require.NoError(t, common.Unmarshal(body.Data, &proof))
				assert.Equal(t, scope, proof.Scope)
				assert.Equal(t, method, proof.Method)
				before, err := model.GetUserById(user.Id, true)
				require.NoError(t, err)
				assert.Empty(t, before.Password)
				assert.Equal(t, identity.UserAuthVersion, before.AuthVersion)
				completeFirstSecurityFactor(t, identity, proof)
				after, err := model.GetUserById(user.Id, true)
				require.NoError(t, err)
				assert.Equal(t, before.TelegramId, after.TelegramId)
				assert.Equal(t, before.WeChatId, after.WeChatId)
			})
		}
	}
}

func TestSecurityEnrollmentNeverTrustsSessionForFirstFactor(t *testing.T) {
	for _, scenario := range []string{"wechat only", "password", "passkey", "locked 2fa", "telegram", "github", "disabled custom binding", "no binding", "binding storage failure", "revoked session"} {
		t.Run(scenario, func(t *testing.T) {
			user, identity := setupSecurityEnrollmentTest(t)
			require.NoError(t, model.DB.Model(user).Updates(map[string]any{"password": "", "wechat_id": "wechat-user"}).Error)
			switch scenario {
			case "password":
				require.NoError(t, model.DB.Model(user).Update("password", "stored-hash").Error)
			case "passkey":
				require.NoError(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id, CredentialID: "credential", PublicKey: "key"}).Error)
			case "locked 2fa":
				until := time.Now().Add(time.Hour)
				require.NoError(t, model.DB.Create(&model.TwoFA{UserId: user.Id, Secret: "secret", IsEnabled: true, LockedUntil: &until}).Error)
			case "telegram":
				require.NoError(t, model.DB.Model(user).Update("telegram_id", "42").Error)
			case "github":
				require.NoError(t, model.DB.Model(user).Update("github_id", "42").Error)
			case "disabled custom binding":
				require.NoError(t, model.DB.Create(&model.UserOAuthBinding{UserId: user.Id, ProviderId: 42, ProviderUserId: "linked"}).Error)
			case "no binding":
				require.NoError(t, model.DB.Model(user).Update("wechat_id", "").Error)
			case "binding storage failure":
				require.NoError(t, model.DB.Callback().Query().Before("gorm:query").Register("wechat_bindings_failure", func(tx *gorm.DB) {
					if tx.Statement.Table == "user_oauth_bindings" {
						tx.AddError(errors.New("private binding failure"))
					}
				}))
			case "revoked session":
				_, err := model.RevokeAllUserSessions(user.Id, "test")
				require.NoError(t, err)
			}
			for _, scope := range []string{"2fa.setup", "passkey.register", "passkey.delete", "channel.key.read"} {
				context := json.RawMessage(nil)
				if scope == "channel.key.read" {
					context = json.RawMessage(`{"channel_id":1}`)
				}
				_, err := service.VerifySecurityInput(identity, service.VerificationInput{Method: "session", Scope: scope, Context: context})
				assert.Error(t, err, scope)
			}
			if scenario == "binding storage failure" {
				require.NoError(t, model.DB.Callback().Query().Remove("wechat_bindings_failure"))
			}
			var count int64
			require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposeSecurityProof).Count(&count).Error)
			assert.Zero(t, count)
		})
	}
}

func TestSecurityEnrollmentMissingTargetsAreBusinessErrors(t *testing.T) {
	_, identity := setupSecurityEnrollmentTest(t)
	require.NoError(t, model.DB.AutoMigrate(&model.Channel{}))
	for _, target := range []struct {
		path, message string
		handler       gin.HandlerFunc
	}{
		{"/api/channel/999/key", "Channel does not exist", GetChannelKey},
		{"/api/user/999/2fa", "User does not exist", AdminDisable2FA},
	} {
		response := securityEnrollmentRequest("POST", target.path, "", "", identity, func(c *gin.Context) {
			c.Params = gin.Params{{Key: "id", Value: "999"}}
			target.handler(c)
		})
		assert.Equal(t, http.StatusOK, response.Code)
		var body securityEnrollmentResponse
		require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
		assert.False(t, body.Success)
		assert.Equal(t, target.message, body.Message)
		assert.NotEqual(t, "AUTH_UNAUTHORIZED", body.Code)
		for _, id := range []string{"invalid", "0", "-1"} {
			invalid := securityEnrollmentRequest("POST", target.path, "", "", identity, func(c *gin.Context) {
				c.Params = gin.Params{{Key: "id", Value: id}}
				target.handler(c)
			})
			assert.Equal(t, http.StatusOK, invalid.Code)
			assert.Contains(t, invalid.Body.String(), `"success":false`)
		}
		require.NoError(t, model.DB.Callback().Query().Before("gorm:query").Register("target_query_failure", func(tx *gorm.DB) {
			if tx.Statement.Table == "users" || tx.Statement.Table == "channels" {
				tx.AddError(errors.New("private database failure"))
			}
		}))
		failed := securityEnrollmentRequest("POST", target.path, "", "", identity, func(c *gin.Context) {
			c.Params = gin.Params{{Key: "id", Value: "999"}}
			target.handler(c)
		})
		require.NoError(t, model.DB.Callback().Query().Remove("target_query_failure"))
		assert.Equal(t, http.StatusInternalServerError, failed.Code)
		assert.NotContains(t, failed.Body.String(), "private database failure")
	}
	_, _, err := service.ValidateLoginSession(identity)
	require.NoError(t, err)
}

func TestSecurityEnrollmentRejectsChangedFirstFactorPolicy(t *testing.T) {
	for _, provider := range []string{"telegram", "wechat"} {
		for _, stage := range []string{"proof", "setup"} {
			t.Run(provider+"/"+stage, func(t *testing.T) {
				fixture := setupTelegramOAuthTest(t)
				user, identity := fixture.user, fixture.identity
				require.NoError(t, model.DB.Model(user).Update("password", "").Error)
				var proof *service.SecurityProof
				var err error
				if provider == "telegram" {
					require.NoError(t, model.DB.Model(user).Update("telegram_id", "42").Error)
					state, code := fixture.authorization(t, "verify", identity, "2fa.setup", telegramIdentityClaims(42))
					response := telegramOAuthCallback(state, code, identity)
					var body securityEnrollmentResponse
					require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
					require.True(t, body.Success, response.Body.String())
					require.NoError(t, common.Unmarshal(body.Data, &proof))
				} else {
					require.NoError(t, model.DB.Model(user).Update("wechat_id", "wechat-user").Error)
					proof, err = service.VerifySecurityInput(identity, service.VerificationInput{Scope: "2fa.setup", Method: "session"})
					require.Error(t, err)
					assert.Nil(t, proof)
					return
				}
				_, err = service.ConsumeOperationProof(proof.ProofToken, identity, service.VerificationOperation{Scope: "passkey.register"})
				assert.Error(t, err)
				var setupToken string
				if stage == "setup" {
					response := securityEnrollmentRequest("POST", "/api/user/2fa/setup", `{}`, proof.ProofToken, identity, Setup2FA)
					var body securityEnrollmentResponse
					require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
					require.True(t, body.Success, response.Body.String())
					var setup struct {
						FlowToken string `json:"flow_token"`
					}
					require.NoError(t, common.Unmarshal(body.Data, &setup))
					setupToken = setup.FlowToken
					_, err = service.ConsumeOperationProof(proof.ProofToken, identity, service.VerificationOperation{Scope: "2fa.setup"})
					assert.ErrorIs(t, err, service.ErrProofConsumed)
				}
				if provider == "telegram" {
					common.TelegramOAuthEnabled = false
				} else {
					require.NoError(t, model.DB.Model(user).Update("telegram_id", "42").Error)
				}
				if stage == "proof" {
					response := securityEnrollmentRequest("POST", "/api/user/2fa/setup", `{}`, proof.ProofToken, identity, Setup2FA)
					assert.Contains(t, response.Body.String(), `"success":false`)
					assert.NotContains(t, response.Body.String(), "flow_token")
				} else {
					request, err := common.Marshal(map[string]string{"flow_token": setupToken, "code": "123456"})
					require.NoError(t, err)
					response := securityEnrollmentRequest("POST", "/api/user/2fa/enable", string(request), "", identity, Enable2FA)
					assert.Contains(t, response.Body.String(), `"success":false`)
				}
				factor, err := model.GetTwoFAByUserId(user.Id)
				require.NoError(t, err)
				assert.True(t, factor == nil || !factor.IsEnabled)
			})
		}
	}
}