diff --git a/internal/database/audit.go b/internal/database/audit.go index 7853ddf5..a4bfe6cb 100644 --- a/internal/database/audit.go +++ b/internal/database/audit.go @@ -69,12 +69,12 @@ func buildAuditLogsWhere(filter ListAuditLogsFilter) (string, []interface{}) { args = append(args, filter.ResourceID) } if filter.Since != nil { - conditions = append(conditions, "created_at >= ?") - args = append(args, *filter.Since) + conditions = append(conditions, sqliteEpochGE("created_at", ">=")) + args = append(args, formatSQLiteUTC(*filter.Since)) } if filter.Until != nil { - conditions = append(conditions, "created_at <= ?") - args = append(args, *filter.Until) + conditions = append(conditions, sqliteEpochGE("created_at", "<=")) + args = append(args, formatSQLiteUTC(*filter.Until)) } if q := strings.TrimSpace(filter.Query); q != "" { like := "%" + q + "%" @@ -93,7 +93,9 @@ func (db *DB) AppendAuditLog(row *AuditLog) error { return errors.New("audit id is required") } if row.CreatedAt.IsZero() { - row.CreatedAt = time.Now() + row.CreatedAt = time.Now().UTC() + } else { + row.CreatedAt = row.CreatedAt.UTC() } if strings.TrimSpace(row.Level) == "" { row.Level = "info" @@ -111,7 +113,7 @@ func (db *DB) AppendAuditLog(row *AuditLog) error { ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` _, err := db.Exec(query, - row.ID, row.CreatedAt, row.Level, row.Category, row.Action, row.Result, + row.ID, formatSQLiteUTC(row.CreatedAt), row.Level, row.Category, row.Action, row.Result, row.Actor, row.SessionHint, row.ClientIP, row.UserAgent, row.ResourceType, row.ResourceID, row.Message, detailJSON, ) @@ -202,7 +204,7 @@ func (db *DB) ListAuditLogs(filter ListAuditLogsFilter) ([]*AuditLog, error) { // DeleteAuditLogsBefore removes rows older than cutoff. func (db *DB) DeleteAuditLogsBefore(cutoff time.Time) (int64, error) { - res, err := db.Exec(`DELETE FROM audit_logs WHERE created_at < ?`, cutoff) + res, err := db.Exec(`DELETE FROM audit_logs WHERE `+sqliteEpochGE("created_at", "<"), formatSQLiteUTC(cutoff)) if err != nil { return 0, err } diff --git a/internal/database/audit_time_test.go b/internal/database/audit_time_test.go new file mode 100644 index 00000000..f4d36026 --- /dev/null +++ b/internal/database/audit_time_test.go @@ -0,0 +1,62 @@ +package database + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "go.uber.org/zap" +) + +func TestBuildAuditLogsWhere_timeFilterSQL(t *testing.T) { + since := time.Date(2026, 6, 16, 17, 2, 0, 0, time.UTC) + until := time.Date(2026, 6, 17, 3, 3, 0, 0, time.UTC) + where, args := buildAuditLogsWhere(ListAuditLogsFilter{Since: &since, Until: &until}) + if !strings.Contains(where, "strftime('%s', created_at) >=") { + t.Fatalf("expected epoch comparison for since, got %q", where) + } + if !strings.Contains(where, "strftime('%s', created_at) <=") { + t.Fatalf("expected epoch comparison for until, got %q", where) + } + if len(args) != 2 { + t.Fatalf("expected 2 time args, got %d", len(args)) + } + for i, arg := range args { + s, ok := arg.(string) + if !ok || s == "" { + t.Fatalf("arg %d: want non-empty UTC RFC3339 string, got %v", i, arg) + } + } +} + +func TestListAuditLogs_timeFilterMixedStorageFormats(t *testing.T) { + root, err := os.Getwd() + if err != nil { + t.Skip(err) + } + dbPath := filepath.Join(root, "..", "..", "data", "conversations.db") + if _, err := os.Stat(dbPath); err != nil { + t.Skip("conversations.db not found") + } + db, err := NewDB(dbPath, zap.NewNop()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + since, _ := ParseRFC3339Time("2026-06-16T17:02:00Z") + until, _ := ParseRFC3339Time("2026-06-17T03:03:00Z") + filter := ListAuditLogsFilter{Since: &since, Until: &until, Limit: 50} + logs, err := db.ListAuditLogs(filter) + if err != nil { + t.Fatal(err) + } + for _, row := range logs { + at := row.CreatedAt.UTC() + if at.Before(since) || at.After(until) { + t.Fatalf("log %s at %s outside [%s, %s]", row.ID, at, since, until) + } + } +} diff --git a/internal/database/sqltime.go b/internal/database/sqltime.go new file mode 100644 index 00000000..8089e44c --- /dev/null +++ b/internal/database/sqltime.go @@ -0,0 +1,33 @@ +package database + +import ( + "errors" + "strings" + "time" +) + +// formatSQLiteUTC stores instants as UTC RFC3339 for consistent SQLite reads/writes. +func formatSQLiteUTC(t time.Time) string { + return t.UTC().Format(time.RFC3339Nano) +} + +// sqliteEpochGE returns SQL comparing column to param as Unix seconds (timezone-safe). +func sqliteEpochGE(column, op string) string { + return "strftime('%s', " + column + ") " + op + " strftime('%s', ?)" +} + +// ParseRFC3339Time parses API/query timestamps (RFC3339 or RFC3339Nano). +func ParseRFC3339Time(value string) (time.Time, error) { + value = strings.TrimSpace(value) + if value == "" { + return time.Time{}, errors.New("empty time value") + } + if t, err := time.Parse(time.RFC3339Nano, value); err == nil { + return t.UTC(), nil + } + t, err := time.Parse(time.RFC3339, value) + if err != nil { + return time.Time{}, err + } + return t.UTC(), nil +}