diff --git a/.github/workflows/go-improved.yml b/.github/workflows/go-improved.yml index f1c5f68ab..d737d0fd4 100644 --- a/.github/workflows/go-improved.yml +++ b/.github/workflows/go-improved.yml @@ -31,7 +31,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.21' + go-version: '1.23' - name: Download dependencies run: go mod download @@ -63,7 +63,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.21' + go-version: '1.23' - name: Download dependencies run: go mod download @@ -110,7 +110,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.21' + go-version: '1.23' - name: Run golangci-lint uses: golangci/golangci-lint-action@v3 diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 96c9b083f..306c5d725 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -22,7 +22,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.21' + go-version: '1.23' - name: Build Linux-amd64 run: env GOOS=linux GOARCH=amd64 go build -v diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cf1a1c29..87884aec8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bumped golang.org/x/net from 0.33.0 to 0.38.0 - Bumped golang.org/x/crypto from 0.31.0 to 0.35.0 +## Removed + +- Compatibility with Go 1.21 removed. + ## [9.2.0] - 2023-04-01 ### Added in 9.2.0 diff --git a/server/channelserver/compression/deltacomp/deltacomp_test.go b/server/channelserver/compression/deltacomp/deltacomp_test.go index 0df33934b..11da4fc9f 100644 --- a/server/channelserver/compression/deltacomp/deltacomp_test.go +++ b/server/channelserver/compression/deltacomp/deltacomp_test.go @@ -4,7 +4,7 @@ import ( "bytes" "encoding/hex" "fmt" - "io/ioutil" + "os" "testing" "erupe-ce/server/channelserver/compression/nullcomp" @@ -68,7 +68,7 @@ var tests = []struct { } func readTestDataFile(filename string) []byte { - data, err := ioutil.ReadFile(fmt.Sprintf("./test_data/%s", filename)) + data, err := os.ReadFile(fmt.Sprintf("./test_data/%s", filename)) if err != nil { panic(err) } diff --git a/server/channelserver/handlers_cast_binary.go b/server/channelserver/handlers_cast_binary.go index a3f2ecfb6..4d8562058 100644 --- a/server/channelserver/handlers_cast_binary.go +++ b/server/channelserver/handlers_cast_binary.go @@ -12,8 +12,8 @@ import ( "erupe-ce/network/binpacket" "erupe-ce/network/mhfpacket" "fmt" - "golang.org/x/exp/slices" "math" + "slices" "strconv" "strings" "time" diff --git a/server/channelserver/handlers_cast_binary_test.go b/server/channelserver/handlers_cast_binary_test.go new file mode 100644 index 000000000..4e9b07974 --- /dev/null +++ b/server/channelserver/handlers_cast_binary_test.go @@ -0,0 +1,713 @@ +package channelserver + +import ( + "net" + "slices" + "strings" + "testing" + + "erupe-ce/common/byteframe" + "erupe-ce/common/mhfcourse" + _config "erupe-ce/config" + "erupe-ce/network/binpacket" + "erupe-ce/network/mhfpacket" +) + +// TestSendServerChatMessage verifies that server chat messages are correctly formatted and queued +func TestSendServerChatMessage(t *testing.T) { + tests := []struct { + name string + message string + wantErr bool + }{ + { + name: "simple_message", + message: "Hello, World!", + wantErr: false, + }, + { + name: "empty_message", + message: "", + wantErr: false, + }, + { + name: "special_characters", + message: "Test @#$%^&*()", + wantErr: false, + }, + { + name: "unicode_message", + message: "テスト メッセージ", + wantErr: false, + }, + { + name: "long_message", + message: strings.Repeat("A", 1000), + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := &MockCryptConn{sentPackets: make([][]byte, 0)} + s := createTestSession(mock) + + // Send the chat message + sendServerChatMessage(s, tt.message) + + // Verify the message was queued + if len(s.sendPackets) == 0 { + t.Error("no packets were queued") + return + } + + // Read from the channel with timeout to avoid hanging + select { + case pkt := <-s.sendPackets: + if pkt.data == nil { + t.Error("packet data is nil") + } + // Verify it's an MHFPacket (contains opcode) + if len(pkt.data) < 2 { + t.Error("packet too short to contain opcode") + } + default: + t.Error("no packet available in channel") + } + }) + } +} + +// TestHandleMsgSysCastBinary_SimpleData verifies basic data message handling +func TestHandleMsgSysCastBinary_SimpleData(t *testing.T) { + mock := &MockCryptConn{sentPackets: make([][]byte, 0)} + s := createTestSession(mock) + s.charID = 54321 + s.stage = NewStage("test_stage") + s.stage.clients[s] = s.charID + s.server.sessions = make(map[net.Conn]*Session) + + // Create a data message payload + bf := byteframe.NewByteFrame() + bf.SetLE() + bf.WriteUint32(0xDEADBEEF) + + pkt := &mhfpacket.MsgSysCastBinary{ + Unk: 0, + BroadcastType: BroadcastTypeStage, + MessageType: BinaryMessageTypeData, + RawDataPayload: bf.Data(), + } + + // Should not panic + handleMsgSysCastBinary(s, pkt) +} + +// TestHandleMsgSysCastBinary_DiceCommand verifies the @dice command +func TestHandleMsgSysCastBinary_DiceCommand(t *testing.T) { + mock := &MockCryptConn{sentPackets: make([][]byte, 0)} + s := createTestSession(mock) + s.charID = 99999 + s.stage = NewStage("test_stage") + s.stage.clients[s] = s.charID + s.server.sessions = make(map[net.Conn]*Session) + + // Build a chat message with @dice command + bf := byteframe.NewByteFrame() + bf.SetLE() + msg := &binpacket.MsgBinChat{ + Unk0: 0, + Type: 5, + Flags: 0x80, + Message: "@dice", + SenderName: "TestPlayer", + } + msg.Build(bf) + + pkt := &mhfpacket.MsgSysCastBinary{ + Unk: 0, + BroadcastType: BroadcastTypeStage, + MessageType: BinaryMessageTypeChat, + RawDataPayload: bf.Data(), + } + + // Should execute dice command and return + handleMsgSysCastBinary(s, pkt) + + // Verify a response was queued (dice result) + if len(s.sendPackets) == 0 { + t.Error("dice command did not queue a response") + } +} + +// TestBroadcastTypes verifies different broadcast types are handled +func TestBroadcastTypes(t *testing.T) { + tests := []struct { + name string + broadcastType uint8 + buildPayload func() []byte + }{ + { + name: "broadcast_targeted", + broadcastType: BroadcastTypeTargeted, + buildPayload: func() []byte { + bf := byteframe.NewByteFrame() + bf.SetBE() // Targeted uses BE + msg := &binpacket.MsgBinTargeted{ + TargetCharIDs: []uint32{1, 2, 3}, + RawDataPayload: []byte{0xDE, 0xAD, 0xBE, 0xEF}, + } + msg.Build(bf) + return bf.Data() + }, + }, + { + name: "broadcast_stage", + broadcastType: BroadcastTypeStage, + buildPayload: func() []byte { + bf := byteframe.NewByteFrame() + bf.SetLE() + bf.WriteUint32(0x12345678) + return bf.Data() + }, + }, + { + name: "broadcast_server", + broadcastType: BroadcastTypeServer, + buildPayload: func() []byte { + bf := byteframe.NewByteFrame() + bf.SetLE() + bf.WriteUint32(0x12345678) + return bf.Data() + }, + }, + { + name: "broadcast_world", + broadcastType: BroadcastTypeWorld, + buildPayload: func() []byte { + bf := byteframe.NewByteFrame() + bf.SetLE() + bf.WriteUint32(0x12345678) + return bf.Data() + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := &MockCryptConn{sentPackets: make([][]byte, 0)} + s := createTestSession(mock) + s.charID = 22222 + s.stage = NewStage("test_stage") + s.stage.clients[s] = s.charID + s.server.sessions = make(map[net.Conn]*Session) + + pkt := &mhfpacket.MsgSysCastBinary{ + Unk: 0, + BroadcastType: tt.broadcastType, + MessageType: BinaryMessageTypeState, + RawDataPayload: tt.buildPayload(), + } + + // Should handle without panic + handleMsgSysCastBinary(s, pkt) + }) + } +} + +// TestBinaryMessageTypes verifies different message types are handled +func TestBinaryMessageTypes(t *testing.T) { + tests := []struct { + name string + messageType uint8 + buildPayload func() []byte + }{ + { + name: "msg_type_state", + messageType: BinaryMessageTypeState, + buildPayload: func() []byte { + bf := byteframe.NewByteFrame() + bf.SetLE() + bf.WriteUint32(0xDEADBEEF) + return bf.Data() + }, + }, + { + name: "msg_type_chat", + messageType: BinaryMessageTypeChat, + buildPayload: func() []byte { + bf := byteframe.NewByteFrame() + bf.SetLE() + msg := &binpacket.MsgBinChat{ + Unk0: 0, + Type: 5, + Flags: 0x80, + Message: "test", + SenderName: "Player", + } + msg.Build(bf) + return bf.Data() + }, + }, + { + name: "msg_type_quest", + messageType: BinaryMessageTypeQuest, + buildPayload: func() []byte { + bf := byteframe.NewByteFrame() + bf.SetLE() + bf.WriteUint32(0xDEADBEEF) + return bf.Data() + }, + }, + { + name: "msg_type_data", + messageType: BinaryMessageTypeData, + buildPayload: func() []byte { + bf := byteframe.NewByteFrame() + bf.SetLE() + bf.WriteUint32(0xDEADBEEF) + return bf.Data() + }, + }, + { + name: "msg_type_mail_notify", + messageType: BinaryMessageTypeMailNotify, + buildPayload: func() []byte { + bf := byteframe.NewByteFrame() + bf.SetLE() + bf.WriteUint32(0xDEADBEEF) + return bf.Data() + }, + }, + { + name: "msg_type_emote", + messageType: BinaryMessageTypeEmote, + buildPayload: func() []byte { + bf := byteframe.NewByteFrame() + bf.SetLE() + bf.WriteUint32(0xDEADBEEF) + return bf.Data() + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := &MockCryptConn{sentPackets: make([][]byte, 0)} + s := createTestSession(mock) + s.charID = 33333 + s.stage = NewStage("test_stage") + s.stage.clients[s] = s.charID + s.server.sessions = make(map[net.Conn]*Session) + + pkt := &mhfpacket.MsgSysCastBinary{ + Unk: 0, + BroadcastType: BroadcastTypeStage, + MessageType: tt.messageType, + RawDataPayload: tt.buildPayload(), + } + + // Should handle without panic + handleMsgSysCastBinary(s, pkt) + }) + } +} + +// TestSlicesContainsUsage verifies the slices.Contains function works correctly +func TestSlicesContainsUsage(t *testing.T) { + tests := []struct { + name string + items []_config.Course + target _config.Course + expected bool + }{ + { + name: "item_exists", + items: []_config.Course{ + {Name: "Course1", Enabled: true}, + {Name: "Course2", Enabled: false}, + }, + target: _config.Course{Name: "Course1", Enabled: true}, + expected: true, + }, + { + name: "item_not_found", + items: []_config.Course{ + {Name: "Course1", Enabled: true}, + {Name: "Course2", Enabled: false}, + }, + target: _config.Course{Name: "Course3", Enabled: true}, + expected: false, + }, + { + name: "empty_slice", + items: []_config.Course{}, + target: _config.Course{Name: "Course1", Enabled: true}, + expected: false, + }, + { + name: "enabled_mismatch", + items: []_config.Course{ + {Name: "Course1", Enabled: true}, + }, + target: _config.Course{Name: "Course1", Enabled: false}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := slices.Contains(tt.items, tt.target) + if result != tt.expected { + t.Errorf("slices.Contains() = %v, want %v", result, tt.expected) + } + }) + } +} + +// TestSlicesIndexFuncUsage verifies the slices.IndexFunc function works correctly +func TestSlicesIndexFuncUsage(t *testing.T) { + tests := []struct { + name string + courses []mhfcourse.Course + predicate func(mhfcourse.Course) bool + expected int + }{ + { + name: "empty_slice", + courses: []mhfcourse.Course{}, + predicate: func(c mhfcourse.Course) bool { + return true + }, + expected: -1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := slices.IndexFunc(tt.courses, tt.predicate) + if result != tt.expected { + t.Errorf("slices.IndexFunc() = %d, want %d", result, tt.expected) + } + }) + } +} + +// TestChatMessageParsing verifies chat message extraction from binary payload +func TestChatMessageParsing(t *testing.T) { + tests := []struct { + name string + messageContent string + authorName string + }{ + { + name: "standard_message", + messageContent: "Hello World", + authorName: "Player123", + }, + { + name: "special_chars_message", + messageContent: "Test@#$%^&*()", + authorName: "SpecialUser", + }, + { + name: "empty_message", + messageContent: "", + authorName: "Silent", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Build a binary chat message + bf := byteframe.NewByteFrame() + bf.SetLE() + msg := &binpacket.MsgBinChat{ + Unk0: 0, + Type: 5, + Flags: 0x80, + Message: tt.messageContent, + SenderName: tt.authorName, + } + msg.Build(bf) + + // Parse it back + parseBf := byteframe.NewByteFrameFromBytes(bf.Data()) + parseBf.SetLE() + parseBf.Seek(8, 0) // Skip initial bytes + + message := string(parseBf.ReadNullTerminatedBytes()) + author := string(parseBf.ReadNullTerminatedBytes()) + + if message != tt.messageContent { + t.Errorf("message mismatch: got %q, want %q", message, tt.messageContent) + } + if author != tt.authorName { + t.Errorf("author mismatch: got %q, want %q", author, tt.authorName) + } + }) + } +} + +// TestBinaryMessageTypeEnums verifies message type constants +func TestBinaryMessageTypeEnums(t *testing.T) { + tests := []struct { + name string + typeVal uint8 + typeID uint8 + }{ + { + name: "state_type", + typeVal: BinaryMessageTypeState, + typeID: 0, + }, + { + name: "chat_type", + typeVal: BinaryMessageTypeChat, + typeID: 1, + }, + { + name: "quest_type", + typeVal: BinaryMessageTypeQuest, + typeID: 2, + }, + { + name: "data_type", + typeVal: BinaryMessageTypeData, + typeID: 3, + }, + { + name: "mail_notify_type", + typeVal: BinaryMessageTypeMailNotify, + typeID: 4, + }, + { + name: "emote_type", + typeVal: BinaryMessageTypeEmote, + typeID: 6, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.typeVal != tt.typeID { + t.Errorf("type mismatch: got %d, want %d", tt.typeVal, tt.typeID) + } + }) + } +} + +// TestBroadcastTypeEnums verifies broadcast type constants +func TestBroadcastTypeEnums(t *testing.T) { + tests := []struct { + name string + typeVal uint8 + typeID uint8 + }{ + { + name: "targeted_type", + typeVal: BroadcastTypeTargeted, + typeID: 0x01, + }, + { + name: "stage_type", + typeVal: BroadcastTypeStage, + typeID: 0x03, + }, + { + name: "server_type", + typeVal: BroadcastTypeServer, + typeID: 0x06, + }, + { + name: "world_type", + typeVal: BroadcastTypeWorld, + typeID: 0x0a, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.typeVal != tt.typeID { + t.Errorf("type mismatch: got %d, want %d", tt.typeVal, tt.typeID) + } + }) + } +} + +// TestPayloadHandling verifies raw payload handling in different scenarios +func TestPayloadHandling(t *testing.T) { + tests := []struct { + name string + payloadSize int + broadcastType uint8 + messageType uint8 + }{ + { + name: "empty_payload", + payloadSize: 0, + broadcastType: BroadcastTypeStage, + messageType: BinaryMessageTypeData, + }, + { + name: "small_payload", + payloadSize: 4, + broadcastType: BroadcastTypeStage, + messageType: BinaryMessageTypeData, + }, + { + name: "large_payload", + payloadSize: 10000, + broadcastType: BroadcastTypeStage, + messageType: BinaryMessageTypeData, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := &MockCryptConn{sentPackets: make([][]byte, 0)} + s := createTestSession(mock) + s.charID = 44444 + s.stage = NewStage("test_stage") + s.stage.clients[s] = s.charID + s.server.sessions = make(map[net.Conn]*Session) + + // Create payload of specified size + payload := make([]byte, tt.payloadSize) + for i := 0; i < len(payload); i++ { + payload[i] = byte(i % 256) + } + + pkt := &mhfpacket.MsgSysCastBinary{ + Unk: 0, + BroadcastType: tt.broadcastType, + MessageType: tt.messageType, + RawDataPayload: payload, + } + + // Should handle without panic + handleMsgSysCastBinary(s, pkt) + }) + } +} + +// TestCastedBinaryPacketConstruction verifies correct packet construction +func TestCastedBinaryPacketConstruction(t *testing.T) { + mock := &MockCryptConn{sentPackets: make([][]byte, 0)} + s := createTestSession(mock) + s.charID = 77777 + + message := "Test message" + + sendServerChatMessage(s, message) + + // Verify a packet was queued + if len(s.sendPackets) == 0 { + t.Fatal("no packets queued") + } + + // Extract packet from channel + pkt := <-s.sendPackets + + if pkt.data == nil { + t.Error("packet data is nil") + } + + // The packet should be at least a valid MHF packet with opcode + if len(pkt.data) < 2 { + t.Error("packet too short") + } +} + +// TestNilPayloadHandling verifies safe handling of nil payloads +func TestNilPayloadHandling(t *testing.T) { + mock := &MockCryptConn{sentPackets: make([][]byte, 0)} + s := createTestSession(mock) + s.charID = 55555 + s.stage = NewStage("test_stage") + s.stage.clients[s] = s.charID + s.server.sessions = make(map[net.Conn]*Session) + + pkt := &mhfpacket.MsgSysCastBinary{ + Unk: 0, + BroadcastType: BroadcastTypeStage, + MessageType: BinaryMessageTypeData, + RawDataPayload: nil, + } + + // Should handle nil payload without panic + handleMsgSysCastBinary(s, pkt) +} + +// BenchmarkSendServerChatMessage benchmarks the chat message sending +func BenchmarkSendServerChatMessage(b *testing.B) { + mock := &MockCryptConn{sentPackets: make([][]byte, 0)} + s := createTestSession(mock) + + message := "This is a benchmark message" + + b.ResetTimer() + for i := 0; i < b.N; i++ { + sendServerChatMessage(s, message) + } +} + +// BenchmarkHandleMsgSysCastBinary benchmarks the packet handling +func BenchmarkHandleMsgSysCastBinary(b *testing.B) { + mock := &MockCryptConn{sentPackets: make([][]byte, 0)} + s := createTestSession(mock) + s.charID = 99999 + s.stage = NewStage("test_stage") + s.stage.clients[s] = s.charID + s.server.sessions = make(map[net.Conn]*Session) + + // Prepare packet + bf := byteframe.NewByteFrame() + bf.SetLE() + bf.WriteUint32(0x12345678) + + pkt := &mhfpacket.MsgSysCastBinary{ + Unk: 0, + BroadcastType: BroadcastTypeStage, + MessageType: BinaryMessageTypeData, + RawDataPayload: bf.Data(), + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + handleMsgSysCastBinary(s, pkt) + } +} + +// BenchmarkSlicesContains benchmarks the slices.Contains function +func BenchmarkSlicesContains(b *testing.B) { + courses := []_config.Course{ + {Name: "Course1", Enabled: true}, + {Name: "Course2", Enabled: false}, + {Name: "Course3", Enabled: true}, + {Name: "Course4", Enabled: false}, + {Name: "Course5", Enabled: true}, + } + + target := _config.Course{Name: "Course3", Enabled: true} + + b.ResetTimer() + for i := 0; i < b.N; i++ { + slices.Contains(courses, target) + } +} + +// BenchmarkSlicesIndexFunc benchmarks the slices.IndexFunc function +func BenchmarkSlicesIndexFunc(b *testing.B) { + // Create mock courses (empty as real data not needed for benchmark) + courses := make([]mhfcourse.Course, 100) + + predicate := func(c mhfcourse.Course) bool { + return false // Worst case - always iterate to end + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + slices.IndexFunc(courses, predicate) + } +}