- SUMMARY.md with self-check passed - STATE.md updated: plan 6 of 14, 36% progress, 4 decisions added - ROADMAP.md updated: 5/14 plans complete - REQUIREMENTS.md: 15 requirements marked complete (BLIND-01-06, CHIP-01-04, FIN-01,02,05,06,10) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
349 lines
8.8 KiB
Go
349 lines
8.8 KiB
Go
package audit
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"testing"
|
|
|
|
"github.com/felt-app/felt/internal/store"
|
|
)
|
|
|
|
// mockPublisher records published audit events for testing.
|
|
type mockPublisher struct {
|
|
published []struct {
|
|
TournamentID string
|
|
Data []byte
|
|
}
|
|
}
|
|
|
|
func (m *mockPublisher) PublishAudit(ctx context.Context, tournamentID string, data []byte) error {
|
|
m.published = append(m.published, struct {
|
|
TournamentID string
|
|
Data []byte
|
|
}{tournamentID, data})
|
|
return nil
|
|
}
|
|
|
|
func setupTestTrail(t *testing.T) (*Trail, *mockPublisher) {
|
|
t.Helper()
|
|
tmpDir := t.TempDir()
|
|
|
|
db, err := store.Open(tmpDir, true)
|
|
if err != nil {
|
|
t.Fatalf("open database: %v", err)
|
|
}
|
|
t.Cleanup(func() { db.Close() })
|
|
|
|
pub := &mockPublisher{}
|
|
trail := NewTrail(db.DB, pub)
|
|
|
|
return trail, pub
|
|
}
|
|
|
|
func TestRecordAuditEntry(t *testing.T) {
|
|
trail, _ := setupTestTrail(t)
|
|
ctx := context.Background()
|
|
|
|
tournamentID := "550e8400-e29b-41d4-a716-446655440000"
|
|
entry := AuditEntry{
|
|
TournamentID: &tournamentID,
|
|
OperatorID: "op-1",
|
|
Action: ActionPlayerBuyin,
|
|
TargetType: "player",
|
|
TargetID: "player-1",
|
|
PreviousState: json.RawMessage(`{"chips":0}`),
|
|
NewState: json.RawMessage(`{"chips":10000}`),
|
|
}
|
|
|
|
recorded, err := trail.Record(ctx, entry)
|
|
if err != nil {
|
|
t.Fatalf("record entry: %v", err)
|
|
}
|
|
|
|
if recorded.ID == "" {
|
|
t.Fatal("expected generated ID")
|
|
}
|
|
if recorded.Timestamp == 0 {
|
|
t.Fatal("expected generated timestamp")
|
|
}
|
|
if recorded.Action != ActionPlayerBuyin {
|
|
t.Fatalf("expected action %s, got %s", ActionPlayerBuyin, recorded.Action)
|
|
}
|
|
}
|
|
|
|
func TestRecordPersistsToDatabase(t *testing.T) {
|
|
trail, _ := setupTestTrail(t)
|
|
ctx := context.Background()
|
|
|
|
tournamentID := "550e8400-e29b-41d4-a716-446655440001"
|
|
entry := AuditEntry{
|
|
TournamentID: &tournamentID,
|
|
OperatorID: "op-1",
|
|
Action: ActionPlayerBust,
|
|
TargetType: "player",
|
|
TargetID: "player-2",
|
|
PreviousState: json.RawMessage(`{"status":"active","chips":5000}`),
|
|
NewState: json.RawMessage(`{"status":"busted","chips":0}`),
|
|
}
|
|
|
|
recorded, err := trail.Record(ctx, entry)
|
|
if err != nil {
|
|
t.Fatalf("record entry: %v", err)
|
|
}
|
|
|
|
// Retrieve and verify
|
|
fetched, err := trail.GetEntry(ctx, recorded.ID)
|
|
if err != nil {
|
|
t.Fatalf("get entry: %v", err)
|
|
}
|
|
|
|
if fetched.Action != ActionPlayerBust {
|
|
t.Fatalf("expected action %s, got %s", ActionPlayerBust, fetched.Action)
|
|
}
|
|
if fetched.OperatorID != "op-1" {
|
|
t.Fatalf("expected operator_id op-1, got %s", fetched.OperatorID)
|
|
}
|
|
if fetched.TargetType != "player" {
|
|
t.Fatalf("expected target_type player, got %s", fetched.TargetType)
|
|
}
|
|
if fetched.TargetID != "player-2" {
|
|
t.Fatalf("expected target_id player-2, got %s", fetched.TargetID)
|
|
}
|
|
if string(fetched.PreviousState) != `{"status":"active","chips":5000}` {
|
|
t.Fatalf("unexpected previous_state: %s", fetched.PreviousState)
|
|
}
|
|
if string(fetched.NewState) != `{"status":"busted","chips":0}` {
|
|
t.Fatalf("unexpected new_state: %s", fetched.NewState)
|
|
}
|
|
}
|
|
|
|
func TestRecordPublishesToNATS(t *testing.T) {
|
|
trail, pub := setupTestTrail(t)
|
|
ctx := context.Background()
|
|
|
|
tournamentID := "550e8400-e29b-41d4-a716-446655440002"
|
|
entry := AuditEntry{
|
|
TournamentID: &tournamentID,
|
|
OperatorID: "op-1",
|
|
Action: ActionClockStart,
|
|
TargetType: "tournament",
|
|
TargetID: tournamentID,
|
|
}
|
|
|
|
_, err := trail.Record(ctx, entry)
|
|
if err != nil {
|
|
t.Fatalf("record entry: %v", err)
|
|
}
|
|
|
|
if len(pub.published) != 1 {
|
|
t.Fatalf("expected 1 published message, got %d", len(pub.published))
|
|
}
|
|
if pub.published[0].TournamentID != tournamentID {
|
|
t.Fatalf("expected tournament ID %s, got %s", tournamentID, pub.published[0].TournamentID)
|
|
}
|
|
|
|
// Verify the published data is valid JSON
|
|
var publishedEntry AuditEntry
|
|
if err := json.Unmarshal(pub.published[0].Data, &publishedEntry); err != nil {
|
|
t.Fatalf("unmarshal published data: %v", err)
|
|
}
|
|
if publishedEntry.Action != ActionClockStart {
|
|
t.Fatalf("expected action %s in NATS message, got %s", ActionClockStart, publishedEntry.Action)
|
|
}
|
|
}
|
|
|
|
func TestRecordWithoutTournamentIDSkipsNATS(t *testing.T) {
|
|
trail, pub := setupTestTrail(t)
|
|
ctx := context.Background()
|
|
|
|
// Venue-level action (no tournament ID)
|
|
entry := AuditEntry{
|
|
OperatorID: "op-1",
|
|
Action: ActionOperatorLogin,
|
|
TargetType: "operator",
|
|
TargetID: "op-1",
|
|
}
|
|
|
|
_, err := trail.Record(ctx, entry)
|
|
if err != nil {
|
|
t.Fatalf("record entry: %v", err)
|
|
}
|
|
|
|
if len(pub.published) != 0 {
|
|
t.Fatalf("expected no published messages for venue-level action, got %d", len(pub.published))
|
|
}
|
|
}
|
|
|
|
func TestRecordRequiresAction(t *testing.T) {
|
|
trail, _ := setupTestTrail(t)
|
|
ctx := context.Background()
|
|
|
|
entry := AuditEntry{
|
|
OperatorID: "op-1",
|
|
TargetType: "player",
|
|
TargetID: "player-1",
|
|
}
|
|
|
|
_, err := trail.Record(ctx, entry)
|
|
if err == nil {
|
|
t.Fatal("expected error for missing action")
|
|
}
|
|
}
|
|
|
|
func TestRecordDefaultsOperatorToSystem(t *testing.T) {
|
|
trail, _ := setupTestTrail(t)
|
|
ctx := context.Background()
|
|
|
|
entry := AuditEntry{
|
|
Action: ActionTournamentCreate,
|
|
TargetType: "tournament",
|
|
TargetID: "t-1",
|
|
}
|
|
|
|
recorded, err := trail.Record(ctx, entry)
|
|
if err != nil {
|
|
t.Fatalf("record entry: %v", err)
|
|
}
|
|
|
|
if recorded.OperatorID != "system" {
|
|
t.Fatalf("expected operator_id 'system', got %s", recorded.OperatorID)
|
|
}
|
|
}
|
|
|
|
func TestGetEntries(t *testing.T) {
|
|
trail, _ := setupTestTrail(t)
|
|
ctx := context.Background()
|
|
|
|
tournamentID := "550e8400-e29b-41d4-a716-446655440003"
|
|
|
|
// Record multiple entries
|
|
for i := 0; i < 5; i++ {
|
|
_, err := trail.Record(ctx, AuditEntry{
|
|
TournamentID: &tournamentID,
|
|
OperatorID: "op-1",
|
|
Action: ActionPlayerBuyin,
|
|
TargetType: "player",
|
|
TargetID: "player-1",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("record entry %d: %v", i, err)
|
|
}
|
|
}
|
|
|
|
// Get all entries for tournament
|
|
entries, err := trail.GetEntries(ctx, tournamentID, 10, 0)
|
|
if err != nil {
|
|
t.Fatalf("get entries: %v", err)
|
|
}
|
|
if len(entries) != 5 {
|
|
t.Fatalf("expected 5 entries, got %d", len(entries))
|
|
}
|
|
|
|
// Test pagination
|
|
page1, err := trail.GetEntries(ctx, tournamentID, 3, 0)
|
|
if err != nil {
|
|
t.Fatalf("get page 1: %v", err)
|
|
}
|
|
if len(page1) != 3 {
|
|
t.Fatalf("expected 3 entries on page 1, got %d", len(page1))
|
|
}
|
|
|
|
page2, err := trail.GetEntries(ctx, tournamentID, 3, 3)
|
|
if err != nil {
|
|
t.Fatalf("get page 2: %v", err)
|
|
}
|
|
if len(page2) != 2 {
|
|
t.Fatalf("expected 2 entries on page 2, got %d", len(page2))
|
|
}
|
|
}
|
|
|
|
func TestGetEntriesFiltersByTournament(t *testing.T) {
|
|
trail, _ := setupTestTrail(t)
|
|
ctx := context.Background()
|
|
|
|
t1 := "550e8400-e29b-41d4-a716-446655440010"
|
|
t2 := "550e8400-e29b-41d4-a716-446655440011"
|
|
|
|
// Record entries for two tournaments
|
|
for i := 0; i < 3; i++ {
|
|
trail.Record(ctx, AuditEntry{
|
|
TournamentID: &t1,
|
|
OperatorID: "op-1",
|
|
Action: ActionPlayerBuyin,
|
|
TargetType: "player",
|
|
TargetID: "player-1",
|
|
})
|
|
}
|
|
for i := 0; i < 2; i++ {
|
|
trail.Record(ctx, AuditEntry{
|
|
TournamentID: &t2,
|
|
OperatorID: "op-1",
|
|
Action: ActionPlayerBuyin,
|
|
TargetType: "player",
|
|
TargetID: "player-2",
|
|
})
|
|
}
|
|
|
|
// Filter by tournament 1
|
|
entries, err := trail.GetEntries(ctx, t1, 50, 0)
|
|
if err != nil {
|
|
t.Fatalf("get entries: %v", err)
|
|
}
|
|
if len(entries) != 3 {
|
|
t.Fatalf("expected 3 entries for tournament 1, got %d", len(entries))
|
|
}
|
|
|
|
// Filter by tournament 2
|
|
entries, err = trail.GetEntries(ctx, t2, 50, 0)
|
|
if err != nil {
|
|
t.Fatalf("get entries: %v", err)
|
|
}
|
|
if len(entries) != 2 {
|
|
t.Fatalf("expected 2 entries for tournament 2, got %d", len(entries))
|
|
}
|
|
}
|
|
|
|
func TestGetEntriesByAction(t *testing.T) {
|
|
trail, _ := setupTestTrail(t)
|
|
ctx := context.Background()
|
|
|
|
// Record different action types
|
|
trail.Record(ctx, AuditEntry{OperatorID: "op-1", Action: ActionPlayerBuyin, TargetType: "player", TargetID: "p-1"})
|
|
trail.Record(ctx, AuditEntry{OperatorID: "op-1", Action: ActionPlayerBust, TargetType: "player", TargetID: "p-1"})
|
|
trail.Record(ctx, AuditEntry{OperatorID: "op-1", Action: ActionPlayerBuyin, TargetType: "player", TargetID: "p-2"})
|
|
|
|
entries, err := trail.GetEntriesByAction(ctx, ActionPlayerBuyin, 50, 0)
|
|
if err != nil {
|
|
t.Fatalf("get entries by action: %v", err)
|
|
}
|
|
if len(entries) != 2 {
|
|
t.Fatalf("expected 2 buyin entries, got %d", len(entries))
|
|
}
|
|
}
|
|
|
|
func TestRecorderFunc(t *testing.T) {
|
|
trail, _ := setupTestTrail(t)
|
|
ctx := context.Background()
|
|
|
|
recorder := trail.RecorderFunc()
|
|
|
|
err := recorder(ctx, ActionOperatorLogin, "operator", "op-1", map[string]interface{}{
|
|
"operator_name": "Admin",
|
|
"role": "admin",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("recorder func: %v", err)
|
|
}
|
|
|
|
// Verify entry was persisted
|
|
entries, err := trail.GetEntriesByAction(ctx, ActionOperatorLogin, 50, 0)
|
|
if err != nil {
|
|
t.Fatalf("get entries: %v", err)
|
|
}
|
|
if len(entries) != 1 {
|
|
t.Fatalf("expected 1 login entry, got %d", len(entries))
|
|
}
|
|
if entries[0].TargetType != "operator" {
|
|
t.Fatalf("expected target_type operator, got %s", entries[0].TargetType)
|
|
}
|
|
}
|