mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-10-26 09:30:30 -05:00
[Report] Add report reopen action for closed reports (#7442)
- New Command_ReportReopen moderator command (extension 1206) with server handler that reopens resolved/dismissed reports, resets resolution state and notified, and records the reopening note in the audit log - Reopen... button in the report queue action bar, gated to resolved and dismissed reports, with optional note prompt and tooltip - Success confirmations for Assign, Resolve/Dismiss and Reopen now survive the list refresh via a pending status message instead of being clobbered - Dispatch/auth coverage in server_moderator_role_test Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
@@ -38,6 +38,7 @@
|
||||
#include <libcockatrice/protocol/pb/command_report_assign.pb.h>
|
||||
#include <libcockatrice/protocol/pb/command_report_details.pb.h>
|
||||
#include <libcockatrice/protocol/pb/command_report_list.pb.h>
|
||||
#include <libcockatrice/protocol/pb/command_report_reopen.pb.h>
|
||||
#include <libcockatrice/protocol/pb/command_report_resolve.pb.h>
|
||||
#include <libcockatrice/protocol/pb/command_report_stats.pb.h>
|
||||
#include <libcockatrice/protocol/pb/command_report_user_info.pb.h>
|
||||
@@ -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."));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -110,6 +110,7 @@
|
||||
#include <libcockatrice/protocol/pb/command_report_details.pb.h>
|
||||
#include <libcockatrice/protocol/pb/command_report_list.pb.h>
|
||||
#include <libcockatrice/protocol/pb/command_report_my_list.pb.h>
|
||||
#include <libcockatrice/protocol/pb/command_report_reopen.pb.h>
|
||||
#include <libcockatrice/protocol/pb/command_report_resolve.pb.h>
|
||||
#include <libcockatrice/protocol/pb/command_report_stats.pb.h>
|
||||
#include <libcockatrice/protocol/pb/command_report_user_info.pb.h>
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
156
tests/server_moderator_role_test.cpp
Normal file
156
tests/server_moderator_role_test.cpp
Normal file
@@ -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 <QString>
|
||||
#include <gtest/gtest.h>
|
||||
#include <libcockatrice/network/server/remote/server.h>
|
||||
#include <libcockatrice/network/server/remote/server_protocolhandler.h>
|
||||
#include <libcockatrice/protocol/pb/command_report_reopen.pb.h>
|
||||
#include <libcockatrice/protocol/pb/commands.pb.h>
|
||||
#include <libcockatrice/protocol/pb/moderator_commands.pb.h>
|
||||
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
|
||||
#include <libcockatrice/rng/rng_abstract.h>
|
||||
#include <stdint.h>
|
||||
#include <string>
|
||||
|
||||
// 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();
|
||||
}
|
||||
Reference in New Issue
Block a user