feat: add SQLite storage for login attempts and sessions
Adds persistent storage using modernc.org/sqlite (pure Go). Login attempts are deduplicated by (username, password, ip) with counts. Sessions and session logs are tracked with UUID IDs. Includes embedded SQL migrations, configurable retention with background pruning, and an in-memory store for tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
69
internal/storage/retention_test.go
Normal file
69
internal/storage/retention_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRunRetentionDeletesOldRecords(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
logger := slog.Default()
|
||||
|
||||
// Insert an old login attempt (200 days ago).
|
||||
oldTime := time.Now().AddDate(0, 0, -200).UTC().Format(time.RFC3339)
|
||||
_, err := store.db.Exec(`
|
||||
INSERT INTO login_attempts (username, password, ip, count, first_seen, last_seen)
|
||||
VALUES ('old', 'old', '1.1.1.1', 1, ?, ?)`, oldTime, oldTime)
|
||||
if err != nil {
|
||||
t.Fatalf("insert old attempt: %v", err)
|
||||
}
|
||||
|
||||
// Insert a recent login attempt.
|
||||
if err := store.RecordLoginAttempt(ctx, "new", "new", "2.2.2.2"); err != nil {
|
||||
t.Fatalf("insert recent attempt: %v", err)
|
||||
}
|
||||
|
||||
// Run retention with a short interval. Cancel immediately after first run.
|
||||
retentionCtx, cancel := context.WithCancel(ctx)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
RunRetention(retentionCtx, store, 90, 24*time.Hour, logger)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// Give it a moment to run the initial prune.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
cancel()
|
||||
<-done
|
||||
|
||||
// Verify old record was deleted.
|
||||
var count int
|
||||
store.db.QueryRow(`SELECT COUNT(*) FROM login_attempts`).Scan(&count)
|
||||
if count != 1 {
|
||||
t.Errorf("remaining attempts = %d, want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRetentionCancellation(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
logger := slog.Default()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
RunRetention(ctx, store, 90, time.Millisecond, logger)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// Cancel and verify it exits.
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
// OK
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("RunRetention did not exit after cancel")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user