[Servatrice] Log room channel messages in audit log (#7479)

* [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 <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
BruebachL 2026-10-16 22:46:39 +02:00 • committed by GitHub
parent c99113ab0e
commit ce51e7147d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 603 additions and 14 deletions

View file

@ -35,10 +35,10 @@ TabLog::TabLog(TabSupervisor *_tabSupervisor, AbstractClient *_client, bool _can
: Tab(_tabSupervisor), client(_client), canUseDeveloperCommands(_canUseDeveloperCommands) : Tab(_tabSupervisor), client(_client), canUseDeveloperCommands(_canUseDeveloperCommands)
{ {
roomTable = new QTableWidget(); roomTable = new QTableWidget();
roomTable->setColumnCount(6); roomTable->setColumnCount(7);
roomTable->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); roomTable->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
roomTable->setHorizontalHeaderLabels( 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 = new QTableWidget();
gameTable->setColumnCount(6); gameTable->setColumnCount(6);
@ -317,7 +317,8 @@ void TabLog::viewLogHistory_processResponse(const Response &resp)
for (int i = 0; i < response.log_message_size(); ++i) { for (int i = 0; i < response.log_message_size(); ++i) {
ServerInfo_ChatMessage message = response.log_message(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->insertRow(roomCounter);
roomTable->setItem(roomCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time()))); roomTable->setItem(roomCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time())));
roomTable->setItem(roomCounter, 1, new QTableWidgetItem(QString::fromStdString(message.sender_name()))); 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, 3, new QTableWidgetItem(QString::fromStdString(message.message())));
roomTable->setItem(roomCounter, 4, new QTableWidgetItem(QString::fromStdString(message.target_id()))); 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, 5, new QTableWidgetItem(QString::fromStdString(message.target_name())));
roomTable->setItem(roomCounter, 6, new QTableWidgetItem(QString::fromStdString(message.channel())));
++roomCounter; ++roomCounter;
} }
if (QString::fromStdString(message.target_type()) == "game") { if (targetType == "game") {
gameTable->insertRow(gameCounter); gameTable->insertRow(gameCounter);
gameTable->setItem(gameCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time()))); gameTable->setItem(gameCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time())));
gameTable->setItem(gameCounter, 1, new QTableWidgetItem(QString::fromStdString(message.sender_name()))); gameTable->setItem(gameCounter, 1, new QTableWidgetItem(QString::fromStdString(message.sender_name())));
@ -339,7 +341,7 @@ void TabLog::viewLogHistory_processResponse(const Response &resp)
++gameCounter; ++gameCounter;
} }
if (QString::fromStdString(message.target_type()) == "chat") { if (targetType == "chat") {
chatTable->insertRow(chatCounter); chatTable->insertRow(chatCounter);
chatTable->setItem(chatCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time()))); chatTable->setItem(chatCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time())));
chatTable->setItem(chatCounter, 1, new QTableWidgetItem(QString::fromStdString(message.sender_name()))); chatTable->setItem(chatCounter, 1, new QTableWidgetItem(QString::fromStdString(message.sender_name())));

View file

@ -151,7 +151,8 @@ public:
MessageTargetRoom, MessageTargetRoom,
MessageTargetGame, MessageTargetGame,
MessageTargetChat, MessageTargetChat,
MessageTargetIslRoom MessageTargetIslRoom,
MessageTargetRoomChannel
}; };
virtual void logMessage(const int /* senderId */, virtual void logMessage(const int /* senderId */,
const QString & /* senderName */, const QString & /* senderName */,
@ -159,7 +160,8 @@ public:
const QString & /* logMessage */, const QString & /* logMessage */,
LogMessage_TargetType /* targetType */, LogMessage_TargetType /* targetType */,
const int /* targetId */, const int /* targetId */,
const QString & /* targetName */) const QString & /* targetName */,
const QString & /* channel */ = QString())
{ {
} }
virtual bool checkUserIsBanned(Server_ProtocolHandler * /* session */, virtual bool checkUserIsBanned(Server_ProtocolHandler * /* session */,

View file

@ -1003,6 +1003,10 @@ Response::ResponseCode Server_ProtocolHandler::cmdRoomChannelSay(const Command_R
room->say(QString::fromStdString(userInfo->name()), msg, channelId); 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; return Response::RespOk;
} }

View file

@ -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 FOREIGN KEY(`id_room`) REFERENCES `cockatrice_rooms`(`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; ) 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; UPDATE cockatrice_schema_version SET version=38 WHERE version=37;

View file

@ -341,9 +341,10 @@ CREATE TABLE IF NOT EXISTS `cockatrice_log` (
`sender_name` varchar(35) NOT NULL, `sender_name` varchar(35) NOT NULL,
`sender_ip` varchar(45) NOT NULL, `sender_ip` varchar(45) NOT NULL,
`log_message` text 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_id` int(7) NULL,
`target_name` varchar(50) NOT NULL, `target_name` varchar(50) NOT NULL,
`channel` varchar(50) NULL,
KEY `sender_name` (`sender_name`), KEY `sender_name` (`sender_name`),
KEY `sender_ip` (`sender_ip`), KEY `sender_ip` (`sender_ip`),
KEY `target_id` (`target_id`), KEY `target_id` (`target_id`),

View file

@ -1286,7 +1286,8 @@ void Servatrice_DatabaseInterface::logMessage(const int senderId,
const QString &logMessage, const QString &logMessage,
LogMessage_TargetType targetType, LogMessage_TargetType targetType,
const int targetId, const int targetId,
const QString &targetName) const QString &targetName,
const QString &channel)
{ {
QString targetTypeString; QString targetTypeString;
switch (targetType) { switch (targetType) {
@ -1296,6 +1297,12 @@ void Servatrice_DatabaseInterface::logMessage(const int senderId,
} }
targetTypeString = "room"; targetTypeString = "room";
break; break;
case MessageTargetRoomChannel:
if (!settingsCache->value("logging/log_user_msg_room", 0).toBool()) {
return;
}
targetTypeString = "room_channel";
break;
case MessageTargetGame: case MessageTargetGame:
if (!settingsCache->value("logging/log_user_msg_game", 0).toBool()) { if (!settingsCache->value("logging/log_user_msg_game", 0).toBool()) {
return; 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, " 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, " "log_message, target_type, target_id, target_name, channel) values (now(), "
":sender_name, :sender_ip, :log_message, :target_type, :target_id, :target_name)"); ":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_id", senderId < 1 ? QVariant() : senderId);
query->bindValue(":sender_name", senderName); query->bindValue(":sender_name", senderName);
query->bindValue(":sender_ip", senderIp); query->bindValue(":sender_ip", senderIp);
@ -1328,6 +1336,7 @@ void Servatrice_DatabaseInterface::logMessage(const int senderId,
query->bindValue(":target_type", targetTypeString); query->bindValue(":target_type", targetTypeString);
query->bindValue(":target_id", (targetType == MessageTargetChat && targetId < 1) ? QVariant() : targetId); query->bindValue(":target_id", (targetType == MessageTargetChat && targetId < 1) ? QVariant() : targetId);
query->bindValue(":target_name", targetName); query->bindValue(":target_name", targetName);
query->bindValue(":channel", channel.isEmpty() ? QVariant() : channel);
execSqlQuery(query); execSqlQuery(query);
} }
@ -1846,9 +1855,9 @@ QList<ServerInfo_ChatMessage> Servatrice_DatabaseInterface::getMessageLogHistory
if (room) { if (room) {
if (game || chat) { if (game || chat) {
queryString.append(" OR `target_type` = 'room'"); queryString.append(" OR `target_type` = 'room' OR `target_type` = 'room_channel'");
} else { } else {
queryString.append("`target_type` = 'room'"); queryString.append("`target_type` = 'room' OR `target_type` = 'room_channel'");
} }
} }
queryString.append(")"); queryString.append(")");
@ -1899,6 +1908,7 @@ QList<ServerInfo_ChatMessage> Servatrice_DatabaseInterface::getMessageLogHistory
chatMessage.set_target_type(QString(query->value(5).toString()).toStdString()); chatMessage.set_target_type(QString(query->value(5).toString()).toStdString());
chatMessage.set_target_id(QString(query->value(6).toString()).toStdString()); chatMessage.set_target_id(QString(query->value(6).toString()).toStdString());
chatMessage.set_target_name(QString(query->value(7).toString()).toStdString()); chatMessage.set_target_name(QString(query->value(7).toString()).toStdString());
chatMessage.set_channel(QString(query->value(8).toString()).toStdString());
results << chatMessage; results << chatMessage;
} }

View file

@ -177,7 +177,8 @@ public:
const QString &logMessage, const QString &logMessage,
LogMessage_TargetType targetType, LogMessage_TargetType targetType,
const int targetId, 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 &password, bool passwordNeedsHash) override;
bool changeUserPassword(const QString &user, bool changeUserPassword(const QString &user,
const QString &oldPassword, const QString &oldPassword,

View file

@ -274,6 +274,17 @@ cockatrice_add_test(
LABELS LABELS
server 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( cockatrice_add_test(
NAME NAME
latency_tracker_test latency_tracker_test

View file

@ -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 <QList>
#include <QObject>
#include <QString>
#include <gtest/gtest.h>
#include <libcockatrice/protocol/get_pb_extension.h>
#include <libcockatrice/protocol/pb/commands.pb.h>
#include <libcockatrice/protocol/pb/event_room_channel_say.pb.h>
#include <libcockatrice/protocol/pb/event_room_say.pb.h>
#include <libcockatrice/protocol/pb/isl_message.pb.h>
#include <libcockatrice/protocol/pb/room_commands.pb.h>
#include <libcockatrice/protocol/pb/room_event.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_chat_message.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_room_channel.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
#include <libcockatrice/protocol/pb/session_commands.pb.h>
#include <libcockatrice/rng/rng_abstract.h>
#include <string>
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<LogEntry> 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<RoomEvent> 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<ServerInfo_RoomChannel> 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<RoomEvent> channelSayEvents(const TestRoomHandler &handler)
{
QList<RoomEvent> result;
for (const RoomEvent &event : handler.receivedRoomEvents) {
if (static_cast<RoomEvent::RoomEventType>(getPbExtension(event)) == RoomEvent::ROOM_CHANNEL_SAY) {
result.append(event);
}
}
return result;
}
static QList<RoomEvent> roomSayEvents(const TestRoomHandler &handler)
{
QList<RoomEvent> result;
for (const RoomEvent &event : handler.receivedRoomEvents) {
if (static_cast<RoomEvent::RoomEventType>(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<ServerInfo_ChatMessage> 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<ServerInfo_ChatMessage> 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<ServerInfo_ChatMessage> 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<RoomEvent> 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<RoomEvent> 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<RoomEvent> 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<IslMessage> 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<RoomEvent::RoomEventType>(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();
}