mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 22:06:38 +08:00
33a1c32cf8
Reorganize internal packages into platform/infra/shared layers and update imports, docs, and seed-count tests to match current system configs.
690 lines
24 KiB
Go
690 lines
24 KiB
Go
// Copyright 2026 Arctel.net
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package user
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
|
|
"github.com/Rain-kl/Wavelet/internal/infra/config"
|
|
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
|
|
"github.com/Rain-kl/Wavelet/internal/model"
|
|
"github.com/Rain-kl/Wavelet/internal/repository"
|
|
"github.com/Rain-kl/Wavelet/internal/testhelper"
|
|
"github.com/gin-contrib/sessions"
|
|
"github.com/gin-contrib/sessions/cookie"
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"github.com/Rain-kl/Wavelet/internal/shared/response"
|
|
)
|
|
|
|
func setupUserTestRouter(t *testing.T) *gin.Engine {
|
|
t.Helper()
|
|
|
|
oldCookieName := config.Config.App.SessionCookieName
|
|
oldSecret := config.Config.App.SessionSecret
|
|
oldDomain := config.Config.App.SessionDomain
|
|
oldSecure := config.Config.App.SessionSecure
|
|
oldHTTPOnly := config.Config.App.SessionHTTPOnly
|
|
t.Cleanup(func() {
|
|
config.Config.App.SessionCookieName = oldCookieName
|
|
config.Config.App.SessionSecret = oldSecret
|
|
config.Config.App.SessionDomain = oldDomain
|
|
config.Config.App.SessionSecure = oldSecure
|
|
config.Config.App.SessionHTTPOnly = oldHTTPOnly
|
|
})
|
|
|
|
config.Config.App.SessionCookieName = "test_session_id"
|
|
config.Config.App.SessionSecret = "test_session_secret"
|
|
config.Config.App.SessionDomain = ""
|
|
config.Config.App.SessionSecure = false
|
|
config.Config.App.SessionHTTPOnly = true
|
|
|
|
store := cookie.NewStore([]byte(config.Config.App.SessionSecret))
|
|
store.Options(oauth.GetSessionOptions(3600))
|
|
r := testhelper.NewTestGinEngine(sessions.Sessions(config.Config.App.SessionCookieName, store))
|
|
|
|
api := r.Group("/api/v1")
|
|
api.POST("/user/register", Register)
|
|
api.POST("/user/login", Login)
|
|
api.GET("/user-info", oauth.LoginRequired(), oauth.UserInfo)
|
|
return r
|
|
}
|
|
|
|
func performUserRequest(r http.Handler, method, path string, body []byte, cookies []*http.Cookie) *httptest.ResponseRecorder {
|
|
var reader *bytes.Reader
|
|
if body != nil {
|
|
reader = bytes.NewReader(body)
|
|
} else {
|
|
reader = bytes.NewReader(nil)
|
|
}
|
|
|
|
req, _ := http.NewRequest(method, path, reader)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
for _, c := range cookies {
|
|
req.AddCookie(c)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
return w
|
|
}
|
|
|
|
func sessionCookieFromResponse(t *testing.T, w *httptest.ResponseRecorder) *http.Cookie {
|
|
t.Helper()
|
|
|
|
for _, c := range w.Result().Cookies() {
|
|
if c.Name == config.Config.App.SessionCookieName {
|
|
return c
|
|
}
|
|
}
|
|
t.Fatalf("sessionCookieFromResponse() did not find %q cookie", config.Config.App.SessionCookieName)
|
|
return nil
|
|
}
|
|
|
|
func basicUserInfoFromResponse(t *testing.T, w *httptest.ResponseRecorder) oauth.BasicUserInfo {
|
|
t.Helper()
|
|
|
|
var resp response.Any
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("basicUserInfoFromResponse() decode response failed: %v", err)
|
|
}
|
|
if resp.ErrorMsg != "" {
|
|
t.Fatalf("basicUserInfoFromResponse() error_msg = %q, want empty", resp.ErrorMsg)
|
|
}
|
|
data, _ := json.Marshal(resp.Data)
|
|
var info oauth.BasicUserInfo
|
|
if err := json.Unmarshal(data, &info); err != nil {
|
|
t.Fatalf("basicUserInfoFromResponse() decode data failed: %v", err)
|
|
}
|
|
return info
|
|
}
|
|
|
|
func TestEmailCooldownKeyIncludesScene(t *testing.T) {
|
|
email := "user@example.com"
|
|
|
|
loginKey := getEmailCooldownKey("login", email)
|
|
registerKey := getEmailCooldownKey("register", email)
|
|
if loginKey == registerKey {
|
|
t.Errorf("getEmailCooldownKey(%q, %q) = %q, want different key from register scene", "login", email, loginKey)
|
|
}
|
|
if want := "email_code:cooldown:login:user@example.com"; loginKey != want {
|
|
t.Errorf("getEmailCooldownKey(%q, %q) = %q, want %q", "login", email, loginKey, want)
|
|
}
|
|
}
|
|
|
|
func TestGenerateVerificationCode(t *testing.T) {
|
|
code, err := generateVerificationCode()
|
|
if err != nil {
|
|
t.Fatalf("generateVerificationCode() error = %v, want nil", err)
|
|
}
|
|
if len(code) != 6 {
|
|
t.Fatalf("generateVerificationCode() length = %d, want 6. Code: %q", len(code), code)
|
|
}
|
|
for _, r := range code {
|
|
if r < '0' || r > '9' {
|
|
t.Fatalf("generateVerificationCode() = %q, want only digits", code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRegisterCreatesAuthenticatedEncryptedUser(t *testing.T) {
|
|
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
|
|
defer cleanup()
|
|
|
|
// Enable registration for this test
|
|
dbConn.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeyRegistrationEnabled).Update("value", "true")
|
|
dbConn.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeyPasswordRegisterEnabled).Update("value", "true")
|
|
_ = repository.InvalidateAllSystemConfigCaches(context.Background())
|
|
|
|
router := setupUserTestRouter(t)
|
|
payload := registerRequest{
|
|
Username: "newuser",
|
|
Password: "newpassword123",
|
|
Nickname: "New User",
|
|
Email: "newuser@example.com",
|
|
}
|
|
body, _ := json.Marshal(payload)
|
|
|
|
w := performUserRequest(router, http.MethodPost, "/api/v1/user/register", body, nil)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("Register(%q) status = %d, want %d. Body: %s", payload.Username, w.Code, http.StatusOK, w.Body.String())
|
|
}
|
|
info := basicUserInfoFromResponse(t, w)
|
|
if info.NeedChangePassword {
|
|
t.Errorf("Register(%q) need_change_password = true, want false", payload.Username)
|
|
}
|
|
|
|
var dbUser model.User
|
|
if err := dbConn.Where("username = ?", payload.Username).First(&dbUser).Error; err != nil {
|
|
t.Fatalf("Register(%q) db lookup failed: %v", payload.Username, err)
|
|
}
|
|
if dbUser.ID < 1000 {
|
|
t.Errorf("Register(%q) user ID = %d, want generated snowflake ID", payload.Username, dbUser.ID)
|
|
}
|
|
if !dbUser.IsPasswordEncrypted() {
|
|
t.Errorf("Register(%q) stored plaintext password, want encrypted password", payload.Username)
|
|
}
|
|
if !dbUser.CheckPassword(payload.Password) {
|
|
t.Errorf("Register(%q) stored password does not match original password", payload.Username)
|
|
}
|
|
|
|
sessionCookie := sessionCookieFromResponse(t, w)
|
|
w = performUserRequest(router, http.MethodGet, "/api/v1/user-info", nil, []*http.Cookie{sessionCookie})
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("UserInfo() after Register(%q) status = %d, want %d. Body: %s", payload.Username, w.Code, http.StatusOK, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestLoginRequiresPasswordChangeForInitialPlaintextAdmin(t *testing.T) {
|
|
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
|
|
defer cleanup()
|
|
|
|
const (
|
|
adminID = uint64(1)
|
|
adminUsername = "admin"
|
|
adminPassword = "12345678"
|
|
)
|
|
now := time.Now()
|
|
if err := dbConn.Exec(
|
|
`INSERT INTO w_users (id, username, password, nickname, is_active, is_admin, last_login_at, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
adminID,
|
|
adminUsername,
|
|
adminPassword,
|
|
"Administrator",
|
|
true,
|
|
true,
|
|
now,
|
|
now,
|
|
now,
|
|
).Error; err != nil {
|
|
t.Fatalf("seed initial admin failed: %v", err)
|
|
}
|
|
|
|
router := setupUserTestRouter(t)
|
|
payload := loginRequest{
|
|
Username: adminUsername,
|
|
Password: adminPassword,
|
|
}
|
|
body, _ := json.Marshal(payload)
|
|
|
|
w := performUserRequest(router, http.MethodPost, "/api/v1/user/login", body, nil)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("Login(%q) status = %d, want %d. Body: %s", adminUsername, w.Code, http.StatusOK, w.Body.String())
|
|
}
|
|
info := basicUserInfoFromResponse(t, w)
|
|
if !info.NeedChangePassword {
|
|
t.Errorf("Login(%q) need_change_password = false, want true", adminUsername)
|
|
}
|
|
|
|
var dbUser model.User
|
|
if err := dbConn.Where("username = ?", adminUsername).First(&dbUser).Error; err != nil {
|
|
t.Fatalf("Login(%q) db lookup failed: %v", adminUsername, err)
|
|
}
|
|
if dbUser.ID != adminID {
|
|
t.Errorf("Login(%q) user ID = %d, want %d", adminUsername, dbUser.ID, adminID)
|
|
}
|
|
if dbUser.IsPasswordEncrypted() {
|
|
t.Errorf("Login(%q) encrypted password during login, want plaintext until password change", adminUsername)
|
|
}
|
|
if !dbUser.CheckPassword(adminPassword) {
|
|
t.Errorf("Login(%q) stored password does not match original password", adminUsername)
|
|
}
|
|
|
|
sessionCookie := sessionCookieFromResponse(t, w)
|
|
w = performUserRequest(router, http.MethodGet, "/api/v1/user-info", nil, []*http.Cookie{sessionCookie})
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("UserInfo() after Login(%q) status = %d, want %d. Body: %s", adminUsername, w.Code, http.StatusOK, w.Body.String())
|
|
}
|
|
info = basicUserInfoFromResponse(t, w)
|
|
if !info.NeedChangePassword {
|
|
t.Errorf("UserInfo() after Login(%q) need_change_password = false, want true", adminUsername)
|
|
}
|
|
}
|
|
|
|
func TestLoginEmailVerificationFallbackWhenSMTPUnconfigured(t *testing.T) {
|
|
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
|
|
defer cleanup()
|
|
|
|
const (
|
|
userID = uint64(222)
|
|
username = "smtpuser"
|
|
password = "newpassword123"
|
|
email = "smtpuser@example.com"
|
|
)
|
|
now := time.Now()
|
|
user := model.User{
|
|
ID: userID,
|
|
Username: username,
|
|
Nickname: "SMTP User",
|
|
Email: email,
|
|
IsActive: true,
|
|
IsAdmin: false,
|
|
LastLoginAt: now,
|
|
}
|
|
if err := user.SetEncryptedPassword(password); err != nil {
|
|
t.Fatalf("set encrypted password failed: %v", err)
|
|
}
|
|
if err := dbConn.Create(&user).Error; err != nil {
|
|
t.Fatalf("create test user failed: %v", err)
|
|
}
|
|
|
|
// 1. Enable email login verification
|
|
if err := dbConn.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeyEmailLoginVerificationEnabled).Update("value", "true").Error; err != nil {
|
|
t.Fatalf("enable email login verification failed: %v", err)
|
|
}
|
|
// 2. Clear SMTP host to simulate unconfigured SMTP
|
|
if err := dbConn.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeySMTPHost).Update("value", "").Error; err != nil {
|
|
t.Fatalf("clear SMTP host failed: %v", err)
|
|
}
|
|
|
|
// 2.5 Invalidate the system config cache in Redis
|
|
if err := db.Redis.Del(context.Background(), db.PrefixedKey(repository.SystemConfigRedisHashKey)).Err(); err != nil {
|
|
t.Fatalf("invalidate system config cache failed: %v", err)
|
|
}
|
|
|
|
router := setupUserTestRouter(t)
|
|
|
|
// 3. Perform login request without verification code
|
|
payload := loginRequest{
|
|
Username: username,
|
|
Password: password,
|
|
}
|
|
body, _ := json.Marshal(payload)
|
|
|
|
w := performUserRequest(router, http.MethodPost, "/api/v1/user/login", body, nil)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("Login(%q) status = %d, want %d. Body: %s", username, w.Code, http.StatusBadRequest, w.Body.String())
|
|
}
|
|
|
|
// Check response error msg
|
|
var resp struct {
|
|
ErrorMsg string `json:"error_msg"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("unmarshal response failed: %v", err)
|
|
}
|
|
expectedError := errSMTPInvalidUseTempCodePrefix + errSMTPInvalidUseTempCode
|
|
if resp.ErrorMsg != expectedError {
|
|
t.Errorf("expected error %q, got %q", expectedError, resp.ErrorMsg)
|
|
}
|
|
|
|
// 4. Check that verification code stored in Redis is "888888"
|
|
ctx := context.Background()
|
|
codeKey := getEmailCodeKey("login", email)
|
|
var storedCode string
|
|
if err := db.GetJSON(ctx, codeKey, &storedCode); err != nil {
|
|
t.Fatalf("get stored verification code failed: %v", err)
|
|
}
|
|
if storedCode != "888888" {
|
|
t.Errorf("expected verification code '888888', got %q", storedCode)
|
|
}
|
|
|
|
// 5. Retry login with code "888888"
|
|
payload.Code = "888888"
|
|
bodyWithCode, _ := json.Marshal(payload)
|
|
w = performUserRequest(router, http.MethodPost, "/api/v1/user/login", bodyWithCode, nil)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("Login with code status = %d, want %d. Body: %s", w.Code, http.StatusOK, w.Body.String())
|
|
}
|
|
|
|
var successResp struct {
|
|
ErrorMsg string `json:"error_msg"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &successResp); err != nil {
|
|
t.Fatalf("unmarshal success response failed: %v", err)
|
|
}
|
|
if successResp.ErrorMsg != "" {
|
|
t.Errorf("expected login success, got error %q", successResp.ErrorMsg)
|
|
}
|
|
}
|
|
|
|
func TestLoginEmailVerificationFallbackForEmptyEmail(t *testing.T) {
|
|
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
|
|
defer cleanup()
|
|
|
|
const (
|
|
userID = uint64(223)
|
|
username = "emptyemailuser"
|
|
password = "newpassword123"
|
|
email = ""
|
|
)
|
|
now := time.Now()
|
|
user := model.User{
|
|
ID: userID,
|
|
Username: username,
|
|
Nickname: "Empty Email User",
|
|
Email: email,
|
|
IsActive: true,
|
|
IsAdmin: true,
|
|
LastLoginAt: now,
|
|
}
|
|
if err := user.SetEncryptedPassword(password); err != nil {
|
|
t.Fatalf("set encrypted password failed: %v", err)
|
|
}
|
|
if err := dbConn.Create(&user).Error; err != nil {
|
|
t.Fatalf("create test user failed: %v", err)
|
|
}
|
|
|
|
// 1. Enable email login verification
|
|
if err := dbConn.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeyEmailLoginVerificationEnabled).Update("value", "true").Error; err != nil {
|
|
t.Fatalf("enable email login verification failed: %v", err)
|
|
}
|
|
// 2. Make sure SMTP is configured so we only trigger empty email check
|
|
if err := dbConn.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeySMTPHost).Update("value", "smtp.example.com").Error; err != nil {
|
|
t.Fatalf("set SMTP host failed: %v", err)
|
|
}
|
|
if err := dbConn.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeySMTPPort).Update("value", "587").Error; err != nil {
|
|
t.Fatalf("set SMTP port failed: %v", err)
|
|
}
|
|
if err := dbConn.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeySMTPUsername).Update("value", "smtpuser").Error; err != nil {
|
|
t.Fatalf("set SMTP username failed: %v", err)
|
|
}
|
|
if err := dbConn.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeySMTPPassword).Update("value", "smtppassword").Error; err != nil {
|
|
t.Fatalf("set SMTP password failed: %v", err)
|
|
}
|
|
|
|
// Invalidate the system config cache in Redis
|
|
if err := db.Redis.Del(context.Background(), db.PrefixedKey(repository.SystemConfigRedisHashKey)).Err(); err != nil {
|
|
t.Fatalf("invalidate system config cache failed: %v", err)
|
|
}
|
|
|
|
router := setupUserTestRouter(t)
|
|
|
|
// 3. Perform login request without verification code
|
|
payload := loginRequest{
|
|
Username: username,
|
|
Password: password,
|
|
}
|
|
body, _ := json.Marshal(payload)
|
|
|
|
w := performUserRequest(router, http.MethodPost, "/api/v1/user/login", body, nil)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("Login(%q) status = %d, want %d. Body: %s", username, w.Code, http.StatusBadRequest, w.Body.String())
|
|
}
|
|
|
|
// Check response error msg
|
|
var resp struct {
|
|
ErrorMsg string `json:"error_msg"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("unmarshal response failed: %v", err)
|
|
}
|
|
expectedError := errSMTPInvalidUseTempCodePrefix + "该账号未绑定邮箱,使用临时码登录"
|
|
if resp.ErrorMsg != expectedError {
|
|
t.Errorf("expected error %q, got %q", expectedError, resp.ErrorMsg)
|
|
}
|
|
|
|
// 4. Check that verification code stored in Redis is "888888"
|
|
ctx := context.Background()
|
|
codeKey := getEmailCodeKey("login", email)
|
|
var storedCode string
|
|
if err := db.GetJSON(ctx, codeKey, &storedCode); err != nil {
|
|
t.Fatalf("get stored verification code failed: %v", err)
|
|
}
|
|
if storedCode != "888888" {
|
|
t.Errorf("expected verification code '888888', got %q", storedCode)
|
|
}
|
|
|
|
// 5. Retry login with code "888888"
|
|
payload.Code = "888888"
|
|
bodyWithCode, _ := json.Marshal(payload)
|
|
w = performUserRequest(router, http.MethodPost, "/api/v1/user/login", bodyWithCode, nil)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("Login with code status = %d, want %d. Body: %s", w.Code, http.StatusOK, w.Body.String())
|
|
}
|
|
|
|
var successResp struct {
|
|
ErrorMsg string `json:"error_msg"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &successResp); err != nil {
|
|
t.Fatalf("unmarshal success response failed: %v", err)
|
|
}
|
|
if successResp.ErrorMsg != "" {
|
|
t.Errorf("expected login success, got error %q", successResp.ErrorMsg)
|
|
}
|
|
}
|
|
|
|
func TestAccessTokenEndpointsDisallowTokenAuth(t *testing.T) {
|
|
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
|
|
defer cleanup()
|
|
|
|
// 1. Seed a user
|
|
const (
|
|
userID = uint64(500)
|
|
username = "tokenuser"
|
|
password = "tokenpassword123"
|
|
)
|
|
now := time.Now()
|
|
userRecord := model.User{
|
|
ID: userID,
|
|
Username: username,
|
|
Nickname: "Token User",
|
|
Email: "tokenuser@example.com",
|
|
IsActive: true,
|
|
IsAdmin: true, // Make them an admin so we can test with is_admin=true token requests if needed
|
|
LastLoginAt: now,
|
|
}
|
|
if err := userRecord.SetEncryptedPassword(password); err != nil {
|
|
t.Fatalf("set encrypted password failed: %v", err)
|
|
}
|
|
if err := dbConn.Create(&userRecord).Error; err != nil {
|
|
t.Fatalf("create test user failed: %v", err)
|
|
}
|
|
|
|
// Seed an active AccessToken for this user
|
|
tokenStr, err := model.GenerateTokenString()
|
|
if err != nil {
|
|
t.Fatalf("generate token string failed: %v", err)
|
|
}
|
|
tokenHash := model.HashToken(tokenStr)
|
|
tokenRecord := model.AccessToken{
|
|
UserID: userID,
|
|
Name: "Test Token",
|
|
TokenHash: tokenHash,
|
|
MaskedToken: model.MaskTokenString(tokenStr),
|
|
IsAdmin: false,
|
|
}
|
|
if err := dbConn.Create(&tokenRecord).Error; err != nil {
|
|
t.Fatalf("create test access token failed: %v", err)
|
|
}
|
|
|
|
// 2. Set up router with access-token routes and oauth middlewares
|
|
store := cookie.NewStore([]byte("test_session_secret"))
|
|
r := testhelper.NewTestGinEngine(sessions.Sessions("test_session_id", store))
|
|
|
|
apiV1Router := r.Group("/api/v1")
|
|
userRouter := apiV1Router.Group("/user")
|
|
tokenRouter := userRouter.Group("/access-tokens")
|
|
tokenRouter.Use(oauth.LoginRequired(), oauth.DisallowTokenAuth())
|
|
{
|
|
tokenRouter.GET("", ListAccessTokens)
|
|
tokenRouter.POST("", CreateAccessToken)
|
|
tokenRouter.DELETE("/:id", DeleteAccessToken)
|
|
tokenRouter.POST("/:id/rotate", RotateAccessToken)
|
|
}
|
|
|
|
// 3. Test that accessing using an Access Token fails with 403 Forbidden
|
|
req, _ := http.NewRequest(http.MethodGet, "/api/v1/user/access-tokens", nil)
|
|
req.Header.Set("X-Access-Token", tokenStr)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusForbidden {
|
|
t.Errorf("expected status 403 Forbidden when accessing with Access Token, got %d. Body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp struct {
|
|
ErrorMsg string `json:"error_msg"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode response failed: %v", err)
|
|
}
|
|
if resp.ErrorMsg != oauth.ErrTokenAuthNotAllowed {
|
|
t.Errorf("expected error message %q, got %q", oauth.ErrTokenAuthNotAllowed, resp.ErrorMsg)
|
|
}
|
|
|
|
// 4. Test that accessing using a Session succeeds
|
|
sessionCookieStore := cookie.NewStore([]byte("test_session_secret"))
|
|
rSession := testhelper.NewTestGinEngine(sessions.Sessions("test_session_id", sessionCookieStore))
|
|
rSession.GET("/api/v1/user/access-tokens", oauth.LoginRequired(), oauth.DisallowTokenAuth(), ListAccessTokens)
|
|
|
|
// We can login/register or just mock the session handler to set user ID
|
|
rSession.GET("/mock-login", func(c *gin.Context) {
|
|
session := sessions.Default(c)
|
|
session.Set(oauth.UserIDKey, userID)
|
|
session.Set(oauth.UserNameKey, username)
|
|
session.Set(oauth.PasswordHashKey, userRecord.Password)
|
|
_ = session.Save()
|
|
c.String(http.StatusOK, "ok")
|
|
})
|
|
|
|
wMock := httptest.NewRecorder()
|
|
reqMock, _ := http.NewRequest(http.MethodGet, "/mock-login", nil)
|
|
rSession.ServeHTTP(wMock, reqMock)
|
|
cookieVal := wMock.Header().Get("Set-Cookie")
|
|
|
|
reqSession, _ := http.NewRequest(http.MethodGet, "/api/v1/user/access-tokens", nil)
|
|
reqSession.Header.Set("Cookie", cookieVal)
|
|
wSession := httptest.NewRecorder()
|
|
rSession.ServeHTTP(wSession, reqSession)
|
|
|
|
if wSession.Code != http.StatusOK {
|
|
t.Errorf("expected status 200 OK when accessing with Session, got %d. Body: %s", wSession.Code, wSession.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestChangePasswordRevocation(t *testing.T) {
|
|
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
|
|
defer cleanup()
|
|
|
|
// 1. Seed a user with a password
|
|
user := model.User{
|
|
ID: uint64(888),
|
|
Username: "revoketest",
|
|
Nickname: "Revoke Test",
|
|
IsActive: true,
|
|
}
|
|
if err := user.SetEncryptedPassword("oldpassword123"); err != nil {
|
|
t.Fatalf("set encrypted password failed: %v", err)
|
|
}
|
|
if err := dbConn.Create(&user).Error; err != nil {
|
|
t.Fatalf("create test user failed: %v", err)
|
|
}
|
|
|
|
// 2. Seed an active AccessToken for this user
|
|
tokenStr, err := model.GenerateTokenString()
|
|
if err != nil {
|
|
t.Fatalf("generate token string failed: %v", err)
|
|
}
|
|
tokenHash := model.HashToken(tokenStr)
|
|
tokenRecord := model.AccessToken{
|
|
UserID: user.ID,
|
|
Name: "Test Token",
|
|
TokenHash: tokenHash,
|
|
MaskedToken: model.MaskTokenString(tokenStr),
|
|
}
|
|
if err := dbConn.Create(&tokenRecord).Error; err != nil {
|
|
t.Fatalf("create test access token failed: %v", err)
|
|
}
|
|
|
|
// 3. Set up router
|
|
store := cookie.NewStore([]byte("test_session_secret"))
|
|
r := testhelper.NewTestGinEngine(sessions.Sessions("test_session_id", store))
|
|
|
|
r.GET("/mock-login", func(c *gin.Context) {
|
|
session := sessions.Default(c)
|
|
session.Set(oauth.UserIDKey, user.ID)
|
|
session.Set(oauth.UserNameKey, user.Username)
|
|
session.Set(oauth.PasswordHashKey, user.Password)
|
|
_ = session.Save()
|
|
c.String(http.StatusOK, "ok")
|
|
})
|
|
|
|
r.GET("/mock-old-session-login", func(c *gin.Context) {
|
|
session := sessions.Default(c)
|
|
session.Set(oauth.UserIDKey, user.ID)
|
|
session.Set(oauth.UserNameKey, user.Username)
|
|
session.Set(oauth.PasswordHashKey, "invalid_old_password_hash")
|
|
_ = session.Save()
|
|
c.String(http.StatusOK, "ok")
|
|
})
|
|
|
|
r.POST("/api/v1/user/change-password", oauth.LoginRequired(), ChangePassword)
|
|
r.GET("/api/v1/user/access-tokens", oauth.LoginRequired(), ListAccessTokens)
|
|
|
|
// 4. Perform mock login to get cookie
|
|
wMock := httptest.NewRecorder()
|
|
reqMock, _ := http.NewRequest(http.MethodGet, "/mock-login", nil)
|
|
r.ServeHTTP(wMock, reqMock)
|
|
cookieVal := wMock.Header().Get("Set-Cookie")
|
|
|
|
// 5. Test that session and token work initially
|
|
reqSession, _ := http.NewRequest(http.MethodGet, "/api/v1/user/access-tokens", nil)
|
|
reqSession.Header.Set("Cookie", cookieVal)
|
|
wSession := httptest.NewRecorder()
|
|
r.ServeHTTP(wSession, reqSession)
|
|
if wSession.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d", wSession.Code)
|
|
}
|
|
|
|
reqToken, _ := http.NewRequest(http.MethodGet, "/api/v1/user/access-tokens", nil)
|
|
reqToken.Header.Set("X-Access-Token", tokenStr)
|
|
wToken := httptest.NewRecorder()
|
|
r.ServeHTTP(wToken, reqToken)
|
|
if wToken.Code != http.StatusOK {
|
|
t.Errorf("expected 200 for token, got %d", wToken.Code)
|
|
}
|
|
|
|
// 6. Change password using the active session
|
|
reqBody := `{"old_password": "oldpassword123", "new_password": "newpassword12345"}`
|
|
reqChange, _ := http.NewRequest(http.MethodPost, "/api/v1/user/change-password", strings.NewReader(reqBody))
|
|
reqChange.Header.Set("Content-Type", "application/json")
|
|
reqChange.Header.Set("Cookie", cookieVal)
|
|
wChange := httptest.NewRecorder()
|
|
r.ServeHTTP(wChange, reqChange)
|
|
if wChange.Code != http.StatusOK {
|
|
t.Fatalf("expected change password to return 200, got %d. Body: %s", wChange.Code, wChange.Body.String())
|
|
}
|
|
|
|
// 7. Verification: The active session that performed change-password is now cleared (401)
|
|
reqSessionAfter, _ := http.NewRequest(http.MethodGet, "/api/v1/user/access-tokens", nil)
|
|
reqSessionAfter.Header.Set("Cookie", cookieVal)
|
|
wSessionAfter := httptest.NewRecorder()
|
|
r.ServeHTTP(wSessionAfter, reqSessionAfter)
|
|
if wSessionAfter.Code != http.StatusUnauthorized {
|
|
t.Errorf("expected session to be revoked (401), got %d", wSessionAfter.Code)
|
|
}
|
|
|
|
// 8. Verification: An old session (holding an outdated password hash) should be rejected (401)
|
|
wMockOld := httptest.NewRecorder()
|
|
reqMockOld, _ := http.NewRequest(http.MethodGet, "/mock-old-session-login", nil)
|
|
r.ServeHTTP(wMockOld, reqMockOld)
|
|
oldCookieVal := wMockOld.Header().Get("Set-Cookie")
|
|
|
|
reqOldSession, _ := http.NewRequest(http.MethodGet, "/api/v1/user/access-tokens", nil)
|
|
reqOldSession.Header.Set("Cookie", oldCookieVal)
|
|
wOldSession := httptest.NewRecorder()
|
|
r.ServeHTTP(wOldSession, reqOldSession)
|
|
if wOldSession.Code != http.StatusUnauthorized {
|
|
t.Errorf("expected old session with invalid hash to return 401, got %d", wOldSession.Code)
|
|
}
|
|
|
|
// 9. Verification: The Access Token should be deleted from DB and rejected (401)
|
|
reqTokenAfter, _ := http.NewRequest(http.MethodGet, "/api/v1/user/access-tokens", nil)
|
|
reqTokenAfter.Header.Set("X-Access-Token", tokenStr)
|
|
wTokenAfter := httptest.NewRecorder()
|
|
r.ServeHTTP(wTokenAfter, reqTokenAfter)
|
|
if wTokenAfter.Code != http.StatusUnauthorized {
|
|
t.Errorf("expected access token to be revoked (401), got %d", wTokenAfter.Code)
|
|
}
|
|
}
|