Harden proxy auth, storage, and conversation access

This commit is contained in:
sid 2026-03-19 19:00:24 -06:00
parent 6cda36312a
commit b9da198e1f
12 changed files with 1362 additions and 121 deletions

View file

@ -3,6 +3,7 @@ package main
import (
"context"
"log"
"net"
"net/http"
"os"
"os/signal"
@ -56,6 +57,7 @@ func main() {
)
r.Use(middleware.Logging)
r.Use(middleware.Auth(cfg.Auth))
r.HandleFunc("/v1/chat/completions", h.ChatCompletions).Methods("POST")
r.HandleFunc("/v1/messages", h.Messages).Methods("POST")
@ -73,7 +75,7 @@ func main() {
r.NotFoundHandler = http.HandlerFunc(h.NotFound)
srv := &http.Server{
Addr: ":" + cfg.Server.Port,
Addr: net.JoinHostPort(cfg.Server.Host, cfg.Server.Port),
Handler: corsHandler(r),
ReadTimeout: cfg.Server.ReadTimeout,
WriteTimeout: cfg.Server.WriteTimeout,
@ -81,14 +83,19 @@ func main() {
}
go func() {
logger.Printf("🚀 Claude Code Monitor Server running on http://localhost:%s", cfg.Server.Port)
logger.Printf("🚀 Claude Code Monitor Server running on http://%s", srv.Addr)
logger.Printf("📡 API endpoints available at:")
logger.Printf(" - POST http://localhost:%s/v1/messages (Anthropic format)", cfg.Server.Port)
logger.Printf(" - GET http://localhost:%s/v1/models", cfg.Server.Port)
logger.Printf(" - GET http://localhost:%s/health", cfg.Server.Port)
logger.Printf(" - POST http://%s/v1/messages (Anthropic format)", srv.Addr)
logger.Printf(" - GET http://%s/v1/models", srv.Addr)
logger.Printf(" - GET http://%s/health", srv.Addr)
logger.Printf("🎨 Web UI available at:")
logger.Printf(" - GET http://localhost:%s/ (Request Visualizer)", cfg.Server.Port)
logger.Printf(" - GET http://localhost:%s/api/requests (Request API)", cfg.Server.Port)
logger.Printf(" - GET http://%s/ (Request Visualizer)", srv.Addr)
logger.Printf(" - GET http://%s/api/requests (Request API)", srv.Addr)
if cfg.Auth.Enabled {
logger.Printf("🔐 Auth enabled using bearer token or %s", cfg.Auth.APIKeyHeader)
} else {
logger.Printf("🔓 Auth disabled for local-only access")
}
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Fatalf("❌ Server failed to start: %v", err)
@ -105,7 +112,13 @@ func main() {
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
logger.Fatalf("❌ Server forced to shutdown: %v", err)
logger.Printf("❌ Server forced to shutdown: %v", err)
}
// Close storage service (checkpoints WAL, closes prepared statements)
logger.Println("🗄️ Closing database...")
if err := storageService.Close(); err != nil {
logger.Printf("❌ Error closing storage: %v", err)
}
logger.Println("✅ Server exited")

View file

@ -2,6 +2,7 @@ package config
import (
"fmt"
"net"
"os"
"path/filepath"
"strconv"
@ -17,6 +18,7 @@ type Config struct {
Providers ProvidersConfig `yaml:"providers"`
Storage StorageConfig `yaml:"storage"`
Subagents SubagentsConfig `yaml:"subagents"`
Auth AuthConfig `yaml:"auth"`
CORS CORSConfig `yaml:"cors"`
Anthropic AnthropicConfig
}
@ -28,6 +30,7 @@ type CORSConfig struct {
}
type ServerConfig struct {
Host string `yaml:"host"`
Port string `yaml:"port"`
Timeouts TimeoutsConfig `yaml:"timeouts"`
// Legacy fields
@ -60,6 +63,13 @@ type OpenAIProviderConfig struct {
ClientAPIKeyHeader string `yaml:"client_api_key_header"` // Header name for client API key (default: x-openai-api-key)
}
type AuthConfig struct {
Enabled bool `yaml:"enabled"`
Token string `yaml:"token"`
APIKeyHeader string `yaml:"api_key_header"`
AllowLocalhostBypass bool `yaml:"allow_localhost_bypass"`
}
type AnthropicConfig struct {
BaseURL string
Version string
@ -67,8 +77,13 @@ type AnthropicConfig struct {
}
type StorageConfig struct {
RequestsDir string `yaml:"requests_dir"`
DBPath string `yaml:"db_path"`
RequestsDir string `yaml:"requests_dir"`
DBPath string `yaml:"db_path"`
CaptureRequestBody bool `yaml:"capture_request_body"`
CaptureResponseBody bool `yaml:"capture_response_body"`
MetadataOnly bool `yaml:"metadata_only"`
RetentionDays int `yaml:"retention_days"`
RedactedFields []string `yaml:"redacted_fields"`
}
type SubagentsConfig struct {
@ -88,40 +103,7 @@ func Load() (*Config, error) {
}
}
// Start with default configuration
cfg := &Config{
Server: ServerConfig{
Port: "3001",
ReadTimeout: 600 * time.Second,
WriteTimeout: 600 * time.Second,
IdleTimeout: 600 * time.Second,
},
Providers: ProvidersConfig{
Anthropic: AnthropicProviderConfig{
BaseURL: "https://api.anthropic.com",
Version: "2023-06-01",
MaxRetries: 3,
},
OpenAI: OpenAIProviderConfig{
BaseURL: "https://api.openai.com",
APIKey: "",
AllowClientAPIKey: false,
ClientAPIKeyHeader: "x-openai-api-key",
},
},
Storage: StorageConfig{
DBPath: "requests.db",
},
Subagents: SubagentsConfig{
Enable: false,
Mappings: make(map[string]string),
},
CORS: CORSConfig{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"*"},
},
}
cfg := defaultConfig()
if err := loadFirstAvailableConfig(cfg, candidateConfigPaths()); err != nil {
return nil, err
@ -131,6 +113,9 @@ func Load() (*Config, error) {
if envPort := os.Getenv("PORT"); envPort != "" {
cfg.Server.Port = envPort
}
if envHost := os.Getenv("SERVER_HOST"); envHost != "" {
cfg.Server.Host = envHost
}
if envTimeout := os.Getenv("READ_TIMEOUT"); envTimeout != "" {
cfg.Server.ReadTimeout = getDuration("READ_TIMEOUT", cfg.Server.ReadTimeout)
}
@ -166,10 +151,44 @@ func Load() (*Config, error) {
cfg.Providers.OpenAI.ClientAPIKeyHeader = envHeader
}
// Override auth settings
if envAuthEnabled := os.Getenv("AUTH_ENABLED"); envAuthEnabled != "" {
cfg.Auth.Enabled = envAuthEnabled == "true" || envAuthEnabled == "1"
}
if envAuthToken := os.Getenv("AUTH_TOKEN"); envAuthToken != "" {
cfg.Auth.Token = envAuthToken
}
if envAPIKeyHeader := os.Getenv("AUTH_API_KEY_HEADER"); envAPIKeyHeader != "" {
cfg.Auth.APIKeyHeader = envAPIKeyHeader
}
if envLocalBypass := os.Getenv("AUTH_ALLOW_LOCALHOST_BYPASS"); envLocalBypass != "" {
cfg.Auth.AllowLocalhostBypass = envLocalBypass == "true" || envLocalBypass == "1"
}
// Override storage settings
if envPath := os.Getenv("DB_PATH"); envPath != "" {
cfg.Storage.DBPath = envPath
}
if envCaptureReq := os.Getenv("STORAGE_CAPTURE_REQUEST_BODY"); envCaptureReq != "" {
cfg.Storage.CaptureRequestBody = envCaptureReq == "true" || envCaptureReq == "1"
}
if envCaptureResp := os.Getenv("STORAGE_CAPTURE_RESPONSE_BODY"); envCaptureResp != "" {
cfg.Storage.CaptureResponseBody = envCaptureResp == "true" || envCaptureResp == "1"
}
if envMetadataOnly := os.Getenv("STORAGE_METADATA_ONLY"); envMetadataOnly != "" {
cfg.Storage.MetadataOnly = envMetadataOnly == "true" || envMetadataOnly == "1"
}
if envRetentionDays := os.Getenv("STORAGE_RETENTION_DAYS"); envRetentionDays != "" {
cfg.Storage.RetentionDays = getInt("STORAGE_RETENTION_DAYS", cfg.Storage.RetentionDays)
}
if envRedacted := os.Getenv("STORAGE_REDACTED_FIELDS"); envRedacted != "" {
cfg.Storage.RedactedFields = splitAndTrim(envRedacted)
}
if cfg.Storage.MetadataOnly {
cfg.Storage.CaptureRequestBody = false
cfg.Storage.CaptureResponseBody = false
}
// Override CORS settings (comma-separated values)
if envOrigins := os.Getenv("CORS_ALLOWED_ORIGINS"); envOrigins != "" {
@ -213,6 +232,10 @@ func Load() (*Config, error) {
MaxRetries: cfg.Providers.Anthropic.MaxRetries,
}
if err := validateSecurity(cfg); err != nil {
return nil, err
}
return cfg, nil
}
@ -225,6 +248,76 @@ func (c *Config) loadFromFile(path string) error {
return yaml.Unmarshal(data, c)
}
func defaultConfig() *Config {
return &Config{
Server: ServerConfig{
Host: "127.0.0.1",
Port: "3001",
ReadTimeout: 600 * time.Second,
WriteTimeout: 600 * time.Second,
IdleTimeout: 600 * time.Second,
},
Providers: ProvidersConfig{
Anthropic: AnthropicProviderConfig{
BaseURL: "https://api.anthropic.com",
Version: "2023-06-01",
MaxRetries: 3,
},
OpenAI: OpenAIProviderConfig{
BaseURL: "https://api.openai.com",
APIKey: "",
AllowClientAPIKey: false,
ClientAPIKeyHeader: "x-openai-api-key",
},
},
Storage: StorageConfig{
DBPath: "requests.db",
CaptureRequestBody: true,
CaptureResponseBody: true,
MetadataOnly: false,
RetentionDays: 0,
RedactedFields: []string{
"api_key",
"authorization",
"token",
"password",
"secret",
"access_token",
"refresh_token",
"client_secret",
},
},
Subagents: SubagentsConfig{
Enable: false,
Mappings: make(map[string]string),
},
Auth: AuthConfig{
Enabled: false,
Token: "",
APIKeyHeader: "x-api-key",
AllowLocalhostBypass: true,
},
CORS: CORSConfig{
AllowedOrigins: []string{
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://localhost:5173",
"http://127.0.0.1:5173",
},
AllowedMethods: []string{"GET", "POST", "DELETE", "OPTIONS"},
AllowedHeaders: []string{
"Accept",
"Authorization",
"Content-Type",
"Anthropic-Version",
"Anthropic-Beta",
"X-API-Key",
"X-Requested-With",
},
},
}
}
func candidateConfigPaths() []string {
paths := []string{
filepath.Join(filepath.Dir(os.Args[0]), "..", "config.yaml"),
@ -246,6 +339,35 @@ func candidateConfigPaths() []string {
return unique
}
func validateSecurity(cfg *Config) error {
if cfg.Server.Host == "" {
cfg.Server.Host = "127.0.0.1"
}
if !isLoopbackHost(cfg.Server.Host) && !cfg.Auth.Enabled {
return fmt.Errorf("refusing to bind to %q without auth enabled; set AUTH_ENABLED=true and AUTH_TOKEN for public access", cfg.Server.Host)
}
if cfg.Auth.Enabled && cfg.Auth.Token == "" && !isLoopbackHost(cfg.Server.Host) {
return fmt.Errorf("auth is enabled for public access but AUTH_TOKEN is empty")
}
return nil
}
func isLoopbackHost(host string) bool {
host = strings.TrimSpace(host)
if host == "localhost" {
return true
}
if ip := net.ParseIP(strings.Trim(host, "[]")); ip != nil {
return ip.IsLoopback()
}
return false
}
func loadFirstAvailableConfig(cfg *Config, paths []string) error {
for _, path := range paths {
if _, err := os.Stat(path); err != nil {

View file

@ -6,6 +6,70 @@ import (
"testing"
)
func TestDefaultConfigIncludesStorageControls(t *testing.T) {
cfg := defaultConfig()
if !cfg.Storage.CaptureRequestBody {
t.Fatal("expected request body capture to be enabled by default")
}
if !cfg.Storage.CaptureResponseBody {
t.Fatal("expected response body capture to be enabled by default")
}
if cfg.Storage.MetadataOnly {
t.Fatal("expected metadata-only mode to be disabled by default")
}
if cfg.Storage.RetentionDays != 0 {
t.Fatalf("expected retention to be disabled by default, got %d", cfg.Storage.RetentionDays)
}
if len(cfg.Storage.RedactedFields) == 0 {
t.Fatal("expected default redacted field list to be populated")
}
}
func TestLoadFromFileParsesStorageControls(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.yaml")
yaml := `
storage:
db_path: /tmp/claude.db
capture_request_body: false
capture_response_body: false
metadata_only: true
retention_days: 7
redacted_fields:
- api_key
- secret
`
if err := os.WriteFile(configPath, []byte(yaml), 0o600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
cfg := defaultConfig()
if err := cfg.loadFromFile(configPath); err != nil {
t.Fatalf("loadFromFile() error = %v", err)
}
if cfg.Storage.DBPath != "/tmp/claude.db" {
t.Fatalf("unexpected db path %q", cfg.Storage.DBPath)
}
if !cfg.Storage.MetadataOnly {
t.Fatal("expected metadata-only mode to load from file")
}
if cfg.Storage.CaptureRequestBody {
t.Fatal("expected request body capture to load as disabled")
}
if cfg.Storage.CaptureResponseBody {
t.Fatal("expected response body capture to load as disabled")
}
if cfg.Storage.RetentionDays != 7 {
t.Fatalf("unexpected retention days %d", cfg.Storage.RetentionDays)
}
if len(cfg.Storage.RedactedFields) != 2 {
t.Fatalf("unexpected redacted field count %d", len(cfg.Storage.RedactedFields))
}
}
func TestLoadFirstAvailableConfigReturnsParseError(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.yaml")
@ -28,3 +92,46 @@ func TestLoadFirstAvailableConfigSkipsMissingFiles(t *testing.T) {
t.Fatalf("expected nil error for missing config, got %v", err)
}
}
func TestDefaultConfigUsesLoopbackAndLocalCors(t *testing.T) {
cfg := defaultConfig()
if cfg.Server.Host != "127.0.0.1" {
t.Fatalf("expected loopback host, got %q", cfg.Server.Host)
}
if cfg.Auth.Enabled {
t.Fatal("expected auth to be disabled by default for local development")
}
if len(cfg.CORS.AllowedOrigins) == 0 {
t.Fatal("expected local CORS origins to be configured")
}
for _, origin := range cfg.CORS.AllowedOrigins {
if origin == "*" {
t.Fatal("expected wildcard origin to be removed from defaults")
}
}
}
func TestValidateSecurityRejectsPublicBindWithoutAuth(t *testing.T) {
cfg := defaultConfig()
cfg.Server.Host = "0.0.0.0"
cfg.Auth.Enabled = false
if err := validateSecurity(cfg); err == nil {
t.Fatal("expected validation error for public bind without auth")
}
}
func TestValidateSecurityAllowsPublicBindWithAuthToken(t *testing.T) {
cfg := defaultConfig()
cfg.Server.Host = "0.0.0.0"
cfg.Auth.Enabled = true
cfg.Auth.Token = "secret"
if err := validateSecurity(cfg); err != nil {
t.Fatalf("expected public bind with auth token to be allowed, got %v", err)
}
}

View file

@ -0,0 +1,83 @@
package middleware
import (
"encoding/json"
"net"
"net/http"
"strings"
"github.com/seifghazi/claude-code-monitor/internal/config"
)
func Auth(cfg config.AuthConfig) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodOptions || r.URL.Path == "/health" {
next.ServeHTTP(w, r)
return
}
if !cfg.Enabled {
next.ServeHTTP(w, r)
return
}
if cfg.AllowLocalhostBypass && isLocalhostRequest(r.RemoteAddr) {
next.ServeHTTP(w, r)
return
}
if token, ok := extractAuthToken(r, cfg); ok && token == cfg.Token {
next.ServeHTTP(w, r)
return
}
w.Header().Set("WWW-Authenticate", `Bearer realm="claude-code-proxy"`)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(map[string]string{
"error": "unauthorized",
})
})
}
}
func extractAuthToken(r *http.Request, cfg config.AuthConfig) (string, bool) {
authHeader := strings.TrimSpace(r.Header.Get("Authorization"))
if authHeader != "" {
const bearerPrefix = "Bearer "
if len(authHeader) > len(bearerPrefix) && strings.EqualFold(authHeader[:len(bearerPrefix)], bearerPrefix) {
return strings.TrimSpace(authHeader[len(bearerPrefix):]), true
}
}
if cfg.APIKeyHeader != "" {
if headerValue := strings.TrimSpace(r.Header.Get(cfg.APIKeyHeader)); headerValue != "" {
return headerValue, true
}
}
// Accept the common X-API-Key header even if callers customize the config.
if cfg.APIKeyHeader != "X-API-Key" && cfg.APIKeyHeader != "x-api-key" {
if headerValue := strings.TrimSpace(r.Header.Get("X-API-Key")); headerValue != "" {
return headerValue, true
}
}
return "", false
}
func isLocalhostRequest(remoteAddr string) bool {
host, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
host = remoteAddr
}
host = strings.TrimSpace(strings.Trim(host, "[]"))
if host == "localhost" {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}

View file

@ -0,0 +1,126 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/seifghazi/claude-code-monitor/internal/config"
)
func TestAuthAllowsLocalhostBypass(t *testing.T) {
called := false
handler := Auth(config.AuthConfig{
Enabled: true,
Token: "secret",
APIKeyHeader: "X-API-Key",
AllowLocalhostBypass: true,
})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodPost, "http://example.local/v1/messages", nil)
req.RemoteAddr = "127.0.0.1:45678"
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if !called {
t.Fatal("expected localhost request to bypass auth")
}
}
func TestAuthRejectsMissingCredentials(t *testing.T) {
handler := Auth(config.AuthConfig{
Enabled: true,
Token: "secret",
APIKeyHeader: "X-API-Key",
})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodPost, "http://example.local/v1/messages", nil)
req.RemoteAddr = "10.1.2.3:45678"
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", rr.Code)
}
}
func TestAuthAcceptsBearerAndAPIKey(t *testing.T) {
testCases := []struct {
name string
setup func(*http.Request)
header string
}{
{
name: "bearer",
setup: func(r *http.Request) {
r.Header.Set("Authorization", "Bearer secret")
},
header: "Authorization",
},
{
name: "api-key",
setup: func(r *http.Request) {
r.Header.Set("X-API-Key", "secret")
},
header: "X-API-Key",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
called := false
handler := Auth(config.AuthConfig{
Enabled: true,
Token: "secret",
APIKeyHeader: "X-API-Key",
})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodPost, "http://example.local/v1/messages", nil)
req.RemoteAddr = "10.1.2.3:45678"
tc.setup(req)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if !called {
t.Fatalf("expected %s auth to pass", tc.header)
}
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
})
}
}
func TestAuthSkipsHealthAndOptions(t *testing.T) {
handler := Auth(config.AuthConfig{
Enabled: true,
Token: "secret",
})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "http://example.local/health", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected health request to bypass auth, got %d", rr.Code)
}
req = httptest.NewRequest(http.MethodOptions, "http://example.local/v1/messages", nil)
rr = httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected OPTIONS request to bypass auth, got %d", rr.Code)
}
}

View file

@ -59,8 +59,12 @@ type Conversation struct {
func (cs *conversationService) GetConversations() (map[string][]*Conversation, error) {
conversations := make(map[string][]*Conversation)
var parseErrors []string
rootPath, err := cs.projectsRoot()
if err != nil {
return nil, fmt.Errorf("failed to resolve claude projects root: %w", err)
}
err := filepath.Walk(cs.claudeProjectsPath, func(path string, info os.FileInfo, err error) error {
err = filepath.Walk(rootPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
// Log but don't fail the entire walk
parseErrors = append(parseErrors, fmt.Sprintf("Error accessing %s: %v", path, err))
@ -71,16 +75,27 @@ func (cs *conversationService) GetConversations() (map[string][]*Conversation, e
return nil
}
// Get the project path relative to claudeProjectsPath
projectDir := filepath.Dir(path)
projectRelPath, _ := filepath.Rel(cs.claudeProjectsPath, projectDir)
// Reject symlinked files or paths that escape the projects root.
resolvedPath, err := cs.resolveExistingPathWithinProjectsRoot(path)
if err != nil {
parseErrors = append(parseErrors, fmt.Sprintf("Skipping %s: %v", path, err))
return nil
}
// Get the project path relative to the resolved root.
projectDir := filepath.Dir(resolvedPath)
projectRelPath, err := filepath.Rel(rootPath, projectDir)
if err != nil {
parseErrors = append(parseErrors, fmt.Sprintf("Skipping %s: %v", path, err))
return nil
}
// Skip files directly in the projects directory
if projectRelPath == "." || projectRelPath == "" {
return nil
}
conv, err := cs.parseConversationFile(path, projectRelPath)
conv, err := cs.parseConversationFile(resolvedPath, projectRelPath)
if err != nil {
// Log parsing errors but continue processing other files
parseErrors = append(parseErrors, fmt.Sprintf("Failed to parse %s: %v", path, err))
@ -113,9 +128,12 @@ func (cs *conversationService) GetConversations() (map[string][]*Conversation, e
// GetConversation returns a specific conversation by project and session ID
func (cs *conversationService) GetConversation(projectPath, sessionID string) (*Conversation, error) {
filePath := filepath.Join(cs.claudeProjectsPath, projectPath, sessionID+".jsonl")
filePath, resolvedProjectPath, err := cs.resolveConversationFile(projectPath, sessionID)
if err != nil {
return nil, fmt.Errorf("failed to resolve conversation path: %w", err)
}
conv, err := cs.parseConversationFile(filePath, projectPath)
conv, err := cs.parseConversationFile(filePath, resolvedProjectPath)
if err != nil {
return nil, fmt.Errorf("failed to parse conversation: %w", err)
}
@ -126,7 +144,10 @@ func (cs *conversationService) GetConversation(projectPath, sessionID string) (*
// GetConversationsByProject returns all conversations for a specific project
func (cs *conversationService) GetConversationsByProject(projectPath string) ([]*Conversation, error) {
var conversations []*Conversation
projectDir := filepath.Join(cs.claudeProjectsPath, projectPath)
projectDir, resolvedProjectPath, err := cs.resolveProjectDir(projectPath)
if err != nil {
return nil, fmt.Errorf("failed to resolve project path: %w", err)
}
files, err := os.ReadDir(projectDir)
if err != nil {
@ -139,7 +160,7 @@ func (cs *conversationService) GetConversationsByProject(projectPath string) ([]
}
filePath := filepath.Join(projectDir, file.Name())
conv, err := cs.parseConversationFile(filePath, projectPath)
conv, err := cs.parseConversationFile(filePath, resolvedProjectPath)
if err != nil {
continue
}
@ -157,6 +178,124 @@ func (cs *conversationService) GetConversationsByProject(projectPath string) ([]
return conversations, nil
}
func (cs *conversationService) projectsRoot() (string, error) {
root, err := filepath.Abs(cs.claudeProjectsPath)
if err != nil {
return "", fmt.Errorf("failed to make projects root absolute: %w", err)
}
resolvedRoot, err := filepath.EvalSymlinks(root)
if err != nil {
if os.IsNotExist(err) {
return root, nil
}
return "", fmt.Errorf("failed to resolve projects root symlinks: %w", err)
}
return resolvedRoot, nil
}
func (cs *conversationService) resolveProjectDir(projectPath string) (string, string, error) {
cleanedProjectPath, err := cleanRelativeConversationPath(projectPath)
if err != nil {
return "", "", err
}
rootPath, err := cs.projectsRoot()
if err != nil {
return "", "", err
}
candidate := filepath.Join(rootPath, cleanedProjectPath)
resolvedCandidate, err := cs.resolveExistingPathWithinProjectsRoot(candidate)
if err != nil {
return "", "", err
}
return resolvedCandidate, cleanedProjectPath, nil
}
func (cs *conversationService) resolveConversationFile(projectPath, sessionID string) (string, string, error) {
if sessionID == "" {
return "", "", fmt.Errorf("session ID is required")
}
if sessionID != filepath.Base(sessionID) || sessionID == "." || sessionID == ".." {
return "", "", fmt.Errorf("invalid session ID: %s", sessionID)
}
projectDir, cleanedProjectPath, err := cs.resolveProjectDir(projectPath)
if err != nil {
return "", "", err
}
candidate := filepath.Join(projectDir, sessionID+".jsonl")
resolvedCandidate, err := cs.resolveExistingPathWithinProjectsRoot(candidate)
if err != nil {
return "", "", err
}
return resolvedCandidate, cleanedProjectPath, nil
}
func (cs *conversationService) resolveExistingPathWithinProjectsRoot(path string) (string, error) {
rootPath, err := cs.projectsRoot()
if err != nil {
return "", err
}
absolutePath, err := filepath.Abs(path)
if err != nil {
return "", fmt.Errorf("failed to make path absolute: %w", err)
}
normalizedPath := filepath.Clean(absolutePath)
if !pathWithinRoot(normalizedPath, rootPath) {
return "", fmt.Errorf("path escapes projects root: %s", path)
}
resolvedPath, err := filepath.EvalSymlinks(normalizedPath)
if err != nil {
return "", fmt.Errorf("failed to resolve path symlinks: %w", err)
}
if !pathWithinRoot(resolvedPath, rootPath) {
return "", fmt.Errorf("path escapes projects root after symlink resolution: %s", path)
}
return resolvedPath, nil
}
func cleanRelativeConversationPath(p string) (string, error) {
if p == "" {
return "", fmt.Errorf("path is required")
}
if filepath.IsAbs(p) {
return "", fmt.Errorf("absolute paths are not allowed: %s", p)
}
cleaned := filepath.Clean(p)
if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(os.PathSeparator)) {
return "", fmt.Errorf("path escapes projects root: %s", p)
}
return cleaned, nil
}
func pathWithinRoot(candidatePath, rootPath string) bool {
relPath, err := filepath.Rel(rootPath, candidatePath)
if err != nil {
return false
}
if relPath == "." {
return true
}
return relPath != ".." && !strings.HasPrefix(relPath, ".."+string(os.PathSeparator))
}
// parseConversationFile reads and parses a JSONL conversation file
func (cs *conversationService) parseConversationFile(filePath, projectPath string) (*Conversation, error) {
// Get file info for modification time

View file

@ -0,0 +1,107 @@
package service
import (
"os"
"path/filepath"
"testing"
)
func TestConversationServiceAllowsNestedProjectPaths(t *testing.T) {
root := t.TempDir()
projectDir := filepath.Join(root, "team", "app")
if err := os.MkdirAll(projectDir, 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
sessionPath := filepath.Join(projectDir, "session.jsonl")
if err := os.WriteFile(sessionPath, []byte(`{"timestamp":"2026-03-19T12:00:00Z","type":"user","message":"hello"}`+"\n"), 0o600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
svc := &conversationService{claudeProjectsPath: root}
conversation, err := svc.GetConversation("team/app", "session")
if err != nil {
t.Fatalf("GetConversation() error = %v", err)
}
if conversation.SessionID != "session" {
t.Fatalf("expected session ID %q, got %q", "session", conversation.SessionID)
}
if conversation.ProjectPath != "team/app" {
t.Fatalf("expected project path %q, got %q", "team/app", conversation.ProjectPath)
}
if len(conversation.Messages) != 1 {
t.Fatalf("expected 1 message, got %d", len(conversation.Messages))
}
conversations, err := svc.GetConversationsByProject("team/app")
if err != nil {
t.Fatalf("GetConversationsByProject() error = %v", err)
}
if len(conversations) != 1 {
t.Fatalf("expected 1 conversation, got %d", len(conversations))
}
}
func TestConversationServiceRejectsTraversalPaths(t *testing.T) {
root := t.TempDir()
projectDir := filepath.Join(root, "team", "app")
if err := os.MkdirAll(projectDir, 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
sessionPath := filepath.Join(projectDir, "session.jsonl")
if err := os.WriteFile(sessionPath, []byte(`{"timestamp":"2026-03-19T12:00:00Z","type":"user","message":"hello"}`+"\n"), 0o600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
svc := &conversationService{claudeProjectsPath: root}
if _, err := svc.GetConversation("../outside", "session"); err == nil {
t.Fatal("expected traversal project path to be rejected")
}
if _, err := svc.GetConversation("team/app", "../session"); err == nil {
t.Fatal("expected traversal session ID to be rejected")
}
if _, err := svc.GetConversationsByProject("../../outside"); err == nil {
t.Fatal("expected traversal project listing to be rejected")
}
}
func TestConversationServiceRejectsSymlinkEscapes(t *testing.T) {
root := t.TempDir()
projectDir := filepath.Join(root, "team")
if err := os.MkdirAll(projectDir, 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
outsideDir := filepath.Join(t.TempDir(), "outside")
if err := os.MkdirAll(outsideDir, 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.WriteFile(filepath.Join(outsideDir, "session.jsonl"), []byte(`{"timestamp":"2026-03-19T12:00:00Z","type":"user","message":"hello"}`+"\n"), 0o600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
linkPath := filepath.Join(projectDir, "app")
if err := os.Symlink(outsideDir, linkPath); err != nil {
t.Skipf("symlink not supported in this environment: %v", err)
}
svc := &conversationService{claudeProjectsPath: root}
if _, err := svc.GetConversation("team/app", "session"); err == nil {
t.Fatal("expected symlink escape to be rejected")
}
if _, err := svc.GetConversationsByProject("team/app"); err == nil {
t.Fatal("expected symlink project listing to be rejected")
}
}

View file

@ -74,6 +74,10 @@ func NewSQLiteStorageServiceWithLogger(cfg *config.StorageConfig, logger *log.Lo
return nil, fmt.Errorf("failed to prepare statements: %w", err)
}
if err := service.cleanupExpiredRequests(); err != nil {
logger.Printf("Warning: failed to apply retention policy during startup: %v", err)
}
return service, nil
}
@ -194,7 +198,12 @@ func (s *sqliteStorageService) SaveRequest(request *model.RequestLog) (string, e
return "", fmt.Errorf("failed to marshal headers: %w", err)
}
bodyJSON, err := json.Marshal(request.Body)
bodyForStorage, err := s.prepareRequestBodyForStorage(request.Body)
if err != nil {
return "", fmt.Errorf("failed to prepare body for storage: %w", err)
}
bodyJSON, err := json.Marshal(bodyForStorage)
if err != nil {
return "", fmt.Errorf("failed to marshal body: %w", err)
}
@ -217,6 +226,10 @@ func (s *sqliteStorageService) SaveRequest(request *model.RequestLog) (string, e
return "", fmt.Errorf("failed to insert request: %w", err)
}
if err := s.cleanupExpiredRequests(); err != nil {
s.logger.Printf("Warning: failed to apply retention policy: %v", err)
}
return request.RequestID, nil
}
@ -302,11 +315,20 @@ func (s *sqliteStorageService) UpdateRequestWithGrading(requestID string, grade
return fmt.Errorf("request %s not found", requestID)
}
if err := s.cleanupExpiredRequests(); err != nil {
s.logger.Printf("Warning: failed to apply retention policy: %v", err)
}
return nil
}
func (s *sqliteStorageService) UpdateRequestWithResponse(request *model.RequestLog) error {
responseJSON, err := json.Marshal(request.Response)
responseForStorage, err := s.prepareResponseForStorage(request.Response)
if err != nil {
return fmt.Errorf("failed to prepare response for storage: %w", err)
}
responseJSON, err := json.Marshal(responseForStorage)
if err != nil {
return fmt.Errorf("failed to marshal response: %w", err)
}
@ -337,7 +359,12 @@ func (s *sqliteStorageService) SaveRequestWithResponse(request *model.RequestLog
return fmt.Errorf("failed to marshal headers: %w", err)
}
bodyJSON, err := json.Marshal(request.Body)
bodyForStorage, err := s.prepareRequestBodyForStorage(request.Body)
if err != nil {
return fmt.Errorf("failed to prepare body for storage: %w", err)
}
bodyJSON, err := json.Marshal(bodyForStorage)
if err != nil {
return fmt.Errorf("failed to marshal body: %w", err)
}
@ -362,7 +389,12 @@ func (s *sqliteStorageService) SaveRequestWithResponse(request *model.RequestLog
// Update with response if present
if request.Response != nil {
responseJSON, err := json.Marshal(request.Response)
responseForStorage, err := s.prepareResponseForStorage(request.Response)
if err != nil {
return fmt.Errorf("failed to prepare response for storage: %w", err)
}
responseJSON, err := json.Marshal(responseForStorage)
if err != nil {
return fmt.Errorf("failed to marshal response: %w", err)
}
@ -377,6 +409,10 @@ func (s *sqliteStorageService) SaveRequestWithResponse(request *model.RequestLog
return fmt.Errorf("failed to commit transaction: %w", err)
}
if err := s.cleanupExpiredRequests(); err != nil {
s.logger.Printf("Warning: failed to apply retention policy: %v", err)
}
return nil
}
@ -579,6 +615,8 @@ func (s *sqliteStorageService) Close() error {
// Helper functions
const redactionPlaceholder = "[REDACTED]"
// escapeLikePattern escapes special characters in LIKE patterns
func escapeLikePattern(s string) string {
// Escape \, %, and _ characters
@ -696,3 +734,146 @@ func (s *sqliteStorageService) unmarshalRequestFields(req *model.RequestLog, hea
return nil
}
func (s *sqliteStorageService) cleanupExpiredRequests() error {
if s.config == nil || s.config.RetentionDays <= 0 {
return nil
}
_, err := s.DeleteRequestsOlderThan(time.Duration(s.config.RetentionDays) * 24 * time.Hour)
return err
}
func (s *sqliteStorageService) prepareRequestBodyForStorage(body interface{}) (interface{}, error) {
if s.shouldSuppressBodies() {
return storageBodyPlaceholder("metadata_only"), nil
}
if s.config != nil && !s.config.CaptureRequestBody {
return storageBodyPlaceholder("request_body_disabled"), nil
}
normalized, err := normalizeJSONValue(body)
if err != nil {
return nil, err
}
fields := []string{}
if s.config != nil {
fields = s.config.RedactedFields
}
return redactJSONValue(normalized, redactedFieldSet(fields)), nil
}
func (s *sqliteStorageService) prepareResponseForStorage(response *model.ResponseLog) (*model.ResponseLog, error) {
if response == nil {
return nil, nil
}
clone := *response
if s.shouldSuppressBodies() || (s.config != nil && !s.config.CaptureResponseBody) {
clone.Body = nil
clone.BodyText = ""
clone.StreamingChunks = nil
return &clone, nil
}
if len(clone.Body) > 0 {
fields := []string{}
if s.config != nil {
fields = s.config.RedactedFields
}
sanitizedBody, err := sanitizeRawJSON(clone.Body, redactedFieldSet(fields))
if err != nil {
// Preserve the original payload if it cannot be parsed as JSON.
s.logger.Printf("Warning: failed to redact response body: %v", err)
} else {
clone.Body = sanitizedBody
}
}
return &clone, nil
}
func (s *sqliteStorageService) shouldSuppressBodies() bool {
return s.config != nil && s.config.MetadataOnly
}
func normalizeJSONValue(value interface{}) (interface{}, error) {
if value == nil {
return nil, nil
}
data, err := json.Marshal(value)
if err != nil {
return nil, err
}
var normalized interface{}
if err := json.Unmarshal(data, &normalized); err != nil {
return nil, err
}
return normalized, nil
}
func sanitizeRawJSON(raw json.RawMessage, redacted map[string]struct{}) (json.RawMessage, error) {
if len(raw) == 0 {
return raw, nil
}
var value interface{}
if err := json.Unmarshal(raw, &value); err != nil {
return raw, err
}
sanitized := redactJSONValue(value, redacted)
data, err := json.Marshal(sanitized)
if err != nil {
return raw, err
}
return json.RawMessage(data), nil
}
func redactJSONValue(value interface{}, redacted map[string]struct{}) interface{} {
switch typed := value.(type) {
case map[string]interface{}:
result := make(map[string]interface{}, len(typed))
for key, child := range typed {
if _, ok := redacted[strings.ToLower(key)]; ok {
result[key] = redactionPlaceholder
continue
}
result[key] = redactJSONValue(child, redacted)
}
return result
case []interface{}:
result := make([]interface{}, len(typed))
for i, child := range typed {
result[i] = redactJSONValue(child, redacted)
}
return result
default:
return value
}
}
func storageBodyPlaceholder(mode string) map[string]interface{} {
return map[string]interface{}{
"_storage_mode": mode,
}
}
func redactedFieldSet(fields []string) map[string]struct{} {
set := make(map[string]struct{}, len(fields))
for _, field := range fields {
field = strings.TrimSpace(strings.ToLower(field))
if field == "" {
continue
}
set[field] = struct{}{}
}
return set
}

View file

@ -1,7 +1,7 @@
package service
import (
"fmt"
"encoding/json"
"path/filepath"
"testing"
"time"
@ -10,9 +10,276 @@ import (
"github.com/seifghazi/claude-code-monitor/internal/model"
)
func TestSQLiteStorageServiceGetRequestsUsesSQLPaginationAndFiltering(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "requests.db")
storage, err := NewSQLiteStorageService(&config.StorageConfig{DBPath: dbPath})
func TestSQLiteStorageServiceRedactsRequestAndResponseBodies(t *testing.T) {
storage := newTestSQLiteStorage(t, config.StorageConfig{
DBPath: filepath.Join(t.TempDir(), "requests.db"),
CaptureRequestBody: true,
CaptureResponseBody: true,
RedactedFields: []string{"api_key", "secret"},
})
request := &model.RequestLog{
RequestID: "redact-123",
Timestamp: time.Now().UTC().Format(time.RFC3339),
Method: "POST",
Endpoint: "/v1/messages",
Headers: map[string][]string{"Content-Type": {"application/json"}},
Body: map[string]interface{}{
"api_key": "abc123",
"nested": map[string]interface{}{
"secret": "top-secret",
"visible": "ok",
},
},
Model: "claude-3-5-sonnet",
UserAgent: "test",
ContentType: "application/json",
}
if _, err := storage.SaveRequest(request); err != nil {
t.Fatalf("SaveRequest() error = %v", err)
}
request.Response = &model.ResponseLog{
StatusCode: httpStatusOK,
Headers: map[string][]string{"Content-Type": {"application/json"}},
Body: json.RawMessage(`{"secret":"response-secret","visible":"yes"}`),
ResponseTime: 12,
CompletedAt: time.Now().UTC().Format(time.RFC3339),
}
if err := storage.UpdateRequestWithResponse(request); err != nil {
t.Fatalf("UpdateRequestWithResponse() error = %v", err)
}
got, _, err := storage.GetRequestByShortID("123")
if err != nil {
t.Fatalf("GetRequestByShortID() error = %v", err)
}
body, ok := got.Body.(map[string]interface{})
if !ok {
t.Fatalf("expected request body to be a map, got %T", got.Body)
}
if body["api_key"] != redactionPlaceholder {
t.Fatalf("expected api_key to be redacted, got %#v", body["api_key"])
}
nested, ok := body["nested"].(map[string]interface{})
if !ok {
t.Fatalf("expected nested body to be a map, got %T", body["nested"])
}
if nested["secret"] != redactionPlaceholder {
t.Fatalf("expected nested secret to be redacted, got %#v", nested["secret"])
}
if nested["visible"] != "ok" {
t.Fatalf("expected visible field to remain, got %#v", nested["visible"])
}
if got.Response == nil || len(got.Response.Body) == 0 {
t.Fatal("expected response body to be stored")
}
var responseBody map[string]interface{}
if err := json.Unmarshal(got.Response.Body, &responseBody); err != nil {
t.Fatalf("response body unmarshal failed: %v", err)
}
if responseBody["secret"] != redactionPlaceholder {
t.Fatalf("expected response secret to be redacted, got %#v", responseBody["secret"])
}
if responseBody["visible"] != "yes" {
t.Fatalf("expected response visible field to remain, got %#v", responseBody["visible"])
}
}
func TestSQLiteStorageServiceHonorsMetadataOnlyMode(t *testing.T) {
storage := newTestSQLiteStorage(t, config.StorageConfig{
DBPath: filepath.Join(t.TempDir(), "requests.db"),
CaptureRequestBody: true,
CaptureResponseBody: true,
MetadataOnly: true,
})
request := &model.RequestLog{
RequestID: "metadata-123",
Timestamp: time.Now().UTC().Format(time.RFC3339),
Method: "POST",
Endpoint: "/v1/messages",
Headers: map[string][]string{"Content-Type": {"application/json"}},
Body: map[string]interface{}{
"message": "keep me out of storage",
},
Model: "claude-3-5-sonnet",
UserAgent: "test",
ContentType: "application/json",
Response: &model.ResponseLog{
StatusCode: httpStatusOK,
Headers: map[string][]string{"Content-Type": {"application/json"}},
Body: json.RawMessage(`{"answer":"secret"}`),
StreamingChunks: []string{
"data: chunk-1",
},
ResponseTime: 10,
CompletedAt: time.Now().UTC().Format(time.RFC3339),
},
}
if _, err := storage.SaveRequest(request); err != nil {
t.Fatalf("SaveRequest() error = %v", err)
}
if err := storage.UpdateRequestWithResponse(request); err != nil {
t.Fatalf("UpdateRequestWithResponse() error = %v", err)
}
got, _, err := storage.GetRequestByShortID("123")
if err != nil {
t.Fatalf("GetRequestByShortID() error = %v", err)
}
body, ok := got.Body.(map[string]interface{})
if !ok {
t.Fatalf("expected metadata-only body placeholder map, got %T", got.Body)
}
if body["_storage_mode"] != "metadata_only" {
t.Fatalf("expected metadata-only placeholder, got %#v", body["_storage_mode"])
}
if got.Response == nil {
t.Fatal("expected response log to exist")
}
if len(got.Response.Body) != 0 {
t.Fatalf("expected response body to be removed, got %s", string(got.Response.Body))
}
if got.Response.BodyText != "" {
t.Fatalf("expected response body text to be removed, got %q", got.Response.BodyText)
}
if len(got.Response.StreamingChunks) != 0 {
t.Fatalf("expected streaming chunks to be removed, got %d", len(got.Response.StreamingChunks))
}
if got.Response.StatusCode != httpStatusOK {
t.Fatalf("expected response status to remain, got %d", got.Response.StatusCode)
}
}
func TestSQLiteStorageServiceHonorsBodyCaptureToggles(t *testing.T) {
storage := newTestSQLiteStorage(t, config.StorageConfig{
DBPath: filepath.Join(t.TempDir(), "requests.db"),
CaptureRequestBody: false,
CaptureResponseBody: false,
MetadataOnly: false,
})
request := &model.RequestLog{
RequestID: "toggle-123",
Timestamp: time.Now().UTC().Format(time.RFC3339),
Method: "POST",
Endpoint: "/v1/messages",
Headers: map[string][]string{"Content-Type": {"application/json"}},
Body: map[string]interface{}{
"message": "do not store me",
},
Model: "claude-3-5-sonnet",
UserAgent: "test",
ContentType: "application/json",
Response: &model.ResponseLog{
StatusCode: httpStatusOK,
Headers: map[string][]string{"Content-Type": {"application/json"}},
Body: json.RawMessage(`{"answer":"do not store me"}`),
BodyText: "sensitive text",
StreamingChunks: []string{"data: chunk-1"},
ResponseTime: 10,
CompletedAt: time.Now().UTC().Format(time.RFC3339),
},
}
if _, err := storage.SaveRequest(request); err != nil {
t.Fatalf("SaveRequest() error = %v", err)
}
if err := storage.UpdateRequestWithResponse(request); err != nil {
t.Fatalf("UpdateRequestWithResponse() error = %v", err)
}
got, _, err := storage.GetRequestByShortID("123")
if err != nil {
t.Fatalf("GetRequestByShortID() error = %v", err)
}
body, ok := got.Body.(map[string]interface{})
if !ok {
t.Fatalf("expected body placeholder map, got %T", got.Body)
}
if body["_storage_mode"] != "request_body_disabled" {
t.Fatalf("expected request body disabled placeholder, got %#v", body["_storage_mode"])
}
if got.Response == nil {
t.Fatal("expected response log to exist")
}
if len(got.Response.Body) != 0 {
t.Fatalf("expected response body to be omitted, got %s", string(got.Response.Body))
}
if got.Response.BodyText != "" {
t.Fatalf("expected response body text to be omitted, got %q", got.Response.BodyText)
}
if len(got.Response.StreamingChunks) != 0 {
t.Fatalf("expected streaming chunks to be omitted, got %d", len(got.Response.StreamingChunks))
}
}
func TestSQLiteStorageServiceDeletesExpiredRequestsOnWrite(t *testing.T) {
storage := newTestSQLiteStorage(t, config.StorageConfig{
DBPath: filepath.Join(t.TempDir(), "requests.db"),
RetentionDays: 1,
RedactedFields: []string{},
})
oldRequest := &model.RequestLog{
RequestID: "old-123",
Timestamp: time.Now().Add(-48 * time.Hour).UTC().Format(time.RFC3339),
Method: "POST",
Endpoint: "/v1/messages",
Headers: map[string][]string{"Content-Type": {"application/json"}},
Body: map[string]interface{}{"message": "old"},
Model: "claude-3-5-sonnet",
UserAgent: "test",
ContentType: "application/json",
}
if _, err := storage.SaveRequest(oldRequest); err != nil {
t.Fatalf("SaveRequest(old) error = %v", err)
}
recentRequest := &model.RequestLog{
RequestID: "recent-123",
Timestamp: time.Now().UTC().Format(time.RFC3339),
Method: "POST",
Endpoint: "/v1/messages",
Headers: map[string][]string{"Content-Type": {"application/json"}},
Body: map[string]interface{}{"message": "recent"},
Model: "claude-3-5-sonnet",
UserAgent: "test",
ContentType: "application/json",
}
if _, err := storage.SaveRequest(recentRequest); err != nil {
t.Fatalf("SaveRequest(recent) error = %v", err)
}
got, err := storage.GetAllRequests("all")
if err != nil {
t.Fatalf("GetAllRequests() error = %v", err)
}
if len(got) != 1 {
t.Fatalf("expected 1 request after retention cleanup, got %d", len(got))
}
if got[0].RequestID != "recent-123" {
t.Fatalf("expected recent request to remain, got %s", got[0].RequestID)
}
}
func newTestSQLiteStorage(t *testing.T, cfg config.StorageConfig) *sqliteStorageService {
t.Helper()
storage, err := NewSQLiteStorageService(&cfg)
if err != nil {
t.Fatalf("NewSQLiteStorageService() error = %v", err)
}
@ -21,49 +288,13 @@ func TestSQLiteStorageServiceGetRequestsUsesSQLPaginationAndFiltering(t *testing
if !ok {
t.Fatalf("unexpected storage type %T", storage)
}
defer sqliteStorage.Close()
requests := []struct {
id string
model string
}{
{id: "1", model: "claude-3-5-sonnet"},
{id: "2", model: "gpt-4o"},
{id: "3", model: "claude-3-5-sonnet"},
{id: "4", model: "gpt-4o-mini"},
}
for i, req := range requests {
_, err := storage.SaveRequest(&model.RequestLog{
RequestID: req.id,
Timestamp: time.Date(2026, 3, 19, 12, 0, i, 0, time.UTC).Format(time.RFC3339),
Method: "POST",
Endpoint: "/v1/messages",
Headers: map[string][]string{"Content-Type": {"application/json"}},
Body: map[string]string{"request": fmt.Sprintf("body-%d", i)},
Model: req.model,
UserAgent: "test",
ContentType: "application/json",
})
if err != nil {
t.Fatalf("SaveRequest() error = %v", err)
t.Cleanup(func() {
if err := sqliteStorage.Close(); err != nil {
t.Errorf("Close() error = %v", err)
}
}
})
got, total, err := storage.GetRequests(1, 1, "gpt")
if err != nil {
t.Fatalf("GetRequests() error = %v", err)
}
if total != 2 {
t.Fatalf("expected filtered total 2, got %d", total)
}
if len(got) != 1 {
t.Fatalf("expected 1 paginated result, got %d", len(got))
}
if got[0].RequestID != "4" {
t.Fatalf("expected newest filtered request ID 4, got %s", got[0].RequestID)
}
return sqliteStorage
}
const httpStatusOK = 200