feat: backend core - auth, user, role, permission, device, webhook, monitoring, cache, repository, service, middleware, API handlers

This commit is contained in:
2026-04-02 11:19:50 +08:00
parent e59a77bc49
commit dcc1f186f8
298 changed files with 62603 additions and 0 deletions

View File

@@ -0,0 +1,466 @@
package repository
import (
"context"
"testing"
"time"
"gorm.io/gorm"
"github.com/user-management-system/internal/domain"
)
func migrateRepositoryTables(t *testing.T, db *gorm.DB, tables ...interface{}) {
t.Helper()
if err := db.AutoMigrate(tables...); err != nil {
t.Fatalf("migrate repository tables failed: %v", err)
}
}
func int64Ptr(v int64) *int64 {
return &v
}
func TestDeviceRepositoryLifecycleAndQueries(t *testing.T) {
db := openTestDB(t)
migrateRepositoryTables(t, db, &domain.Device{})
repo := NewDeviceRepository(db)
ctx := context.Background()
now := time.Now().UTC()
devices := []*domain.Device{
{
UserID: 1,
DeviceID: "device-alpha",
DeviceName: "Alpha",
DeviceType: domain.DeviceTypeDesktop,
DeviceOS: "Windows",
DeviceBrowser: "Chrome",
IP: "10.0.0.1",
Location: "Shanghai",
Status: domain.DeviceStatusActive,
LastActiveTime: now.Add(-1 * time.Hour),
},
{
UserID: 1,
DeviceID: "device-beta",
DeviceName: "Beta",
DeviceType: domain.DeviceTypeWeb,
DeviceOS: "macOS",
DeviceBrowser: "Safari",
IP: "10.0.0.2",
Location: "Hangzhou",
Status: domain.DeviceStatusInactive,
LastActiveTime: now.Add(-2 * time.Hour),
},
{
UserID: 2,
DeviceID: "device-gamma",
DeviceName: "Gamma",
DeviceType: domain.DeviceTypeMobile,
DeviceOS: "Android",
DeviceBrowser: "WebView",
IP: "10.0.0.3",
Location: "Beijing",
Status: domain.DeviceStatusActive,
LastActiveTime: now.Add(-40 * 24 * time.Hour),
},
}
for _, device := range devices {
if err := repo.Create(ctx, device); err != nil {
t.Fatalf("Create(%s) failed: %v", device.DeviceID, err)
}
}
if allDevices, total, err := repo.List(ctx, 0, 10); err != nil {
t.Fatalf("List failed: %v", err)
} else if total != 3 || len(allDevices) != 3 {
t.Fatalf("expected 3 devices, got total=%d len=%d", total, len(allDevices))
}
loadedByDeviceID, err := repo.GetByDeviceID(ctx, 1, "device-beta")
if err != nil {
t.Fatalf("GetByDeviceID failed: %v", err)
}
if loadedByDeviceID.DeviceName != "Beta" {
t.Fatalf("expected device name Beta, got %q", loadedByDeviceID.DeviceName)
}
exists, err := repo.Exists(ctx, 1, "device-alpha")
if err != nil {
t.Fatalf("Exists(device-alpha) failed: %v", err)
}
if !exists {
t.Fatal("expected device-alpha to exist")
}
missing, err := repo.Exists(ctx, 1, "missing-device")
if err != nil {
t.Fatalf("Exists(missing-device) failed: %v", err)
}
if missing {
t.Fatal("expected missing-device to be absent")
}
userDevices, total, err := repo.ListByUserID(ctx, 1, 0, 10)
if err != nil {
t.Fatalf("ListByUserID failed: %v", err)
}
if total != 2 || len(userDevices) != 2 {
t.Fatalf("expected 2 devices for user 1, got total=%d len=%d", total, len(userDevices))
}
if userDevices[0].DeviceID != "device-alpha" {
t.Fatalf("expected latest active device first, got %q", userDevices[0].DeviceID)
}
activeDevices, total, err := repo.ListByStatus(ctx, domain.DeviceStatusActive, 0, 10)
if err != nil {
t.Fatalf("ListByStatus failed: %v", err)
}
if total != 2 || len(activeDevices) != 2 {
t.Fatalf("expected 2 active devices, got total=%d len=%d", total, len(activeDevices))
}
if err := repo.UpdateStatus(ctx, devices[1].ID, domain.DeviceStatusActive); err != nil {
t.Fatalf("UpdateStatus failed: %v", err)
}
beforeTouch, err := repo.GetByID(ctx, devices[1].ID)
if err != nil {
t.Fatalf("GetByID before UpdateLastActiveTime failed: %v", err)
}
time.Sleep(10 * time.Millisecond)
if err := repo.UpdateLastActiveTime(ctx, devices[1].ID); err != nil {
t.Fatalf("UpdateLastActiveTime failed: %v", err)
}
afterTouch, err := repo.GetByID(ctx, devices[1].ID)
if err != nil {
t.Fatalf("GetByID after UpdateLastActiveTime failed: %v", err)
}
if !afterTouch.LastActiveTime.After(beforeTouch.LastActiveTime) {
t.Fatal("expected last_active_time to move forward")
}
recentDevices, err := repo.GetActiveDevices(ctx, 1)
if err != nil {
t.Fatalf("GetActiveDevices failed: %v", err)
}
if len(recentDevices) != 2 {
t.Fatalf("expected 2 recent devices for user 1, got %d", len(recentDevices))
}
if err := repo.DeleteByUserID(ctx, 1); err != nil {
t.Fatalf("DeleteByUserID failed: %v", err)
}
remainingDevices, remainingTotal, err := repo.List(ctx, 0, 10)
if err != nil {
t.Fatalf("List after DeleteByUserID failed: %v", err)
}
if remainingTotal != 1 || len(remainingDevices) != 1 {
t.Fatalf("expected 1 remaining device, got total=%d len=%d", remainingTotal, len(remainingDevices))
}
if err := repo.Delete(ctx, devices[2].ID); err != nil {
t.Fatalf("Delete failed: %v", err)
}
if _, err := repo.GetByID(ctx, devices[2].ID); err == nil {
t.Fatal("expected deleted device lookup to fail")
}
}
func TestLoginLogRepositoryQueriesAndRetention(t *testing.T) {
db := openTestDB(t)
migrateRepositoryTables(t, db, &domain.LoginLog{})
repo := NewLoginLogRepository(db)
ctx := context.Background()
now := time.Now().UTC()
logs := []*domain.LoginLog{
{
UserID: int64Ptr(1),
LoginType: int(domain.LoginTypePassword),
DeviceID: "device-alpha",
IP: "10.0.0.1",
Location: "Shanghai",
Status: 1,
CreatedAt: now.Add(-1 * time.Hour),
},
{
UserID: int64Ptr(1),
LoginType: int(domain.LoginTypeSMSCode),
DeviceID: "device-beta",
IP: "10.0.0.2",
Location: "Hangzhou",
Status: 0,
FailReason: "code expired",
CreatedAt: now.Add(-30 * time.Minute),
},
{
UserID: int64Ptr(2),
LoginType: int(domain.LoginTypeOAuth),
DeviceID: "device-gamma",
IP: "10.0.0.3",
Location: "Beijing",
Status: 1,
CreatedAt: now.Add(-45 * 24 * time.Hour),
},
}
for _, log := range logs {
if err := repo.Create(ctx, log); err != nil {
t.Fatalf("Create login log failed: %v", err)
}
}
loaded, err := repo.GetByID(ctx, logs[0].ID)
if err != nil {
t.Fatalf("GetByID failed: %v", err)
}
if loaded.DeviceID != "device-alpha" {
t.Fatalf("expected device-alpha, got %q", loaded.DeviceID)
}
userLogs, total, err := repo.ListByUserID(ctx, 1, 0, 10)
if err != nil {
t.Fatalf("ListByUserID failed: %v", err)
}
if total != 2 || len(userLogs) != 2 {
t.Fatalf("expected 2 user logs, got total=%d len=%d", total, len(userLogs))
}
if userLogs[0].DeviceID != "device-beta" {
t.Fatalf("expected newest login log first, got %q", userLogs[0].DeviceID)
}
allLogs, total, err := repo.List(ctx, 0, 10)
if err != nil {
t.Fatalf("List failed: %v", err)
}
if total != 3 || len(allLogs) != 3 {
t.Fatalf("expected 3 total logs, got total=%d len=%d", total, len(allLogs))
}
successLogs, total, err := repo.ListByStatus(ctx, 1, 0, 10)
if err != nil {
t.Fatalf("ListByStatus failed: %v", err)
}
if total != 2 || len(successLogs) != 2 {
t.Fatalf("expected 2 success logs, got total=%d len=%d", total, len(successLogs))
}
recentLogs, total, err := repo.ListByTimeRange(ctx, now.Add(-2*time.Hour), now, 0, 10)
if err != nil {
t.Fatalf("ListByTimeRange failed: %v", err)
}
if total != 2 || len(recentLogs) != 2 {
t.Fatalf("expected 2 recent logs, got total=%d len=%d", total, len(recentLogs))
}
if count := repo.CountByResultSince(ctx, true, now.Add(-2*time.Hour)); count != 1 {
t.Fatalf("expected 1 recent success login, got %d", count)
}
if count := repo.CountByResultSince(ctx, false, now.Add(-2*time.Hour)); count != 1 {
t.Fatalf("expected 1 recent failed login, got %d", count)
}
if err := repo.DeleteOlderThan(ctx, 30); err != nil {
t.Fatalf("DeleteOlderThan failed: %v", err)
}
retainedLogs, retainedTotal, err := repo.List(ctx, 0, 10)
if err != nil {
t.Fatalf("List after DeleteOlderThan failed: %v", err)
}
if retainedTotal != 2 || len(retainedLogs) != 2 {
t.Fatalf("expected 2 retained logs, got total=%d len=%d", retainedTotal, len(retainedLogs))
}
if err := repo.DeleteByUserID(ctx, 1); err != nil {
t.Fatalf("DeleteByUserID failed: %v", err)
}
finalLogs, finalTotal, err := repo.List(ctx, 0, 10)
if err != nil {
t.Fatalf("List after DeleteByUserID failed: %v", err)
}
if finalTotal != 0 || len(finalLogs) != 0 {
t.Fatalf("expected all logs removed, got total=%d len=%d", finalTotal, len(finalLogs))
}
}
func TestPasswordHistoryRepositoryKeepsNewestRecords(t *testing.T) {
db := openTestDB(t)
migrateRepositoryTables(t, db, &domain.PasswordHistory{})
repo := NewPasswordHistoryRepository(db)
ctx := context.Background()
now := time.Now().UTC()
histories := []*domain.PasswordHistory{
{UserID: 1, PasswordHash: "hash-1", CreatedAt: now.Add(-4 * time.Hour)},
{UserID: 1, PasswordHash: "hash-2", CreatedAt: now.Add(-3 * time.Hour)},
{UserID: 1, PasswordHash: "hash-3", CreatedAt: now.Add(-2 * time.Hour)},
{UserID: 1, PasswordHash: "hash-4", CreatedAt: now.Add(-1 * time.Hour)},
{UserID: 2, PasswordHash: "hash-foreign", CreatedAt: now.Add(-30 * time.Minute)},
}
for _, history := range histories {
if err := repo.Create(ctx, history); err != nil {
t.Fatalf("Create password history failed: %v", err)
}
}
latestTwo, err := repo.GetByUserID(ctx, 1, 2)
if err != nil {
t.Fatalf("GetByUserID(limit=2) failed: %v", err)
}
if len(latestTwo) != 2 {
t.Fatalf("expected 2 latest password histories, got %d", len(latestTwo))
}
if latestTwo[0].PasswordHash != "hash-4" || latestTwo[1].PasswordHash != "hash-3" {
t.Fatalf("expected newest password hashes to be retained, got %q and %q", latestTwo[0].PasswordHash, latestTwo[1].PasswordHash)
}
if err := repo.DeleteOldRecords(ctx, 1, 2); err != nil {
t.Fatalf("DeleteOldRecords failed: %v", err)
}
remainingHistories, err := repo.GetByUserID(ctx, 1, 10)
if err != nil {
t.Fatalf("GetByUserID after DeleteOldRecords failed: %v", err)
}
if len(remainingHistories) != 2 {
t.Fatalf("expected 2 remaining histories, got %d", len(remainingHistories))
}
if remainingHistories[0].PasswordHash != "hash-4" || remainingHistories[1].PasswordHash != "hash-3" {
t.Fatalf("unexpected remaining password hashes: %q and %q", remainingHistories[0].PasswordHash, remainingHistories[1].PasswordHash)
}
if err := repo.DeleteOldRecords(ctx, 999, 3); err != nil {
t.Fatalf("DeleteOldRecords for missing user failed: %v", err)
}
}
func TestOperationLogRepositorySearchAndRetention(t *testing.T) {
db := openTestDB(t)
migrateRepositoryTables(t, db, &domain.OperationLog{})
repo := NewOperationLogRepository(db)
ctx := context.Background()
now := time.Now().UTC()
logs := []*domain.OperationLog{
{
UserID: int64Ptr(1),
OperationType: "user",
OperationName: "create user",
RequestMethod: "POST",
RequestPath: "/api/v1/users",
RequestParams: `{"username":"alice"}`,
ResponseStatus: 201,
IP: "10.0.0.1",
UserAgent: "Chrome",
CreatedAt: now.Add(-20 * time.Minute),
},
{
UserID: int64Ptr(1),
OperationType: "dashboard",
OperationName: "view dashboard",
RequestMethod: "GET",
RequestPath: "/dashboard",
RequestParams: "{}",
ResponseStatus: 200,
IP: "10.0.0.2",
UserAgent: "Chrome",
CreatedAt: now.Add(-10 * time.Minute),
},
{
UserID: int64Ptr(2),
OperationType: "user",
OperationName: "delete user",
RequestMethod: "DELETE",
RequestPath: "/api/v1/users/7",
RequestParams: "{}",
ResponseStatus: 204,
IP: "10.0.0.3",
UserAgent: "Firefox",
CreatedAt: now.Add(-40 * 24 * time.Hour),
},
}
for _, log := range logs {
if err := repo.Create(ctx, log); err != nil {
t.Fatalf("Create operation log failed: %v", err)
}
}
loaded, err := repo.GetByID(ctx, logs[0].ID)
if err != nil {
t.Fatalf("GetByID failed: %v", err)
}
if loaded.OperationName != "create user" {
t.Fatalf("expected create user log, got %q", loaded.OperationName)
}
userLogs, total, err := repo.ListByUserID(ctx, 1, 0, 10)
if err != nil {
t.Fatalf("ListByUserID failed: %v", err)
}
if total != 2 || len(userLogs) != 2 {
t.Fatalf("expected 2 user operation logs, got total=%d len=%d", total, len(userLogs))
}
if userLogs[0].OperationName != "view dashboard" {
t.Fatalf("expected newest operation log first, got %q", userLogs[0].OperationName)
}
allLogs, total, err := repo.List(ctx, 0, 10)
if err != nil {
t.Fatalf("List failed: %v", err)
}
if total != 3 || len(allLogs) != 3 {
t.Fatalf("expected 3 total operation logs, got total=%d len=%d", total, len(allLogs))
}
postLogs, total, err := repo.ListByMethod(ctx, "POST", 0, 10)
if err != nil {
t.Fatalf("ListByMethod failed: %v", err)
}
if total != 1 || len(postLogs) != 1 || postLogs[0].OperationName != "create user" {
t.Fatalf("expected a single POST operation log, got total=%d len=%d", total, len(postLogs))
}
recentLogs, total, err := repo.ListByTimeRange(ctx, now.Add(-1*time.Hour), now, 0, 10)
if err != nil {
t.Fatalf("ListByTimeRange failed: %v", err)
}
if total != 2 || len(recentLogs) != 2 {
t.Fatalf("expected 2 recent operation logs, got total=%d len=%d", total, len(recentLogs))
}
searchResults, total, err := repo.Search(ctx, "user", 0, 10)
if err != nil {
t.Fatalf("Search failed: %v", err)
}
if total != 2 || len(searchResults) != 2 {
t.Fatalf("expected 2 operation logs matching user, got total=%d len=%d", total, len(searchResults))
}
if err := repo.DeleteOlderThan(ctx, 30); err != nil {
t.Fatalf("DeleteOlderThan failed: %v", err)
}
retainedLogs, retainedTotal, err := repo.List(ctx, 0, 10)
if err != nil {
t.Fatalf("List after DeleteOlderThan failed: %v", err)
}
if retainedTotal != 2 || len(retainedLogs) != 2 {
t.Fatalf("expected 2 retained operation logs, got total=%d len=%d", retainedTotal, len(retainedLogs))
}
}