// 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"