fix(n+1): 批量查询替代循环单查

- IsAdminBootstrapRequired: userRepo.GetByID 循环 → GetByIDs 批量
- AssignRoles: roleRepo.GetByID 循环 → GetByIDs 批量
- 在 userRepositoryInterface 补充 GetByIDs 方法签名
This commit is contained in:
2026-05-08 08:05:26 +08:00
parent 9b1cea246e
commit 2a18a6fb47
39 changed files with 3169 additions and 393 deletions

View File

@@ -730,6 +730,173 @@ func TestUserHandler_UpdateUser_AdminCanUpdateAnotherUser(t *testing.T) {
}
}
func TestUserHandler_UpdateUser_ProfileFieldsPersisted(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
registerUser(server.URL, "profileuser", "profileuser@test.com", "UserPass123!")
token := getToken(server.URL, "profileuser", "UserPass123!")
updatePayload := map[string]interface{}{
"nickname": "Profile Updated",
"gender": 1,
"birthday": "2026-03-15",
"region": "Hangzhou",
"bio": "Updated bio",
}
resp, body := doPut(server.URL+"/api/v1/users/1", token, updatePayload)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, resp.StatusCode, body)
}
var updateResult map[string]interface{}
if err := json.Unmarshal([]byte(body), &updateResult); err != nil {
t.Fatalf("failed to parse update response: %v", err)
}
updateData, ok := updateResult["data"].(map[string]interface{})
if !ok {
t.Fatalf("expected update response data, got %s", body)
}
if updateData["nickname"] != "Profile Updated" {
t.Fatalf("expected nickname to be updated, got %+v", updateData)
}
if updateData["gender"] != float64(1) {
t.Fatalf("expected gender=1, got %+v", updateData)
}
if updateData["region"] != "Hangzhou" {
t.Fatalf("expected region to be persisted, got %+v", updateData)
}
if updateData["bio"] != "Updated bio" {
t.Fatalf("expected bio to be persisted, got %+v", updateData)
}
updateBirthday, ok := updateData["birthday"].(string)
if !ok || updateBirthday == "" {
t.Fatalf("expected birthday in update response, got %+v", updateData)
}
parsedUpdateBirthday, err := time.Parse(time.RFC3339, updateBirthday)
if err != nil {
t.Fatalf("expected RFC3339 birthday, got %q: %v", updateBirthday, err)
}
if parsedUpdateBirthday.Format("2006-01-02") != "2026-03-15" {
t.Fatalf("expected birthday 2026-03-15, got %s", parsedUpdateBirthday.Format("2006-01-02"))
}
getResp, getBody := doGet(server.URL+"/api/v1/users/1", token)
defer getResp.Body.Close()
if getResp.StatusCode != http.StatusOK {
t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, getResp.StatusCode, getBody)
}
var getResult map[string]interface{}
if err := json.Unmarshal([]byte(getBody), &getResult); err != nil {
t.Fatalf("failed to parse get response: %v", err)
}
getData, ok := getResult["data"].(map[string]interface{})
if !ok {
t.Fatalf("expected get response data, got %s", getBody)
}
if getData["region"] != "Hangzhou" {
t.Fatalf("expected region in get response, got %+v", getData)
}
if getData["bio"] != "Updated bio" {
t.Fatalf("expected bio in get response, got %+v", getData)
}
}
func TestUserHandler_UpdatePassword_NonAdminCannotUpdateAnotherUser(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
registerUser(server.URL, "password-actor", "password-actor@test.com", "ActorPass123!")
registerUser(server.URL, "password-target", "password-target@test.com", "TargetPass123!")
actorToken := getToken(server.URL, "password-actor", "ActorPass123!")
if actorToken == "" {
t.Fatal("actor token should not be empty")
}
resp, body := doPut(server.URL+"/api/v1/users/2/password", actorToken, map[string]interface{}{
"old_password": "TargetPass123!",
"new_password": "ChangedByOther123!",
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected status %d, got %d, body: %s", http.StatusForbidden, resp.StatusCode, body)
}
oldLoginResp, oldLoginBody := doPost(server.URL+"/api/v1/auth/login", "", map[string]interface{}{
"account": "password-target",
"password": "TargetPass123!",
})
defer oldLoginResp.Body.Close()
if oldLoginResp.StatusCode != http.StatusOK {
t.Fatalf("expected target old password to remain valid, got %d, body: %s", oldLoginResp.StatusCode, oldLoginBody)
}
newLoginResp, _ := doPost(server.URL+"/api/v1/auth/login", "", map[string]interface{}{
"account": "password-target",
"password": "ChangedByOther123!",
})
defer newLoginResp.Body.Close()
if newLoginResp.StatusCode == http.StatusOK {
t.Fatal("expected unauthorized password change attempt to leave target password unchanged")
}
}
func TestUserHandler_UpdatePassword_AdminCanResetAnotherUser(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
t.Setenv("BOOTSTRAP_SECRET", "handler-bootstrap-secret")
adminToken := bootstrapAdmin(server.URL, "handler-bootstrap-secret", "passwordadmin", "passwordadmin@test.com", "AdminPass123!")
registerUser(server.URL, "password-target", "password-target@test.com", "TargetPass123!")
if adminToken == "" {
t.Fatal("bootstrap admin should return access token")
}
resp, body := doPut(server.URL+"/api/v1/users/2/password", adminToken, map[string]interface{}{
"new_password": "AdminReset123!",
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, resp.StatusCode, body)
}
oldLoginResp, _ := doPost(server.URL+"/api/v1/auth/login", "", map[string]interface{}{
"account": "password-target",
"password": "TargetPass123!",
})
defer oldLoginResp.Body.Close()
if oldLoginResp.StatusCode == http.StatusOK {
t.Fatal("expected old password to be invalid after admin reset")
}
newLoginResp, newLoginBody := doPost(server.URL+"/api/v1/auth/login", "", map[string]interface{}{
"account": "password-target",
"password": "AdminReset123!",
})
defer newLoginResp.Body.Close()
if newLoginResp.StatusCode != http.StatusOK {
t.Fatalf("expected reset password to work, got %d, body: %s", newLoginResp.StatusCode, newLoginBody)
}
}
func TestUserHandler_DeleteUser_NonAdmin_Forbidden(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
@@ -958,6 +1125,218 @@ func TestDeviceHandler_CreateDevice_Success(t *testing.T) {
}
}
func createDeviceForHandlerTest(t *testing.T, baseURL, token, deviceID, deviceName string) int64 {
t.Helper()
resp, body := doPost(baseURL+"/api/v1/devices", token, map[string]interface{}{
"device_id": deviceID,
"device_name": deviceName,
"device_type": 1,
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("expected device create status %d, got %d, body: %s", http.StatusCreated, resp.StatusCode, body)
}
var result map[string]interface{}
if err := json.Unmarshal([]byte(body), &result); err != nil {
t.Fatalf("parse create device response failed: %v", err)
}
data, ok := result["data"].(map[string]interface{})
if !ok {
t.Fatalf("expected device payload, got body: %s", body)
}
id, ok := data["id"].(float64)
if !ok {
t.Fatalf("expected numeric device id, got body: %s", body)
}
return int64(id)
}
func TestDeviceHandler_GetDevice_IDOR_Forbidden(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
registerUser(server.URL, "deviceidor_get_actor", "deviceidor_get_actor@test.com", "UserPass123!")
registerUser(server.URL, "deviceidor_get_owner", "deviceidor_get_owner@test.com", "UserPass123!")
actorToken := getToken(server.URL, "deviceidor_get_actor", "UserPass123!")
ownerToken := getToken(server.URL, "deviceidor_get_owner", "UserPass123!")
deviceID := createDeviceForHandlerTest(t, server.URL, ownerToken, "device-idor-get", "Owner Device")
resp, body := doGet(fmt.Sprintf("%s/api/v1/devices/%d", server.URL, deviceID), actorToken)
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected status %d for cross-user device read, got %d, body: %s", http.StatusForbidden, resp.StatusCode, body)
}
}
func TestDeviceHandler_UpdateDevice_IDOR_Forbidden(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
registerUser(server.URL, "deviceidor_update_actor", "deviceidor_update_actor@test.com", "UserPass123!")
registerUser(server.URL, "deviceidor_update_owner", "deviceidor_update_owner@test.com", "UserPass123!")
actorToken := getToken(server.URL, "deviceidor_update_actor", "UserPass123!")
ownerToken := getToken(server.URL, "deviceidor_update_owner", "UserPass123!")
deviceID := createDeviceForHandlerTest(t, server.URL, ownerToken, "device-idor-update", "Original Device")
resp, body := doPut(fmt.Sprintf("%s/api/v1/devices/%d", server.URL, deviceID), actorToken, map[string]interface{}{
"device_name": "Hacked Device",
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected status %d for cross-user device update, got %d, body: %s", http.StatusForbidden, resp.StatusCode, body)
}
ownerResp, ownerBody := doGet(fmt.Sprintf("%s/api/v1/devices/%d", server.URL, deviceID), ownerToken)
defer ownerResp.Body.Close()
if ownerResp.StatusCode != http.StatusOK {
t.Fatalf("expected owner device read status %d, got %d, body: %s", http.StatusOK, ownerResp.StatusCode, ownerBody)
}
if !bytes.Contains([]byte(ownerBody), []byte("Original Device")) {
t.Fatalf("expected device name to remain unchanged, body: %s", ownerBody)
}
}
func TestDeviceHandler_DeleteDevice_IDOR_Forbidden(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
registerUser(server.URL, "deviceidor_delete_actor", "deviceidor_delete_actor@test.com", "UserPass123!")
registerUser(server.URL, "deviceidor_delete_owner", "deviceidor_delete_owner@test.com", "UserPass123!")
actorToken := getToken(server.URL, "deviceidor_delete_actor", "UserPass123!")
ownerToken := getToken(server.URL, "deviceidor_delete_owner", "UserPass123!")
deviceID := createDeviceForHandlerTest(t, server.URL, ownerToken, "device-idor-delete", "Delete Target")
resp, body := doDelete(fmt.Sprintf("%s/api/v1/devices/%d", server.URL, deviceID), actorToken)
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected status %d for cross-user device delete, got %d, body: %s", http.StatusForbidden, resp.StatusCode, body)
}
ownerResp, ownerBody := doGet(fmt.Sprintf("%s/api/v1/devices/%d", server.URL, deviceID), ownerToken)
defer ownerResp.Body.Close()
if ownerResp.StatusCode != http.StatusOK {
t.Fatalf("expected device to remain after forbidden delete, got %d, body: %s", ownerResp.StatusCode, ownerBody)
}
}
func TestDeviceHandler_TrustDevice_IDOR_Forbidden(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
registerUser(server.URL, "deviceidor_trust_actor", "deviceidor_trust_actor@test.com", "UserPass123!")
registerUser(server.URL, "deviceidor_trust_owner", "deviceidor_trust_owner@test.com", "UserPass123!")
actorToken := getToken(server.URL, "deviceidor_trust_actor", "UserPass123!")
ownerToken := getToken(server.URL, "deviceidor_trust_owner", "UserPass123!")
deviceID := createDeviceForHandlerTest(t, server.URL, ownerToken, "device-idor-trust", "Trust Target")
resp, body := doPost(fmt.Sprintf("%s/api/v1/devices/%d/trust", server.URL, deviceID), actorToken, map[string]interface{}{
"trust_duration": "24h",
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected status %d for cross-user device trust, got %d, body: %s", http.StatusForbidden, resp.StatusCode, body)
}
ownerResp, ownerBody := doGet(fmt.Sprintf("%s/api/v1/devices/%d", server.URL, deviceID), ownerToken)
defer ownerResp.Body.Close()
if ownerResp.StatusCode != http.StatusOK {
t.Fatalf("expected owner device read status %d, got %d, body: %s", http.StatusOK, ownerResp.StatusCode, ownerBody)
}
if bytes.Contains([]byte(ownerBody), []byte("\"is_trusted\":true")) {
t.Fatalf("expected forbidden trust to leave device untrusted, body: %s", ownerBody)
}
}
func TestDeviceHandler_UntrustDevice_IDOR_Forbidden(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
registerUser(server.URL, "deviceidor_untrust_actor", "deviceidor_untrust_actor@test.com", "UserPass123!")
registerUser(server.URL, "deviceidor_untrust_owner", "deviceidor_untrust_owner@test.com", "UserPass123!")
actorToken := getToken(server.URL, "deviceidor_untrust_actor", "UserPass123!")
ownerToken := getToken(server.URL, "deviceidor_untrust_owner", "UserPass123!")
deviceID := createDeviceForHandlerTest(t, server.URL, ownerToken, "device-idor-untrust", "Untrust Target")
trustResp, trustBody := doPost(fmt.Sprintf("%s/api/v1/devices/%d/trust", server.URL, deviceID), ownerToken, map[string]interface{}{
"trust_duration": "24h",
})
defer trustResp.Body.Close()
if trustResp.StatusCode != http.StatusOK {
t.Fatalf("expected owner trust status %d, got %d, body: %s", http.StatusOK, trustResp.StatusCode, trustBody)
}
resp, body := doDelete(fmt.Sprintf("%s/api/v1/devices/%d/trust", server.URL, deviceID), actorToken)
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected status %d for cross-user device untrust, got %d, body: %s", http.StatusForbidden, resp.StatusCode, body)
}
ownerResp, ownerBody := doGet(fmt.Sprintf("%s/api/v1/devices/%d", server.URL, deviceID), ownerToken)
defer ownerResp.Body.Close()
if ownerResp.StatusCode != http.StatusOK {
t.Fatalf("expected owner device read status %d, got %d, body: %s", http.StatusOK, ownerResp.StatusCode, ownerBody)
}
if !bytes.Contains([]byte(ownerBody), []byte("\"is_trusted\":true")) {
t.Fatalf("expected forbidden untrust to leave trusted device unchanged, body: %s", ownerBody)
}
}
func TestDeviceHandler_UpdateDeviceStatus_IDOR_Forbidden(t *testing.T) {
server, cleanup := setupHandlerTestServer(t)
defer cleanup()
registerUser(server.URL, "deviceidor_status_actor", "deviceidor_status_actor@test.com", "UserPass123!")
registerUser(server.URL, "deviceidor_status_owner", "deviceidor_status_owner@test.com", "UserPass123!")
actorToken := getToken(server.URL, "deviceidor_status_actor", "UserPass123!")
ownerToken := getToken(server.URL, "deviceidor_status_owner", "UserPass123!")
deviceID := createDeviceForHandlerTest(t, server.URL, ownerToken, "device-idor-status", "Status Target")
resp, body := doPut(fmt.Sprintf("%s/api/v1/devices/%d/status", server.URL, deviceID), actorToken, map[string]interface{}{
"status": "inactive",
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected status %d for cross-user device status update, got %d, body: %s", http.StatusForbidden, resp.StatusCode, body)
}
ownerResp, ownerBody := doGet(fmt.Sprintf("%s/api/v1/devices/%d", server.URL, deviceID), ownerToken)
defer ownerResp.Body.Close()
if ownerResp.StatusCode != http.StatusOK {
t.Fatalf("expected owner device read status %d, got %d, body: %s", http.StatusOK, ownerResp.StatusCode, ownerBody)
}
if !bytes.Contains([]byte(ownerBody), []byte("\"status\":1")) {
t.Fatalf("expected forbidden status update to leave device active, body: %s", ownerBody)
}
}
// =============================================================================
// Role Handler Tests
// =============================================================================