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>
162 lines
3.9 KiB
Go
162 lines
3.9 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|