diff --git a/audit_test.go b/audit_test.go new file mode 100644 index 0000000..83c0ed1 --- /dev/null +++ b/audit_test.go @@ -0,0 +1,146 @@ +package audit_test + +import ( + "path/filepath" + "testing" + "time" + "log/slog" + "os" + + "github.com/mailarchive/internal/audit" +) + +func newTestAudit(t *testing.T) *audit.Logger { + t.Helper() + dir := t.TempDir() + logger := slog.New(slog.NewTextHandler(os.Discard, nil)) + l, err := audit.New(filepath.Join(dir, "audit.db"), dir, logger) + if err != nil { + t.Fatalf("audit.New: %v", err) + } + t.Cleanup(func() { l.Close() }) + return l +} + +func TestLogAndQuery(t *testing.T) { + l := newTestAudit(t) + + l.Log(audit.Entry{ + EventType: audit.EventLogin, + Username: "alice", + IPAddress: "192.168.1.1", + Success: true, + }) + l.Log(audit.Entry{ + EventType: audit.EventSearch, + Username: "alice", + IPAddress: "192.168.1.1", + Query: "invoice", + Success: true, + }) + l.Log(audit.Entry{ + EventType: audit.EventLogin, + Username: "bob", + IPAddress: "10.0.0.1", + Success: false, + Detail: "wrong password", + }) + + all, total, err := l.Query(audit.QueryFilter{PageSize: 50}) + if err != nil { + t.Fatalf("Query all: %v", err) + } + if total != 3 { + t.Errorf("expected 3 entries, got %d", total) + } + _ = all +} + +func TestQueryByUsername(t *testing.T) { + l := newTestAudit(t) + + l.Log(audit.Entry{EventType: audit.EventLogin, Username: "alice", Success: true}) + l.Log(audit.Entry{EventType: audit.EventSearch, Username: "alice", Success: true}) + l.Log(audit.Entry{EventType: audit.EventLogin, Username: "bob", Success: true}) + + entries, total, _ := l.Query(audit.QueryFilter{Username: "alice", PageSize: 50}) + if total != 2 { + t.Errorf("alice: expected 2 entries, got %d", total) + } + for _, e := range entries { + if e.Username != "alice" { + t.Errorf("got entry for user %q in alice filter", e.Username) + } + } +} + +func TestQueryByEventType(t *testing.T) { + l := newTestAudit(t) + + l.Log(audit.Entry{EventType: audit.EventLogin, Username: "alice", Success: true}) + l.Log(audit.Entry{EventType: audit.EventSearch, Username: "alice", Success: true}) + l.Log(audit.Entry{EventType: audit.EventMailView, Username: "alice", MailID: "abc123", Success: true}) + + _, total, _ := l.Query(audit.QueryFilter{EventType: audit.EventSearch, PageSize: 50}) + if total != 1 { + t.Errorf("search event filter: expected 1, got %d", total) + } +} + +func TestQueryByMailID(t *testing.T) { + l := newTestAudit(t) + + l.Log(audit.Entry{EventType: audit.EventMailView, Username: "alice", MailID: "mail-001", Success: true}) + l.Log(audit.Entry{EventType: audit.EventMailView, Username: "bob", MailID: "mail-001", Success: true}) + l.Log(audit.Entry{EventType: audit.EventMailView, Username: "alice", MailID: "mail-002", Success: true}) + + _, total, _ := l.Query(audit.QueryFilter{MailID: "mail-001", PageSize: 50}) + if total != 2 { + t.Errorf("mailID filter: expected 2, got %d", total) + } +} + +func TestQueryDateRange(t *testing.T) { + l := newTestAudit(t) + + l.Log(audit.Entry{EventType: audit.EventLogin, Username: "alice", Success: true}) + l.Log(audit.Entry{EventType: audit.EventLogin, Username: "bob", Success: true}) + + // Query with future date range — should return 0 + future := time.Now().Add(24 * time.Hour) + futureEnd := time.Now().Add(48 * time.Hour) + _, total, _ := l.Query(audit.QueryFilter{From: &future, To: &futureEnd, PageSize: 50}) + if total != 0 { + t.Errorf("future date range should return 0, got %d", total) + } + + // Query with past-to-now range — should return all + past := time.Now().Add(-1 * time.Minute) + now := time.Now().Add(1 * time.Minute) + _, total, _ = l.Query(audit.QueryFilter{From: &past, To: &now, PageSize: 50}) + if total != 2 { + t.Errorf("current date range should return 2, got %d", total) + } +} + +func TestQueryPagination(t *testing.T) { + l := newTestAudit(t) + + for i := 0; i < 10; i++ { + l.Log(audit.Entry{EventType: audit.EventSearch, Username: "alice", Success: true}) + } + + page0, total, _ := l.Query(audit.QueryFilter{PageSize: 4, Page: 0}) + _, _, _ = l.Query(audit.QueryFilter{PageSize: 4, Page: 1}) + page2, _, _ := l.Query(audit.QueryFilter{PageSize: 4, Page: 2}) + + if total != 10 { + t.Errorf("total = %d, want 10", total) + } + if len(page0) != 4 { + t.Errorf("page 0 len = %d, want 4", len(page0)) + } + if len(page2) != 2 { + t.Errorf("page 2 len = %d, want 2", len(page2)) + } +} diff --git a/auth_test.go b/auth_test.go new file mode 100644 index 0000000..f4c3e88 --- /dev/null +++ b/auth_test.go @@ -0,0 +1,161 @@ +package auth_test + +import ( + "path/filepath" + "testing" + + "github.com/mailarchive/internal/auth" + "github.com/mailarchive/internal/userstore" +) + +func newTestAuth(t *testing.T) (*auth.Manager, *userstore.Store) { + t.Helper() + store, err := userstore.New(filepath.Join(t.TempDir(), "users.db")) + if err != nil { + t.Fatalf("userstore.New: %v", err) + } + t.Cleanup(func() { store.Close() }) + + // Seed a test user + store.Create(userstore.CreateUserRequest{ + Username: "testadmin", + Email: "admin@example.com", + Password: "adminpass", + Role: userstore.RoleAdmin, + }) + store.Create(userstore.CreateUserRequest{ + Username: "regularuser", + Email: "user@example.com", + Password: "userpass", + Role: userstore.RoleUser, + }) + + mgr := auth.New(store, nil, "test-jwt-secret-32chars-long-enough") + return mgr, store +} + +func TestLoginSuccess(t *testing.T) { + mgr, _ := newTestAuth(t) + + token, user, err := mgr.Login("testadmin", "adminpass") + if err != nil { + t.Fatalf("Login: %v", err) + } + if token == "" { + t.Error("expected non-empty token") + } + if user.Username != "testadmin" { + t.Errorf("Username = %q", user.Username) + } + if user.Role != userstore.RoleAdmin { + t.Errorf("Role = %q", user.Role) + } +} + +func TestLoginWrongPassword(t *testing.T) { + mgr, _ := newTestAuth(t) + + if _, _, err := mgr.Login("testadmin", "wrongpass"); err == nil { + t.Error("expected error for wrong password") + } +} + +func TestLoginUnknownUser(t *testing.T) { + mgr, _ := newTestAuth(t) + + if _, _, err := mgr.Login("nobody", "pw"); err == nil { + t.Error("expected error for unknown user") + } +} + +func TestTokenValidation(t *testing.T) { + mgr, _ := newTestAuth(t) + + token, _, _ := mgr.Login("testadmin", "adminpass") + sess, err := mgr.ValidateToken(token) + if err != nil { + t.Fatalf("ValidateToken: %v", err) + } + if sess.Username != "testadmin" { + t.Errorf("Session Username = %q", sess.Username) + } + if sess.Role != userstore.RoleAdmin { + t.Errorf("Session Role = %q", sess.Role) + } + if sess.JTI == "" { + t.Error("Session JTI should not be empty") + } +} + +func TestTokenTampering(t *testing.T) { + mgr, _ := newTestAuth(t) + + token, _, _ := mgr.Login("testadmin", "adminpass") + tampered := token + "x" + + if _, err := mgr.ValidateToken(tampered); err == nil { + t.Error("tampered token should fail validation") + } +} + +func TestLogout(t *testing.T) { + mgr, _ := newTestAuth(t) + + token, _, _ := mgr.Login("testadmin", "adminpass") + + // Token valid before logout + if _, err := mgr.ValidateToken(token); err != nil { + t.Fatalf("token should be valid before logout: %v", err) + } + + if err := mgr.Logout(token); err != nil { + t.Fatalf("Logout: %v", err) + } + + // Token invalid after logout + if _, err := mgr.ValidateToken(token); err == nil { + t.Error("token should be invalid after logout") + } +} + +func TestHasRole(t *testing.T) { + tests := []struct { + userRole string + required string + want bool + }{ + {userstore.RoleAdmin, userstore.RoleAdmin, true}, + {userstore.RoleAdmin, userstore.RoleAuditor, true}, + {userstore.RoleAdmin, userstore.RoleUser, true}, + {userstore.RoleAuditor, userstore.RoleAdmin, false}, + {userstore.RoleAuditor, userstore.RoleAuditor, true}, + {userstore.RoleAuditor, userstore.RoleUser, true}, + {userstore.RoleUser, userstore.RoleAdmin, false}, + {userstore.RoleUser, userstore.RoleAuditor, false}, + {userstore.RoleUser, userstore.RoleUser, true}, + } + + for _, tt := range tests { + got := auth.HasRole(tt.userRole, tt.required) + if got != tt.want { + t.Errorf("HasRole(%q, %q) = %v, want %v", tt.userRole, tt.required, got, tt.want) + } + } +} + +func TestMultipleSessionsIndependent(t *testing.T) { + mgr, _ := newTestAuth(t) + + token1, _, _ := mgr.Login("testadmin", "adminpass") + token2, _, _ := mgr.Login("testadmin", "adminpass") + + if token1 == token2 { + t.Error("two logins should produce different tokens (different JTIs)") + } + + // Logout session 1, session 2 should still work + mgr.Logout(token1) + if _, err := mgr.ValidateToken(token2); err != nil { + t.Errorf("session 2 should still be valid after session 1 logout: %v", err) + } +}