From ce51e7147dc029327733ca0decb3b6a86c8ac259 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:46:39 +0200 Subject: [PATCH] [Servatrice] Log room channel messages in audit log (#7479) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Servatrice] Log room channel messages and bump schema to 38 * Fixup: show room_channel audit rows and keep migration nullable The log history query returns room_channel rows, but the log tab dropped them. Render them in the room table with an extra Channel column. Also drop the NOT NULL on target_type from the 37->38 migration so migrated schemas match the nullable servatrice.sql definition (and strict-mode ALTERs cannot fail on legacy NULL rows). Update the channel tests to the flag-based privilege model: the admin fixture now carries IsModerator alongside IsAdmin, as the database layer assigns. --------- Co-authored-by: Lukas BrĂ¼bach --- .../src/interface/widgets/tabs/tab_logs.cpp | 12 +- .../server/remote/server_database_interface.h | 6 +- .../server/remote/server_protocolhandler.cpp | 4 + .../migrations/servatrice_0037_to_0038.sql | 8 + servatrice/servatrice.sql | 3 +- .../src/servatrice_database_interface.cpp | 20 +- .../src/servatrice_database_interface.h | 3 +- tests/CMakeLists.txt | 11 + tests/server_room_channels_test.cpp | 550 ++++++++++++++++++ 9 files changed, 603 insertions(+), 14 deletions(-) create mode 100644 tests/server_room_channels_test.cpp diff --git a/cockatrice/src/interface/widgets/tabs/tab_logs.cpp b/cockatrice/src/interface/widgets/tabs/tab_logs.cpp index 9f17afea2..67e076983 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_logs.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_logs.cpp @@ -35,10 +35,10 @@ TabLog::TabLog(TabSupervisor *_tabSupervisor, AbstractClient *_client, bool _can : Tab(_tabSupervisor), client(_client), canUseDeveloperCommands(_canUseDeveloperCommands) { roomTable = new QTableWidget(); - roomTable->setColumnCount(6); + roomTable->setColumnCount(7); roomTable->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); roomTable->setHorizontalHeaderLabels( - QString(tr("Time;SenderName;SenderIP;Message;TargetID;TargetName")).split(";")); + QString(tr("Time;SenderName;SenderIP;Message;TargetID;TargetName;Channel")).split(";")); gameTable = new QTableWidget(); gameTable->setColumnCount(6); @@ -317,7 +317,8 @@ void TabLog::viewLogHistory_processResponse(const Response &resp) for (int i = 0; i < response.log_message_size(); ++i) { ServerInfo_ChatMessage message = response.log_message(i); - if (QString::fromStdString(message.target_type()) == "room") { + const QString targetType = QString::fromStdString(message.target_type()); + if (targetType == "room" || targetType == "room_channel") { roomTable->insertRow(roomCounter); roomTable->setItem(roomCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time()))); roomTable->setItem(roomCounter, 1, new QTableWidgetItem(QString::fromStdString(message.sender_name()))); @@ -325,10 +326,11 @@ void TabLog::viewLogHistory_processResponse(const Response &resp) roomTable->setItem(roomCounter, 3, new QTableWidgetItem(QString::fromStdString(message.message()))); roomTable->setItem(roomCounter, 4, new QTableWidgetItem(QString::fromStdString(message.target_id()))); roomTable->setItem(roomCounter, 5, new QTableWidgetItem(QString::fromStdString(message.target_name()))); + roomTable->setItem(roomCounter, 6, new QTableWidgetItem(QString::fromStdString(message.channel()))); ++roomCounter; } - if (QString::fromStdString(message.target_type()) == "game") { + if (targetType == "game") { gameTable->insertRow(gameCounter); gameTable->setItem(gameCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time()))); gameTable->setItem(gameCounter, 1, new QTableWidgetItem(QString::fromStdString(message.sender_name()))); @@ -339,7 +341,7 @@ void TabLog::viewLogHistory_processResponse(const Response &resp) ++gameCounter; } - if (QString::fromStdString(message.target_type()) == "chat") { + if (targetType == "chat") { chatTable->insertRow(chatCounter); chatTable->setItem(chatCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time()))); chatTable->setItem(chatCounter, 1, new QTableWidgetItem(QString::fromStdString(message.sender_name()))); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h b/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h index 167adfeaf..3fef26904 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h @@ -151,7 +151,8 @@ public: MessageTargetRoom, MessageTargetGame, MessageTargetChat, - MessageTargetIslRoom + MessageTargetIslRoom, + MessageTargetRoomChannel }; virtual void logMessage(const int /* senderId */, const QString & /* senderName */, @@ -159,7 +160,8 @@ public: const QString & /* logMessage */, LogMessage_TargetType /* targetType */, const int /* targetId */, - const QString & /* targetName */) + const QString & /* targetName */, + const QString & /* channel */ = QString()) { } virtual bool checkUserIsBanned(Server_ProtocolHandler * /* session */, diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp index 8b4ade900..f75e2d3e3 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp @@ -1003,6 +1003,10 @@ Response::ResponseCode Server_ProtocolHandler::cmdRoomChannelSay(const Command_R room->say(QString::fromStdString(userInfo->name()), msg, channelId); + databaseInterface->logMessage( + userInfo->id(), QString::fromStdString(userInfo->name()), QString::fromStdString(userInfo->address()), msg, + Server_DatabaseInterface::MessageTargetRoomChannel, room->getId(), room->getName(), channelId); + return Response::RespOk; } diff --git a/servatrice/migrations/servatrice_0037_to_0038.sql b/servatrice/migrations/servatrice_0037_to_0038.sql index 6b8f155ab..ad6477c46 100644 --- a/servatrice/migrations/servatrice_0037_to_0038.sql +++ b/servatrice/migrations/servatrice_0037_to_0038.sql @@ -18,4 +18,12 @@ CREATE TABLE IF NOT EXISTS `cockatrice_rooms_channels` ( FOREIGN KEY(`id_room`) REFERENCES `cockatrice_rooms`(`id`) ON DELETE CASCADE ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; +-- 2. Audit log: room_channel target type plus a channel column so moderators +-- can tell which stream a logged message belongs to. +ALTER TABLE `cockatrice_log` + MODIFY `target_type` ENUM('room', 'game', 'chat', 'room_channel'); + +ALTER TABLE `cockatrice_log` + ADD COLUMN `channel` varchar(50) NULL AFTER `target_name`; + UPDATE cockatrice_schema_version SET version=38 WHERE version=37; \ No newline at end of file diff --git a/servatrice/servatrice.sql b/servatrice/servatrice.sql index 839dbf572..438b32ccd 100644 --- a/servatrice/servatrice.sql +++ b/servatrice/servatrice.sql @@ -341,9 +341,10 @@ CREATE TABLE IF NOT EXISTS `cockatrice_log` ( `sender_name` varchar(35) NOT NULL, `sender_ip` varchar(45) NOT NULL, `log_message` text NOT NULL, - `target_type` ENUM('room', 'game', 'chat'), + `target_type` ENUM('room', 'game', 'chat', 'room_channel'), `target_id` int(7) NULL, `target_name` varchar(50) NOT NULL, + `channel` varchar(50) NULL, KEY `sender_name` (`sender_name`), KEY `sender_ip` (`sender_ip`), KEY `target_id` (`target_id`), diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index e7d46c050..d7912cfe6 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -1286,7 +1286,8 @@ void Servatrice_DatabaseInterface::logMessage(const int senderId, const QString &logMessage, LogMessage_TargetType targetType, const int targetId, - const QString &targetName) + const QString &targetName, + const QString &channel) { QString targetTypeString; switch (targetType) { @@ -1296,6 +1297,12 @@ void Servatrice_DatabaseInterface::logMessage(const int senderId, } targetTypeString = "room"; break; + case MessageTargetRoomChannel: + if (!settingsCache->value("logging/log_user_msg_room", 0).toBool()) { + return; + } + targetTypeString = "room_channel"; + break; case MessageTargetGame: if (!settingsCache->value("logging/log_user_msg_game", 0).toBool()) { return; @@ -1319,8 +1326,9 @@ void Servatrice_DatabaseInterface::logMessage(const int senderId, } QSqlQuery *query = prepareQuery("insert into {prefix}_log (log_time, sender_id, sender_name, sender_ip, " - "log_message, target_type, target_id, target_name) values (now(), :sender_id, " - ":sender_name, :sender_ip, :log_message, :target_type, :target_id, :target_name)"); + "log_message, target_type, target_id, target_name, channel) values (now(), " + ":sender_id, :sender_name, :sender_ip, :log_message, :target_type, :target_id, " + ":target_name, :channel)"); query->bindValue(":sender_id", senderId < 1 ? QVariant() : senderId); query->bindValue(":sender_name", senderName); query->bindValue(":sender_ip", senderIp); @@ -1328,6 +1336,7 @@ void Servatrice_DatabaseInterface::logMessage(const int senderId, query->bindValue(":target_type", targetTypeString); query->bindValue(":target_id", (targetType == MessageTargetChat && targetId < 1) ? QVariant() : targetId); query->bindValue(":target_name", targetName); + query->bindValue(":channel", channel.isEmpty() ? QVariant() : channel); execSqlQuery(query); } @@ -1846,9 +1855,9 @@ QList Servatrice_DatabaseInterface::getMessageLogHistory if (room) { if (game || chat) { - queryString.append(" OR `target_type` = 'room'"); + queryString.append(" OR `target_type` = 'room' OR `target_type` = 'room_channel'"); } else { - queryString.append("`target_type` = 'room'"); + queryString.append("`target_type` = 'room' OR `target_type` = 'room_channel'"); } } queryString.append(")"); @@ -1899,6 +1908,7 @@ QList Servatrice_DatabaseInterface::getMessageLogHistory chatMessage.set_target_type(QString(query->value(5).toString()).toStdString()); chatMessage.set_target_id(QString(query->value(6).toString()).toStdString()); chatMessage.set_target_name(QString(query->value(7).toString()).toStdString()); + chatMessage.set_channel(QString(query->value(8).toString()).toStdString()); results << chatMessage; } diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h index 2e0d69113..eccd0d943 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -177,7 +177,8 @@ public: const QString &logMessage, LogMessage_TargetType targetType, const int targetId, - const QString &targetName) override; + const QString &targetName, + const QString &channel = QString()) override; bool changeUserPassword(const QString &user, const QString &password, bool passwordNeedsHash) override; bool changeUserPassword(const QString &user, const QString &oldPassword, diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index ee0fd0698..b58942f3f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -274,6 +274,17 @@ cockatrice_add_test( LABELS server ) +cockatrice_add_test( + NAME + server_room_channels_test + SOURCES + server_room_channels_test.cpp + LIBS + libcockatrice_network_server_remote + libcockatrice_rng + LABELS + server +) cockatrice_add_test( NAME latency_tracker_test diff --git a/tests/server_room_channels_test.cpp b/tests/server_room_channels_test.cpp new file mode 100644 index 000000000..006c9fa39 --- /dev/null +++ b/tests/server_room_channels_test.cpp @@ -0,0 +1,550 @@ +/** @file server_room_channels_test.cpp + * @brief Tests for room chat channels: fan-out, privilege gating, whitelist, history replay and audit logging. + * @ingroup Tests + */ + +#include "libcockatrice/protocol/pb/response.pb.h" +#include "libcockatrice/protocol/pb/server_message.pb.h" +#include "server.h" +#include "server_database_interface.h" +#include "server_protocolhandler.h" +#include "server_room.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +RNG_Abstract *rng = nullptr; // referenced by the server_remote library + +namespace +{ + +class MockDatabaseInterface : public Server_DatabaseInterface +{ +public: + struct LogEntry + { + QString senderName; + QString message; + LogMessage_TargetType targetType; + int targetId; + QString targetName; + QString channel; + }; + QList logEntries; + + void logMessage(const int /* senderId */, + const QString &senderName, + const QString & /* senderIp */, + const QString &message, + LogMessage_TargetType targetType, + const int targetId, + const QString &targetName, + const QString &channel = QString()) override + { + logEntries.append({senderName, message, targetType, targetId, targetName, channel}); + } + + AuthenticationResult checkUserPassword(Server_ProtocolHandler *, + const QString &, + const QString &, + const QString &, + QString &, + int &, + bool) override + { + return NotLoggedIn; + } + int getNextReplayId() override + { + return 1; + } + int getNextGameId() override + { + return 1; + } + int getActiveUserCount(QString) override + { + return 0; + } + ServerInfo_User getUserData(const QString &, bool) override + { + return ServerInfo_User(); + } +}; + +class FakeServer : public Server +{ +public: + explicit FakeServer(Server_DatabaseInterface *db) : Server() + { + setDatabaseInterface(db); + } + void insertRoom(Server_Room *newRoom) + { + addRoom(newRoom); + } +}; + +class TestRoomHandler : public Server_ProtocolHandler +{ +public: + TestRoomHandler(Server *_server, Server_DatabaseInterface *_db) : Server_ProtocolHandler(_server, _db) + { + } + + QString getAddress() const override + { + return {}; + } + QString getConnectionType() const override + { + return {}; + } + + void authenticate(const ServerInfo_User &userInfo_) + { + setUserInfo(userInfo_); + authState = PasswordRight; + } + void attachToRoom(Server_Room *targetRoom) + { + rooms.insert(targetRoom->getId(), targetRoom); + } + + QList receivedRoomEvents; + Response::ResponseCode lastResponseCode = Response::RespNothing; + + void clearReceived() + { + receivedRoomEvents.clear(); + lastResponseCode = Response::RespNothing; + } + +protected: + void transmitProtocolItem(const ServerMessage &item) override + { + if (item.message_type() == ServerMessage::ROOM_EVENT) { + receivedRoomEvents.append(item.room_event()); + } else if (item.message_type() == ServerMessage::RESPONSE) { + lastResponseCode = item.response().response_code(); + } + } +}; + +class RoomChannelTest : public ::testing::Test +{ +protected: + MockDatabaseInterface db; + FakeServer server{&db}; + QList catalog; + Server_Room *room = nullptr; + TestRoomHandler mod{&server, &db}; + TestRoomHandler adminUser{&server, &db}; + TestRoomHandler user{&server, &db}; + ServerInfo_User moderator; + ServerInfo_User admin; + ServerInfo_User plainUser; + + void SetUp() override + { + ServerInfo_RoomChannel lobby; + lobby.set_id("lobby"); + lobby.set_display_name("Lobby"); + lobby.set_access_level(ServerInfo_RoomChannel::Public); + catalog.append(lobby); + + ServerInfo_RoomChannel mods; + mods.set_id("mods"); + mods.set_display_name("Moderators"); + mods.set_access_level(ServerInfo_RoomChannel::Moderator); + catalog.append(mods); + + ServerInfo_RoomChannel ops; + ops.set_id("ops"); + ops.set_display_name("Admins"); + ops.set_access_level(ServerInfo_RoomChannel::Admin); + catalog.append(ops); + + room = new Server_Room(1, 1000, "test room", "", "", "", false, "Welcome", QStringList(), &server, catalog); + + moderator.set_name("mod"); + moderator.set_user_level(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered | + ServerInfo_User::IsModerator); + // Mirrors the database layer: the admin bit grants IsAdmin alongside + // IsModerator, so admins reach moderator channels via the moderator flag. + admin.set_name("admin"); + admin.set_user_level(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered | ServerInfo_User::IsAdmin | + ServerInfo_User::IsModerator); + plainUser.set_name("user"); + plainUser.set_user_level(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered); + } + + void TearDown() override + { + delete room; + } + + // Make @p handler a member of the room directly, without history replay or JOIN_ROOM bookkeeping. + void joinMember(TestRoomHandler &handler, const ServerInfo_User &userInfo_) + { + handler.authenticate(userInfo_); + handler.attachToRoom(room); + room->addClient(&handler); + } + + void sendMainSay(TestRoomHandler &handler, const QString &message) + { + CommandContainer cont; + cont.set_cmd_id(1); + cont.set_room_id(room->getId()); + Command_RoomSay cmd; + cmd.set_message(message.toStdString()); + cont.add_room_command()->MutableExtension(Command_RoomSay::ext)->CopyFrom(cmd); + handler.processCommandContainer(cont); + } + + void sendChannelSay(TestRoomHandler &handler, const QString &channelId, const QString &message) + { + CommandContainer cont; + cont.set_cmd_id(1); + cont.set_room_id(room->getId()); + Command_RoomChannelSay cmd; + cmd.set_channel_id(channelId.toStdString()); + cmd.set_message(message.toStdString()); + cont.add_room_command()->MutableExtension(Command_RoomChannelSay::ext)->CopyFrom(cmd); + handler.processCommandContainer(cont); + } + + void joinThroughCommand(TestRoomHandler &handler) + { + CommandContainer cont; + cont.set_cmd_id(1); + Command_JoinRoom cmd; + cmd.set_room_id(room->getId()); + cont.add_session_command()->MutableExtension(Command_JoinRoom::ext)->CopyFrom(cmd); + handler.processCommandContainer(cont); + } + + static QList channelSayEvents(const TestRoomHandler &handler) + { + QList result; + for (const RoomEvent &event : handler.receivedRoomEvents) { + if (static_cast(getPbExtension(event)) == RoomEvent::ROOM_CHANNEL_SAY) { + result.append(event); + } + } + return result; + } + + static QList roomSayEvents(const TestRoomHandler &handler) + { + QList result; + for (const RoomEvent &event : handler.receivedRoomEvents) { + if (static_cast(getPbExtension(event)) == RoomEvent::ROOM_SAY) { + result.append(event); + } + } + return result; + } + + static QString channelIdOf(const RoomEvent &event) + { + return QString::fromStdString(event.GetExtension(Event_RoomChannelSay::ext).channel_id()); + } + + static QString messageOf(const RoomEvent &event) + { + return QString::fromStdString(event.GetExtension(Event_RoomChannelSay::ext).message()); + } + + static QString mainMessageOf(const RoomEvent &event) + { + return QString::fromStdString(event.GetExtension(Event_RoomSay::ext).message()); + } +}; + +TEST_F(RoomChannelTest, MainSayStillBroadcastsToAllRoomMembers) +{ + joinMember(mod, moderator); + joinMember(user, plainUser); + mod.clearReceived(); + user.clearReceived(); + + sendMainSay(user, "hello everyone"); + + EXPECT_EQ(user.lastResponseCode, Response::RespOk); + EXPECT_EQ(roomSayEvents(mod).size(), 1); + EXPECT_EQ(mainMessageOf(roomSayEvents(mod).first()), "hello everyone"); + EXPECT_EQ(roomSayEvents(user).size(), 1); + EXPECT_EQ(mainMessageOf(roomSayEvents(user).first()), "hello everyone"); + EXPECT_TRUE(channelSayEvents(mod).isEmpty()); + EXPECT_TRUE(channelSayEvents(user).isEmpty()); +} + +TEST_F(RoomChannelTest, PublicChannelSaysBroadcastToEveryone) +{ + joinMember(mod, moderator); + joinMember(user, plainUser); + mod.clearReceived(); + user.clearReceived(); + + sendChannelSay(user, "lobby", "is there a game tonight?"); + + EXPECT_EQ(user.lastResponseCode, Response::RespOk); + EXPECT_EQ(channelSayEvents(mod).size(), 1); + EXPECT_EQ(channelIdOf(channelSayEvents(mod).first()), "lobby"); + EXPECT_EQ(messageOf(channelSayEvents(mod).first()), "is there a game tonight?"); + EXPECT_EQ(channelSayEvents(user).size(), 1); +} + +TEST_F(RoomChannelTest, ModeratorChannelReachesModeratorAndAbove) +{ + joinMember(mod, moderator); + joinMember(adminUser, admin); + joinMember(user, plainUser); + mod.clearReceived(); + adminUser.clearReceived(); + user.clearReceived(); + + sendChannelSay(mod, "mods", "quiet channel"); + + EXPECT_EQ(mod.lastResponseCode, Response::RespOk); + EXPECT_EQ(channelSayEvents(mod).size(), 1); + EXPECT_EQ(channelIdOf(channelSayEvents(mod).first()), "mods"); + // An admin holds the moderator flag alongside the admin flag and so also receives the channel message. + EXPECT_EQ(channelSayEvents(adminUser).size(), 1); + EXPECT_TRUE(channelSayEvents(user).isEmpty()); + EXPECT_TRUE(roomSayEvents(user).isEmpty()); +} + +TEST_F(RoomChannelTest, AdminReachesModeratorChannel) +{ + joinMember(mod, moderator); + joinMember(adminUser, admin); + mod.clearReceived(); + adminUser.clearReceived(); + + sendChannelSay(adminUser, "mods", "admin checking in"); + + EXPECT_EQ(adminUser.lastResponseCode, Response::RespOk); + EXPECT_EQ(channelSayEvents(mod).size(), 1); + EXPECT_EQ(channelSayEvents(adminUser).size(), 1); + EXPECT_EQ(messageOf(channelSayEvents(mod).first()), "admin checking in"); +} + +TEST_F(RoomChannelTest, UserBelowRequiredLevelCannotSendToChannel) +{ + joinMember(mod, moderator); + joinMember(user, plainUser); + mod.clearReceived(); + user.clearReceived(); + + sendChannelSay(user, "mods", "let me in"); + + EXPECT_EQ(user.lastResponseCode, Response::RespFunctionNotAllowed); + EXPECT_TRUE(channelSayEvents(mod).isEmpty()); + EXPECT_TRUE(channelSayEvents(user).isEmpty()); + bool loggedAsChannel = false; + for (const MockDatabaseInterface::LogEntry &entry : db.logEntries) { + loggedAsChannel |= entry.targetType == Server_DatabaseInterface::MessageTargetRoomChannel; + } + EXPECT_FALSE(loggedAsChannel); +} + +TEST_F(RoomChannelTest, AdminChannelRejectsModerator) +{ + joinMember(mod, moderator); + joinMember(adminUser, admin); + mod.clearReceived(); + adminUser.clearReceived(); + + sendChannelSay(mod, "ops", "not allowed"); + + EXPECT_EQ(mod.lastResponseCode, Response::RespFunctionNotAllowed); + EXPECT_TRUE(channelSayEvents(mod).isEmpty()); + EXPECT_TRUE(channelSayEvents(adminUser).isEmpty()); +} + +TEST_F(RoomChannelTest, AdminChannelReachesAdminsOnly) +{ + joinMember(mod, moderator); + joinMember(adminUser, admin); + joinMember(user, plainUser); + mod.clearReceived(); + adminUser.clearReceived(); + user.clearReceived(); + + sendChannelSay(adminUser, "ops", "staff only"); + + EXPECT_EQ(adminUser.lastResponseCode, Response::RespOk); + EXPECT_EQ(channelSayEvents(adminUser).size(), 1); + EXPECT_TRUE(channelSayEvents(mod).isEmpty()); + EXPECT_TRUE(channelSayEvents(user).isEmpty()); +} + +TEST_F(RoomChannelTest, UnknownChannelIdIsRejected) +{ + joinMember(user, plainUser); + user.clearReceived(); + + sendChannelSay(user, "not-a-channel", "spam"); + + EXPECT_EQ(user.lastResponseCode, Response::RespInvalidCommand); + EXPECT_TRUE(channelSayEvents(user).isEmpty()); + EXPECT_TRUE(db.logEntries.isEmpty()); +} + +TEST_F(RoomChannelTest, MissingAccessLevelDefaultsToPublic) +{ + ServerInfo_RoomChannel defaultAccess; + defaultAccess.set_id("public-by-default"); + defaultAccess.set_display_name("Default Access"); + catalog.append(defaultAccess); + auto *secondRoom = new Server_Room(2, 1000, "second", "", "", "", false, "", QStringList(), &server, catalog); + + TestRoomHandler joiner{&server, &db}; + joiner.authenticate(plainUser); + joiner.attachToRoom(secondRoom); + secondRoom->addClient(&joiner); + + CommandContainer cont; + cont.set_cmd_id(1); + cont.set_room_id(2); + Command_RoomChannelSay cmd; + cmd.set_channel_id("public-by-default"); + cmd.set_message("hi"); + cont.add_room_command()->MutableExtension(Command_RoomChannelSay::ext)->CopyFrom(cmd); + joiner.processCommandContainer(cont); + + EXPECT_EQ(joiner.lastResponseCode, Response::RespOk); + EXPECT_EQ(channelSayEvents(joiner).size(), 1); + + delete secondRoom; +} + +TEST_F(RoomChannelTest, HistoryStoredPerChannel) +{ + room->say("user", "main message", QString(), false); + room->say("user", "lobby message", "lobby", false); + room->say("user", "mods message", "mods", false); + + const QList mainHistory = room->getChatHistory(Server_Room::MAIN_CHANNEL_KEY); + EXPECT_EQ(mainHistory.size(), 1); + EXPECT_EQ(QString::fromStdString(mainHistory.first().message()), "main message"); + EXPECT_TRUE(QString::fromStdString(mainHistory.first().channel()).isEmpty()); + + const QList lobbyHistory = room->getChatHistory("lobby"); + EXPECT_EQ(lobbyHistory.size(), 1); + EXPECT_EQ(QString::fromStdString(lobbyHistory.first().message()), "lobby message"); + EXPECT_EQ(QString::fromStdString(lobbyHistory.first().channel()), "lobby"); + + const QList modsHistory = room->getChatHistory("mods"); + EXPECT_EQ(modsHistory.size(), 1); + EXPECT_EQ(QString::fromStdString(modsHistory.first().message()), "mods message"); + EXPECT_EQ(QString::fromStdString(modsHistory.first().channel()), "mods"); + + // A channel id maps to its own history bucket, never to the main one. + EXPECT_EQ(room->getChatHistory("").size(), mainHistory.size()); +} + +TEST_F(RoomChannelTest, JoinReplaysOnlyVisibleChannelHistory) +{ + server.insertRoom(room); + joinMember(mod, moderator); + joinMember(user, plainUser); + + room->say("user", "main message", QString(), false); + room->say("user", "lobby message", "lobby", false); + room->say("mod", "mods message", "mods", false); + room->say("admin", "ops message", "ops", false); + + TestRoomHandler lateUser{&server, &db}; + lateUser.authenticate(plainUser); + joinThroughCommand(lateUser); + + EXPECT_EQ(lateUser.lastResponseCode, Response::RespOk); + const QList lateChannelEvents = channelSayEvents(lateUser); + // Replay: one main history event, one public channel history event, the welcome message -- but no restricted + // history. + EXPECT_EQ(roomSayEvents(lateUser).size(), 2); + EXPECT_EQ(lateChannelEvents.size(), 1); + EXPECT_EQ(channelIdOf(lateChannelEvents.first()), "lobby"); + + TestRoomHandler lateMod{&server, &db}; + lateMod.authenticate(moderator); + joinThroughCommand(lateMod); + + EXPECT_EQ(lateMod.lastResponseCode, Response::RespOk); + const QList lateModChannelEvents = channelSayEvents(lateMod); + EXPECT_EQ(lateModChannelEvents.size(), 2); + EXPECT_EQ(channelIdOf(lateModChannelEvents.at(0)), "lobby"); + EXPECT_EQ(channelIdOf(lateModChannelEvents.at(1)), "mods"); + + TestRoomHandler lateAdmin{&server, &db}; + lateAdmin.authenticate(admin); + joinThroughCommand(lateAdmin); + + EXPECT_EQ(lateAdmin.lastResponseCode, Response::RespOk); + const QList lateAdminChannelEvents = channelSayEvents(lateAdmin); + // An admin sees the moderator channel and the admin channel on top of the public one. + EXPECT_EQ(lateAdminChannelEvents.size(), 3); + EXPECT_EQ(channelIdOf(lateAdminChannelEvents.at(0)), "lobby"); + EXPECT_EQ(channelIdOf(lateAdminChannelEvents.at(1)), "mods"); + EXPECT_EQ(channelIdOf(lateAdminChannelEvents.at(2)), "ops"); +} + +TEST_F(RoomChannelTest, AuditLoggingTagsChannelMessages) +{ + joinMember(user, plainUser); + + sendMainSay(user, "main log line"); + sendChannelSay(user, "lobby", "channel log line"); + + ASSERT_EQ(db.logEntries.size(), 2); + EXPECT_EQ(db.logEntries.at(0).targetType, Server_DatabaseInterface::MessageTargetRoom); + EXPECT_TRUE(db.logEntries.at(0).channel.isEmpty()); + EXPECT_EQ(db.logEntries.at(1).targetType, Server_DatabaseInterface::MessageTargetRoomChannel); + EXPECT_EQ(db.logEntries.at(1).channel, "lobby"); + EXPECT_EQ(db.logEntries.at(1).senderName, "user"); + EXPECT_EQ(db.logEntries.at(1).message, "channel log line"); +} + +TEST_F(RoomChannelTest, PublicChannelRelaysOverIslButRestrictedChannelsDoNot) +{ + QList relMessages; + QObject::connect(&server, &Server::sigSendIslMessage, + [&relMessages](const IslMessage &message, int) { relMessages.append(message); }); + + room->say("mod", "public msg", "lobby"); + room->say("mod", "mods msg", "mods"); + room->say("admin", "ops msg", "ops"); + + ASSERT_EQ(relMessages.size(), 1); + const RoomEvent &relayed = relMessages.first().room_event(); + EXPECT_EQ(static_cast(getPbExtension(relayed)), RoomEvent::ROOM_CHANNEL_SAY); + EXPECT_EQ(channelIdOf(relayed), "lobby"); + EXPECT_EQ(messageOf(relayed), "public msg"); +} + +} // namespace + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} \ No newline at end of file