mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-09-26 21:07:56 +00:00
Publishing moves the old primary to .bak before the verified temp takes that name, so a failed second rename left the new image exactly where boot recovery promotes it — while the observer setter told the operator the change had been rolled back. The refused value came back at the next reset. Restore the backup and discard the temp on that path, and distinguish CommitIndeterminate from CommitFailed when the filesystem cannot be put back, so the CLI reply says the flash state is unresolved rather than claiming the change is gone. The indeterminate condition latches for the boot: the artifact left behind also makes every later transaction fail to begin, so it cannot clear itself. Also state the version-first rule the future-version probe depends on. The probe reads the root version with this firmware's grammar, so a newer file that introduces unknown syntax ahead of that field reads as corrupt rather than future and loses its preservation guarantee. Tests: publish-failure rollback and the indeterminate outcome against a SPIFFS-shaped store fake; the version-first writer invariant and the cost of violating it; /prefs.json coverage for the strict shape checks (deployed-shape file, unknown nested groups, torn files, mismatches).
336 lines
11 KiB
C++
336 lines
11 KiB
C++
#include <gtest/gtest.h>
|
|
#include "helpers/ConfigSerializer.h"
|
|
|
|
class NativeFileSystem {
|
|
public:
|
|
void mkdir(const char*) { }
|
|
};
|
|
#define FILESYSTEM NativeFileSystem
|
|
#include "helpers/CommonCLI.h"
|
|
#undef FILESYSTEM
|
|
|
|
#define TEST_INT_S "56"
|
|
#define TEST_INT 56
|
|
#define TEST_FLOAT_S "-6.123"
|
|
#define TEST_FLOAT -6.1230f
|
|
#define TEST_DOUBLE_S "12.123456"
|
|
#define TEST_DOUBLE 12.123456
|
|
|
|
class MockInputStream : public Stream {
|
|
const char* _text;
|
|
int pos, len;
|
|
public:
|
|
MockInputStream(const char* text) : _text(text) { pos = 0; len = strlen(text); }
|
|
int available() override { return len - pos; }
|
|
int read() override { if (pos < len) { return _text[pos++]; } return -1; }
|
|
int peek() override { if (pos < len) { return _text[pos]; } return -1; }
|
|
};
|
|
|
|
class MockPrintStream : public Stream {
|
|
int len = 0;
|
|
uint8_t _buf[1024];
|
|
|
|
size_t printSigned(long long value) {
|
|
char text[24];
|
|
snprintf(text, sizeof(text), "%lld", value);
|
|
return Print::print(text);
|
|
}
|
|
|
|
size_t printUnsigned(unsigned long long value) {
|
|
char text[24];
|
|
snprintf(text, sizeof(text), "%llu", value);
|
|
return Print::print(text);
|
|
}
|
|
|
|
public:
|
|
size_t write(uint8_t b) override {
|
|
if (len < sizeof(_buf)) {
|
|
_buf[len++] = b;
|
|
return 1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
size_t print(unsigned char v, int r) override { return printUnsigned(v); }
|
|
size_t print(int v, int r) override { return printSigned(v); }
|
|
size_t print(unsigned int v, int r) override { return printUnsigned(v); }
|
|
size_t print(long v, int r) override { return printSigned(v); }
|
|
size_t print(unsigned long v, int r) override { return printUnsigned(v); }
|
|
size_t print(long long v, int r) override { return printSigned(v); }
|
|
size_t print(unsigned long long v, int r) override { return printUnsigned(v); }
|
|
size_t print(double v, int p = 2) override {
|
|
char text[32];
|
|
snprintf(text, sizeof(text), "%.*f", p, v);
|
|
return Print::print(text);
|
|
}
|
|
|
|
int getLength() const { return len; }
|
|
const uint8_t* getBytes() const { return _buf; }
|
|
};
|
|
|
|
class TestStruct : public ConfigSerializer {
|
|
protected:
|
|
void structure() override {
|
|
def("age", age);
|
|
def("flags", flags);
|
|
def("name", name, sizeof(name));
|
|
}
|
|
public:
|
|
int32_t age;
|
|
char name[16];
|
|
uint8_t flags;
|
|
};
|
|
|
|
// ── saveSerial: basic ───────────────────────────────────────────────────────
|
|
|
|
TEST(ConfigSerializer, SaveSerial_Basic) {
|
|
MockPrintStream s;
|
|
TestStruct data;
|
|
|
|
data.age = TEST_INT;
|
|
data.flags = TEST_INT;
|
|
strcpy(data.name, "Scott");
|
|
|
|
bool success = data.saveSerial(s);
|
|
EXPECT_TRUE(success);
|
|
|
|
auto l = s.getLength();
|
|
const char* expect = "{age:" TEST_INT_S ",flags:" TEST_INT_S ",name:\"Scott\"}";
|
|
EXPECT_EQ(strlen(expect), l);
|
|
|
|
bool match = memcmp(s.getBytes(), expect, l) == 0;
|
|
EXPECT_TRUE(match);
|
|
}
|
|
|
|
|
|
TEST(ConfigSerializer, SaveSerial_EscChars) {
|
|
MockPrintStream s;
|
|
TestStruct data;
|
|
|
|
data.age = TEST_INT;
|
|
data.flags = TEST_INT;
|
|
strcpy(data.name, "\"Scott\"\n");
|
|
|
|
bool success = data.saveSerial(s);
|
|
EXPECT_TRUE(success);
|
|
|
|
auto l = s.getLength();
|
|
const char* expect = "{age:" TEST_INT_S ",flags:" TEST_INT_S ",name:\"\\\"Scott\\\"\\n\"}";
|
|
EXPECT_EQ(strlen(expect), l);
|
|
|
|
bool match = memcmp(s.getBytes(), expect, l) == 0;
|
|
EXPECT_TRUE(match);
|
|
}
|
|
|
|
// ── loadSerial: basic ───────────────────────────────────────────────────────
|
|
|
|
TEST(ConfigSerializer, LoadSerial_Basic) {
|
|
MockInputStream s("{age:" TEST_INT_S ",flags:" TEST_INT_S ",name:\"Scott\"}");
|
|
TestStruct data;
|
|
|
|
bool success = data.loadSerial(s);
|
|
EXPECT_TRUE(success);
|
|
|
|
EXPECT_EQ(TEST_INT, data.age);
|
|
EXPECT_EQ(TEST_INT, data.flags);
|
|
bool match = strcmp("Scott", data.name) == 0;
|
|
EXPECT_TRUE(match);
|
|
}
|
|
|
|
TEST(ConfigSerializer, LoadSerial_HandleWhitespace) {
|
|
MockInputStream s(" { age: " TEST_INT_S " , flags: " TEST_INT_S " , name: \"Scott\" } ");
|
|
TestStruct data;
|
|
|
|
bool success = data.loadSerial(s);
|
|
EXPECT_TRUE(success);
|
|
|
|
EXPECT_EQ(TEST_INT, data.age);
|
|
EXPECT_EQ(TEST_INT, data.flags);
|
|
bool match = strcmp("Scott", data.name) == 0;
|
|
EXPECT_TRUE(match);
|
|
}
|
|
|
|
TEST(ConfigSerializer, LoadSerial_EscChars) {
|
|
MockInputStream s("{age:" TEST_INT_S ",flags:" TEST_INT_S ",name:\"\\\"Scott\\\"\\n\"}");
|
|
TestStruct data;
|
|
|
|
bool success = data.loadSerial(s);
|
|
EXPECT_TRUE(success);
|
|
|
|
bool match = strcmp("\"Scott\"\n", data.name) == 0;
|
|
EXPECT_TRUE(match);
|
|
}
|
|
|
|
TEST(ConfigSerializer, LoadSerial_UnmatchedBraces) {
|
|
MockInputStream s("{age:" TEST_INT_S ",flags:" TEST_INT_S ",name:\"Scott\"");
|
|
TestStruct data;
|
|
|
|
bool success = data.loadSerial(s);
|
|
EXPECT_FALSE(success);
|
|
}
|
|
|
|
TEST(ConfigSerializer, LoadSerial_MissingCommas) {
|
|
MockInputStream s("{age:" TEST_INT_S " flags:" TEST_INT_S " name:\"Scott\"}");
|
|
TestStruct data;
|
|
|
|
bool success = data.loadSerial(s);
|
|
EXPECT_FALSE(success);
|
|
}
|
|
|
|
TEST(ConfigSerializer, LoadSerial_IgnoreUnknowns) {
|
|
MockInputStream s("{age:" TEST_INT_S ",xxx:" TEST_INT_S ",name:\"Scott\"}");
|
|
TestStruct data;
|
|
data.flags = 1;
|
|
|
|
// should ignore the 'xxx' property
|
|
bool success = data.loadSerial(s);
|
|
EXPECT_TRUE(success);
|
|
|
|
EXPECT_EQ(TEST_INT, data.age);
|
|
EXPECT_EQ(1, data.flags); // flags should be unmodified
|
|
bool match = strcmp("Scott", data.name) == 0;
|
|
EXPECT_TRUE(match);
|
|
}
|
|
|
|
TEST(NodePrefs, FemGainSettingsRoundTrip) {
|
|
NodePrefs saved;
|
|
saved.radio_fem_rxgain = 0;
|
|
saved.radio_fem_txgain = 1;
|
|
|
|
MockPrintStream output;
|
|
ASSERT_TRUE(saved.saveSerial(output));
|
|
|
|
std::string serialised(reinterpret_cast<const char*>(output.getBytes()), output.getLength());
|
|
EXPECT_NE(std::string::npos, serialised.find("fem_rxgain:0"));
|
|
EXPECT_NE(std::string::npos, serialised.find("fem_txgain:1"));
|
|
|
|
MockInputStream input(serialised.c_str());
|
|
NodePrefs loaded;
|
|
loaded.radio_fem_rxgain = 1;
|
|
loaded.radio_fem_txgain = 0;
|
|
|
|
ASSERT_TRUE(loaded.loadSerial(input));
|
|
EXPECT_EQ(0, loaded.radio_fem_rxgain);
|
|
EXPECT_EQ(1, loaded.radio_fem_txgain);
|
|
}
|
|
|
|
TEST(ConfigSerializer, LoadSerial_KeyDigitsAfterFirstCharacter) {
|
|
MockInputStream s("{age:1,flags:2,name:\"ok\",slot1:7}");
|
|
TestStruct data;
|
|
data.age = data.flags = 0;
|
|
strcpy(data.name, "before");
|
|
EXPECT_TRUE(data.loadSerial(s));
|
|
EXPECT_EQ(1, data.age);
|
|
EXPECT_EQ(2, data.flags);
|
|
EXPECT_STREQ("ok", data.name);
|
|
}
|
|
|
|
TEST(ConfigSerializer, LoadSerial_RejectsLeadingDigitKey) {
|
|
MockInputStream s("{1slot:7}");
|
|
TestStruct data;
|
|
EXPECT_FALSE(data.loadSerial(s));
|
|
}
|
|
|
|
// ── /prefs.json compatibility under the strict shape checks ─────────────────
|
|
//
|
|
// The scalar-vs-object rejection added for /mqtt.json also runs for the plain
|
|
// def() overloads that NodePrefs uses, and loadPrefsInt() applies /prefs.json
|
|
// straight onto the live object without consulting the return value. These
|
|
// pin what that combination does to files already on deployed devices.
|
|
|
|
// A settings file in the exact shape the firmware writes, transcribed rather
|
|
// than produced by saveSerial() so a change to the writer cannot quietly move
|
|
// the fixture with it.
|
|
static const char* DEPLOYED_PREFS_JSON =
|
|
"{name:\"Repeater-1\",pass:\"hunter2\",guest:\"\",owner:\"ops@example.com\","
|
|
"adv_int:4,f_adv_int:12,lat:47.612345,lon:-122.334567,disc_mod:1719791234,"
|
|
"radio:{freq:910.5250,bw:250.0000,sf:10,cr:5,cad:0,int_thr:0,rxgain:1,"
|
|
"fem_rxgain:0,fem_txgain:1,tx:22,af:1.0000,rxdelay:1000.0000,"
|
|
"f_txdelay:0.5000,d_txdelay:0.2000,agc_int:0,hash_mode:0,multi_ack:0},"
|
|
"bridge:{en:0,delay:500,src:1,baud:115200,ch:1,secret:\"\"},"
|
|
"gps:{en:0,int:60,adv_loc:0},"
|
|
"repeat:{disable:0,f_max:64,f_max_uns:32,f_max_adv:16,loop:1},"
|
|
"room:{rd_only:0},power:{adc_mult:1.0000,pwr_sav_en:0}}";
|
|
|
|
TEST(NodePrefs, DeployedPrefsJsonStillLoadsCompletely) {
|
|
MockInputStream s(DEPLOYED_PREFS_JSON);
|
|
NodePrefs prefs;
|
|
|
|
ASSERT_TRUE(prefs.loadSerial(s));
|
|
EXPECT_STREQ("Repeater-1", prefs.node_name);
|
|
EXPECT_STREQ("ops@example.com", prefs.owner_info);
|
|
EXPECT_EQ(4, prefs.advert_interval);
|
|
EXPECT_DOUBLE_EQ(47.612345, prefs.node_lat);
|
|
EXPECT_DOUBLE_EQ(-122.334567, prefs.node_lon);
|
|
EXPECT_EQ(1719791234u, prefs.discovery_mod_timestamp);
|
|
EXPECT_FLOAT_EQ(910.525f, prefs.freq);
|
|
EXPECT_EQ(10, prefs.sf);
|
|
EXPECT_EQ(22, prefs.tx_power_dbm);
|
|
EXPECT_EQ(1, prefs.radio_fem_txgain);
|
|
EXPECT_EQ(1, prefs.bridge_pkt_src);
|
|
EXPECT_EQ(115200u, prefs.bridge_baud);
|
|
EXPECT_EQ(60u, prefs.gps_interval);
|
|
EXPECT_EQ(64, prefs.flood_max);
|
|
EXPECT_EQ(1, prefs.loop_detect);
|
|
}
|
|
|
|
TEST(NodePrefs, UnknownNestedObjectCannotBeMistakenForARootScalar) {
|
|
// Depth is what keeps an unknown group's inner keys from matching a root
|
|
// field of the same name, so an unrecognized section must stay ignorable.
|
|
MockInputStream s("{name:\"Repeater-1\",mqtt:{name:\"other\",owner:\"nobody\"},adv_int:4}");
|
|
NodePrefs prefs;
|
|
strcpy(prefs.owner_info, "ops@example.com");
|
|
|
|
EXPECT_TRUE(prefs.loadSerial(s));
|
|
EXPECT_STREQ("Repeater-1", prefs.node_name);
|
|
EXPECT_STREQ("ops@example.com", prefs.owner_info);
|
|
EXPECT_EQ(4, prefs.advert_interval);
|
|
}
|
|
|
|
TEST(NodePrefs, TornPrefsJsonAppliesOnlyTheFieldsBeforeTheTear) {
|
|
// A power cut during the old non-transactional /prefs.json write leaves a
|
|
// file like this. loadPrefsInt() ignores the failed return and keeps the
|
|
// partial result, which is the pre-existing behavior; the strict checks
|
|
// must not turn it into something worse than a partial load.
|
|
MockInputStream s("{name:\"Repeater-1\",adv_int:4,radio:{freq:910.5250,sf:1");
|
|
NodePrefs prefs;
|
|
prefs.sf = 9;
|
|
|
|
EXPECT_FALSE(prefs.loadSerial(s));
|
|
EXPECT_STREQ("Repeater-1", prefs.node_name);
|
|
EXPECT_EQ(4, prefs.advert_interval);
|
|
EXPECT_FLOAT_EQ(910.525f, prefs.freq);
|
|
EXPECT_EQ(9, prefs.sf); // the torn value never completed a token
|
|
}
|
|
|
|
TEST(NodePrefs, ShapeMismatchIsRejectedAndStopsFurtherApplication) {
|
|
// Hand-edited or corrupted files are the regression surface for the strict
|
|
// checks: a known scalar holding an object now fails the load and stops
|
|
// parsing, so nothing after the mismatch reaches the live object.
|
|
MockInputStream scalar_as_object("{name:{x:1},adv_int:4}");
|
|
NodePrefs prefs;
|
|
prefs.advert_interval = 7;
|
|
EXPECT_FALSE(prefs.loadSerial(scalar_as_object));
|
|
EXPECT_EQ(7, prefs.advert_interval);
|
|
|
|
// The reverse mismatch is rejected too: a known group given a scalar.
|
|
MockInputStream object_as_scalar("{name:\"Repeater-1\",radio:1}");
|
|
NodePrefs scalar_group;
|
|
EXPECT_FALSE(scalar_group.loadSerial(object_as_scalar));
|
|
|
|
// And a known scalar nested one level deeper than the schema places it.
|
|
MockInputStream over_nested("{radio:{sf:{value:10}}}");
|
|
NodePrefs nested;
|
|
nested.sf = 9;
|
|
EXPECT_FALSE(nested.loadSerial(over_nested));
|
|
EXPECT_EQ(9, nested.sf);
|
|
}
|
|
|
|
|
|
// ── main ───────────────────────────────────────────────────────
|
|
|
|
int main(int argc, char** argv) {
|
|
::testing::InitGoogleTest(&argc, argv);
|
|
return RUN_ALL_TESTS();
|
|
}
|