fix: update propagation node sync endpoints to use POST method and improve database log handling

This commit is contained in:
Ivan 2026-08-10 18:00:42 -05:00
parent 57da414bcc
commit 8f381c8693
No known key found for this signature in database
11 changed files with 107 additions and 25 deletions

Binary file not shown.

View file

@ -204,8 +204,13 @@ def register_lxmf_routes(routes, app):
# sync propagation node
# sync propagation node
@routes.get("/api/v1/lxmf/propagation-node/sync")
@routes.post("/api/v1/lxmf/propagation-node/sync")
async def propagation_node_sync(request):
from meshchatx.src.backend.demo_mode import demo_mode_block_response
blocked = demo_mode_block_response(app)
if blocked is not None:
return blocked
# ensure propagation node is configured before attempting to sync
outbound_node = app.message_router.get_outbound_propagation_node()
if outbound_node is None:
@ -234,8 +239,13 @@ def register_lxmf_routes(routes, app):
# stop syncing propagation node
# stop syncing propagation node
@routes.get("/api/v1/lxmf/propagation-node/stop-sync")
@routes.post("/api/v1/lxmf/propagation-node/stop-sync")
async def propagation_node_stop_sync(request):
from meshchatx.src.backend.demo_mode import demo_mode_block_response
blocked = demo_mode_block_response(app)
if blocked is not None:
return blocked
app.stop_propagation_node_sync()
return web.json_response(

View file

@ -42,6 +42,13 @@ class PersistentLogHandler(logging.Handler):
def set_database(self, database):
with self.lock:
if (
self.database is not None
and database is not None
and self.database is not database
and self.logs_buffer
):
self._flush_to_db()
self.database = database
def emit(self, record):

View file

@ -2109,7 +2109,7 @@ export default {
// continue to sync
}
}
await window.api.get("/api/v1/lxmf/propagation-node/sync");
await window.api.post("/api/v1/lxmf/propagation-node/sync");
} catch (e) {
this.userInitiatedPropagationSync = false;
const errorMessage =
@ -2202,7 +2202,7 @@ export default {
async stopSyncingPropagationNode() {
const propagationSyncToastKey = "propagation-sync-status";
try {
await window.api.get("/api/v1/lxmf/propagation-node/stop-sync");
await window.api.post("/api/v1/lxmf/propagation-node/stop-sync");
} catch {
// do nothing on error
}

View file

@ -3138,15 +3138,21 @@ export default {
this.discovery.discoverable = this.parseBool(config.discoverable);
}
if (config.discovery_name) this.discovery.discovery_name = config.discovery_name;
if (config.announce_interval) this.discovery.announce_interval = Number(config.announce_interval);
if (config.announce_interval != null && config.announce_interval !== "") {
this.discovery.announce_interval = Number(config.announce_interval);
}
if (config.reachable_on) this.discovery.reachable_on = config.reachable_on;
if (config.discovery_stamp_value)
this.discovery.discovery_stamp_value = Number(config.discovery_stamp_value);
if (config.discovery_encrypt !== undefined)
this.discovery.discovery_encrypt = this.parseBool(config.discovery_encrypt);
if (config.publish_ifac !== undefined) this.discovery.publish_ifac = this.parseBool(config.publish_ifac);
if (config.latitude) this.discovery.latitude = Number(config.latitude);
if (config.longitude) this.discovery.longitude = Number(config.longitude);
if (config.latitude != null && config.latitude !== "") {
this.discovery.latitude = Number(config.latitude);
}
if (config.longitude != null && config.longitude !== "") {
this.discovery.longitude = Number(config.longitude);
}
if (config.height) this.discovery.height = Number(config.height);
if (config.location_cmd) this.discovery.location_cmd = String(config.location_cmd);

View file

@ -115,3 +115,32 @@ def test_log_cleanup(handler, db):
count = db.debug_logs.get_total_count()
assert count <= 11 # 10 + the trigger log
def test_set_database_flushes_buffered_logs_to_previous_database(tmp_path):
db_a_file = tmp_path / "a.db"
db_b_file = tmp_path / "b.db"
db_a = Database(str(db_a_file))
db_b = Database(str(db_b_file))
db_a.initialize()
db_b.initialize()
handler = PersistentLogHandler(database=db_a, flush_interval=3600)
logger = logging.getLogger("identity_a_logger")
logger.addHandler(handler)
logger.setLevel(logging.INFO)
logger.info("identity A buffered log")
assert len(handler.logs_buffer) == 1
handler.set_database(db_b)
logger.info("identity B log")
handler._flush_to_db()
logs_a = db_a.debug_logs.get_logs(limit=10)
logs_b = db_b.debug_logs.get_logs(limit=10)
assert any("identity A buffered log" in row["message"] for row in logs_a)
assert all("identity A buffered log" not in row["message"] for row in logs_b)
assert any("identity B log" in row["message"] for row in logs_b)
logger.removeHandler(handler)

View file

@ -24,6 +24,8 @@ from tests.backend.demo_http_support import build_test_aio_app
("POST", "/api/v1/identities/create"),
("DELETE", "/api/v1/lxmf-messages/aa"),
("PATCH", "/api/v1/server/security"),
("POST", "/api/v1/lxmf/propagation-node/sync"),
("POST", "/api/v1/lxmf/propagation-node/stop-sync"),
],
)
@pytest.mark.asyncio

View file

@ -135,7 +135,7 @@ async def test_remote_propagation_sync_transitions_path_requested_to_complete(
if request_count[key] >= 2:
known_paths.add(key)
sync_handler = _route_handler(app, "/api/v1/lxmf/propagation-node/sync")
sync_handler = _route_handler(app, "/api/v1/lxmf/propagation-node/sync", method="POST")
status_handler = _route_handler(app, "/api/v1/lxmf/propagation-node/status")
with (
@ -175,7 +175,7 @@ async def test_local_preferred_propagation_sync_completes_without_remote_lookup(
local_hash = fake_router.propagation_destination.hash
fake_router.set_outbound_propagation_node(local_hash)
sync_handler = _route_handler(app, "/api/v1/lxmf/propagation-node/sync")
sync_handler = _route_handler(app, "/api/v1/lxmf/propagation-node/sync", method="POST")
status_handler = _route_handler(app, "/api/v1/lxmf/propagation-node/status")
with (

View file

@ -94,7 +94,7 @@ async def test_lxmf_sync_endpoints(mock_app):
# 2. Test sync initiation
sync_handler = None
for route in mock_app.get_routes():
if route.path == "/api/v1/lxmf/propagation-node/sync" and route.method == "GET":
if route.path == "/api/v1/lxmf/propagation-node/sync" and route.method == "POST":
sync_handler = route.handler
break
@ -137,7 +137,7 @@ async def test_specific_node_hash_validation(mock_app):
# Trigger sync
sync_handler = None
for route in mock_app.get_routes():
if route.path == "/api/v1/lxmf/propagation-node/sync" and route.method == "GET":
if route.path == "/api/v1/lxmf/propagation-node/sync" and route.method == "POST":
sync_handler = route.handler
break

View file

@ -278,4 +278,23 @@ target_port = 4242`;
})
);
});
it("imports announce_interval zero and equator coordinates from config", async () => {
const wrapper = mountPage();
wrapper.vm.applyConfig({
name: "ZeroInterval",
type: "TCPClientInterface",
target_host: "node.example",
target_port: "4242",
discoverable: "yes",
announce_interval: 0,
latitude: 0,
longitude: 0,
});
expect(wrapper.vm.discovery.announce_interval).toBe(0);
expect(wrapper.vm.discovery.latitude).toBe(0);
expect(wrapper.vm.discovery.longitude).toBe(0);
});
});

View file

@ -106,11 +106,13 @@ describe("App propagation sync", () => {
});
it("shows detailed success toast with stored, confirmations and hidden counts", async () => {
axiosMock.post.mockResolvedValue({ data: { message: "ok" } });
axiosMock.get.mockImplementation((url) => {
axiosMock.post.mockImplementation((url) => {
if (url === "/api/v1/lxmf/propagation-node/sync") {
return Promise.resolve({ data: { message: "Sync is starting" } });
}
return Promise.resolve({ data: { message: "ok" } });
});
axiosMock.get.mockImplementation((url) => {
if (url === "/api/v1/lxmf/propagation-node/status") {
return Promise.resolve({
data: {
@ -138,17 +140,20 @@ describe("App propagation sync", () => {
expect(ToastUtils.success).toHaveBeenCalledWith(
"Sync complete. 8 messages received. (3 stored, 2 confirmations, 3 hidden)"
);
expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/lxmf/propagation-node/sync");
expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/destination/deadbeef/request-path");
expect(ToastUtils.error).not.toHaveBeenCalled();
});
it("polls status while syncing and updates live loading toast", async () => {
axiosMock.post.mockResolvedValue({ data: { message: "ok" } });
let statusCalls = 0;
axiosMock.get.mockImplementation((url) => {
axiosMock.post.mockImplementation((url) => {
if (url === "/api/v1/lxmf/propagation-node/sync") {
return Promise.resolve({ data: { message: "Sync is starting" } });
}
return Promise.resolve({ data: { message: "ok" } });
});
let statusCalls = 0;
axiosMock.get.mockImplementation((url) => {
if (url === "/api/v1/lxmf/propagation-node/status") {
statusCalls += 1;
if (statusCalls < 3) {
@ -196,11 +201,13 @@ describe("App propagation sync", () => {
});
it("uses translated status in error toast when sync ends in a failure state", async () => {
axiosMock.post.mockResolvedValue({ data: { message: "ok" } });
axiosMock.get.mockImplementation((url) => {
axiosMock.post.mockImplementation((url) => {
if (url === "/api/v1/lxmf/propagation-node/sync") {
return Promise.resolve({ data: { message: "Sync is starting" } });
}
return Promise.resolve({ data: { message: "ok" } });
});
axiosMock.get.mockImplementation((url) => {
if (url === "/api/v1/lxmf/propagation-node/status") {
return Promise.resolve({
data: {
@ -228,20 +235,22 @@ describe("App propagation sync", () => {
});
it("still starts sync when request-path fails (stale CSRF / brief offline after background)", async () => {
// Oracle: request-path is best-effort priming. The /sync GET already
// Oracle: request-path is best-effort priming. The /sync POST already
// requests a path server-side. A CSRF or network failure on the POST
// must not abort the user-initiated sync (common after a backgrounded tab).
axiosMock.post.mockRejectedValue(
Object.assign(new Error("HTTP 403"), {
response: { status: 403, data: { error: "Invalid or missing CSRF token" } },
})
);
let syncCalled = false;
axiosMock.get.mockImplementation((url) => {
axiosMock.post.mockImplementation((url) => {
if (url === "/api/v1/lxmf/propagation-node/sync") {
syncCalled = true;
return Promise.resolve({ data: { message: "Sync is starting" } });
}
return Promise.reject(
Object.assign(new Error("HTTP 403"), {
response: { status: 403, data: { error: "Invalid or missing CSRF token" } },
})
);
});
axiosMock.get.mockImplementation((url) => {
if (url === "/api/v1/lxmf/propagation-node/status") {
return Promise.resolve({
data: {