Files
MeshCore/test/fixtures/room_web_service/service.cpp
T
mikecarper 74bc91bf69 Improve room retention, moderation and local browser access
Add opt-in bounded chat history, persist explicit room ACL roles, enforce full-key bans and per-user quotas, and provide versioned information-board reads plus an ESP32 local room page. Preserve stored evidence on faults and withhold room delivery acknowledgments when a Companion cannot retain the message.

Exercise production handlers and filesystem fault recovery, pinned official-app JavaScript, real Chromium interactions, and representative ESP32, nRF52, RP2040 and STM32 builds. Add regression suites and bounded browser-workspace RAM reservations to CI.
2026-10-09 23:44:00 -07:00

280 lines
17 KiB
C++

#include "filesystem.h"
#include <cstdlib>
#include <new>
#include <array>
#include <ArduinoJson.h>
static bool deny_json_allocation = false;
static unsigned json_allocations = 0;
static void* trackedMalloc(size_t size) {
if (deny_json_allocation) return nullptr;
void* result = std::malloc(size); if (result) ++json_allocations; return result;
}
static void trackedFree(void* ptr) {
if (ptr) { assert(json_allocations != 0); --json_allocations; } std::free(ptr);
}
static void* trackedRealloc(void* ptr, size_t size) {
if (deny_json_allocation) return nullptr;
return std::realloc(ptr, size);
}
#define malloc trackedMalloc
#define free trackedFree
#define realloc trackedRealloc
#include <helpers/RoomWebJson.h>
#undef malloc
#undef free
#undef realloc
#include <helpers/RoomBoardStore.h>
#include <helpers/RoomHistoryStore.h>
#include <helpers/RoomAccessPolicy.h>
#include <helpers/RoomLoginAuthorization.h>
#define WITH_WEBCONFIG 1
#define PUB_KEY_SIZE 32
#define MAX_POST_TEXT_LEN 151
#define MAX_UNSYNCED_POSTS 32
#define POST_SYNC_DELAY_SECS 2
#define PUSH_NOTIFY_DELAY_MILLIS 200
#define MESH_DEBUG_PRINTLN(...) ((void)0)
#include "roles.inc"
static uint32_t ticks = 100;
static uint32_t millis() { return ticks; }
static uint32_t futureMillis(uint32_t duration) { return millis() + duration; }
namespace mesh {
struct Identity {
uint8_t pub_key[32] = {};
bool matches(const Identity& other) const { return memcmp(pub_key, other.pub_key, 32) == 0; }
};
struct Utils {
// Cryptography is a boundary mock: every fixture token has a distinct key.
static void sha256(uint8_t* out, size_t length, const uint8_t* token, size_t token_length,
const uint8_t* server, size_t server_length) {
assert(length == 32 && token_length == 32 && server_length == 32);
for (size_t i = 0; i < length; ++i) out[i] = token[i] ^ server[i] ^ 0x5a;
}
static void toHex(char* output, const uint8_t* key, size_t length) {
static const char digits[] = "0123456789abcdef";
for (size_t i = 0; i < length; ++i) { output[2 * i] = digits[key[i] >> 4]; output[2 * i + 1] = digits[key[i] & 15]; }
output[length * 2] = 0;
}
};
}
struct StrHelper {
static void strncpy(char* out, const char* text, size_t capacity) { std::snprintf(out, capacity, "%s", text); }
};
struct Preferences {
static bool value, fail_open, fail_write;
bool begin(const char* name, bool readonly) { assert(strcmp(name, "mesh-room") == 0 && !readonly); return !fail_open; }
size_t putBool(const char* key, bool next) { assert(strcmp(key, "web") == 0); if (fail_write) return 0; value = next; return sizeof(bool); }
bool getBool(const char* key, bool fallback) { (void)fallback; assert(strcmp(key, "web") == 0); return value; }
void end() {}
};
bool Preferences::value = false, Preferences::fail_open = false, Preferences::fail_write = false;
struct ClientInfo {
mesh::Identity id;
uint8_t permissions = PERM_ACL_ADMIN;
struct { struct { uint16_t post_quota_used = 0, poll_quota_used = 0; uint32_t last_post_timestamp = 0; } room; } extra;
};
struct ACL {
ClientInfo radio_admin;
unsigned lookups = 0;
int getNumClients() const { return 1; }
ClientInfo* getClientByIdx(int i) { assert(i == 0); return &radio_admin; }
ClientInfo* getClient(const mesh::Identity&) { ++lookups; return &radio_admin; }
};
struct Clock { uint32_t now = 1000; uint32_t getCurrentTimeUnique() { return now++; } };
#include "post.inc"
struct MyMesh {
MemoryFS storage; MemoryFS* _fs = &storage;
mesh::RoomAccessPolicy room_access;
mesh::Identity self_id;
struct { char password[65] = "admin-secret", guest_password[65] = "write-secret", node_name[64] = "Test room"; bool allow_read_only = true; } _prefs;
bool room_web_enabled = false;
bool room_web_request_authenticated = false;
uint32_t room_web_boot_id = 71;
#include "web_user.inc"
ACL acl;
Clock clock;
PostInfo posts[MAX_UNSYNCED_POSTS] = {};
int next_post_idx = 0;
uint32_t last_room_timestamp = 0, post_ready_at[MAX_UNSYNCED_POSTS] = {}, post_ready_mask = UINT32_MAX, next_push = 0;
uint16_t _num_posted = 0;
mesh::RoomHistoryState room_history_state;
bool room_history_enabled = false, room_history_available = true;
char room_topic[152] = "Topic <img src=x onerror=attack()>";
MyMesh() { self_id.pub_key[0] = 4; metadata_filesystem = &storage; assert(room_access.load(_fs)); }
Clock* getRTCClock() { return &clock; }
bool handleRoomWebCommand(const char*, char*);
void processRoomRequest(const uint8_t*, uint32_t, char*, char*, size_t);
void serviceRoomQuotas();
bool storePost(const mesh::Identity&, const char*);
bool snapshotRoomHistory();
};
#include "production.inc"
static std::array<uint8_t, 32> token(uint8_t id = 1) { std::array<uint8_t, 32> value = {}; value[0] = id; return value; }
static std::string execute(MyMesh& m, const std::string& input, uint32_t sequence,
uint8_t owner = 1, size_t capacity = 2048) {
metadata_filesystem = &m.storage;
std::vector<char> request(input.begin(), input.end()); request.push_back(0);
std::vector<char> reply(capacity + 2, 0x7f);
const auto key = token(owner);
m.processRoomRequest(key.data(), sequence, request.data(), reply.data() + 1, capacity);
assert(reply.front() == 0x7f && reply.back() == 0x7f && json_allocations == 0);
if (!capacity) return "";
assert(memchr(reply.data() + 1, 0, capacity)); return reply.data() + 1;
}
static JsonDocument result(MyMesh& m, const std::string& input, uint32_t sequence, uint8_t owner = 1) {
const auto text = execute(m, input, sequence, owner);
JsonDocument json; assert(deserializeJson(json, text) == DeserializationError::Ok); return json;
}
static std::string request(const char* op, const char* password = "write-secret", const std::string& fields = "") {
return std::string("{\"op\":\"") + op + "\",\"password\":\"" + password + "\"" + fields + "}";
}
static void error(MyMesh& m, const std::string& input, uint32_t sequence, const char* fragment, uint8_t owner = 1) {
const auto json = result(m, input, sequence, owner); const char* text = json["error"];
if (!text || !strstr(text, fragment)) fprintf(stderr, "Expected %s, got %s for %.120s\n", fragment, text ? text : "no error", input.c_str());
assert(text && strstr(text, fragment));
}
static std::string encoded(const std::string& body) {
std::vector<char> out(4 * ((body.size() + 2) / 3) + 1);
mesh::roomEncodeBase64(reinterpret_cast<const uint8_t*>(body.data()), body.size(), out.data()); return out.data();
}
static std::string saveRequest(uint8_t id, uint32_t version, const std::string& title, const std::string& body, const char* password = "admin-secret") {
return request("board.save", password, ",\"boot\":71,\"id\":" + std::to_string(id) + ",\"version\":" + std::to_string(version)
+ ",\"title\":\"" + title + "\",\"body64\":\"" + encoded(body) + "\"");
}
static void enable(MyMesh& m) { char reply[160]; assert(m.handleRoomWebCommand("set room.web on", reply)); assert(m.room_web_enabled); }
static unsigned floors(const MyMesh& m) { unsigned total = 0; for (const auto& user : m.room_web_users) total += user.last_sequence; return total; }
int main() {
unsigned scenarios = 0;
{
MyMesh m; assert(!m.room_web_enabled);
error(m, request("status"), 1, "disabled"); assert(floors(m) == 0);
char reply[160]; assert(m.handleRoomWebCommand("get room.web", reply) && strstr(reply, "off"));
Preferences::fail_write = true; assert(m.handleRoomWebCommand("set room.web on", reply) && !m.room_web_enabled);
Preferences::fail_write = false; enable(m);
assert(!m.handleRoomWebCommand("get room.webx", reply));
auto status = result(m, request("status"), 1); assert(status["role"] == PERM_ACL_READ_WRITE && status["boot"] == 71);
assert(strcmp(status["topic"], m.room_topic) == 0 && status["persistent_history"] == false);
status = result(m, request("status", "bad-password", ",\"key\":\"radio-admin\",\"permissions\":3"), 2);
assert(status["role"] == PERM_ACL_GUEST && m.acl.lookups == 0);
const std::string web_key = status["web_key"].as<std::string>(); assert(web_key.size() == 64);
error(m, request("post", "bad-password", ",\"boot\":71,\"name\":\"A\",\"text\":\"denied\""), 500, "read-only");
assert(floors(m) == 2 && m.last_room_timestamp == 0 && m._num_posted == 0);
assert(result(m, request("status", "admin-secret"), 3)["role"] == PERM_ACL_ADMIN); ++scenarios;
}
{
MyMesh m; enable(m); m._prefs.allow_read_only = false;
error(m, request("status", "wrong"), 100, "incorrect"); assert(floors(m) == 0 && !m.room_web_request_authenticated);
error(m, request("status"), 100, "invalid browser", 0); assert(floors(m) == 0 && !m.room_web_request_authenticated);
assert(result(m, request("status"), 1)["role"] == PERM_ACL_READ_WRITE);
error(m, request("post", "write-secret", ",\"boot\":70,\"name\":\"A\",\"text\":\"no\""), 200, "restarted");
assert(floors(m) == 1 && m._num_posted == 0);
error(m, saveRequest(1, 0, "Title", "body", "write-secret"), 300, "admin"); assert(floors(m) == 1);
const auto okay = result(m, request("post", "write-secret", ",\"boot\":71,\"name\":\"A\",\"text\":\"one\""), 2);
assert(okay["ok"] == true && m.last_room_timestamp == 1000 && m._num_posted == 1);
error(m, request("post", "write-secret", ",\"boot\":71,\"name\":\"A\",\"text\":\"one\""), 2, "completed");
assert(m._num_posted == 1 && m.acl.radio_admin.extra.room.last_post_timestamp == 0);
auto post = result(m, request("posts", "write-secret", ",\"after\":0"), 3);
assert(strcmp(post["post"]["text"], "A: one") == 0 && post["post"]["timestamp"] == 1000);
post = result(m, request("posts", "write-secret", ",\"after\":1000"), 4); assert(post["post"].isNull()); ++scenarios;
}
{
MyMesh m; enable(m); m.room_history_enabled = true;
const std::string text(148, 'x');
assert(result(m, request("post", "write-secret", ",\"boot\":71,\"name\":\"A\",\"text\":\"" + text + "\""), 1)["ok"] == true);
assert(strlen(m.posts[0].text) == 151 && m.room_history_state.count == 1);
error(m, request("post", "write-secret", ",\"boot\":71,\"name\":\"A\",\"text\":\"" + text + "x\""), 2, "151");
assert(m._num_posted == 1 && m.last_room_timestamp == 1000);
error(m, request("post", "write-secret", ",\"boot\":71,\"name\":\"A:B\",\"text\":\"x\""), 3, "display name");
m.storage.capacity = 0;
error(m, request("post", "write-secret", ",\"boot\":71,\"name\":\"A\",\"text\":\"retain\""), 4, "not retained");
assert(m._num_posted == 1 && m.last_room_timestamp == 1000 && m.room_history_state.count == 1);
m.storage.reset(); m.clock.now = 2;
assert(result(m, request("post", "write-secret", ",\"boot\":71,\"name\":\"A\",\"text\":\"retry with new sequence\""), 5)["timestamp"] == 1001);
assert(m._num_posted == 2 && m.room_history_state.count == 2); ++scenarios;
}
{
MyMesh m; enable(m); assert(m.room_access.setRates(m._fs, 1, 2));
assert(result(m, request("post", "write-secret", ",\"boot\":71,\"name\":\"A\",\"text\":\"one\""), 1)["ok"] == true);
error(m, request("post", "write-secret", ",\"boot\":71,\"name\":\"A\",\"text\":\"two\""), 2, "rate limit");
assert(m._num_posted == 1 && m.last_room_timestamp == 1000);
result(m, request("status"), 3); result(m, request("status"), 4); error(m, request("status"), 5, "rate limit");
ticks += 60000; result(m, request("status"), 6);
const auto key = token(); uint8_t author[32]; mesh::Utils::sha256(author, 32, key.data(), 32, m.self_id.pub_key, 32);
assert(m.room_access.addBan(m._fs, author) == mesh::RoomAccessPolicy::BanResult::Saved);
const unsigned floor = floors(m); error(m, request("status"), 1000, "denied"); assert(floors(m) == floor);
assert(m.room_access.removeBan(m._fs, author) == mesh::RoomAccessPolicy::BanResult::Saved);
result(m, request("status"), 7); ++scenarios;
}
{
MyMesh m; enable(m); const std::string title = u8"Café 地図 📡"; const std::string body(2048, 'b');
const auto saved = result(m, saveRequest(1, 0, title, body), 1);
if (saved["ok"] != true) fprintf(stderr, "Full board save error: %s\n", saved["error"].as<const char*>());
assert(saved["ok"] == true);
assert(saved["version"] == 1);
auto page = result(m, request("board.index", "", ",\"revision\":0,\"cursor\":0"), 2);
assert(page["revision"] == 1 && page["articles"].size() == 1 && page["next"] == 255);
assert(page["articles"][0]["length"] == 2048 && strcmp(page["articles"][0]["title"], title.c_str()) == 0);
std::string assembled;
for (unsigned offset = 0, sequence = 3; offset < 2048; ++sequence) {
auto chunk = result(m, request("board.read", "", ",\"id\":1,\"version\":1,\"offset\":" + std::to_string(offset)), sequence);
assert(chunk["version"] == 1 && chunk["offset"] == offset && chunk["total"] == 2048);
uint8_t bytes[128]; size_t length; assert(mesh::roomDecodeBase64(chunk["data64"], bytes, sizeof(bytes), length) && length > 0 && length <= 128);
assembled.append(reinterpret_cast<const char*>(bytes), length); offset += length; assert(chunk["next"] == offset);
}
assert(assembled == body);
const auto updated = result(m, saveRequest(1, 1, "Updated", "new body"), 30);
assert(updated["ok"] == true && updated["version"] == 2);
error(m, request("board.index", "", ",\"revision\":1,\"cursor\":0"), 31, "board changed");
error(m, request("board.read", "", ",\"id\":1,\"version\":1,\"offset\":128"), 32, "article changed");
error(m, saveRequest(1, 1, "Stale", "old"), 33, "article changed");
error(m, request("board.delete", "admin-secret", ",\"boot\":71,\"id\":1,\"version\":0"), 34, "current article version");
assert(result(m, request("board.delete", "admin-secret", ",\"boot\":71,\"id\":1,\"version\":2"), 35)["ok"] == true);
assert(result(m, saveRequest(1, 0, "Reused", "new"), 36)["ok"] == true);
error(m, request("board.read", "", ",\"id\":1,\"version\":2,\"offset\":0"), 37, "article changed"); ++scenarios;
}
{
MyMesh m; enable(m); uint32_t sequence = 1;
for (const std::string& input : {std::string("{"), std::string("[]"), std::string("null"), std::string("{\"op\":{\"a\":{\"b\":{\"c\":{\"d\":1}}}}}"),
std::string("{\"op\":\"status\",\"password\":\"write-secret\\u0000ignored\"}"), std::string("{\"op\":\"status\\u0000ignored\",\"password\":\"write-secret\"}"),
std::string("{\"op\":3,\"password\":[]}")}) {
const auto json = result(m, input, sequence++); assert(json["error"].is<const char*>() && floors(m) == 0);
}
error(m, std::string(4097, ' '), sequence++, "invalid request");
for (const std::string text : {"A===", "====", "Zg=A", "Zg==Zg==", "Zh==", "Zm9=", "*m9v", "Zg", "Zg== "}) {
error(m, request("board.save", "admin-secret", ",\"boot\":71,\"id\":1,\"version\":0,\"title\":\"T\",\"body64\":\"" + text + "\""), sequence++, "encoding");
}
error(m, request("post", "write-secret", ",\"boot\":71,\"name\":\"A\",\"text\":\"before\\u0000after\""), sequence++, "name and message");
error(m, request("post", "write-secret", ",\"boot\":71,\"name\":\"A\\u0000B\",\"text\":\"body\""), sequence++, "name and message");
error(m, request("board.save", "admin-secret", ",\"boot\":71,\"id\":1,\"version\":0,\"title\":\"T\\u0000X\",\"body64\":\"Zg==\""), sequence++, "article changed");
error(m, request("board.save", "admin-secret", ",\"boot\":71,\"id\":1,\"version\":0,\"title\":\"T\",\"body64\":\"Zg==\\u0000Zg==\""), sequence++, "article changed");
assert(m._num_posted == 0 && m.last_room_timestamp == 0);
error(m, saveRequest(1, 0, std::string(64, 't'), "body"), sequence++, "article changed");
error(m, saveRequest(1, 0, "Title", std::string(2049, 'b')), sequence++, "encoding");
assert(!m.storage.files.count(mesh::ROOM_BOARD_PRIMARY_PATH));
deny_json_allocation = true; error(m, request("status"), sequence++, "invalid JSON"); deny_json_allocation = false;
result(m, request("status"), sequence++);
// Excess unknown array members exhaust the actual bounded allocator.
std::string huge = "{\"op\":\"status\",\"password\":\"write-secret\",\"extra\":[";
for (unsigned i = 0; i < 1000; ++i) { if (i) huge += ','; huge += '0'; } huge += "]}";
error(m, huge, sequence++, "invalid JSON"); assert(json_allocations == 0); ++scenarios;
}
{
MyMesh m; enable(m);
for (uint8_t owner = 1; owner <= 8; ++owner) result(m, request("status"), 1, owner);
error(m, request("status"), 1, "busy", 9); ticks += 1200000;
result(m, request("status"), 1, 9);
// Every small response capacity stays within its provided buffer.
for (size_t capacity = 0; capacity < 200; ++capacity) execute(m, request("status"), uint32_t(capacity + 2), 9, capacity);
for (unsigned i = 0; i < 40; ++i) assert(result(m, request("post", "write-secret", ",\"boot\":71,\"name\":\"A\",\"text\":\"bounded ring\""), 300 + i, 9)["ok"] == true);
assert(m._num_posted == 40 && m.next_post_idx == 8 && m.last_room_timestamp == 1039); ++scenarios;
}
// Deliver concrete backend responses for the browser boundary fixture.
MyMesh boundary; enable(boundary);
printf("BOUNDARY_STATUS %s\n", execute(boundary, request("status"), 1).c_str());
assert(result(boundary, saveRequest(1, 0, "Instructions", "Read only this article"), 2)["ok"] == true);
printf("BOUNDARY_INDEX %s\n", execute(boundary, request("board.index", "write-secret", ",\"revision\":0,\"cursor\":0"), 3).c_str());
printf("BOUNDARY_READ %s\n", execute(boundary, request("board.read", "write-secret", ",\"id\":1,\"version\":1,\"offset\":0"), 4).c_str());
printf("PASS: %u actual room web service authorization, storage, quota, JSON and boundary scenarios\n", scenarios);
}