Files
MeshCore/test/fixtures/webconfig_recovery_auth.cpp
mikecarper 6b3f7df4d0 Suspend failed ESP32 MQTT and NTP uplinks for power saving
Share one bounded WiFi outage and recovery schedule across MQTT, automatic NTP, Companion station ownership and WebConfig. Preserve enabled settings and foreground access while allowing normal sleep after confirmed physical shutdown.

Honor failed SDK client stops before handing off WiFi, protect clean teardown and reconfiguration, and retry transient ESP-NOW channel restoration without repeated disconnects. Add production-method, SDK failure and real sleep regressions to CI.

Validation: native MQTT/NTP/clock/reconnect suites; production power and wrapper failure tests; browser, room HTTP, OTA and combined bridge regressions; Full Heltec V4 repeater, room and companion builds with capability, flash and linked-memory checks.
2026-10-12 14:57:01 -07:00

110 lines
5.8 KiB
C++

#include <cassert>
#include <cstdio>
#include <cstring>
#include <map>
#include <string>
#include <ArduinoJson.h>
struct String : std::string {
using std::string::string;
String(std::string value) : std::string(std::move(value)) {}
int indexOf(const char* value) const { auto pos=find(value); return pos==npos ? -1 : int(pos); }
String substring(int begin, int end) const { return substr(begin, end-begin); }
};
static uint32_t now_ms=10, random_word=0x12345678;
uint32_t millis() { return now_ms; }
uint32_t esp_random() { return ++random_word; }
static constexpr uint32_t WEBCONFIG_SESSION_TTL_MS=10000;
struct NodeSnapshot { char admin_password[16] = {}; };
struct Callback {
NodeSnapshot node;
void getNodeSnapshot(NodeSnapshot& result) { result=node; }
};
struct WCLock { explicit WCLock(int) {} };
struct WiFiMock { int softAPIP() const { return 4; } } WiFi;
struct Client { int local_ip=4; int localIP() const { return local_ip; } };
struct Header { String cookies; const String& value() const { return cookies; } };
struct AsyncWebServerResponse {
int status; std::map<std::string, std::string> headers;
void addHeader(const char* key, const char* value) { headers[key]=value; }
};
struct AsyncWebServerRequest {
Client socket; bool has_client=true;
Header header; std::string body; void* _tempObject=nullptr;
int status=0; AsyncWebServerResponse response{};
Client* client() { return has_client ? &socket : nullptr; }
bool hasHeader(const char*) const { return !header.cookies.empty(); }
Header* getHeader(const char*) { return &header; }
void send(int code, const char* =nullptr, const char* =nullptr) { status=code; }
AsyncWebServerResponse* beginResponse(int code, const char*, const char*) { response.status=code; return &response; }
void send(AsyncWebServerResponse* value) { status=value->status; }
void password(const char* value) { body=std::string("{\"password\":\"")+value+"\"}"; _tempObject=body.data(); }
};
struct WebConfigServer {
enum { MODE_OFF, MODE_SETUP, MODE_LAN };
int _mode=MODE_SETUP, _mux=0;
char _wifi_ssid[33]="SavedNet", _session_token[33] = {};
bool _initial_setup=false, config_accepted=false;
uint32_t _last_activity=0, _session_last_seen=0, _login_lock_until=0;
int _login_fails=0; Callback callback; Callback* _cb=&callback;
WebConfigServer() { strcpy(callback.node.admin_password, "existing-secret"); }
unsigned network_activity = 0;
void noteNetworkActivity() { ++network_activity; }
bool checkAuth(AsyncWebServerRequest*);
void handleLogin(AsyncWebServerRequest*);
void handleLogout(AsyncWebServerRequest*);
void checkConfigPost(AsyncWebServerRequest*);
};
@METHODS@
int main() {
WebConfigServer portal; AsyncWebServerRequest attacker;
attacker.body="{\"set\":{\"password\":\"attacker\",\"wifi.ssid\":\"EvilNet\"}}";
attacker._tempObject=attacker.body.data();
assert(!portal.checkAuth(&attacker));
assert(portal.network_activity==0);
portal.checkConfigPost(&attacker);
assert(attacker.status==401 && !portal.config_accepted);
attacker.password("wrong"); portal.handleLogin(&attacker);
assert(attacker.status==401 && portal._session_token[0]==0);
assert(portal.network_activity==0);
AsyncWebServerRequest admin; admin.password("existing-secret");
portal.handleLogin(&admin); assert(admin.status==200);
assert(portal.network_activity>0);
assert(strlen(portal._session_token)==32);
assert(admin.response.headers["Set-Cookie"].find("HttpOnly; SameSite=Lax")!=std::string::npos);
admin.header.cookies=std::string("wcs=")+portal._session_token;
assert(portal.checkAuth(&admin)); portal.checkConfigPost(&admin);
assert(portal.config_accepted);
std::string valid_token=portal._session_token;
portal.handleLogout(&attacker);
assert(attacker.status==401 && valid_token==portal._session_token);
admin.socket.local_ip=9;
assert(!portal.checkAuth(&admin));
admin.password("existing-secret"); portal.handleLogin(&admin);
assert(admin.status==401); // recovery must not authorize a recovered STA/LAN
admin.socket.local_ip=4;
admin.has_client=false; assert(!portal.checkAuth(&admin)); admin.has_client=true;
now_ms+=WEBCONFIG_SESSION_TTL_MS+1; assert(!portal.checkAuth(&admin));
admin.password("existing-secret"); portal.handleLogin(&admin); assert(admin.status==200);
admin.header.cookies=std::string("wcs=")+portal._session_token;
portal.handleLogout(&admin); assert(admin.status==200 && portal._session_token[0]==0);
assert(!portal.checkAuth(&admin));
portal._mode=WebConfigServer::MODE_LAN; admin.socket.local_ip=9;
admin.password("existing-secret"); portal.handleLogin(&admin); assert(admin.status==200);
admin.header.cookies=std::string("wcs=")+portal._session_token; assert(portal.checkAuth(&admin));
portal._mode=WebConfigServer::MODE_OFF; assert(!portal.checkAuth(&admin));
portal.handleLogin(&admin); assert(admin.status==503);
// First provisioning remains possible and retains its AP-only privilege
// through a save batch that changes SSID before the confirmation arrives.
portal._mode=WebConfigServer::MODE_SETUP; portal._wifi_ssid[0]=0;
portal._initial_setup=true; assert(portal.checkAuth(&attacker));
strcpy(portal._wifi_ssid, "ProvisionedNet"); assert(portal.checkAuth(&attacker));
portal.handleLogin(&attacker); assert(attacker.status==200);
attacker.socket.local_ip=9; assert(!portal.checkAuth(&attacker));
portal.handleLogin(&attacker); assert(attacker.status==401); attacker.socket.local_ip=4;
// Companion images deliberately have no administrator password or CLI
// password management. Keep that explicit no-password deployment policy.
portal._initial_setup=false; portal.callback.node.admin_password[0]=0;
assert(portal.checkAuth(&attacker)); portal.handleLogin(&attacker); assert(attacker.status==200);
puts("WebConfig recovery authentication regression checks passed");
}