mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-08 02:53:25 -08:00
The client resolved COCKATRICE_SENTRY_DSN (environment over the baked-in value) without ever saying which one won, and sentry-native refuses to build a request for a DSN it considers invalid without reporting anything, so a tester pointing the client at a scratch project had nothing to go on when a crash never appeared. Say which DSN source won, and log the destination (host and project id, never the key) once the SDK is armed. Warn instead of staying quiet when no DSN is configured at all, when a DSN is malformed - disabling reporting rather than silently falling back to the project baked into the build, which is the rule the installer already follows in cmake/CrashReporting.cmake - and when the build was compiled without Sentry support. A deliberate Never-send stays at info level: that is a choice, not a misconfiguration. Also warn when a pending crash envelope was captured for a different destination than the session that will upload it. sentry-native posts every envelope with the DSN held in the transport state of the session doing the sending, and envelopes restored from disk are raw, so the "dsn" header of a cached envelope is never read: a crash captured for one project can be uploaded to another with nothing in the log to show it. Tests cover the DSN shape check (same unanchored pattern the build matches, so a DSN this build accepted is never rejected here), destination description without the key, and the mismatch detection, which stays quiet for a different key on the same project, unreadable files, and headers without a dsn.
348 lines
16 KiB
C++
348 lines
16 KiB
C++
#include "client/crash/crash_reporter.h"
|
|
|
|
#include <QByteArray>
|
|
#include <QDir>
|
|
#include <QFile>
|
|
#include <QString>
|
|
#include <QTemporaryDir>
|
|
#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")));
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main(int argc, char **argv)
|
|
{
|
|
::testing::InitGoogleTest(&argc, argv);
|
|
return RUN_ALL_TESTS();
|
|
}
|