Embeds a DB-IP Lite country MMDB (~5MB) in the binary via go:embed, keeping the single-binary deployment story clean. Country codes are stored alongside login attempts and sessions, shown in the dashboard (Top IPs, Top Countries card, Recent/Active Sessions, session detail). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
309 lines
7.9 KiB
Go
309 lines
7.9 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.t-juice.club/torjus/oubliette/internal/metrics"
|
|
"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, nil)
|
|
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 range 5 {
|
|
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, nil)
|
|
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(http.MethodGet, "/", 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(http.MethodGet, "/", 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(http.MethodGet, "/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(http.MethodGet, "/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 TestSessionDetailHandler(t *testing.T) {
|
|
t.Run("not found", func(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
req := httptest.NewRequest(http.MethodGet, "/sessions/nonexistent", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
srv.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("status = %d, want 404", w.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("found", func(t *testing.T) {
|
|
store := storage.NewMemoryStore()
|
|
ctx := context.Background()
|
|
id, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash", "")
|
|
if err != nil {
|
|
t.Fatalf("CreateSession: %v", err)
|
|
}
|
|
|
|
srv, err := NewServer(store, slog.Default(), nil)
|
|
if err != nil {
|
|
t.Fatalf("NewServer: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/sessions/"+id, 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, "10.0.0.1") {
|
|
t.Error("response should contain IP")
|
|
}
|
|
if !strings.Contains(body, "root") {
|
|
t.Error("response should contain username")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAPISessionEvents(t *testing.T) {
|
|
store := storage.NewMemoryStore()
|
|
ctx := context.Background()
|
|
id, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash", "")
|
|
if err != nil {
|
|
t.Fatalf("CreateSession: %v", err)
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
events := []storage.SessionEvent{
|
|
{SessionID: id, Timestamp: now, Direction: 0, Data: []byte("ls\n")},
|
|
{SessionID: id, Timestamp: now.Add(500 * time.Millisecond), Direction: 1, Data: []byte("file1\n")},
|
|
}
|
|
if err := store.AppendSessionEvents(ctx, events); err != nil {
|
|
t.Fatalf("AppendSessionEvents: %v", err)
|
|
}
|
|
|
|
srv, err := NewServer(store, slog.Default(), nil)
|
|
if err != nil {
|
|
t.Fatalf("NewServer: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/sessions/"+id+"/events", 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, "application/json") {
|
|
t.Errorf("Content-Type = %q, want application/json", ct)
|
|
}
|
|
|
|
var resp apiEventsResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decoding response: %v", err)
|
|
}
|
|
if len(resp.Events) != 2 {
|
|
t.Fatalf("len = %d, want 2", len(resp.Events))
|
|
}
|
|
// First event should have t=0 (relative).
|
|
if resp.Events[0].T != 0 {
|
|
t.Errorf("events[0].T = %d, want 0", resp.Events[0].T)
|
|
}
|
|
// Second event should have t=500 (500ms later).
|
|
if resp.Events[1].T != 500 {
|
|
t.Errorf("events[1].T = %d, want 500", resp.Events[1].T)
|
|
}
|
|
if resp.Events[0].D != 0 {
|
|
t.Errorf("events[0].D = %d, want 0", resp.Events[0].D)
|
|
}
|
|
if resp.Events[1].D != 1 {
|
|
t.Errorf("events[1].D = %d, want 1", resp.Events[1].D)
|
|
}
|
|
}
|
|
|
|
func TestMetricsEndpoint(t *testing.T) {
|
|
t.Run("enabled", func(t *testing.T) {
|
|
m := metrics.New("test")
|
|
store := storage.NewMemoryStore()
|
|
srv, err := NewServer(store, slog.Default(), m.Handler())
|
|
if err != nil {
|
|
t.Fatalf("NewServer: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/metrics", 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_build_info{version="test"} 1`) {
|
|
t.Errorf("response should contain build_info metric, got:\n%s", body)
|
|
}
|
|
})
|
|
|
|
t.Run("disabled", func(t *testing.T) {
|
|
store := storage.NewMemoryStore()
|
|
srv, err := NewServer(store, slog.Default(), nil)
|
|
if err != nil {
|
|
t.Fatalf("NewServer: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
|
w := httptest.NewRecorder()
|
|
srv.ServeHTTP(w, req)
|
|
|
|
// Without a metrics handler, /metrics falls through to the dashboard.
|
|
body := w.Body.String()
|
|
if strings.Contains(body, "oubliette_build_info") {
|
|
t.Error("response should not contain prometheus metrics when disabled")
|
|
}
|
|
})
|
|
}
|
|
|
|
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(http.MethodGet, 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)
|
|
}
|
|
})
|
|
}
|
|
}
|