150 lines
8.3 KiB
Go
150 lines
8.3 KiB
Go
package companion
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
"unicode/utf16"
|
|
)
|
|
|
|
func TestSCUMLoginLogParserParsesAuthenticUTF16LELoginAndLogout(t *testing.T) {
|
|
availability := VerifiedSCUMLoginLogParser()
|
|
if !availability.Available || availability.ParserKey != SCUMLoginLogParserKey || availability.ParserVersion != SCUMLoginLogParserVersion {
|
|
t.Fatalf("login log parser should be versioned and available: %+v", availability)
|
|
}
|
|
records := []SCUMLoginLogRecord{
|
|
scumLoginFixtureRecord(1, "gen-1", "2026.07.05-05.39.10: '203.0.113.10:7777 76561198000000001:Fixture One(profile-1)' logged in at: X=123.45 Y=-456.78 Z=90"),
|
|
scumLoginFixtureRecord(2, "gen-1", "2026.07.05-07.21.15: '203.0.113.10:7777 76561198000000001:Fixture One(profile-1)' logged out at: X=124.45 Y=-455.78 Z=91"),
|
|
}
|
|
batch := ParseSCUMLoginLogRecords("server-1", records, SCUMLoginLogParseOptions{})
|
|
if len(batch.Diagnostics) != 0 {
|
|
t.Fatalf("unexpected diagnostics: %+v", batch.Diagnostics)
|
|
}
|
|
if len(batch.Events) != 2 {
|
|
t.Fatalf("expected login and logout events, got %+v", batch.Events)
|
|
}
|
|
login := batch.Events[0]
|
|
if login.Type != "scum.login" || login.ExternalPlayerID != "76561198000000001" || login.DisplayName != "Fixture One" || login.ProfileLocalID != "profile-1" || login.OccurredAtSourceText != "2026.07.05-05.39.10" {
|
|
t.Fatalf("unexpected login event: %+v", login)
|
|
}
|
|
logout := batch.Events[1]
|
|
if logout.Type != "scum.logout" || logout.LogicalEventIdentity == login.LogicalEventIdentity || logout.TransportCursor.Sequence != 2 {
|
|
t.Fatalf("unexpected logout event: %+v", logout)
|
|
}
|
|
serialized, err := json.Marshal(batch.Events)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
serializedText := string(serialized)
|
|
for _, forbidden := range []string{"203.0.113.10", "7777", "123.45", "-456.78", "124.45", "-455.78"} {
|
|
if strings.Contains(serializedText, forbidden) {
|
|
t.Fatalf("parser leaked network or coordinate material %q in %s", forbidden, serializedText)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSCUMLoginLogParserRejectsFailedPartialUndecodableOversizedAndMalformedLines(t *testing.T) {
|
|
records := []SCUMLoginLogRecord{
|
|
scumLoginFixtureRecord(1, "gen-1", "2026.07.05-05.39.10: '203.0.113.20 76561198000000002:Rejected(profile-2)' login failed at: X=1 Y=2 Z=3"),
|
|
{ServerID: "server-1", SourceIdentity: "source-1", StreamGeneration: "gen-1", Sequence: 2, RawLine: scumUTF16LE("2026.07.05-05.39.10: '203.0.113.20 76561198000000002:Partial(profile-2)' logged in at: X=1 Y=2 Z=3"), Partial: true},
|
|
{ServerID: "server-1", SourceIdentity: "source-1", StreamGeneration: "gen-1", Sequence: 3, RawLine: []byte{0x00, 0xD8}},
|
|
{ServerID: "server-1", SourceIdentity: "source-1", StreamGeneration: "gen-1", Sequence: 4, RawLine: make([]byte, SCUMLoginLogMaxLineBytes+2)},
|
|
scumLoginFixtureRecord(5, "gen-1", "not a real login line"),
|
|
}
|
|
batch := ParseSCUMLoginLogRecords("server-1", records, SCUMLoginLogParseOptions{})
|
|
if len(batch.Events) != 0 {
|
|
t.Fatalf("invalid lines must not create events: %+v", batch.Events)
|
|
}
|
|
assertDiagnosticCodes(t, batch.Diagnostics, []string{"failed-login", "partial-line", "undecodable-line", "oversized-line", "malformed-line"})
|
|
}
|
|
|
|
func TestSCUMLoginLogParserDeduplicatesRotationOverlapAcrossNewGeneration(t *testing.T) {
|
|
line := "2026.06.20-06.36.46: '198.51.100.44 76561198000000003:Overlap(profile-3)' logged in at: X=10 Y=20 Z=30"
|
|
first := scumLoginFixtureRecord(5, "gen-old", line)
|
|
second := scumLoginFixtureRecord(1, "gen-new", line)
|
|
batch := ParseSCUMLoginLogRecords("server-1", []SCUMLoginLogRecord{first, second}, SCUMLoginLogParseOptions{})
|
|
if len(batch.Events) != 1 {
|
|
t.Fatalf("rotation overlap should emit one logical event, got %+v", batch.Events)
|
|
}
|
|
assertDiagnosticCodes(t, batch.Diagnostics, []string{"duplicate-logical-event"})
|
|
oldGeneration := ParseSCUMLoginLogRecords("server-1", []SCUMLoginLogRecord{first}, SCUMLoginLogParseOptions{})
|
|
newGeneration := ParseSCUMLoginLogRecords("server-1", []SCUMLoginLogRecord{second}, SCUMLoginLogParseOptions{})
|
|
if oldGeneration.Events[0].LogicalEventIdentity != newGeneration.Events[0].LogicalEventIdentity {
|
|
t.Fatalf("logical identity must be generation-independent: %s != %s", oldGeneration.Events[0].LogicalEventIdentity, newGeneration.Events[0].LogicalEventIdentity)
|
|
}
|
|
}
|
|
|
|
func TestSCUMLoginLogParserLogicalIdentityExcludesNetworkAndCoordinates(t *testing.T) {
|
|
first := scumLoginFixtureRecord(1, "gen-1", "2026.06.20-06.36.46: '198.51.100.44 76561198000000003:Private(profile-3)' logged in at: X=10 Y=20 Z=30")
|
|
second := scumLoginFixtureRecord(2, "gen-1", "2026.06.20-06.36.46: '203.0.113.44:7777 76561198000000003:Private(profile-3)' logged in at: X=-999 Y=888 Z=777")
|
|
firstBatch := ParseSCUMLoginLogRecords("server-1", []SCUMLoginLogRecord{first}, SCUMLoginLogParseOptions{})
|
|
secondBatch := ParseSCUMLoginLogRecords("server-1", []SCUMLoginLogRecord{second}, SCUMLoginLogParseOptions{})
|
|
if len(firstBatch.Events) != 1 || len(secondBatch.Events) != 1 {
|
|
t.Fatalf("expected both privacy variants to parse: %+v %+v", firstBatch, secondBatch)
|
|
}
|
|
if firstBatch.Events[0].LogicalEventIdentity != secondBatch.Events[0].LogicalEventIdentity {
|
|
t.Fatalf("logical identity must exclude network and coordinates: %s != %s", firstBatch.Events[0].LogicalEventIdentity, secondBatch.Events[0].LogicalEventIdentity)
|
|
}
|
|
}
|
|
|
|
func TestSCUMLoginLogParserHonorsRestartResumeAndDuplicateDelivery(t *testing.T) {
|
|
record := scumLoginFixtureRecord(9, "gen-1", "2026.06.20-12.30.26: '198.51.100.60 76561198000000004:Resume(profile-4)' logged in at: X=10 Y=20 Z=30")
|
|
first := ParseSCUMLoginLogRecords("server-1", []SCUMLoginLogRecord{record}, SCUMLoginLogParseOptions{})
|
|
if len(first.Events) != 1 {
|
|
t.Fatalf("expected first delivery to emit one event: %+v", first)
|
|
}
|
|
acknowledgedTransport := map[SCUMTransportCursor]struct{}{first.Events[0].TransportCursor: {}}
|
|
acknowledgedLogical := map[string]struct{}{first.Events[0].LogicalEventIdentity: {}}
|
|
duplicateCursor := ParseSCUMLoginLogRecords("server-1", []SCUMLoginLogRecord{record}, SCUMLoginLogParseOptions{AcknowledgedTransportCursors: acknowledgedTransport, AcknowledgedLogicalEventIdentities: acknowledgedLogical})
|
|
if len(duplicateCursor.Events) != 0 {
|
|
t.Fatalf("duplicate acknowledged cursor must not emit events: %+v", duplicateCursor.Events)
|
|
}
|
|
assertDiagnosticCodes(t, duplicateCursor.Diagnostics, []string{"duplicate-transport-cursor"})
|
|
replayedAfterRestart := record
|
|
replayedAfterRestart.StreamGeneration = "gen-after-restart"
|
|
replayedAfterRestart.Sequence = 1
|
|
duplicateLogical := ParseSCUMLoginLogRecords("server-1", []SCUMLoginLogRecord{replayedAfterRestart}, SCUMLoginLogParseOptions{AcknowledgedLogicalEventIdentities: acknowledgedLogical})
|
|
if len(duplicateLogical.Events) != 0 {
|
|
t.Fatalf("restart replay must be idempotent by logical identity: %+v", duplicateLogical.Events)
|
|
}
|
|
assertDiagnosticCodes(t, duplicateLogical.Diagnostics, []string{"duplicate-logical-event"})
|
|
}
|
|
|
|
func TestSCUMLoginLogParserAcceptsOutOfOrderNewEventsWithDiagnostic(t *testing.T) {
|
|
records := []SCUMLoginLogRecord{
|
|
scumLoginFixtureRecord(8, "gen-1", "2026.06.20-12.32.40: '198.51.100.70 76561198000000005:Late(profile-5)' logged out at: X=10 Y=20 Z=30"),
|
|
scumLoginFixtureRecord(7, "gen-1", "2026.06.20-12.30.26: '198.51.100.70 76561198000000005:Late(profile-5)' logged in at: X=10 Y=20 Z=30"),
|
|
}
|
|
batch := ParseSCUMLoginLogRecords("server-1", records, SCUMLoginLogParseOptions{})
|
|
if len(batch.Events) != 2 {
|
|
t.Fatalf("out-of-order unique events should still be parsed: %+v", batch.Events)
|
|
}
|
|
assertDiagnosticCodes(t, batch.Diagnostics, []string{"out-of-order-transport"})
|
|
}
|
|
|
|
func scumLoginFixtureRecord(sequence uint64, generation string, line string) SCUMLoginLogRecord {
|
|
return SCUMLoginLogRecord{ServerID: "server-1", SourceIdentity: "source-1", StreamGeneration: generation, Sequence: sequence, RawLine: scumUTF16LE(line)}
|
|
}
|
|
|
|
func scumUTF16LE(value string) []byte {
|
|
encoded := utf16.Encode([]rune(value))
|
|
bytes := make([]byte, len(encoded)*2)
|
|
for index, codeUnit := range encoded {
|
|
binary.LittleEndian.PutUint16(bytes[index*2:], codeUnit)
|
|
}
|
|
return bytes
|
|
}
|
|
|
|
func assertDiagnosticCodes(t *testing.T, diagnostics []SCUMLoginLogDiagnostic, expected []string) {
|
|
t.Helper()
|
|
if len(diagnostics) != len(expected) {
|
|
t.Fatalf("expected diagnostics %v, got %+v", expected, diagnostics)
|
|
}
|
|
for index, code := range expected {
|
|
if diagnostics[index].Code != code {
|
|
t.Fatalf("expected diagnostic %d to be %q, got %+v", index, code, diagnostics[index])
|
|
}
|
|
}
|
|
}
|