UserRepository.Update and APIKeyRepository.Update rewrote the whole row on every call, regardless of which fields the caller meant to change. Several columns on those tables are maintained by dedicated atomic paths (balance deduction, quota and rate-limit counters, limit adjustments, activity timestamps), so a caller holding a slightly older snapshot could silently roll them back - a lost update. Both methods now take an explicit column mask and persist only the columns the caller declares; everything else keeps its current database value. - All user and API-key call sites declare exactly what they mutate, which turns admin edits and profile saves into genuine partial updates. - Email uniqueness locking/lookup and allowed_groups sync only run when those fields are part of the update. - UserUpdateFields deliberately has no balance/total_recharged members, so Update cannot touch them. New AdjustBalance/SetBalance apply the change in a single statement and return before/after values; admin balance adjustment uses them instead of read-modify-write. - promo_codes.used_count is no longer written by Update; it is only ever incremented by the redemption path. - The billing hot path that marks an API key quota-exhausted writes only status. - Dropped a no-op row write in RevokeAllUserTokens: users has no token_version column, so it persisted nothing while still overwriting concurrently-updated columns. Adds integration coverage that a stale snapshot cannot revert concurrent atomic writes, and unit coverage pinning the column set each entry point declares.
66 lines
2.1 KiB
Go
66 lines
2.1 KiB
Go
//go:build unit
|
||
|
||
package handler
|
||
|
||
import (
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"testing"
|
||
|
||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/stretchr/testify/require"
|
||
)
|
||
|
||
func TestAuthHandlerRevokeAllSessionsInvalidatesAccessTokens(t *testing.T) {
|
||
gin.SetMode(gin.TestMode)
|
||
|
||
repo := &userHandlerRepoStub{
|
||
user: &service.User{
|
||
ID: 29,
|
||
Email: "session@example.com",
|
||
Username: "session-user",
|
||
Role: service.RoleUser,
|
||
Status: service.StatusActive,
|
||
TokenVersion: 7,
|
||
},
|
||
}
|
||
refreshTokenCache := &userHandlerRefreshTokenCacheStub{}
|
||
cfg := &config.Config{
|
||
JWT: config.JWTConfig{
|
||
Secret: "test-secret",
|
||
ExpireHour: 1,
|
||
},
|
||
}
|
||
authService := service.NewAuthService(nil, repo, nil, refreshTokenCache, cfg, nil, nil, nil, nil, nil, nil, nil, nil)
|
||
handler := &AuthHandler{authService: authService}
|
||
|
||
recorder := httptest.NewRecorder()
|
||
c, _ := gin.CreateTestContext(recorder)
|
||
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/auth/revoke-all-sessions", nil)
|
||
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 29})
|
||
|
||
handler.RevokeAllSessions(c)
|
||
|
||
require.Equal(t, http.StatusOK, recorder.Code)
|
||
require.Equal(t, []int64{29}, refreshTokenCache.revokedUserIDs)
|
||
// users 表没有 token_version 列(见 resolvedTokenVersion:JWT 里的值由
|
||
// email+password_hash 指纹推导),所以自增 TokenVersion 只停留在内存里。
|
||
// 此前紧跟其后的整行 Update 不写任何有效数据,却会用旧快照覆盖并发写入的列,
|
||
// 已移除。会话撤销由上面的 refresh session 清理承担。
|
||
require.Equal(t, int64(7), repo.user.TokenVersion)
|
||
|
||
var resp struct {
|
||
Code int `json:"code"`
|
||
Data struct {
|
||
Message string `json:"message"`
|
||
} `json:"data"`
|
||
}
|
||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
|
||
require.Equal(t, 0, resp.Code)
|
||
require.Equal(t, "All sessions have been revoked. Please log in again.", resp.Data.Message)
|
||
}
|