feat: add minimal web dashboard with stats, top credentials, and sessions

Implements Phase 1.5 — an embedded web UI using Go templates, Pico CSS
(dark theme), and htmx for auto-refreshing stats and active sessions.

Adds read query methods to the Store interface (GetDashboardStats,
GetTopUsernames, GetTopPasswords, GetTopIPs, GetRecentSessions) with
implementations for both SQLite and MemoryStore. Introduces the
internal/web package with server, handlers, templates, and tests.
Web server is opt-in via [web] config section and runs alongside
SSH with graceful shutdown. Bumps version to 0.2.0.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-14 20:59:12 +01:00
parent 85e79c97ac
commit 96c8476f77
20 changed files with 1104 additions and 2 deletions

161
internal/web/web_test.go Normal file
View File

@@ -0,0 +1,161 @@
package web
import (
"context"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.t-juice.club/torjus/oubliette/internal/storage"
)
func newTestServer(t *testing.T) *Server {
t.Helper()
store := storage.NewMemoryStore()
logger := slog.Default()
srv, err := NewServer(store, logger)
if err != nil {
t.Fatalf("creating server: %v", err)
}
return srv
}
func newSeededTestServer(t *testing.T) *Server {
t.Helper()
store := storage.NewMemoryStore()
ctx := context.Background()
for i := 0; i < 5; i++ {
if err := store.RecordLoginAttempt(ctx, "root", "toor", "10.0.0.1"); err != nil {
t.Fatalf("seeding attempt: %v", err)
}
}
if err := store.RecordLoginAttempt(ctx, "admin", "admin", "10.0.0.2"); err != nil {
t.Fatalf("seeding attempt: %v", err)
}
if _, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash"); err != nil {
t.Fatalf("creating session: %v", err)
}
if _, err := store.CreateSession(ctx, "10.0.0.2", "admin", "bash"); err != nil {
t.Fatalf("creating session: %v", err)
}
logger := slog.Default()
srv, err := NewServer(store, logger)
if err != nil {
t.Fatalf("creating server: %v", err)
}
return srv
}
func TestDashboardHandler(t *testing.T) {
t.Run("empty store", func(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "Oubliette") {
t.Error("response should contain 'Oubliette'")
}
if !strings.Contains(body, "No data") {
t.Error("response should contain 'No data' for empty tables")
}
})
t.Run("with data", func(t *testing.T) {
srv := newSeededTestServer(t)
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "root") {
t.Error("response should contain username 'root'")
}
if !strings.Contains(body, "10.0.0.1") {
t.Error("response should contain IP '10.0.0.1'")
}
})
}
func TestFragmentStats(t *testing.T) {
srv := newSeededTestServer(t)
req := httptest.NewRequest("GET", "/fragments/stats", nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
body := w.Body.String()
// Should be a fragment, not a full HTML page.
if strings.Contains(body, "<!DOCTYPE html>") {
t.Error("stats fragment should not contain full HTML document")
}
if !strings.Contains(body, "Total Attempts") {
t.Error("stats fragment should contain 'Total Attempts'")
}
}
func TestFragmentActiveSessions(t *testing.T) {
srv := newSeededTestServer(t)
req := httptest.NewRequest("GET", "/fragments/active-sessions", nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
body := w.Body.String()
if strings.Contains(body, "<!DOCTYPE html>") {
t.Error("active sessions fragment should not contain full HTML document")
}
// Both sessions are active (not ended).
if !strings.Contains(body, "10.0.0.1") {
t.Error("active sessions should contain IP '10.0.0.1'")
}
}
func TestStaticAssets(t *testing.T) {
srv := newTestServer(t)
tests := []struct {
path string
contentType string
}{
{"/static/pico.min.css", "text/css"},
{"/static/htmx.min.js", "text/javascript"},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
req := httptest.NewRequest("GET", tt.path, nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
ct := w.Header().Get("Content-Type")
if !strings.Contains(ct, tt.contentType) {
t.Errorf("Content-Type = %q, want to contain %q", ct, tt.contentType)
}
})
}
}