fix: close auth, permission, contract and e2e review blockers
This commit is contained in:
@@ -7,7 +7,9 @@ import (
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -35,6 +37,11 @@ func setupHandlerTestServer(t *testing.T) (*httptest.Server, func()) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
previousBootstrapSecret, hadBootstrapSecret := os.LookupEnv("BOOTSTRAP_SECRET")
|
||||
if err := os.Setenv("BOOTSTRAP_SECRET", "test-bootstrap-secret"); err != nil {
|
||||
t.Fatalf("set bootstrap secret failed: %v", err)
|
||||
}
|
||||
|
||||
id := atomic.AddInt64(&handlerDbCounter, 1)
|
||||
dsn := fmt.Sprintf("file:handlerdb_%d_%s?mode=memory&cache=shared", id, t.Name())
|
||||
db, err := gorm.Open(gormsqlite.New(gormsqlite.Config{
|
||||
@@ -64,6 +71,20 @@ func setupHandlerTestServer(t *testing.T) (*httptest.Server, func()) {
|
||||
t.Fatalf("db migration failed: %v", err)
|
||||
}
|
||||
|
||||
adminRole := &domain.Role{Code: "admin", Name: "管理员", Status: domain.RoleStatusEnabled}
|
||||
if err := db.Create(adminRole).Error; err != nil {
|
||||
t.Fatalf("seed admin role failed: %v", err)
|
||||
}
|
||||
for _, permission := range domain.DefaultPermissions() {
|
||||
perm := permission
|
||||
if err := db.Create(&perm).Error; err != nil {
|
||||
t.Fatalf("seed permission %s failed: %v", perm.Code, err)
|
||||
}
|
||||
if err := db.Create(&domain.RolePermission{RoleID: adminRole.ID, PermissionID: perm.ID}).Error; err != nil {
|
||||
t.Fatalf("seed role permission %s failed: %v", perm.Code, err)
|
||||
}
|
||||
}
|
||||
|
||||
jwtManager, err := auth.NewJWTWithOptions(auth.JWTOptions{
|
||||
HS256Secret: "test-handler-secret-key",
|
||||
AccessTokenExpire: 15 * time.Minute,
|
||||
@@ -136,6 +157,11 @@ func setupHandlerTestServer(t *testing.T) (*httptest.Server, func()) {
|
||||
server := httptest.NewServer(engine)
|
||||
return server, func() {
|
||||
server.Close()
|
||||
if hadBootstrapSecret {
|
||||
_ = os.Setenv("BOOTSTRAP_SECRET", previousBootstrapSecret)
|
||||
} else {
|
||||
_ = os.Unsetenv("BOOTSTRAP_SECRET")
|
||||
}
|
||||
if sqlDB, _ := db.DB(); sqlDB != nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
@@ -207,6 +233,35 @@ func registerUser(baseURL, username, email, password string) bool {
|
||||
return resp.StatusCode == http.StatusCreated
|
||||
}
|
||||
|
||||
func bootstrapAdminToken(baseURL, username, email, password string) string {
|
||||
payload, _ := json.Marshal(map[string]interface{}{
|
||||
"username": username,
|
||||
"email": email,
|
||||
"password": password,
|
||||
})
|
||||
req, _ := http.NewRequest("POST", baseURL+"/api/v1/auth/bootstrap-admin", bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Bootstrap-Secret", "test-bootstrap-secret")
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
return ""
|
||||
}
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
||||
return ""
|
||||
}
|
||||
data, ok := result["data"].(map[string]interface{})
|
||||
if !ok || data["access_token"] == nil {
|
||||
return ""
|
||||
}
|
||||
return data["access_token"].(string)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Auth Handler Tests
|
||||
// =============================================================================
|
||||
@@ -292,6 +347,89 @@ func TestAuthHandler_Login_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandler_Login_SetsSessionCookies(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
registerUser(server.URL, "cookieuser", "cookie@example.com", "Password123!")
|
||||
resp, body := doPost(server.URL+"/api/v1/auth/login", "", map[string]interface{}{
|
||||
"account": "cookieuser",
|
||||
"password": "Password123!",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, resp.StatusCode, body)
|
||||
}
|
||||
|
||||
cookies := resp.Cookies()
|
||||
var hasRefreshCookie bool
|
||||
var hasPresenceCookie bool
|
||||
for _, cookie := range cookies {
|
||||
switch cookie.Name {
|
||||
case "ums_refresh_token":
|
||||
hasRefreshCookie = cookie.HttpOnly && cookie.Value != ""
|
||||
case "ums_session_present":
|
||||
hasPresenceCookie = !cookie.HttpOnly && cookie.Value == "1"
|
||||
}
|
||||
}
|
||||
if !hasRefreshCookie {
|
||||
t.Fatalf("expected login response to set ums_refresh_token cookie, got %#v", cookies)
|
||||
}
|
||||
if !hasPresenceCookie {
|
||||
t.Fatalf("expected login response to set ums_session_present cookie, got %#v", cookies)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandler_RefreshToken_UsesCookieFallback(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
registerUser(server.URL, "refreshcookieuser", "refreshcookie@example.com", "Password123!")
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("cookiejar.New() error: %v", err)
|
||||
}
|
||||
client := &http.Client{Jar: jar}
|
||||
|
||||
loginBody, _ := json.Marshal(map[string]interface{}{
|
||||
"account": "refreshcookieuser",
|
||||
"password": "Password123!",
|
||||
})
|
||||
loginReq, _ := http.NewRequest("POST", server.URL+"/api/v1/auth/login", bytes.NewReader(loginBody))
|
||||
loginReq.Header.Set("Content-Type", "application/json")
|
||||
loginResp, err := client.Do(loginReq)
|
||||
if err != nil {
|
||||
t.Fatalf("login request failed: %v", err)
|
||||
}
|
||||
defer loginResp.Body.Close()
|
||||
if loginResp.StatusCode != http.StatusOK {
|
||||
payload, _ := io.ReadAll(loginResp.Body)
|
||||
t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, loginResp.StatusCode, string(payload))
|
||||
}
|
||||
|
||||
refreshReq, _ := http.NewRequest("POST", server.URL+"/api/v1/auth/refresh", nil)
|
||||
refreshReq.Header.Set("Content-Type", "application/json")
|
||||
refreshResp, err := client.Do(refreshReq)
|
||||
if err != nil {
|
||||
t.Fatalf("refresh request failed: %v", err)
|
||||
}
|
||||
defer refreshResp.Body.Close()
|
||||
refreshPayload, _ := io.ReadAll(refreshResp.Body)
|
||||
if refreshResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, refreshResp.StatusCode, string(refreshPayload))
|
||||
}
|
||||
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(refreshPayload, &parsed); err != nil {
|
||||
t.Fatalf("refresh response json unmarshal failed: %v", err)
|
||||
}
|
||||
data, _ := parsed["data"].(map[string]interface{})
|
||||
if data == nil || data["access_token"] == nil || data["refresh_token"] == nil {
|
||||
t.Fatalf("expected refresh response to include token pair, got %v", parsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandler_Login_WrongPassword(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
@@ -336,33 +474,61 @@ func TestAuthHandler_BootstrapAdmin_MissingSecret(t *testing.T) {
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Without BOOTSTRAP_SECRET env var set, should get forbidden
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("expected status %d for missing bootstrap secret, got %d", http.StatusForbidden, resp.StatusCode)
|
||||
// P0 修复后:已配置 BOOTSTRAP_SECRET 但未提供 header,应返回 401
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("expected status %d for missing bootstrap secret header, got %d", http.StatusUnauthorized, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandler_GetAuthCapabilities(t *testing.T) {
|
||||
func TestAuthHandler_VerifyTOTPAfterPasswordLogin_RequiresTempToken(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
resp, body := doGet(server.URL+"/api/v1/auth/capabilities", "")
|
||||
resp, body := doPost(server.URL+"/api/v1/auth/login/totp-verify", "", map[string]interface{}{
|
||||
"user_id": 1,
|
||||
"code": "123456",
|
||||
"device_id": "device-1",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected status %d, got %d", http.StatusOK, resp.StatusCode)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
json.Unmarshal([]byte(body), &result)
|
||||
if result["code"] != float64(0) {
|
||||
t.Errorf("expected code 0, got %v", result["code"])
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("expected status %d, got %d, body: %s", http.StatusBadRequest, resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// User Handler Tests
|
||||
// =============================================================================
|
||||
func TestAuthHandler_UnconfiguredOAuthAndBindingsFailClosed(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
registerUser(server.URL, "failclosed", "failclosed@test.com", "AdminPass123!")
|
||||
token := getToken(server.URL, "failclosed", "AdminPass123!")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
body map[string]interface{}
|
||||
}{
|
||||
{name: "oauth login", url: server.URL + "/api/v1/auth/oauth/github"},
|
||||
{name: "email bind code", url: server.URL + "/api/v1/users/me/bind-email/code", body: map[string]interface{}{"email": "bind@example.com"}},
|
||||
{name: "social bind", url: server.URL + "/api/v1/users/me/bind-social", body: map[string]interface{}{"provider": "github"}},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var resp *http.Response
|
||||
var body string
|
||||
if tc.body == nil {
|
||||
resp, body = doGet(tc.url, token)
|
||||
} else {
|
||||
resp, body = doPost(tc.url, token, tc.body)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expected status %d, got %d, body: %s", http.StatusServiceUnavailable, resp.StatusCode, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserHandler_CreateUser_RequiresAdmin(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
@@ -400,39 +566,33 @@ func TestUserHandler_CreateUser_Unauthorized(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserHandler_ListUsers_Success(t *testing.T) {
|
||||
func TestUserHandler_ListUsers_ForbiddenForRegularUser(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
registerUser(server.URL, "listadmin", "listadmin@test.com", "AdminPass123!")
|
||||
token := getToken(server.URL, "listadmin", "AdminPass123!")
|
||||
registerUser(server.URL, "listuser", "listuser@test.com", "AdminPass123!")
|
||||
token := getToken(server.URL, "listuser", "AdminPass123!")
|
||||
|
||||
resp, body := doGet(server.URL+"/api/v1/users?page=1&page_size=10", token)
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected status %d, got %d, body: %s", http.StatusOK, resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
json.Unmarshal([]byte(body), &result)
|
||||
if result["code"] != float64(0) {
|
||||
t.Errorf("expected code 0, got %v", result["code"])
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("expected status %d, got %d, body: %s", http.StatusForbidden, resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserHandler_GetUser_Success(t *testing.T) {
|
||||
func TestUserHandler_GetUser_ForbiddenForRegularUser(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
registerUser(server.URL, "getadmin", "getadmin@test.com", "AdminPass123!")
|
||||
token := getToken(server.URL, "getadmin", "AdminPass123!")
|
||||
registerUser(server.URL, "getuser", "getuser@test.com", "AdminPass123!")
|
||||
token := getToken(server.URL, "getuser", "AdminPass123!")
|
||||
|
||||
resp, _ := doGet(server.URL+"/api/v1/users/1", token)
|
||||
resp, body := doGet(server.URL+"/api/v1/users/1", token)
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected status %d, got %d", http.StatusOK, resp.StatusCode)
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("expected status %d, got %d, body: %s", http.StatusForbidden, resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,8 +600,8 @@ func TestUserHandler_UpdateUser_Success(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
registerUser(server.URL, "updateadmin", "updateadmin@test.com", "AdminPass123!")
|
||||
token := getToken(server.URL, "updateadmin", "AdminPass123!")
|
||||
registerUser(server.URL, "updateuser", "update@example.com", "UserPass123!")
|
||||
token := getToken(server.URL, "updateuser", "UserPass123!")
|
||||
|
||||
resp, body := doPut(server.URL+"/api/v1/users/1", token, map[string]string{"nickname": "Updated Nickname"})
|
||||
defer resp.Body.Close()
|
||||
@@ -451,6 +611,43 @@ func TestUserHandler_UpdateUser_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateUser_AdminCanUpdateOther(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
token := bootstrapAdminToken(server.URL, "updateadmin", "updateadmin@test.com", "AdminPass123!")
|
||||
if token == "" {
|
||||
t.Fatal("bootstrap admin token should succeed")
|
||||
}
|
||||
registerUser(server.URL, "manageduser", "manageduser@test.com", "UserPass123!")
|
||||
|
||||
resp, body := doPut(server.URL+"/api/v1/users/2", token, map[string]string{"nickname": "Admin Updated"})
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected status %d, got %d, body: %s", http.StatusOK, resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdatePassword_NonAdminCannotUpdateOther(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
registerUser(server.URL, "pwd-user-1", "pwd-user-1@test.com", "UserPass123!")
|
||||
token := getToken(server.URL, "pwd-user-1", "UserPass123!")
|
||||
registerUser(server.URL, "pwd-user-2", "pwd-user-2@test.com", "TargetPass123!")
|
||||
|
||||
resp, body := doPut(server.URL+"/api/v1/users/2/password", token, map[string]string{
|
||||
"old_password": "TargetPass123!",
|
||||
"new_password": "TargetNew456!",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("expected status %d, got %d, body: %s", http.StatusForbidden, resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserHandler_DeleteUser_NonAdmin_Forbidden(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
@@ -471,8 +668,10 @@ func TestUserHandler_SearchUsers_Success(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
registerUser(server.URL, "searchadmin", "searchadmin@test.com", "AdminPass123!")
|
||||
token := getToken(server.URL, "searchadmin", "AdminPass123!")
|
||||
token := bootstrapAdminToken(server.URL, "searchadmin", "searchadmin@test.com", "AdminPass123!")
|
||||
if token == "" {
|
||||
t.Fatal("bootstrap admin token should succeed")
|
||||
}
|
||||
|
||||
resp, body := doGet(server.URL+"/api/v1/users/1", token)
|
||||
defer resp.Body.Close()
|
||||
@@ -515,6 +714,24 @@ func TestUserHandler_GetUserRoles_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserHandler_GetUserRoles_AdminCanViewOther(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
token := bootstrapAdminToken(server.URL, "rolesbootstrap", "rolesbootstrap@test.com", "AdminPass123!")
|
||||
if token == "" {
|
||||
t.Fatal("bootstrap admin token should succeed")
|
||||
}
|
||||
registerUser(server.URL, "role-target", "role-target@test.com", "UserPass123!")
|
||||
|
||||
resp, body := doGet(server.URL+"/api/v1/users/2/roles", token)
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected status %d, got %d, body: %s", http.StatusOK, resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserHandler_AssignRoles_RequiresAdmin(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
@@ -974,8 +1191,10 @@ func TestInvalidUserID_ReturnsBadRequest(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
registerUser(server.URL, "invalidid", "invalidid@test.com", "AdminPass123!")
|
||||
token := getToken(server.URL, "invalidid", "AdminPass123!")
|
||||
token := bootstrapAdminToken(server.URL, "invalidid", "invalidid@test.com", "AdminPass123!")
|
||||
if token == "" {
|
||||
t.Fatal("bootstrap admin token should succeed")
|
||||
}
|
||||
|
||||
resp, _ := doGet(server.URL+"/api/v1/users/invalid", token)
|
||||
defer resp.Body.Close()
|
||||
@@ -989,8 +1208,10 @@ func TestNonExistentUserID_ReturnsNotFound(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
registerUser(server.URL, "notfound", "notfound@test.com", "AdminPass123!")
|
||||
token := getToken(server.URL, "notfound", "AdminPass123!")
|
||||
token := bootstrapAdminToken(server.URL, "notfound", "notfound@test.com", "AdminPass123!")
|
||||
if token == "" {
|
||||
t.Fatal("bootstrap admin token should succeed")
|
||||
}
|
||||
|
||||
resp, _ := doGet(server.URL+"/api/v1/users/99999", token)
|
||||
defer resp.Body.Close()
|
||||
@@ -1350,6 +1571,29 @@ func TestAvatarHandler_UploadAvatar_NonAdminCannotUpdateOther(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvatarHandler_UploadAvatar_AdminCanUpdateOther(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
token := bootstrapAdminToken(server.URL, "avataradmin", "avataradmin@test.com", "AdminPass123!")
|
||||
if token == "" {
|
||||
t.Fatal("bootstrap admin token should succeed")
|
||||
}
|
||||
registerUser(server.URL, "avatar-target", "avatar-target@test.com", "UserPass123!")
|
||||
|
||||
fileContent := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
|
||||
resp, err := doUploadFile(server.URL+"/api/v1/users/2/avatar", token, "avatar", "test.png", fileContent)
|
||||
if err != nil {
|
||||
t.Fatalf("upload request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected status %d for admin updating other's avatar, got %d, body: %s", http.StatusOK, resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvatarHandler_UploadAvatar_UserNotFoundOrForbidden(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
Reference in New Issue
Block a user