diff --git a/data-src/index.html b/data-src/index.html index 235f1bb..0d9c41c 100644 --- a/data-src/index.html +++ b/data-src/index.html @@ -8,9 +8,9 @@ - - - + + + @@ -114,7 +114,7 @@ rel="apple-touch-startup-image"> - +
@@ -207,11 +207,11 @@
- +
- +
@@ -305,7 +305,7 @@
- +
@@ -412,7 +412,7 @@
- +
diff --git a/data-src/index.js b/data-src/index.js index e435753..e67a839 100644 --- a/data-src/index.js +++ b/data-src/index.js @@ -1236,7 +1236,7 @@ class UIBinder { divCfg.style.display = 'none'; document.getElementById('icoConfig').className = 'icss-gear'; if (sockIsOpen) socket.send('leave:0'); - general.setSecurityConfig({ type: 0, username: '', password: '', pin: '', permissions: 0 }); + general.setSecurityConfig({ type: 0, username: '', permissions: 0 }); } toggleConfig() { if (this.isConfigOpen()) @@ -1617,20 +1617,22 @@ class General { } } setSecurityConfig(security) { + // The device never returns the stored password or PIN, only whether one is set. + this.savedSecurity = { type: security.type, username: security.username, hasPassword: makeBool(security.hasPassword), hasPin: makeBool(security.hasPin) }; // We need to transform the security object so that it can be set to the configuration. let obj = { security: { - type: security.type, username: security.username, password: security.password, + type: security.type, username: security.username, password: '', repeatpassword: '', permissions: { configOnly: makeBool(security.permissions & 0x01) }, - pin: { - d0: security.pin[0], - d1: security.pin[1], - d2: security.pin[2], - d3: security.pin[3] - } + pin: { d0: '', d1: '', d2: '', d3: '' } } }; - ui.toElement(document.getElementById('divSecurityOptions'), obj); + let pnl = document.getElementById('divSecurityOptions'); + ui.toElement(pnl, obj); + let keepHint = 'Saved (leave blank to keep)'; + pnl.querySelector('#fldPassword').placeholder = this.savedSecurity.hasPassword ? keepHint : 'Password'; + pnl.querySelector('#fldReenterPassword').placeholder = this.savedSecurity.hasPassword ? keepHint : 'Re-enter Password'; + pnl.querySelector('label[for="fldPinEntry"]').innerHTML = this.savedSecurity.hasPin ? 'Enter Pin (leave blank to keep)' : 'Enter Pin'; this.onSecurityTypeChanged(); } rebootDevice() { @@ -1674,9 +1676,17 @@ class General { sec.pin += security.pin[`d${i}`]; } sec.permissions |= security.permissions.configOnly ? 0x01 : 0x00; + // Blank secret fields keep the stored value while the type (and username) stay the same. + let saved = this.savedSecurity || {}; + let keepPin = security.type === 1 && sec.pin === '' && saved.hasPin && saved.type === 1; + let keepPassword = security.type === 2 && sec.password === '' && security.repeatpassword === '' && + saved.hasPassword && saved.type === 2 && sec.username === saved.username; + // Only send the secret for the selected type so the other one stays stored. + if (security.type !== 1 || keepPin) delete sec.pin; + if (security.type !== 2 || keepPassword) delete sec.password; let confirm = ''; console.log(sec); - if (security.type === 1) { // Pin Entry + if (security.type === 1 && !keepPin) { // Pin Entry // Make sure our pin is 4 digits. if (sec.pin.length !== 4) { ui.errorMessage('Invalid Pin').querySelector('.sub-message').innerHTML = 'Pins must be exactly 4 alpha-numeric values in length. Please enter a complete pin.'; @@ -1693,7 +1703,8 @@ class General { ui.errorMessage('Invalid Username').querySelector('.sub-message').innerHTML = 'The maximum username length is 32 characters.'; return; } - + } + if (security.type === 2 && !keepPassword) { // Password if (sec.password.length === 0) { ui.errorMessage('No Password Provided').querySelector('.sub-message').innerHTML = 'You must provide a password for password security.'; return; @@ -1803,6 +1814,11 @@ class Wifi { document.getElementById('cbHardwired').checked = settings.connType >= 2; document.getElementById('cbFallbackWireless').checked = settings.connType === 3; ui.toElement(pnl, settings); + // The device never returns the stored passphrase; leave the field blank to keep it. + this.savedSsid = settings.wifi.ssid; + let fldPassphrase = document.getElementById('fldPassphrase'); + fldPassphrase.value = ''; + fldPassphrase.placeholder = settings.wifi.hasPassphrase ? 'Saved (leave blank to keep)' : 'Passphrase'; /* if (settings.connType >= 2) { document.getElementById('divWiFiMode').style.display = 'none'; @@ -1850,7 +1866,7 @@ class Wifi { //document.getElementById('btnConnectWiFi').classList.remove('disabled'); console.log(aps); if (err) { - this.displayAPs({ connected: { name: '', passphrase: '' }, accessPoints: [] }); + this.displayAPs({ connected: { name: '' }, accessPoints: [] }); } else { this.displayAPs(aps); @@ -1955,6 +1971,7 @@ class Wifi { saveNetwork() { let pnl = document.getElementById('divNetAdapter'); let obj = ui.fromElement(pnl); + if (this.keepSavedPassphrase(obj.wifi.ssid, obj.wifi.passphrase)) delete obj.wifi.passphrase; obj.connType = obj.ethernet.hardwired ? (obj.ethernet.wirelessFallback ? 3 : 2) : 1; console.log(obj); if (obj.connType >= 2) { @@ -1991,6 +2008,8 @@ class Wifi { this.sendNetworkSettings(obj); } } + // A blank passphrase for the saved SSID keeps the stored one; for a new SSID it means an open network. + keepSavedPassphrase(ssid, passphrase) { return passphrase === '' && ssid === this.savedSsid; } sendNetworkSettings(obj) { putJSONSync('/setNetwork', obj, (err, response) => { if (err) { @@ -2014,6 +2033,7 @@ class Wifi { ui.errorMessage('Invalid Passphrase').querySelector('.sub-message').innerHTML = 'The maximum length of the passphrase is 64 characters.'; return; } + if (this.keepSavedPassphrase(obj.ssid, obj.passphrase)) delete obj.passphrase; let overlay = ui.waitMessage(document.getElementById('divNetAdapter')); @@ -4364,12 +4384,19 @@ class MQTT { else { console.log(settings); ui.toElement(document.getElementById('divMQTT'), { mqtt: settings }); + // The device never returns the stored password; leave the field blank to keep it. + this.savedUsername = settings.username; + let fldPassword = document.getElementById('fldMqttPassword'); + fldPassword.value = ''; + fldPassword.placeholder = settings.hasPassword ? 'Saved (leave blank to keep)' : 'Password'; document.getElementById('divDiscoveryTopic').style.display = settings.pubDisco ? '' : 'none'; } }); } connectMQTT() { let obj = ui.fromElement(document.getElementById('divMQTT')); + // A blank password keeps the stored one unless the username changed. + if (obj.mqtt.password === '' && obj.mqtt.username === this.savedUsername) delete obj.mqtt.password; console.log(obj); if (obj.mqtt.enabled) { if (typeof obj.mqtt.hostname !== 'string' || obj.mqtt.hostname.length === 0) { diff --git a/docs/KNOWN_ISSUES.md b/docs/KNOWN_ISSUES.md index f6516a1..f083de3 100644 --- a/docs/KNOWN_ISSUES.md +++ b/docs/KNOWN_ISSUES.md @@ -105,3 +105,39 @@ It needs a group whose flags are out of date at the moment a client reads it, fo - [ADR-0002](adr/0002-loop-task-is-the-only-writer-of-the-shade-model.md): the loop task is the only writer of the shade model. - [Large websocket events are dropped](#large-websocket-events-are-dropped): the same `g_response` buffer. + +## A saved WiFi passphrase or MQTT password can't be cleared from the UI + +**Status:** open. A workaround exists (below). +**Introduced:** 2026-10-18, when the device stopped sending stored secrets back to clients. +**Location:** `applySecret` in [src/Secrets.h](../src/Secrets.h), called from `WifiSettings::fromJSON` and `MQTTSettings::fromJSON` in [src/ConfigSettings.cpp](../src/ConfigSettings.cpp) and from `/connectwifi`. The UI side is `keepSavedPassphrase` in `Wifi` and `connectMQTT` in `MQTT`, in [data-src/index.js](../data-src/index.js). + +### Summary + +The settings routes report only whether a secret is stored (`hasPassphrase`, `hasPassword`), so the UI loads every secret field empty. The firmware decides what an empty secret in a request means, so that every UI version is safe, including one older than this change, which sends `""` for the field it can no longer fill in: + +| Secret | Key absent | `""` | A value | +|---|---|---|---| +| WiFi passphrase | keep | keep, or clear if the same request changes the SSID | replace | +| MQTT password | keep | keep, or clear if the same request changes the username | replace | +| Login password, PIN | keep | keep | replace | + +Without "empty keeps", saving any other setting from an old UI would wipe the secret: for WiFi the device would reboot and drop into AP mode. + +The cost: an empty value can't mean "no password" while the SSID or username stays the same. Switching to a different open network works. + +The login password and PIN aren't affected. Neither can be empty, and security is turned off with the type selector. + +### Concrete failure modes + +1. The network the device is on is changed to open on the router, keeping its SSID. Saving the network page with a blank passphrase keeps the old one. When a passphrase is set, `WiFi.begin` won't accept anything weaker than WPA2-PSK, so the device can't join the now-open network. +2. The MQTT broker stops requiring a password but keeps the username. The device keeps sending the old password. + +### Workaround + +- **MQTT:** save once with a different username, then change it back and save again with the password blank. +- **WiFi:** there's no safe workaround. Saving a different SSID makes the device reboot and try to join that network. + +### Fix options + +- **(a) "Clear saved password" checkbox** next to the WiFi passphrase and MQTT password fields, shown only when a value is stored. Since `""` means keep, ticking it sends an explicit flag (`clearPassphrase` / `clearPassword`), which `fromJSON` turns into `applySecret(..., identityChanged = true)`. diff --git a/src/ConfigSettings.cpp b/src/ConfigSettings.cpp index b87cc38..c796caa 100644 --- a/src/ConfigSettings.cpp +++ b/src/ConfigSettings.cpp @@ -5,6 +5,7 @@ #include #include "ConfigSettings.h" #include "Utils.h" +#include "Secrets.h" #include "esp_chip_info.h" #include "esp_log.h" @@ -301,7 +302,7 @@ bool MQTTSettings::toJSON(JsonObject &obj) { obj["hostname"] = this->hostname; obj["port"] = this->port; obj["username"] = this->username; - obj["password"] = this->password; + obj["hasPassword"] = hasSecret(this->password); obj["rootTopic"] = this->rootTopic; obj["discoTopic"] = this->discoTopic; return true; @@ -311,8 +312,11 @@ bool MQTTSettings::fromJSON(JsonObject &obj) { if(!obj["pubDisco"].isNull()) this->pubDisco = obj["pubDisco"]; this->parseValueString(obj, "protocol", this->protocol, sizeof(this->protocol)); this->parseValueString(obj, "hostname", this->hostname, sizeof(this->hostname)); + // An empty password clears the stored one only alongside a new username. + const char *username = obj["username"].as(); + bool newUsername = username && strcmp(username, this->username) != 0; this->parseValueString(obj, "username", this->username, sizeof(this->username)); - this->parseValueString(obj, "password", this->password, sizeof(this->password)); + applySecret(obj["password"].as(), this->password, sizeof(this->password), newUsername); this->parseValueString(obj, "rootTopic", this->rootTopic, sizeof(this->rootTopic)); this->parseValueString(obj, "discoTopic", this->discoTopic, sizeof(this->discoTopic)); if(!obj["port"].isNull()) this->port = obj["port"]; @@ -478,16 +482,16 @@ bool SecuritySettings::begin() { bool SecuritySettings::fromJSON(JsonObject &obj) { if(!obj["type"].isNull()) this->type = static_cast(obj["type"].as()); this->parseValueString(obj, "username", this->username, sizeof(this->username)); - this->parseValueString(obj, "password", this->password, sizeof(this->password)); - this->parseValueString(obj, "pin", this->pin, sizeof(this->pin)); + applySecret(obj["password"].as(), this->password, sizeof(this->password)); + applySecret(obj["pin"].as(), this->pin, sizeof(this->pin)); if(!obj["permissions"].isNull()) this->permissions = obj["permissions"]; return true; } bool SecuritySettings::toJSON(JsonObject &obj) { obj["type"] = static_cast(this->type); obj["username"] = this->username; - obj["password"] = this->password; - obj["pin"] = this->pin; + obj["hasPassword"] = hasSecret(this->password); + obj["hasPin"] = hasSecret(this->pin); obj["permissions"] = this->permissions; return true; } @@ -516,7 +520,7 @@ bool SecuritySettings::load() { } void SecuritySettings::print() { ESP_LOGD(TAG, "SECURITY Type:%u Username:[%s] Password:[%s] Pin:[%s] Permissions:%u", - static_cast(this->type), this->username, this->password, this->pin, this->permissions); + static_cast(this->type), this->username, maskSecret(this->password), maskSecret(this->pin), this->permissions); } WifiSettings::WifiSettings() {} @@ -525,15 +529,18 @@ bool WifiSettings::begin() { return true; } bool WifiSettings::fromJSON(JsonObject &obj) { + // An empty passphrase clears the stored one only alongside a new SSID (an open network). + const char *ssid = obj["ssid"].as(); + bool newSsid = ssid && strcmp(ssid, this->ssid) != 0; this->parseValueString(obj, "ssid", this->ssid, sizeof(this->ssid)); - this->parseValueString(obj, "passphrase", this->passphrase, sizeof(this->passphrase)); + applySecret(obj["passphrase"].as(), this->passphrase, sizeof(this->passphrase), newSsid); if(!obj["roaming"].isNull()) this->roaming = obj["roaming"]; if(!obj["hidden"].isNull()) this->hidden = obj["hidden"]; return true; } bool WifiSettings::toJSON(JsonObject &obj) { obj["ssid"] = this->ssid; - obj["passphrase"] = this->passphrase; + obj["hasPassphrase"] = hasSecret(this->passphrase); obj["roaming"] = this->roaming; obj["hidden"] = this->hidden; return true; @@ -579,7 +586,7 @@ String WifiSettings::mapEncryptionType(int type) { return "Unknown"; } void WifiSettings::print() { - ESP_LOGD(TAG, "WIFI Settings SSID: [%s] PassPhrase: [%s]", this->ssid, this->passphrase); + ESP_LOGD(TAG, "WIFI Settings SSID: [%s] PassPhrase: [%s]", this->ssid, maskSecret(this->passphrase)); } void WifiSettings::printNetworks() { int n = WiFi.scanNetworks(false, false); diff --git a/src/Secrets.h b/src/Secrets.h new file mode 100644 index 0000000..64937bf --- /dev/null +++ b/src/Secrets.h @@ -0,0 +1,30 @@ +#ifndef secrets_h +#define secrets_h +#include + +// Stored secrets (WiFi passphrase, MQTT password, login password/PIN) are +// write-only: responses carry hasSecret() flags and logs carry maskSecret(). + +inline bool hasSecret(const char *s) { return s && s[0] != '\0'; } + +inline const char *maskSecret(const char *s) { return hasSecret(s) ? "" : ""; } + +// Updates dest from an incoming value, truncating to size. Pass +// obj["key"].as(), which is null when the key is absent. +// absent -> keep the stored secret. +// "" -> keep it too, unless identityChanged (a new SSID or username in +// the same request), then clear it. UIs older than write-only +// secrets send "" for a field the device no longer fills in. +// value -> replace it. +// Returns true when dest changed. +inline bool applySecret(const char *incoming, char *dest, size_t size, bool identityChanged = false) { + if(!incoming || size == 0) return false; + if(incoming[0] == '\0' && !identityChanged) return false; + size_t len = strlen(incoming); + if(len >= size) len = size - 1; + if(strncmp(dest, incoming, len) == 0 && dest[len] == '\0') return false; + memcpy(dest, incoming, len); + dest[len] = '\0'; + return true; +} +#endif diff --git a/src/entryPoints/Web.cpp b/src/entryPoints/Web.cpp index f84634f..f3e988d 100644 --- a/src/entryPoints/Web.cpp +++ b/src/entryPoints/Web.cpp @@ -125,7 +125,6 @@ bool Web::createAPIToken(const char *payload, char *token) { sprintf(str, "%02x", (int)hmacResult[i]); strcat(token, str); } - ESP_LOGD(TAG, "Hash: %s", token); return true; } bool Web::createAPIToken(const IPAddress ipAddress, char *token) { diff --git a/src/entryPoints/WebUiRoutes.cpp b/src/entryPoints/WebUiRoutes.cpp index e46176b..de5dd67 100644 --- a/src/entryPoints/WebUiRoutes.cpp +++ b/src/entryPoints/WebUiRoutes.cpp @@ -6,6 +6,7 @@ #include "ConfigSettings.h" #include "ConfigFile.h" #include "Utils.h" +#include "Secrets.h" #include "SSDP.h" #include "Somfy.h" #include "WResp.h" @@ -621,7 +622,6 @@ void registerWebUiRoutes(AsyncWebServer &server) { resp.beginObject(); resp.beginObject("connected"); resp.addElem("name", settings.WIFI.ssid); - resp.addElem("passphrase", settings.WIFI.passphrase); resp.addElem("strength", (int32_t)WiFi.RSSI()); resp.addElem("channel", (int32_t)WiFi.channel()); resp.endObject(); @@ -782,16 +782,13 @@ void registerWebUiRoutes(AsyncWebServer &server) { } if(!obj["wifi"].isNull()) { JsonObject objWifi = obj["wifi"]; - if(settings.connType == conn_types_t::wifi) { - if(!objWifi["ssid"].isNull() && objWifi["ssid"].as().compareTo(settings.WIFI.ssid) != 0) { - if(WiFi.softAPgetStationNum() == 0) reboot = true; - } - if(!objWifi["passphrase"].isNull() && objWifi["passphrase"].as().compareTo(settings.WIFI.passphrase) != 0) { - if(WiFi.softAPgetStationNum() == 0) reboot = true; - } - } + // Compared after fromJSON, which decides whether an empty passphrase keeps the stored one. + WifiSettings before = settings.WIFI; settings.WIFI.fromJSON(objWifi); settings.WIFI.save(); + if(settings.connType == conn_types_t::wifi && WiFi.softAPgetStationNum() == 0 && + (strcmp(before.ssid, settings.WIFI.ssid) != 0 || strcmp(before.passphrase, settings.WIFI.passphrase) != 0)) + reboot = true; } if(!obj["ethernet"].isNull()) { JsonObject objEth = obj["ethernet"]; @@ -834,18 +831,19 @@ void registerWebUiRoutes(AsyncWebServer &server) { JsonObject obj = json.as(); ESP_LOGD(TAG, "Settings WIFI connection..."); String ssid = ""; - String passphrase = ""; if(!obj["ssid"].isNull()) ssid = obj["ssid"].as(); - if(!obj["passphrase"].isNull()) passphrase = obj["passphrase"].as(); - bool reboot = false; - if(ssid.compareTo(settings.WIFI.ssid) != 0) reboot = true; - if(passphrase.compareTo(settings.WIFI.passphrase) != 0) reboot = true; + bool newSsid = ssid.compareTo(settings.WIFI.ssid) != 0; + // Clients never receive the stored passphrase: an omitted or empty one keeps it unless the SSID changes. + char passphrase[sizeof(settings.WIFI.passphrase)]; + SETCHARPROP(passphrase, settings.WIFI.passphrase, sizeof(passphrase)); + bool newPassphrase = applySecret(obj["passphrase"].as(), passphrase, sizeof(passphrase), newSsid); + bool reboot = newSsid || newPassphrase; if(!settings.WIFI.ssidExists(ssid.c_str()) && ssid.length() > 0) { request->send(400, _encoding_json, "{\"status\":\"ERROR\",\"desc\":\"WiFi Network Does not exist\"}"); } else { SETCHARPROP(settings.WIFI.ssid, ssid.c_str(), sizeof(settings.WIFI.ssid)); - SETCHARPROP(settings.WIFI.passphrase, passphrase.c_str(), sizeof(settings.WIFI.passphrase)); + SETCHARPROP(settings.WIFI.passphrase, passphrase, sizeof(settings.WIFI.passphrase)); settings.WIFI.save(); settings.WIFI.print(); request->send(201, _encoding_json, "{\"status\":\"OK\",\"desc\":\"Successfully set server connection\"}"); diff --git a/test/test_secrets/test_secrets.cpp b/test/test_secrets/test_secrets.cpp new file mode 100644 index 0000000..07fe321 --- /dev/null +++ b/test/test_secrets/test_secrets.cpp @@ -0,0 +1,106 @@ +#include +#include "Secrets.h" + +void setUp() {} +void tearDown() {} + +/********************************************************************* + * hasSecret / maskSecret + ********************************************************************/ +static void test_hasSecret_null_and_empty_are_unset() { + TEST_ASSERT_FALSE(hasSecret(nullptr)); + TEST_ASSERT_FALSE(hasSecret("")); +} +static void test_hasSecret_any_value_is_set() { + TEST_ASSERT_TRUE(hasSecret("x")); + TEST_ASSERT_TRUE(hasSecret(" ")); +} +static void test_maskSecret_never_returns_the_secret() { + TEST_ASSERT_EQUAL_STRING("", maskSecret("hunter2")); + TEST_ASSERT_EQUAL_STRING("", maskSecret("")); + TEST_ASSERT_EQUAL_STRING("", maskSecret(nullptr)); +} + +/********************************************************************* + * applySecret + ********************************************************************/ +static void test_applySecret_absent_keeps_stored_value() { + char dest[16] = "stored"; + TEST_ASSERT_FALSE(applySecret(nullptr, dest, sizeof(dest))); + TEST_ASSERT_EQUAL_STRING("stored", dest); +} +static void test_applySecret_new_value_replaces_stored() { + char dest[16] = "stored"; + TEST_ASSERT_TRUE(applySecret("fresh", dest, sizeof(dest))); + TEST_ASSERT_EQUAL_STRING("fresh", dest); +} +static void test_applySecret_same_value_is_no_change() { + char dest[16] = "stored"; + TEST_ASSERT_FALSE(applySecret("stored", dest, sizeof(dest))); + TEST_ASSERT_EQUAL_STRING("stored", dest); +} +static void test_applySecret_prefix_of_stored_is_a_change() { + char dest[16] = "stored"; + TEST_ASSERT_TRUE(applySecret("sto", dest, sizeof(dest))); + TEST_ASSERT_EQUAL_STRING("sto", dest); +} +// An older UI sends "" for a field the device no longer fills in. +static void test_applySecret_empty_value_keeps_stored() { + char dest[16] = "stored"; + TEST_ASSERT_FALSE(applySecret("", dest, sizeof(dest))); + TEST_ASSERT_EQUAL_STRING("stored", dest); +} +// A new SSID or username with an empty secret, e.g. an open WiFi network. +static void test_applySecret_empty_value_with_new_identity_clears_stored() { + char dest[16] = "stored"; + TEST_ASSERT_TRUE(applySecret("", dest, sizeof(dest), true)); + TEST_ASSERT_EQUAL_STRING("", dest); +} +static void test_applySecret_empty_value_with_new_identity_and_nothing_stored() { + char dest[16] = ""; + TEST_ASSERT_FALSE(applySecret("", dest, sizeof(dest), true)); + TEST_ASSERT_EQUAL_STRING("", dest); +} +static void test_applySecret_new_identity_still_takes_a_value() { + char dest[16] = "stored"; + TEST_ASSERT_TRUE(applySecret("fresh", dest, sizeof(dest), true)); + TEST_ASSERT_EQUAL_STRING("fresh", dest); +} +static void test_applySecret_absent_keeps_stored_even_with_new_identity() { + char dest[16] = "stored"; + TEST_ASSERT_FALSE(applySecret(nullptr, dest, sizeof(dest), true)); + TEST_ASSERT_EQUAL_STRING("stored", dest); +} +static void test_applySecret_truncates_and_terminates() { + char storage[8]; + memset(storage, 'X', sizeof(storage)); + storage[0] = '\0'; + TEST_ASSERT_TRUE(applySecret("abcdefgh", storage, 5)); + TEST_ASSERT_EQUAL_STRING("abcd", storage); + // Nothing past the buffer size is touched. + TEST_ASSERT_EQUAL_HEX8('X', storage[5]); +} +static void test_applySecret_truncated_same_value_is_no_change() { + char dest[5] = "abcd"; + TEST_ASSERT_FALSE(applySecret("abcdefgh", dest, sizeof(dest))); + TEST_ASSERT_EQUAL_STRING("abcd", dest); +} + +int main(int argc, char **argv) { + UNITY_BEGIN(); + RUN_TEST(test_hasSecret_null_and_empty_are_unset); + RUN_TEST(test_hasSecret_any_value_is_set); + RUN_TEST(test_maskSecret_never_returns_the_secret); + RUN_TEST(test_applySecret_absent_keeps_stored_value); + RUN_TEST(test_applySecret_new_value_replaces_stored); + RUN_TEST(test_applySecret_same_value_is_no_change); + RUN_TEST(test_applySecret_prefix_of_stored_is_a_change); + RUN_TEST(test_applySecret_empty_value_keeps_stored); + RUN_TEST(test_applySecret_empty_value_with_new_identity_clears_stored); + RUN_TEST(test_applySecret_empty_value_with_new_identity_and_nothing_stored); + RUN_TEST(test_applySecret_new_identity_still_takes_a_value); + RUN_TEST(test_applySecret_absent_keeps_stored_even_with_new_identity); + RUN_TEST(test_applySecret_truncates_and_terminates); + RUN_TEST(test_applySecret_truncated_same_value_is_no_change); + return UNITY_END(); +}