[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:
BruebachL 2026-10-11 15:28:45 +02:00 • committed by GitHub
parent 315cbae345
commit 4a010bd17f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 280 additions and 2 deletions

View file

@ -38,6 +38,7 @@
#include <libcockatrice/protocol/pb/command_report_assign.pb.h> #include <libcockatrice/protocol/pb/command_report_assign.pb.h>
#include <libcockatrice/protocol/pb/command_report_details.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_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_resolve.pb.h>
#include <libcockatrice/protocol/pb/command_report_stats.pb.h> #include <libcockatrice/protocol/pb/command_report_stats.pb.h>
#include <libcockatrice/protocol/pb/command_report_user_info.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; resolveButton = new QPushButton;
resolveWithNoteButton = new QPushButton; resolveWithNoteButton = new QPushButton;
dismissButton = new QPushButton; dismissButton = new QPushButton;
reopenButton = new QPushButton;
viewReplayButton = new QPushButton; viewReplayButton = new QPushButton;
joinGameButton = new QPushButton; joinGameButton = new QPushButton;
connect(assignButton, &QPushButton::clicked, this, &TabReport::assignReport); connect(assignButton, &QPushButton::clicked, this, &TabReport::assignReport);
connect(resolveButton, &QPushButton::clicked, this, [this]() { resolveReport(false, false); }); connect(resolveButton, &QPushButton::clicked, this, [this]() { resolveReport(false, false); });
connect(resolveWithNoteButton, &QPushButton::clicked, this, [this]() { resolveReport(false, true); }); connect(resolveWithNoteButton, &QPushButton::clicked, this, [this]() { resolveReport(false, true); });
connect(dismissButton, &QPushButton::clicked, this, [this]() { resolveReport(true, 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(viewReplayButton, &QPushButton::clicked, this, &TabReport::viewReplay);
connect(joinGameButton, &QPushButton::clicked, this, &TabReport::joinGame); connect(joinGameButton, &QPushButton::clicked, this, &TabReport::joinGame);
@ -249,6 +252,7 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
actionBar->addWidget(resolveButton); actionBar->addWidget(resolveButton);
actionBar->addWidget(resolveWithNoteButton); actionBar->addWidget(resolveWithNoteButton);
actionBar->addWidget(dismissButton); actionBar->addWidget(dismissButton);
actionBar->addWidget(reopenButton);
actionBar->addSpacing(20); actionBar->addSpacing(20);
actionBar->addWidget(viewReplayButton); actionBar->addWidget(viewReplayButton);
actionBar->addWidget(joinGameButton); actionBar->addWidget(joinGameButton);
@ -315,6 +319,8 @@ void TabReport::retranslateUi()
resolveButton->setText(tr("Resolve")); resolveButton->setText(tr("Resolve"));
resolveWithNoteButton->setText(tr("Resolve with note...")); resolveWithNoteButton->setText(tr("Resolve with note..."));
dismissButton->setText(tr("Dismiss...")); dismissButton->setText(tr("Dismiss..."));
reopenButton->setText(tr("Reopen..."));
reopenButton->setToolTip(tr("Available for resolved or dismissed reports."));
viewReplayButton->setText(tr("View Replay")); viewReplayButton->setText(tr("View Replay"));
joinGameButton->setText(tr("Join Game")); joinGameButton->setText(tr("Join Game"));
} }
@ -346,6 +352,7 @@ void TabReport::reportListResponse(const Response &response)
refreshButton->setEnabled(true); refreshButton->setEnabled(true);
if (response.response_code() != Response::RespOk) { if (response.response_code() != Response::RespOk) {
pendingStatusMessage.clear();
statusLabel->setText(tr("Failed to load reports.")); statusLabel->setText(tr("Failed to load reports."));
return; return;
} }
@ -359,6 +366,11 @@ void TabReport::reportListResponse(const Response &response)
applyFilters(); applyFilters();
updateStats(); updateStats();
if (!pendingStatusMessage.isEmpty()) {
statusLabel->setText(pendingStatusMessage);
pendingStatusMessage.clear();
}
if (selectedReportIdBeforeRefresh >= 0) { if (selectedReportIdBeforeRefresh >= 0) {
bool found = false; bool found = false;
for (int row = 0; row < table->rowCount(); ++row) { for (int row = 0; row < table->rowCount(); ++row) {
@ -579,6 +591,7 @@ void TabReport::updateActionStates()
resolveButton->setEnabled(status == "open" || status == "assigned"); resolveButton->setEnabled(status == "open" || status == "assigned");
resolveWithNoteButton->setEnabled(status == "open" || status == "assigned"); resolveWithNoteButton->setEnabled(status == "open" || status == "assigned");
dismissButton->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 hasGameId = report.game_id() > 0;
const bool hasReplay = hasGameId && report.has_replay_id() && report.replay_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); resolveButton->setEnabled(enabled);
resolveWithNoteButton->setEnabled(enabled); resolveWithNoteButton->setEnabled(enabled);
dismissButton->setEnabled(enabled); dismissButton->setEnabled(enabled);
reopenButton->setEnabled(enabled);
viewReplayButton->setEnabled(false); viewReplayButton->setEnabled(false);
joinGameButton->setEnabled(false); joinGameButton->setEnabled(false);
commentButton->setEnabled(enabled); commentButton->setEnabled(enabled);
@ -645,7 +659,7 @@ void TabReport::assignReport()
void TabReport::assignResponse(const Response &response) void TabReport::assignResponse(const Response &response)
{ {
if (response.response_code() == Response::RespOk) { if (response.response_code() == Response::RespOk) {
statusLabel->setText(tr("Assigned.")); pendingStatusMessage = tr("Assigned.");
refreshList(); refreshList();
} else { } else {
statusLabel->setText(tr("Assignment failed.")); statusLabel->setText(tr("Assignment failed."));
@ -689,7 +703,46 @@ void TabReport::resolveReport(bool dismissed, bool promptNote)
void TabReport::resolveResponse(const Response &response) void TabReport::resolveResponse(const Response &response)
{ {
if (response.response_code() == Response::RespOk) { 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(); refreshList();
} else { } else {
statusLabel->setText(tr("Action failed.")); statusLabel->setText(tr("Action failed."));

View file

@ -56,6 +56,8 @@ private slots:
void reportDetailsResponse(const Response &response); void reportDetailsResponse(const Response &response);
void requestStats(); void requestStats();
void statsResponse(const Response &response); void statsResponse(const Response &response);
void reopenReport();
void reopenResponse(const Response &response);
private: private:
int selectedReportId() const; int selectedReportId() const;
@ -87,6 +89,7 @@ private:
QPushButton *resolveButton; QPushButton *resolveButton;
QPushButton *resolveWithNoteButton; QPushButton *resolveWithNoteButton;
QPushButton *dismissButton; QPushButton *dismissButton;
QPushButton *reopenButton;
QPushButton *viewReplayButton; QPushButton *viewReplayButton;
QPushButton *joinGameButton; QPushButton *joinGameButton;
QLabel *statusLabel; QLabel *statusLabel;
@ -108,6 +111,7 @@ private:
QLabel *userContextNotesLabel; QLabel *userContextNotesLabel;
QLabel *userContextRecentReportsLabel; QLabel *userContextRecentReportsLabel;
QString lastRequestedUser; QString lastRequestedUser;
QString pendingStatusMessage;
QGroupBox *statsGroup; QGroupBox *statsGroup;
QLabel *statsTotalLabel; QLabel *statsTotalLabel;
QLabel *statsTrendLabel; QLabel *statsTrendLabel;

View file

@ -55,6 +55,7 @@ set(PROTO_FILES
command_report_details.proto command_report_details.proto
command_report_list.proto command_report_list.proto
command_report_my_list.proto command_report_my_list.proto
command_report_reopen.proto
command_report_resolve.proto command_report_resolve.proto
command_report_stats.proto command_report_stats.proto
command_report_user_info.proto command_report_user_info.proto

View file

@ -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;
}

View file

@ -26,6 +26,7 @@ message ModeratorCommand {
REPLAY_DOWNLOAD_BY_GAME_ID = 1203; REPLAY_DOWNLOAD_BY_GAME_ID = 1203;
REPORT_USER_INFO = 1204; REPORT_USER_INFO = 1204;
REPORT_STATS = 1205; REPORT_STATS = 1205;
REPORT_REOPEN = 1206;
} }
extensions 100 to max; extensions 100 to max;
} }

View file

@ -110,6 +110,7 @@
#include <libcockatrice/protocol/pb/command_report_details.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_list.pb.h>
#include <libcockatrice/protocol/pb/command_report_my_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_resolve.pb.h>
#include <libcockatrice/protocol/pb/command_report_stats.pb.h> #include <libcockatrice/protocol/pb/command_report_stats.pb.h>
#include <libcockatrice/protocol/pb/command_report_user_info.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); return cmdReportAssign(cmd.GetExtension(Command_ReportAssign::ext), rc);
case ModeratorCommand::REPORT_RESOLVE: case ModeratorCommand::REPORT_RESOLVE:
return cmdReportResolve(cmd.GetExtension(Command_ReportResolve::ext), rc); return cmdReportResolve(cmd.GetExtension(Command_ReportResolve::ext), rc);
case ModeratorCommand::REPORT_REOPEN:
return cmdReportReopen(cmd.GetExtension(Command_ReportReopen::ext), rc);
case ModeratorCommand::VIEWLOG_HISTORY: case ModeratorCommand::VIEWLOG_HISTORY:
return cmdGetLogHistory(cmd.GetExtension(Command_ViewLogHistory::ext), rc, true); return cmdGetLogHistory(cmd.GetExtension(Command_ViewLogHistory::ext), rc, true);
case ModeratorCommand::GRANT_REPLAY_ACCESS: case ModeratorCommand::GRANT_REPLAY_ACCESS:
@ -2791,6 +2794,42 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReportMyList(const Comm
return Response::RespOk; 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, Response::ResponseCode AbstractServerSocketInterface::cmdReportDetails(const Command_ReportDetails &cmd,
ResponseContainer &rc) ResponseContainer &rc)
{ {

View file

@ -96,6 +96,7 @@ class Command_ReportAssign;
class Command_ReportDetails; class Command_ReportDetails;
class Command_ReportList; class Command_ReportList;
class Command_ReportMyList; class Command_ReportMyList;
class Command_ReportReopen;
class Command_ReportResolve; class Command_ReportResolve;
class Command_ReportStats; class Command_ReportStats;
class Command_ReportUserInfo; class Command_ReportUserInfo;
@ -181,6 +182,7 @@ private:
Response::ResponseCode cmdUpdateServerMessage(const Command_UpdateServerMessage &cmd, ResponseContainer &rc); Response::ResponseCode cmdUpdateServerMessage(const Command_UpdateServerMessage &cmd, ResponseContainer &rc);
Response::ResponseCode cmdReportAssign(const Command_ReportAssign &cmd, ResponseContainer &); Response::ResponseCode cmdReportAssign(const Command_ReportAssign &cmd, ResponseContainer &);
Response::ResponseCode cmdReportResolve(const Command_ReportResolve &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 cmdReportUserInfo(const Command_ReportUserInfo &cmd, ResponseContainer &rc);
Response::ResponseCode cmdReportStats(const Command_ReportStats &cmd, ResponseContainer &rc); Response::ResponseCode cmdReportStats(const Command_ReportStats &cmd, ResponseContainer &rc);
Response::ResponseCode cmdRegisterAccount(const Command_Register &cmd, ResponseContainer &rc); Response::ResponseCode cmdRegisterAccount(const Command_Register &cmd, ResponseContainer &rc);

View file

@ -228,6 +228,18 @@ cockatrice_add_test(
LABELS LABELS
server 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( cockatrice_add_test(
NAME NAME
server_game_join_test server_game_join_test

View 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();
}