Cockatrice/tests/crash_reporter_test.cpp
Lukas Brübach c40763e61d [CrashReporting] Breakpad everywhere, session detection, symbol CI (inc 3)
Breakpad on all platforms (no crashpad packaging); remove the Windows
MiniDumpWithFullMemory filter and the installer WER LocalDumps full-dump
registration. QLockFile per-process session tracking replaces the
Windows-only .dmp scan, placed after parser.process and the instance
hand-off so help/version exits and handed-off secondaries never leak a
lock. CI: Release-only DSN bake from SENTRY_DSN secret, tag-only
sentry-cli symbol upload (org cockatrice, project native).

Tests: 533/533 ctest green, format clean, code gate PASS.
2026-10-18 23:52:38 +02:00

427 lines
19 KiB
C++

#include "client/crash/crash_reporter.h"
#include <QByteArray>
#include <QCoreApplication>
#include <QDir>
#include <QFile>
#include <QLockFile>
#include <QString>
#include <QSysInfo>
#include <QTemporaryDir>
#include <QTextStream>
#include <gtest/gtest.h>
#include <libcockatrice/settings/personal_settings.h>
#include <string>
namespace
{
TEST(CrashReporterTest, SanitizeLogLineStripsAnsiColorCodes)
{
ASSERT_EQ(CrashReporter::sanitizeLogLine(QStringLiteral("\033[32mhello\033[0m")), QStringLiteral("hello"));
ASSERT_EQ(CrashReporter::sanitizeLogLine(QStringLiteral("\033[1;31mfatal\033[0m")), QStringLiteral("fatal"));
ASSERT_EQ(CrashReporter::sanitizeLogLine(QStringLiteral("plain line")), QStringLiteral("plain line"));
}
TEST(CrashReporterTest, RedactCockatriceUrlsKeepsSchemeAndHostOnly)
{
ASSERT_EQ(
CrashReporter::redactCockatriceUrls(QStringLiteral("open cockatrice://opendeck?token=secret&user=me now")),
QStringLiteral("open cockatrice://opendeck now"));
}
TEST(CrashReporterTest, RedactCockatriceUrlsIsIdempotent)
{
// A secret-free URL must not match again after redaction (regression: this hung forever).
ASSERT_EQ(CrashReporter::redactCockatriceUrls(QStringLiteral("open cockatrice://opendeck now")),
QStringLiteral("open cockatrice://opendeck now"));
}
TEST(CrashReporterTest, RedactCockatriceUrlsLeavesOtherTextAlone)
{
ASSERT_EQ(CrashReporter::redactCockatriceUrls(QStringLiteral("https://cockatrice.github.io/?token=secret")),
QStringLiteral("https://cockatrice.github.io/?token=secret"));
ASSERT_EQ(CrashReporter::sanitizeLogLine(QStringLiteral("")), QStringLiteral(""));
}
TEST(CrashReporterTest, InitSkippedWithoutDsn)
{
CrashReporter reporter;
reporter.init(QString(), QStringLiteral("3.1.2"), QDir::tempPath() + QStringLiteral("/cockatrice-crash-test"),
PersonalSettings::CrashReportingMode::Ask);
ASSERT_FALSE(reporter.isReady());
reporter.shutdown();
ASSERT_FALSE(reporter.isReady());
}
TEST(CrashReporterTest, InitSkippedWhenNeverSend)
{
CrashReporter reporter;
reporter.init(QStringLiteral("https://key@sentry.io/1"), QStringLiteral("3.1.2"),
QDir::tempPath() + QStringLiteral("/cockatrice-crash-test"),
PersonalSettings::CrashReportingMode::NeverSend);
ASSERT_FALSE(reporter.isReady());
reporter.shutdown();
}
// The init() tests above compile to no-ops when the SDK is off, so they pass
// regardless of the gating; the gating decision itself is covered here, in both
// build modes, through the SDK-independent helper init() consults.
TEST(CrashReporterTest, ReportingEnabledGating)
{
using Mode = PersonalSettings::CrashReportingMode;
const QString dsn = QStringLiteral("https://key@sentry.io/1");
ASSERT_FALSE(CrashReporter::isReportingEnabled(QString(), Mode::Ask));
ASSERT_FALSE(CrashReporter::isReportingEnabled(QString(), Mode::AlwaysSend));
ASSERT_FALSE(CrashReporter::isReportingEnabled(QString(), Mode::NeverSend));
ASSERT_TRUE(CrashReporter::isReportingEnabled(dsn, Mode::Ask));
ASSERT_TRUE(CrashReporter::isReportingEnabled(dsn, Mode::AlwaysSend));
ASSERT_FALSE(CrashReporter::isReportingEnabled(dsn, Mode::NeverSend));
}
TEST(CrashReporterTest, IsDsnWellFormedAcceptsWhatTheBuildAcceptsAndRejectsUiLinks)
{
ASSERT_TRUE(CrashReporter::isDsnWellFormed(QStringLiteral("https://abc123@o0.ingest.sentry.io/1234567")));
ASSERT_TRUE(CrashReporter::isDsnWellFormed(QStringLiteral("http://key@localhost:9000/42")));
// Unanchored like cmake/CrashReporting.cmake's MATCHES, so a DSN the build baked is never
// rejected here: a trailing path still matches.
ASSERT_TRUE(CrashReporter::isDsnWellFormed(QStringLiteral("https://key@host.example/7/extra")));
// The project-settings URL is the mistake a tester actually makes.
ASSERT_FALSE(CrashReporter::isDsnWellFormed(
QStringLiteral("https://sentry.io/settings/cockatrice/projects/cockatrice-desktop/keys/")));
ASSERT_FALSE(CrashReporter::isDsnWellFormed(QStringLiteral("https://key@host.example/not-a-number")));
ASSERT_FALSE(CrashReporter::isDsnWellFormed(QStringLiteral("o0.ingest.sentry.io/1234567")));
ASSERT_FALSE(CrashReporter::isDsnWellFormed(QString()));
}
TEST(CrashReporterTest, DescribeDsnNamesHostAndProjectWithoutTheKey)
{
const QString described =
CrashReporter::describeDsn(QStringLiteral("https://supersecretkey@o0.ingest.sentry.io/1234567"));
ASSERT_EQ(described, QStringLiteral("o0.ingest.sentry.io (project 1234567)"));
ASSERT_FALSE(described.contains(QStringLiteral("supersecretkey")));
ASSERT_TRUE(CrashReporter::describeDsn(QStringLiteral("not a dsn")).isEmpty());
ASSERT_TRUE(CrashReporter::describeDsn(QString()).isEmpty());
}
TEST(CrashReporterTest, DescribeOriginMismatchReportsOnlyRealDestinationChanges)
{
const QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString envelopePath = dir.filePath(QStringLiteral("crash.envelope"));
QFile file(envelopePath);
ASSERT_TRUE(file.open(QIODevice::WriteOnly));
file.write(QByteArrayLiteral("{\"event_id\":\"x\",\"dsn\":\"https://keyA@hostA.example/111\"}\n"));
file.write(QByteArrayLiteral("{\"type\":\"event\",\"length\":2}\n{}\n"));
file.close();
const QString described =
CrashReporter::describeOriginMismatch(envelopePath, QStringLiteral("https://keyB@hostB.example/222"));
ASSERT_FALSE(described.isEmpty());
ASSERT_TRUE(described.contains(QStringLiteral("hostA.example")));
ASSERT_TRUE(described.contains(QStringLiteral("hostB.example")));
ASSERT_FALSE(described.contains(QStringLiteral("keyA")));
ASSERT_FALSE(described.contains(QStringLiteral("keyB")));
// A different key for the SAME host and project is not a routing change and must stay quiet.
ASSERT_TRUE(
CrashReporter::describeOriginMismatch(envelopePath, QStringLiteral("https://otherkey@hostA.example/111"))
.isEmpty());
// An unreadable envelope, a header without a dsn, or no current dsn are all silent.
ASSERT_TRUE(CrashReporter::describeOriginMismatch(dir.filePath(QStringLiteral("absent.envelope")),
QStringLiteral("https://keyB@hostB.example/222"))
.isEmpty());
ASSERT_TRUE(CrashReporter::describeOriginMismatch(envelopePath, QString()).isEmpty());
const QString headerlessPath = dir.filePath(QStringLiteral("no-dsn.envelope"));
QFile headerless(headerlessPath);
ASSERT_TRUE(headerless.open(QIODevice::WriteOnly));
headerless.write(QByteArrayLiteral("{\"event_id\":\"y\"}\n"));
headerless.close();
ASSERT_TRUE(CrashReporter::describeOriginMismatch(headerlessPath, QStringLiteral("https://keyB@hostB.example/222"))
.isEmpty());
}
TEST(CrashReporterTest, ShutdownWithoutInitIsSafe)
{
CrashReporter reporter;
ASSERT_FALSE(reporter.isReady());
reporter.shutdown();
ASSERT_FALSE(reporter.isReady());
}
TEST(CrashReporterTest, ConsentAndSendWithoutDsnAreSafeNoOps)
{
CrashReporter reporter;
reporter.init(QString(), QStringLiteral("3.1.2"), QDir::tempPath() + QStringLiteral("/cockatrice-crash-test"),
PersonalSettings::CrashReportingMode::Ask);
ASSERT_FALSE(reporter.isReady());
reporter.giveConsent();
reporter.revokeConsent();
ASSERT_FALSE(reporter.isReady());
ASSERT_FALSE(reporter.sendReport(QStringLiteral("preview")));
ASSERT_FALSE(reporter.sendReport(QString()));
reporter.shutdown();
ASSERT_FALSE(reporter.isReady());
}
namespace
{
QByteArray readFileBytes(const QString &path)
{
QFile file(path);
if (!file.open(QIODevice::ReadOnly)) {
return {};
}
return file.readAll();
}
bool writeFileBytes(const QString &path, const QByteArray &content)
{
QFile file(path);
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
return false;
}
return file.write(content) == content.size();
}
QString uuidOf(char marker)
{
// 36 chars, 4 hyphens — structurally a UUID, content irrelevant for these tests.
return QStringLiteral("00000000-0000-4000-8000-00000000000%1").arg(marker);
}
} // namespace
TEST(CrashReporterTest, AppendAttachmentAppendsWellFormedItemAfterExistingContent)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString path = dir.filePath(QStringLiteral("event.envelope"));
// Existing envelope content deliberately WITHOUT a trailing newline.
const QByteArray original = QByteArrayLiteral("{\"event_id\":\"x\"}\n{\"type\":\"event\",\"length\":2}\n{}");
ASSERT_TRUE(writeFileBytes(path, original));
ASSERT_TRUE(
CrashReporter::appendAttachmentToEnvelope(path, QStringLiteral("crash-log.txt"), QByteArrayLiteral("hello")));
const QByteArray expected = original + '\n' +
QByteArrayLiteral("{\"type\":\"attachment\",\"length\":5,\"attachment_length\":5,"
"\"filename\":\"crash-log.txt\",\"content_type\":\"text/plain\"}\n") +
QByteArrayLiteral("hello");
ASSERT_EQ(readFileBytes(path), expected);
}
TEST(CrashReporterTest, AppendAttachmentOnEmptyFileHasNoLeadingNewline)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString path = dir.filePath(QStringLiteral("event.envelope"));
ASSERT_TRUE(writeFileBytes(path, QByteArray()));
ASSERT_TRUE(
CrashReporter::appendAttachmentToEnvelope(path, QStringLiteral("user-note.txt"), QByteArrayLiteral("note")));
const QByteArray expected = QByteArrayLiteral("{\"type\":\"attachment\",\"length\":4,\"attachment_length\":4,"
"\"filename\":\"user-note.txt\",\"content_type\":\"text/plain\"}\n") +
QByteArrayLiteral("note");
ASSERT_EQ(readFileBytes(path), expected);
}
TEST(CrashReporterTest, AppendAttachmentRejectsMissingFile)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
ASSERT_FALSE(CrashReporter::appendAttachmentToEnvelope(dir.filePath(QStringLiteral("missing.envelope")),
QStringLiteral("crash-log.txt"), QByteArrayLiteral("x")));
}
TEST(CrashReporterTest, AppendAttachmentLengthIsByteAccurate)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString path = dir.filePath(QStringLiteral("event.envelope"));
ASSERT_TRUE(writeFileBytes(path, QByteArrayLiteral("{}")));
const QByteArray utf8Payload = QString::fromUtf8("héllo").toUtf8();
ASSERT_EQ(utf8Payload.size(), 6); // 5 characters, "é" is 2 bytes
ASSERT_TRUE(CrashReporter::appendAttachmentToEnvelope(path, QStringLiteral("crash-log.txt"), utf8Payload));
const QByteArray content = readFileBytes(path);
ASSERT_TRUE(content.contains(QByteArrayLiteral("\"length\":6")));
ASSERT_TRUE(content.contains(QByteArrayLiteral("\"attachment_length\":6")));
ASSERT_TRUE(content.endsWith(utf8Payload));
}
// The SDK's envelope re-parser frames each item's payload by its `length` header; a
// missing or wrong length ends the payload at the first newline, so the remaining log
// lines get parsed as item headers and the whole envelope is rejected (and deleted).
TEST(CrashReporterTest, AppendAttachmentKeepsMultiLinePayloadIntact)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString path = dir.filePath(QStringLiteral("event.envelope"));
ASSERT_TRUE(writeFileBytes(path, QByteArrayLiteral("{}")));
const QByteArray payload = QByteArrayLiteral("first line\nsecond line\nthird line");
ASSERT_TRUE(CrashReporter::appendAttachmentToEnvelope(path, QStringLiteral("crash-log.txt"), payload));
const QByteArray content = readFileBytes(path);
ASSERT_TRUE(content.contains("\"length\":" + QByteArray::number(payload.size())));
ASSERT_TRUE(content.contains("\"attachment_length\":" + QByteArray::number(payload.size())));
ASSERT_TRUE(content.endsWith(payload));
}
TEST(CrashReporterTest, PendingEnvelopePathsMatchesSdkNamingInCacheAndRunDirs)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString dbPath = dir.path();
ASSERT_TRUE(QDir().mkpath(dbPath + QStringLiteral("/cache")));
ASSERT_TRUE(QDir().mkpath(dbPath + QStringLiteral("/aaaa.run")));
ASSERT_TRUE(QDir().mkpath(dbPath + QStringLiteral("/other")));
const QString retryName = QStringLiteral("1700000000000-0-") + uuidOf('a') + QStringLiteral(".envelope");
const QString bareName = uuidOf('b') + QStringLiteral(".envelope");
// Valid: retry-style cache file + bare run-dir file.
ASSERT_TRUE(writeFileBytes(dbPath + QStringLiteral("/cache/") + retryName, QByteArrayLiteral("{}")));
ASSERT_TRUE(writeFileBytes(dbPath + QStringLiteral("/aaaa.run/") + bareName, QByteArrayLiteral("{}")));
// Rejected: junk names and the SDK's own marker file.
ASSERT_TRUE(writeFileBytes(dbPath + QStringLiteral("/cache/") + uuidOf('c') + QStringLiteral(".envelope.bak"),
QByteArrayLiteral("{}")));
ASSERT_TRUE(writeFileBytes(dbPath + QStringLiteral("/cache/") + QStringLiteral("random.envelope"),
QByteArrayLiteral("{}")));
ASSERT_TRUE(writeFileBytes(dbPath + QStringLiteral("/aaaa.run/") + QStringLiteral("__sentry-crash.envelope"),
QByteArrayLiteral("{}")));
ASSERT_TRUE(writeFileBytes(dbPath + QStringLiteral("/other/") + bareName, QByteArrayLiteral("{}")));
const QStringList found = CrashReporter::pendingEnvelopePaths(dbPath);
ASSERT_EQ(found.size(), 2);
ASSERT_TRUE(found.contains(dbPath + QStringLiteral("/cache/") + retryName));
ASSERT_TRUE(found.contains(dbPath + QStringLiteral("/aaaa.run/") + bareName));
}
TEST(CrashReporterTest, PendingEnvelopePathsWithoutDatabaseIsEmpty)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
ASSERT_TRUE(CrashReporter::pendingEnvelopePaths(dir.filePath(QStringLiteral("no-such-db"))).isEmpty());
ASSERT_TRUE(CrashReporter::pendingEnvelopePaths(QString()).isEmpty());
}
TEST(CrashReporterTest, AttachFoldsLogAndNoteIntoPendingEnvelopes)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString dbPath = dir.path();
ASSERT_TRUE(QDir().mkpath(dbPath + QStringLiteral("/cache")));
const QString envelopePath = dbPath + QStringLiteral("/cache/") + uuidOf('d') + QStringLiteral(".envelope");
ASSERT_TRUE(writeFileBytes(envelopePath, QByteArrayLiteral("{\"event_id\":\"x\"}\n{}\n")));
CrashReporter reporter;
// Empty DSN: no SDK, but init() still records the database path the dialog uses.
reporter.init(QString(), QStringLiteral("3.1.2"), dbPath, PersonalSettings::CrashReportingMode::Ask);
ASSERT_EQ(reporter.attachToPendingCrashEnvelopes(QStringLiteral("log line"), QStringLiteral("my note")), 1);
const QByteArray content = readFileBytes(envelopePath);
ASSERT_TRUE(content.startsWith(QByteArrayLiteral("{\"event_id\":\"x\"}\n{}\n")));
ASSERT_TRUE(content.contains(QByteArrayLiteral("\"filename\":\"crash-log.txt\"")));
ASSERT_TRUE(content.contains(QByteArrayLiteral("\nlog line")));
ASSERT_TRUE(content.contains(QByteArrayLiteral("\"filename\":\"user-note.txt\"")));
ASSERT_TRUE(content.endsWith(QByteArrayLiteral("\nmy note")));
}
TEST(CrashReporterTest, AttachSkipsNoteWhenEmptyAndFindsRunDirEnvelopes)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString dbPath = dir.path();
ASSERT_TRUE(QDir().mkpath(dbPath + QStringLiteral("/bbbb.run")));
const QString envelopePath = dbPath + QStringLiteral("/bbbb.run/") + uuidOf('e') + QStringLiteral(".envelope");
ASSERT_TRUE(writeFileBytes(envelopePath, QByteArrayLiteral("{}")));
// Static overload (the pre-init AlwaysSend path) with an empty note.
ASSERT_EQ(CrashReporter::attachToPendingCrashEnvelopes(dbPath, QStringLiteral("log only"), QString()), 1);
const QByteArray content = readFileBytes(envelopePath);
ASSERT_TRUE(content.contains(QByteArrayLiteral("\"filename\":\"crash-log.txt\"")));
ASSERT_FALSE(content.contains(QByteArrayLiteral("user-note.txt")));
}
TEST(CrashReporterTest, BeginSessionOnEmptyDirReportsClean)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
CrashReporter reporter;
ASSERT_FALSE(reporter.beginSession(dir.path()));
reporter.endSession();
}
TEST(CrashReporterTest, BeginSessionIgnoresLiveConcurrentInstance)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
// A second QLockFile held in-process stands in for a live concurrent instance: same
// pid, alive, so the scan must leave it alone and report a clean start.
QLockFile liveInstance(dir.filePath(QStringLiteral("session-424242")));
ASSERT_TRUE(liveInstance.tryLock(0));
CrashReporter reporter;
ASSERT_FALSE(reporter.beginSession(dir.path()));
ASSERT_TRUE(QFile::exists(liveInstance.fileName()));
reporter.endSession();
liveInstance.unlock();
}
TEST(CrashReporterTest, BeginSessionDetectsStaleDeadOwnerLock)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
// A well-formed lock whose owner is dead: our own pid (so the host matches) but a
// process name that cannot match, which is how QLockFile spots pid reuse. Must be
// reported unclean and cleaned up, even though the file is brand new (this is why
// the scan probes with staleLockTime 0 rather than the 30 s default).
const QString stalePath = dir.filePath(QStringLiteral("session-stale-owner"));
QFile stale(stalePath);
ASSERT_TRUE(stale.open(QIODevice::WriteOnly | QIODevice::Text));
QTextStream out(&stale);
out << QCoreApplication::applicationPid() << '\n'
<< QStringLiteral("definitely-not-the-test-binary") << '\n'
<< QSysInfo::machineHostName() << '\n';
stale.close();
CrashReporter reporter;
ASSERT_TRUE(reporter.beginSession(dir.path()));
ASSERT_FALSE(QFile::exists(stalePath));
reporter.endSession();
}
TEST(CrashReporterTest, BeginSessionCleansGarbageFileAndReportsUnclean)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
// An unreadable lock file has no live owner by definition: report unclean and remove it.
const QString garbagePath = dir.filePath(QStringLiteral("session-garbage"));
QFile garbage(garbagePath);
ASSERT_TRUE(garbage.open(QIODevice::WriteOnly));
garbage.write("not-a-lock-file\x00\xff", 17);
garbage.close();
CrashReporter reporter;
ASSERT_TRUE(reporter.beginSession(dir.path()));
ASSERT_FALSE(QFile::exists(garbagePath));
reporter.endSession();
}
TEST(CrashReporterTest, BeginSessionEndSessionRoundTripReportsClean)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
CrashReporter reporter;
ASSERT_FALSE(reporter.beginSession(dir.path()));
reporter.endSession();
ASSERT_FALSE(reporter.beginSession(dir.path()));
reporter.endSession();
}
} // namespace
int main(int argc, char **argv)
{
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}