mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-10-20 15:01:58 +00:00
Add room catch-up and Wi-Fi administration
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 <complete-64-hex-public-key> outpath A1B2C3,D4E5F6
|
||||
room.user <complete-64-hex-public-key> altpath 71CE82,BA09F0
|
||||
room.user <complete-64-hex-public-key> altpath clear
|
||||
room.user <complete-64-hex-public-key> keep 20
|
||||
room.user <complete-64-hex-public-key> 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://<reported-radio-IP>/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
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
#include <helpers/ClientPathPersistence.h>
|
||||
#include <helpers/ClientPathObservation.h>
|
||||
#include <helpers/RoomClientPathCommand.h>
|
||||
#include <helpers/RoomCatchUp.h>
|
||||
#include <helpers/LazyPersistence.h>
|
||||
#if MESH_PACKET_LOGGING
|
||||
#include <helpers/SerialPacketLog.h>
|
||||
@@ -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<const char*>()) return nullptr;
|
||||
const JsonString text = value.as<JsonString>();
|
||||
@@ -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<uint32_t>()
|
||||
|| input["boot"].as<uint32_t>() != 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<uint16_t>() || input["cursor"].as<uint16_t>() > acl.getNumClients()) {
|
||||
error("invalid user cursor; reload users"); return;
|
||||
}
|
||||
const uint16_t cursor = input["cursor"].as<uint16_t>();
|
||||
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<bool>() || !input["expected"].is<bool>()) {
|
||||
error("history setting and expected setting required"); return;
|
||||
}
|
||||
if (input["expected"].as<bool>() != room_history_enabled) {
|
||||
error("history setting changed; refresh before editing"); return;
|
||||
}
|
||||
if (input["enabled"].as<bool>() != room_history_enabled) {
|
||||
handleRoomHistoryCommand(input["enabled"].as<bool>() ? "set room.history on"
|
||||
: "set room.history off", reply);
|
||||
}
|
||||
} else if (strcmp(operation, "admin.rates") == 0) {
|
||||
if (!input["post_rate"].is<uint16_t>() || !input["poll_rate"].is<uint16_t>()) {
|
||||
error("rates must be 0-65535"); return;
|
||||
}
|
||||
if (!room_access.setRates(_fs, input["post_rate"].as<uint16_t>(), input["poll_rate"].as<uint16_t>())) {
|
||||
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<bool>()) { error("ban setting required"); return; }
|
||||
const bool banned = input["banned"].as<bool>();
|
||||
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<uint32_t>()
|
||||
|| input["expected_sync"].as<uint32_t>() != 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<uint32_t>()
|
||||
: !input["count"].is<uint16_t>())) {
|
||||
error("use before timestamp or keep count"); return;
|
||||
}
|
||||
applyRoomCatchUpCommand(client, mode, strcmp(mode, "before") == 0
|
||||
? input["before"].as<uint32_t>() : input["count"].as<uint16_t>(), reply);
|
||||
} else if (strcmp(operation, "admin.access") == 0) {
|
||||
if (!input["role"].is<uint8_t>() || input["role"].as<uint8_t>() > PERM_ACL_ADMIN
|
||||
|| !input["expected_role"].is<uint8_t>()
|
||||
|| input["expected_role"].as<uint8_t>() != (client->permissions & PERM_ACL_ROLE_MASK)) {
|
||||
error("invalid role or access changed; refresh before editing"); return;
|
||||
}
|
||||
const uint8_t selected = input["role"].as<uint8_t>();
|
||||
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 <UTC date> or keep <count>"); 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 <UTC date> or keep <count>"); 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;
|
||||
|
||||
@@ -36,12 +36,15 @@
|
||||
#include <helpers/LogicalMessageCache.h>
|
||||
#include <helpers/RemoteCliReplyCache.h>
|
||||
#include <helpers/RemoteCliRequest.h>
|
||||
#include <helpers/RoomClientPathCommand.h>
|
||||
#include <helpers/RegionMap.h>
|
||||
#include "FloodRuleEngine.h"
|
||||
#include <helpers/RoutingPolicy.h>
|
||||
#include <RTClib.h>
|
||||
#include <target.h>
|
||||
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
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<uint32_t>(*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<uint32_t>(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<uint64_t>(days) * 86400
|
||||
+ hour * 3600 + minute * 60 + second;
|
||||
if (timestamp > UINT32_MAX) return false;
|
||||
result = static_cast<uint32_t>(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 <typename Client, typename Post>
|
||||
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 <typename Client>
|
||||
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
|
||||
@@ -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 <typename Hash>
|
||||
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; }
|
||||
|
||||
+265
-16
@@ -8,13 +8,14 @@
|
||||
static const char ROOM_WEB_HTML[] PROGMEM = R"roomhtml(<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>MeshCore Room</title><style>
|
||||
:root{color-scheme:dark}body{font:16px system-ui;background:#111820;color:#eee;max-width:720px;margin:auto;padding:20px}
|
||||
:root{color-scheme:dark}body{font:16px system-ui;background:#111820;color:#eee;max-width:1000px;margin:auto;padding:20px}
|
||||
input,textarea,button,select{font:inherit;border:1px solid #6b7786;border-radius:6px;padding:9px;background:#202b38;color:inherit}
|
||||
input,textarea{box-sizing:border-box;width:100%;margin:5px 0 14px}textarea{min-height:85px}button{cursor:pointer;margin:4px 6px 4px 0}
|
||||
button:disabled{opacity:.45;cursor:default}label{display:block}.muted{color:#b5beca}#error{color:#ffb9b9;white-space:pre-wrap}
|
||||
#topic,#article,.post{white-space:pre-wrap;overflow-wrap:anywhere}#article{border:1px solid #6b7786;padding:14px}
|
||||
.post{padding:10px 0;border-bottom:1px solid #364351}.post small{display:block;color:#b5beca}
|
||||
details{margin:18px 0}summary{cursor:pointer}code{overflow-wrap:anywhere}a{color:#a8d5ff}[hidden]{display:none!important}
|
||||
.panel{border:1px solid #526273;border-radius:10px;padding:18px;margin:20px 0}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:10px}.card{background:#202b38;padding:12px;border-radius:6px}.card strong{display:block;font-size:1.3em}.table-wrap{overflow:auto}table{border-collapse:collapse;width:100%;font-size:.92em}th,td{text-align:left;padding:9px 7px;border-bottom:1px solid #364351;vertical-align:top}th{color:#b5beca}.key{font-family:monospace;overflow-wrap:anywhere}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:16px}.notice{padding:10px;background:#273947;border-radius:6px}.warning{color:#ffd3a3}.success{color:#a5e1b1}dl{display:grid;grid-template-columns:minmax(110px,1fr) 2fr;gap:8px}dt{color:#b5beca}dd{margin:0;overflow-wrap:anywhere}select{max-width:100%}.inline{display:flex;flex-wrap:wrap;align-items:center;gap:8px}.inline label{display:inline}.inline input{width:auto;margin:0}fieldset{border:1px solid #526273;border-radius:6px;margin:14px 0;padding:12px}.small{font-size:.9em}
|
||||
</style></head><body>
|
||||
<h1 id="room-name">MeshCore Room</h1>
|
||||
<p class="muted">Chat and selected notices over local Wi-Fi. This page also works without internet access.</p>
|
||||
@@ -27,24 +28,53 @@ details{margin:18px 0}summary{cursor:pointer}code{overflow-wrap:anywhere}a{color
|
||||
<details><summary>Access and history</summary><p id="history"></p>
|
||||
<p>Your browser identity for room moderation: <code id="web-key"></code></p>
|
||||
<p class="muted">This browser identity is separate from a radio's public key. The password stays in memory for this session.</p></details>
|
||||
<button id="refresh" type="button">Refresh chat</button><div id="posts" aria-live="polite"></div>
|
||||
<button id="refresh" type="button">Refresh chat</button><button id="disconnect" type="button">Disconnect</button><div id="posts" aria-live="polite"></div>
|
||||
<details id="browser-catchup"><summary>Choose chat shown in this browser</summary>
|
||||
<p class="muted">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.</p>
|
||||
<form id="browser-catchup-form" class="grid"><div><label for="browser-skip-date">Show messages from this date and time (local time)</label><input id="browser-skip-date" type="datetime-local"><button id="browser-skip-before" type="button">Show from date</button></div>
|
||||
<div><label for="browser-skip-count">Show the newest number of retained messages</label><input id="browser-skip-count" type="number" min="1" max="32" value="10"><button id="browser-skip-number" type="button">Show newest messages</button></div></form>
|
||||
<p id="browser-catchup-result" class="muted" role="status"></p></details>
|
||||
<form id="compose"><label for="message">Message</label><textarea id="message"></textarea>
|
||||
<p id="budget" class="muted"></p><button id="send" type="submit">Send</button></form>
|
||||
<h2>Information board</h2><button id="refresh-board" type="button">Refresh notices</button>
|
||||
<div id="notices"></div><h3 id="article-title" hidden></h3><pre id="article" hidden></pre>
|
||||
<section id="editor" hidden><h3>Publish or edit a notice</h3><form id="publish">
|
||||
<label for="article-id">Article slot (1–8)</label><select id="article-id">
|
||||
<label for="article-id">Article slot (1-8)</label><select id="article-id">
|
||||
<option>1</option><option>2</option><option>3</option><option>4</option><option>5</option><option>6</option><option>7</option><option>8</option></select>
|
||||
<label for="title">Title (up to 63 UTF-8 bytes)</label><input id="title">
|
||||
<label for="body">Article (up to 2,048 UTF-8 bytes)</label><textarea id="body"></textarea>
|
||||
<button type="submit">Save notice</button><button id="delete" type="button">Delete selected notice</button></form></section>
|
||||
<section id="admin-panel" class="panel" hidden><h2>Room administration</h2>
|
||||
<p class="muted">Manage radio users, their room-to-user routes, and chat delivery. Activity and delivery details are available only with admin access.</p>
|
||||
<div class="inline"><button id="admin-refresh" type="button">Load users and delivery</button><label><input id="admin-live" type="checkbox"> Refresh every 15 seconds</label></div>
|
||||
<p id="admin-state" role="status" class="muted">Load users to begin.</p>
|
||||
<div id="admin-cards" class="cards" hidden></div>
|
||||
<div class="table-wrap"><table id="admin-users" hidden><thead><tr><th scope="col">User</th><th scope="col">Access</th><th scope="col">Activity</th><th scope="col">Unread retained</th><th scope="col">Delivery</th><th scope="col">Manage</th></tr></thead><tbody id="admin-user-rows"></tbody></table></div>
|
||||
<div class="inline"><button id="admin-previous" type="button" disabled>Previous users</button><button id="admin-next" type="button" disabled>Next users</button><span id="admin-page" class="muted"></span></div>
|
||||
<section id="admin-selected" hidden><h3>Selected radio user</h3><p id="admin-selected-key" class="key"></p><button id="admin-selected-refresh" type="button">Reload selected user</button><dl id="admin-details"></dl>
|
||||
<div class="grid"><form id="admin-outpath"><fieldset><legend>Primary route (outpath)</legend><label for="admin-out-mode">Route mode</label><select id="admin-out-mode"><option value="path">Explicit path</option><option value="direct">Direct</option><option value="flood">Flood</option><option value="clear">Clear saved route</option></select><label for="admin-out-value">Repeater path</label><input id="admin-out-value" spellcheck="false" autocomplete="off" placeholder="A1,B2 or A100,B200"><button type="submit">Save primary route</button></fieldset></form>
|
||||
<form id="admin-altpath"><fieldset><legend>Alternate route (altpath)</legend><label for="admin-alt-mode">Route mode</label><select id="admin-alt-mode"><option value="path">Explicit path</option><option value="direct">Direct</option><option value="clear">No secondary copy (clear)</option></select><label for="admin-alt-value">Repeater path</label><input id="admin-alt-value" spellcheck="false" autocomplete="off" placeholder="C1,D2 or C100,D200"><button type="submit">Save alternate route</button></fieldset></form></div>
|
||||
<p class="muted small">These are routes from the room server to this user. A route change does not grant access or change the user's role. Clear on the primary route allows normal route discovery. Clear on the alternate route disables the secondary copy; an alternate route is an additional copy, not a second delivery queue.</p>
|
||||
<fieldset><legend>Advance this user's chat catch-up</legend><p class="warning">Skipped messages remain in room history but will no longer be delivered as unread chat to this user. Other users are unchanged.</p>
|
||||
<div class="grid"><div><label for="admin-skip-date">Skip chat before this date and time (local time)</label><input id="admin-skip-date" type="datetime-local"><button id="admin-skip-before" type="button">Review date skip</button></div><div><label for="admin-skip-count">Keep the newest number of unread retained messages (0 skips all)</label><input id="admin-skip-count" type="number" min="0" max="32" value="10"><button id="admin-skip-number" type="button">Review older-message skip</button></div></div></fieldset>
|
||||
<fieldset><legend>User access and moderation</legend><p class="muted small">Saved access uses this complete public key. Blocking also stops an existing room session. Password-based access remains separate from a saved ACL role.</p>
|
||||
<label for="admin-access-role">Saved role</label><select id="admin-access-role"><option value="0">Remove saved access</option><option value="1">Read only</option><option value="2">Read and write</option><option value="3">Admin</option></select><button id="admin-access-save" type="button">Review access change</button><button id="admin-ban-selected" type="button">Block selected user</button></fieldset>
|
||||
<p id="admin-result" role="status"></p></section>
|
||||
<details><summary>Room settings and blocked identities</summary><button id="admin-settings-load" type="button">Load room settings</button>
|
||||
<section id="admin-settings-panel" hidden><div class="grid"><form id="admin-topic"><label for="admin-topic-value">Room topic (up to 151 UTF-8 bytes)</label><textarea id="admin-topic-value"></textarea><button type="submit">Save topic</button></form>
|
||||
<div><label><input id="admin-history-enabled" type="checkbox"> Keep the newest 32 chat posts across reboots</label><button id="admin-history-save" type="button">Save history setting</button><p class="muted small">Turning this off keeps the existing archive; it does not erase messages.</p></div>
|
||||
<form id="admin-rates"><label for="admin-post-rate">Posts per user per minute (0 disables this limit)</label><input id="admin-post-rate" type="number" min="0" max="65535"><label for="admin-poll-rate">Polls per user per minute (0 disables this limit)</label><input id="admin-poll-rate" type="number" min="0" max="65535"><button type="submit">Save rate limits</button></form></div><p id="admin-settings-result" role="status"></p></section>
|
||||
<fieldset><legend>Block or unblock a complete identity</legend><label for="admin-ban-key">Radio or browser public identity (64 hexadecimal characters)</label><input id="admin-ban-key" class="key" spellcheck="false" autocomplete="off" maxlength="64"><button id="admin-ban-add" type="button">Review block</button><button id="admin-ban-remove" type="button">Review unblock</button><p id="admin-ban-result" role="status"></p></fieldset></details></section>
|
||||
</section><p><a href="/">Radio configuration</a></p>
|
||||
<script>
|
||||
'use strict';
|
||||
const $=id=>document.getElementById(id), encoder=new TextEncoder();
|
||||
let token='', sequence=0, password='', boot=0, role=0, after=0, joined=false, polling=false;
|
||||
let token='', sequence=0, password='', boot=0, role=0, after=0, joined=false, polling=false, connectionGeneration=0;
|
||||
let chain=Promise.resolve(), notices=new Map(), transfers=new Map();
|
||||
let sending=false, editId=1, editVersion=0;
|
||||
let adminLoaded=false, adminLoading=false, adminBusy=false, adminCursor=0, adminNext=null, adminPages=[];
|
||||
let selectedKey='', selectedUser=null, adminSettings=null;
|
||||
const routeDirty={outpath:false,altpath:false},routeBaseline={outpath:'',altpath:''};
|
||||
function saved(key,fallback){try{return localStorage.getItem(key)||fallback}catch(e){return fallback}}
|
||||
function remember(key,value){try{localStorage.setItem(key,String(value))}catch(e){}}
|
||||
function makeIdentity(){
|
||||
@@ -54,7 +84,7 @@ function makeIdentity(){
|
||||
token=Array.from(bytes,b=>b.toString(16).padStart(2,'0')).join('');remember('mc-room-token',token);
|
||||
}
|
||||
sequence=Number(saved('mc-room-sequence','0'));
|
||||
if(!Number.isSafeInteger(sequence)||sequence<0||sequence>=4294967295) throw Error('Browser sequence exhausted. Clear this site’s stored room identity to reconnect.');
|
||||
if(!Number.isSafeInteger(sequence)||sequence<0||sequence>=4294967295) throw Error("Browser sequence exhausted. Clear this site's stored room identity to reconnect.");
|
||||
}
|
||||
function message(error){$('error').textContent=error?String(error.message||error):''}
|
||||
const pause=ms=>new Promise(resolve=>setTimeout(resolve,ms));
|
||||
@@ -64,21 +94,23 @@ async function fetchBounded(url,options){
|
||||
finally{clearTimeout(timer)}
|
||||
}
|
||||
function api(op,values={}){
|
||||
const intent=Object.assign({},values,{boot:boot});
|
||||
const run=()=>request(op,intent), result=chain.then(run,run);
|
||||
const intent=Object.assign({},values,{boot:boot}),generation=connectionGeneration,secret=password;
|
||||
const run=()=>request(op,intent,generation,secret), result=chain.then(run,run);
|
||||
chain=result.catch(()=>{});return result;
|
||||
}
|
||||
async function request(op,values){
|
||||
async function request(op,values,generation,secret){
|
||||
if(generation!==connectionGeneration)throw Error('Room connection changed. Reconnect before trying again.');
|
||||
if(++sequence>4294967295)throw Error('Browser sequence exhausted.');
|
||||
remember('mc-room-sequence',sequence);
|
||||
const headers={'Content-Type':'application/json','X-Room-Token':token,'X-Room-Seq':String(sequence)};
|
||||
const body=JSON.stringify(Object.assign({},values,{op:op,password:password}));
|
||||
if(encoder.encode(body).length>4096)throw Error('Request exceeds the radio’s 4 KB limit.');
|
||||
const body=JSON.stringify(Object.assign({},values,{op:op,password:secret}));
|
||||
if(encoder.encode(body).length>4096)throw Error("Request exceeds the radio's 4 KB limit.");
|
||||
const deadline=Date.now()+20000;
|
||||
let accepted=false;
|
||||
// An interrupted submission retries the identical sequence and body. Once
|
||||
// admitted, only poll its result. Never silently resend with a new identity.
|
||||
while(Date.now()<deadline){
|
||||
if(generation!==connectionGeneration)throw Error('Room connection changed. Reconnect before trying again.');
|
||||
let response;
|
||||
try{response=await fetchBounded(accepted?'/api/room/result':'/api/room',
|
||||
{method:'POST',headers:headers,body:accepted?undefined:body})}
|
||||
@@ -87,6 +119,7 @@ async function request(op,values){
|
||||
if(response.status===202){accepted=true;await pause(250);continue}
|
||||
let result;
|
||||
try{result=await response.json()}catch(e){throw Error('Invalid response; refresh to check before retrying.')}
|
||||
if(generation!==connectionGeneration)throw Error('Room connection changed. Reconnect before trying again.');
|
||||
if(!response.ok||result.error)throw Error(result.error||('Room request failed ('+response.status+').'));
|
||||
return result;
|
||||
}
|
||||
@@ -99,13 +132,14 @@ function updateBudget(){
|
||||
}
|
||||
async function status(){
|
||||
const result=await api('status');
|
||||
if(boot&&boot!==result.boot){after=0;$('posts').replaceChildren();transfers.clear();notices.clear()}
|
||||
if(boot&&boot!==result.boot){after=0;$('posts').replaceChildren();transfers.clear();notices.clear();clearAdmin()}
|
||||
boot=result.boot;role=result.role;
|
||||
$('room-name').textContent=result.name;$('topic').textContent=result.topic;
|
||||
$('web-key').textContent=result.web_key;
|
||||
$('history').textContent=result.persistent_history?'Radio retains its newest 32 posts across reboots.':'Radio history is in RAM and is lost on reboot.';
|
||||
$('state').textContent=role===3?'Admin access':role===2?'Read and write access':'Read-only access';
|
||||
$('editor').hidden=role!==3;updateBudget();
|
||||
$('editor').hidden=role!==3;$('admin-panel').hidden=role!==3;
|
||||
if(role!==3)clearAdmin();updateAdminButtons();updateBudget();
|
||||
}
|
||||
async function refreshChat(){
|
||||
if(polling||!joined)return;polling=true;
|
||||
@@ -115,7 +149,7 @@ async function refreshChat(){
|
||||
const result=await api('posts',{after:after}), post=result.post;if(!post)break;
|
||||
if(!Number.isInteger(post.timestamp)||post.timestamp<=after)throw Error('Invalid message cursor.');
|
||||
const row=document.createElement('div'),stamp=document.createElement('small'),text=document.createElement('div');
|
||||
row.className='post';stamp.textContent=new Date(post.timestamp*1000).toLocaleString()+' · '+post.author.slice(0,12);
|
||||
row.className='post';stamp.textContent=new Date(post.timestamp*1000).toLocaleString()+' - '+post.author.slice(0,12);
|
||||
text.textContent=post.text;row.append(stamp,text);$('posts').append(row);
|
||||
while($('posts').childElementCount>32)$('posts').firstElementChild.remove();
|
||||
after=post.timestamp;
|
||||
@@ -143,8 +177,187 @@ function renderNotices(){
|
||||
button.addEventListener('click',()=>openArticle(item).catch(message));$('notices').append(button);
|
||||
}
|
||||
}
|
||||
function retainedCount(id,minimum=1){
|
||||
const value=$(id).value;
|
||||
if(!/^\d+$/.test(value)||Number(value)<minimum||Number(value)>32)throw Error('Choose a number from '+minimum+' to 32 retained messages.');
|
||||
return Number(value);
|
||||
}
|
||||
function dateSeconds(id){
|
||||
const value=$(id).value,seconds=Math.floor(new Date(value).getTime()/1000);
|
||||
if(!value||!Number.isInteger(seconds)||seconds<1||seconds>4294967295)throw Error('Choose a valid date and time.');
|
||||
return seconds;
|
||||
}
|
||||
async function browserCatchup(mode){
|
||||
if(!joined)throw Error('Connect to the room first.');
|
||||
if(polling)throw Error('Chat is refreshing. Try again when it finishes.');
|
||||
const before=mode==='before'?dateSeconds('browser-skip-date'):0;
|
||||
const count=mode==='keep'?retainedCount('browser-skip-count'):32;
|
||||
let skipped=0;polling=true;
|
||||
try{
|
||||
const unread=[];let cursor=0;
|
||||
for(let index=0;index<32;index++){
|
||||
const result=await api('posts',{after:cursor}),post=result.post;
|
||||
if(!post)break;
|
||||
if(!Number.isInteger(post.timestamp)||post.timestamp<=cursor)throw Error('Invalid message cursor.');
|
||||
if(mode==='before'&&post.timestamp>=before)break;
|
||||
// Advance only to an actual retained message. A future date must not
|
||||
// make this browser miss posts that arrive after this request.
|
||||
unread.push(post.timestamp);cursor=post.timestamp;
|
||||
}
|
||||
skipped=mode==='keep'?Math.max(0,unread.length-count):unread.length;
|
||||
after=skipped?unread[skipped-1]:0;
|
||||
$('posts').replaceChildren();$('browser-catchup-result').textContent='Hidden '+skipped+' older retained message'+(skipped===1?'':'s')+' in this browser view.';
|
||||
}finally{polling=false}
|
||||
await refreshChat();
|
||||
}
|
||||
function requireAdmin(){if(!joined||role!==3)throw Error('Admin permission required. Reconnect with the room admin password.');}
|
||||
function clearAdmin(){
|
||||
adminLoaded=false;adminCursor=0;adminNext=null;adminPages=[];selectedKey='';selectedUser=null;adminSettings=null;
|
||||
routeDirty.outpath=false;routeDirty.altpath=false;routeBaseline.outpath='';routeBaseline.altpath='';
|
||||
$('admin-user-rows').replaceChildren();$('admin-details').replaceChildren();$('admin-cards').replaceChildren();
|
||||
$('admin-selected-key').textContent='';$('admin-users').hidden=true;$('admin-selected').hidden=true;$('admin-cards').hidden=true;
|
||||
$('admin-live').checked=false;$('admin-state').textContent='Load users to begin.';$('admin-page').textContent='';$('admin-result').textContent='';
|
||||
$('admin-out-value').value='';$('admin-alt-value').value='';$('admin-settings-panel').hidden=true;
|
||||
$('admin-topic-value').value='';$('admin-ban-key').value='';$('admin-settings-result').textContent='';$('admin-ban-result').textContent='';updateAdminButtons();
|
||||
}
|
||||
function roleName(value){return({0:'Guest',1:'Read only',2:'Read and write',3:'Admin',4:'Region manager',5:'Filter manager'})[value]||('Role '+value);}
|
||||
function age(value){
|
||||
if(value===null||value===undefined)return 'Not heard this boot';
|
||||
if(value<60)return value+' seconds ago';if(value<3600)return Math.floor(value/60)+' minutes ago';
|
||||
return Math.floor(value/3600)+' hours ago';
|
||||
}
|
||||
function stamp(value){return value?new Date(value*1000).toLocaleString():'None';}
|
||||
function deliveryName(user){
|
||||
if(user.banned)return 'Blocked';if(user.failures>=3)return 'Paused after failures';
|
||||
if(user.delivery==='topic')return 'Waiting for topic ACK';if(user.delivery==='post')return 'Waiting for post ACK';
|
||||
return user.pending_count?'Ready to deliver':'Caught up';
|
||||
}
|
||||
function updateAdminButtons(){
|
||||
const locked=adminBusy||adminLoading||role!==3;
|
||||
for(const button of $('admin-panel').querySelectorAll('button'))button.disabled=locked;
|
||||
$('admin-previous').disabled=locked||!adminPages.length;$('admin-next').disabled=locked||adminNext===null;
|
||||
for(const input of $('admin-panel').querySelectorAll('input,select,textarea'))if(input.id!=='admin-live')input.disabled=locked;
|
||||
for(const prefix of ['admin-out','admin-alt'])$(prefix+'-value').disabled=locked||$(prefix+'-mode').value!=='path';
|
||||
}
|
||||
function validKey(key){return typeof key==='string'&&/^[0-9a-fA-F]{64}$/.test(key)&&!/^0+$/.test(key);}
|
||||
function renderAdminCards(result){
|
||||
$('admin-cards').replaceChildren();
|
||||
for(const [label,value] of [['Users',result.total],['Heard this boot',result.active],['Unread retained',result.backlog],['Awaiting ACK',result.pending],['Users with failures',result.failed]]){
|
||||
const card=document.createElement('div'),number=document.createElement('strong'),title=document.createElement('span');
|
||||
card.className='card';number.textContent=String(value);title.textContent=label;card.append(number,title);$('admin-cards').append(card);
|
||||
}
|
||||
$('admin-cards').hidden=false;
|
||||
}
|
||||
async function loadAdmin(cursor=adminCursor){
|
||||
requireAdmin();if(adminLoading||adminBusy)return;adminLoading=true;updateAdminButtons();
|
||||
try{
|
||||
const result=await api('admin.users',{cursor:cursor});requireAdmin();
|
||||
if(result.boot!==boot)throw Error('Radio restarted; refresh the room before managing users.');
|
||||
if(!Array.isArray(result.users)||result.users.length>2||result.users.some(user=>!validKey(user.key))
|
||||
||(result.next!==null&&(!Number.isInteger(result.next)||result.next<=cursor||result.next>65535)))throw Error('Invalid user page.');
|
||||
adminCursor=cursor;adminNext=result.next;adminLoaded=true;renderAdminCards(result);$('admin-user-rows').replaceChildren();
|
||||
for(const user of result.users){
|
||||
const row=document.createElement('tr');
|
||||
for(const text of [user.key.slice(0,12)+(user.retained?' (saved)':''),roleName(user.role),age(user.heard_ago),String(user.pending_count),deliveryName(user)]){
|
||||
const cell=document.createElement('td');cell.textContent=text;row.append(cell);
|
||||
}
|
||||
const cell=document.createElement('td'),button=document.createElement('button');button.type='button';button.textContent='Select';
|
||||
button.addEventListener('click',()=>selectAdminUser(user.key,true).catch(message));cell.append(button);row.append(cell);$('admin-user-rows').append(row);
|
||||
}
|
||||
$('admin-users').hidden=false;$('admin-page').textContent='Page '+(adminPages.length+1);
|
||||
$('admin-state').textContent=result.users.length?'Updated '+new Date().toLocaleTimeString()+'. Heard this boot means recorded room activity, not guaranteed current radio reachability.':'No radio users retained or connected.';
|
||||
if(selectedKey)await selectAdminUser(selectedKey,false);
|
||||
}finally{adminLoading=false;updateAdminButtons()}
|
||||
}
|
||||
function addDetail(label,value){const term=document.createElement('dt'),description=document.createElement('dd');term.textContent=label;description.textContent=String(value);$('admin-details').append(term,description);}
|
||||
function routeEditor(which,value,force){
|
||||
const prefix=which==='outpath'?'admin-out':'admin-alt';
|
||||
if(routeDirty[which]&&!force){
|
||||
if(routeBaseline[which]!==value)$('admin-result').textContent='A route changed on the radio. Your draft is preserved; refresh the selected user before replacing it.';
|
||||
return;
|
||||
}
|
||||
const mode=value==='direct'?'direct':value==='flood'?(which==='altpath'?'clear':'flood'):value==='unknown'||value==='clear'?'clear':'path';
|
||||
$(prefix+'-mode').value=mode;$(prefix+'-value').value=mode==='path'?value:'';$(prefix+'-value').disabled=mode!=='path'||adminBusy;
|
||||
routeBaseline[which]=value;routeDirty[which]=false;
|
||||
}
|
||||
async function selectAdminUser(key,force=false){
|
||||
requireAdmin();if(!validKey(key))throw Error('Select a complete radio public key.');
|
||||
const previous=selectedKey;selectedKey=key;
|
||||
try{
|
||||
const result=await api('admin.user',{key:key});requireAdmin();if(selectedKey!==key)return;
|
||||
if(result.boot!==boot||!result.user||result.user.key!==key)throw Error('User changed or radio restarted; refresh users.');
|
||||
selectedUser=result.user;$('admin-selected-key').textContent=key;$('admin-selected').hidden=false;$('admin-details').replaceChildren();
|
||||
const user=selectedUser;
|
||||
for(const [label,value] of [['Access',roleName(user.role)+(user.retained?' (saved ACL)':' (session only)')],['Activity',user.active?'Heard this boot':'No allowed room activity this boot'],['Last activity',age(user.heard_ago)],['Delivery',deliveryName(user)],['Unread retained posts',user.pending_count],['Chat cursor',stamp(user.sync_since)],['Pending post',stamp(user.pending_post)],['Topic revision waiting',user.topic_pending||'None'],['ACK wait',user.ack_wait_ms===null?'None':user.ack_wait_ms+' ms'],['Delivery failures',user.failures],['Primary route',user.outpath],['Alternate route',user.altpath],['Observed return path',user.observed_pending?'Awaiting path discovery':user.observed]])addDetail(label,value);
|
||||
routeEditor('outpath',user.outpath,force||previous!==key);routeEditor('altpath',user.altpath,force||previous!==key);
|
||||
$('admin-access-role').value=String(user.role<=3?user.role:1);$('admin-ban-selected').textContent=user.banned?'Unblock selected user':'Block selected user';
|
||||
updateAdminButtons();
|
||||
}catch(error){if(selectedKey===key&&previous!==key){selectedKey='';selectedUser=null;$('admin-selected').hidden=true}throw error}
|
||||
}
|
||||
async function adminMutation(action,success){
|
||||
requireAdmin();if(adminBusy)return;adminBusy=true;updateAdminButtons();$('admin-result').textContent='';
|
||||
try{await action();requireAdmin();$('admin-result').textContent=typeof success==='function'?success():success;}
|
||||
finally{adminBusy=false;updateAdminButtons()}
|
||||
}
|
||||
async function saveAdminRoute(which){
|
||||
if(!selectedUser)throw Error('Select a radio user first.');
|
||||
const prefix=which==='outpath'?'admin-out':'admin-alt',mode=$(prefix+'-mode').value;
|
||||
const value=mode==='path'?$(prefix+'-value').value.trim():mode;
|
||||
if(mode==='path'&&(!value||value.length>191||!/^[0-9a-fA-F, \t]+$/.test(value)))throw Error('Use a hexadecimal repeater path separated by commas or spaces.');
|
||||
const key=selectedKey,expected=routeBaseline[which];
|
||||
await adminMutation(async()=>{await api('admin.route',{key:key,which:which,value:value,expected:expected});
|
||||
if(selectedKey===key){routeDirty[which]=false;await selectAdminUser(key,false);}},'Saved '+which+' for '+key.slice(0,12)+'.');
|
||||
}
|
||||
async function adminCatchup(mode){
|
||||
requireAdmin();if(!selectedUser)throw Error('Select a radio user first.');
|
||||
const key=selectedKey,values={key:key,mode:mode,expected_sync:selectedUser.sync_since};
|
||||
let explanation;
|
||||
if(mode==='before'){values.before=dateSeconds('admin-skip-date');explanation='Skip unread chat before '+new Date(values.before*1000).toLocaleString();}
|
||||
else{values.count=retainedCount('admin-skip-count',0);explanation=values.count===0?'Skip all unread retained chat messages':'Keep only the newest '+values.count+' unread retained messages and skip the older backlog';}
|
||||
if(!confirm(explanation+' for radio '+key+'?\nThis advances only this user and does not delete room history.'))return;
|
||||
await adminMutation(async()=>{const result=await api('admin.catchup',values);if(selectedKey===key)await selectAdminUser(key,false);
|
||||
$('admin-state').textContent='Skipped '+result.skipped+' retained messages for '+key.slice(0,12)+'.';},'Chat catch-up updated.');
|
||||
}
|
||||
async function saveAdminAccess(){
|
||||
requireAdmin();if(!selectedUser)throw Error('Select a radio user first.');
|
||||
const key=selectedKey,selectedRole=Number($('admin-access-role').value),expected=selectedUser.role;
|
||||
if(!confirm('Set saved access to '+(selectedRole===0?'none':roleName(selectedRole))+' for radio '+key+'?'))return;
|
||||
let pendingSave=false;
|
||||
await adminMutation(async()=>{const result=await api('admin.access',{key:key,role:selectedRole,expected_role:expected});pendingSave=result.pending_save===true;
|
||||
if(selectedKey===key){
|
||||
if(selectedRole===0){selectedKey='';selectedUser=null;$('admin-selected').hidden=true;$('admin-selected-key').textContent='';$('admin-details').replaceChildren();routeDirty.outpath=false;routeDirty.altpath=false;}
|
||||
else await selectAdminUser(key,false);
|
||||
}},()=>pendingSave?'Access updated; keep power on for at least 5 seconds while it is saved.':'Access updated.');
|
||||
if(adminLoaded)await loadAdmin();
|
||||
}
|
||||
async function setAdminBan(key,banned){
|
||||
requireAdmin();if(!validKey(key))throw Error('Use a complete nonzero 64-character public identity.');
|
||||
if(!confirm((banned?'Block ':'Unblock ')+key+' for room access?'))return;
|
||||
await adminMutation(async()=>{await api('admin.ban',{key:key,banned:banned});
|
||||
if(selectedKey===key)await selectAdminUser(key,false);},banned?'Identity blocked.':'Identity unblocked.');
|
||||
$('admin-ban-result').textContent=banned?'Identity blocked.':'Identity unblocked.';
|
||||
}
|
||||
async function loadAdminSettings(){
|
||||
requireAdmin();const result=await api('admin.settings');requireAdmin();
|
||||
if(result.boot!==boot)throw Error('Radio restarted; refresh the room before changing settings.');
|
||||
adminSettings=result;$('admin-topic-value').value=result.topic;$('admin-history-enabled').checked=result.persistent_history;
|
||||
$('admin-post-rate').value=result.post_rate;$('admin-poll-rate').value=result.poll_rate;$('admin-settings-panel').hidden=false;
|
||||
}
|
||||
function rateValue(id){const value=$(id).value;if(!/^\d+$/.test(value)||Number(value)>65535)throw Error('Rate limits must be whole numbers from 0 to 65535.');return Number(value);}
|
||||
async function saveAdminSetting(which){
|
||||
requireAdmin();if(!adminSettings)throw Error('Load room settings before editing.');
|
||||
let values;
|
||||
if(which==='topic'){
|
||||
const value=$('admin-topic-value').value;
|
||||
if(encoder.encode(value).length>151)throw Error('The topic must fit in 151 UTF-8 bytes.');
|
||||
values={value:value,expected:adminSettings.topic};
|
||||
}else if(which==='history')values={enabled:$('admin-history-enabled').checked,expected:adminSettings.persistent_history};
|
||||
else values={post_rate:rateValue('admin-post-rate'),poll_rate:rateValue('admin-poll-rate')};
|
||||
await adminMutation(async()=>{await api('admin.'+which,values);await loadAdminSettings();},'Room '+which+' saved.');
|
||||
$('admin-settings-result').textContent='Saved. Changes are confirmed by the radio.';
|
||||
}
|
||||
async function openArticle(item){
|
||||
if(!Number.isInteger(item.length)||item.length<0||item.length>2048)throw Error('Article exceeds the radio’s limit.');
|
||||
if(!Number.isInteger(item.length)||item.length<0||item.length>2048)throw Error("Article exceeds the radio's limit.");
|
||||
let transfer=transfers.get(item.id);
|
||||
if(!transfer||transfer.version!==item.version){transfer={version:item.version,next:0,bytes:new Uint8Array(item.length)};transfers.set(item.id,transfer)}
|
||||
try{
|
||||
@@ -169,7 +382,8 @@ async function openArticle(item){
|
||||
}
|
||||
$('join').addEventListener('submit',async event=>{
|
||||
event.preventDefault();message('');
|
||||
try{makeIdentity();password=$('password').value;joined=false;await status();joined=true;$('room').hidden=false;updateBudget();await refreshChat();await refreshBoard()}
|
||||
connectionGeneration++;joined=false;role=0;clearAdmin();$('editor').hidden=true;$('admin-panel').hidden=true;$('room').hidden=true;updateBudget();
|
||||
try{makeIdentity();password=$('password').value;$('password').value='';await status();joined=true;$('room').hidden=false;updateBudget();await refreshChat();await refreshBoard()}
|
||||
catch(e){message(e)}
|
||||
});
|
||||
$('name').value=saved('mc-room-name','Web');
|
||||
@@ -182,6 +396,40 @@ $('compose').addEventListener('submit',async event=>{
|
||||
catch(e){message(e)}finally{sending=false;updateBudget()}
|
||||
});
|
||||
$('refresh').addEventListener('click',()=>refreshChat().catch(message));
|
||||
$('disconnect').addEventListener('click',()=>{
|
||||
connectionGeneration++;joined=false;role=0;password='';clearAdmin();$('room').hidden=true;$('admin-panel').hidden=true;$('editor').hidden=true;$('state').textContent='Disconnected';updateBudget();
|
||||
});
|
||||
$('browser-catchup-form').addEventListener('submit',event=>event.preventDefault());
|
||||
$('browser-skip-before').addEventListener('click',()=>browserCatchup('before').catch(message));
|
||||
$('browser-skip-number').addEventListener('click',()=>browserCatchup('keep').catch(message));
|
||||
$('admin-refresh').addEventListener('click',()=>loadAdmin().catch(message));
|
||||
$('admin-next').addEventListener('click',()=>{
|
||||
if(adminNext===null)return;const previous=adminCursor;adminPages.push(previous);
|
||||
loadAdmin(adminNext).catch(error=>{adminPages.pop();message(error);updateAdminButtons()});
|
||||
});
|
||||
$('admin-previous').addEventListener('click',()=>{
|
||||
if(!adminPages.length)return;const cursor=adminPages.pop();
|
||||
loadAdmin(cursor).catch(error=>{adminPages.push(cursor);message(error);updateAdminButtons()});
|
||||
});
|
||||
$('admin-selected-refresh').addEventListener('click',()=>{
|
||||
if((routeDirty.outpath||routeDirty.altpath)&&!confirm('Replace the unsaved route drafts with the current radio routes?'))return;
|
||||
selectAdminUser(selectedKey,true).catch(message);
|
||||
});
|
||||
for(const [which,prefix] of [['outpath','admin-out'],['altpath','admin-alt']]){
|
||||
$(prefix+'-value').addEventListener('input',()=>{routeDirty[which]=true});
|
||||
$(prefix+'-mode').addEventListener('change',()=>{routeDirty[which]=true;updateAdminButtons()});
|
||||
$(which==='outpath'?'admin-outpath':'admin-altpath').addEventListener('submit',event=>{event.preventDefault();saveAdminRoute(which).catch(message)});
|
||||
}
|
||||
$('admin-skip-before').addEventListener('click',()=>adminCatchup('before').catch(message));
|
||||
$('admin-skip-number').addEventListener('click',()=>adminCatchup('keep').catch(message));
|
||||
$('admin-access-save').addEventListener('click',()=>saveAdminAccess().catch(message));
|
||||
$('admin-ban-selected').addEventListener('click',()=>{if(selectedUser)setAdminBan(selectedKey,!selectedUser.banned).catch(message)});
|
||||
$('admin-ban-add').addEventListener('click',()=>setAdminBan($('admin-ban-key').value.trim(),true).catch(message));
|
||||
$('admin-ban-remove').addEventListener('click',()=>setAdminBan($('admin-ban-key').value.trim(),false).catch(message));
|
||||
$('admin-settings-load').addEventListener('click',()=>loadAdminSettings().catch(message));
|
||||
$('admin-topic').addEventListener('submit',event=>{event.preventDefault();saveAdminSetting('topic').catch(message)});
|
||||
$('admin-history-save').addEventListener('click',()=>saveAdminSetting('history').catch(message));
|
||||
$('admin-rates').addEventListener('submit',event=>{event.preventDefault();saveAdminSetting('rates').catch(message)});
|
||||
$('refresh-board').addEventListener('click',()=>refreshBoard().catch(message));
|
||||
$('article-id').addEventListener('change',()=>{editId=Number($('article-id').value);editVersion=0});
|
||||
$('publish').addEventListener('submit',async event=>{
|
||||
@@ -201,6 +449,7 @@ $('delete').addEventListener('click',async()=>{
|
||||
try{await api('board.delete',{id:id,version:entry.version});transfers.delete(id);await refreshBoard();$('article').hidden=true;$('article-title').hidden=true}
|
||||
catch(e){message(e)}
|
||||
});
|
||||
setInterval(()=>{if(joined)refreshChat().catch(message)},15000);
|
||||
setInterval(()=>{if(joined)refreshChat().catch(message);if(joined&&role===3&&adminLoaded&&$('admin-live').checked)loadAdmin().catch(message)},15000);
|
||||
updateBudget();
|
||||
updateAdminButtons();
|
||||
</script></body></html>)roomhtml";
|
||||
|
||||
Vendored
+211
@@ -0,0 +1,211 @@
|
||||
#include <algorithm>
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
#include <MeshCore.h>
|
||||
#include <helpers/RoomCatchUp.h>
|
||||
|
||||
@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<PostInfo>& 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<PostInfo> 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<PostInfo> 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<PostInfo> 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<PostInfo> 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<PostInfo> 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<PostInfo> 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<PostInfo> posts; std::vector<uint32_t> 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<size_t>(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);
|
||||
}
|
||||
Vendored
+56
-6
@@ -8,6 +8,7 @@
|
||||
#include <Packet.h>
|
||||
#include <helpers/RoomAccessPolicy.h>
|
||||
#include <helpers/RoomClientPathCommand.h>
|
||||
#include <helpers/RoomCatchUp.h>
|
||||
#include <helpers/ClientPathObservation.h>
|
||||
#include <helpers/ClientPathPersistence.h>
|
||||
#include <helpers/RemoteCliReplyCache.h>
|
||||
@@ -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> 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@
|
||||
|
||||
+4
@@ -7,6 +7,7 @@
|
||||
#include "../room_history_store/filesystem.h"
|
||||
#include <helpers/RoomAccessPolicy.h>
|
||||
#include <helpers/ClientPathPersistence.h>
|
||||
#include <helpers/RoomClientPathCommand.h>
|
||||
|
||||
#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.
|
||||
|
||||
+460
-15
@@ -27,8 +27,17 @@ static void* trackedRealloc(void* ptr, size_t size) {
|
||||
#include <helpers/RoomHistoryStore.h>
|
||||
#include <helpers/RoomAccessPolicy.h>
|
||||
#include <helpers/RoomLoginAuthorization.h>
|
||||
#include <helpers/ClientPathPersistence.h>
|
||||
#include <helpers/ClientPathObservation.h>
|
||||
#include <helpers/RoomClientPathCommand.h>
|
||||
#include <helpers/LazyPersistence.h>
|
||||
#include <helpers/RoomTopicStore.h>
|
||||
#include <helpers/RoomCatchUp.h>
|
||||
#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 <img src=x onerror=attack()>";
|
||||
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<ClientInfo> 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<const char*>() && strstr(reply["error"].as<const char*>(), "admin"));
|
||||
assert(reply.as<JsonObjectConst>().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<const char*>() && 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<const char*>());
|
||||
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<const char*>() && 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<const char*>() && 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<const char*>() && 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<JsonArray>() && page["users"].size() == expected);
|
||||
for (unsigned i = 0; i < expected; ++i) {
|
||||
const auto user = page["users"][i];
|
||||
assert(user["key"].as<std::string>() == 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<std::string>() == 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<std::string>().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<const char*>() && 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<const char*>() && 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<const char*>() && 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<const char*>() && 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<const char*>() && 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<const char*>() && 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<ClientInfo> 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<const char*>() && denied.as<JsonObjectConst>().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);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -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 <cassert>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
#include <openssl/sha.h>
|
||||
#include <MeshCore.h>
|
||||
#include <helpers/RoomClientPathCommand.h>
|
||||
@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<int> 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 <typename Entry>
|
||||
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()
|
||||
@@ -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):
|
||||
|
||||
@@ -19,6 +19,7 @@ HARNESS = r'''
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
#include <algorithm>
|
||||
#include "filesystem.h"
|
||||
#include <helpers/RoomAccessPolicy.h>
|
||||
#include <helpers/RemoteCliReplyCache.h>
|
||||
@@ -27,6 +28,7 @@ HARNESS = r'''
|
||||
#include <helpers/ClientPathPersistence.h>
|
||||
#include <helpers/ClientPathObservation.h>
|
||||
#include <helpers/RoomClientPathCommand.h>
|
||||
#include <helpers/RoomCatchUp.h>
|
||||
#include <helpers/TxtDataHelpers.h>
|
||||
#include <Packet.h>
|
||||
#include <helpers/RoomLoginAuthorization.h>
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = "<script>\n" + COMMON.replace("__BOUNDARY__", json.dumps(fixture)) + scenario + "\n</script>"
|
||||
self.assertIn("<script>\n'use strict';", source)
|
||||
@@ -287,6 +300,339 @@ window.addEventListener('load',()=>{(async()=>{
|
||||
self.assertEqual(self.attribute(dom, "retained-draft"), "my new draft")
|
||||
self.assertEqual(self.attribute(dom, "final-effects"), "1")
|
||||
|
||||
def test_admin_pages_full_identities_safe_rendering_and_permission_downgrade(self):
|
||||
dom = self.run_page(ADMIN_COMMON + r'''
|
||||
let currentRole=2;
|
||||
window.fetch=(url,options)=>{
|
||||
const p=record(url,options);
|
||||
if(p.op==='status')return response(Object.assign({},boundary.status,{role:currentRole}));
|
||||
if(p.op==='admin.users')return response(p.cursor===0?adminPage([radioUser(),radioUser(otherKey)],7):adminPage([radioUser('ef'.repeat(32))]));
|
||||
if(p.op==='admin.user')return response({user:radioUser(p.key,{outpath:'<img src=x onerror=attack()>',observed:'<svg onload=attack()>'}),boot:71});
|
||||
const result=baseRoom(p);if(result)return result;throw Error('unexpected '+p.op);
|
||||
};
|
||||
window.addEventListener('load',()=>{(async()=>{
|
||||
await joinedRoom();mark('reader-hidden',$('admin-panel').hidden);
|
||||
try{await loadAdmin()}catch(e){}mark('reader-admin-calls',calls.filter(c=>c.op.startsWith('admin.')).length);
|
||||
currentRole=3;await status();await loadAdmin();mark('rows',$('admin-user-rows').childElementCount);
|
||||
$('admin-user-rows').children[1].querySelector('button').click();await until(()=>selectedUser&&selectedUser.key===otherKey);
|
||||
mark('selected-key',$('admin-selected-key').textContent);mark('safe-path',$('admin-out-value').value);mark('safe-observed',$('admin-details').textContent);
|
||||
$('admin-next').click();await until(()=>adminCursor===7&&!adminLoading);mark('next-cursor',adminCursor);mark('last-disabled',$('admin-next').disabled);
|
||||
$('admin-previous').click();await until(()=>adminCursor===0&&!adminLoading);mark('previous-cursor',adminCursor);
|
||||
currentRole=2;await status();mark('downgraded-hidden',$('admin-panel').hidden);mark('cleared-rows',$('admin-user-rows').childElementCount);
|
||||
mark('cleared-key',$('admin-selected-key').textContent);mark('cleared-details',$('admin-details').textContent);mark('image-count',document.querySelectorAll('img,svg').length);mark('done',true);
|
||||
})().catch(e=>mark('js-error',e.message))});
|
||||
''')
|
||||
self.assertEqual(self.attribute(dom, "reader-hidden"), "true")
|
||||
self.assertEqual(self.attribute(dom, "reader-admin-calls"), "0")
|
||||
self.assertEqual(self.attribute(dom, "rows"), "2")
|
||||
self.assertEqual(self.attribute(dom, "selected-key"), "ab" * 6 + "cd" * 26)
|
||||
self.assertIn("<img", self.attribute(dom, "safe-path"))
|
||||
self.assertIn("<svg", self.attribute(dom, "safe-observed"))
|
||||
self.assertEqual(self.attribute(dom, "next-cursor"), "7")
|
||||
self.assertEqual(self.attribute(dom, "last-disabled"), "true")
|
||||
self.assertEqual(self.attribute(dom, "previous-cursor"), "0")
|
||||
self.assertEqual(self.attribute(dom, "downgraded-hidden"), "true")
|
||||
self.assertEqual(self.attribute(dom, "cleared-rows"), "0")
|
||||
self.assertEqual(self.attribute(dom, "cleared-key"), "")
|
||||
self.assertEqual(self.attribute(dom, "cleared-details"), "")
|
||||
self.assertEqual(self.attribute(dom, "image-count"), "0")
|
||||
|
||||
def test_admin_routes_preserve_drafts_stale_baselines_and_identical_write_retry(self):
|
||||
dom = self.run_page(ADMIN_COMMON + r'''
|
||||
let route='A1',effects=0,attempts=0,first=null,pending=null;
|
||||
window.fetch=(url,options)=>{
|
||||
const p=record(url,options);
|
||||
if(url==='/api/room/result')return response(pending);
|
||||
if(p.op==='admin.user')return response({user:radioUser(p.key,{outpath:route}),boot:71});
|
||||
if(p.op==='admin.route'){
|
||||
if(p.expected!==route&&pending===null)return response({error:'route changed; reload before editing'});
|
||||
attempts++;
|
||||
if(attempts===1){first=calls[calls.length-1];effects++;route=p.value;pending={ok:true};return Promise.reject(Error('response lost'))}
|
||||
mark('retry-seq',calls[calls.length-1].seq===first.seq);mark('retry-body',calls[calls.length-1].body===first.body);return response({},202);
|
||||
}
|
||||
const result=baseRoom(p);if(result)return result;throw Error('unexpected '+p.op);
|
||||
};
|
||||
window.addEventListener('load',()=>{(async()=>{
|
||||
await joinedRoom();await selectAdminUser(radioKey,true);
|
||||
$('admin-out-value').value='C1';$('admin-out-value').dispatchEvent(new Event('input'));route='D1';await selectAdminUser(radioKey,false);
|
||||
mark('draft',$('admin-out-value').value);mark('old-baseline',routeBaseline.outpath);
|
||||
try{await saveAdminRoute('outpath')}catch(e){mark('stale-error',e.message)}mark('stale-effects',effects);
|
||||
await selectAdminUser(radioKey,true);$('admin-out-value').value='E1';$('admin-out-value').dispatchEvent(new Event('input'));
|
||||
$('admin-alt-value').value='F1';$('admin-alt-value').dispatchEvent(new Event('input'));
|
||||
await saveAdminRoute('outpath');mark('effects',effects);mark('attempts',attempts);mark('route',route);mark('other-draft',$('admin-alt-value').value);
|
||||
mark('saved-baseline',routeBaseline.outpath);mark('expected',JSON.parse(first.body).expected);mark('target',JSON.parse(first.body).key);mark('write-boot',JSON.parse(first.body).boot);mark('done',true);
|
||||
})().catch(e=>mark('js-error',e.message))});
|
||||
''')
|
||||
self.assertEqual(self.attribute(dom, "draft"), "C1")
|
||||
self.assertEqual(self.attribute(dom, "old-baseline"), "A1")
|
||||
self.assertIn("route changed", self.attribute(dom, "stale-error"))
|
||||
self.assertEqual(self.attribute(dom, "stale-effects"), "0")
|
||||
self.assertEqual(self.attribute(dom, "retry-seq"), "true")
|
||||
self.assertEqual(self.attribute(dom, "retry-body"), "true")
|
||||
self.assertEqual(self.attribute(dom, "effects"), "1")
|
||||
self.assertEqual(self.attribute(dom, "attempts"), "2")
|
||||
self.assertEqual(self.attribute(dom, "route"), "E1")
|
||||
self.assertEqual(self.attribute(dom, "other-draft"), "F1")
|
||||
self.assertEqual(self.attribute(dom, "saved-baseline"), "E1")
|
||||
self.assertEqual(self.attribute(dom, "expected"), "D1")
|
||||
self.assertEqual(self.attribute(dom, "target"), "ab" * 32)
|
||||
self.assertEqual(self.attribute(dom, "write-boot"), "71")
|
||||
|
||||
def test_admin_catchup_confirmation_count_date_and_optimistic_cursor(self):
|
||||
dom = self.run_page(ADMIN_COMMON + r'''
|
||||
let cursor=100,approve=false,changes=[];window.confirm=()=>approve;
|
||||
window.fetch=(url,options)=>{
|
||||
const p=record(url,options);
|
||||
if(p.op==='admin.user')return response({user:radioUser(p.key,{sync_since:cursor}),boot:71});
|
||||
if(p.op==='admin.catchup'){
|
||||
changes.push(p);if(p.expected_sync!==cursor)return response({error:'delivery cursor changed; refresh selected user'});
|
||||
cursor=p.mode==='keep'?102:104;return response({ok:true,skipped:2,sync_since:cursor});
|
||||
}
|
||||
const result=baseRoom(p);if(result)return result;throw Error('unexpected '+p.op);
|
||||
};
|
||||
window.addEventListener('load',()=>{(async()=>{
|
||||
await joinedRoom();await selectAdminUser(radioKey,true);$('admin-skip-count').value='2';await adminCatchup('keep');mark('cancel-effects',changes.length);
|
||||
approve=true;await adminCatchup('keep');mark('keep',JSON.stringify(changes[0]));
|
||||
$('admin-skip-count').value='33';try{await adminCatchup('keep')}catch(e){mark('bad-count',e.message)}mark('invalid-effects',changes.length);
|
||||
$('admin-skip-date').value='2030-01-01T12:00';const requested=Math.floor(new Date($('admin-skip-date').value).getTime()/1000);
|
||||
await adminCatchup('before');mark('date',JSON.stringify(changes[1]));mark('date-seconds',requested);mark('cursor',selectedUser.sync_since);
|
||||
cursor=105;try{await adminCatchup('before')}catch(e){mark('stale',e.message)}mark('stale-cursor',selectedUser.sync_since);mark('done',true);
|
||||
})().catch(e=>mark('js-error',e.message))});
|
||||
''')
|
||||
self.assertEqual(self.attribute(dom, "cancel-effects"), "0")
|
||||
keep = json.loads(self.attribute(dom, "keep"))
|
||||
self.assertEqual((keep["mode"], keep["count"], keep["expected_sync"], keep["boot"]), ("keep", 2, 100, 71))
|
||||
self.assertEqual(keep["key"], "ab" * 32)
|
||||
self.assertIn("0 to 32", self.attribute(dom, "bad-count"))
|
||||
self.assertEqual(self.attribute(dom, "invalid-effects"), "1")
|
||||
before = json.loads(self.attribute(dom, "date"))
|
||||
self.assertEqual(before["mode"], "before")
|
||||
self.assertEqual(before["before"], int(self.attribute(dom, "date-seconds")))
|
||||
self.assertEqual(before["expected_sync"], 102)
|
||||
self.assertEqual(self.attribute(dom, "cursor"), "104")
|
||||
self.assertIn("cursor changed", self.attribute(dom, "stale"))
|
||||
self.assertEqual(self.attribute(dom, "stale-cursor"), "104")
|
||||
|
||||
def test_alternate_route_has_no_flood_choice_and_existing_sentinel_maps_to_clear(self):
|
||||
dom = self.run_page(ADMIN_COMMON + r'''
|
||||
let alternate='flood',changes=[];
|
||||
window.fetch=(url,options)=>{
|
||||
const p=record(url,options);
|
||||
if(p.op==='admin.user')return response({user:radioUser(p.key,{outpath:'flood',altpath:alternate}),boot:71});
|
||||
if(p.op==='admin.route'){changes.push(p);if(p.which!=='altpath'||p.value!=='clear'||p.expected!=='flood')throw Error('wrong alternate disable request');alternate='unknown';return response({ok:true})}
|
||||
const result=baseRoom(p);if(result)return result;throw Error('unexpected '+p.op);
|
||||
};
|
||||
window.addEventListener('load',()=>{(async()=>{
|
||||
await joinedRoom();await selectAdminUser(radioKey,true);
|
||||
mark('primary-flood',Boolean($('admin-out-mode').querySelector('option[value="flood"]')));
|
||||
mark('alternate-flood',Boolean($('admin-alt-mode').querySelector('option[value="flood"]')));mark('alternate-mode',$('admin-alt-mode').value);
|
||||
mark('alternate-input-disabled',$('admin-alt-value').disabled);await saveAdminRoute('altpath');
|
||||
mark('expected',changes[0].expected);mark('value',changes[0].value);mark('primary-mode',$('admin-out-mode').value);mark('alternate-after',$('admin-alt-mode').value);mark('done',true);
|
||||
})().catch(e=>mark('js-error',e.message))});
|
||||
''')
|
||||
self.assertEqual(self.attribute(dom, "primary-flood"), "true")
|
||||
self.assertEqual(self.attribute(dom, "alternate-flood"), "false")
|
||||
self.assertEqual(self.attribute(dom, "alternate-mode"), "clear")
|
||||
self.assertEqual(self.attribute(dom, "alternate-input-disabled"), "true")
|
||||
self.assertEqual(self.attribute(dom, "expected"), "flood")
|
||||
self.assertEqual(self.attribute(dom, "value"), "clear")
|
||||
self.assertEqual(self.attribute(dom, "primary-mode"), "flood")
|
||||
self.assertEqual(self.attribute(dom, "alternate-after"), "clear")
|
||||
|
||||
def test_long_valid_comma_route_and_failure_summary_label(self):
|
||||
dom = self.run_page(ADMIN_COMMON + r'''
|
||||
const longPath=Array.from({length:63},(_,i)=>(i+1).toString(16).padStart(2,'0')).join(',');let route='A1',writes=[];
|
||||
window.fetch=(url,options)=>{
|
||||
const p=record(url,options);
|
||||
if(p.op==='admin.users')return response(adminPage([radioUser()]));
|
||||
if(p.op==='admin.user')return response({user:radioUser(p.key,{outpath:route}),boot:71});
|
||||
if(p.op==='admin.route'){if(p.value!==longPath||p.expected!=='A1')throw Error('wrong long route request');writes.push(p);route=p.value.replaceAll(',','');return response({ok:true})}
|
||||
const result=baseRoom(p);if(result)return result;throw Error('unexpected '+p.op);
|
||||
};
|
||||
window.addEventListener('load',()=>{(async()=>{
|
||||
await joinedRoom();await loadAdmin();await selectAdminUser(radioKey,true);$('admin-out-value').value=longPath;$('admin-out-value').dispatchEvent(new Event('input'));
|
||||
await saveAdminRoute('outpath');mark('writes',writes.length);mark('bytes',writes[0].value.length);mark('route-bytes',$('admin-out-value').value.length);mark('cards',$('admin-cards').textContent);mark('done',true);
|
||||
})().catch(e=>mark('js-error',e.message))});
|
||||
''')
|
||||
self.assertEqual(self.attribute(dom, "writes"), "1")
|
||||
self.assertEqual(self.attribute(dom, "bytes"), "188")
|
||||
self.assertEqual(self.attribute(dom, "route-bytes"), "126")
|
||||
self.assertIn("Users with failures", self.attribute(dom, "cards"))
|
||||
self.assertNotIn("Paused delivery", self.attribute(dom, "cards"))
|
||||
|
||||
def test_browser_catchup_keeps_newest_and_never_sets_future_cursor(self):
|
||||
dom = self.run_page(r'''
|
||||
let available=[];
|
||||
window.fetch=(url,options)=>{
|
||||
const p=record(url,options);
|
||||
if(p.op==='status')return response(boundary.status);
|
||||
if(p.op==='posts')return response({post:available.find(post=>post.timestamp>p.after)||null});
|
||||
if(p.op==='board.index')return response({revision:0,articles:[],next:255});
|
||||
throw Error('unexpected '+p.op);
|
||||
};
|
||||
window.addEventListener('load',()=>{(async()=>{
|
||||
submit('join');await until(()=>$('notices').textContent==='No notices published.');
|
||||
available=[101,102,103,104,105].map(timestamp=>({timestamp:timestamp,author:'ab'.repeat(32),text:'Message '+timestamp}));
|
||||
await refreshChat();mark('initial-after',after);
|
||||
$('browser-skip-count').value='2';const start=calls.length;await browserCatchup('keep');
|
||||
mark('keep-posts',$('posts').textContent);mark('keep-skipped',$('browser-catchup-result').textContent);mark('keep-cursors',JSON.stringify(calls.slice(start).filter(c=>c.op==='posts').map(c=>JSON.parse(c.body).after)));
|
||||
available.push({timestamp:106,author:'ab'.repeat(32),text:'Next unread'});$('browser-skip-date').value='2030-01-01T12:00';await browserCatchup('before');mark('future-after',after);
|
||||
available.push({timestamp:107,author:'ab'.repeat(32),text:'Arrived after future-date skip'});await refreshChat();mark('new-post',$('posts').textContent);
|
||||
mark('admin-calls',calls.filter(c=>c.op.startsWith('admin.')).length);mark('future-requests',calls.filter(c=>c.op==='posts').some(c=>JSON.parse(c.body).after>107));mark('done',true);
|
||||
})().catch(e=>mark('js-error',e.message))});
|
||||
''')
|
||||
self.assertIn("Message 104", self.attribute(dom, "keep-posts"))
|
||||
self.assertIn("Message 105", self.attribute(dom, "keep-posts"))
|
||||
self.assertNotIn("Message 103", self.attribute(dom, "keep-posts"))
|
||||
self.assertIn("Hidden 3", self.attribute(dom, "keep-skipped"))
|
||||
self.assertEqual(self.attribute(dom, "initial-after"), "105")
|
||||
self.assertEqual(json.loads(self.attribute(dom, "keep-cursors")), [0, 101, 102, 103, 104, 105, 103, 104, 105])
|
||||
self.assertEqual(self.attribute(dom, "future-after"), "106")
|
||||
self.assertIn("Arrived after future-date skip", self.attribute(dom, "new-post"))
|
||||
self.assertEqual(self.attribute(dom, "admin-calls"), "0")
|
||||
self.assertEqual(self.attribute(dom, "future-requests"), "false")
|
||||
|
||||
def test_admin_settings_access_and_block_are_independent_confirmed_operations(self):
|
||||
dom = self.run_page(ADMIN_COMMON + r'''
|
||||
let topic='Original',history=false,postRate=0,pollRate=0,userRole=1,banned=false,updates=[];window.confirm=()=>true;
|
||||
window.fetch=(url,options)=>{
|
||||
const p=record(url,options);
|
||||
if(p.op==='admin.user')return response({user:radioUser(p.key,{role:userRole,banned:banned}),boot:71});
|
||||
if(p.op==='admin.settings')return response({topic:topic,persistent_history:history,post_rate:postRate,poll_rate:pollRate,boot:71});
|
||||
if(p.op==='admin.topic'){updates.push(p);if(p.expected!==topic)return response({error:'topic changed; reload settings'});topic=p.value;return response({ok:true})}
|
||||
if(p.op==='admin.history'){updates.push(p);if(p.expected!==history)return response({error:'history setting changed'});history=p.enabled;return response({ok:true})}
|
||||
if(p.op==='admin.rates'){updates.push(p);postRate=p.post_rate;pollRate=p.poll_rate;return response({ok:true})}
|
||||
if(p.op==='admin.access'){updates.push(p);if(p.expected_role!==userRole)return response({error:'role changed'});userRole=p.role;return response({ok:true})}
|
||||
if(p.op==='admin.ban'){updates.push(p);banned=p.banned;return response({ok:true})}
|
||||
const result=baseRoom(p);if(result)return result;throw Error('unexpected '+p.op);
|
||||
};
|
||||
window.addEventListener('load',()=>{(async()=>{
|
||||
$('password').value='fixture-secret';await joinedRoom();mark('password-cleared',$('password').value==='');
|
||||
await loadAdminSettings();$('admin-topic-value').value='My draft';topic='Changed by other admin';try{await saveAdminSetting('topic')}catch(e){mark('stale-topic',e.message)}mark('topic-draft',$('admin-topic-value').value);
|
||||
await loadAdminSettings();$('admin-topic-value').value='New topic';await saveAdminSetting('topic');
|
||||
$('admin-history-enabled').checked=true;await saveAdminSetting('history');$('admin-post-rate').value='12';$('admin-poll-rate').value='65535';await saveAdminSetting('rates');
|
||||
await selectAdminUser(radioKey,true);$('admin-access-role').value='3';await saveAdminAccess();await setAdminBan(radioKey,true);await setAdminBan(radioKey,false);
|
||||
mark('updates',JSON.stringify(updates));mark('topic',topic);mark('history',history);mark('post-rate',postRate);mark('poll-rate',pollRate);mark('role',userRole);mark('banned',banned);
|
||||
mark('stored-secret',Object.keys(localStorage).some(key=>localStorage.getItem(key).includes('fixture-secret')));mark('done',true);
|
||||
})().catch(e=>mark('js-error',e.message))});
|
||||
''')
|
||||
self.assertEqual(self.attribute(dom, "password-cleared"), "true")
|
||||
self.assertIn("topic changed", self.attribute(dom, "stale-topic"))
|
||||
self.assertEqual(self.attribute(dom, "topic-draft"), "My draft")
|
||||
self.assertEqual(self.attribute(dom, "topic"), "New topic")
|
||||
self.assertEqual(self.attribute(dom, "history"), "true")
|
||||
self.assertEqual(self.attribute(dom, "post-rate"), "12")
|
||||
self.assertEqual(self.attribute(dom, "poll-rate"), "65535")
|
||||
self.assertEqual(self.attribute(dom, "role"), "3")
|
||||
self.assertEqual(self.attribute(dom, "banned"), "false")
|
||||
self.assertEqual(self.attribute(dom, "stored-secret"), "false")
|
||||
changes = json.loads(self.attribute(dom, "updates"))
|
||||
self.assertEqual([item["op"] for item in changes], ["admin.topic", "admin.topic", "admin.history", "admin.rates", "admin.access", "admin.ban", "admin.ban"])
|
||||
self.assertTrue(all(item["boot"] == 71 for item in changes))
|
||||
self.assertEqual(changes[4]["expected_role"], 1)
|
||||
|
||||
def test_disconnect_discards_late_admin_response_and_clears_private_view(self):
|
||||
dom = self.run_page(ADMIN_COMMON + r'''
|
||||
let release=null;
|
||||
window.fetch=(url,options)=>{
|
||||
const p=record(url,options);
|
||||
if(p.op==='admin.users')return new Promise(resolve=>{release=()=>resolve({ok:true,status:200,json:()=>Promise.resolve(adminPage([radioUser()]))})});
|
||||
const result=baseRoom(p);if(result)return result;throw Error('unexpected '+p.op);
|
||||
};
|
||||
window.addEventListener('load',()=>{(async()=>{
|
||||
await joinedRoom();const pending=loadAdmin().catch(()=>{});await until(()=>release!==null);$('disconnect').click();release();await pending;
|
||||
mark('hidden',$('admin-panel').hidden);mark('room-hidden',$('room').hidden);mark('role',role);mark('password-empty',password==='');mark('rows',$('admin-user-rows').childElementCount);
|
||||
mark('loaded',adminLoaded);mark('identity-secret',localStorage.getItem('mc-room-token')===token);mark('done',true);
|
||||
})().catch(e=>mark('js-error',e.message))});
|
||||
''')
|
||||
self.assertEqual(self.attribute(dom, "hidden"), "true")
|
||||
self.assertEqual(self.attribute(dom, "room-hidden"), "true")
|
||||
self.assertEqual(self.attribute(dom, "role"), "0")
|
||||
self.assertEqual(self.attribute(dom, "password-empty"), "true")
|
||||
self.assertEqual(self.attribute(dom, "rows"), "0")
|
||||
self.assertEqual(self.attribute(dom, "loaded"), "false")
|
||||
self.assertEqual(self.attribute(dom, "identity-secret"), "true")
|
||||
|
||||
def test_remove_saved_access_does_not_read_deleted_client_or_report_failed_write(self):
|
||||
dom = self.run_page(ADMIN_COMMON + r'''
|
||||
let removed=false,effects=0;window.confirm=()=>true;
|
||||
window.fetch=(url,options)=>{
|
||||
const p=record(url,options);
|
||||
if(p.op==='admin.user'){if(removed)throw Error('deleted client must not be read');return response({user:radioUser(p.key),boot:71})}
|
||||
if(p.op==='admin.users')return response(adminPage(removed?[]:[radioUser()]));
|
||||
if(p.op==='admin.access'){if(p.role!==0||p.expected_role!==1)throw Error('wrong removal request');effects++;removed=true;return response({ok:true})}
|
||||
const result=baseRoom(p);if(result)return result;throw Error('unexpected '+p.op);
|
||||
};
|
||||
window.addEventListener('load',()=>{(async()=>{
|
||||
await joinedRoom();await loadAdmin();await selectAdminUser(radioKey,true);$('admin-access-role').value='0';await saveAdminAccess();
|
||||
mark('effects',effects);mark('selected-hidden',$('admin-selected').hidden);mark('selected-key',selectedKey);mark('rows',$('admin-user-rows').childElementCount);
|
||||
mark('result',$('admin-result').textContent);mark('done',true);
|
||||
})().catch(e=>mark('js-error',e.message))});
|
||||
''')
|
||||
self.assertEqual(self.attribute(dom, "effects"), "1")
|
||||
self.assertEqual(self.attribute(dom, "selected-hidden"), "true")
|
||||
self.assertEqual(self.attribute(dom, "selected-key"), "")
|
||||
self.assertEqual(self.attribute(dom, "rows"), "0")
|
||||
self.assertEqual(self.attribute(dom, "result"), "Access updated.")
|
||||
|
||||
def test_pending_access_save_and_zero_keep_catchup_have_accurate_feedback(self):
|
||||
dom = self.run_page(ADMIN_COMMON + r'''
|
||||
let userRole=1,cursor=100,pendingSave=true,confirmations=[],changes=[];
|
||||
window.confirm=text=>{confirmations.push(text);return true};
|
||||
window.fetch=(url,options)=>{
|
||||
const p=record(url,options);
|
||||
if(p.op==='admin.user')return response({user:radioUser(p.key,{role:userRole,sync_since:cursor}),boot:71});
|
||||
if(p.op==='admin.access'){if(p.expected_role!==userRole)throw Error('wrong access baseline');userRole=p.role;return response({ok:true,pending_save:pendingSave})}
|
||||
if(p.op==='admin.catchup'){changes.push(p);if(p.mode!=='keep'||p.count!==0||p.expected_sync!==100)throw Error('wrong skip-all request');cursor=104;return response({ok:true,skipped:4,sync_since:cursor})}
|
||||
const result=baseRoom(p);if(result)return result;throw Error('unexpected '+p.op);
|
||||
};
|
||||
window.addEventListener('load',()=>{(async()=>{
|
||||
await joinedRoom();await selectAdminUser(radioKey,true);$('admin-access-role').value='2';await saveAdminAccess();mark('pending-save',$('admin-result').textContent);
|
||||
pendingSave=false;$('admin-access-role').value='3';await saveAdminAccess();mark('saved',$('admin-result').textContent);
|
||||
$('admin-skip-count').value='0';await adminCatchup('keep');mark('zero-keep',JSON.stringify(changes[0]));mark('skip-all-confirm',confirmations[2]);mark('cursor',selectedUser.sync_since);mark('done',true);
|
||||
})().catch(e=>mark('js-error',e.message))});
|
||||
''')
|
||||
self.assertEqual(self.attribute(dom, "pending-save"), "Access updated; keep power on for at least 5 seconds while it is saved.")
|
||||
self.assertEqual(self.attribute(dom, "saved"), "Access updated.")
|
||||
skip = json.loads(self.attribute(dom, "zero-keep"))
|
||||
self.assertEqual((skip["mode"], skip["count"], skip["expected_sync"]), ("keep", 0, 100))
|
||||
self.assertIn("Skip all unread retained chat messages", self.attribute(dom, "skip-all-confirm"))
|
||||
self.assertIn("ab" * 32, self.attribute(dom, "skip-all-confirm"))
|
||||
self.assertEqual(self.attribute(dom, "cursor"), "104")
|
||||
|
||||
def test_browser_regression_guards_reject_stale_route_private_cache_and_future_cursor(self):
|
||||
controls = (
|
||||
(
|
||||
"stale route baseline",
|
||||
"if(routeDirty[which]&&!force){",
|
||||
"if(routeDirty[which]&&!force){routeBaseline[which]=value;",
|
||||
self.test_admin_routes_preserve_drafts_stale_baselines_and_identical_write_retry,
|
||||
),
|
||||
(
|
||||
"private admin cache after role loss",
|
||||
"if(role!==3)clearAdmin();updateAdminButtons();updateBudget();",
|
||||
"updateAdminButtons();updateBudget();",
|
||||
self.test_admin_pages_full_identities_safe_rendering_and_permission_downgrade,
|
||||
),
|
||||
(
|
||||
"future timestamp cursor",
|
||||
"after=skipped?unread[skipped-1]:0;",
|
||||
"after=mode==='before'?before-1:(skipped?unread[skipped-1]:0);",
|
||||
self.test_browser_catchup_keeps_newest_and_never_sets_future_cursor,
|
||||
),
|
||||
)
|
||||
for name, old, new, check in controls:
|
||||
with self.subTest(name=name):
|
||||
def transform(source, old=old, new=new):
|
||||
self.assertIn(old, source)
|
||||
return source.replace(old, new, 1)
|
||||
self.page_transform = transform
|
||||
try:
|
||||
with self.assertRaises(AssertionError):
|
||||
check()
|
||||
finally:
|
||||
del self.page_transform
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user