S1 #7 steps 1-2: IPv4 checks, the IP setting per Saved Network, DNS and NTP settings

The plan and decisions Q105 to Q116 (docs/milestones/S1.md). lib/net:
strict IPv4 parsing, prefix and mask, and the checks a Fixed setting must
pass, each refusal with its reason. A Saved Network is Automatic or Fixed
(address/prefix and an optional gateway), kept with it in flash. Settings:
two DNS servers (9.9.9.9, 1.1.1.1), "Always use my DNS", two NTP servers
(pool.ntp.org, time.cloudflare.com). Host-tested: 383 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
2026-10-05 23:39:07 +02:00
co-authored by Claude Opus 5.5
parent 7e8882d9cb
commit bdd027cb50
10 changed files with 429 additions and 1 deletions
+101
View File
@@ -0,0 +1,101 @@
#include <unity.h>
#include <string>
#include "ipv4.h"
using namespace roro::net;
void setUp() {}
void tearDown() {}
static uint32_t ip(const char* s) {
uint32_t a = 0;
TEST_ASSERT_TRUE_MESSAGE(parseIpv4(s, a), s);
return a;
}
void test_parse_and_format() {
TEST_ASSERT_EQUAL_HEX32(0x0A27270C, ip("10.39.39.12"));
TEST_ASSERT_EQUAL_HEX32(0x00000000, ip("0.0.0.0"));
TEST_ASSERT_EQUAL_HEX32(0xFFFFFFFF, ip("255.255.255.255"));
TEST_ASSERT_EQUAL_STRING("10.39.39.12", formatIpv4(0x0A27270C).c_str());
TEST_ASSERT_EQUAL_STRING("192.168.1.254", formatIpv4(ip("192.168.1.254")).c_str());
}
void test_parse_refuses() {
uint32_t a;
for (const char* bad : {"", "10.39.39", "10.39.39.12.1", "10.39.39.256", "10..39.12", "10.39.39.", ".10.39.39",
"10.39.39.1a", " 10.39.39.12", "10.39.39.12 ", "1.2.3.-4", "01234.1.1.1", "1.2.3.4/24"})
TEST_ASSERT_FALSE_MESSAGE(parseIpv4(bad, a), bad);
}
void test_prefix_and_mask() {
TEST_ASSERT_EQUAL_HEX32(0xFFFFFF00, maskOf(24));
TEST_ASSERT_EQUAL_HEX32(0xFFFFFFFC, maskOf(30));
TEST_ASSERT_EQUAL_HEX32(0x80000000, maskOf(1));
TEST_ASSERT_EQUAL_HEX32(0xFFFF0000, maskOf(16));
TEST_ASSERT_EQUAL_STRING("255.255.255.0", formatIpv4(maskOf(24)).c_str());
}
// Q111: what a Fixed setting must satisfy, and the reason given when it doesn't.
void test_fixed_setting_accepted() {
TEST_ASSERT_EQUAL_STRING("", checkFixed({ip("10.39.39.13"), 24, ip("10.39.39.1")}).c_str());
TEST_ASSERT_EQUAL_STRING("", checkFixed({ip("192.168.4.2"), 30, ip("192.168.4.1")}).c_str());
TEST_ASSERT_EQUAL_STRING("", checkFixed({ip("10.0.0.5"), 8, 0}).c_str()); // Q107: no gateway is fine
}
void test_fixed_setting_refused() {
TEST_ASSERT_EQUAL_STRING("The prefix must be 1 to 30", checkFixed({ip("10.39.39.13"), 0, 0}).c_str());
TEST_ASSERT_EQUAL_STRING("The prefix must be 1 to 30", checkFixed({ip("10.39.39.13"), 31, 0}).c_str());
TEST_ASSERT_EQUAL_STRING("10.39.39.0 is the network's own address", checkFixed({ip("10.39.39.0"), 24, 0}).c_str());
TEST_ASSERT_EQUAL_STRING("10.39.39.255 is the broadcast address", checkFixed({ip("10.39.39.255"), 24, 0}).c_str());
TEST_ASSERT_EQUAL_STRING("The gateway 10.39.40.1 isn't in 10.39.39.0/24",
checkFixed({ip("10.39.39.13"), 24, ip("10.39.40.1")}).c_str());
TEST_ASSERT_EQUAL_STRING("The gateway can't be this device's address",
checkFixed({ip("10.39.39.13"), 24, ip("10.39.39.13")}).c_str());
TEST_ASSERT_EQUAL_STRING("The gateway 10.39.39.255 is the broadcast address",
checkFixed({ip("10.39.39.13"), 24, ip("10.39.39.255")}).c_str());
TEST_ASSERT_EQUAL_STRING("0.0.0.0 isn't an address a device can have", checkFixed({0, 24, 0}).c_str());
}
// How a Fixed setting is typed on the console and kept in flash: "address/prefix [gateway]".
void test_fixed_setting_as_text() {
FixedIp f;
TEST_ASSERT_EQUAL_STRING("", parseFixed("10.39.39.13/24 10.39.39.1", f).c_str());
TEST_ASSERT_EQUAL_HEX32(ip("10.39.39.13"), f.address);
TEST_ASSERT_EQUAL_UINT8(24, f.prefix);
TEST_ASSERT_EQUAL_HEX32(ip("10.39.39.1"), f.gateway);
TEST_ASSERT_EQUAL_STRING("10.39.39.13/24 10.39.39.1", formatFixed(f).c_str());
TEST_ASSERT_EQUAL_STRING("", parseFixed("192.168.4.2/30", f).c_str());
TEST_ASSERT_EQUAL_HEX32(0, f.gateway);
TEST_ASSERT_EQUAL_STRING("192.168.4.2/30", formatFixed(f).c_str());
TEST_ASSERT_EQUAL_STRING("Write it as address/prefix, then the gateway if there is one", parseFixed("10.39.39.13", f).c_str());
TEST_ASSERT_EQUAL_STRING("10.39.39.300 isn't an IPv4 address", parseFixed("10.39.39.300/24", f).c_str());
TEST_ASSERT_EQUAL_STRING("The prefix must be 1 to 30", parseFixed("10.39.39.13/abc", f).c_str());
TEST_ASSERT_EQUAL_STRING("gw isn't an IPv4 address", parseFixed("10.39.39.13/24 gw", f).c_str());
TEST_ASSERT_EQUAL_STRING("The gateway 10.39.40.1 isn't in 10.39.39.0/24", parseFixed("10.39.39.13/24 10.39.40.1", f).c_str());
}
// An NTP server is an address or a host name (Q110).
void test_host_names() {
for (const char* good : {"pool.ntp.org", "time.cloudflare.com", "10.39.39.1", "ntp", "a-b.example", "0.be.pool.ntp.org"})
TEST_ASSERT_TRUE_MESSAGE(validHost(good), good);
for (const char* bad : {"", "pool .ntp.org", "-ntp.org", "ntp-.org", "ntp..org", ".ntp.org", "ntp.org.", "n_tp.org", "häst.se",
"999.1.1.1", "1.2.3"})
TEST_ASSERT_FALSE_MESSAGE(validHost(bad), bad);
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_parse_and_format);
RUN_TEST(test_parse_refuses);
RUN_TEST(test_prefix_and_mask);
RUN_TEST(test_fixed_setting_accepted);
RUN_TEST(test_fixed_setting_refused);
RUN_TEST(test_fixed_setting_as_text);
RUN_TEST(test_host_names);
return UNITY_END();
}
@@ -97,6 +97,76 @@ void test_find() {
TEST_ASSERT_NULL(n.find("other"));
}
// S1, Q105: each Saved Network is Automatic (DHCP) or Fixed, and remembers which.
void test_new_network_is_automatic() {
MemoryStore store;
SavedNetworks nets(store);
nets.add("home", "password1");
TEST_ASSERT_FALSE(nets.find("home")->fixed);
}
void test_ip_setting_survives_reload() {
MemoryStore store;
SavedNetworks nets(store);
nets.add("home", "password1");
nets.add("bench", "");
roro::net::FixedIp f;
TEST_ASSERT_EQUAL_STRING("", roro::net::parseFixed("10.39.39.13/24 10.39.39.1", f).c_str());
TEST_ASSERT_EQUAL_STRING("", nets.setIp("bench", &f).c_str());
SavedNetworks again(store);
again.load();
TEST_ASSERT_FALSE(again.find("home")->fixed);
TEST_ASSERT_TRUE(again.find("bench")->fixed);
TEST_ASSERT_EQUAL_STRING("10.39.39.13/24 10.39.39.1", roro::net::formatFixed(again.find("bench")->ip).c_str());
TEST_ASSERT_EQUAL_STRING("", again.setIp("bench", nullptr).c_str()); // back to Automatic
SavedNetworks third(store);
third.load();
TEST_ASSERT_FALSE(third.find("bench")->fixed);
}
void test_ip_setting_is_checked() {
MemoryStore store;
SavedNetworks nets(store);
nets.add("bench", "");
roro::net::FixedIp bad{0x0A272700, 24, 0}; // 10.39.39.0: the network's own address
TEST_ASSERT_EQUAL_STRING("10.39.39.0 is the network's own address", nets.setIp("bench", &bad).c_str());
TEST_ASSERT_FALSE(nets.find("bench")->fixed);
roro::net::FixedIp ok{0x0A27270D, 24, 0};
TEST_ASSERT_EQUAL_STRING("Not a saved network", nets.setIp("nowhere", &ok).c_str());
}
// Forgetting a network moves the ones after it: each keeps its own setting.
void test_ip_setting_follows_its_network() {
MemoryStore store;
SavedNetworks nets(store);
nets.add("a", "");
nets.add("b", "");
nets.add("c", "");
roro::net::FixedIp f{0x0A27270D, 24, 0};
nets.setIp("c", &f);
nets.forget("a");
SavedNetworks again(store);
again.load();
TEST_ASSERT_FALSE(again.find("b")->fixed);
TEST_ASSERT_TRUE(again.find("c")->fixed);
// And changing the password keeps it.
again.add("c", "password2");
TEST_ASSERT_TRUE(again.find("c")->fixed);
}
// A stored setting that no longer passes the checks falls back to Automatic.
void test_bad_stored_ip_setting_is_ignored() {
MemoryStore store;
SavedNetworks nets(store);
nets.add("bench", "");
store.strings["net0_ip"] = "10.39.39.13/99";
SavedNetworks again(store);
again.load();
TEST_ASSERT_FALSE(again.find("bench")->fixed);
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_empty_store_has_no_networks);
@@ -107,5 +177,10 @@ int main() {
RUN_TEST(test_validation_follows_wifi_limits);
RUN_TEST(test_hidden_flag_survives_reload);
RUN_TEST(test_find);
RUN_TEST(test_new_network_is_automatic);
RUN_TEST(test_ip_setting_survives_reload);
RUN_TEST(test_ip_setting_is_checked);
RUN_TEST(test_ip_setting_follows_its_network);
RUN_TEST(test_bad_stored_ip_setting_is_ignored);
return UNITY_END();
}
+33
View File
@@ -154,6 +154,37 @@ void test_storage_keys_fit_nvs_limit() {
TEST_ASSERT_TRUE(std::strlen(Settings::key(static_cast<Setting>(i))) <= 15);
}
// S1, Q108 to Q110: DNS and NTP servers, with public defaults.
void test_dns_and_ntp_defaults() {
MemoryStore store;
EventBus bus;
Settings s(store, bus);
s.load();
TEST_ASSERT_EQUAL_STRING("9.9.9.9", s.getString(Setting::Dns1).c_str());
TEST_ASSERT_EQUAL_STRING("1.1.1.1", s.getString(Setting::Dns2).c_str());
TEST_ASSERT_FALSE(s.getBool(Setting::DnsAlways));
TEST_ASSERT_EQUAL_STRING("pool.ntp.org", s.getString(Setting::Ntp1).c_str());
TEST_ASSERT_EQUAL_STRING("time.cloudflare.com", s.getString(Setting::Ntp2).c_str());
}
void test_dns_and_ntp_are_checked() {
MemoryStore store;
EventBus bus;
Settings s(store, bus);
s.load();
TEST_ASSERT_TRUE(s.setString(Setting::Dns1, "10.39.39.1"));
TEST_ASSERT_FALSE(s.setString(Setting::Dns1, "dns.example")); // an address, not a name
TEST_ASSERT_FALSE(s.setString(Setting::Dns1, "")); // the first one is required
TEST_ASSERT_EQUAL_STRING("10.39.39.1", s.getString(Setting::Dns1).c_str());
TEST_ASSERT_TRUE(s.setString(Setting::Dns2, "")); // the second is optional
TEST_ASSERT_FALSE(s.setString(Setting::Dns2, "1.1.1"));
TEST_ASSERT_TRUE(s.setString(Setting::Ntp1, "10.39.39.1"));
TEST_ASSERT_TRUE(s.setString(Setting::Ntp1, "0.be.pool.ntp.org"));
TEST_ASSERT_FALSE(s.setString(Setting::Ntp1, "time server"));
TEST_ASSERT_FALSE(s.setString(Setting::Ntp1, ""));
TEST_ASSERT_TRUE(s.setString(Setting::Ntp2, ""));
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_defaults_when_store_is_empty);
@@ -168,5 +199,7 @@ int main() {
RUN_TEST(test_change_publishes_setting_changed_once);
RUN_TEST(test_transmit_requires_confirmed_region);
RUN_TEST(test_storage_keys_fit_nvs_limit);
RUN_TEST(test_dns_and_ntp_defaults);
RUN_TEST(test_dns_and_ntp_are_checked);
return UNITY_END();
}