diff --git a/core/orm/crud_update.go b/core/orm/crud_update.go index c1926538..7018a027 100644 --- a/core/orm/crud_update.go +++ b/core/orm/crud_update.go @@ -16,6 +16,11 @@ func UpdateRecordByID(ctx context.Context, modelName string, id int, values map[ return fmt.Errorf("invalid id") } _, err = Update(ctx, modelName, [][]interface{}{{"id", "=", id}}, values) + if err == nil && modelName == "core.user" { + if v, ok := values["active"]; ok && !AsBool(v) { + DestroySessionsForUser(ctx, id) + } + } return err } diff --git a/core/orm/session_revoke.go b/core/orm/session_revoke.go deleted file mode 100644 index e4d51fdc..00000000 --- a/core/orm/session_revoke.go +++ /dev/null @@ -1,14 +0,0 @@ -package orm - -import ( - "context" -) - -// DestroySessionsForUser deletes all DB-backed sessions for a user. -func DestroySessionsForUser(ctx context.Context, userID int) { - if DB == nil || userID <= 0 { - return - } - sessionTable := MustQuotedTableName("sys.session") - _, _ = DB.ExecContext(ctx, `DELETE FROM `+sessionTable+` WHERE user_id = $1`, userID) -} diff --git a/core/orm/user_password.go b/core/orm/user_password.go index 7a5da659..86ec802f 100644 --- a/core/orm/user_password.go +++ b/core/orm/user_password.go @@ -66,3 +66,11 @@ func SetUserPasswordHash(ctx context.Context, userID int, hash string) error { DestroySessionsForUser(ctx, userID) return nil } + +func DestroySessionsForUser(ctx context.Context, userID int) { + if DB == nil || userID <= 0 { + return + } + tbl := MustQuotedTableName("sys.session") + _, _ = DB.ExecContext(ctx, `DELETE FROM `+tbl+` WHERE user_id = $1`, userID) +} diff --git a/test/core/orm/user_session_revoke_integration_test.go b/test/core/orm/user_session_revoke_integration_test.go new file mode 100644 index 00000000..7fffb16d --- /dev/null +++ b/test/core/orm/user_session_revoke_integration_test.go @@ -0,0 +1,121 @@ +//go:build integration + +package orm_test + +import ( + "context" + "database/sql" + "fmt" + "os" + "testing" + "time" + + _ "github.com/lib/pq" + + "sumeru/core/orm" +) + +const testPasswordHash = "$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi" + +func integrationCtx() context.Context { + ctx := orm.ContextWithBypass(context.Background(), true) + return orm.ContextWithUID(ctx, 1) +} + +func initIntegrationDB(t *testing.T) context.Context { + t.Helper() + dsn := os.Getenv("SUMERU_TEST_DSN") + if dsn == "" { + t.Skip("SUMERU_TEST_DSN not set") + } + preflight, err := sql.Open("postgres", dsn) + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := preflight.Ping(); err != nil { + _ = preflight.Close() + t.Fatalf("db ping: %v", err) + } + _ = preflight.Close() + + orm.InitDBWithPool(dsn, orm.DBPoolSettings{MaxOpenConns: 5, MaxIdleConns: 2}) + if !orm.IsInitialized() { + t.Skip("database schema not bootstrapped (run sumeru -i base)") + } + return integrationCtx() +} + +func createTestUser(t *testing.T, ctx context.Context) int { + t.Helper() + userModel, ok := orm.Registry["core.user"] + if !ok { + t.Fatal("core.user not registered") + } + login := fmt.Sprintf("sess_revoke_%d", time.Now().UnixNano()) + userID, err := orm.Create(ctx, userModel, map[string]interface{}{ + "login": login, + "name": "Session Revoke Test", + }) + if err != nil { + t.Fatalf("create test user: %v", err) + } + return userID +} + +func insertSessions(t *testing.T, ctx context.Context, userID, count int) { + t.Helper() + sessionTable := orm.MustQuotedTableName("sys.session") + for i := 0; i < count; i++ { + sid := fmt.Sprintf("test-sid-%d-%d-%d", userID, i, time.Now().UnixNano()) + _, err := orm.DB.ExecContext(ctx, + `INSERT INTO `+sessionTable+` (sid, user_id, expires_at) VALUES ($1, $2, NOW() + interval '1 day')`, + sid, userID, + ) + if err != nil { + t.Fatalf("insert session: %v", err) + } + } +} + +func sessionCount(t *testing.T, ctx context.Context, userID int) int { + t.Helper() + sessionTable := orm.MustQuotedTableName("sys.session") + var count int + err := orm.DB.QueryRowContext(ctx, + `SELECT COUNT(*) FROM `+sessionTable+` WHERE user_id = $1`, userID, + ).Scan(&count) + if err != nil { + t.Fatalf("count sessions: %v", err) + } + return count +} + +func TestPasswordChangeRevokesSessions(t *testing.T) { + ctx := initIntegrationDB(t) + userID := createTestUser(t, ctx) + insertSessions(t, ctx, userID, 2) + if sessionCount(t, ctx, userID) != 2 { + t.Fatal("expected 2 sessions before password change") + } + if err := orm.SetUserPasswordHash(ctx, userID, testPasswordHash); err != nil { + t.Fatalf("SetUserPasswordHash: %v", err) + } + if got := sessionCount(t, ctx, userID); got != 0 { + t.Fatalf("expected 0 sessions after password change, got %d", got) + } +} + +func TestDeactivateUserRevokesSessions(t *testing.T) { + ctx := initIntegrationDB(t) + userID := createTestUser(t, ctx) + insertSessions(t, ctx, userID, 1) + if sessionCount(t, ctx, userID) != 1 { + t.Fatal("expected 1 session before deactivation") + } + if err := orm.UpdateRecordByID(ctx, "core.user", userID, map[string]interface{}{"active": false}); err != nil { + t.Fatalf("deactivate user: %v", err) + } + if got := sessionCount(t, ctx, userID); got != 0 { + t.Fatalf("expected 0 sessions after deactivation, got %d", got) + } +}