package service import ( "browser.local/platform/domain" "strings" "testing" "time" ) func TestSCUMGamePlayerProjectionIsIdempotentAndRedactsNetworkMaterial(t *testing.T) { svc, token := newRegisteredLogIngestService(t) createLogStreamFixture(t, svc) base := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) login := gamePlayerBatch(t, token, 1, []domain.LogEntry{{Seq: 1, Timestamp: base, Line: "login", Fields: map[string]string{"eventType": "scum.login", "playerId": "steam-1", "playerName": "Moon", "sessionId": "session-1", "outcome": "accepted", "networkFingerprint": "203.0.113.9"}}}) if _, err := svc.IngestLogBatch(login); err != nil { t.Fatalf("login projection: %v", err) } if _, err := svc.IngestLogBatch(login); err != nil { t.Fatalf("duplicate projection: %v", err) } players, err := svc.store.GamePlayers().List(domain.GamePlayerFilter{ServerInstanceID: "server-1"}) if err != nil || len(players) != 1 { t.Fatalf("players=%+v err=%v", players, err) } sessions, err := svc.store.GamePlayerSessions().List(domain.GamePlayerSessionFilter{GamePlayerRecordID: players[0].ID}) if err != nil || len(sessions) != 1 { t.Fatalf("sessions=%+v err=%v", sessions, err) } raw, err := svc.QueryLogStream(domain.LogStreamCursorQuery{LogStreamID: "log-1", Limit: 10}) if err != nil { t.Fatal(err) } if len(raw.Entries) != 1 || strings.Contains(strings.Join(mapValues(raw.Entries[0].Fields), " "), "203.0.113.9") { t.Fatalf("raw network material leaked into log storage: %+v", raw.Entries) } logout := gamePlayerBatch(t, token, 2, []domain.LogEntry{{Seq: 2, Timestamp: base.Add(time.Minute), Line: "logout", Fields: map[string]string{"eventType": "scum.logout", "playerId": "steam-1", "playerName": "Moon Renamed", "sessionId": "session-1", "reason": "disconnect"}}}) if _, err := svc.IngestLogBatch(logout); err != nil { t.Fatalf("logout projection: %v", err) } aliases, _ := svc.store.GamePlayerAliases().List(domain.GamePlayerAliasFilter{GamePlayerRecordID: players[0].ID}) sessions, _ = svc.store.GamePlayerSessions().List(domain.GamePlayerSessionFilter{GamePlayerRecordID: players[0].ID}) if len(aliases) != 2 || len(sessions) != 1 || sessions[0].EndedAt.IsZero() { t.Fatalf("expected alias history and closed session: aliases=%+v sessions=%+v", aliases, sessions) } } func TestSCUMRejectedAccessCreatesReviewOnlySignal(t *testing.T) { svc, token := newRegisteredLogIngestService(t) createLogStreamFixture(t, svc) entries := make([]domain.LogEntry, 0, 5) at := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) for seq := uint64(1); seq <= 5; seq++ { entries = append(entries, domain.LogEntry{Seq: seq, Timestamp: at.Add(time.Duration(seq) * time.Minute), Line: "rejected", Fields: map[string]string{"eventType": "scum.login", "playerId": "steam-rejected", "playerName": "Rejected", "sessionId": "failed", "outcome": "rejected", "networkFingerprint": "198.51.100.8"}}) } if _, err := svc.IngestLogBatch(gamePlayerBatch(t, token, 1, entries)); err != nil { t.Fatal(err) } signals, err := svc.store.GameSecuritySignals().List(domain.GameSecuritySignalFilter{ServerInstanceID: "server-1"}) if err != nil || len(signals) != 1 { t.Fatalf("signals=%+v err=%v", signals, err) } if signals[0].Status != domain.GameSecuritySignalReviewRequired || signals[0].EvidenceCount != 5 || strings.Contains(signals[0].Summary, "198.51.100.8") { t.Fatalf("unsafe signal=%+v", signals[0]) } } func TestSCUMSharedServerLocalFingerprintSignalsPossibleAltAccount(t *testing.T) { svc, token := newRegisteredLogIngestService(t) createLogStreamFixture(t, svc) at := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) entries := []domain.LogEntry{{Seq: 1, Timestamp: at, Line: "login", Fields: map[string]string{"eventType": "scum.login", "playerId": "steam-a", "playerName": "A", "sessionId": "a", "outcome": "accepted", "networkFingerprint": "198.51.100.9"}}, {Seq: 2, Timestamp: at.Add(time.Minute), Line: "login", Fields: map[string]string{"eventType": "scum.login", "playerId": "steam-b", "playerName": "B", "sessionId": "b", "outcome": "accepted", "networkFingerprint": "198.51.100.9"}}} if _, err := svc.IngestLogBatch(gamePlayerBatch(t, token, 1, entries)); err != nil { t.Fatal(err) } signals, err := svc.store.GameSecuritySignals().List(domain.GameSecuritySignalFilter{ServerInstanceID: "server-1"}) if err != nil || len(signals) != 1 || signals[0].RuleKey != "possible-alt-account" || signals[0].EvidenceCount != 2 { t.Fatalf("signals=%+v err=%v", signals, err) } } func TestSCUMGamePlayerProjectionIgnoresOutOfOrderLogoutAndPrunesExpiredEvidence(t *testing.T) { svc, token := newRegisteredLogIngestService(t) createLogStreamFixture(t, svc) now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) login := domain.LogEntry{Seq: 1, Timestamp: now, Line: "login", Fields: map[string]string{"eventType": "scum.login", "playerId": "steam-order", "playerName": "Order", "sessionId": "order", "outcome": "accepted"}} logout := domain.LogEntry{Seq: 2, Timestamp: now.Add(-time.Minute), Line: "logout", Fields: map[string]string{"eventType": "scum.logout", "playerId": "steam-order", "playerName": "Order", "sessionId": "order", "reason": "disconnect"}} if _, err := svc.IngestLogBatch(gamePlayerBatch(t, token, 1, []domain.LogEntry{login, logout})); err != nil { t.Fatal(err) } players, _ := svc.store.GamePlayers().List(domain.GamePlayerFilter{ServerInstanceID: "server-1"}) sessions, _ := svc.store.GamePlayerSessions().List(domain.GamePlayerSessionFilter{GamePlayerRecordID: players[0].ID}) if len(sessions) != 1 || !sessions[0].EndedAt.IsZero() { t.Fatalf("stale logout closed active session: %+v", sessions) } if _, err := svc.ListGamePlayersForSession("", domain.GamePlayerFilter{ServerInstanceID: "server-1"}); err != ErrUnauthorized { t.Fatalf("expected unauthorized player query, got %v", err) } if err := svc.store.GameAccessAttempts().Create(domain.GameAccessAttempt{ID: "expired", ServerInstanceID: "server-1", GamePlayerRecordID: players[0].ID, ExpiresAt: now.Add(-time.Hour)}); err != nil { t.Fatal(err) } svc.now = func() time.Time { return now } if err := svc.pruneGamePlayerEvidence("server-1"); err != nil { t.Fatal(err) } if _, err := svc.store.GameAccessAttempts().Get("expired"); err == nil { t.Fatal("expired access evidence was retained") } } func gamePlayerBatch(t *testing.T, token string, first uint64, entries []domain.LogEntry) domain.LogBatchIngest { t.Helper() return domain.LogBatchIngest{RunEndpointID: "run-local", SessionToken: token, LogStreamID: "log-1", ServerInstanceID: "server-1", StreamKey: "stdout", Source: domain.LogStreamSourceProcess, FirstSeq: first, LastSeq: entries[len(entries)-1].Seq, Compression: "none", Checksum: checksumForEntries(t, entries), Entries: entries} } func mapValues(values map[string]string) []string { out := make([]string, 0, len(values)) for _, value := range values { out = append(out, value) } return out }