3d47227fbd
Remove embedded frontend and workflow editor assets, add standalone API deployment configuration, and retain the current backend service updates.
489 lines
14 KiB
Go
489 lines
14 KiB
Go
package services
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"agent-desk/internal/models"
|
|
"agent-desk/internal/pkg/config"
|
|
"agent-desk/internal/pkg/dto/request"
|
|
"agent-desk/internal/pkg/enums"
|
|
"agent-desk/internal/pkg/errorsx"
|
|
|
|
"github.com/glebarez/sqlite"
|
|
"github.com/mlogclub/simple/sqls"
|
|
"github.com/mlogclub/simple/web"
|
|
"golang.org/x/crypto/bcrypt"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/schema"
|
|
)
|
|
|
|
func TestExtractBearerToken(t *testing.T) {
|
|
svc := newAuthService()
|
|
|
|
if got := svc.extractBearerToken("Bearer token_123"); got != "token_123" {
|
|
t.Fatalf("expected bearer token to be extracted, got %q", got)
|
|
}
|
|
|
|
if got := svc.extractBearerToken("token_123"); got != "" {
|
|
t.Fatalf("expected raw token to be rejected by bearer extractor, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestAuthServiceLoginCreatesSingleAccessSession(t *testing.T) {
|
|
db := setupAuthServiceTestDB(t)
|
|
user := createAuthTestUser(t, db, "admin", "secret")
|
|
svc := newAuthService()
|
|
|
|
ret, err := svc.Login(request.LoginRequest{
|
|
Username: " admin ",
|
|
Password: "secret",
|
|
}, config.AuthConfig{TokenTTLHours: 2, MaxFailedAttempts: 5, CredentialLockMinute: 15}, "127.0.0.1", "go-test")
|
|
if err != nil {
|
|
t.Fatalf("login failed: %v", err)
|
|
}
|
|
|
|
if ret.AccessToken == "" || !strings.HasPrefix(ret.AccessToken, "ak_") {
|
|
t.Fatalf("expected ak_ access token, got %q", ret.AccessToken)
|
|
}
|
|
if ret.ExpiresAt == "" {
|
|
t.Fatal("expected expiresAt to be returned")
|
|
}
|
|
|
|
var sessions []models.LoginSession
|
|
if err := db.Find(&sessions).Error; err != nil {
|
|
t.Fatalf("query login sessions: %v", err)
|
|
}
|
|
if len(sessions) != 1 {
|
|
t.Fatalf("expected exactly one session, got %d", len(sessions))
|
|
}
|
|
if sessions[0].Token != ret.AccessToken {
|
|
t.Fatalf("expected session token %q, got %q", ret.AccessToken, sessions[0].Token)
|
|
}
|
|
if sessions[0].UserID != user.ID {
|
|
t.Fatalf("expected session user %d, got %d", user.ID, sessions[0].UserID)
|
|
}
|
|
if sessions[0].ClientType != "admin_web" {
|
|
t.Fatalf("expected admin_web client type, got %q", sessions[0].ClientType)
|
|
}
|
|
|
|
logs := findCredentialLogs(t, db)
|
|
if len(logs) != 1 {
|
|
t.Fatalf("expected one credential log, got %d", len(logs))
|
|
}
|
|
if !logs[0].Success || logs[0].Principal != "admin" || logs[0].UserID != user.ID {
|
|
t.Fatalf("unexpected success credential log: %+v", logs[0])
|
|
}
|
|
}
|
|
|
|
func TestAuthServiceLoginFailureWritesCredentialLogs(t *testing.T) {
|
|
db := setupAuthServiceTestDB(t)
|
|
createAuthTestUser(t, db, "admin", "secret")
|
|
svc := newAuthService()
|
|
authCfg := config.AuthConfig{TokenTTLHours: 2, MaxFailedAttempts: 5, CredentialLockMinute: 15}
|
|
|
|
if _, err := svc.Login(request.LoginRequest{Username: "missing", Password: "secret"}, authCfg, "127.0.0.1", "go-test"); !hasCode(err, errorsx.CodeAuthInvalidAccount) {
|
|
t.Fatalf("expected invalid account for missing user, got %v", err)
|
|
}
|
|
if _, err := svc.Login(request.LoginRequest{Username: "admin", Password: "wrong"}, authCfg, "127.0.0.1", "go-test"); !hasCode(err, errorsx.CodeAuthInvalidAccount) {
|
|
t.Fatalf("expected invalid account for password mismatch, got %v", err)
|
|
}
|
|
|
|
logs := findCredentialLogs(t, db)
|
|
if len(logs) != 2 {
|
|
t.Fatalf("expected two credential logs, got %d", len(logs))
|
|
}
|
|
if logs[0].Reason != "user not found" || logs[0].Success {
|
|
t.Fatalf("unexpected missing-user log: %+v", logs[0])
|
|
}
|
|
if logs[1].Reason != "password mismatch" || logs[1].Success {
|
|
t.Fatalf("unexpected password-mismatch log: %+v", logs[1])
|
|
}
|
|
}
|
|
|
|
func TestAuthServiceLoginCredentialLockout(t *testing.T) {
|
|
db := setupAuthServiceTestDB(t)
|
|
user := createAuthTestUser(t, db, "admin", "secret")
|
|
now := time.Now()
|
|
for i := 0; i < 2; i++ {
|
|
if err := db.Create(&models.LoginCredentialLog{
|
|
Principal: "admin",
|
|
UserID: user.ID,
|
|
Success: false,
|
|
Reason: "password mismatch",
|
|
CreatedAt: now.Add(-time.Duration(i+1) * time.Minute),
|
|
}).Error; err != nil {
|
|
t.Fatalf("seed credential log: %v", err)
|
|
}
|
|
}
|
|
if err := db.Create(&models.LoginCredentialLog{
|
|
Principal: "admin",
|
|
UserID: user.ID,
|
|
Success: false,
|
|
Reason: "password mismatch",
|
|
CreatedAt: now.Add(-30 * time.Minute),
|
|
}).Error; err != nil {
|
|
t.Fatalf("seed old credential log: %v", err)
|
|
}
|
|
|
|
svc := newAuthService()
|
|
_, err := svc.Login(request.LoginRequest{Username: "admin", Password: "secret"}, config.AuthConfig{
|
|
TokenTTLHours: 2,
|
|
MaxFailedAttempts: 2,
|
|
CredentialLockMinute: 15,
|
|
}, "127.0.0.1", "go-test")
|
|
if !hasCode(err, errorsx.CodeAuthCredentialLocked) {
|
|
t.Fatalf("expected credential locked error, got %v", err)
|
|
}
|
|
|
|
var lockedLog models.LoginCredentialLog
|
|
if err := db.Order("id DESC").Take(&lockedLog).Error; err != nil {
|
|
t.Fatalf("query latest credential log: %v", err)
|
|
}
|
|
if lockedLog.Reason != "credential locked" || lockedLog.Success {
|
|
t.Fatalf("unexpected locked credential log: %+v", lockedLog)
|
|
}
|
|
|
|
var sessionCount int64
|
|
if err := db.Model(&models.LoginSession{}).Count(&sessionCount).Error; err != nil {
|
|
t.Fatalf("count sessions: %v", err)
|
|
}
|
|
if sessionCount != 0 {
|
|
t.Fatalf("expected no session while credential locked, got %d", sessionCount)
|
|
}
|
|
}
|
|
|
|
func TestAuthServiceCredentialLockoutDoesNotExtendWhileLocked(t *testing.T) {
|
|
db := setupAuthServiceTestDB(t)
|
|
createAuthTestUser(t, db, "admin", "secret")
|
|
now := time.Now()
|
|
entries := []models.LoginCredentialLog{
|
|
{
|
|
Principal: "admin",
|
|
UserID: 1,
|
|
Success: false,
|
|
Reason: "password mismatch",
|
|
CreatedAt: now.Add(-2 * time.Minute),
|
|
},
|
|
{
|
|
Principal: "admin",
|
|
UserID: 0,
|
|
Success: false,
|
|
Reason: "credential locked",
|
|
CreatedAt: now.Add(-1 * time.Minute),
|
|
},
|
|
}
|
|
if err := db.Create(&entries).Error; err != nil {
|
|
t.Fatalf("seed credential logs: %v", err)
|
|
}
|
|
|
|
ret, err := newAuthService().Login(request.LoginRequest{Username: "admin", Password: "secret"}, config.AuthConfig{
|
|
TokenTTLHours: 2,
|
|
MaxFailedAttempts: 2,
|
|
CredentialLockMinute: 15,
|
|
}, "127.0.0.1", "go-test")
|
|
if err != nil {
|
|
t.Fatalf("expected locked attempt logs not to extend lockout, got %v", err)
|
|
}
|
|
if ret == nil || !strings.HasPrefix(ret.AccessToken, "ak_") {
|
|
t.Fatalf("expected login response with ak_ token, got %+v", ret)
|
|
}
|
|
}
|
|
|
|
func TestAuthServiceCredentialLockoutNormalizesPrincipalCase(t *testing.T) {
|
|
db := setupAuthServiceTestDB(t)
|
|
createAuthTestUser(t, db, "admin", "secret")
|
|
if err := db.Create(&models.LoginCredentialLog{
|
|
Principal: "admin",
|
|
UserID: 1,
|
|
Success: false,
|
|
Reason: "password mismatch",
|
|
CreatedAt: time.Now().Add(-time.Minute),
|
|
}).Error; err != nil {
|
|
t.Fatalf("seed credential log: %v", err)
|
|
}
|
|
|
|
_, err := newAuthService().Login(request.LoginRequest{Username: "ADMIN", Password: "secret"}, config.AuthConfig{
|
|
TokenTTLHours: 2,
|
|
MaxFailedAttempts: 1,
|
|
CredentialLockMinute: 15,
|
|
}, "127.0.0.1", "go-test")
|
|
if !hasCode(err, errorsx.CodeAuthCredentialLocked) {
|
|
t.Fatalf("expected normalized principal to be locked, got %v", err)
|
|
}
|
|
|
|
var lockedLog models.LoginCredentialLog
|
|
if err := db.Order("id DESC").Take(&lockedLog).Error; err != nil {
|
|
t.Fatalf("query latest credential log: %v", err)
|
|
}
|
|
if lockedLog.Principal != "admin" || lockedLog.Reason != "credential locked" {
|
|
t.Fatalf("unexpected locked log: %+v", lockedLog)
|
|
}
|
|
}
|
|
|
|
func TestAuthServiceCredentialLockoutDisabledWhenMaxAttemptsNonPositive(t *testing.T) {
|
|
db := setupAuthServiceTestDB(t)
|
|
user := createAuthTestUser(t, db, "admin", "secret")
|
|
now := time.Now()
|
|
for i := 0; i < 3; i++ {
|
|
if err := db.Create(&models.LoginCredentialLog{
|
|
Principal: "admin",
|
|
UserID: user.ID,
|
|
Success: false,
|
|
Reason: "credential locked",
|
|
CreatedAt: now.Add(-time.Duration(i+1) * time.Minute),
|
|
}).Error; err != nil {
|
|
t.Fatalf("seed credential log: %v", err)
|
|
}
|
|
}
|
|
|
|
ret, err := newAuthService().Login(request.LoginRequest{Username: "admin", Password: "secret"}, config.AuthConfig{
|
|
TokenTTLHours: 2,
|
|
MaxFailedAttempts: 0,
|
|
CredentialLockMinute: 15,
|
|
}, "127.0.0.1", "go-test")
|
|
if err != nil {
|
|
t.Fatalf("expected lockout to be disabled, got %v", err)
|
|
}
|
|
if ret == nil || ret.AccessToken == "" {
|
|
t.Fatalf("expected login response with access token, got %+v", ret)
|
|
}
|
|
}
|
|
|
|
func TestValidateSessionTokenStates(t *testing.T) {
|
|
db := setupAuthServiceTestDB(t)
|
|
svc := newAuthService()
|
|
now := time.Now()
|
|
|
|
if _, err := svc.validateSessionToken(" "); !hasCode(err, errorsx.CodeAuthUnauthorized) {
|
|
t.Fatalf("expected unauthorized for empty token, got %v", err)
|
|
}
|
|
if _, err := svc.validateSessionToken("missing"); !hasCode(err, errorsx.CodeAuthInvalidToken) {
|
|
t.Fatalf("expected invalid token for missing session, got %v", err)
|
|
}
|
|
|
|
revokedAt := now
|
|
if err := db.Create(&models.LoginSession{
|
|
UserID: 1,
|
|
Token: "ak_revoked",
|
|
ClientType: "admin_web",
|
|
ExpiredAt: now.Add(time.Hour),
|
|
RevokedAt: &revokedAt,
|
|
AuditFields: models.AuditFields{
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
},
|
|
}).Error; err != nil {
|
|
t.Fatalf("seed revoked session: %v", err)
|
|
}
|
|
if _, err := svc.validateSessionToken("ak_revoked"); !hasCode(err, errorsx.CodeAuthInvalidToken) {
|
|
t.Fatalf("expected invalid token for revoked session, got %v", err)
|
|
}
|
|
|
|
if err := db.Create(&models.LoginSession{
|
|
UserID: 1,
|
|
Token: "ak_expired",
|
|
ClientType: "admin_web",
|
|
ExpiredAt: now.Add(-time.Hour),
|
|
AuditFields: models.AuditFields{
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
},
|
|
}).Error; err != nil {
|
|
t.Fatalf("seed expired session: %v", err)
|
|
}
|
|
if _, err := svc.validateSessionToken("ak_expired"); !hasCode(err, errorsx.CodeAuthInvalidToken) {
|
|
t.Fatalf("expected invalid token for expired session, got %v", err)
|
|
}
|
|
|
|
if err := db.Create(&models.LoginSession{
|
|
UserID: 1,
|
|
Token: "ak_valid",
|
|
ClientType: "admin_web",
|
|
ExpiredAt: now.Add(time.Hour),
|
|
AuditFields: models.AuditFields{
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
},
|
|
}).Error; err != nil {
|
|
t.Fatalf("seed valid session: %v", err)
|
|
}
|
|
session, err := svc.validateSessionToken("ak_valid")
|
|
if err != nil {
|
|
t.Fatalf("expected valid session token, got %v", err)
|
|
}
|
|
if session.Token != "ak_valid" {
|
|
t.Fatalf("expected valid session token ak_valid, got %q", session.Token)
|
|
}
|
|
}
|
|
|
|
func TestAuthServiceLogoutRevokesCurrentTokenOnly(t *testing.T) {
|
|
db := setupAuthServiceTestDB(t)
|
|
now := time.Now()
|
|
sessions := []models.LoginSession{
|
|
{
|
|
UserID: 1,
|
|
Token: "ak_current",
|
|
ClientType: "admin_web",
|
|
ExpiredAt: now.Add(time.Hour),
|
|
AuditFields: models.AuditFields{
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
},
|
|
},
|
|
{
|
|
UserID: 1,
|
|
Token: "ak_other",
|
|
ClientType: "admin_web",
|
|
ExpiredAt: now.Add(time.Hour),
|
|
AuditFields: models.AuditFields{
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
},
|
|
},
|
|
}
|
|
if err := db.Create(&sessions).Error; err != nil {
|
|
t.Fatalf("seed sessions: %v", err)
|
|
}
|
|
|
|
if err := newAuthService().Logout("Bearer ak_current"); err != nil {
|
|
t.Fatalf("logout failed: %v", err)
|
|
}
|
|
|
|
var current models.LoginSession
|
|
if err := db.Take(¤t, "token = ?", "ak_current").Error; err != nil {
|
|
t.Fatalf("query current session: %v", err)
|
|
}
|
|
if current.RevokedAt == nil {
|
|
t.Fatal("expected current session to be revoked")
|
|
}
|
|
var other models.LoginSession
|
|
if err := db.Take(&other, "token = ?", "ak_other").Error; err != nil {
|
|
t.Fatalf("query other session: %v", err)
|
|
}
|
|
if other.RevokedAt != nil {
|
|
t.Fatal("expected other session to remain active")
|
|
}
|
|
}
|
|
|
|
func TestLoadUserPermissionCodesReturnsSortedDistinctCodes(t *testing.T) {
|
|
db := setupAuthServiceTestDB(t)
|
|
user := createAuthTestUser(t, db, "admin", "secret")
|
|
now := time.Now()
|
|
auditFields := models.AuditFields{CreatedAt: now, UpdatedAt: now}
|
|
|
|
permissions := []models.Permission{
|
|
{Name: "Zulu", Code: "z.read", SortNo: 1, Status: enums.StatusOk, AuditFields: auditFields},
|
|
{Name: "Alpha", Code: "a.read", SortNo: 2, Status: enums.StatusOk, AuditFields: auditFields},
|
|
}
|
|
if err := db.Create(&permissions).Error; err != nil {
|
|
t.Fatalf("seed permissions: %v", err)
|
|
}
|
|
|
|
roles := []models.Role{
|
|
{Name: "Role One", Code: "role_one", Status: enums.StatusOk, AuditFields: auditFields},
|
|
{Name: "Role Two", Code: "role_two", Status: enums.StatusOk, AuditFields: auditFields},
|
|
}
|
|
if err := db.Create(&roles).Error; err != nil {
|
|
t.Fatalf("seed roles: %v", err)
|
|
}
|
|
|
|
userRoles := []models.UserRole{
|
|
{UserID: user.ID, RoleID: roles[0].ID, AuditFields: auditFields},
|
|
{UserID: user.ID, RoleID: roles[1].ID, AuditFields: auditFields},
|
|
}
|
|
if err := db.Create(&userRoles).Error; err != nil {
|
|
t.Fatalf("seed user roles: %v", err)
|
|
}
|
|
|
|
rolePermissions := []models.RolePermission{
|
|
{RoleID: roles[0].ID, PermissionID: permissions[0].ID, AuditFields: auditFields},
|
|
{RoleID: roles[0].ID, PermissionID: permissions[1].ID, AuditFields: auditFields},
|
|
{RoleID: roles[1].ID, PermissionID: permissions[0].ID, AuditFields: auditFields},
|
|
}
|
|
if err := db.Create(&rolePermissions).Error; err != nil {
|
|
t.Fatalf("seed role permissions: %v", err)
|
|
}
|
|
|
|
codes, err := newAuthService().loadUserPermissionCodes(db, user.ID)
|
|
if err != nil {
|
|
t.Fatalf("load user permission codes: %v", err)
|
|
}
|
|
if got, want := strings.Join(codes, ","), "a.read,z.read"; got != want {
|
|
t.Fatalf("permission codes = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func setupAuthServiceTestDB(t *testing.T) *gorm.DB {
|
|
t.Helper()
|
|
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{
|
|
NamingStrategy: schema.NamingStrategy{
|
|
TablePrefix: "t_",
|
|
SingularTable: true,
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("open sqlite db: %v", err)
|
|
}
|
|
if err := db.AutoMigrate(
|
|
&models.User{},
|
|
&models.UserIdentity{},
|
|
&models.Role{},
|
|
&models.Permission{},
|
|
&models.UserRole{},
|
|
&models.RolePermission{},
|
|
&models.UserPermission{},
|
|
&models.LoginSession{},
|
|
&models.LoginCredentialLog{},
|
|
); err != nil {
|
|
t.Fatalf("migrate auth tables: %v", err)
|
|
}
|
|
sqls.SetDB(db)
|
|
return db
|
|
}
|
|
|
|
func createAuthTestUser(t *testing.T, db *gorm.DB, username, password string) *models.User {
|
|
t.Helper()
|
|
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
t.Fatalf("hash password: %v", err)
|
|
}
|
|
now := time.Now()
|
|
user := &models.User{
|
|
Username: username,
|
|
Nickname: username,
|
|
Password: string(passwordHash),
|
|
Status: enums.StatusOk,
|
|
AuditFields: models.AuditFields{
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
},
|
|
}
|
|
if err := db.Create(user).Error; err != nil {
|
|
t.Fatalf("create auth test user: %v", err)
|
|
}
|
|
return user
|
|
}
|
|
|
|
func findCredentialLogs(t *testing.T, db *gorm.DB) []models.LoginCredentialLog {
|
|
t.Helper()
|
|
var logs []models.LoginCredentialLog
|
|
if err := db.Order("id ASC").Find(&logs).Error; err != nil {
|
|
t.Fatalf("query credential logs: %v", err)
|
|
}
|
|
return logs
|
|
}
|
|
|
|
func hasCode(err error, code int) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
var codeErr *web.CodeError
|
|
if errors.As(err, &codeErr) {
|
|
return codeErr.Code == code
|
|
}
|
|
return false
|
|
}
|