Files
ttstd-infinite-canvas/backend/internal/app/admin_system_performance_test.go

242 lines
8.3 KiB
Go

package app
import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
"infinite-canvas/backend/internal/database"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/platform"
"infinite-canvas/backend/internal/repository"
"github.com/redis/go-redis/v9"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestAdminSystemPerformanceRequiresAdmin(t *testing.T) {
svc := &Service{}
user := &model.User{ID: "user-1", Role: model.UserRoleUser}
if _, err := svc.AdminSystemPerformance(context.Background(), user); authStatus(err) != 403 {
t.Fatalf("AdminSystemPerformance() error = %#v, want 403", err)
}
if _, err := svc.ClearAdminRuntimeCache(context.Background(), user, AdminCacheClearRequest{Scope: "runtime"}); authStatus(err) != 403 {
t.Fatalf("ClearAdminRuntimeCache() error = %#v, want 403", err)
}
if _, err := svc.AdminSystemPerformance(context.Background(), nil); authStatus(err) != 401 {
t.Fatalf("AdminSystemPerformance(nil) error = %#v, want 401", err)
}
}
func TestAdminSystemPerformanceReportsSQLiteWithoutRedis(t *testing.T) {
svc, db := newSystemPerformanceTestService(t)
admin := &model.User{ID: "admin-1", Role: model.UserRoleAdmin}
result, err := svc.AdminSystemPerformance(context.Background(), admin)
if err != nil {
t.Fatal(err)
}
if result.Status != "healthy" {
t.Fatalf("status = %q, want healthy", result.Status)
}
if result.Database.Driver != "sqlite" || !result.Database.Connected || result.Database.Postgres != nil {
t.Fatalf("database = %#v", result.Database)
}
if result.Database.DatabaseBytes <= 0 {
t.Fatalf("database bytes = %d, want > 0", result.Database.DatabaseBytes)
}
if result.Database.Schema.Expected != database.CurrentSchemaVersion {
t.Fatalf("schema = %#v", result.Database.Schema)
}
if result.Redis.Configured || result.Redis.Connected || result.Redis.Mode != "local" {
t.Fatalf("redis = %#v", result.Redis)
}
if len(result.Redis.CacheGroups) != 1 || result.Redis.CacheGroups[0].ID != "rateLimits" {
t.Fatalf("cache groups = %#v", result.Redis.CacheGroups)
}
if !result.Disk.Available || !result.Disk.Writable {
t.Fatalf("disk = %#v", result.Disk)
}
if result.Host.CPUCores < 1 || result.Host.GOMAXPROCS < 1 || result.Host.ProcessID < 1 {
t.Fatalf("host = %#v", result.Host)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
if stats := sqlDB.Stats(); result.Database.Pool.OpenConnections != stats.OpenConnections {
t.Fatalf("pool open connections = %d, want %d", result.Database.Pool.OpenConnections, stats.OpenConnections)
}
}
func TestClearAdminRuntimeCacheRejectsUnknownScopeAndAuditsLocalClear(t *testing.T) {
svc, db := newSystemPerformanceTestService(t)
admin := &model.User{ID: "admin-1", Role: model.UserRoleAdmin}
if ok, err := svc.coordinator.Allow(context.Background(), "user-1", 10, time.Hour); err != nil || !ok {
t.Fatalf("seed local rate cache: ok=%v err=%v", ok, err)
}
if _, err := svc.ClearAdminRuntimeCache(context.Background(), admin, AdminCacheClearRequest{Scope: "all"}); authStatus(err) != 400 {
t.Fatalf("unknown scope error = %#v, want 400", err)
}
if svc.coordinator.LocalRateCount() != 1 {
t.Fatal("invalid scope changed local runtime cache")
}
svc.initReadCaches()
if _, err := svc.routeVersionReadCache.Get(context.Background(), "route-version", func(context.Context) (int64, int, error) {
return 7, 256, nil
}); err != nil {
t.Fatal(err)
}
svc.routeHealthBlocked["route-1"] = time.Now().Add(time.Minute)
svc.routeCatalog = &routeCatalogSnapshot{}
svc.routeCatalogRetryAt = time.Now().Add(time.Minute)
svc.routeCatalogRefreshError = errors.New("cached refresh failure")
result, err := svc.ClearAdminRuntimeCache(context.Background(), admin, AdminCacheClearRequest{Scope: "runtime"})
if err != nil {
t.Fatal(err)
}
if result.DeletedKeys != 1 || svc.coordinator.LocalRateCount() != 0 {
t.Fatalf("local clear result = %#v, remaining = %d", result, svc.coordinator.LocalRateCount())
}
if result.MemoryEntries != 3 {
t.Fatalf("memory entries = %d, want 3", result.MemoryEntries)
}
if svc.routeCatalog != nil || !svc.routeCatalogRetryAt.IsZero() || svc.routeCatalogRefreshError != nil || len(svc.routeHealthBlocked) != 0 {
t.Fatalf("route caches were not reset")
}
var audit model.AdminAuditEvent
if err := db.Where("action = ?", "system.runtime_cache.clear").First(&audit).Error; err != nil {
t.Fatal(err)
}
if audit.ActorUserID != admin.ID || audit.TargetID != "runtime-cache" {
t.Fatalf("audit = %#v", audit)
}
}
func TestClearAdminRuntimeCacheUsesRedisWhitelist(t *testing.T) {
client := startSystemPerformanceRedis(t)
svc, db := newSystemPerformanceTestService(t)
svc.coordinator = platform.NewCoordinatorWithRedis(client, "system-performance-test")
admin := &model.User{ID: "admin-redis", Role: model.UserRoleAdmin}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
clearable := []string{
"canvas:rate:user-1",
"canvas:circuit:failures:channel-1",
"canvas:circuit:open:channel-1",
"canvas:logical-route-health:route-1",
}
preserved := []string{
"canvas:slots:channel:channel-1",
"canvas:logical-model-route-catalog:version",
"canvas:task:task-1",
}
for _, key := range append(append([]string{}, clearable...), preserved...) {
if err := client.Set(ctx, key, "1", 0).Err(); err != nil {
t.Fatal(err)
}
}
before := svc.collectRedisPerformance(ctx)
if !before.Configured || !before.Connected || before.Mode != "redis" || before.Keys != int64(len(clearable)+len(preserved)) {
t.Fatalf("redis performance = %#v", before)
}
result, err := svc.ClearAdminRuntimeCache(ctx, admin, AdminCacheClearRequest{Scope: "runtime"})
if err != nil {
t.Fatal(err)
}
if result.DeletedKeys != int64(len(clearable)) || len(result.Groups) != len(runtimeCacheDefinitions) {
t.Fatalf("clear result = %#v", result)
}
if remaining, err := client.Exists(ctx, clearable...).Result(); err != nil || remaining != 0 {
t.Fatalf("clearable keys remaining = %d, err = %v", remaining, err)
}
if remaining, err := client.Exists(ctx, preserved...).Result(); err != nil || remaining != int64(len(preserved)) {
t.Fatalf("preserved keys remaining = %d, err = %v", remaining, err)
}
var auditCount int64
if err := db.Model(&model.AdminAuditEvent{}).Where("action = ?", "system.runtime_cache.clear").Count(&auditCount).Error; err != nil {
t.Fatal(err)
}
if auditCount != 1 {
t.Fatalf("audit count = %d, want 1", auditCount)
}
}
func newSystemPerformanceTestService(t *testing.T) (*Service, *gorm.DB) {
t.Helper()
dataDir := t.TempDir()
db, err := gorm.Open(sqlite.Open(filepath.Join(dataDir, "system-performance.db")), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.AdminAuditEvent{}); err != nil {
t.Fatal(err)
}
if sqlDB, err := db.DB(); err == nil {
sqlDB.SetMaxOpenConns(1)
t.Cleanup(func() { _ = sqlDB.Close() })
}
return &Service{
repo: repository.New(db),
dataDir: dataDir,
coordinator: platform.NewCoordinatorWithRedis(nil, "local-test"),
routeCatalogTTL: 30 * time.Second,
routeCatalogMaxStale: 5 * time.Minute,
routeHealthBlocked: map[string]time.Time{},
}, db
}
func startSystemPerformanceRedis(t *testing.T) *redis.Client {
t.Helper()
binary, err := exec.LookPath("redis-server")
if err != nil {
t.Skip("redis-server unavailable; Redis cache whitelist integration not run")
}
dir, err := os.MkdirTemp("", "canvas-performance-redis-")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.RemoveAll(dir) })
socket := filepath.Join(dir, "r.sock")
cmd := exec.Command(binary, "--port", "0", "--unixsocket", socket, "--unixsocketperm", "700", "--save", "", "--appendonly", "no", "--dir", dir)
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = cmd.Process.Kill()
_ = cmd.Wait()
})
client := redis.NewClient(&redis.Options{Network: "unix", Addr: socket, MaxRetries: -1, ContextTimeoutEnabled: true})
t.Cleanup(func() { _ = client.Close() })
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
for client.Ping(ctx).Err() != nil {
if ctx.Err() != nil {
t.Fatal("temporary Redis did not start")
}
time.Sleep(20 * time.Millisecond)
}
return client
}
func authStatus(err error) int {
var authErr *AuthError
if errors.As(err, &authErr) {
return authErr.Status
}
return 0
}