diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 1bc7420ae..05218a994 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -122,6 +122,8 @@ jobs: python3 -B test/test_room_login_authorization.py -v python3 -B test/test_room_login_integration.py -v python3 -B test/test_room_client_paths.py -v + python3 -B test/test_room_catchup.py -v + python3 -B test/test_room_catchup_transport.py -v python3 -B test/test_room_topic_store.py -v python3 -B test/test_room_topic_delivery.py -v python3 -B test/test_room_acl_persistence.py -v diff --git a/README.md b/README.md index cd31e6013..d1cf4f4cf 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ * **[Flood retries that account for network topology](https://mikecarper.github.io/MeshCore/halo_keymind_settings/#flood-and-advert-settings).** Configure retry counts, path limits, packet-type limits, target prefixes, ignored repeaters, and channel-specific eligibility. Bridge buckets keep retrying until traffic is heard on the intended sides of a relay or the retry budget expires. Qualifying echoes cancel unnecessary attempts. * **[Enhanced direct messages and direct-route retries](https://mikecarper.github.io/MeshCore/halo_keymind_settings/#direct-retry-settings).** Adapt the coding rate across bounded retry attempts and cancel unnecessary retries when a qualifying echo is heard. [Single Key DMs](https://mikecarper.github.io/MeshCore/companion_protocol/#private-text-when-only-the-sender-knows-the-recipient) let compatible Companions send private text when only the sender knows the recipient's key, using a signed, encrypted introduction. The recipient accepts the sender through the app or opts into automatic acceptance; compact STM32WL builds omit this feature. * **[Enhanced telemetry history](https://mikecarper.github.io/MeshCore/telemetry_decoder/).** Send compact temperature and battery-voltage snapshots of up to 165 samples per packet, plus external-voltage history in chunks. Read retained GPS history through the CLI; GPS history is not included in the automatic telemetry packets. Decode snapshots and CLI history in the browser. -* **[Room topics, history, and access controls](https://mikecarper.github.io/MeshCore/room_services/).** Save an announcement and optionally retain the newest 32 chat posts across reboots. Explicit ACL roles persist; full-key bans and per-user post/request limits apply to room sessions. ESP32 images with WebConfig provide local Wi-Fi chat and a persistent information board with selected, resumable article downloads. The existing app's chat and catch-up formats remain supported. +* **[Room topics, history, and access controls](https://mikecarper.github.io/MeshCore/room_services/).** Save an announcement and optionally retain the newest 32 chat posts across reboots. Room users can manage their own return routes and skip older unread chat by date or keep the newest N messages. Explicit ACL roles persist; full-key bans and per-user post/request limits apply to room sessions. ESP32 images with WebConfig provide local Wi-Fi chat, a persistent information board, and an admin dashboard for all users' routes, delivery, activity, catch-up and access. The existing app's chat and catch-up formats remain supported. * **[Full firmware profiles, Companion transports, and ESP32 bridges](https://mikecarper.github.io/MeshCore/full_profiles/).** Full combines the exact board and role's supported features with runtime controls: Companion transports and firmware serving, or qualified ESP32 infrastructure services and MQTT/ESP-NOW/RS-232 bridges. Includes capacity exceptions, matching application slots, exact-board/role partition migration packages, and nRF52 Full/Reduced supported-sensor packages with LoRa OTA. The picker links supported migration routes; USB layout changes use the matching merged image, since an app-only image cannot change the partition table. * **[Operational reporting and alerts](https://mikecarper.github.io/MeshCore/management_reports/).** Adds optional management reports with firmware version, bootloader version, LoRa update capabilities, uptime, and historical voltage and temperature ranges. * **[Set time using the LoRa network](https://mikecarper.github.io/MeshCore/cli_commands/#estimate-and-correct-infrastructure-node-time-after-startup).** Infrastructure nodes can estimate time from fresh mesh evidence and correct their clocks when a consensus is available. Successful manual, GPS, or NTP synchronization takes precedence until reboot. diff --git a/docs/room_services.md b/docs/room_services.md index e5cc86916..ab8c21eff 100644 --- a/docs/room_services.md +++ b/docs/room_services.md @@ -160,6 +160,58 @@ changing a route never makes their permissions permanent. Failed saves restore the previous route and report an error. Repeating an unchanged selection or an exact request retry does not cause another settings write. +## Choose where chat catch-up starts + +Room readers, writers and administrators can skip older unread messages for +their own radio using the same encrypted CLI request framing as route commands: + +~~~text +get room.catchup +room.catchup keep 20 +room.catchup keep 0 +room.catchup before 2026-10-10 +room.catchup before 2026-10-10T18:30:00Z +room.catchup before 1791655200 +~~~ + +`keep 20` leaves the newest 20 unread retained messages to deliver and skips +the older backlog. `keep 0` skips all currently retained unread chat. Own posts +do not count toward unread messages. A date skips unread messages strictly +older than that cutoff; date-only values mean midnight UTC. A numeric cutoff +is a Unix timestamp in seconds. A count larger than the backlog is a no-op. + +Skipping advances only the requesting user's delivery cursor. It never deletes +room history, rewinds a cursor or suppresses another user's delivery. A future +date advances only through actual retained messages; new messages remain +deliverable. Pending delivery and retries for a skipped post are cancelled; +an outstanding topic remains pending. Delayed ACKs, routine keep-alives and +normal reconnects cannot restore the skipped backlog. A copy already queued +or transmitted can still arrive; skipping stops subsequent catch-up delivery. + +Saved ACL users persist an advanced catch-up cursor through the existing +filtered contacts store. Failed saves restore delivery state and report an +error. Transient sessions retain changes only for the current session. History +retention remains 32 posts; this command cannot recover older discarded chat. + +## Administrator controls for any radio user + +From a local console or authenticated administrator CLI session, use a user's +complete 64-character public key to edit either route or their catch-up position: + +~~~text +room.user outpath A1B2C3,D4E5F6 +room.user altpath 71CE82,BA09F0 +room.user altpath clear +room.user keep 20 +room.user before 2026-10-10T18:30:00Z +~~~ + +Names and shortened keys cannot select a user. Administrators can change any +existing radio user's routes without changing that user's role. Route and +catch-up persistence follows the same rules as the user's own commands. +The browser dashboard below provides activity, delivery and route details for +all radio users; these views require administrator credentials. + ## Local Wi-Fi chat on ESP32 ESP32 Room Server images with WebConfig have an offline browser room page. @@ -200,6 +252,42 @@ only admit a bounded request and return its owned result. No arbitrary CLI operation is exposed. Disable room access with `set room.web off`; stop the portal with `stop webconfig`. +### Wi-Fi room administration + +Connect to `/room` with the room administrator password and open **Room +administration**, or visit `http:///room#admin-panel`. +This management area offers: + +- Radio users in pages of two, with saved roles, time since activity, unread + retained posts, current delivery and failure counts. +- A selected user's primary, alternate and observed routes, catch-up cursor, + pending chat or topic, and remaining ACK wait. **Heard this boot** records + authenticated activity; it does not prove that a radio is currently reachable. +- Editing primary and alternate routes, keeping the newest N unread messages, + or skipping chat older than a chosen date. Browser date inputs use local time + and send the corresponding Unix timestamp. +- Assigning or removing saved access, blocking and unblocking full identities, + editing the topic, enabling persistent history, and changing post/poll limits. +- Optional refresh every 15 seconds. Unsaved route edits remain in place while + live status refreshes. + +The dashboard rechecks administrator credentials for each operation and +rejects a write from an earlier boot. Route, catch-up, access, topic and history +edits include the loaded value; a changed value requires a refresh instead of +silently overwriting someone else's change. Each setting saves independently. +Access changes use the existing delayed ACL save; the page reminds you to leave +power on for at least five seconds. Removing a saved assignment does not ban +guest-password access; use **Block** to deny that identity altogether. + +Readers and writers cannot retrieve these radio-user activity/delivery views +or management operations. Losing administrator access clears cached management +data from the page. User names, messages and routes are rendered as text. + +**Choose chat shown in this browser** filters the local display to the newest +N retained messages or a chosen start date. It changes only the browser view; +it does not move a radio user's cursor or delete posts. The administrator's +selected-user catch-up controls do change that radio user's delivery position. + ## Information board Eight persistent article slots have titles up to 63 UTF-8 bytes and bodies up diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 7ddad4715..85e4e5b4b 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -27,6 +27,7 @@ #include #include #include +#include #include #if MESH_PACKET_LOGGING #include @@ -267,6 +268,51 @@ bool MyMesh::handleRoomWebCommand(const char* command, char* reply) { return true; } +void MyMesh::writeRoomClientJson(mesh::RoomJsonWriter& json, ClientInfo* client, bool detail) { + char key[PUB_KEY_SIZE * 2 + 1]; + mesh::Utils::toHex(key, client->id.pub_key, PUB_KEY_SIZE); + const bool active = client->last_activity != 0 && room_access.allowsIdentity(client->id.pub_key); + const uint32_t clock = getRTCClock()->getCurrentTime(); + const char* delivery = client->extra.room.pending_ack == 0 ? "idle" + : client->extra.room.pending_topic_revision != 0 ? "topic" : "post"; + json.raw("{\"key\":"); json.string(key); + json.raw(",\"role\":"); json.number(client->permissions & PERM_ACL_ROLE_MASK); + json.raw(",\"retained\":"); json.raw(saveFilter(client) ? "true" : "false"); + json.raw(",\"active\":"); json.raw(active ? "true" : "false"); + json.raw(",\"banned\":"); json.raw(room_access.isBanned(client->id.pub_key) ? "true" : "false"); + json.raw(",\"heard_ago\":"); + if (client->last_activity == 0 || clock < client->last_activity) json.raw("null"); + else json.number(clock - client->last_activity); + json.raw(",\"pending_count\":"); json.number(getUnsyncedCount(client)); + json.raw(",\"delivery\":"); json.string(delivery); + json.raw(",\"failures\":"); json.number(std::max(client->extra.room.push_failures, + client->extra.room.topic_failures)); + if (detail) { + char route[157]; + mesh::formatRoomClientPathReply(client->out_path, client->out_path_len, route, sizeof(route)); + json.raw(",\"outpath\":"); json.string(route + 2); + mesh::formatRoomClientPathReply(client->alt_path, client->alt_path_len, route, sizeof(route)); + json.raw(",\"altpath\":"); json.string(route + 2); + mesh::formatRoomClientPathReply(client->observed_path, client->observed_path_len, route, sizeof(route)); + json.raw(",\"observed\":"); json.string(route + 2); + json.raw(",\"observed_pending\":"); + json.raw(mesh::isObservedClientPathPending(*client, futureMillis(0)) ? "true" : "false"); + json.raw(",\"sync_since\":"); json.number(client->extra.room.sync_since); + json.raw(",\"pending_post\":"); json.number(client->extra.room.pending_ack != 0 + && client->extra.room.pending_topic_revision == 0 ? client->extra.room.push_post_timestamp : 0); + json.raw(",\"topic_pending\":"); json.number(client->extra.room.pending_topic_revision); + json.raw(",\"topic_seen\":"); json.number(client->extra.room.topic_seen_revision); + json.raw(",\"ack_wait_ms\":"); + if (client->extra.room.pending_ack == 0) json.raw("null"); + else { + const int32_t remaining = int32_t(client->extra.room.ack_timeout - futureMillis(0)); + json.number(remaining > 0 ? uint32_t(remaining) : 0); + } + json.raw(",\"boot\":"); json.number(room_web_boot_id); + } + json.raw("}"); +} + static const char* roomWebString(JsonVariantConst value, size_t maximum) { if (!value.is()) return nullptr; const JsonString text = value.as(); @@ -305,8 +351,19 @@ void MyMesh::processRoomRequest(const uint8_t* token, uint32_t sequence, char* r if (!authorization.accepted) { error("incorrect room password"); return; } room_web_request_authenticated = true; const uint8_t role = authorization.role & PERM_ACL_ROLE_MASK; + const bool admin_operation = strncmp(operation, "admin.", 6) == 0; + if (admin_operation && role != PERM_ACL_ADMIN) { + error("admin permission required"); return; + } + const bool admin_read = strcmp(operation, "admin.users") == 0 + || strcmp(operation, "admin.user") == 0 || strcmp(operation, "admin.settings") == 0; + const bool admin_write = strcmp(operation, "admin.route") == 0 + || strcmp(operation, "admin.catchup") == 0 || strcmp(operation, "admin.access") == 0 + || strcmp(operation, "admin.ban") == 0 || strcmp(operation, "admin.topic") == 0 + || strcmp(operation, "admin.history") == 0 || strcmp(operation, "admin.rates") == 0; const bool writing = strcmp(operation, "post") == 0 - || strcmp(operation, "board.save") == 0 || strcmp(operation, "board.delete") == 0; + || strcmp(operation, "board.save") == 0 || strcmp(operation, "board.delete") == 0 + || admin_write; if (writing && (!input["boot"].is() || input["boot"].as() != room_web_boot_id)) { error("radio restarted; refresh before sending"); return; @@ -319,7 +376,9 @@ void MyMesh::processRoomRequest(const uint8_t* token, uint32_t sequence, char* r if (strcmp(operation, "status") != 0 && strcmp(operation, "posts") != 0 && strcmp(operation, "post") != 0 && strcmp(operation, "board.index") != 0 && strcmp(operation, "board.read") != 0 && strcmp(operation, "board.save") != 0 - && strcmp(operation, "board.delete") != 0) { error("unknown room operation"); return; } + && strcmp(operation, "board.delete") != 0 && !admin_read && !admin_write) { + error("unknown room operation"); return; + } const uint32_t now = millis(); RoomWebUser* user = nullptr; @@ -343,6 +402,148 @@ void MyMesh::processRoomRequest(const uint8_t* token, uint32_t sequence, char* r } char key[65]; mesh::Utils::toHex(key, author.pub_key, PUB_KEY_SIZE); mesh::RoomJsonWriter json(response, capacity); + if (admin_operation) { + if (strcmp(operation, "admin.settings") == 0) { + json.raw("{\"topic\":"); json.string(room_topic); + json.raw(",\"persistent_history\":"); json.raw(room_history_enabled ? "true" : "false"); + json.raw(",\"post_rate\":"); json.number(room_access.postRate()); + json.raw(",\"poll_rate\":"); json.number(room_access.pollRate()); + json.raw(",\"boot\":"); json.number(room_web_boot_id); + json.raw("}"); json.finish(); return; + } + if (strcmp(operation, "admin.users") == 0) { + if (!input["cursor"].is() || input["cursor"].as() > acl.getNumClients()) { + error("invalid user cursor; reload users"); return; + } + const uint16_t cursor = input["cursor"].as(); + uint16_t active = 0, pending = 0, failed = 0; + uint32_t backlog = 0; + for (int i = 0; i < acl.getNumClients(); ++i) { + ClientInfo* client = acl.getClientByIdx(i); + active += client->last_activity != 0 && room_access.allowsIdentity(client->id.pub_key); + backlog += getUnsyncedCount(client); + pending += client->extra.room.pending_ack != 0; + failed += client->extra.room.push_failures != 0 || client->extra.room.topic_failures != 0; + } + json.raw("{\"users\":["); + uint16_t next = cursor; + for (; next < acl.getNumClients() && next < cursor + 2; ++next) { + if (next != cursor) json.raw(","); + writeRoomClientJson(json, acl.getClientByIdx(next), false); + } + json.raw("],\"next\":"); + if (next < acl.getNumClients()) json.number(next); else json.raw("null"); + json.raw(",\"total\":"); json.number(acl.getNumClients()); + json.raw(",\"active\":"); json.number(active); + json.raw(",\"backlog\":"); json.number(backlog); + json.raw(",\"pending\":"); json.number(pending); + json.raw(",\"failed\":"); json.number(failed); + json.raw(",\"boot\":"); json.number(room_web_boot_id); + json.raw("}"); json.finish(); return; + } + char reply[157] = {}; + if (strcmp(operation, "admin.topic") == 0) { + const char* value = roomWebString(input["value"], MAX_POST_TEXT_LEN); + const char* expected = roomWebString(input["expected"], MAX_POST_TEXT_LEN); + if (!value || !expected) { error("topic and expected topic required"); return; } + if (strcmp(expected, room_topic) != 0) { error("topic changed; refresh before editing"); return; } + char command[MAX_POST_TEXT_LEN + 11]; + snprintf(command, sizeof(command), "set topic %s", value); + if (strcmp(value, room_topic) != 0) handleRoomTopicCommand(command, reply); + } else if (strcmp(operation, "admin.history") == 0) { + if (!input["enabled"].is() || !input["expected"].is()) { + error("history setting and expected setting required"); return; + } + if (input["expected"].as() != room_history_enabled) { + error("history setting changed; refresh before editing"); return; + } + if (input["enabled"].as() != room_history_enabled) { + handleRoomHistoryCommand(input["enabled"].as() ? "set room.history on" + : "set room.history off", reply); + } + } else if (strcmp(operation, "admin.rates") == 0) { + if (!input["post_rate"].is() || !input["poll_rate"].is()) { + error("rates must be 0-65535"); return; + } + if (!room_access.setRates(_fs, input["post_rate"].as(), input["poll_rate"].as())) { + error("rate save failed; unchanged"); return; + } + } else { + const char* target = roomWebString(input["key"], PUB_KEY_SIZE * 2); + uint8_t decoded[PUB_KEY_SIZE]; + if (!target || strlen(target) != PUB_KEY_SIZE * 2 + || !mesh::Utils::fromHex(decoded, sizeof(decoded), target)) { + error("complete 64-hex public key required"); return; + } + ClientInfo* client = acl.getClient(decoded, sizeof(decoded)); + if (strcmp(operation, "admin.ban") == 0) { + if (!input["banned"].is()) { error("ban setting required"); return; } + const bool banned = input["banned"].as(); + const auto result = banned ? room_access.addBan(_fs, decoded) : room_access.removeBan(_fs, decoded); + if (result != mesh::RoomAccessPolicy::BanResult::Saved + && result != mesh::RoomAccessPolicy::BanResult::Unchanged) { + error("ban save failed or ban list full; unchanged"); return; + } + } else { + if (!client) { error("unknown room user; reload users"); return; } + if (strcmp(operation, "admin.user") == 0) { + json.raw("{\"user\":"); writeRoomClientJson(json, client, true); + json.raw(",\"boot\":"); json.number(room_web_boot_id); + json.raw("}"); json.finish(); return; + } + if (strcmp(operation, "admin.route") == 0) { + const char* which = roomWebString(input["which"], 7); + const char* value = roomWebString(input["value"], 191); + const char* expected = roomWebString(input["expected"], 154); + if (!which || !value || !expected + || (strcmp(which, "outpath") != 0 && strcmp(which, "altpath") != 0)) { + error("path selection, value and expected path required"); return; + } + const bool alternate = strcmp(which, "altpath") == 0; + char current[157]; + mesh::formatRoomClientPathReply(alternate ? client->alt_path : client->out_path, + alternate ? client->alt_path_len : client->out_path_len, current, sizeof(current)); + if (strcmp(expected, current + 2) != 0) { error("path changed; refresh before editing"); return; } + setRoomClientPath(client, which, value, reply); + } else if (strcmp(operation, "admin.catchup") == 0) { + const char* mode = roomWebString(input["mode"], 6); + if (!input["expected_sync"].is() + || input["expected_sync"].as() != client->extra.room.sync_since) { + error("catch-up position changed; refresh before editing"); return; + } + if (!mode || (strcmp(mode, "before") != 0 && strcmp(mode, "keep") != 0) + || (strcmp(mode, "before") == 0 ? !input["before"].is() + : !input["count"].is())) { + error("use before timestamp or keep count"); return; + } + applyRoomCatchUpCommand(client, mode, strcmp(mode, "before") == 0 + ? input["before"].as() : input["count"].as(), reply); + } else if (strcmp(operation, "admin.access") == 0) { + if (!input["role"].is() || input["role"].as() > PERM_ACL_ADMIN + || !input["expected_role"].is() + || input["expected_role"].as() != (client->permissions & PERM_ACL_ROLE_MASK)) { + error("invalid role or access changed; refresh before editing"); return; + } + const uint8_t selected = input["role"].as(); + if (selected != (client->permissions & PERM_ACL_ROLE_MASK) + || !client->permissions_are_explicit || selected == PERM_ACL_GUEST) { + const uint8_t permissions = selected == PERM_ACL_GUEST ? 0 + : (client->permissions & ~PERM_ACL_ROLE_MASK) | selected; + if (!acl.applyPermissions(self_id, decoded, sizeof(decoded), permissions)) { + error("access change failed; unchanged"); return; + } + mesh::scheduleLazyPersistenceMutation(dirty_contacts_expiry, contacts_save_failures, + futureMillis(LAZY_CONTACTS_WRITE_DELAY)); + } + json.raw("{\"ok\":true,\"pending_save\":"); + json.raw(dirty_contacts_expiry != 0 ? "true" : "false"); + json.raw("}"); json.finish(); return; + } + } + } + if (strncmp(reply, "Err", 3) == 0) { error(reply); return; } + json.raw("{\"ok\":true}"); json.finish(); return; + } if (strcmp(operation, "status") == 0) { json.raw("{\"name\":"); json.string(_prefs.node_name); json.raw(",\"topic\":"); json.string(room_topic); @@ -507,6 +708,12 @@ bool MyMesh::handleClientPathCommand(ClientInfo* client, char* command, char* re strcpy(reply, "Err - room user permission required"); return true; } + return executeClientPathCommand(client, operation, body, reply); +} + +bool MyMesh::executeClientPathCommand(ClientInfo* client, + mesh::RoomClientPathCommand operation, + const char* body, char* reply) { const bool alternate = operation == mesh::RoomClientPathCommand::GetAlt || operation == mesh::RoomClientPathCommand::SetAlt; const bool observed = operation == mesh::RoomClientPathCommand::GetObserved @@ -582,6 +789,111 @@ bool MyMesh::handleClientPathCommand(ClientInfo* client, char* command, char* re return true; } +bool MyMesh::setRoomClientPath(ClientInfo* client, const char* which, + const char* value, char* reply) { + if (client == NULL || which == NULL || value == NULL + || (strcmp(which, "outpath") != 0 && strcmp(which, "altpath") != 0)) { + strcpy(reply, "Err - invalid client or path selection"); return false; + } + char command[224]; + const int length = snprintf(command, sizeof(command), "set %s %s", which, value); + if (length < 0 || size_t(length) >= sizeof(command)) { + strcpy(reply, "Err - path too long"); return false; + } + executeClientPathCommand(client, mesh::classifyRoomClientPathCommand(command), command, reply); + return strncmp(reply, "Err", 3) != 0; +} + +bool MyMesh::applyRoomCatchUpCommand(ClientInfo* client, const char* mode, + uint32_t value, char* reply) { + if (client == NULL || mode == NULL + || (strcmp(mode, "before") != 0 && strcmp(mode, "keep") != 0)) { + strcpy(reply, "Err - use before or keep "); return false; + } + const auto plan = mesh::planRoomCatchUp(*client, posts, MAX_UNSYNCED_POSTS, + strcmp(mode, "before") == 0 ? mesh::RoomCatchUpMode::Before + : mesh::RoomCatchUpMode::KeepNewest, value); + const auto previous = client->extra.room; + if (mesh::applyRoomCatchUpPlan(*client, plan) && saveFilter(client)) { + if (!acl.save(_fs, MyMesh::saveFilter)) { + client->extra.room = previous; + strcpy(reply, "Err - catch-up save failed; unchanged"); return false; + } + mesh::resetLazyPersistenceAfterSuccess(dirty_contacts_expiry, contacts_save_failures); + } + if (plan.pending_post_cancelled) { + uint8_t key[MAX_HASH_SIZE]; + mesh::roomDeliveryRetryKey(key, sizeof(key), self_id.pub_key, client->id.pub_key, + false, [](uint8_t* out, size_t size, const uint8_t* bytes, size_t length) { + mesh::Utils::sha256(out, size, bytes, length); + }); + cancelActiveMessageRetries(key, previous.push_post_timestamp); + } + snprintf(reply, 157, "OK - skipped=%u unread=%u since=%lu", unsigned(plan.skipped), + unsigned(plan.backlog_remaining), (unsigned long)plan.candidate_cursor); + return true; +} + +bool MyMesh::handleRoomCatchUpCommand(ClientInfo* client, char* command, char* reply) { + char* start = command; + while (*start == ' ' || *start == '\t') start++; + const bool prefix = strlen(start) > 4 && start[2] == '|'; + char* body = mesh::normalizeRoomClientPathCommand(command, reply); + if (strcmp(body, "get room.catchup") != 0 + && strncmp(body, "room.catchup ", 13) != 0) return false; + if (prefix) reply += 3; + if (client == NULL) { strcpy(reply, "Err - command needs remote client context"); return true; } + const uint8_t role = client->permissions & PERM_ACL_ROLE_MASK; + if (!(role == PERM_ACL_READ_ONLY || role == PERM_ACL_READ_WRITE + || role == PERM_ACL_ADMIN || (role == PERM_ACL_GUEST && client->last_activity != 0))) { + strcpy(reply, "Err - room user permission required"); return true; + } + if (strcmp(body, "get room.catchup") == 0) { + snprintf(reply, 157, "> since=%lu unread=%u", (unsigned long)client->extra.room.sync_since, + unsigned(getUnsyncedCount(client))); return true; + } + char* mode = body + 13; + char* value = strchr(mode, ' '); + uint32_t parsed = 0; + if (value == NULL) { strcpy(reply, "Err - use before or keep "); return true; } + *value++ = 0; + if (!(strcmp(mode, "before") == 0 ? mesh::parseRoomCatchUpBefore(value, parsed) + : mesh::parseRoomCatchUpCount(value, parsed))) { + strcpy(reply, "Err - invalid date or count"); return true; + } + applyRoomCatchUpCommand(client, mode, parsed, reply); + return true; +} + +bool MyMesh::handleRoomManagementCommand(char* command, char* reply) { + if (strncmp(command, "room.user ", 10) != 0) return false; + char* key = command + 10; + char* operation = strchr(key, ' '); + if (operation == NULL || size_t(operation - key) != PUB_KEY_SIZE * 2) { + strcpy(reply, "Err - complete 64-hex public key required"); return true; + } + *operation++ = 0; + uint8_t decoded[PUB_KEY_SIZE]; + if (!mesh::Utils::fromHex(decoded, sizeof(decoded), key)) { + strcpy(reply, "Err - invalid public key"); return true; + } + ClientInfo* client = acl.getClient(decoded, sizeof(decoded)); + if (client == NULL) { strcpy(reply, "Err - unknown room user"); return true; } + char* value = strchr(operation, ' '); + if (value == NULL) { strcpy(reply, "Err - route or catch-up value required"); return true; } + *value++ = 0; + if (strcmp(operation, "outpath") == 0 || strcmp(operation, "altpath") == 0) { + setRoomClientPath(client, operation, value, reply); + } else if (strcmp(operation, "before") == 0 || strcmp(operation, "keep") == 0) { + uint32_t parsed = 0; + if (!(strcmp(operation, "before") == 0 ? mesh::parseRoomCatchUpBefore(value, parsed) + : mesh::parseRoomCatchUpCount(value, parsed))) { + strcpy(reply, "Err - invalid date or count"); + } else applyRoomCatchUpCommand(client, operation, parsed, reply); + } else strcpy(reply, "Err - use outpath, altpath, before or keep"); + return true; +} + bool MyMesh::pushRoomTextToClient(ClientInfo *client, uint32_t timestamp, const mesh::Identity& author, const char* text, uint32_t topic_revision) { @@ -601,13 +913,12 @@ bool MyMesh::pushRoomTextToClient(ClientInfo *client, uint32_t timestamp, memcpy(&reply_data[len], text, text_len); len += text_len; - uint8_t message_participants[2 * PUB_KEY_SIZE]; - memcpy(message_participants, client->id.pub_key, PUB_KEY_SIZE); - memcpy(&message_participants[PUB_KEY_SIZE], author.pub_key, PUB_KEY_SIZE); uint8_t message_retry_key[MAX_HASH_SIZE]; - mesh::Utils::sha256(message_retry_key, sizeof(message_retry_key), - message_participants, sizeof(message_participants), - (const uint8_t*)text, text_len); + mesh::roomDeliveryRetryKey(message_retry_key, sizeof(message_retry_key), + self_id.pub_key, client->id.pub_key, topic_revision != 0, + [](uint8_t* out, size_t size, const uint8_t* bytes, size_t length) { + mesh::Utils::sha256(out, size, bytes, length); + }); // calc expected ACK reply mesh::Utils::sha256((uint8_t *)&client->extra.room.pending_ack, 4, reply_data, len, client->id.pub_key, PUB_KEY_SIZE); @@ -1348,14 +1659,16 @@ void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const m secret, sender_timestamp, getRTCClock()->getCurrentTime(), reset_out_path, OUT_PATH_UNKNOWN); mesh::clearObservedClientPath(*client, OUT_PATH_UNKNOWN); - client->extra.room.sync_since = sender_sync_since; + // A normal reconnect still advertises the companion's last received post. + // Preserve a server-side skip, including one restored from saved contacts. + client->extra.room.sync_since = std::max(previous_sync_since, sender_sync_since); client->extra.room.pending_ack = 0; client->extra.room.push_failures = 0; client->extra.room.topic_seen_revision = 0; client->extra.room.pending_topic_revision = 0; client->extra.room.topic_failures = 0; persisted_changed = persisted_changed - || previous_sync_since != sender_sync_since; + || previous_sync_since != client->extra.room.sync_since; if (mesh::successfulClientLoginNeedsPersistence( client_existed, previous_permissions, client->permissions, PERM_ACL_ROLE_MASK, PERM_ACL_GUEST, persisted_changed)) { @@ -1551,7 +1864,8 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, const char* body = mesh::normalizeRoomClientPathCommand(normalized, prefix); const uint8_t role = client->permissions & PERM_ACL_ROLE_MASK; const bool own_path_allowed = - mesh::classifyRoomClientPathCommand(body) != mesh::RoomClientPathCommand::None + (mesh::classifyRoomClientPathCommand(body) != mesh::RoomClientPathCommand::None + || strcmp(body, "get room.catchup") == 0 || strncmp(body, "room.catchup ", 13) == 0) && (role == PERM_ACL_READ_ONLY || role == PERM_ACL_READ_WRITE || role == PERM_ACL_ADMIN || (role == PERM_ACL_GUEST && client->last_activity != 0)); @@ -1571,7 +1885,8 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, MESH_DEBUG_PRINTLN("onPeerDataRecv: duplicate remote CLI request has no cached reply"); return; } else { - if (!handleClientPathCommand(client, (char*)text, (char*)&temp[5])) { + if (!handleClientPathCommand(client, (char*)text, (char*)&temp[5]) + && !handleRoomCatchUpCommand(client, (char*)text, (char*)&temp[5])) { if (client->isAdmin()) { handleCommand(sender_timestamp, (char*)text, (char*)&temp[5], i, packet->getPathHashSize()); @@ -1692,8 +2007,10 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, } else { memcpy(&data[5], &forceSince, 4); // make sure there are zeroes in payload (for ack_hash calc below) } - if (forceSince > 0) { - client->extra.room.sync_since = forceSince; // force-update the 'sync since' + if (forceSince > client->extra.room.sync_since) { + // The companion may still report its older received timestamp after + // a user/admin skip. A routine poll must not restore that backlog. + client->extra.room.sync_since = forceSince; } client->extra.room.pending_ack = 0; @@ -3215,6 +3532,7 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply mesh::cli::normalizeCommandVerb(command); + if (handleRoomManagementCommand(command, reply)) return; if (handleRoomTopicCommand(command, reply)) return; if (handleRoomHistoryCommand(command, reply)) return; if (room_access.handleConfig(_fs, command, reply, 157)) return; diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 6003a51c3..def6ed166 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -36,12 +36,15 @@ #include #include #include +#include #include #include "FloodRuleEngine.h" #include #include #include +namespace mesh { class RoomJsonWriter; } + #ifndef MESH_ENABLE_TELEMETRY_HISTORY // LoRa-E5-class STM32 room images do not have enough flash for history. #if defined(STM32_PLATFORM) @@ -599,6 +602,15 @@ public: int gpio_client_index = -1, uint8_t gpio_path_hash_size = 1); bool handleClientPathCommand(ClientInfo* client, char* command, char* reply); + bool executeClientPathCommand(ClientInfo* client, mesh::RoomClientPathCommand operation, + const char* body, char* reply); + bool setRoomClientPath(ClientInfo* client, const char* which, const char* value, char* reply); + bool handleRoomCatchUpCommand(ClientInfo* client, char* command, char* reply); + bool applyRoomCatchUpCommand(ClientInfo* client, const char* mode, uint32_t value, char* reply); + bool handleRoomManagementCommand(char* command, char* reply); +#ifdef WITH_WEBCONFIG + void writeRoomClientJson(mesh::RoomJsonWriter& json, ClientInfo* client, bool detail); +#endif bool sendClientReply(ClientInfo* client, mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size); bool _wireless_usb_command = false; diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 8e26fbb13..f538e2791 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -2299,6 +2299,29 @@ bool Mesh::cancelActiveRetries(const uint8_t retry_key[MAX_HASH_SIZE]) { return cancelled; } +bool Mesh::cancelActiveMessageRetries(const uint8_t message_key[MAX_HASH_SIZE], + uint32_t message_timestamp) { + if (message_key == NULL) return false; + uint8_t key[MAX_HASH_SIZE]; + memcpy(key, message_key, sizeof(key)); + bool cancelled = false; + for (int i = 0; i < TOTAL_DIRECT_RETRY_SLOTS; i++) { + if (_direct_retries[i].active && _direct_retries[i].has_message_replacement_key + && _direct_retries[i].message_timestamp == message_timestamp + && memcmp(key, _direct_retries[i].message_replacement_key, sizeof(key)) == 0) { + retireDirectRetrySlot(i); cancelled = true; + } + } + for (int i = 0; i < TOTAL_FLOOD_RETRY_SLOTS; i++) { + if (_flood_retries[i].active && _flood_retries[i].has_message_replacement_key + && _flood_retries[i].message_timestamp == message_timestamp + && memcmp(key, _flood_retries[i].message_replacement_key, sizeof(key)) == 0) { + retireFloodRetrySlot(i); cancelled = true; + } + } + return cancelled; +} + void Mesh::replaceActiveRetries(const Packet* replacement_packet, const uint8_t retry_key[MAX_HASH_SIZE]) { cancelActiveRetries(retry_key); diff --git a/src/Mesh.h b/src/Mesh.h index e3a1dc0a5..c60909352 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -323,6 +323,10 @@ protected: * \returns true if at least one active retry sequence was cancelled. */ bool cancelActiveRetries(const uint8_t retry_key[MAX_HASH_SIZE]); + // Cancel one locally identified message delivery across both radio profiles. + // This does not rely on the retained text or a short destination path hash. + bool cancelActiveMessageRetries(const uint8_t message_key[MAX_HASH_SIZE], + uint32_t message_timestamp); /** * \brief After a replacement packet is queued, retire the prior retry diff --git a/src/helpers/RoomCatchUp.h b/src/helpers/RoomCatchUp.h new file mode 100644 index 000000000..7f5f4529e --- /dev/null +++ b/src/helpers/RoomCatchUp.h @@ -0,0 +1,156 @@ +#pragma once + +#include +#include +#include + +namespace mesh { + +enum class RoomCatchUpMode : uint8_t { Before, SkipNext, KeepNewest }; + +struct RoomCatchUpPlan { + uint32_t candidate_cursor; + uint16_t skipped; + bool pending_post_cancelled; + uint16_t backlog_remaining; +}; + +inline bool parseRoomCatchUpCount(const char* input, uint32_t& result) { + if (input == nullptr || *input == 0) return false; + uint32_t value = 0; + for (const char* next = input; *next; ++next) { + if (*next < '0' || *next > '9') return false; + const uint32_t digit = static_cast(*next - '0'); + if (value > (UINT32_MAX - digit) / 10) return false; + value = value * 10 + digit; + } + result = value; + return true; +} + +inline bool parseRoomCatchUpDatePart(const char* input, size_t length, + uint32_t& value) { + value = 0; + for (size_t index = 0; index < length; ++index) { + if (input[index] < '0' || input[index] > '9') return false; + value = value * 10 + static_cast(input[index] - '0'); + } + return true; +} + +// UTC only. Date-only values denote midnight. A zero epoch is a valid no-op +// cutoff. No libc time conversion, timezone state, or allocation is needed. +inline bool parseRoomCatchUpBefore(const char* input, uint32_t& result) { + if (input == nullptr) return false; + const size_t length = strlen(input); + if ((length != 10 && length != 20) || input[4] != '-') { + return parseRoomCatchUpCount(input, result); + } + if (input[7] != '-') return false; + uint32_t year, month, day, hour = 0, minute = 0, second = 0; + if (!parseRoomCatchUpDatePart(input, 4, year) + || !parseRoomCatchUpDatePart(input + 5, 2, month) + || !parseRoomCatchUpDatePart(input + 8, 2, day)) return false; + if (length == 20 && (input[10] != 'T' || input[13] != ':' + || input[16] != ':' || input[19] != 'Z' + || !parseRoomCatchUpDatePart(input + 11, 2, hour) + || !parseRoomCatchUpDatePart(input + 14, 2, minute) + || !parseRoomCatchUpDatePart(input + 17, 2, second))) return false; + if (year < 1970 || year > 2106 || month == 0 || month > 12 + || hour > 23 || minute > 59 || second > 59) return false; + const bool leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); + static const uint8_t month_days[] = {31,28,31,30,31,30,31,31,30,31,30,31}; + const uint32_t limit = month_days[month - 1] + (month == 2 && leap ? 1 : 0); + if (day == 0 || day > limit) return false; + const uint32_t previous_year = year - 1; + uint32_t days = (year - 1970) * 365 + + (previous_year / 4 - previous_year / 100 + previous_year / 400) + - (1969 / 4 - 1969 / 100 + 1969 / 400); + for (uint32_t index = 0; index + 1 < month; ++index) { + days += month_days[index] + (index == 1 && leap ? 1 : 0); + } + days += day - 1; + const uint64_t timestamp = static_cast(days) * 86400 + + hour * 3600 + minute * 60 + second; + if (timestamp > UINT32_MAX) return false; + result = static_cast(timestamp); + return true; +} + +// Room retention is bounded to 32 posts. The cyclic array need not be in +// timestamp order. Own posts never count toward unread backlog or a skip count. +// Cursor selection uses retained timestamps only, never a requested future +// date or cursor+1 arithmetic near UINT32_MAX. +template +RoomCatchUpPlan planRoomCatchUp(const Client& client, const Post* posts, + size_t count, RoomCatchUpMode mode, + uint32_t value) { + const uint32_t floor = client.extra.room.sync_since; + RoomCatchUpPlan plan = {floor, 0, false, 0}; + if (posts == nullptr || count > 32) return plan; + uint16_t total = 0; + for (size_t index = 0; index < count; ++index) { + const auto& post = posts[index]; + if (post.post_timestamp > floor && !post.author.matches(client.id)) ++total; + } + if (mode == RoomCatchUpMode::Before) { + for (size_t index = 0; index < count; ++index) { + const auto& post = posts[index]; + if (post.post_timestamp > plan.candidate_cursor && post.post_timestamp < value + && !post.author.matches(client.id)) plan.candidate_cursor = post.post_timestamp; + } + } else if (mode == RoomCatchUpMode::SkipNext || mode == RoomCatchUpMode::KeepNewest) { + const uint32_t target = mode == RoomCatchUpMode::SkipNext + ? (value < total ? value : total) : (value < total ? total - value : 0); + while (plan.skipped < target) { + uint32_t next_timestamp = 0; + for (size_t index = 0; index < count; ++index) { + const auto& post = posts[index]; + if (post.post_timestamp > plan.candidate_cursor && !post.author.matches(client.id) + && (next_timestamp == 0 || post.post_timestamp < next_timestamp)) { + next_timestamp = post.post_timestamp; + } + } + if (next_timestamp == 0) break; + plan.candidate_cursor = next_timestamp; + plan.skipped = 0; + for (size_t index = 0; index < count; ++index) { + const auto& post = posts[index]; + if (post.post_timestamp > floor && post.post_timestamp <= plan.candidate_cursor + && !post.author.matches(client.id)) ++plan.skipped; + } + } + } + plan.skipped = 0; + for (size_t index = 0; index < count; ++index) { + const auto& post = posts[index]; + if (post.post_timestamp > floor && post.post_timestamp <= plan.candidate_cursor + && !post.author.matches(client.id)) ++plan.skipped; + } + plan.backlog_remaining = total - plan.skipped; + plan.pending_post_cancelled = plan.candidate_cursor > floor + && client.extra.room.pending_ack != 0 && client.extra.room.pending_topic_revision == 0 + && client.extra.room.push_post_timestamp != 0 + && client.extra.room.push_post_timestamp <= plan.candidate_cursor; + return plan; +} + +// The caller cancels the matching transport retry before/after applying this +// plan. A delayed post ACK cannot match once pending_ack is cleared. An +// outstanding topic or a newer post is rechecked and retained unchanged. +template +bool applyRoomCatchUpPlan(Client& client, const RoomCatchUpPlan& plan) { + if (plan.candidate_cursor <= client.extra.room.sync_since) return false; + client.extra.room.sync_since = plan.candidate_cursor; + client.extra.room.push_failures = 0; + if (plan.pending_post_cancelled && client.extra.room.pending_topic_revision == 0 + && client.extra.room.push_post_timestamp != 0 + && client.extra.room.push_post_timestamp <= plan.candidate_cursor) { + client.extra.room.pending_ack = 0; + client.extra.room.push_post_timestamp = 0; + client.extra.room.ack_timeout = 0; + } + return true; +} + +} // namespace mesh diff --git a/src/helpers/RoomClientPathCommand.h b/src/helpers/RoomClientPathCommand.h index 55276b1a0..8868687bd 100644 --- a/src/helpers/RoomClientPathCommand.h +++ b/src/helpers/RoomClientPathCommand.h @@ -37,6 +37,20 @@ inline RoomClientPathCommand classifyRoomClientPathCommand(const char* command) return RoomClientPathCommand::None; } +// One outstanding delivery per recipient, with separate namespaces for topic +// and chat. The complete room/recipient identities participate in the key. +template +inline void roomDeliveryRetryKey(uint8_t* output, size_t size, + const uint8_t* room, const uint8_t* recipient, + bool topic, Hash hash) { + const uint8_t domain[] = {'r','o','o','m', topic ? uint8_t(1) : uint8_t(0)}; + uint8_t material[5 + 64]; + memcpy(material, domain, sizeof(domain)); + memcpy(material + sizeof(domain), room, 32); + memcpy(material + sizeof(domain) + 32, recipient, 32); + hash(output, size, material, sizeof(material)); +} + inline void formatRoomClientPathReply(const uint8_t* path, uint8_t length, char* reply, size_t capacity) { if (length == 0xfe) { snprintf(reply, capacity, "> flood"); return; } diff --git a/src/helpers/esp32/RoomWebHtml.h b/src/helpers/esp32/RoomWebHtml.h index 1bc8a9497..73471d06a 100644 --- a/src/helpers/esp32/RoomWebHtml.h +++ b/src/helpers/esp32/RoomWebHtml.h @@ -8,13 +8,14 @@ static const char ROOM_WEB_HTML[] PROGMEM = R"roomhtml( MeshCore Room

MeshCore Room

Chat and selected notices over local Wi-Fi. This page also works without internet access.

@@ -27,24 +28,53 @@ details{margin:18px 0}summary{cursor:pointer}code{overflow-wrap:anywhere}a{color
Access and history

Your browser identity for room moderation:

This browser identity is separate from a radio's public key. The password stays in memory for this session.

-
+
+
Choose chat shown in this browser +

Filter the room's retained history in this browser. This changes only your local view and does not delete history or change a radio's delivery cursor.

+
+
+

Information board

+

Radio configuration

)roomhtml"; diff --git a/test/fixtures/room_catchup.cpp b/test/fixtures/room_catchup.cpp new file mode 100644 index 000000000..fa3366188 --- /dev/null +++ b/test/fixtures/room_catchup.cpp @@ -0,0 +1,211 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +@CONSTANTS@ +#define MESH_CLIENT_REPEATER_ONLY 0 +namespace mesh { +struct Identity { + uint8_t pub_key[PUB_KEY_SIZE]{}; + bool matches(const Identity& other) const { + return memcmp(pub_key, other.pub_key, PUB_KEY_SIZE) == 0; + } +}; +} +@CLIENT_INFO@; +@POST_INFO@; + +struct MyMesh { + struct ACL { + ClientInfo clients[2]{}; + int getNumClients() const { return 2; } + ClientInfo* getClientByIdx(int index) { return &clients[index]; } + } acl; + struct Access { + bool allowsIdentity(const uint8_t*) const { return true; } + } room_access; + bool processAck(const uint8_t*); +}; +@PROCESS_ACK@ + +static ClientInfo client(uint8_t identity = 1, uint32_t cursor = 0) { + ClientInfo value{}; + memset(value.id.pub_key, identity, PUB_KEY_SIZE); + value.permissions = PERM_ACL_READ_WRITE; + value.extra.room.sync_since = cursor; + value.extra.room.last_post_timestamp = 550; + value.extra.room.post_quota_used = 9; + value.extra.room.poll_quota_used = 11; + value.extra.room.topic_seen_revision = 7; + value.extra.room.topic_failures = 2; + return value; +} +static PostInfo post(uint32_t timestamp, uint8_t author = 2) { + PostInfo value{}; value.post_timestamp = timestamp; + memset(value.author.pub_key, author, PUB_KEY_SIZE); + strcpy(value.text, "post"); + return value; +} +static void validatePlan(const ClientInfo& value, const std::vector& posts, + mesh::RoomCatchUpMode mode, uint32_t argument, + uint32_t cursor, unsigned skipped, unsigned remaining) { + const ClientInfo before = value; + const auto plan = mesh::planRoomCatchUp(value, posts.data(), posts.size(), mode, argument); + assert(memcmp(&value, &before, sizeof(value)) == 0); + assert(plan.candidate_cursor == cursor && plan.skipped == skipped && plan.backlog_remaining == remaining); + assert(plan.candidate_cursor >= value.extra.room.sync_since); +} + +static void orderedCounts() { + const auto value = client(1, 100); + std::vector posts = {post(400), post(150, 1), post(0), post(50), post(300), post(200)}; + validatePlan(value, posts, mesh::RoomCatchUpMode::SkipNext, 0, 100, 0, 3); + validatePlan(value, posts, mesh::RoomCatchUpMode::SkipNext, 1, 200, 1, 2); + validatePlan(value, posts, mesh::RoomCatchUpMode::SkipNext, 2, 300, 2, 1); + validatePlan(value, posts, mesh::RoomCatchUpMode::SkipNext, UINT32_MAX, 400, 3, 0); + validatePlan(value, posts, mesh::RoomCatchUpMode::KeepNewest, 0, 400, 3, 0); + validatePlan(value, posts, mesh::RoomCatchUpMode::KeepNewest, 1, 300, 2, 1); + validatePlan(value, posts, mesh::RoomCatchUpMode::KeepNewest, 2, 200, 1, 2); + validatePlan(value, posts, mesh::RoomCatchUpMode::KeepNewest, UINT32_MAX, 100, 0, 3); + validatePlan(value, posts, mesh::RoomCatchUpMode::Before, 0, 100, 0, 3); + validatePlan(value, posts, mesh::RoomCatchUpMode::Before, 200, 100, 0, 3); + validatePlan(value, posts, mesh::RoomCatchUpMode::Before, 300, 200, 1, 2); + validatePlan(value, posts, mesh::RoomCatchUpMode::Before, UINT32_MAX, 400, 3, 0); + for (size_t rotation = 0; rotation < posts.size(); ++rotation) { + std::rotate(posts.begin(), posts.begin() + 1, posts.end()); + validatePlan(value, posts, mesh::RoomCatchUpMode::KeepNewest, 1, 300, 2, 1); + } + std::vector own = {post(200, 1), post(400, 1), post(0)}; + validatePlan(value, own, mesh::RoomCatchUpMode::KeepNewest, 0, 100, 0, 0); + puts("all modes order cyclic retention, exclude own posts, and bound future selections passed"); +} + +static void epochBoundaries() { + std::vector posts = {post(UINT32_MAX), post(UINT32_MAX - 1), post(1)}; + auto value = client(1, UINT32_MAX - 2); + validatePlan(value, posts, mesh::RoomCatchUpMode::SkipNext, UINT32_MAX, UINT32_MAX, 2, 0); + validatePlan(value, posts, mesh::RoomCatchUpMode::Before, UINT32_MAX, UINT32_MAX - 1, 1, 1); + value.extra.room.sync_since = UINT32_MAX; + validatePlan(value, posts, mesh::RoomCatchUpMode::KeepNewest, 0, UINT32_MAX, 0, 0); + std::vector empty; + validatePlan(value, empty, mesh::RoomCatchUpMode::KeepNewest, 0, UINT32_MAX, 0, 0); + value = client(1, 100); + const auto invalid = mesh::planRoomCatchUp(value, posts.data(), 33, mesh::RoomCatchUpMode::SkipNext, 5); + assert(invalid.candidate_cursor == 100 && invalid.skipped == 0 && !invalid.pending_post_cancelled); + // Corrupt equal timestamps cannot under-report the actual cursor effect. + std::vector duplicate = {post(200), post(200), post(300)}; + validatePlan(value, duplicate, mesh::RoomCatchUpMode::SkipNext, 1, 200, 2, 1); + puts("uint32 limits, empty retention, bounded input, and equal-timestamp accounting passed"); +} + +static void pendingDelivery() { + std::vector posts = {post(200), post(300), post(400)}; + for (bool topic : {false, true}) { + MyMesh mesh; mesh.acl.clients[0] = client(1, 100); mesh.acl.clients[1] = client(3, 50); + auto& own = mesh.acl.clients[0]; const ClientInfo other = mesh.acl.clients[1]; + own.extra.room.pending_ack = 0x55667788; own.extra.room.push_post_timestamp = 200; + own.extra.room.pending_topic_revision = topic ? 8 : 0; + own.extra.room.ack_timeout = 50000; own.extra.room.push_failures = 3; + const auto plan = mesh::planRoomCatchUp(own, posts.data(), posts.size(), mesh::RoomCatchUpMode::KeepNewest, 1); + assert(plan.pending_post_cancelled == !topic && plan.candidate_cursor == 300); + assert(mesh::applyRoomCatchUpPlan(own, plan)); + assert(own.extra.room.sync_since == 300 && own.extra.room.push_failures == 0); + assert(own.extra.room.last_post_timestamp == 550 && own.extra.room.post_quota_used == 9 + && own.extra.room.poll_quota_used == 11); + assert(own.extra.room.topic_seen_revision == 7 && own.extra.room.topic_failures == 2); + assert(memcmp(&other, &mesh.acl.clients[1], sizeof(other)) == 0); + const uint32_t old_ack = 0x55667788; + assert(mesh.processAck((const uint8_t*)&old_ack) == topic); + assert(own.extra.room.sync_since == 300); // Actual processAck cannot rewind after skip. + if (topic) assert(own.extra.room.topic_seen_revision == 8); + else assert(own.extra.room.pending_ack == 0 && own.extra.room.push_post_timestamp == 0 + && own.extra.room.ack_timeout == 0); + } + auto own = client(1, 100); own.extra.room.pending_ack = 55; own.extra.room.push_post_timestamp = 400; + own.extra.room.ack_timeout = 600; + const auto later = mesh::planRoomCatchUp(own, posts.data(), posts.size(), mesh::RoomCatchUpMode::KeepNewest, 1); + assert(!later.pending_post_cancelled && mesh::applyRoomCatchUpPlan(own, later)); + assert(own.extra.room.pending_ack == 55 && own.extra.room.push_post_timestamp == 400 + && own.extra.room.ack_timeout == 600); + const ClientInfo unchanged = own; + assert(!mesh::applyRoomCatchUpPlan(own, later) && memcmp(&own, &unchanged, sizeof(own)) == 0); + auto replaced = client(1, 100); replaced.extra.room.pending_ack = 55; + replaced.extra.room.push_post_timestamp = 200; + const auto old_plan = mesh::planRoomCatchUp(replaced, posts.data(), posts.size(), mesh::RoomCatchUpMode::KeepNewest, 1); + replaced.extra.room.pending_topic_revision = 10; replaced.extra.room.pending_ack = 77; + assert(mesh::applyRoomCatchUpPlan(replaced, old_plan)); + assert(replaced.extra.room.pending_topic_revision == 10 && replaced.extra.room.pending_ack == 77); + puts("actual client application clears skipped posts, preserves topics and quotas, and rejects stale ACKs passed"); +} + +static void dateCases() { + struct Date { const char* text; uint32_t expected; }; + const Date valid[] = { + @VALID_DATES@ + }; + for (const auto& date : valid) { + uint32_t result = 999; + assert(mesh::parseRoomCatchUpBefore(date.text, result) && result == date.expected); + } + const char* invalid[] = {"", "-1", "+1", "4294967296", "1969-12-31", "1970-00-01", "1970-13-01", + "1970-01-00", "2026-02-29", "2100-02-29", "2000-02-30", "2026-04-31", "2026-1-01", + "2026-01-01T24:00:00Z", "2026-01-01T00:60:00Z", "2026-01-01T00:00:60Z", "2026-01-01t00:00:00Z", + "2026-01-01T00:00:00z", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00", "2026-01-01 ", + "2106-02-07T06:28:16Z", "2106-02-08", "0000-01-01", "9999-01-01", "2026-01-01;erase", + "18446744073709551616", " 200", "200 ", "1.5"}; + for (const char* text : invalid) { + uint32_t result = 1234; + assert(!mesh::parseRoomCatchUpBefore(text, result) && result == 1234); + } + uint32_t result = 1234; + assert(!mesh::parseRoomCatchUpBefore(nullptr, result) && result == 1234); + assert(mesh::parseRoomCatchUpCount("0", result) && result == 0); + assert(mesh::parseRoomCatchUpCount("4294967295", result) && result == UINT32_MAX); + for (const char* text : {"-1", "+1", "1;erase", "1 2", "1x", "1.0", "4294967296", ""}) { + result = 1234; assert(!mesh::parseRoomCatchUpCount(text, result) && result == 1234); + } + puts("strict UTC Gregorian and uint32 parsing match independent epoch references passed"); +} + +static void randomizedPlans() { + uint32_t random = 0xABCDEF01; + for (unsigned iteration = 0; iteration < 5000; ++iteration) { + auto next = [&random]() { random ^= random << 13; random ^= random >> 17; random ^= random << 5; return random; }; + const uint32_t floor = next() % 300; + auto value = client(1, floor); + std::vector posts; std::vector unread; + const size_t count = next() % 33; + for (size_t index = 0; index < count; ++index) { + const uint32_t timestamp = (index + 1) * 20; + const uint8_t author = next() % 3 == 0 ? 1 : 2; + posts.push_back(post(timestamp, author)); + if (author != 1 && timestamp > floor) unread.push_back(timestamp); + } + for (size_t index = 0; index < posts.size(); ++index) std::swap(posts[index], posts[next() % posts.size()]); + for (auto mode : {mesh::RoomCatchUpMode::Before, mesh::RoomCatchUpMode::SkipNext, mesh::RoomCatchUpMode::KeepNewest}) { + const uint32_t argument = mode == mesh::RoomCatchUpMode::Before ? next() % 1000 : next() % 40; + size_t skipped; + if (mode == mesh::RoomCatchUpMode::Before) skipped = std::lower_bound(unread.begin(), unread.end(), argument) - unread.begin(); + else if (mode == mesh::RoomCatchUpMode::SkipNext) skipped = std::min(argument, unread.size()); + else skipped = argument < unread.size() ? unread.size() - argument : 0; + const uint32_t cursor = skipped ? unread[skipped - 1] : floor; + validatePlan(value, posts, mode, argument, cursor, skipped, unread.size() - skipped); + } + } + puts("15000 plans match independent ordered reference without mutating retention passed"); +} + +int main(int argc, char** argv) { + assert(argc == 2); + if (!strcmp(argv[1], "counts")) orderedCounts(); + else if (!strcmp(argv[1], "bounds")) epochBoundaries(); + else if (!strcmp(argv[1], "pending")) pendingDelivery(); + else if (!strcmp(argv[1], "dates")) dateCases(); + else if (!strcmp(argv[1], "random")) randomizedPlans(); + else assert(false); +} diff --git a/test/fixtures/room_client_paths.cpp b/test/fixtures/room_client_paths.cpp index a610bf6f9..88ee49c7a 100644 --- a/test/fixtures/room_client_paths.cpp +++ b/test/fixtures/room_client_paths.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -21,8 +22,26 @@ @STR_HELPER@ #define MESH_CLIENT_REPEATER_ONLY 0 namespace mesh { -struct Identity { uint8_t pub_key[PUB_KEY_SIZE]; }; +struct Identity { + uint8_t pub_key[PUB_KEY_SIZE]; + bool matches(const Identity& other) const { + return memcmp(pub_key, other.pub_key, PUB_KEY_SIZE) == 0; + } +}; struct Utils { + static bool fromHex(uint8_t* out, size_t n, const char* text) { + if (strlen(text) != n * 2) return false; + for (size_t index = 0; index < n; ++index) { + const int high = cli::recentRepeaterHexNibble(text[index * 2]); + const int low = cli::recentRepeaterHexNibble(text[index * 2 + 1]); + if (high < 0 || low < 0) return false; + out[index] = (high << 4) | low; + } + return true; + } + static void sha256(uint8_t* out, size_t n, const uint8_t* data, size_t length) { + sha256(out, n, data, length, nullptr, 0); + } static void sha256(uint8_t* out, size_t n, const uint8_t* left, size_t ln, const uint8_t* right, size_t rn) { memset(out, 0, n); @@ -33,6 +52,7 @@ struct Utils { @PACKET_METHODS@ } @CLIENT_INFO@; +@POST_INFO@; struct FakeACL { ClientInfo clients[2]{}; @@ -42,6 +62,13 @@ struct FakeACL { unsigned saves = 0; int getNumClients() const { return 2; } ClientInfo* getClientByIdx(int i) { assert(i >= 0 && i < 2); return &clients[i]; } + ClientInfo* getClient(const uint8_t* key, size_t length) { + assert(length == PUB_KEY_SIZE); + for (auto& client : clients) { + if (memcmp(client.id.pub_key, key, length) == 0) return &client; + } + return nullptr; + } bool save(MemoryFS* fs, bool (*filter)(ClientInfo*)) { ++saves; assert(fs != nullptr && filter != nullptr); @@ -74,6 +101,7 @@ struct Sent { }; struct MyMesh { FakeACL acl; + mesh::Identity self_id{}; MemoryFS policy_fs; MemoryFS* _fs = &policy_fs; mesh::RoomAccessPolicy room_access; @@ -89,7 +117,11 @@ struct MyMesh { mesh::Packet pool[64]; unsigned used = 0, allocations = 0, releases = 0, fail_allocation = UINT32_MAX; bool send_ok = true; - unsigned general_commands = 0, posts = 0, retry_replacements = 0, _num_post_pushes = 0; + unsigned general_commands = 0, post_count = 0, retry_replacements = 0, _num_post_pushes = 0; + PostInfo posts[MAX_UNSYNCED_POSTS]{}; + unsigned message_cancellations = 0; + uint32_t cancelled_timestamp = 0; + uint8_t cancelled_key[MAX_HASH_SIZE]{}; uint32_t retry_timestamp = 0; mesh::Packet retry_packet; std::vector sent; @@ -162,16 +194,27 @@ struct MyMesh { void replaceActiveMessageRetries(mesh::Packet* p, const uint8_t*, uint32_t timestamp) { ++retry_replacements; retry_packet = *p; retry_timestamp = timestamp; } - void handleCommand(uint32_t, char*, char* reply, int, uint8_t) { - ++general_commands; strcpy(reply, "OK admin"); + void handleCommand(uint32_t, char* command, char* reply, int, uint8_t) { + ++general_commands; + if (handleRoomManagementCommand(command, reply)) return; + strcpy(reply, "OK admin"); } - bool addPost(ClientInfo*, const char*) { ++posts; return true; } + bool addPost(ClientInfo*, const char*) { ++post_count; return true; } void serviceRoomQuotas(); - uint8_t getUnsyncedCount(ClientInfo*) { return 0; } + uint8_t getUnsyncedCount(ClientInfo*); + bool cancelActiveMessageRetries(const uint8_t* key, uint32_t timestamp) { + ++message_cancellations; cancelled_timestamp = timestamp; + memcpy(cancelled_key, key, MAX_HASH_SIZE); return true; + } int getExtraAckTransmitCount() { return 0; } int handleRequest(ClientInfo*, uint32_t, uint8_t*, size_t, size_t) { return 0; } static bool saveFilter(ClientInfo*); bool handleClientPathCommand(ClientInfo*, char*, char*); + bool executeClientPathCommand(ClientInfo*, mesh::RoomClientPathCommand, const char*, char*); + bool handleRoomCatchUpCommand(ClientInfo*, char*, char*); + bool applyRoomCatchUpCommand(ClientInfo*, const char*, uint32_t, char*); + bool setRoomClientPath(ClientInfo*, const char*, const char*, char*); + bool handleRoomManagementCommand(char*, char*); bool sendClientReply(ClientInfo*, mesh::Packet*, unsigned long, uint8_t); bool pushRoomTextToClient(ClientInfo*, uint32_t, const mesh::Identity&, const char*, uint32_t); void onPeerDataRecv(mesh::Packet*, uint8_t, int, const uint8_t*, uint8_t*, size_t); @@ -191,6 +234,13 @@ struct MyMesh { const auto& p = sent.back().packet; return std::string((const char*)p.payload + 5, p.payload_len - 5); } + void poll(uint32_t timestamp, uint32_t since, unsigned sender = 0) { + uint8_t data[10]{}; + memcpy(data, ×tamp, 4); data[4] = REQ_TYPE_KEEP_ALIVE; + memcpy(data + 5, &since, 4); + mesh::Packet packet; packet.header = ROUTE_TYPE_DIRECT; + onPeerDataRecv(&packet, PAYLOAD_TYPE_REQ, sender, acl.clients[sender].shared_secret, data, 9); + } }; @PRODUCTION_METHODS@ @TEST_CASES@ diff --git a/test/fixtures/room_topic_delivery/test.cpp b/test/fixtures/room_topic_delivery/test.cpp index b64b6b44d..772ea526c 100644 --- a/test/fixtures/room_topic_delivery/test.cpp +++ b/test/fixtures/room_topic_delivery/test.cpp @@ -7,6 +7,7 @@ #include "../room_history_store/filesystem.h" #include #include +#include #define PUB_KEY_SIZE 32 #define MAX_PATH_SIZE 64 @@ -43,6 +44,9 @@ struct Packet { static bool isValidPathLen(uint8_t path); }; struct Utils { + static void sha256(uint8_t* out, size_t n, const uint8_t* a, size_t na) { + sha256(out, n, a, na, nullptr, 0); + } static void sha256(uint8_t* out, size_t n, const uint8_t* a, size_t na, const uint8_t* b, size_t nb) { // Deterministic fixture fingerprint; no cryptography is replaced in firmware. diff --git a/test/fixtures/room_web_service/service.cpp b/test/fixtures/room_web_service/service.cpp index ec72081e6..29d667ce9 100644 --- a/test/fixtures/room_web_service/service.cpp +++ b/test/fixtures/room_web_service/service.cpp @@ -27,8 +27,17 @@ static void* trackedRealloc(void* ptr, size_t size) { #include #include #include +#include +#include +#include +#include +#include +#include #define WITH_WEBCONFIG 1 #define PUB_KEY_SIZE 32 +#define MAX_PATH_SIZE 64 +#define MAX_HASH_SIZE 8 +#define MESH_CLIENT_REPEATER_ONLY 0 #define MAX_POST_TEXT_LEN 151 #define MAX_UNSYNCED_POSTS 32 #define POST_SYNC_DELAY_SECS 2 @@ -39,11 +48,21 @@ static uint32_t ticks = 100; static uint32_t millis() { return ticks; } static uint32_t futureMillis(uint32_t duration) { return millis() + duration; } namespace mesh { +struct Packet { static bool isValidPathLen(uint8_t length); }; struct Identity { - uint8_t pub_key[32] = {}; + uint8_t pub_key[32]; + Identity() = default; + explicit Identity(const uint8_t* key) { memcpy(pub_key, key, PUB_KEY_SIZE); } bool matches(const Identity& other) const { return memcmp(pub_key, other.pub_key, 32) == 0; } }; +struct LocalIdentity : Identity { + void calcSharedSecret(uint8_t* output, const uint8_t* key) const { + for (size_t i = 0; i < PUB_KEY_SIZE; ++i) output[i] = key[i] ^ pub_key[i]; + } +}; struct Utils { + static bool isHexChar(char); + static bool fromHex(uint8_t*, int, const char*); // 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) { @@ -55,6 +74,10 @@ struct Utils { 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; } + static void sha256(uint8_t* output, size_t size, const uint8_t* bytes, size_t length) { + memset(output, 0, size); + for (size_t i = 0; i < length; ++i) output[i % size] ^= bytes[i]; + } }; } struct StrHelper { @@ -68,24 +91,49 @@ struct Preferences { 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; -}; +#include "client.inc" 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; } + ClientInfo clients[8]{}; + ClientInfo durable[8]{}; + bool retained[8]{}; + ClientInfo& radio_admin = clients[0]; + unsigned lookups = 0, saves = 0; + int count = 1; + int& num_clients = count; + static constexpr int capacity = 8; + bool acl_load_complete = true, protect_explicit_permissions = true; + bool save_ok = true; + int getNumClients() const { return count; } + ClientInfo* getClientByIdx(int i) { assert(i >= 0 && i < count); return &clients[i]; } + ClientInfo* getClient(const uint8_t* key, int length) { + ++lookups; assert(length == PUB_KEY_SIZE); + for (int i = 0; i < count; ++i) { + if (memcmp(clients[i].id.pub_key, key, PUB_KEY_SIZE) == 0) return &clients[i]; + } + return nullptr; + } + bool save(MemoryFS* fs, bool (*filter)(ClientInfo*)) { + assert(fs && filter); ++saves; + if (!save_ok) return false; + for (int i = 0; i < count; ++i) { + retained[i] = (clients[i].permissions & PERM_ACL_ROLE_MASK) != PERM_ACL_GUEST && filter(&clients[i]); + if (retained[i]) durable[i] = clients[i]; + } + return true; + } + ClientInfo* putClient(const mesh::Identity& id, uint8_t permissions); + bool applyPermissions(const mesh::LocalIdentity& self, const uint8_t* key, int length, uint8_t permissions); +}; +struct Clock { + uint32_t now = 1000; + uint32_t getCurrentTime() const { return now; } + uint32_t getCurrentTimeUnique() { return now++; } }; -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; + mesh::LocalIdentity 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; @@ -100,13 +148,39 @@ struct MyMesh { mesh::RoomHistoryState room_history_state; bool room_history_enabled = false, room_history_available = true; char room_topic[152] = "Topic "; - MyMesh() { self_id.pub_key[0] = 4; metadata_filesystem = &storage; assert(room_access.load(_fs)); } + unsigned long dirty_contacts_expiry = 0; + uint8_t contacts_save_failures = 0; + uint32_t room_topic_revision = 0, room_topic_timestamp = 0, room_topic_ready_at = 0; + bool room_topic_ready = false; + struct { unsigned clears = 0; void clear() { ++clears; } } recent_room_posts; + unsigned retry_cancellations = 0; + uint32_t cancelled_retry_timestamp = 0; + MyMesh() { + self_id.pub_key[0] = 4; metadata_filesystem = &storage; assert(room_access.load(_fs)); + acl.radio_admin.permissions = PERM_ACL_ADMIN; + for (auto& client : acl.clients) { + client.out_path_len = client.alt_path_len = client.observed_path_len = OUT_PATH_UNKNOWN; + client.out_path_is_persistable = true; + } + } 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(); + bool executeClientPathCommand(ClientInfo*, mesh::RoomClientPathCommand, const char*, char*); + bool setRoomClientPath(ClientInfo*, const char*, const char*, char*); + bool applyRoomCatchUpCommand(ClientInfo*, const char*, uint32_t, char*); + static bool saveFilter(ClientInfo*); + uint8_t getUnsyncedCount(ClientInfo*); + bool handleRoomHistoryCommand(const char*, char*); + bool handleRoomTopicCommand(const char*, char*); + void activateRoomTopic(); + void writeRoomClientJson(mesh::RoomJsonWriter&, ClientInfo*, bool); + void cancelActiveMessageRetries(const uint8_t*, uint32_t timestamp) { + ++retry_cancellations; cancelled_retry_timestamp = timestamp; + } }; #include "production.inc" @@ -144,6 +218,371 @@ static std::string saveRequest(uint8_t id, uint32_t version, const std::string& } 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; } +static std::string clientKey(const ClientInfo& client) { + char key[65]; mesh::Utils::toHex(key, client.id.pub_key, PUB_KEY_SIZE); return key; +} +static void seedClients(MyMesh& m, unsigned count = 5) { + assert(count <= 8); m.acl.count = count; + for (unsigned i = 0; i < count; ++i) { + auto& client = m.acl.clients[i]; + memset(client.id.pub_key, i + 1, PUB_KEY_SIZE); + client.permissions = i == 0 ? PERM_ACL_ADMIN : PERM_ACL_READ_WRITE; + client.permissions_are_explicit = i < 2; + client.last_activity = 900 + i; + client.out_path_len = 1; client.out_path[0] = 0xA1 + i; + client.out_path_is_persistable = true; + client.alt_path_len = OUT_PATH_UNKNOWN; + client.extra.room.sync_since = 100 + i; + } +} +static void adminAuthorizationCases() { + const char* operations[] = {"admin.users", "admin.user", "admin.route", "admin.catchup", + "admin.access", "admin.ban", "admin.settings", "admin.topic", "admin.history", "admin.rates"}; + unsigned denied = 0; + for (const char* password : {"", "wrong-password", "write-secret"}) { + for (const char* operation : operations) { + MyMesh m; enable(m); seedClients(m); + const std::vector before(m.acl.clients, m.acl.clients + m.acl.count); + const unsigned writes = m.storage.write_opens; + const auto reply = result(m, request(operation, password, + ",\"boot\":71,\"cursor\":0,\"key\":\"" + clientKey(m.acl.clients[1]) + + "\",\"which\":\"outpath\",\"value\":\"B1\",\"expected\":\"> A2\"," + "\"mode\":\"keep\",\"count\":1,\"expected_sync\":101," + "\"role\":3,\"expected_role\":2,\"banned\":true"), 999); + assert(reply["error"].is() && strstr(reply["error"].as(), "admin")); + assert(reply.as().size() == 1); + assert(floors(m) == 0 && m.acl.lookups == 0 && m.acl.saves == 0); + assert(m.storage.write_opens == writes && m.room_access.banCount() == 0); + assert(memcmp(before.data(), m.acl.clients, before.size() * sizeof(ClientInfo)) == 0); + assert(m._num_posted == 0 && m.last_room_timestamp == 0); + ++denied; + } + } + MyMesh closed; enable(closed); closed._prefs.allow_read_only = false; + error(closed, request("admin.users", "wrong-password", ",\"cursor\":0"), 100, "incorrect"); + assert(floors(closed) == 0 && !closed.room_web_request_authenticated && closed.acl.lookups == 0); + MyMesh empty; enable(empty); empty._prefs.password[0] = 0; + error(empty, request("admin.users", "admin-secret", ",\"cursor\":0"), 100, "admin"); + assert(floors(empty) == 0 && empty.acl.lookups == 0); + printf("ADMIN_AUTH: %u rejected low-trust operations leave state and identity data unchanged\n", denied + 2); +} +static std::string adminClientRequest(const char* operation, const ClientInfo& client, + const std::string& fields = "", uint32_t boot = 71) { + return request(operation, "admin-secret", ",\"boot\":" + std::to_string(boot) + + ",\"key\":\"" + clientKey(client) + "\"" + fields); +} +static std::string routeFields(const char* which, const char* value, const char* expected) { + if (strncmp(expected, "> ", 2) == 0) expected += 2; + return std::string(",\"which\":\"") + which + "\",\"value\":\"" + value + + "\",\"expected\":\"" + expected + "\""; +} +static void adminRouteCases() { + MyMesh m; enable(m); seedClients(m); + auto& retained = m.acl.clients[1]; + const ClientInfo other = m.acl.clients[0]; + uint32_t sequence = 1; + error(m, adminClientRequest("admin.route", retained, + routeFields("outpath", "B1", "> A2"), 70), sequence++, "restarted"); + assert(m.acl.saves == 0 && floors(m) == 0); + assert(result(m, adminClientRequest("admin.route", retained, + routeFields("outpath", "B1", "> A2")), sequence++)["ok"] == true); + assert(retained.out_path_len == 1 && retained.out_path[0] == 0xB1 && m.acl.saves == 1); + assert(m.acl.retained[1] && !m.acl.retained[2]); + assert(memcmp(&other, &m.acl.clients[0], sizeof(other)) == 0); + assert(retained.permissions == PERM_ACL_READ_WRITE && retained.permissions_are_explicit); + assert(result(m, adminClientRequest("admin.route", retained, + routeFields("outpath", "B1", "> B1")), sequence++)["ok"] == true); + assert(m.acl.saves == 1); + const ClientInfo selected = retained; + const auto stale = result(m, adminClientRequest("admin.route", retained, + routeFields("outpath", "C1", "> A2")), sequence++); + assert(stale["error"].is() && m.acl.saves == 1); + assert(memcmp(&retained, &selected, sizeof(selected)) == 0); + for (const char* value : {"B1,", "B1,,B2", "B1,1234", "direct;erase", "get prv.key", "path"}) { + const auto invalid = result(m, adminClientRequest("admin.route", retained, + routeFields("outpath", value, "> B1")), sequence++); + assert(invalid["error"].is()); + assert(memcmp(&retained, &selected, sizeof(selected)) == 0 && m.acl.saves == 1); + } + auto& transient = m.acl.clients[2]; + assert(result(m, adminClientRequest("admin.route", transient, + routeFields("altpath", "C1,C2", "> unknown")), sequence++)["ok"] == true); + assert(transient.alt_path_len == 2 && transient.alt_path[0] == 0xC1 && m.acl.saves == 1); + assert(transient.permissions == PERM_ACL_READ_WRITE && !transient.permissions_are_explicit); + m.acl.save_ok = false; + m.dirty_contacts_expiry = 50000; m.contacts_save_failures = 3; + error(m, adminClientRequest("admin.route", retained, + routeFields("altpath", "D1", "> unknown")), sequence++, "save failed"); + assert(m.acl.saves == 2 && memcmp(&retained, &selected, sizeof(selected)) == 0); + assert(m.dirty_contacts_expiry == 50000 && m.contacts_save_failures == 3); + m.acl.save_ok = true; + const std::string key = clientKey(retained); + for (const std::string& invalid_key : {key.substr(0, 8), key.substr(0, 12), std::string(64, '0'), std::string(64, 'g')}) { + const auto denied = result(m, request("admin.route", "admin-secret", + ",\"boot\":71,\"key\":\"" + invalid_key + "\"" + routeFields("outpath", "D1", "> B1")), sequence++); + assert(denied["error"].is() && m.acl.saves == 2); + assert(memcmp(&retained, &selected, sizeof(selected)) == 0); + } + puts("ADMIN_ROUTES: exact identities, stale guards, filtered saves, no-op writes, transient roles and rollback passed"); +} +static void unreadPosts(MyMesh& m, const ClientInfo& client) { + m.posts[0].post_timestamp = 150; m.posts[1].post_timestamp = 200; + m.posts[2].post_timestamp = 250; m.posts[3].post_timestamp = 300; + for (unsigned i = 0; i < 4; ++i) memset(m.posts[i].author.pub_key, 0x99, PUB_KEY_SIZE); + m.posts[4].post_timestamp = 1000; m.posts[4].author = client.id; + m.last_room_timestamp = 1000; +} +static void adminCatchUpCases() { + MyMesh m; enable(m); seedClients(m); + auto& client = m.acl.clients[1]; unreadPosts(m, client); + client.extra.room.pending_ack = 0x12345678; + client.extra.room.push_post_timestamp = 150; + client.extra.room.ack_timeout = 9999; + client.extra.room.push_failures = 2; + const ClientInfo other = m.acl.clients[0]; + uint32_t sequence = 1; + assert(result(m, adminClientRequest("admin.catchup", client, + ",\"mode\":\"keep\",\"count\":2,\"expected_sync\":101"), sequence++)["ok"] == true); + assert(client.extra.room.sync_since == 200 && client.extra.room.pending_ack == 0); + assert(client.extra.room.push_post_timestamp == 0 && client.extra.room.ack_timeout == 0); + assert(client.extra.room.push_failures == 0 && m.acl.saves == 1); + assert(m.retry_cancellations == 1 && m.cancelled_retry_timestamp == 150); + assert(memcmp(&other, &m.acl.clients[0], sizeof(other)) == 0); + const ClientInfo advanced = client; + const auto stale = result(m, adminClientRequest("admin.catchup", client, + ",\"mode\":\"keep\",\"count\":0,\"expected_sync\":101"), sequence++); + assert(stale["error"].is() && m.acl.saves == 1); + assert(memcmp(&client, &advanced, sizeof(advanced)) == 0); + assert(m.retry_cancellations == 1); + assert(result(m, adminClientRequest("admin.catchup", client, + ",\"mode\":\"keep\",\"count\":99,\"expected_sync\":200"), sequence++)["ok"] == true); + assert(memcmp(&client, &advanced, sizeof(advanced)) == 0 && m.acl.saves == 1); + m.acl.save_ok = false; + const auto failed = result(m, adminClientRequest("admin.catchup", client, + ",\"mode\":\"keep\",\"count\":0,\"expected_sync\":200"), sequence++); + assert(failed["error"].is() && m.acl.saves == 2); + assert(memcmp(&client, &advanced, sizeof(advanced)) == 0); + m.acl.save_ok = true; + assert(result(m, adminClientRequest("admin.catchup", client, + ",\"mode\":\"before\",\"before\":4294967295,\"expected_sync\":200"), sequence++)["ok"] == true); + assert(client.extra.room.sync_since == 300 && m.acl.saves == 3); + // A retained own-author post must not create a future catch-up cursor. + assert(client.extra.room.sync_since < m.posts[4].post_timestamp); + auto& transient = m.acl.clients[2]; unreadPosts(m, transient); + transient.extra.room.pending_ack = 0x87654321; + transient.extra.room.pending_topic_revision = 7; + transient.extra.room.push_post_timestamp = 150; + assert(result(m, adminClientRequest("admin.catchup", transient, + ",\"mode\":\"keep\",\"count\":0,\"expected_sync\":102"), sequence++)["ok"] == true); + assert(transient.extra.room.sync_since == 300 && transient.extra.room.pending_ack == 0x87654321); + assert(transient.extra.room.pending_topic_revision == 7 && m.acl.saves == 3); + assert(!transient.permissions_are_explicit && transient.permissions == PERM_ACL_READ_WRITE); + puts("ADMIN_CATCHUP: newest counts, stale cursors, future dates, own posts, topic ACKs, rollback and transient policy passed"); +} +static void adminListingCases() { + MyMesh m; enable(m); seedClients(m); + auto& selected = m.acl.clients[1]; unreadPosts(m, selected); + selected.extra.room.pending_ack = 1234; + selected.extra.room.push_post_timestamp = 150; + selected.extra.room.ack_timeout = ticks + 3000; + selected.observed_path_len = 0x41; + selected.observed_path[0] = 0x12; selected.observed_path[1] = 0x34; + m.acl.clients[2].last_activity = 0; + m.acl.clients[3].last_activity = m.clock.now + 1; + m.acl.clients[3].extra.room.push_failures = 3; + assert(m.room_access.addBan(m._fs, m.acl.clients[4].id.pub_key) == mesh::RoomAccessPolicy::BanResult::Saved); + uint32_t sequence = 1; + for (unsigned cursor : {0, 2, 4}) { + const std::string output = execute(m, request("admin.users", "admin-secret", + ",\"cursor\":" + std::to_string(cursor)), sequence++); + assert(output.size() < 2048); + JsonDocument page; assert(deserializeJson(page, output) == DeserializationError::Ok); + assert(page["total"] == 5 && page["boot"] == 71); + assert(page["active"] == 3 && page["backlog"] == 24); + assert(page["pending"] == 1 && page["failed"] == 1); + const unsigned expected = cursor == 4 ? 1 : 2; + assert(page["users"].is() && page["users"].size() == expected); + for (unsigned i = 0; i < expected; ++i) { + const auto user = page["users"][i]; + assert(user["key"].as() == clientKey(m.acl.clients[cursor + i])); + assert(user["shared_secret"].isUnbound() && user["last_timestamp"].isUnbound()); + assert(user["outpath"].isUnbound() && user["altpath"].isUnbound()); + } + if (cursor < 4) assert(page["next"] == cursor + 2); + else assert(page["next"].isNull()); + } + const auto detail = result(m, adminClientRequest("admin.user", selected), sequence++); + assert(detail["boot"] == 71 && detail["user"]["key"].as() == clientKey(selected)); + assert(detail["user"]["outpath"] == "A2" && detail["user"]["altpath"] == "unknown"); + assert(detail["user"]["observed"] == "1234" && detail["user"]["sync_since"] == 101); + assert(detail["user"]["delivery"] == "post" && detail["user"]["ack_wait_ms"] == 3000); + assert(detail["user"]["shared_secret"].isUnbound() && m.acl.saves == 0); + const auto inactive = result(m, adminClientRequest("admin.user", m.acl.clients[2]), sequence++); + assert(inactive["user"]["active"] == false && inactive["user"]["heard_ago"].isNull()); + const auto future = result(m, adminClientRequest("admin.user", m.acl.clients[3]), sequence++); + assert(future["user"]["heard_ago"].isNull()); + const auto banned = result(m, adminClientRequest("admin.user", m.acl.clients[4]), sequence++); + assert(banned["user"]["banned"] == true && banned["user"]["active"] == false); + selected.out_path_len = selected.alt_path_len = selected.observed_path_len = 0x60; + memset(selected.out_path, 0xA1, MAX_PATH_SIZE); + memset(selected.alt_path, 0xB2, MAX_PATH_SIZE); + memset(selected.observed_path, 0xC3, MAX_PATH_SIZE); + const std::string maximum = execute(m, adminClientRequest("admin.user", selected), sequence++); + JsonDocument complete; assert(deserializeJson(complete, maximum) == DeserializationError::Ok); + assert(complete["user"]["outpath"].as().size() == 128 && maximum.size() < 2048); + for (size_t capacity = 0; capacity < 750; ++capacity) { + execute(m, adminClientRequest("admin.user", selected), sequence++, 1, capacity); + } + assert(m.acl.saves == 0); + puts("ADMIN_USERS: two-row pages, delivery detail, full routes, inactive/future/banned states and bounded JSON passed"); +} +static void adminAccessAndSettingsCases() { + MyMesh m; enable(m); seedClients(m); + uint32_t sequence = 1; + auto& selected = m.acl.clients[1]; + const std::string target_key = clientKey(selected); + const ClientInfo following = m.acl.clients[2]; + const auto changed_access = result(m, adminClientRequest("admin.access", selected, + ",\"role\":1,\"expected_role\":2"), sequence++); + assert(changed_access["ok"] == true && changed_access["pending_save"] == true); + assert(selected.permissions == PERM_ACL_READ_ONLY && selected.permissions_are_explicit); + assert(m.dirty_contacts_expiry != 0 && m.acl.saves == 0); + const auto stale = result(m, adminClientRequest("admin.access", selected, + ",\"role\":3,\"expected_role\":2"), sequence++); + assert(stale["error"].is() && selected.permissions == PERM_ACL_READ_ONLY); + assert(result(m, adminClientRequest("admin.access", selected, + ",\"role\":0,\"expected_role\":1"), sequence++)["ok"] == true); + assert(m.acl.count == 4 && m.acl.getClient(following.id.pub_key, PUB_KEY_SIZE) != nullptr); + assert(clientKey(m.acl.clients[1]) == clientKey(following)); + uint8_t removed[PUB_KEY_SIZE]; memset(removed, 2, sizeof(removed)); + assert(m.acl.getClient(removed, PUB_KEY_SIZE) == nullptr); + const unsigned before_ban = m.storage.write_opens; + assert(result(m, request("admin.ban", "admin-secret", ",\"boot\":71,\"key\":\"" + + target_key + "\",\"banned\":true"), sequence++)["ok"] == true); + assert(m.room_access.isBanned(removed) && m.storage.write_opens > before_ban); + const unsigned after_ban = m.storage.write_opens; + assert(result(m, request("admin.ban", "admin-secret", ",\"boot\":71,\"key\":\"" + + target_key + "\",\"banned\":true"), sequence++)["ok"] == true); + assert(m.storage.write_opens == after_ban); + assert(result(m, request("admin.ban", "admin-secret", ",\"boot\":71,\"key\":\"" + + target_key + "\",\"banned\":false"), sequence++)["ok"] == true); + assert(!m.room_access.isBanned(removed)); + const unsigned reads_only = m.storage.write_opens; + const auto settings = result(m, request("admin.settings", "admin-secret"), sequence++); + assert(settings["boot"] == 71 && settings["topic"] == m.room_topic); + assert(settings["persistent_history"] == false && settings["post_rate"] == 0 && settings["poll_rate"] == 0); + assert(m.storage.write_opens == reads_only); + const std::string old_topic = m.room_topic; + const auto topic = result(m, request("admin.topic", "admin-secret", ",\"boot\":71,\"value\":\"New topic\",\"expected\":\"" + + old_topic + "\""), sequence++); + assert(topic["ok"] == true && strcmp(m.room_topic, "New topic") == 0); + const uint32_t revision = m.room_topic_revision; + const auto stale_topic = result(m, request("admin.topic", "admin-secret", ",\"boot\":71,\"value\":\"Stale\",\"expected\":\"" + + old_topic + "\""), sequence++); + assert(stale_topic["error"].is() && strcmp(m.room_topic, "New topic") == 0 && m.room_topic_revision == revision); + assert(result(m, request("admin.history", "admin-secret", ",\"boot\":71,\"enabled\":true,\"expected\":false"), sequence++)["ok"] == true); + assert(m.room_history_enabled); + const auto stale_history = result(m, request("admin.history", "admin-secret", ",\"boot\":71,\"enabled\":false,\"expected\":false"), sequence++); + assert(stale_history["error"].is() && m.room_history_enabled); + assert(result(m, request("admin.rates", "admin-secret", ",\"boot\":71,\"post_rate\":12,\"poll_rate\":60"), sequence++)["ok"] == true); + assert(m.room_access.postRate() == 12 && m.room_access.pollRate() == 60); + const unsigned rate_writes = m.storage.write_opens; + assert(result(m, request("admin.rates", "admin-secret", ",\"boot\":71,\"post_rate\":12,\"poll_rate\":60"), sequence++)["ok"] == true); + assert(m.storage.write_opens == rate_writes); + ticks += 60000; + m.storage.capacity = 0; + const auto failed_topic = result(m, request("admin.topic", "admin-secret", ",\"boot\":71,\"value\":\"Not saved\",\"expected\":\"New topic\""), sequence++); + assert(failed_topic["error"].is() && strcmp(m.room_topic, "New topic") == 0); + m.storage.reset(); m.storage.faults.insert("open:w:/room_hist_cfg.tmp"); + const auto failed_history = result(m, request("admin.history", "admin-secret", ",\"boot\":71,\"enabled\":false,\"expected\":true"), sequence++); + assert(failed_history["error"].is() && m.room_history_enabled); + m.storage.reset(); m.storage.capacity = 0; + const auto failed_rates = result(m, request("admin.rates", "admin-secret", ",\"boot\":71,\"post_rate\":13,\"poll_rate\":61"), sequence++); + assert(failed_rates["error"].is() && m.room_access.postRate() == 12 && m.room_access.pollRate() == 60); + puts("ADMIN_SETTINGS: queued ACL semantics, role revocation, bans, topic/history/rates stale guards and storage failure passed"); +} +static void adminInvalidAndSessionCases() { + struct Invalid { const char* operation; const char* fields; }; + const Invalid invalid[] = { + {"admin.users", ""}, {"admin.users", ",\"cursor\":-1"}, + {"admin.users", ",\"cursor\":\"0\""}, {"admin.users", ",\"cursor\":1.5"}, + {"admin.users", ",\"cursor\":false"}, {"admin.users", ",\"cursor\":6"}, + {"admin.user", ",\"key\":9"}, {"admin.user", ",\"key\":\"02020202\""}, + {"admin.user", ",\"key\":\"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\""}, + {"admin.route", ",\"which\":\"secret\",\"value\":\"B1\",\"expected\":\"A2\""}, + {"admin.route", ",\"which\":false,\"value\":\"B1\",\"expected\":\"A2\""}, + {"admin.route", ",\"which\":\"outpath\",\"value\":[],\"expected\":\"A2\""}, + {"admin.route", ",\"which\":\"outpath\",\"value\":\"B1\",\"expected\":2"}, + {"admin.route", ",\"which\":\"outpath\",\"value\":\"B1\",\"expected\":\"A2\\u0000\""}, + {"admin.catchup", ",\"mode\":\"skip\",\"count\":0,\"expected_sync\":101"}, + {"admin.catchup", ",\"mode\":\"keep\",\"count\":-1,\"expected_sync\":101"}, + {"admin.catchup", ",\"mode\":\"keep\",\"count\":65536,\"expected_sync\":101"}, + {"admin.catchup", ",\"mode\":\"keep\",\"count\":\"0\",\"expected_sync\":101"}, + {"admin.catchup", ",\"mode\":\"keep\",\"count\":1.5,\"expected_sync\":101"}, + {"admin.catchup", ",\"mode\":\"keep\",\"count\":0,\"expected_sync\":\"101\""}, + {"admin.catchup", ",\"mode\":\"before\",\"before\":-1,\"expected_sync\":101"}, + {"admin.catchup", ",\"mode\":\"before\",\"before\":4294967296,\"expected_sync\":101"}, + {"admin.access", ",\"role\":4,\"expected_role\":2"}, + {"admin.access", ",\"role\":5,\"expected_role\":2"}, + {"admin.access", ",\"role\":256,\"expected_role\":2"}, + {"admin.access", ",\"role\":-1,\"expected_role\":2"}, + {"admin.access", ",\"role\":2.5,\"expected_role\":2"}, + {"admin.access", ",\"role\":\"3\",\"expected_role\":2"}, + {"admin.access", ",\"role\":3,\"expected_role\":\"2\""}, + {"admin.ban", ",\"banned\":\"true\""}, {"admin.ban", ",\"banned\":1"}, + {"admin.history", ",\"enabled\":1,\"expected\":false"}, + {"admin.history", ",\"enabled\":true,\"expected\":\"false\""}, + {"admin.topic", ",\"value\":7,\"expected\":\"unchanged\""}, + {"admin.topic", ",\"value\":\"New\\u0000topic\",\"expected\":\"unchanged\""}, + {"admin.rates", ",\"post_rate\":-1,\"poll_rate\":60"}, + {"admin.rates", ",\"post_rate\":65536,\"poll_rate\":60"}, + {"admin.rates", ",\"post_rate\":1.5,\"poll_rate\":60"}, + {"admin.rates", ",\"post_rate\":12,\"poll_rate\":\"60\""}, + {"admin.cli", ",\"value\":\"erase\""}, {"admin.userdetail", ""}, + {"admin.users\\u0000post", ",\"cursor\":0"}, + }; + for (const auto& entry : invalid) { + MyMesh m; enable(m); seedClients(m); strcpy(m.room_topic, "unchanged"); + const std::vector clients(m.acl.clients, m.acl.clients + m.acl.count); + const unsigned writes = m.storage.write_opens; + const auto denied = result(m, adminClientRequest(entry.operation, m.acl.clients[1], entry.fields), 1); + assert(denied["error"].is() && denied.as().size() == 1); + assert(m.acl.count == 5 && memcmp(clients.data(), m.acl.clients, clients.size() * sizeof(ClientInfo)) == 0); + assert(m.acl.saves == 0 && m.dirty_contacts_expiry == 0 && m.retry_cancellations == 0); + assert(m.storage.write_opens == writes && m.room_access.banCount() == 0); + assert(!m.room_history_enabled && strcmp(m.room_topic, "unchanged") == 0); + assert(m.room_access.postRate() == 0 && m.room_access.pollRate() == 0); + } + const char* writes[] = {"admin.route", "admin.catchup", "admin.access", "admin.ban", + "admin.topic", "admin.history", "admin.rates"}; + for (const char* operation : writes) { + MyMesh m; enable(m); seedClients(m); + error(m, adminClientRequest(operation, m.acl.clients[1], "", 70), 99, "restarted"); + error(m, request(operation, "admin-secret", ",\"boot\":\"71\""), 99, "restarted"); + error(m, request(operation, "admin-secret"), 99, "restarted"); + assert(floors(m) == 0 && m.acl.lookups == 0 && m.acl.saves == 0); + } + MyMesh m; enable(m); seedClients(m); + const auto mutation = adminClientRequest("admin.route", m.acl.clients[1], routeFields("outpath", "B1", "A2")); + assert(result(m, mutation, 1)["ok"] == true && m.acl.saves == 1); + error(m, mutation, 1, "completed"); assert(m.acl.saves == 1); + error(m, request("admin.users", "write-secret", ",\"cursor\":0"), 100, "admin"); + assert(floors(m) == 1); + assert(result(m, request("admin.users", "admin-secret", ",\"cursor\":0"), 2)["users"].size() == 2); + MyMesh full; enable(full); + for (uint8_t owner = 1; owner <= 8; ++owner) { + assert(result(full, request("admin.settings", "admin-secret"), 1, owner)["boot"] == 71); + } + error(full, request("admin.settings", "admin-secret"), 1, "busy", 9); + assert(floors(full) == 8 && full.acl.lookups == 0); + MyMesh banned; enable(banned); + uint8_t author[PUB_KEY_SIZE]; const auto identity = token(); + mesh::Utils::sha256(author, sizeof(author), identity.data(), identity.size(), banned.self_id.pub_key, PUB_KEY_SIZE); + assert(banned.room_access.addBan(banned._fs, author) == mesh::RoomAccessPolicy::BanResult::Saved); + error(banned, request("admin.settings", "admin-secret"), 1, "access denied"); + assert(floors(banned) == 0 && banned.acl.lookups == 0 && !banned.room_web_request_authenticated); + printf("ADMIN_INVALID: %zu strict schemas, 21 boot guards, replay/auth floors, bounded sessions and banned browser passed\n", std::size(invalid)); +} int main() { unsigned scenarios = 0; { @@ -269,11 +708,17 @@ int main() { 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; } + adminAuthorizationCases(); + adminRouteCases(); + adminCatchUpCases(); + adminListingCases(); + adminAccessAndSettingsCases(); + adminInvalidAndSessionCases(); // 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); + printf("PASS: %u base and 6 admin actual room web service authorization, storage, quota, JSON and boundary groups\n", scenarios); } diff --git a/test/test_room_catchup.py b/test/test_room_catchup.py new file mode 100644 index 000000000..e1c3665e1 --- /dev/null +++ b/test/test_room_catchup.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Run actual room catch-up planning against production client and post layouts.""" + +from datetime import datetime, timezone +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = ROOT / "test/fixtures/room_catchup.cpp" + + +def date_reference(): + rows = ['{"0", 0}', '{"4294967295", UINT32_MAX}', + '{"2106-02-07T06:28:15Z", UINT32_MAX}'] + for year in range(1970, 2107): + for month, day in ((1, 1), (2, 28), (3, 1), (12, 31)): + date = datetime(year, month, day, tzinfo=timezone.utc) + timestamp = int(date.timestamp()) + if timestamp <= 0xFFFFFFFF: + rows.append('{"' + date.strftime("%Y-%m-%d") + '", ' + str(timestamp) + '}') + later = date.replace(hour=12, minute=34, second=56) + if int(later.timestamp()) <= 0xFFFFFFFF: + rows.append('{"' + later.strftime("%Y-%m-%dT%H:%M:%SZ") + '", ' + + str(int(later.timestamp())) + '}') + if year % 4 == 0 and (year % 100 != 0 or year % 400 == 0): + date = datetime(year, 2, 29, tzinfo=timezone.utc) + rows.append('{"' + date.strftime("%Y-%m-%d") + '", ' + str(int(date.timestamp())) + '}') + return ",\n ".join(rows) + + +def production_source(): + room = (ROOT / "examples/simple_room_server/MyMesh.cpp").read_text() + room_header = (ROOT / "examples/simple_room_server/MyMesh.h").read_text() + acl = (ROOT / "src/helpers/ClientACL.h").read_text() + constants = "\n".join(line for line in acl.splitlines() + if line.startswith(("#define PERM_ACL_", "#define OUT_PATH_"))) + text_length = re.search(r"^#define\s+MAX_POST_TEXT_LEN\s+.*$", room_header, re.MULTILINE) + if not text_length: + raise AssertionError("production post text capacity not found") + constants += "\n" + text_length[0] + replacements = { + "@CONSTANTS@": constants, + "@CLIENT_INFO@": extract_braced(acl, "struct ClientInfo {"), + "@POST_INFO@": extract_braced(room_header, "struct PostInfo {"), + "@PROCESS_ACK@": extract_braced(room, "bool MyMesh::processAck("), + "@VALID_DATES@": date_reference(), + } + source = FIXTURE.read_text() + for marker, replacement in replacements.items(): + source = source.replace(marker, replacement) + return source + + +class RoomCatchUpTests(unittest.TestCase): + @classmethod + def build(cls, name, helper=None): + work = Path(cls.directory.name) + include = work / name + include.mkdir() + if helper is not None: + (include / "helpers").mkdir() + (include / "helpers/RoomCatchUp.h").write_text(helper, encoding="ascii") + source = include / "test.cpp" + source.write_text(cls.generated, encoding="ascii") + binary = include / "catchup" + command = [cls.compiler, "-std=c++17", "-O1", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter", + "-I" + str(include), "-I" + str(ROOT / "src"), str(source), "-o", str(binary)] + if sys.platform.startswith("linux"): + command[1:1] = ["-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-omit-frame-pointer", "-fno-pie", "-no-pie"] + checked = subprocess.run(command, capture_output=True, text=True, timeout=60) + if checked.returncode: + raise AssertionError(checked.stdout + checked.stderr) + return binary + + @classmethod + def setUpClass(cls): + cls.compiler = shutil.which("g++") or shutil.which("clang++") + if not cls.compiler: + raise AssertionError("a host C++17 compiler is required") + cls.directory = tempfile.TemporaryDirectory(prefix="room-catchup-") + cls.addClassCleanup(cls.directory.cleanup) + cls.generated = production_source() + cls.binary = cls.build("production") + + def run_case(self, case, expected): + checked = subprocess.run([str(self.binary), case], capture_output=True, text=True, timeout=20) + self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr) + self.assertIn(expected, checked.stdout) + + def test_before_skipnext_keepnewest_ring_order_and_author_exclusion(self): + self.run_case("counts", "all modes order cyclic retention, exclude own posts, and bound future selections passed") + + def test_epoch_limit_empty_retention_and_duplicate_timestamps(self): + self.run_case("bounds", "uint32 limits, empty retention, bounded input, and equal-timestamp accounting passed") + + def test_actual_client_and_ack_handler_preserve_topic_and_quotas(self): + self.run_case("pending", "actual client application clears skipped posts, preserves topics and quotas, and rejects stale ACKs passed") + + def test_strict_dates_match_python_utc_reference_and_reject_invalid_input(self): + self.run_case("dates", "strict UTC Gregorian and uint32 parsing match independent epoch references passed") + + def test_randomized_plans_match_independent_ordered_reference(self): + self.run_case("random", "15000 plans match independent ordered reference without mutating retention passed") + + def test_negative_controls_detect_self_posts_date_boundary_and_uncleared_ack(self): + helper = (ROOT / "src/helpers/RoomCatchUp.h").read_text() + controls = ( + ("self-posts", "&& !post.author.matches(client.id)", "", "counts"), + ("date-boundary", "post.post_timestamp < value", "post.post_timestamp <= value", "counts"), + ("pending-ack", "client.extra.room.pending_ack = 0;", "(void)client.extra.room.pending_ack;", "pending"), + ) + for name, before, after, case in controls: + with self.subTest(control=name): + self.assertIn(before, helper, "update the negative control to the production seam") + binary = self.build(name, helper.replace(before, after)) + checked = subprocess.run([str(binary), case], capture_output=True, text=True, timeout=20) + self.assertNotEqual(checked.returncode, 0, "negative control missed " + name) + self.assertIn("Assertion", checked.stderr, checked.stdout + checked.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_room_catchup_transport.py b/test/test_room_catchup_transport.py new file mode 100644 index 000000000..a44a12105 --- /dev/null +++ b/test/test_room_catchup_transport.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +"""Execute actual Mesh cancellation and production room delivery identities.""" + +import hashlib +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] + +HARNESS = r''' +#include +#include +#include +#include +#include +#include +#include +#include +@CONSTANTS@ +namespace mesh { +struct Packet {}; +struct Mesh { + @DIRECT_ENTRY@; + @FLOOD_ENTRY@; + DirectRetryEntry _direct_retries[TOTAL_DIRECT_RETRY_SLOTS]{}; + FloodRetryEntry _flood_retries[TOTAL_FLOOD_RETRY_SLOTS]{}; + std::vector direct_retired, flood_retired; + void retireDirectRetrySlot(int index) { + assert(_direct_retries[index].active); direct_retired.push_back(index); + memset(&_direct_retries[index], 0, sizeof(_direct_retries[index])); + } + void retireFloodRetrySlot(int index) { + assert(_flood_retries[index].active); flood_retired.push_back(index); + memset(&_flood_retries[index], 0, sizeof(_flood_retries[index])); + } + bool cancelActiveMessageRetries(const uint8_t*, uint32_t); +}; +@CANCEL@ +} +static void hash(uint8_t* output, size_t size, const uint8_t* input, size_t length) { + uint8_t digest[SHA256_DIGEST_LENGTH]; + assert(size <= sizeof(digest)); + SHA256(input, length, digest); memcpy(output, digest, size); +} +static void makeKey(uint8_t* output, const uint8_t* room, const uint8_t* recipient, bool topic) { + mesh::roomDeliveryRetryKey(output, MAX_HASH_SIZE, room, recipient, topic, hash); +} +static void identityKeys() { + uint8_t room[32], recipient[32]; memset(room, 1, 32); memset(recipient, 2, 32); + uint8_t primary[MAX_HASH_SIZE], topic[MAX_HASH_SIZE], other_room[MAX_HASH_SIZE], other_user[MAX_HASH_SIZE]; + makeKey(primary, room, recipient, false); makeKey(topic, room, recipient, true); + const uint8_t expected[] = {@EXPECTED_KEY@}; + assert(memcmp(primary, expected, sizeof(expected)) == 0); + room[31] = 3; makeKey(other_room, room, recipient, false); + room[31] = 1; recipient[31] = 3; makeKey(other_user, room, recipient, false); + assert(memcmp(primary, topic, MAX_HASH_SIZE) != 0); + assert(memcmp(primary, other_room, MAX_HASH_SIZE) != 0); + assert(memcmp(primary, other_user, MAX_HASH_SIZE) != 0); + recipient[31] = 2; uint8_t repeated[MAX_HASH_SIZE]; makeKey(repeated, room, recipient, false); + assert(memcmp(primary, repeated, MAX_HASH_SIZE) == 0); + puts("full room and recipient identities and topic namespace match SHA256 reference passed"); +} +template +static void seed(Entry& entry, const uint8_t* key, uint32_t timestamp, bool recognized = true) { + entry.active = true; entry.message_timestamp = timestamp; + entry.has_message_replacement_key = recognized; + memcpy(entry.message_replacement_key, key, MAX_HASH_SIZE); +} +static void cancellationCases() { + uint8_t room[32], user[32]; memset(room, 1, 32); memset(user, 2, 32); + uint8_t primary[MAX_HASH_SIZE], topic[MAX_HASH_SIZE], other[MAX_HASH_SIZE]; + makeKey(primary, room, user, false); makeKey(topic, room, user, true); + user[31] ^= 1; makeKey(other, room, user, false); + mesh::Mesh mesh; + for (int profile = 0; profile < 2; ++profile) { + const int d = profile * MAX_DIRECT_RETRY_SLOTS, f = profile * MAX_FLOOD_RETRY_SLOTS; + seed(mesh._direct_retries[d], primary, 500); seed(mesh._flood_retries[f], primary, 500); + seed(mesh._direct_retries[d + 1], primary, 501); seed(mesh._flood_retries[f + 1], primary, 501); + seed(mesh._direct_retries[d + 2], topic, 500); seed(mesh._flood_retries[f + 2], topic, 500); + seed(mesh._direct_retries[d + 3], other, 500); seed(mesh._flood_retries[f + 3], other, 500); + seed(mesh._direct_retries[d + 4], primary, 500, false); seed(mesh._flood_retries[f + 4], primary, 500, false); + mesh._direct_retries[d].queued = profile == 0; + mesh._flood_retries[f].waiting_final_echo = profile == 1; + } + assert(!mesh.cancelActiveMessageRetries(nullptr, 500)); + assert(!mesh.cancelActiveMessageRetries(primary, 499)); + const auto before = mesh; + assert(mesh.cancelActiveMessageRetries(primary, 500)); + assert(mesh.direct_retired.size() == 2 && mesh.flood_retired.size() == 2); + for (int profile = 0; profile < 2; ++profile) { + const int d = profile * MAX_DIRECT_RETRY_SLOTS, f = profile * MAX_FLOOD_RETRY_SLOTS; + assert(!mesh._direct_retries[d].active && !mesh._flood_retries[f].active); + for (int offset = 1; offset < MAX_DIRECT_RETRY_SLOTS; ++offset) { + assert(memcmp(&mesh._direct_retries[d + offset], &before._direct_retries[d + offset], + sizeof(mesh._direct_retries[0])) == 0); + } + for (int offset = 1; offset < MAX_FLOOD_RETRY_SLOTS; ++offset) { + assert(memcmp(&mesh._flood_retries[f + offset], &before._flood_retries[f + offset], + sizeof(mesh._flood_retries[0])) == 0); + } + } + assert(!mesh.cancelActiveMessageRetries(primary, 500)); + // Retirement clears the slot's key. An input alias must survive all matches. + mesh::Mesh alias; + seed(alias._direct_retries[0], primary, 500); + seed(alias._direct_retries[MAX_DIRECT_RETRY_SLOTS], primary, 500); + seed(alias._flood_retries[0], primary, 500); + seed(alias._flood_retries[MAX_FLOOD_RETRY_SLOTS], primary, 500); + assert(alias.cancelActiveMessageRetries(alias._direct_retries[0].message_replacement_key, 500)); + assert(alias.direct_retired.size() == 2 && alias.flood_retired.size() == 2); + puts("actual cancellation retires one delivery across profiles and preserves every unrelated retry passed"); +} +int main(int argc, char** argv) { + assert(argc == 2); + if (!strcmp(argv[1], "keys")) identityKeys(); + else if (!strcmp(argv[1], "cancel")) cancellationCases(); + else assert(false); +} +''' + + +def production_source(cancel_override=None): + source = (ROOT / "src/Mesh.cpp").read_text() + header = (ROOT / "src/Mesh.h").read_text() + constants = [] + for name in ("MAX_DIRECT_RETRY_SLOTS", "MAX_FLOOD_RETRY_SLOTS", "TOTAL_DIRECT_RETRY_SLOTS", "TOTAL_FLOOD_RETRY_SLOTS"): + found = re.search(r"^\s*#define\s+" + name + r"\s+.*$", header, re.MULTILINE) + if not found: + raise AssertionError("production retry capacity not found: " + name) + constants.append(found[0]) + digest = hashlib.sha256(b"room\x00" + bytes([1]) * 32 + bytes([2]) * 32).digest()[:8] + replacements = { + "@CONSTANTS@": "\n".join(constants), + "@DIRECT_ENTRY@": extract_braced(header, "struct DirectRetryEntry {"), + "@FLOOD_ENTRY@": extract_braced(header, "struct FloodRetryEntry {"), + "@CANCEL@": cancel_override or extract_braced(source, "bool Mesh::cancelActiveMessageRetries("), + "@EXPECTED_KEY@": ",".join("0x%02X" % byte for byte in digest), + } + generated = HARNESS + for marker, value in replacements.items(): + generated = generated.replace(marker, value) + return generated + + +class RoomCatchUpTransportTests(unittest.TestCase): + @classmethod + def build(cls, name, source): + work = Path(cls.directory.name) + path = work / (name + ".cpp"); path.write_text(source, encoding="ascii") + binary = work / name + command = [cls.compiler, "-std=c++17", "-O1", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter", + "-I" + str(ROOT / "src"), str(path), "-lcrypto", "-o", str(binary)] + if sys.platform.startswith("linux"): + command[1:1] = ["-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-omit-frame-pointer", "-fno-pie", "-no-pie"] + checked = subprocess.run(command, capture_output=True, text=True, timeout=60) + if checked.returncode: + raise AssertionError(checked.stdout + checked.stderr) + return binary + + @classmethod + def setUpClass(cls): + cls.compiler = shutil.which("g++") or shutil.which("clang++") + if not cls.compiler: + raise AssertionError("a host C++17 compiler is required") + cls.directory = tempfile.TemporaryDirectory(prefix="room-catchup-transport-") + cls.addClassCleanup(cls.directory.cleanup) + cls.binary = cls.build("production", production_source()) + + def run_case(self, name, expected): + checked = subprocess.run([str(self.binary), name], capture_output=True, text=True, timeout=15) + self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr) + self.assertIn(expected, checked.stdout) + + def test_actual_domain_key_covers_full_room_recipient_and_topic_namespace(self): + self.run_case("keys", "full room and recipient identities and topic namespace match SHA256 reference passed") + + def test_actual_mesh_cancellation_key_timestamp_kind_and_both_profiles(self): + self.run_case("cancel", "actual cancellation retires one delivery across profiles and preserves every unrelated retry passed") + + def test_negative_controls_detect_broad_timestamp_and_key_cancellation(self): + source = (ROOT / "src/Mesh.cpp").read_text() + cancel = extract_braced(source, "bool Mesh::cancelActiveMessageRetries(") + for kind, before in (("timestamp", ".message_timestamp == message_timestamp"), + ("recognition", ".has_message_replacement_key")): + with self.subTest(control=kind): + self.assertEqual(cancel.count(before), 2) + changed = cancel.replace(before, before + " || true") + # Preserve expression grouping while deliberately relaxing just this gate. + for table in ("_direct_retries[i]", "_flood_retries[i]"): + changed = changed.replace(table + before + " || true", "(" + table + before + " || true)") + binary = self.build("negative-" + kind, production_source(changed)) + checked = subprocess.run([str(binary), "cancel"], capture_output=True, text=True, timeout=15) + self.assertNotEqual(checked.returncode, 0, "negative control missed " + kind) + self.assertIn("Assertion", checked.stderr, checked.stdout + checked.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_room_client_paths.py b/test/test_room_client_paths.py index 74d90b92a..df1ce37bd 100644 --- a/test/test_room_client_paths.py +++ b/test/test_room_client_paths.py @@ -78,7 +78,7 @@ static void exactAllowlist() { assert(normalized.reply() == "AA|> B1,B2" && normalized.general_commands == 0); MyMesh chat; chat.command("set outpath B1", 100, 0, 0, TXT_TYPE_PLAIN); - assert(chat.posts == 1 && chat.acl.clients[0].out_path[0] == 0xA1); + assert(chat.post_count == 1 && chat.acl.clients[0].out_path[0] == 0xA1); MyMesh banned; assert(banned.room_access.addBan(&banned.policy_fs, banned.acl.clients[0].id.pub_key) == mesh::RoomAccessPolicy::BanResult::Saved); @@ -260,6 +260,150 @@ static void pushCases() { assert(failed.acl.clients[0].extra.room.pending_ack == 0); puts("actual room post and topic pushes share copies, ACK state, and primary retry ownership passed"); } + +static void backlog(MyMesh& mesh) { + const unsigned indexes[] = {23, 0, 12, 9, 31}; + const uint32_t timestamps[] = {100, 200, 300, 400, 500}; + for (unsigned index = 0; index < 5; ++index) { + auto& post = mesh.posts[indexes[index]]; + post.post_timestamp = timestamps[index]; + post.author = mesh.acl.clients[index == 1 || index == 3 ? 0 : 1].id; + strcpy(post.text, "unread post"); + } + mesh.acl.clients[0].extra.room.sync_since = 50; +} + +static void catchupRadio() { + for (unsigned permissions = 0; permissions < 256; ++permissions) { + for (bool active : {false, true}) { + MyMesh mesh; backlog(mesh); auto& own = mesh.acl.clients[0]; + own.permissions = permissions; own.last_activity = active ? 800 : 0; + own.extra.room.post_quota_used = 9; own.extra.room.poll_quota_used = 11; + const ClientInfo other = mesh.acl.clients[1]; + const uint8_t role = permissions & PERM_ACL_ROLE_MASK; + const bool allowed = role == PERM_ACL_READ_ONLY || role == PERM_ACL_READ_WRITE + || role == PERM_ACL_ADMIN || (role == PERM_ACL_GUEST && active); + mesh.command("room.catchup keep 1"); + assert(mesh.general_commands == 0 && own.permissions == permissions && !own.permissions_are_explicit); + assert(memcmp(&mesh.acl.clients[1], &other, sizeof(other)) == 0); + assert(own.extra.room.post_quota_used == 9 && own.extra.room.poll_quota_used == 11); + assert(own.extra.room.sync_since == (allowed ? 300U : 50U)); + assert(mesh.acl.saves == (allowed && role == PERM_ACL_ADMIN ? 1U : 0U)); + if (allowed) assert(mesh.reply() == "OK - skipped=2 unread=1 since=300"); + else if (role == PERM_ACL_GUEST) assert(mesh.sent.empty() && own.last_activity == 0); + else assert(mesh.reply().find("Err") != std::string::npos); + } + } + MyMesh mesh; backlog(mesh); + auto& own = mesh.acl.clients[0]; own.extra.room.pending_ack = 0x778899; + own.extra.room.push_post_timestamp = 100; own.extra.room.ack_timeout = 5000; + mesh.command(" AB|GET room.catchup ", 100); + assert(mesh.reply() == "AB|> since=50 unread=3"); + mesh.command("AB|room.catchup before 1970-01-01T00:05:00Z", 101); + assert(mesh.reply() == "AB|OK - skipped=1 unread=2 since=100"); + assert(own.extra.room.pending_ack == 0 && mesh.message_cancellations == 1 + && mesh.cancelled_timestamp == 100); + mesh.command("room.catchup before 4294967295", 102); + assert(own.extra.room.sync_since == 500 && mesh.reply() == "OK - skipped=2 unread=0 since=500"); + assert(mesh.message_cancellations == 1); // No repeated cancel after the post was cleared. + MyMesh evicted; backlog(evicted); evicted.acl.clients[0].extra.room.pending_ack = 55; + evicted.acl.clients[0].extra.room.push_post_timestamp = 75; // The retained posts no longer include this one. + evicted.command("room.catchup keep 1"); + assert(evicted.message_cancellations == 1 && evicted.cancelled_timestamp == 75); + MyMesh topic; backlog(topic); topic.acl.clients[0].extra.room.pending_ack = 55; + topic.acl.clients[0].extra.room.push_post_timestamp = 100; + topic.acl.clients[0].extra.room.pending_topic_revision = 22; + topic.command("room.catchup keep 1"); + assert(topic.message_cancellations == 0 && topic.acl.clients[0].extra.room.pending_ack == 55 + && topic.acl.clients[0].extra.room.pending_topic_revision == 22); + puts("actual radio catch-up keeps newest, skips before UTC dates, and only changes own client passed"); +} + +static void catchupRetriesAndDenials() { + MyMesh mesh; backlog(mesh); auto& own = mesh.acl.clients[0]; own.permissions_are_explicit = true; + mesh.command("AB|room.catchup keep 1", 100, 0, 777); + assert(own.extra.room.sync_since == 300 && mesh.acl.saves == 1); + const std::string first_reply = mesh.reply(); + mesh.command("room.catchup keep 0", 101); + assert(own.extra.room.sync_since == 500 && mesh.acl.saves == 2); + mesh.command("AB|room.catchup keep 1", 102, 0, 777); + assert(own.extra.room.sync_since == 500 && mesh.acl.saves == 2 && mesh.reply() == first_reply); + const size_t replies = mesh.sent.size(); + mesh.command("room.catchup keep 0", 99); + mesh.command("room.catchup keep 2", 102); + assert(mesh.sent.size() == replies && own.extra.room.sync_since == 500); + own.permissions = PERM_ACL_FILTER_MGR; + mesh.command("AB|room.catchup keep 1", 103, 0, 777); + assert(mesh.reply().find("Err") != std::string::npos && own.extra.room.sync_since == 500); + const char* rejected[] = {"room.catchupx keep 0", "room.catchup", "get room.catchup extra", + "room.catchup keep -1", "room.catchup keep 4294967296", "room.catchup keep 1;erase", + "room.catchup before 2026-02-29", "room.catchup before 2106-02-08", "room.catchup set 0", + "room.catchup skip 1", "AA|BB|room.catchup keep 0", "get room.catchup;erase"}; + for (const char* text : rejected) { + MyMesh rejected; backlog(rejected); rejected.command(text); + assert(rejected.acl.clients[0].extra.room.sync_since == 50 && rejected.general_commands == 0); + assert(rejected.reply().find("Err") != std::string::npos && rejected.acl.saves == 0); + } + MyMesh plain; backlog(plain); plain.command("room.catchup keep 0", 100, 0, 0, TXT_TYPE_PLAIN); + assert(plain.post_count == 1 && plain.acl.clients[0].extra.room.sync_since == 50); + MyMesh failed; backlog(failed); failed.acl.clients[0].permissions_are_explicit = true; + failed.acl.clients[0].extra.room.pending_ack = 0x778899; + failed.acl.clients[0].extra.room.push_post_timestamp = 100; + failed.acl.clients[0].extra.room.ack_timeout = 5000; + const auto before = failed.acl.clients[0].extra.room; + failed.acl.save_ok = false; failed.command("room.catchup keep 1"); + assert(memcmp(&before, &failed.acl.clients[0].extra.room, sizeof(before)) == 0); + assert(failed.reply().find("save failed") != std::string::npos && failed.message_cancellations == 0); + puts("catch-up cache executes once, role revocation and invalid families deny, and storage failure rolls back passed"); +} + +static void adminClients() { + const std::string target_key(64, '2'); + // The fixture identity is repeated byte0x02, which encodes as repeated "02". + std::string full_key; + for (unsigned byte = 0; byte < PUB_KEY_SIZE; ++byte) full_key += "02"; + for (uint8_t target_role : {uint8_t(PERM_ACL_GUEST), uint8_t(PERM_ACL_FILTER_MGR), uint8_t(PERM_ACL_ADMIN)}) { + MyMesh mesh; mesh.acl.clients[0].permissions = PERM_ACL_ADMIN; + mesh.acl.clients[1].permissions = target_role; + const auto permissions = mesh.acl.clients[1].permissions; + mesh.command(("room.user " + full_key + " outpath C1,C2").c_str()); + assert(mesh.general_commands == 1 && mesh.acl.clients[1].out_path_len == 2); + assert(mesh.acl.clients[1].out_path[0] == 0xC1 && mesh.acl.clients[0].out_path[0] == 0xA1); + assert(mesh.acl.clients[1].permissions == permissions && !mesh.acl.clients[1].permissions_are_explicit); + assert(mesh.acl.saves == (target_role == PERM_ACL_ADMIN ? 1U : 0U)); + } + for (const std::string& key : {full_key.substr(0, 8), full_key.substr(0, 12), target_key, + full_key.substr(0, 62) + "GG", full_key + "00"}) { + MyMesh mesh; mesh.acl.clients[0].permissions = PERM_ACL_ADMIN; + mesh.command(("room.user " + key + " outpath C1").c_str()); + assert(mesh.acl.clients[1].out_path[0] == 0xA2 && mesh.reply().find("Err") != std::string::npos); + } + MyMesh admin; backlog(admin); admin.acl.clients[0].permissions = PERM_ACL_ADMIN; + admin.acl.clients[1].extra.room.sync_since = 50; + // Other authors leave all five retained posts unread to the target. + for (auto& post : admin.posts) if (post.post_timestamp) memset(post.author.pub_key, 3, PUB_KEY_SIZE); + admin.command(("room.user " + full_key + " keep 2").c_str()); + assert(admin.acl.clients[1].extra.room.sync_since == 300 && admin.acl.clients[0].extra.room.sync_since == 50); + MyMesh user; user.command(("room.user " + full_key + " outpath C1").c_str()); + assert(user.general_commands == 0 && user.acl.clients[1].out_path[0] == 0xA2); + puts("admin targets full identities without role promotion while ordinary users cannot edit another client passed"); +} +static void catchupPolls() { + MyMesh mesh; backlog(mesh); mesh.command("room.catchup keep 0"); + auto& own = mesh.acl.clients[0]; assert(own.extra.room.sync_since == 500); + const size_t replies = mesh.sent.size(); + mesh.poll(101, 300); + assert(own.extra.room.sync_since == 500 && own.last_timestamp == 101); + assert(mesh.sent.size() == replies + 1 && mesh.sent.back().packet.getPayloadType() == PAYLOAD_TYPE_ACK); + mesh.poll(102, 700); + assert(own.extra.room.sync_since == 700 && mesh.sent.size() == replies + 2); + mesh.poll(101, 300); // Exact poll retry returns an ACK without rewinding. + assert(own.extra.room.sync_since == 700 && own.last_timestamp == 102 && mesh.sent.size() == replies + 3); + mesh.poll(103, 0); + assert(own.extra.room.sync_since == 700 && mesh.sent.size() == replies + 4); + puts("normal keep-alive echoes preserve selected catch-up while forward cursors and exact ACK retries work passed"); +} + int main(int argc, char** argv) { assert(argc == 2); if (!strcmp(argv[1], "roles")) roleCases(); @@ -269,6 +413,10 @@ int main(int argc, char** argv) { else if (!strcmp(argv[1], "retries")) retryCases(); else if (!strcmp(argv[1], "alternate")) alternateCases(); else if (!strcmp(argv[1], "push")) pushCases(); + else if (!strcmp(argv[1], "catchup")) catchupRadio(); + else if (!strcmp(argv[1], "catchup-retry")) catchupRetriesAndDenials(); + else if (!strcmp(argv[1], "admin-clients")) adminClients(); + else if (!strcmp(argv[1], "catchup-poll")) catchupPolls(); else assert(false); } ''' @@ -286,7 +434,7 @@ def production_source(): for name in ("ROOM_MESSAGE_CACHE_SIZE", "TXT_TYPE_PLAIN", "TXT_TYPE_CLI_DATA", "TXT_TYPE_CLI_COMMAND", "TXT_TYPE_SIGNED_PLAIN", "TXT_ACK_DELAY", "REPLY_DELAY_MILLIS", "REQ_TYPE_KEEP_ALIVE", "SERVER_RESPONSE_DELAY", "LAZY_CONTACTS_WRITE_DELAY", "PUSH_TIMEOUT_BASE", - "PUSH_ACK_TIMEOUT_FACTOR", "PUSH_ACK_TIMEOUT_FLOOD"): + "PUSH_ACK_TIMEOUT_FACTOR", "PUSH_ACK_TIMEOUT_FLOOD", "MAX_UNSYNCED_POSTS", "MAX_POST_TEXT_LEN"): found = None for text in sources: found = re.search(r"^\s*#define\s+" + name + r"\s+.*$", text, re.MULTILINE) @@ -296,7 +444,11 @@ def production_source(): raise AssertionError("missing production constant " + name) constants += "\n" + found[0] methods = "\n".join(extract_braced(room, signature) for signature in ( - "bool MyMesh::handleClientPathCommand(", "bool MyMesh::sendClientReply(", + "bool MyMesh::handleClientPathCommand(", "bool MyMesh::executeClientPathCommand(", + "bool MyMesh::sendClientReply(", + "bool MyMesh::handleRoomCatchUpCommand(", "bool MyMesh::applyRoomCatchUpCommand(", + "bool MyMesh::setRoomClientPath(", "bool MyMesh::handleRoomManagementCommand(", + "uint8_t MyMesh::getUnsyncedCount(", "void MyMesh::onPeerDataRecv(", "bool MyMesh::pushRoomTextToClient(", "void MyMesh::serviceRoomQuotas(", "bool MyMesh::saveFilter(")) replacements = { @@ -304,6 +456,7 @@ def production_source(): "@STR_HELPER@": extract_braced((ROOT / "src/helpers/TxtDataHelpers.cpp").read_text(), "void StrHelper::strncpy("), "@CLIENT_INFO@": extract_braced(acl, "struct ClientInfo {"), + "@POST_INFO@": extract_braced(header, "struct PostInfo {"), "@PACKET_METHODS@": "\n".join(extract_braced(packet, signature) for signature in ( "Packet::Packet()", "bool Packet::isValidPathLen(", "uint8_t Packet::copyPath(", "size_t Packet::writePath(")), @@ -370,6 +523,26 @@ class RoomClientPathTests(unittest.TestCase): def test_actual_post_and_topic_pushes_share_ack_and_retry_state(self): self.run_case("push", "actual room post and topic pushes share copies, ACK state, and primary retry ownership passed") + def test_actual_radio_catchup_frames_are_own_only_and_keep_newest(self): + self.run_case("catchup", "actual radio catch-up keeps newest, skips before UTC dates, and only changes own client passed") + + def test_catchup_correlated_retries_revoked_roles_invalid_commands_and_save_failure(self): + self.run_case("catchup-retry", "catch-up cache executes once, role revocation and invalid families deny, and storage failure rolls back passed") + + def test_admin_full_identity_routes_and_catchup_without_promoting_target_role(self): + self.run_case("admin-clients", "admin targets full identities without role promotion while ordinary users cannot edit another client passed") + + def test_actual_general_cli_dispatches_management_after_remote_admin_admission(self): + source = (ROOT / "examples/simple_room_server/MyMesh.cpp").read_text() + command = extract_braced(source, "void MyMesh::handleCommand(uint32_t sender_timestamp,") + self.assertIn("if (handleRoomManagementCommand(command, reply)) return;", command) + receive = extract_braced(source, "void MyMesh::onPeerDataRecv(") + admin = extract_braced(receive, "if (client->isAdmin()) {") + self.assertIn("handleCommand(sender_timestamp,", admin) + + def test_actual_keepalive_echo_cannot_undo_skip_and_exact_retries_still_ack(self): + self.run_case("catchup-poll", "normal keep-alive echoes preserve selected catch-up while forward cursors and exact ACK retries work passed") + def test_negative_controls_detect_rollback_cache_authorization_and_retry_regressions(self): controls = ( ("rollback", "memcpy(selected, previous, sizeof(previous));", @@ -378,6 +551,8 @@ class RoomClientPathTests(unittest.TestCase): "if (false && cached_retry && !client->isAdmin() && !own_path_allowed) {", "retries"), ("alternate-retries", "_prefs.direct_retry_enabled = 0;", "_prefs.direct_retry_enabled = 1;", "alternate"), + ("poll-rewind", "if (forceSince > client->extra.room.sync_since) {", + "if (forceSince > 0) {", "catchup-poll"), ) for name, before, after, case in controls: with self.subTest(control=name): diff --git a/test/test_room_login_integration.py b/test/test_room_login_integration.py index be50bd09f..e7ee3863e 100644 --- a/test/test_room_login_integration.py +++ b/test/test_room_login_integration.py @@ -19,6 +19,7 @@ HARNESS = r''' #include #include #include +#include #include "filesystem.h" #include #include @@ -27,6 +28,7 @@ HARNESS = r''' #include #include #include +#include #include #include #include @@ -37,8 +39,16 @@ HARNESS = r''' @STR_HELPER@ #define MESH_CLIENT_REPEATER_ONLY 0 namespace mesh { -struct Identity { uint8_t pub_key[PUB_KEY_SIZE]; }; +struct Identity { + uint8_t pub_key[PUB_KEY_SIZE]; + bool matches(const Identity& other) const { + return memcmp(pub_key, other.pub_key, PUB_KEY_SIZE) == 0; + } +}; struct Utils { + static void sha256(uint8_t* out, size_t n, const uint8_t* data, size_t length) { + sha256(out, n, data, length, nullptr, 0); + } static void sha256(uint8_t* out, size_t n, const uint8_t* key, size_t key_n, const uint8_t* text, size_t text_n) { // Posting authorization and replay are tested here, not the hash primitive. @@ -51,6 +61,7 @@ struct Utils { @PACKET_PATH_CHECK@ } @CLIENT_INFO@; +@POST_INFO@; struct ReplayStorage { uint8_t key[PUB_KEY_SIZE] = {}; @@ -138,6 +149,8 @@ static uint32_t ticks = 0; static uint32_t millis() { return ticks; } struct MyMesh { FakeACL acl; + mesh::Identity self_id{}; + PostInfo retained_posts[MAX_UNSYNCED_POSTS]{}; MemoryFS policy_fs; MemoryFS* _fs = &policy_fs; mesh::RoomAccessPolicy room_access; @@ -148,10 +161,14 @@ struct MyMesh { void serviceRoomQuotas(); static bool saveFilter(ClientInfo*); bool handleClientPathCommand(ClientInfo*, char*, char*); + bool executeClientPathCommand(ClientInfo*, mesh::RoomClientPathCommand, const char*, char*); + bool handleRoomCatchUpCommand(ClientInfo*, char*, char*); + bool applyRoomCatchUpCommand(ClientInfo*, const char*, uint32_t, char*); + bool cancelActiveMessageRetries(const uint8_t*, uint32_t) { return true; } bool sendClientReply(ClientInfo*, mesh::Packet*, unsigned long, uint8_t); unsigned path_acks = 0; bool processAck(const uint8_t*) { ++path_acks; return true; } - uint8_t getUnsyncedCount(ClientInfo*) { return 2; } + uint8_t getUnsyncedCount(ClientInfo*); int getExtraAckTransmitCount() { return 0; } unsigned requests = 0; uint8_t last_request_type = 0; @@ -250,7 +267,11 @@ struct MyMesh { @QUOTA_SERVICE@ @SAVE_FILTER@ @CLIENT_PATH_HANDLER@ +@CLIENT_PATH_EXECUTE@ @SEND_CLIENT_REPLY@ +@ROOM_CATCHUP_HANDLER@ +@ROOM_CATCHUP_APPLY@ +@ROOM_UNSYNCED_COUNT@ static mesh::Identity identity(uint8_t n = 1) { mesh::Identity sender{}; @@ -618,6 +639,20 @@ static void observedLoginPath() { puts("actual flood login opens observed window, PATH captures, direct login clears, and expiry rejects passed"); } +static void reconnectCursor() { + for (uint32_t supplied : {uint32_t(0), uint32_t(300), uint32_t(500), uint32_t(700)}) { + MyMesh mesh; seedKnown(mesh, PERM_ACL_READ_WRITE); + mesh.acl.client.extra.room.sync_since = 500; + login(mesh, "guest", 100, supplied); + const uint32_t expected = supplied > 500 ? supplied : 500; + assert(mesh.acl.client.extra.room.sync_since == expected && mesh.creations == 1); + } + MyMesh new_client; + login(new_client, "guest", 100, 300); + assert(new_client.acl.client.extra.room.sync_since == 300 && new_client.creations == 1); + puts("existing reconnect retains catch-up floor and accepts newer cursors while new clients honor supplied history passed"); +} + static void readOnlyPosting() { for (const char* password : {"", "wrong"}) { MyMesh mesh; @@ -704,7 +739,7 @@ static void quotaCases() { assert(mesh.posts == 1 && mesh.post_acks == 2 && mesh.acl.client.extra.room.post_quota_used == 1); mesh.poll(1001, 50); assert(mesh.post_acks == 3 && mesh.acl.client.extra.room.poll_quota_used == 1); - assert(mesh.acl.client.last_timestamp == 1001 && mesh.acl.client.extra.room.sync_since == 50); + assert(mesh.acl.client.last_timestamp == 1001 && mesh.acl.client.extra.room.sync_since == 80); mesh.acl.client.extra.room.pending_ack = 0x12345678; mesh.acl.client.extra.room.pending_topic_revision = 55; const auto poll_retry_before = mesh.acl.client; @@ -712,7 +747,7 @@ static void quotaCases() { assert(mesh.post_acks == 4 && mesh.acl.client.extra.room.poll_quota_used == 1); assert(memcmp(&poll_retry_before, &mesh.acl.client, sizeof(poll_retry_before)) == 0); mesh.poll(1002, 60); - assert(mesh.post_acks == 4 && mesh.acl.client.last_timestamp == 1001 && mesh.acl.client.extra.room.sync_since == 50); + assert(mesh.post_acks == 4 && mesh.acl.client.last_timestamp == 1001 && mesh.acl.client.extra.room.sync_since == 80); login(mesh, "guest", 1002, 80); assert(mesh.acl.client.extra.room.post_quota_used == 1 && mesh.acl.client.extra.room.poll_quota_used == 1); mesh.poll(1001, 50); // Old exact retry ACK cannot rewind a refreshed/newer cursor. @@ -725,7 +760,7 @@ static void quotaCases() { assert(mesh.acl.client.extra.room.post_quota_used == 1 && mesh.acl.client.extra.room.poll_quota_used == 0); mesh.poll(1002, 60); // Earlier quota rejection must not have entered the retry cache. assert(mesh.post_acks == 7 && mesh.acl.client.extra.room.poll_quota_used == 1); - assert(mesh.acl.client.extra.room.sync_since == 60); + assert(mesh.acl.client.extra.room.sync_since == 80); ticks = 120100; mesh.store_ok = false; const auto post_floor = mesh.acl.client.extra.room.last_post_timestamp; const auto last_activity = mesh.acl.client.last_activity; @@ -774,6 +809,7 @@ int main(int argc, char** argv) { else if (!strcmp(argv[1], "quotas")) quotaCases(); else if (!strcmp(argv[1], "requests")) allRequestQuotaCases(); else if (!strcmp(argv[1], "observed")) observedLoginPath(); + else if (!strcmp(argv[1], "reconnect")) reconnectCursor(); else assert(false); } ''' @@ -795,7 +831,7 @@ class RoomLoginIntegrationTests(unittest.TestCase): constants = "\n".join(line for line in acl_header.splitlines() if line.startswith(("#define PERM_ACL_", "#define OUT_PATH_"))) for name in ("PUSH_NOTIFY_DELAY_MILLIS", "FIRMWARE_VER_LEVEL", "RESP_SERVER_LOGIN_OK", - "LAZY_CONTACTS_WRITE_DELAY", "ROOM_MESSAGE_CACHE_SIZE", "TXT_TYPE_PLAIN", + "LAZY_CONTACTS_WRITE_DELAY", "ROOM_MESSAGE_CACHE_SIZE", "MAX_POST_TEXT_LEN", "MAX_UNSYNCED_POSTS", "TXT_TYPE_PLAIN", "TXT_TYPE_CLI_DATA", "TXT_TYPE_CLI_COMMAND", "TXT_ACK_DELAY", "REPLY_DELAY_MILLIS", "REQ_TYPE_KEEP_ALIVE", "REQ_TYPE_GET_STATUS", "REQ_TYPE_GET_TELEMETRY_DATA", "REQ_TYPE_GET_ACCESS_LIST"): match = re.search(r"^\s*#define\s+" + name + r"\s+.*$", room, re.MULTILINE) if match is None: @@ -824,13 +860,22 @@ class RoomLoginIntegrationTests(unittest.TestCase): "@PACKET_CONSTRUCTOR@": extract_braced(packet, "Packet::Packet()"), "@PACKET_PATH_CHECK@": extract_braced(packet, "bool Packet::isValidPathLen("), "@CLIENT_INFO@": extract_braced(acl_header, "struct ClientInfo {"), + "@POST_INFO@": extract_braced(room_header, "struct PostInfo {"), "@REPLAY_HANDLER@": extract_braced(acl_source, "bool ClientACL::authorizeLoginTimestamp("), "@PEER_HANDLER@": extract_braced(room, "void MyMesh::onPeerDataRecv("), "@PATH_HANDLER@": extract_braced(room, "bool MyMesh::onPeerPathRecv("), "@QUOTA_SERVICE@": extract_braced(room, "void MyMesh::serviceRoomQuotas("), "@SAVE_FILTER@": extract_braced(room, "bool MyMesh::saveFilter("), "@CLIENT_PATH_HANDLER@": extract_braced(room, "bool MyMesh::handleClientPathCommand("), + "@CLIENT_PATH_EXECUTE@": extract_braced(room, "bool MyMesh::executeClientPathCommand("), "@SEND_CLIENT_REPLY@": extract_braced(room, "bool MyMesh::sendClientReply("), + "@ROOM_CATCHUP_HANDLER@": extract_braced(room, "bool MyMesh::handleRoomCatchUpCommand("), + # Only the field name changes: retain the posting counter used by + # this older login fixture while executing the real retention code. + "@ROOM_CATCHUP_APPLY@": re.sub(r"\bposts\b", "retained_posts", extract_braced( + room, "bool MyMesh::applyRoomCatchUpCommand(")), + "@ROOM_UNSYNCED_COUNT@": re.sub(r"\bposts\b", "retained_posts", extract_braced( + room, "uint8_t MyMesh::getUnsyncedCount(")), "@LOGIN_HANDLER@": extract_braced(room, "void MyMesh::onAnonDataRecv("), } generated = HARNESS @@ -851,6 +896,9 @@ class RoomLoginIntegrationTests(unittest.TestCase): compiled = subprocess.run(command, capture_output=True, text=True, timeout=60) if compiled.returncode != 0: raise AssertionError(compiled.stdout + compiled.stderr) + cls.generated = generated + cls.compile_command = command + cls.source = source def run_case(self, case, expected): checked = subprocess.run([str(self.binary), case], capture_output=True, text=True, timeout=15) @@ -872,6 +920,23 @@ class RoomLoginIntegrationTests(unittest.TestCase): def test_observed_route_uses_actual_login_and_path_handlers_with_timeout(self): self.run_case("observed", "actual flood login opens observed window, PATH captures, direct login clears, and expiry rejects passed") + def test_existing_reconnect_preserves_skip_floor_and_new_clients_choose_history_cursor(self): + self.run_case("reconnect", "existing reconnect retains catch-up floor and accepts newer cursors while new clients honor supplied history passed") + + def test_negative_control_detects_old_reconnect_cursor_assignment(self): + before = "client->extra.room.sync_since = std::max(previous_sync_since, sender_sync_since);" + self.assertEqual(self.generated.count(before), 1) + source = self.source.parent / "negative-reconnect.cpp" + source.write_text(self.generated.replace(before, "client->extra.room.sync_since = sender_sync_since;"), encoding="ascii") + binary = self.source.parent / "negative-reconnect" + command = [str(source) if arg == str(self.source) else str(binary) if arg == str(self.binary) + else arg for arg in self.compile_command] + compiled = subprocess.run(command, capture_output=True, text=True, timeout=60) + self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr) + checked = subprocess.run([str(binary), "reconnect"], capture_output=True, text=True, timeout=15) + self.assertNotEqual(checked.returncode, 0, "old reconnect assignment must undo a selected skip") + self.assertIn("Assertion", checked.stderr, checked.stdout + checked.stderr) + def test_all_room_request_types_consume_poll_budget_and_preserve_exact_retries(self): self.run_case("requests", "actual request quota covers status, telemetry, ACL, board, and exact payload retries passed") diff --git a/test/test_room_web_service.py b/test/test_room_web_service.py index ceefc51b3..5ce75ee6f 100644 --- a/test/test_room_web_service.py +++ b/test/test_room_web_service.py @@ -30,9 +30,28 @@ class RoomWebServiceTest(unittest.TestCase): production = "\n".join(extract_braced(source, signature) for signature in ( "bool MyMesh::handleRoomWebCommand(", "static const char* roomWebString(", "void MyMesh::processRoomRequest(", "void MyMesh::serviceRoomQuotas(", - "bool MyMesh::storePost(", "bool MyMesh::snapshotRoomHistory(")) - roles = "\n".join(line for line in (ROOT / "src/helpers/ClientACL.h").read_text().splitlines() - if line.startswith("#define PERM_ACL_")) + "bool MyMesh::storePost(", "bool MyMesh::snapshotRoomHistory(", + "bool MyMesh::executeClientPathCommand(", "bool MyMesh::setRoomClientPath(", + "bool MyMesh::applyRoomCatchUpCommand(", "bool MyMesh::saveFilter(", + "void MyMesh::writeRoomClientJson(", + "uint8_t MyMesh::getUnsyncedCount(", "bool MyMesh::handleRoomHistoryCommand(", + "bool MyMesh::handleRoomTopicCommand(", "void MyMesh::activateRoomTopic(")) + packet = (ROOT / "src/Packet.cpp").read_text() + production = "namespace mesh {\n" + extract_braced( + packet, "bool Packet::isValidPathLen(") + "\n}\n" + production + utils = (ROOT / "src/Utils.cpp").read_text() + production = "namespace mesh {\n" + "\n".join(extract_braced(utils, signature) + for signature in ("static uint8_t hexVal(", "bool Utils::isHexChar(", + "bool Utils::fromHex(")) + "\n}\n" + production + production = next(line for line in source.splitlines() + if line.startswith("#define LAZY_CONTACTS_WRITE_DELAY")) + "\n" + production + acl = (ROOT / "src/helpers/ClientACL.h").read_text() + acl_source = (ROOT / "src/helpers/ClientACL.cpp").read_text() + production += "\n" + "\n".join(extract_braced(acl_source, signature).replace( + "ClientACL::", "ACL::") for signature in ( + "ClientInfo* ClientACL::putClient(", "bool ClientACL::applyPermissions(")) + roles = "\n".join(line for line in acl.splitlines() + if line.startswith(("#define PERM_ACL_", "#define OUT_PATH_"))) sanitizers = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else []) # 64-bit host slots are twice ESP32's size. This reproduces the real @@ -45,6 +64,7 @@ class RoomWebServiceTest(unittest.TestCase): (work / "filesystem.h").write_text((ROOT / "test/fixtures/room_history_store/filesystem.h").read_text()) (work / "production.inc").write_text(production) (work / "roles.inc").write_text(roles) + (work / "client.inc").write_text(extract_braced(acl, "struct ClientInfo") + ";") (work / "post.inc").write_text(extract_braced(header, "struct PostInfo {") + ";") web_user = extract_braced(header, "struct RoomWebUser {") (work / "web_user.inc").write_text(web_user + " room_web_users[8] = {};") @@ -59,6 +79,9 @@ class RoomWebServiceTest(unittest.TestCase): checked = subprocess.run([str(binary)], capture_output=True, text=True, timeout=60) self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr) self.assertIn("actual room web service", checked.stdout) + for group in ("ADMIN_AUTH", "ADMIN_ROUTES", "ADMIN_CATCHUP", "ADMIN_USERS", + "ADMIN_SETTINGS", "ADMIN_INVALID"): + self.assertRegex(checked.stdout, r"(?m)^" + group + ":") for kind in ("STATUS", "INDEX", "READ"): match = re.search(r"^BOUNDARY_" + kind + r" (.+)$", checked.stdout, re.M) self.assertIsNotNone(match) diff --git a/test/test_room_web_ui.py b/test/test_room_web_ui.py index 84afbb3d6..6a947755e 100644 --- a/test/test_room_web_ui.py +++ b/test/test_room_web_ui.py @@ -29,6 +29,16 @@ const record=(url,options)=>{let p=options.body?JSON.parse(options.body):null;ca const mark=(key,value)=>document.body.setAttribute('data-test-'+key,String(value)); ''' +ADMIN_COMMON = r''' +const radioKey='ab'.repeat(32),otherKey='ab'.repeat(6)+'cd'.repeat(26); +const radioUser=(key=radioKey,extra={})=>Object.assign({key:key,role:1,retained:true,active:true,heard_ago:12,pending_count:4, + delivery:'post',failures:0,outpath:'A1',altpath:'B1',observed:'A2',observed_pending:false,sync_since:100, + pending_post:101,topic_pending:0,ack_wait_ms:700,banned:false},extra); +const adminPage=(users,next=null)=>({users:users,next:next,total:3,active:2,backlog:6,pending:1,failed:1,boot:71}); +const baseRoom=p=>p.op==='status'?response(Object.assign({},boundary.status,{role:3})):p.op==='posts'?response({post:null}):p.op==='board.index'?response({revision:0,articles:[],next:255}):null; +const joinedRoom=async()=>{submit('join');await until(()=>!$('room').hidden&&$('notices').textContent==='No notices published.')}; +''' + class RoomWebUiTest(unittest.TestCase): def run_page(self, scenario, virtual_time=7000): @@ -37,6 +47,9 @@ class RoomWebUiTest(unittest.TestCase): self.fail("Chromium is required for room browser validation") self.skipTest("Chromium-family browser is unavailable") source = HEADER.read_text().split('R"roomhtml(', 1)[1].split(')roomhtml";', 1)[0] + transform = getattr(self, "page_transform", None) + if transform: + source = transform(source) fixture = json.loads(BOUNDARY.read_text()) prelude = "" self.assertIn("