diff --git a/tests/login_smoke.cpp b/tests/login_smoke.cpp index c0fc2f2..3f727b3 100644 --- a/tests/login_smoke.cpp +++ b/tests/login_smoke.cpp @@ -218,12 +218,141 @@ struct PacketGCChannel }; #pragma pack(pop) +struct SmokeOptions +{ + bool json = false; + std::string create_character_name; + std::string client_version = "1215955205"; + std::string expect_auth_failure; + std::string expect_channel_failure; +}; + +struct SmokeResult +{ + bool ok = false; + std::string result = "unexpected_failure"; + std::string stage = "startup"; + std::string error; + std::string failure_status; + uint32_t login_key = 0; + int empire = -1; + int character_index = -1; + std::string character_name; + bool character_created = false; + int game_channel = -1; + int64_t auth_handshake_ms = -1; + int64_t auth_login_ms = -1; + int64_t channel_handshake_ms = -1; + int64_t channel_login_ms = -1; + int64_t character_create_ms = -1; + int64_t character_select_ms = -1; + int64_t entergame_ms = -1; + std::vector events; +}; + +struct AuthResponse +{ + bool success = false; + uint32_t login_key = 0; + std::string failure_status; +}; + +struct ChannelLoginResponse +{ + bool success = false; + int empire = -1; + std::string failure_status; + PacketGCLoginSuccess4 login_success {}; +}; + void Expect(bool condition, std::string_view message) { if (!condition) throw std::runtime_error(std::string(message)); } +std::string JsonEscape(std::string_view input) +{ + std::string escaped; + escaped.reserve(input.size() + 8); + + for (const char c : input) + { + switch (c) + { + case '\\': + escaped += "\\\\"; + break; + case '"': + escaped += "\\\""; + break; + case '\n': + escaped += "\\n"; + break; + case '\r': + escaped += "\\r"; + break; + case '\t': + escaped += "\\t"; + break; + default: + escaped.push_back(c); + break; + } + } + + return escaped; +} + +void EmitEvent(SmokeResult& result, bool json, const std::string& line) +{ + result.events.push_back(line); + if (!json) + std::cout << line << "\n"; +} + +int64_t ElapsedMs(std::chrono::steady_clock::time_point started_at) +{ + return std::chrono::duration_cast( + std::chrono::steady_clock::now() - started_at) + .count(); +} + +void PrintJson(const SmokeResult& result) +{ + std::cout + << "{" + << "\"ok\":" << (result.ok ? "true" : "false") + << ",\"result\":\"" << JsonEscape(result.result) << "\"" + << ",\"stage\":\"" << JsonEscape(result.stage) << "\"" + << ",\"error\":\"" << JsonEscape(result.error) << "\"" + << ",\"failure_status\":\"" << JsonEscape(result.failure_status) << "\"" + << ",\"login_key\":" << result.login_key + << ",\"empire\":" << result.empire + << ",\"character_index\":" << result.character_index + << ",\"character_name\":\"" << JsonEscape(result.character_name) << "\"" + << ",\"character_created\":" << (result.character_created ? "true" : "false") + << ",\"game_channel\":" << result.game_channel + << ",\"timings_ms\":{" + << "\"auth_handshake\":" << result.auth_handshake_ms + << ",\"auth_login\":" << result.auth_login_ms + << ",\"channel_handshake\":" << result.channel_handshake_ms + << ",\"channel_login\":" << result.channel_login_ms + << ",\"character_create\":" << result.character_create_ms + << ",\"character_select\":" << result.character_select_ms + << ",\"entergame\":" << result.entergame_ms + << "},\"events\":["; + + for (size_t i = 0; i < result.events.size(); ++i) + { + if (i != 0) + std::cout << ","; + std::cout << "\"" << JsonEscape(result.events[i]) << "\""; + } + + std::cout << "]}\n"; +} + void WriteExact(int fd, const void* data, size_t length, std::string_view context) { const uint8_t* cursor = static_cast(data); @@ -423,11 +552,8 @@ uint8_t FindFirstPlayableCharacterIndex(const PacketGCLoginSuccess4& success) throw std::runtime_error("account has no playable characters"); } -uint32_t Authenticate(const std::string& host, uint16_t auth_port, const std::string& login, const std::string& password) +AuthResponse Authenticate(EncryptedClient& auth_client, const std::string& login, const std::string& password, SmokeResult& result, bool json) { - EncryptedClient auth_client(host, auth_port); - auth_client.Handshake(); - PacketCGLogin3 login_packet {}; login_packet.header = CG::LOGIN3; login_packet.length = sizeof(login_packet); @@ -448,7 +574,8 @@ uint32_t Authenticate(const std::string& host, uint16_t auth_port, const std::st { Expect(frame.size() == sizeof(PacketGCLoginFailure), "unexpected login failure size"); const auto* failure = reinterpret_cast(frame.data()); - throw std::runtime_error(std::string("auth login failed: ") + failure->status); + EmitEvent(result, json, "auth_failure status=" + std::string(failure->status)); + return AuthResponse {false, 0, failure->status}; } if (header == GC::AUTH_SUCCESS) @@ -456,15 +583,15 @@ uint32_t Authenticate(const std::string& host, uint16_t auth_port, const std::st Expect(frame.size() == sizeof(PacketGCAuthSuccess), "unexpected auth success size"); const auto* success = reinterpret_cast(frame.data()); Expect(success->result != 0, "auth result returned failure"); - std::cout << "auth_success login_key=" << success->login_key << "\n"; - return success->login_key; + EmitEvent(result, json, "auth_success login_key=" + std::to_string(success->login_key)); + return AuthResponse {true, success->login_key, {}}; } } throw std::runtime_error("did not receive AUTH_SUCCESS"); } -PacketGCLoginSuccess4 LoginToChannel(EncryptedClient& channel_client, const std::string& login, uint32_t login_key) +ChannelLoginResponse LoginToChannel(EncryptedClient& channel_client, const std::string& login, uint32_t login_key, SmokeResult& result, bool json) { PacketCGLogin2 login_packet {}; login_packet.header = CG::LOGIN2; @@ -492,7 +619,8 @@ PacketGCLoginSuccess4 LoginToChannel(EncryptedClient& channel_client, const std: { Expect(frame.size() == sizeof(PacketGCLoginFailure), "unexpected channel login failure size"); const auto* failure = reinterpret_cast(frame.data()); - throw std::runtime_error(std::string("channel login failed: ") + failure->status); + EmitEvent(result, json, "channel_failure status=" + std::string(failure->status)); + return ChannelLoginResponse {false, -1, failure->status, {}}; } if (header == GC::EMPIRE) @@ -501,7 +629,7 @@ PacketGCLoginSuccess4 LoginToChannel(EncryptedClient& channel_client, const std: const auto* empire_packet = reinterpret_cast(frame.data()); saw_empire = true; empire = empire_packet->empire; - std::cout << "channel_empire empire=" << static_cast(empire) << "\n"; + EmitEvent(result, json, "channel_empire empire=" + std::to_string(static_cast(empire))); continue; } @@ -510,17 +638,17 @@ PacketGCLoginSuccess4 LoginToChannel(EncryptedClient& channel_client, const std: Expect(frame.size() == sizeof(PacketGCLoginSuccess4), "unexpected channel login success size"); std::memcpy(&login_success, frame.data(), sizeof(login_success)); saw_login_success = true; - std::cout << "channel_login_success length=" << length << "\n"; + EmitEvent(result, json, "channel_login_success length=" + std::to_string(length)); break; } } Expect(saw_empire, "did not receive EMPIRE"); Expect(saw_login_success, "did not receive LOGIN_SUCCESS4"); - return login_success; + return ChannelLoginResponse {true, empire, {}, login_success}; } -uint8_t CreateCharacter(EncryptedClient& channel_client, std::string_view create_name) +uint8_t CreateCharacter(EncryptedClient& channel_client, std::string_view create_name, SmokeResult& result, bool json) { PacketCGPlayerCreate create_packet {}; create_packet.header = CG::CHARACTER_CREATE; @@ -559,8 +687,10 @@ uint8_t CreateCharacter(EncryptedClient& channel_client, std::string_view create { Expect(frame.size() == sizeof(PacketGCPlayerCreateSuccess), "unexpected player create success size"); const auto* success = reinterpret_cast(frame.data()); - std::cout << "character_created index=" << static_cast(success->account_character_index) - << " name=" << success->player.name << "\n"; + EmitEvent(result, json, "character_created index=" + std::to_string(static_cast(success->account_character_index)) + + " name=" + success->player.name); + result.character_created = true; + result.character_name = success->player.name; return success->account_character_index; } } @@ -568,7 +698,7 @@ uint8_t CreateCharacter(EncryptedClient& channel_client, std::string_view create throw std::runtime_error("did not receive PLAYER_CREATE_SUCCESS"); } -void SelectCharacter(EncryptedClient& channel_client, uint8_t character_index) +void SelectCharacter(EncryptedClient& channel_client, uint8_t character_index, SmokeResult& result, bool json) { PacketCGPlayerSelect select_packet {}; select_packet.header = CG::CHARACTER_SELECT; @@ -602,7 +732,7 @@ void SelectCharacter(EncryptedClient& channel_client, uint8_t character_index) if (phase->phase == PHASE_LOADING) { saw_phase_loading = true; - std::cout << "phase_loading\n"; + EmitEvent(result, json, "phase_loading"); } continue; } @@ -613,7 +743,8 @@ void SelectCharacter(EncryptedClient& channel_client, uint8_t character_index) const auto* main_character = reinterpret_cast(frame.data()); Expect(main_character->name[0] != '\0', "main character name is empty"); saw_main_character = true; - std::cout << "main_character name=" << main_character->name << "\n"; + result.character_name = main_character->name; + EmitEvent(result, json, "main_character name=" + std::string(main_character->name)); } if (saw_phase_loading && saw_main_character) @@ -621,7 +752,7 @@ void SelectCharacter(EncryptedClient& channel_client, uint8_t character_index) } } -void SendClientVersion(EncryptedClient& channel_client, std::string_view client_version) +void SendClientVersion(EncryptedClient& channel_client, std::string_view client_version, SmokeResult& result, bool json) { PacketCGClientVersion version_packet {}; version_packet.header = CG::CLIENT_VERSION; @@ -629,10 +760,10 @@ void SendClientVersion(EncryptedClient& channel_client, std::string_view client_ std::strncpy(version_packet.filename, "Metin2_Debug.exe", sizeof(version_packet.filename) - 1); std::strncpy(version_packet.timestamp, client_version.data(), sizeof(version_packet.timestamp) - 1); channel_client.SendEncryptedPacket(version_packet); - std::cout << "client_version " << version_packet.timestamp << "\n"; + EmitEvent(result, json, "client_version " + std::string(version_packet.timestamp)); } -void EnterGame(EncryptedClient& channel_client) +void EnterGame(EncryptedClient& channel_client, SmokeResult& result, bool json) { PacketCGEnterGame enter_game {}; enter_game.header = CG::ENTERGAME; @@ -666,7 +797,7 @@ void EnterGame(EncryptedClient& channel_client) if (phase->phase == PHASE_GAME) { saw_phase_game = true; - std::cout << "phase_game\n"; + EmitEvent(result, json, "phase_game"); } continue; } @@ -683,7 +814,8 @@ void EnterGame(EncryptedClient& channel_client) Expect(frame.size() == sizeof(PacketGCChannel), "unexpected channel packet size"); const auto* channel = reinterpret_cast(frame.data()); saw_channel = true; - std::cout << "game_channel channel=" << static_cast(channel->channel) << "\n"; + result.game_channel = channel->channel; + EmitEvent(result, json, "game_channel channel=" + std::to_string(static_cast(channel->channel))); } if (saw_phase_game && saw_time && saw_channel) @@ -694,20 +826,22 @@ void EnterGame(EncryptedClient& channel_client) int main(int argc, char** argv) { + SmokeOptions options; + SmokeResult result; + try { - Expect(argc >= 6 && argc <= 8, + Expect(argc >= 6, "usage: metin_login_smoke \n" " or: metin_login_smoke --password-env=ENV_NAME\n" - " optional: --create-character-name=NAME [--client-version=VERSION]"); + " optional: --create-character-name=NAME [--client-version=VERSION] [--json] " + "[--expect-auth-failure=STATUS] [--expect-channel-failure=STATUS]"); const std::string host = argv[1]; const uint16_t auth_port = static_cast(std::stoi(argv[2])); const uint16_t channel_port = static_cast(std::stoi(argv[3])); const std::string login = argv[4]; std::string password; - std::string create_character_name; - std::string client_version = "1215955205"; const std::string password_arg = argv[5]; const std::string prefix = "--password-env="; @@ -727,54 +861,174 @@ int main(int argc, char** argv) const std::string create_arg = argv[i]; const std::string create_prefix = "--create-character-name="; const std::string version_prefix = "--client-version="; + const std::string auth_failure_prefix = "--expect-auth-failure="; + const std::string channel_failure_prefix = "--expect-channel-failure="; if (create_arg.rfind(create_prefix, 0) == 0) { - create_character_name = create_arg.substr(create_prefix.size()); - Expect(!create_character_name.empty(), "create character name is empty"); - Expect(create_character_name.size() <= CHARACTER_NAME_MAX_LEN_LOCAL, "create character name too long"); + options.create_character_name = create_arg.substr(create_prefix.size()); + Expect(!options.create_character_name.empty(), "create character name is empty"); + Expect(options.create_character_name.size() <= CHARACTER_NAME_MAX_LEN_LOCAL, "create character name too long"); continue; } if (create_arg.rfind(version_prefix, 0) == 0) { - client_version = create_arg.substr(version_prefix.size()); - Expect(!client_version.empty(), "client version is empty"); + options.client_version = create_arg.substr(version_prefix.size()); + Expect(!options.client_version.empty(), "client version is empty"); continue; } - throw std::runtime_error("unknown optional argument"); + if (create_arg.rfind(auth_failure_prefix, 0) == 0) + { + options.expect_auth_failure = create_arg.substr(auth_failure_prefix.size()); + Expect(!options.expect_auth_failure.empty(), "expected auth failure status is empty"); + continue; + } + + if (create_arg.rfind(channel_failure_prefix, 0) == 0) + { + options.expect_channel_failure = create_arg.substr(channel_failure_prefix.size()); + Expect(!options.expect_channel_failure.empty(), "expected channel failure status is empty"); + continue; + } + + if (create_arg == "--json") + { + options.json = true; + continue; + } + + throw std::runtime_error("unknown optional argument: " + create_arg); } Expect(login.size() <= LOGIN_MAX_LEN_LOCAL, "login too long"); Expect(password.size() <= PASSWD_MAX_LEN_LOCAL, "password too long"); - const uint32_t login_key = Authenticate(host, auth_port, login, password); + result.stage = "auth_handshake"; + auto started_at = std::chrono::steady_clock::now(); + EncryptedClient auth_client(host, auth_port); + auth_client.Handshake(); + result.auth_handshake_ms = ElapsedMs(started_at); + + result.stage = "auth_login"; + started_at = std::chrono::steady_clock::now(); + const AuthResponse auth = Authenticate(auth_client, login, password, result, options.json); + result.auth_login_ms = ElapsedMs(started_at); + result.failure_status.clear(); + + if (!auth.success) + { + result.failure_status = auth.failure_status; + if (!options.expect_auth_failure.empty()) + { + Expect(auth.failure_status == options.expect_auth_failure, + "expected auth failure '" + options.expect_auth_failure + "' but got '" + auth.failure_status + "'"); + result.ok = true; + result.result = "expected_auth_failure"; + result.stage = "auth_login"; + EmitEvent(result, options.json, "expected_auth_failure status=" + auth.failure_status); + if (options.json) + PrintJson(result); + return 0; + } + + throw std::runtime_error("auth login failed: " + auth.failure_status); + } + + Expect(options.expect_auth_failure.empty(), + "auth login succeeded but expected failure '" + options.expect_auth_failure + "'"); + + const uint32_t login_key = auth.login_key; + result.login_key = login_key; + + result.stage = "channel_handshake"; + started_at = std::chrono::steady_clock::now(); EncryptedClient channel_client(host, channel_port); channel_client.Handshake(); - const PacketGCLoginSuccess4 login_success = LoginToChannel(channel_client, login, login_key); + result.channel_handshake_ms = ElapsedMs(started_at); + + result.stage = "channel_login"; + started_at = std::chrono::steady_clock::now(); + const ChannelLoginResponse channel = LoginToChannel(channel_client, login, login_key, result, options.json); + result.channel_login_ms = ElapsedMs(started_at); + + if (!channel.success) + { + result.failure_status = channel.failure_status; + if (!options.expect_channel_failure.empty()) + { + Expect(channel.failure_status == options.expect_channel_failure, + "expected channel failure '" + options.expect_channel_failure + "' but got '" + channel.failure_status + "'"); + result.ok = true; + result.result = "expected_channel_failure"; + result.stage = "channel_login"; + EmitEvent(result, options.json, "expected_channel_failure status=" + channel.failure_status); + if (options.json) + PrintJson(result); + return 0; + } + + throw std::runtime_error("channel login failed: " + channel.failure_status); + } + + Expect(options.expect_channel_failure.empty(), + "channel login succeeded but expected failure '" + options.expect_channel_failure + "'"); + + const PacketGCLoginSuccess4& login_success = channel.login_success; + result.empire = channel.empire; uint8_t character_index = 0; try { character_index = FindFirstPlayableCharacterIndex(login_success); - std::cout << "selected_character index=" << static_cast(character_index) << "\n"; + result.character_index = character_index; + result.character_name = login_success.players[character_index].name; + EmitEvent(result, options.json, "selected_character index=" + std::to_string(static_cast(character_index))); } catch (const std::exception&) { - Expect(!create_character_name.empty(), "account has no playable characters and no create-character name was provided"); - character_index = CreateCharacter(channel_client, create_character_name); + Expect(!options.create_character_name.empty(), "account has no playable characters and no create-character name was provided"); + result.stage = "character_create"; + started_at = std::chrono::steady_clock::now(); + character_index = CreateCharacter(channel_client, options.create_character_name, result, options.json); + result.character_create_ms = ElapsedMs(started_at); + result.character_index = character_index; } - SelectCharacter(channel_client, character_index); - SendClientVersion(channel_client, client_version); - EnterGame(channel_client); + result.stage = "character_select"; + started_at = std::chrono::steady_clock::now(); + SelectCharacter(channel_client, character_index, result, options.json); + result.character_select_ms = ElapsedMs(started_at); - std::cout << "full_login_success\n"; + result.stage = "client_version"; + SendClientVersion(channel_client, options.client_version, result, options.json); + + result.stage = "entergame"; + started_at = std::chrono::steady_clock::now(); + EnterGame(channel_client, result, options.json); + result.entergame_ms = ElapsedMs(started_at); + + result.ok = true; + result.result = "success"; + result.stage = "complete"; + EmitEvent(result, options.json, "full_login_success"); + if (options.json) + PrintJson(result); return 0; } catch (const std::exception& e) { - std::cerr << "metin_login_smoke failed: " << e.what() << "\n"; + result.ok = false; + result.result = "unexpected_failure"; + result.error = e.what(); + if (options.json) + { + PrintJson(result); + } + else + { + std::cerr << "metin_login_smoke failed: " << e.what() << "\n"; + } return 1; } }