From a3ba7128606c65703b436ca4be4e949ecda389b3 Mon Sep 17 00:00:00 2001 From: Matzman Date: Mon, 5 Oct 2026 22:09:42 +0200 Subject: [PATCH] Load the settings file (configuration, --config) Config parses and validates the JSON settings (D34): required device name and broker host, defaults, type checks, device id from ha_unique_id or derived from the name (D31), warnings for unknown keys and for a password in a file others can read. Dashboard::prepare() loads it in overlay and desktop mode; --config selects another file (D22). New static library frameassistant_lib for app logic; tst_config covers it. Co-Authored-By: Claude Opus 5.5 --- src/CMakeLists.txt | 9 ++ src/Dashboard.cpp | 32 ++++- src/Dashboard.h | 8 ++ src/config/Config.cpp | 234 +++++++++++++++++++++++++++++++++++++ src/config/Config.h | 53 +++++++++ tests/CMakeLists.txt | 5 + tests/tst_config.cpp | 264 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 604 insertions(+), 1 deletion(-) create mode 100644 src/config/Config.cpp create mode 100644 src/config/Config.h create mode 100644 tests/tst_config.cpp diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 8d4e423..5892416 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -49,6 +49,14 @@ else() endif() message(STATUS "Vulkan backend: ${_vulkan_ok}") +# The app's own logic (no SteamVR, no rendering): linked by the app and its tests. +qt_add_library(frameassistant_lib STATIC + config/Config.h config/Config.cpp +) +target_include_directories(frameassistant_lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) +target_link_libraries(frameassistant_lib PUBLIC Qt6::Core) +target_compile_options(frameassistant_lib PRIVATE -Wall -Wextra) + # The app: its UI is the QML module "FrameAssistant" (src/qml/). vrdash_add_dashboard(frameassistant OUTPUT_NAME ${VRDASH_EXECUTABLE_NAME} @@ -57,6 +65,7 @@ vrdash_add_dashboard(frameassistant QML_FILES qml/Main.qml qml/Card.qml) +target_link_libraries(frameassistant PRIVATE frameassistant_lib) install(FILES ${OPENVR_LIBRARY} DESTINATION ${CMAKE_INSTALL_LIBDIR}) diff --git a/src/Dashboard.cpp b/src/Dashboard.cpp index 084286a..81ade1c 100644 --- a/src/Dashboard.cpp +++ b/src/Dashboard.cpp @@ -1,6 +1,36 @@ #include "Dashboard.h" +#include +#include + +namespace { + +const QString kConfigOption = QStringLiteral("config"); + +} // namespace + Dashboard::Dashboard(int& argc, char** argv) - : DashboardApplication(argc, argv) { + : DashboardApplication( + argc, argv, + {QCommandLineOption(kConfigOption, + QStringLiteral("Settings file (default: %1).") + .arg(Config::defaultPath()), + QStringLiteral("path"))}) { addSurface({.qmlRootType = QStringLiteral("Main")}); } + +bool Dashboard::prepare(QString* error) { + // Snapshot and benchmark only render the UI: no settings needed. + if (mode() != DashboardMode::Overlay && mode() != DashboardMode::Desktop) + return true; + const QString path = isOptionSet(kConfigOption) ? optionValue(kConfigOption) + : Config::defaultPath(); + QStringList warnings; + std::optional config = Config::load(path, error, &warnings); + for (const QString& warning : warnings) qWarning().noquote() << warning; + if (!config) return false; + m_config = *config; + qInfo().noquote() << "Settings from" << path << "- device" + << m_config.deviceName << "(" + m_config.deviceId + ")"; + return true; +} diff --git a/src/Dashboard.h b/src/Dashboard.h index 47ae3b8..10d6eed 100644 --- a/src/Dashboard.h +++ b/src/Dashboard.h @@ -1,6 +1,7 @@ #pragma once #include "app/DashboardApplication.h" +#include "config/Config.h" // The dashboard app. Its UI is the QML module set up in src/CMakeLists.txt // (QML_URI FrameAssistant, files in src/qml/). @@ -11,4 +12,11 @@ class Dashboard : public DashboardApplication { Q_OBJECT public: Dashboard(int& argc, char** argv); + + protected: + // Loads the settings (overlay and desktop mode; docs/DESIGN.md, D22). + bool prepare(QString* error) override; + + private: + Config m_config; }; diff --git a/src/config/Config.cpp b/src/config/Config.cpp new file mode 100644 index 0000000..f4759d7 --- /dev/null +++ b/src/config/Config.cpp @@ -0,0 +1,234 @@ +#include "config/Config.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +// Walks one JSON object: typed getters that record the first error, and a +// check for keys nobody asked for. +class Section { + public: + Section(const QJsonObject& object, QString path, QString* error, + QStringList* warnings) + : m_object(object), + m_path(std::move(path)), + m_error(error), + m_warnings(warnings) {} + + bool has(const QString& key) const { return m_object.contains(key); } + + Section section(const QString& key) { + const QJsonValue value = take(key); + if (!value.isUndefined() && !value.isObject()) + fail(key, QStringLiteral("must be an object")); + return Section(value.toObject(), name(key), m_error, m_warnings); + } + + QString string(const QString& key, bool required = false) { + const QJsonValue value = take(key); + if (value.isUndefined()) { + if (required) fail(key, QStringLiteral("is required")); + return {}; + } + if (!value.isString()) { + fail(key, QStringLiteral("must be a string")); + return {}; + } + if (required && value.toString().trimmed().isEmpty()) + fail(key, QStringLiteral("must not be empty")); + return value.toString(); + } + + bool boolean(const QString& key, bool fallback) { + const QJsonValue value = take(key); + if (value.isUndefined()) return fallback; + if (!value.isBool()) { + fail(key, QStringLiteral("must be true or false")); + return fallback; + } + return value.toBool(); + } + + // An integer in [min, max]; `fallback` when missing. + int integer(const QString& key, int fallback, int min, int max) { + const QJsonValue value = take(key); + if (value.isUndefined()) return fallback; + const double number = value.toDouble(); + if (!value.isDouble() || number != std::floor(number) || number < min || + number > max) { + fail(key, QStringLiteral("must be a whole number from %1 to %2") + .arg(min) + .arg(max)); + return fallback; + } + return int(number); + } + + // Call last: warns about keys no getter took. + void warnUnknown() { + for (auto it = m_object.constBegin(); it != m_object.constEnd(); ++it) { + if (!m_taken.contains(it.key())) + m_warnings->append( + QStringLiteral("Unknown setting '%1' ignored").arg(name(it.key()))); + } + } + + void fail(const QString& key, const QString& problem) { + if (m_error->isEmpty()) + *m_error = QStringLiteral("Setting '%1' %2").arg(name(key), problem); + } + + private: + QJsonValue take(const QString& key) { + m_taken.append(key); + return m_object.value(key); + } + QString name(const QString& key) const { + return m_path.isEmpty() ? key : m_path + '.' + key; + } + + QJsonObject m_object; + QString m_path; + QString* m_error; + QStringList* m_warnings; + QStringList m_taken; +}; + +} // namespace + +QString Config::defaultPath() { + return QStandardPaths::writableLocation( + QStandardPaths::GenericConfigLocation) + + QStringLiteral("/frameassistant/config.json"); +} + +QString Config::deriveDeviceId(const QString& name) { + QString text = name.trimmed().toLower(); + text.replace(QChar(0x00DF), QStringLiteral("ss")); // ß + // Decompose (é → e + accent) and drop the combining marks. + text = text.normalized(QString::NormalizationForm_KD); + QString id; + for (const QChar c : text) { + if (c.category() == QChar::Mark_NonSpacing) continue; + const bool allowed = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || + c == '_' || c == '-'; + const QChar out = allowed ? c : QChar('_'); + if (out == '_' && (id.isEmpty() || id.endsWith('_'))) continue; + id += out; + } + while (id.endsWith('_')) id.chop(1); + return id; +} + +std::optional Config::parse(const QByteArray& json, QString* error, + QStringList* warnings) { + error->clear(); + QJsonParseError parseError; + const QJsonDocument doc = QJsonDocument::fromJson(json, &parseError); + if (parseError.error != QJsonParseError::NoError) { + *error = QStringLiteral("Invalid JSON at offset %1: %2") + .arg(parseError.offset) + .arg(parseError.errorString()); + return std::nullopt; + } + if (!doc.isObject()) { + *error = QStringLiteral("The settings must be a JSON object"); + return std::nullopt; + } + + Config config; + Section root(doc.object(), {}, error, warnings); + + Section device = root.section(QStringLiteral("device")); + if (!root.has(QStringLiteral("device"))) + root.fail(QStringLiteral("device"), QStringLiteral("is required")); + config.deviceName = device.string(QStringLiteral("name"), true).trimmed(); + config.deviceId = device.string(QStringLiteral("ha_unique_id")); + if (device.has(QStringLiteral("ha_unique_id"))) { + static const QRegularExpression valid(QStringLiteral("^[a-zA-Z0-9_-]+$")); + if (!valid.match(config.deviceId).hasMatch()) + device.fail(QStringLiteral("ha_unique_id"), + QStringLiteral("may only contain a-z, A-Z, 0-9, _ and -")); + } else if (!config.deviceName.isEmpty()) { + config.deviceId = deriveDeviceId(config.deviceName); + if (config.deviceId.isEmpty()) + device.fail(QStringLiteral("name"), + QStringLiteral("gives no usable id; set " + "device.ha_unique_id")); + } + device.warnUnknown(); + + Section mqtt = root.section(QStringLiteral("mqtt")); + if (!root.has(QStringLiteral("mqtt"))) + root.fail(QStringLiteral("mqtt"), QStringLiteral("is required")); + config.mqtt.host = mqtt.string(QStringLiteral("host"), true).trimmed(); + config.mqtt.username = mqtt.string(QStringLiteral("username")); + config.mqtt.password = mqtt.string(QStringLiteral("password")); + Section tls = mqtt.section(QStringLiteral("tls")); + config.mqtt.tls.enabled = tls.boolean(QStringLiteral("enabled"), false); + config.mqtt.tls.caFile = tls.string(QStringLiteral("ca_file")); + config.mqtt.tls.certFile = tls.string(QStringLiteral("cert_file")); + config.mqtt.tls.keyFile = tls.string(QStringLiteral("key_file")); + tls.warnUnknown(); + config.mqtt.port = mqtt.integer( + QStringLiteral("port"), config.mqtt.tls.enabled ? 8883 : 1883, 1, 65535); + mqtt.warnUnknown(); + + Section ha = root.section(QStringLiteral("homeassistant")); + const QString prefix = ha.string(QStringLiteral("discovery_prefix")); + if (ha.has(QStringLiteral("discovery_prefix"))) { + if (prefix.trimmed().isEmpty()) + ha.fail(QStringLiteral("discovery_prefix"), + QStringLiteral("must not be empty")); + config.discoveryPrefix = prefix.trimmed(); + } + const QString url = ha.string(QStringLiteral("url")).trimmed(); + if (!url.isEmpty()) { + config.haUrl = QUrl(url, QUrl::StrictMode); + const QString scheme = config.haUrl.scheme(); + if (!config.haUrl.isValid() || config.haUrl.host().isEmpty() || + (scheme != QLatin1String("http") && scheme != QLatin1String("https"))) + ha.fail(QStringLiteral("url"), + QStringLiteral("must be an http:// or https:// URL")); + } + ha.warnUnknown(); + + Section notifications = root.section(QStringLiteral("notifications")); + config.notificationTimeoutS = + notifications.integer(QStringLiteral("timeout_s"), 10, 1, 3600); + notifications.warnUnknown(); + + root.warnUnknown(); + if (!error->isEmpty()) return std::nullopt; + return config; +} + +std::optional Config::load(const QString& path, QString* error, + QStringList* warnings) { + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + *error = QStringLiteral("Cannot read settings file %1: %2") + .arg(path, file.errorString()); + return std::nullopt; + } + std::optional config = parse(file.readAll(), error, warnings); + if (!config) { + *error = QStringLiteral("Settings file %1: %2").arg(path, *error); + return std::nullopt; + } + const QFileDevice::Permissions others = + QFileDevice::ReadGroup | QFileDevice::ReadOther; + if (!config->mqtt.password.isEmpty() && (file.permissions() & others)) + warnings->append( + QStringLiteral("Settings file %1 contains a password and is readable " + "by other users; restrict it with: chmod 600 %1") + .arg(path)); + return config; +} diff --git a/src/config/Config.h b/src/config/Config.h new file mode 100644 index 0000000..0e8673e --- /dev/null +++ b/src/config/Config.h @@ -0,0 +1,53 @@ +#pragma once + +#include +#include +#include +#include +#include + +// FrameAssistant's settings from its JSON file (docs/DESIGN.md, D34). +// Loaded once by the app and passed on as an immutable value (D19): each +// component gets the part it needs. +struct MqttTlsConfig { + bool enabled = false; + QString caFile; // empty: the system's certificates + QString certFile; + QString keyFile; +}; + +struct MqttConfig { + QString host; + int port = 1883; // 8883 with TLS unless given + QString username; + QString password; + MqttTlsConfig tls; +}; + +struct Config { + QString deviceName; + // `ha_unique_id`, or derived from the device name (D31). + QString deviceId; + MqttConfig mqtt; + QString discoveryPrefix = QStringLiteral("homeassistant"); + // HA web UI for the dashboard tab (DSH-1); empty: not configured. + QUrl haUrl; + int notificationTimeoutS = 10; + + // ~/.config/frameassistant/config.json ($XDG_CONFIG_HOME respected). + static QString defaultPath(); + + // Reads and validates the file. On failure returns nothing and sets + // *error. *warnings gets problems that don't stop the app (unknown keys, + // a password in a file others can read). + static std::optional load(const QString& path, QString* error, + QStringList* warnings); + // The same for JSON text (no file checks). + static std::optional parse(const QByteArray& json, QString* error, + QStringList* warnings); + + // A device id from a display name: lower case, letters without + // diacritics, everything outside [a-z0-9_-] as '_'. Empty if nothing is + // left. + static QString deriveDeviceId(const QString& name); +}; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 61207a2..23e5d8d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -35,3 +35,8 @@ add_test(NAME surfaces_prepare_fail COMMAND tst_surfaces --graphics-api opengl) set_tests_properties(surfaces_prepare_fail PROPERTIES ENVIRONMENT "VRDASH_TEST_PREPARE_FAIL=1") set_tests_properties(surfaces_opengl surfaces_vulkan surfaces_cli_key PROPERTIES SKIP_RETURN_CODE 77) + +# The app's settings file (docs/DESIGN.md, D34). +qt_add_executable(tst_config tst_config.cpp) +target_link_libraries(tst_config PRIVATE frameassistant_lib Qt6::Test) +add_test(NAME config COMMAND tst_config) diff --git a/tests/tst_config.cpp b/tests/tst_config.cpp new file mode 100644 index 0000000..35775f4 --- /dev/null +++ b/tests/tst_config.cpp @@ -0,0 +1,264 @@ +// The settings file (docs/DESIGN.md, D34): required values, defaults, types, +// device ids, unknown keys, file checks. + +#include +#include +#include + +#include "config/Config.h" + +namespace { + +// Test JSON with ' instead of " (moc can't parse raw string literals that +// contain "//" and then misses the test class). +QByteArray json(const char* text) { + return QByteArray(text).replace('\'', '"'); +} + +const QByteArray kMinimal = json( + "{'device': {'name': 'Wohnzimmer Frame'}, 'mqtt': {'host': 'broker'}}"); + +} // namespace + +class TestConfig : public QObject { + Q_OBJECT + + private slots: + void minimalUsesDefaults() { + QString error; + QStringList warnings; + const std::optional config = + Config::parse(kMinimal, &error, &warnings); + QVERIFY2(config, qPrintable(error)); + QVERIFY(warnings.isEmpty()); + QCOMPARE(config->deviceName, QStringLiteral("Wohnzimmer Frame")); + QCOMPARE(config->deviceId, QStringLiteral("wohnzimmer_frame")); + QCOMPARE(config->mqtt.host, QStringLiteral("broker")); + QCOMPARE(config->mqtt.port, 1883); + QVERIFY(config->mqtt.username.isEmpty()); + QVERIFY(!config->mqtt.tls.enabled); + QCOMPARE(config->discoveryPrefix, QStringLiteral("homeassistant")); + QVERIFY(config->haUrl.isEmpty()); + QCOMPARE(config->notificationTimeoutS, 10); + } + + void fullFile() { + const QByteArray text = json( + "{" + "'device': {'name': 'Frame', 'ha_unique_id': 'frame-1'}," + "'mqtt': {'host': 'broker', 'port': 8884, 'username': 'u'," + "'password': 'p'," + "'tls': {'enabled': true, 'ca_file': '/ca.crt'," + "'cert_file': '/c.crt', 'key_file': '/c.key'}}," + "'homeassistant': {'discovery_prefix': 'ha', 'url': " + "'https://ha.local:8123/lovelace'}," + "'notifications': {'timeout_s': 5}" + "}"); + QString error; + QStringList warnings; + const std::optional config = Config::parse(text, &error, &warnings); + QVERIFY2(config, qPrintable(error)); + QVERIFY(warnings.isEmpty()); + QCOMPARE(config->deviceId, QStringLiteral("frame-1")); + QCOMPARE(config->mqtt.port, 8884); + QCOMPARE(config->mqtt.username, QStringLiteral("u")); + QCOMPARE(config->mqtt.password, QStringLiteral("p")); + QVERIFY(config->mqtt.tls.enabled); + QCOMPARE(config->mqtt.tls.caFile, QStringLiteral("/ca.crt")); + QCOMPARE(config->mqtt.tls.certFile, QStringLiteral("/c.crt")); + QCOMPARE(config->mqtt.tls.keyFile, QStringLiteral("/c.key")); + QCOMPARE(config->discoveryPrefix, QStringLiteral("ha")); + QCOMPARE(config->haUrl, + QUrl(QStringLiteral("https://ha.local:8123/lovelace"))); + QCOMPARE(config->notificationTimeoutS, 5); + } + + void tlsChangesDefaultPort() { + QString error; + QStringList warnings; + const std::optional config = + Config::parse(json("{'device': {'name': 'F'}," + "'mqtt': {'host': 'b', 'tls': {'enabled': true}}}"), + &error, &warnings); + QVERIFY2(config, qPrintable(error)); + QCOMPARE(config->mqtt.port, 8883); + } + + void invalid_data() { + QTest::addColumn("text"); + QTest::addColumn("errorPart"); + QTest::newRow("not JSON") << QByteArray("{device") << "Invalid JSON"; + QTest::newRow("not an object") << QByteArray("[]") << "JSON object"; + QTest::newRow("no device") + << json("{'mqtt': {'host': 'b'}}") << "'device' is required"; + QTest::newRow("no name") << json("{'device': {}, 'mqtt': {'host': 'b'}}") + << "'device.name' is required"; + QTest::newRow("empty name") + << json("{'device': {'name': ' '}, 'mqtt': {'host': 'b'}}") + << "'device.name' must not be empty"; + QTest::newRow("name not a string") + << json("{'device': {'name': 3}, 'mqtt': {'host': 'b'}}") + << "'device.name' must be a string"; + QTest::newRow("no mqtt") + << json("{'device': {'name': 'F'}}") << "'mqtt' is required"; + QTest::newRow("no host") << json("{'device': {'name': 'F'}, 'mqtt': {}}") + << "'mqtt.host' is required"; + QTest::newRow("mqtt not an object") + << json("{'device': {'name': 'F'}, 'mqtt': 1}") + << "'mqtt' must be an object"; + QTest::newRow("port text") + << QByteArray(json( + "{'device': {'name': 'F'}, 'mqtt': {'host': 'b', 'port': '1'}}")) + << "'mqtt.port' must be a whole number"; + QTest::newRow("port fraction") + << QByteArray(json( + "{'device': {'name': 'F'}, 'mqtt': {'host': 'b', 'port': 1.5}}")) + << "'mqtt.port' must be a whole number"; + QTest::newRow("port range") + << QByteArray(json("{'device': {'name': 'F'}, 'mqtt': {'host': 'b', " + "'port': 70000}}")) + << "'mqtt.port' must be a whole number"; + QTest::newRow("tls enabled text") + << json( + "{'device': {'name': 'F'}," + "'mqtt': {'host': 'b', 'tls': {'enabled': 'yes'}}}") + << "'mqtt.tls.enabled' must be true or false"; + QTest::newRow("bad unique id") + << json( + "{'device': {'name': 'F', 'ha_unique_id': 'a b'}," + "'mqtt': {'host': 'b'}}") + << "'device.ha_unique_id' may only contain"; + QTest::newRow("name without id characters") + << json("{'device': {'name': '!!!'}, 'mqtt': {'host': 'b'}}") + << "set device.ha_unique_id"; + QTest::newRow("url scheme") + << json( + "{'device': {'name': 'F'}, 'mqtt': {'host': 'b'}," + "'homeassistant': {'url': 'ftp://ha'}}") + << "'homeassistant.url' must be an http"; + QTest::newRow("empty prefix") + << json( + "{'device': {'name': 'F'}, 'mqtt': {'host': 'b'}," + "'homeassistant': {'discovery_prefix': ''}}") + << "'homeassistant.discovery_prefix' must not be empty"; + QTest::newRow("timeout zero") + << json( + "{'device': {'name': 'F'}, 'mqtt': {'host': 'b'}," + "'notifications': {'timeout_s': 0}}") + << "'notifications.timeout_s' must be a whole number"; + } + void invalid() { + QFETCH(QByteArray, text); + QFETCH(QString, errorPart); + QString error; + QStringList warnings; + QVERIFY(!Config::parse(text, &error, &warnings)); + QVERIFY2(error.contains(errorPart), qPrintable(error)); + } + + void unknownKeysWarn() { + QString error; + QStringList warnings; + const std::optional config = + Config::parse(json("{'device': {'name': 'F', 'colour': 1}," + "'mqtt': {'host': 'b', 'tsl': {}}, 'extra': true}"), + &error, &warnings); + QVERIFY2(config, qPrintable(error)); + QCOMPARE(warnings.size(), 3); + QVERIFY(warnings.join(' ').contains(QLatin1String("'device.colour'"))); + QVERIFY(warnings.join(' ').contains(QLatin1String("'mqtt.tsl'"))); + QVERIFY(warnings.join(' ').contains(QLatin1String("'extra'"))); + } + + void deriveDeviceId_data() { + QTest::addColumn("name"); + QTest::addColumn("id"); + QTest::newRow("spaces") << "Wohnzimmer Frame" << "wohnzimmer_frame"; + QTest::newRow("umlauts") << "Küche Süd" << "kuche_sud"; + QTest::newRow("sharp s") << "Große Halle" << "grosse_halle"; + QTest::newRow("allowed") << "frame-2_b" << "frame-2_b"; + QTest::newRow("runs and edges") << " --Frame !! #2 " << "--frame_2"; + QTest::newRow("nothing usable") << "!!!" << ""; + } + void deriveDeviceId() { + QFETCH(QString, name); + QFETCH(QString, id); + QCOMPARE(Config::deriveDeviceId(name), id); + } + + void loadFromFile() { + QTemporaryDir dir; + const QString path = dir.filePath(QStringLiteral("config.json")); + QFile file(path); + QVERIFY(file.open(QIODevice::WriteOnly)); + file.write(kMinimal); + file.close(); + QString error; + QStringList warnings; + QVERIFY2(Config::load(path, &error, &warnings), qPrintable(error)); + } + + void missingFile() { + QString error; + QStringList warnings; + QVERIFY(!Config::load(QStringLiteral("/nonexistent/config.json"), &error, + &warnings)); + QVERIFY2(error.contains(QLatin1String("/nonexistent/config.json")), + qPrintable(error)); + } + + void errorNamesFile() { + QTemporaryDir dir; + const QString path = dir.filePath(QStringLiteral("config.json")); + QFile file(path); + QVERIFY(file.open(QIODevice::WriteOnly)); + file.write("{}"); + file.close(); + QString error; + QStringList warnings; + QVERIFY(!Config::load(path, &error, &warnings)); + QVERIFY2(error.startsWith(QStringLiteral("Settings file ") + path), + qPrintable(error)); + } + + void readablePasswordWarns_data() { + QTest::addColumn("password"); + QTest::addColumn("othersRead"); + QTest::addColumn("warns"); + QTest::newRow("password, readable") << true << true << true; + QTest::newRow("password, private") << true << false << false; + QTest::newRow("no password, readable") << false << true << false; + } + void readablePasswordWarns() { + QFETCH(bool, password); + QFETCH(bool, othersRead); + QFETCH(bool, warns); + QTemporaryDir dir; + const QString path = dir.filePath(QStringLiteral("config.json")); + QFile file(path); + QVERIFY(file.open(QIODevice::WriteOnly)); + file.write(password ? json("{'device': {'name': 'F'}," + "'mqtt': {'host': 'b', 'password': 'x'}}") + : kMinimal); + file.close(); + QFileDevice::Permissions permissions = + QFileDevice::ReadOwner | QFileDevice::WriteOwner; + if (othersRead) + permissions |= QFileDevice::ReadGroup | QFileDevice::ReadOther; + QVERIFY(file.setPermissions(permissions)); + QString error; + QStringList warnings; + QVERIFY2(Config::load(path, &error, &warnings), qPrintable(error)); + QCOMPARE(!warnings.isEmpty(), warns); + } + + void defaultPathFollowsXdg() { + qputenv("XDG_CONFIG_HOME", "/tmp/xdg-test"); + QCOMPARE(Config::defaultPath(), + QStringLiteral("/tmp/xdg-test/frameassistant/config.json")); + qunsetenv("XDG_CONFIG_HOME"); + } +}; + +QTEST_GUILESS_MAIN(TestConfig) +#include "tst_config.moc"