Persist byte-level I/O events from SSH sessions to SQLite and add a web
UI to replay them with original timing. Events are buffered in memory
and flushed every 2s to avoid blocking SSH I/O on database writes.
- Add session_events table (migration 002)
- Add SessionEvent type and storage methods (SQLite + MemoryStore)
- Change RecordingChannel to support multiple callbacks
- Add EventRecorder for buffered event persistence
- Add session detail page with xterm.js terminal replay
- Add /api/sessions/{id}/events JSON endpoint
- Linkify session IDs in dashboard and active sessions
- Vendor xterm.js v5.3.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
84 lines
2.2 KiB
Go
84 lines
2.2 KiB
Go
package storage
|
|
|
|
import (
|
|
"database/sql"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
func TestMigrateCreatesTablesAndVersion(t *testing.T) {
|
|
dbPath := filepath.Join(t.TempDir(), "test.db")
|
|
db, err := sql.Open("sqlite", dbPath)
|
|
if err != nil {
|
|
t.Fatalf("open: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
if err := Migrate(db); err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
|
|
// Verify schema version.
|
|
var version int
|
|
if err := db.QueryRow(`SELECT version FROM schema_version`).Scan(&version); err != nil {
|
|
t.Fatalf("query version: %v", err)
|
|
}
|
|
if version != 2 {
|
|
t.Errorf("version = %d, want 2", version)
|
|
}
|
|
|
|
// Verify tables exist by inserting into them.
|
|
_, err = db.Exec(`INSERT INTO login_attempts (username, password, ip, count, first_seen, last_seen) VALUES ('a', 'b', 'c', 1, '2024-01-01', '2024-01-01')`)
|
|
if err != nil {
|
|
t.Fatalf("insert into login_attempts: %v", err)
|
|
}
|
|
_, err = db.Exec(`INSERT INTO sessions (id, ip, username, shell_name, connected_at) VALUES ('test-id', 'c', 'a', '', '2024-01-01')`)
|
|
if err != nil {
|
|
t.Fatalf("insert into sessions: %v", err)
|
|
}
|
|
_, err = db.Exec(`INSERT INTO session_logs (session_id, timestamp, input, output) VALUES ('test-id', '2024-01-01', '', '')`)
|
|
if err != nil {
|
|
t.Fatalf("insert into session_logs: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestMigrateIdempotent(t *testing.T) {
|
|
dbPath := filepath.Join(t.TempDir(), "test.db")
|
|
db, err := sql.Open("sqlite", dbPath)
|
|
if err != nil {
|
|
t.Fatalf("open: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
// Run twice; second should be a no-op.
|
|
if err := Migrate(db); err != nil {
|
|
t.Fatalf("first migrate: %v", err)
|
|
}
|
|
if err := Migrate(db); err != nil {
|
|
t.Fatalf("second migrate: %v", err)
|
|
}
|
|
|
|
var version int
|
|
if err := db.QueryRow(`SELECT version FROM schema_version`).Scan(&version); err != nil {
|
|
t.Fatalf("query version: %v", err)
|
|
}
|
|
if version != 2 {
|
|
t.Errorf("version = %d after double migrate, want 2", version)
|
|
}
|
|
}
|
|
|
|
func TestLoadMigrations(t *testing.T) {
|
|
migrations, err := loadMigrations()
|
|
if err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
if len(migrations) == 0 {
|
|
t.Fatal("no migrations found")
|
|
}
|
|
if migrations[0].Version != 1 {
|
|
t.Errorf("first migration version = %d, want 1", migrations[0].Version)
|
|
}
|
|
}
|