package services import ( "encoding/json" "net/http/httptest" "path/filepath" "slices" "testing" "time" "agent-desk/internal/models" "agent-desk/internal/pkg/dto" "agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/errorsx" "github.com/gin-gonic/gin" "github.com/glebarez/sqlite" "github.com/mlogclub/simple/sqls" "gorm.io/gorm" "gorm.io/gorm/logger" "gorm.io/gorm/schema" ) type authSessionFixture struct { db *gorm.DB now time.Time user models.User session models.LoginSession role models.Role permission models.Permission principal *dto.AuthPrincipal } func newAuthSessionFixture(t *testing.T) *authSessionFixture { t.Helper() db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "auth.db")), &gorm.Config{ NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}, Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { t.Fatalf("open isolated auth database: %v", err) } sqlDB, err := db.DB() if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = sqlDB.Close() }) if err := db.AutoMigrate(&models.User{}, &models.LoginSession{}, &models.Role{}, &models.Permission{}, &models.UserRole{}, &models.RolePermission{}, &models.UserPermission{}); err != nil { t.Fatalf("migrate isolated auth database: %v", err) } f := &authSessionFixture{db: db, now: time.Now().UTC().Truncate(time.Microsecond)} f.user = models.User{Username: "staff", UserType: enums.UserTypeEmployee, Status: enums.StatusOk} f.role = models.Role{Code: "support", Status: enums.StatusOk} f.permission = models.Permission{Code: "conversation.read", Status: enums.StatusOk} for _, item := range []any{&f.user, &f.role, &f.permission} { if err := db.Create(item).Error; err != nil { t.Fatalf("seed auth fixture: %v", err) } } f.session = models.LoginSession{UserID: f.user.ID, Token: "ak_test_only_session", ExpiredAt: f.now.Add(time.Hour)} for _, item := range []any{ &f.session, &models.UserRole{UserID: f.user.ID, RoleID: f.role.ID}, &models.RolePermission{RoleID: f.role.ID, PermissionID: f.permission.ID}, } { if err := db.Create(item).Error; err != nil { t.Fatalf("seed auth fixture: %v", err) } } f.principal = buildAuthPrincipal(&f.user, &f.session, []string{f.role.Code}, []string{f.permission.Code}) return f } func TestAuthScopeExcludesDisabledRolesAndPermissionOverrides(t *testing.T) { f := newAuthSessionFixture(t) disabledRole := models.Role{Code: "disabled", Status: enums.StatusDisabled} if err := f.db.Create(&disabledRole).Error; err != nil { t.Fatal(err) } if err := f.db.Create(&models.UserRole{UserID: f.user.ID, RoleID: disabledRole.ID}).Error; err != nil { t.Fatal(err) } permissions := []models.Permission{ {Code: "disabled-role-only", Status: enums.StatusOk}, {Code: "disabled-permission", Status: enums.StatusDisabled}, {Code: "direct-allow", Status: enums.StatusOk}, {Code: "expired-allow", Status: enums.StatusOk}, } if err := f.db.Create(&permissions).Error; err != nil { t.Fatal(err) } for _, item := range []any{ &models.RolePermission{RoleID: disabledRole.ID, PermissionID: permissions[0].ID}, &models.RolePermission{RoleID: f.role.ID, PermissionID: permissions[1].ID}, &models.UserPermission{UserID: f.user.ID, PermissionID: permissions[1].ID, Effect: 1}, &models.UserPermission{UserID: f.user.ID, PermissionID: permissions[2].ID, Effect: 1}, &models.UserPermission{UserID: f.user.ID, PermissionID: permissions[3].ID, Effect: 1, ExpiredAt: &f.now}, &models.UserPermission{UserID: f.user.ID, PermissionID: f.permission.ID, Effect: -1}, } { if err := f.db.Create(item).Error; err != nil { t.Fatal(err) } } roles, codes, err := newAuthService().loadUserAuthScope(f.db, f.user.ID) if err != nil { t.Fatal(err) } if !slices.Equal(roles, []string{"support"}) || !slices.Equal(codes, []string{"direct-allow"}) { t.Fatalf("unexpected enabled authorization scope: roles=%v permissions=%v", roles, codes) } } func TestRevalidatePrincipalRejectsRevokedExpiredOrUnboundIdentity(t *testing.T) { cases := []struct { name string change func(*authSessionFixture) error }{ {"nil principal", func(f *authSessionFixture) error { f.principal = nil; return nil }}, {"missing session binding", func(f *authSessionFixture) error { f.principal.LoginSessionID = 0; return nil }}, {"missing user binding", func(f *authSessionFixture) error { f.principal.UserID = 0; return nil }}, {"missing expiry", func(f *authSessionFixture) error { f.principal.ExpiresAt = time.Time{}; return nil }}, {"original deadline reached", func(f *authSessionFixture) error { f.principal.ExpiresAt = f.now; return nil }}, {"original deadline passed", func(f *authSessionFixture) error { f.principal.ExpiresAt = f.now.Add(-time.Second); return nil }}, {"revoked session", func(f *authSessionFixture) error { return f.db.Model(&f.session).Update("revoked_at", f.now).Error }}, {"session deadline reached", func(f *authSessionFixture) error { return f.db.Model(&f.session).Update("expired_at", f.now).Error }}, {"session deadline passed", func(f *authSessionFixture) error { return f.db.Model(&f.session).Update("expired_at", f.now.Add(-time.Second)).Error }}, {"deleted session", func(f *authSessionFixture) error { return f.db.Delete(&f.session).Error }}, {"different session owner", func(f *authSessionFixture) error { return f.db.Model(&f.session).Update("user_id", f.user.ID+1).Error }}, {"disabled user", func(f *authSessionFixture) error { return f.db.Model(&f.user).Update("status", enums.StatusDisabled).Error }}, {"soft-deleted user", func(f *authSessionFixture) error { return f.db.Model(&f.user).Update("deleted_at", f.now).Error }}, {"deleted user", func(f *authSessionFixture) error { return f.db.Delete(&f.user).Error }}, {"failed session query", func(f *authSessionFixture) error { return f.db.Migrator().DropTable(&models.LoginSession{}) }}, {"failed scope query", func(f *authSessionFixture) error { return f.db.Migrator().DropTable(&models.Permission{}) }}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { f := newAuthSessionFixture(t) if err := tc.change(f); err != nil { t.Fatal(err) } fresh, err := newAuthService().revalidatePrincipal(f.db, f.principal, f.now) if fresh != nil || !hasCode(err, errorsx.CodeAuthUnauthorized) { t.Fatalf("expected fail-closed unauthorized result, got principal=%v err=%v", fresh != nil, err) } }) } } func TestRevalidatePrincipalRefreshesScopeWithoutMutatingSnapshot(t *testing.T) { cases := []struct { name string change func(*authSessionFixture) error wantRoles []string }{ {"disabled role", func(f *authSessionFixture) error { return f.db.Model(&f.role).Update("status", enums.StatusDisabled).Error }, nil}, {"removed role", func(f *authSessionFixture) error { return f.db.Where("user_id = ?", f.user.ID).Delete(&models.UserRole{}).Error }, nil}, {"removed role permission", func(f *authSessionFixture) error { return f.db.Where("role_id = ?", f.role.ID).Delete(&models.RolePermission{}).Error }, []string{"support"}}, {"disabled permission", func(f *authSessionFixture) error { return f.db.Model(&f.permission).Update("status", enums.StatusDisabled).Error }, []string{"support"}}, {"direct deny", func(f *authSessionFixture) error { return f.db.Create(&models.UserPermission{UserID: f.user.ID, PermissionID: f.permission.ID, Effect: -1}).Error }, []string{"support"}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { f := newAuthSessionFixture(t) if err := tc.change(f); err != nil { t.Fatal(err) } fresh, err := newAuthService().revalidatePrincipal(f.db, f.principal, f.now) if err != nil { t.Fatal(err) } if fresh == f.principal || len(fresh.Permissions) != 0 || !slices.Equal(fresh.Roles, tc.wantRoles) { t.Fatalf("expected a fresh reduced scope: roles=%v permissions=%v", fresh.Roles, fresh.Permissions) } if !slices.Equal(f.principal.Roles, []string{"support"}) || !slices.Equal(f.principal.Permissions, []string{"conversation.read"}) { t.Fatal("revalidation mutated the original snapshot") } }) } } func TestRevalidatePrincipalCannotExtendAbsoluteExpiry(t *testing.T) { for _, delta := range []time.Duration{-time.Minute, time.Minute} { t.Run(delta.String(), func(t *testing.T) { f := newAuthSessionFixture(t) storedExpiry := f.session.ExpiredAt.Add(delta) if err := f.db.Model(&f.session).Update("expired_at", storedExpiry).Error; err != nil { t.Fatal(err) } fresh, err := newAuthService().revalidatePrincipal(f.db, f.principal, f.now) if err != nil { t.Fatal(err) } want := f.principal.ExpiresAt if storedExpiry.Before(want) { want = storedExpiry } if !fresh.ExpiresAt.Equal(want) { t.Fatal("revalidation did not preserve the earliest absolute deadline") } fresh.Roles[0] = "changed" fresh.Permissions[0] = "changed" if f.principal.Roles[0] != "support" || f.principal.Permissions[0] != "conversation.read" { t.Fatal("fresh authorization slices alias the original snapshot") } }) } } func TestAuthenticateBindsPrincipalToSessionWithoutExposingMetadata(t *testing.T) { f := newAuthSessionFixture(t) previousDB := sqls.DB() sqls.SetDB(f.db) t.Cleanup(func() { sqls.SetDB(previousDB) }) ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) ctx.Request = httptest.NewRequest("GET", "/", nil) ctx.Request.Header.Set("Authorization", "Bearer "+f.session.Token) principal, err := newAuthService().Authenticate(ctx) if err != nil { t.Fatal(err) } if principal.LoginSessionID != f.session.ID || !principal.ExpiresAt.Equal(f.session.ExpiredAt) { t.Fatal("authenticated principal is not bound to its original session") } if _, err := newAuthService().RevalidatePrincipal(principal); err != nil { t.Fatalf("valid session failed public revalidation API: %v", err) } encoded, err := json.Marshal(principal) if err != nil { t.Fatal(err) } var publicFields map[string]json.RawMessage if err := json.Unmarshal(encoded, &publicFields); err != nil { t.Fatal(err) } for _, name := range []string{"LoginSessionID", "ExpiresAt", "Token"} { if _, ok := publicFields[name]; ok { t.Fatalf("internal auth metadata leaked into JSON: %s", name) } } if err := newAuthService().Logout("Bearer " + f.session.Token); err != nil { t.Fatal(err) } if fresh, err := newAuthService().RevalidatePrincipal(principal); fresh != nil || !hasCode(err, errorsx.CodeAuthUnauthorized) { t.Fatalf("logout left the authenticated snapshot valid: principal=%v err=%v", fresh != nil, err) } }