feat: add PUT /api/settings/sync federation endpoint with newer-wins logic
This commit is contained in:
+37
-1
@@ -30,7 +30,7 @@ import websockets
|
||||
from websockets.typing import Subprotocol
|
||||
|
||||
from fastapi import FastAPI, Form, HTTPException, Request, WebSocket
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, field_validator
|
||||
from starlette.responses import RedirectResponse
|
||||
@@ -65,6 +65,7 @@ from muxplex.state import (
|
||||
state_lock,
|
||||
)
|
||||
from muxplex.settings import (
|
||||
apply_synced_settings,
|
||||
get_syncable_settings,
|
||||
load_federation_key,
|
||||
load_settings,
|
||||
@@ -359,6 +360,11 @@ class CreateSessionPayload(BaseModel):
|
||||
return stripped
|
||||
|
||||
|
||||
class SettingsSyncPayload(BaseModel):
|
||||
settings: dict
|
||||
settings_updated_at: float
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Frontend directory + hostname
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -739,6 +745,36 @@ async def get_settings_sync() -> dict:
|
||||
return {"settings": settings, "settings_updated_at": ts}
|
||||
|
||||
|
||||
@app.put("/api/settings/sync")
|
||||
async def put_settings_sync(payload: SettingsSyncPayload):
|
||||
"""Accept synced settings from a remote server (newer-wins).
|
||||
|
||||
Compares the incoming timestamp against the local settings_updated_at.
|
||||
If the incoming timestamp is strictly newer, applies only the syncable
|
||||
keys via apply_synced_settings() and returns 200 with the final state.
|
||||
If the incoming timestamp is equal to or older than the local one, returns
|
||||
409 (Conflict) with the current local state so the caller can see what
|
||||
this instance has.
|
||||
"""
|
||||
current = load_settings()
|
||||
local_ts: float = current.get("settings_updated_at", 0.0)
|
||||
|
||||
if payload.settings_updated_at > local_ts:
|
||||
apply_synced_settings(payload.settings, payload.settings_updated_at)
|
||||
syncable = get_syncable_settings()
|
||||
ts = syncable.get("settings_updated_at", 0.0)
|
||||
settings_out = {k: v for k, v in syncable.items() if k != "settings_updated_at"}
|
||||
return {"settings": settings_out, "settings_updated_at": ts}
|
||||
else:
|
||||
syncable = get_syncable_settings()
|
||||
ts = syncable.get("settings_updated_at", 0.0)
|
||||
settings_out = {k: v for k, v in syncable.items() if k != "settings_updated_at"}
|
||||
return JSONResponse(
|
||||
status_code=409,
|
||||
content={"settings": settings_out, "settings_updated_at": ts},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/instance-info")
|
||||
async def instance_info() -> dict:
|
||||
"""Return this instance's display name and version.
|
||||
|
||||
@@ -2915,3 +2915,124 @@ def test_federation_auth_headers_guard_empty_key():
|
||||
"use `{...} if key else {}` to skip the header when key is empty:\n"
|
||||
+ "\n".join(f" {line}" for line in offending)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PUT /api/settings/sync (task-8)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_put_settings_sync_applies_when_newer(client, tmp_path, monkeypatch):
|
||||
"""PUT /api/settings/sync applies settings when incoming timestamp is newer."""
|
||||
import json
|
||||
|
||||
import muxplex.settings as settings_mod
|
||||
|
||||
settings_path = tmp_path / "settings.json"
|
||||
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
|
||||
|
||||
# Set local timestamp to something old
|
||||
settings_path.write_text(json.dumps({"settings_updated_at": 1000.0}))
|
||||
|
||||
response = client.put(
|
||||
"/api/settings/sync",
|
||||
json={"settings": {"fontSize": 20}, "settings_updated_at": 2000.0},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "settings" in data, f"Response must have 'settings' key, got: {data}"
|
||||
assert "settings_updated_at" in data, (
|
||||
f"Response must have 'settings_updated_at' key, got: {data}"
|
||||
)
|
||||
assert data["settings_updated_at"] == 2000.0, (
|
||||
f"Timestamp must be 2000.0, got: {data['settings_updated_at']}"
|
||||
)
|
||||
assert data["settings"]["fontSize"] == 20, (
|
||||
f"fontSize must be 20, got: {data['settings'].get('fontSize')}"
|
||||
)
|
||||
|
||||
|
||||
def test_put_settings_sync_rejects_when_older(client, tmp_path, monkeypatch):
|
||||
"""PUT /api/settings/sync returns 409 when incoming timestamp is older."""
|
||||
import json
|
||||
|
||||
import muxplex.settings as settings_mod
|
||||
|
||||
settings_path = tmp_path / "settings.json"
|
||||
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
|
||||
|
||||
# Set local timestamp to something newer than the incoming
|
||||
settings_path.write_text(json.dumps({"settings_updated_at": 2000.0}))
|
||||
|
||||
response = client.put(
|
||||
"/api/settings/sync",
|
||||
json={"settings": {"fontSize": 18}, "settings_updated_at": 1000.0},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
data = response.json()
|
||||
assert "settings" in data, f"409 body must have 'settings' key, got: {data}"
|
||||
assert "settings_updated_at" in data, (
|
||||
f"409 body must have 'settings_updated_at' key, got: {data}"
|
||||
)
|
||||
# Should return local state, which has the newer timestamp
|
||||
assert data["settings_updated_at"] == 2000.0, (
|
||||
f"409 body must return local timestamp 2000.0, got: {data['settings_updated_at']}"
|
||||
)
|
||||
|
||||
|
||||
def test_put_settings_sync_rejects_when_equal(client, tmp_path, monkeypatch):
|
||||
"""PUT /api/settings/sync returns 409 when timestamps are equal."""
|
||||
import json
|
||||
|
||||
import muxplex.settings as settings_mod
|
||||
|
||||
settings_path = tmp_path / "settings.json"
|
||||
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
|
||||
|
||||
# Set local timestamp
|
||||
settings_path.write_text(json.dumps({"settings_updated_at": 1500.0}))
|
||||
|
||||
response = client.put(
|
||||
"/api/settings/sync",
|
||||
json={"settings": {"fontSize": 16}, "settings_updated_at": 1500.0},
|
||||
)
|
||||
assert response.status_code == 409, (
|
||||
f"Equal timestamps must return 409, got {response.status_code}"
|
||||
)
|
||||
data = response.json()
|
||||
assert data["settings_updated_at"] == 1500.0, (
|
||||
f"409 body must return local timestamp 1500.0, got: {data.get('settings_updated_at')}"
|
||||
)
|
||||
|
||||
|
||||
def test_put_settings_sync_ignores_nonsyncable_keys(client, tmp_path, monkeypatch):
|
||||
"""PUT /api/settings/sync does not apply non-syncable keys like host."""
|
||||
import json
|
||||
|
||||
import muxplex.settings as settings_mod
|
||||
|
||||
settings_path = tmp_path / "settings.json"
|
||||
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
|
||||
|
||||
# Start with default settings (host defaults to "127.0.0.1")
|
||||
settings_path.write_text(json.dumps({"settings_updated_at": 0.0}))
|
||||
|
||||
response = client.put(
|
||||
"/api/settings/sync",
|
||||
json={
|
||||
"settings": {"fontSize": 20, "host": "evil.com"},
|
||||
"settings_updated_at": 9999999999.0,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify fontSize (syncable) was changed
|
||||
local = settings_mod.load_settings()
|
||||
assert local["fontSize"] == 20, (
|
||||
f"Syncable key 'fontSize' must be updated to 20, got: {local['fontSize']}"
|
||||
)
|
||||
|
||||
# Verify host (non-syncable) was NOT changed
|
||||
assert local["host"] == "127.0.0.1", (
|
||||
f"Non-syncable key 'host' must remain '127.0.0.1', got: {local['host']}"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user