Source-only publication of reviewed commit 64e96a7f423673dc29ad56485be9fc79bce8af1c.
Passed in isolation: focused frontend/backend tests, race checks, TypeScript typecheck, lint, Go compilation, Flowgram build, and deterministic SDK build/tests.
Not accepted as a production release: Next.js export/native assembly remains blocked by environment memory limits; browser interaction and MySQL staging acceptance remain unverified. No production server, database/schema, runtime configuration, deployment, release tag, or workflow execution was performed.
Expected source tree: 0a4a7528e9.
261 lines
10 KiB
Go
261 lines
10 KiB
Go
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)
|
|
}
|
|
}
|