diff --git a/cockatrice/src/interface/widgets/tabs/tab_report.cpp b/cockatrice/src/interface/widgets/tabs/tab_report.cpp index 5f158e9b1..5fe656f44 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_report.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_report.cpp @@ -38,6 +38,7 @@ #include #include #include +#include #include #include #include @@ -233,12 +234,14 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T resolveButton = new QPushButton; resolveWithNoteButton = new QPushButton; dismissButton = new QPushButton; + reopenButton = new QPushButton; viewReplayButton = new QPushButton; joinGameButton = new QPushButton; connect(assignButton, &QPushButton::clicked, this, &TabReport::assignReport); connect(resolveButton, &QPushButton::clicked, this, [this]() { resolveReport(false, false); }); connect(resolveWithNoteButton, &QPushButton::clicked, this, [this]() { resolveReport(false, true); }); connect(dismissButton, &QPushButton::clicked, this, [this]() { resolveReport(true, true); }); + connect(reopenButton, &QPushButton::clicked, this, &TabReport::reopenReport); connect(viewReplayButton, &QPushButton::clicked, this, &TabReport::viewReplay); connect(joinGameButton, &QPushButton::clicked, this, &TabReport::joinGame); @@ -249,6 +252,7 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T actionBar->addWidget(resolveButton); actionBar->addWidget(resolveWithNoteButton); actionBar->addWidget(dismissButton); + actionBar->addWidget(reopenButton); actionBar->addSpacing(20); actionBar->addWidget(viewReplayButton); actionBar->addWidget(joinGameButton); @@ -315,6 +319,8 @@ void TabReport::retranslateUi() resolveButton->setText(tr("Resolve")); resolveWithNoteButton->setText(tr("Resolve with note...")); dismissButton->setText(tr("Dismiss...")); + reopenButton->setText(tr("Reopen...")); + reopenButton->setToolTip(tr("Available for resolved or dismissed reports.")); viewReplayButton->setText(tr("View Replay")); joinGameButton->setText(tr("Join Game")); } @@ -346,6 +352,7 @@ void TabReport::reportListResponse(const Response &response) refreshButton->setEnabled(true); if (response.response_code() != Response::RespOk) { + pendingStatusMessage.clear(); statusLabel->setText(tr("Failed to load reports.")); return; } @@ -359,6 +366,11 @@ void TabReport::reportListResponse(const Response &response) applyFilters(); updateStats(); + if (!pendingStatusMessage.isEmpty()) { + statusLabel->setText(pendingStatusMessage); + pendingStatusMessage.clear(); + } + if (selectedReportIdBeforeRefresh >= 0) { bool found = false; for (int row = 0; row < table->rowCount(); ++row) { @@ -579,6 +591,7 @@ void TabReport::updateActionStates() resolveButton->setEnabled(status == "open" || status == "assigned"); resolveWithNoteButton->setEnabled(status == "open" || status == "assigned"); dismissButton->setEnabled(status == "open" || status == "assigned"); + reopenButton->setEnabled(status == "resolved" || status == "dismissed"); const bool hasGameId = report.game_id() > 0; const bool hasReplay = hasGameId && report.has_replay_id() && report.replay_id() > 0; @@ -592,6 +605,7 @@ void TabReport::setActionsEnabled(bool enabled) resolveButton->setEnabled(enabled); resolveWithNoteButton->setEnabled(enabled); dismissButton->setEnabled(enabled); + reopenButton->setEnabled(enabled); viewReplayButton->setEnabled(false); joinGameButton->setEnabled(false); commentButton->setEnabled(enabled); @@ -645,7 +659,7 @@ void TabReport::assignReport() void TabReport::assignResponse(const Response &response) { if (response.response_code() == Response::RespOk) { - statusLabel->setText(tr("Assigned.")); + pendingStatusMessage = tr("Assigned."); refreshList(); } else { statusLabel->setText(tr("Assignment failed.")); @@ -689,7 +703,46 @@ void TabReport::resolveReport(bool dismissed, bool promptNote) void TabReport::resolveResponse(const Response &response) { if (response.response_code() == Response::RespOk) { - statusLabel->setText(tr("Done.")); + pendingStatusMessage = tr("Done."); + refreshList(); + } else { + statusLabel->setText(tr("Action failed.")); + updateActionStates(); + } +} + +void TabReport::reopenReport() +{ + const int reportId = selectedReportId(); + if (reportId < 0) { + return; + } + + bool ok; + QString note = QInputDialog::getText(this, tr("Reopen Report"), tr("Reopening note (optional):"), QLineEdit::Normal, + QString(), &ok); + if (!ok) { + return; + } + + setActionsEnabled(false); + statusLabel->setText(tr("Reopening...")); + + Command_ReportReopen cmd; + cmd.set_report_id(reportId); + if (!note.isEmpty()) { + cmd.set_reopen_note(note.toStdString()); + } + + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabReport::reopenResponse); + client->sendCommand(pend); +} + +void TabReport::reopenResponse(const Response &response) +{ + if (response.response_code() == Response::RespOk) { + pendingStatusMessage = tr("Reopened."); refreshList(); } else { statusLabel->setText(tr("Action failed.")); diff --git a/cockatrice/src/interface/widgets/tabs/tab_report.h b/cockatrice/src/interface/widgets/tabs/tab_report.h index 645f949de..6ea8199ce 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_report.h +++ b/cockatrice/src/interface/widgets/tabs/tab_report.h @@ -56,6 +56,8 @@ private slots: void reportDetailsResponse(const Response &response); void requestStats(); void statsResponse(const Response &response); + void reopenReport(); + void reopenResponse(const Response &response); private: int selectedReportId() const; @@ -87,6 +89,7 @@ private: QPushButton *resolveButton; QPushButton *resolveWithNoteButton; QPushButton *dismissButton; + QPushButton *reopenButton; QPushButton *viewReplayButton; QPushButton *joinGameButton; QLabel *statusLabel; @@ -108,6 +111,7 @@ private: QLabel *userContextNotesLabel; QLabel *userContextRecentReportsLabel; QString lastRequestedUser; + QString pendingStatusMessage; QGroupBox *statsGroup; QLabel *statsTotalLabel; QLabel *statsTrendLabel; diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt index bc4814d5e..4e4f0e190 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt @@ -55,6 +55,7 @@ set(PROTO_FILES command_report_details.proto command_report_list.proto command_report_my_list.proto + command_report_reopen.proto command_report_resolve.proto command_report_stats.proto command_report_user_info.proto diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_reopen.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_reopen.proto new file mode 100644 index 000000000..1f9c707e8 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_reopen.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; +import "moderator_commands.proto"; + +message Command_ReportReopen { + extend ModeratorCommand { + optional Command_ReportReopen ext = 1206; + } + required int32 report_id = 1; + optional string reopen_note = 2; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto index 4f1e80c27..5298d3dad 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto @@ -26,6 +26,7 @@ message ModeratorCommand { REPLAY_DOWNLOAD_BY_GAME_ID = 1203; REPORT_USER_INFO = 1204; REPORT_STATS = 1205; + REPORT_REOPEN = 1206; } extensions 100 to max; } diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index d46a9b0ee..8fa070ce7 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -110,6 +110,7 @@ #include #include #include +#include #include #include #include @@ -434,6 +435,8 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedModeratorCo return cmdReportAssign(cmd.GetExtension(Command_ReportAssign::ext), rc); case ModeratorCommand::REPORT_RESOLVE: return cmdReportResolve(cmd.GetExtension(Command_ReportResolve::ext), rc); + case ModeratorCommand::REPORT_REOPEN: + return cmdReportReopen(cmd.GetExtension(Command_ReportReopen::ext), rc); case ModeratorCommand::VIEWLOG_HISTORY: return cmdGetLogHistory(cmd.GetExtension(Command_ViewLogHistory::ext), rc, true); case ModeratorCommand::GRANT_REPLAY_ACCESS: @@ -2791,6 +2794,42 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReportMyList(const Comm return Response::RespOk; } +Response::ResponseCode AbstractServerSocketInterface::cmdReportReopen(const Command_ReportReopen &cmd, + ResponseContainer & /*rc*/) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + int reportId = cmd.report_id(); + QString note = textFromStdString(cmd.reopen_note()); + + QSqlQuery *query = sqlInterface->prepareQuery("UPDATE {prefix}_reports " + "SET status = 'open', resolution_note = NULL, " + "resolution_time = NULL, resolved_by = NULL, assigned_to = NULL, " + "notified = 0 " + "WHERE id = :id AND status IN ('resolved', 'dismissed')"); + + query->bindValue(":id", reportId); + + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespInternalError; + } + + if (query->numRowsAffected() == 0) { + return Response::RespInvalidData; + } + + QString auditDetails = QString("Report #%1 reopened").arg(reportId); + if (!note.isEmpty()) { + auditDetails += QString(". Reason: %1").arg(note.left(200)); + } + sqlInterface->addAuditRecord(QString::number(reportId), this->getAddress(), + QString::fromStdString(userInfo->clientid()), "REPORT_REOPENED", auditDetails, true); + + return Response::RespOk; +} + Response::ResponseCode AbstractServerSocketInterface::cmdReportDetails(const Command_ReportDetails &cmd, ResponseContainer &rc) { diff --git a/servatrice/src/serversocketinterface.h b/servatrice/src/serversocketinterface.h index 27708ed27..7de742c66 100644 --- a/servatrice/src/serversocketinterface.h +++ b/servatrice/src/serversocketinterface.h @@ -96,6 +96,7 @@ class Command_ReportAssign; class Command_ReportDetails; class Command_ReportList; class Command_ReportMyList; +class Command_ReportReopen; class Command_ReportResolve; class Command_ReportStats; class Command_ReportUserInfo; @@ -181,6 +182,7 @@ private: Response::ResponseCode cmdUpdateServerMessage(const Command_UpdateServerMessage &cmd, ResponseContainer &rc); Response::ResponseCode cmdReportAssign(const Command_ReportAssign &cmd, ResponseContainer &); Response::ResponseCode cmdReportResolve(const Command_ReportResolve &cmd, ResponseContainer &); + Response::ResponseCode cmdReportReopen(const Command_ReportReopen &cmd, ResponseContainer &); Response::ResponseCode cmdReportUserInfo(const Command_ReportUserInfo &cmd, ResponseContainer &rc); Response::ResponseCode cmdReportStats(const Command_ReportStats &cmd, ResponseContainer &rc); Response::ResponseCode cmdRegisterAccount(const Command_Register &cmd, ResponseContainer &rc); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index c48e6c957..22dbedb98 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -228,6 +228,18 @@ cockatrice_add_test( LABELS server ) +cockatrice_add_test( + NAME + server_moderator_role_test + NO_SANITIZE + SOURCES + server_moderator_role_test.cpp + LIBS + libcockatrice_network + libcockatrice_rng + LABELS + server +) cockatrice_add_test( NAME server_game_join_test diff --git a/tests/server_moderator_role_test.cpp b/tests/server_moderator_role_test.cpp new file mode 100644 index 000000000..2517a2d86 --- /dev/null +++ b/tests/server_moderator_role_test.cpp @@ -0,0 +1,156 @@ +/** @file server_moderator_role_test.cpp + * @brief Tests for the moderator staff role authorization and dispatch. + * @ingroup Tests + */ + +#include "libcockatrice/protocol/pb/response.pb.h" +#include "libcockatrice/protocol/pb/server_message.pb.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// The server_remote library references the global RNG, which is normally +// defined by the servatrice/client executable main(). Provide a stub so the +// unit test can link against it. +RNG_Abstract *rng = nullptr; + +namespace +{ + +class TestModeratorHandler : public Server_ProtocolHandler +{ +public: + explicit TestModeratorHandler(Server *_server) : Server_ProtocolHandler(_server, nullptr) + { + } + + QString getAddress() const override + { + return {}; + } + QString getConnectionType() const override + { + return {}; + } + + // Buffer the last response code sent to the client so tests can assert on + // the outcome of processCommandContainer(). + Response::ResponseCode lastResponseCode = Response::RespNothing; + int dispatchCount = 0; + +protected: + void transmitProtocolItem(const ServerMessage &item) override + { + if (item.message_type() == ServerMessage::RESPONSE) { + lastResponseCode = item.response().response_code(); + } + } + + Response::ResponseCode + processExtendedModeratorCommand(int cmdType, const ModeratorCommand &, ResponseContainer &) override + { + ++dispatchCount; + // Fail closed for anything not explicitly handled. + if (cmdType != ModeratorCommand::REPORT_REOPEN) { + return Response::RespFunctionNotAllowed; + } + return Response::RespOk; + } +}; + +class ModeratorRoleTest : public ::testing::Test +{ +protected: + Server server; + TestModeratorHandler handler{&server}; + + void setUserLevel(uint32_t level) + { + ServerInfo_User user; + user.set_user_level(level); + handler.setUserInfo(user); + } +}; + +TEST_F(ModeratorRoleTest, RejectsWhenNotLoggedIn) +{ + CommandContainer cont; + cont.add_moderator_command(); + handler.processCommandContainer(cont); + EXPECT_EQ(handler.lastResponseCode, Response::RespLoginNeeded); + EXPECT_EQ(handler.dispatchCount, 0); +} + +TEST_F(ModeratorRoleTest, RejectsPlainUser) +{ + setUserLevel(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered); + + CommandContainer cont; + cont.add_moderator_command(); + handler.processCommandContainer(cont); + EXPECT_EQ(handler.lastResponseCode, Response::RespLoginNeeded); + EXPECT_EQ(handler.dispatchCount, 0); +} + +TEST_F(ModeratorRoleTest, DispatchesToModeratorCommandForModerator) +{ + setUserLevel(ServerInfo_User::IsModerator); + + CommandContainer cont; + ModeratorCommand *cmd = cont.add_moderator_command(); + cmd->MutableExtension(Command_ReportReopen::ext); + handler.processCommandContainer(cont); + EXPECT_EQ(handler.lastResponseCode, Response::RespOk); + EXPECT_EQ(handler.dispatchCount, 1); +} + +TEST_F(ModeratorRoleTest, DispatchesToModeratorCommandForAdmin) +{ + setUserLevel(ServerInfo_User::IsModerator | ServerInfo_User::IsAdmin); + + CommandContainer cont; + ModeratorCommand *cmd = cont.add_moderator_command(); + cmd->MutableExtension(Command_ReportReopen::ext); + handler.processCommandContainer(cont); + EXPECT_EQ(handler.lastResponseCode, Response::RespOk); + EXPECT_EQ(handler.dispatchCount, 1); +} + +TEST_F(ModeratorRoleTest, RejectsDeveloperThatIsNotModerator) +{ + setUserLevel(ServerInfo_User::IsDeveloper); + + CommandContainer cont; + cont.add_moderator_command(); + handler.processCommandContainer(cont); + EXPECT_EQ(handler.lastResponseCode, Response::RespLoginNeeded); + EXPECT_EQ(handler.dispatchCount, 0); +} + +TEST_F(ModeratorRoleTest, FailClosedForUnknownModeratorCommand) +{ + setUserLevel(ServerInfo_User::IsModerator); + + CommandContainer cont; + cont.add_moderator_command(); // no extension set -> getPbExtension() returns -1 + handler.processCommandContainer(cont); + EXPECT_EQ(handler.lastResponseCode, Response::RespFunctionNotAllowed); + EXPECT_EQ(handler.dispatchCount, 1); +} + +} // namespace + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +}