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 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
// The settings file (docs/DESIGN.md, D34): required values, defaults, types,
|
||||
// device ids, unknown keys, file checks.
|
||||
|
||||
#include <QFile>
|
||||
#include <QTemporaryDir>
|
||||
#include <QTest>
|
||||
|
||||
#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 =
|
||||
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 = 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 =
|
||||
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<QByteArray>("text");
|
||||
QTest::addColumn<QString>("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 =
|
||||
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<QString>("name");
|
||||
QTest::addColumn<QString>("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<bool>("password");
|
||||
QTest::addColumn<bool>("othersRead");
|
||||
QTest::addColumn<bool>("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"
|
||||
Reference in New Issue
Block a user