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 websockets.typing import Subprotocol
|
||||||
|
|
||||||
from fastapi import FastAPI, Form, HTTPException, Request, WebSocket
|
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 fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel, field_validator
|
from pydantic import BaseModel, field_validator
|
||||||
from starlette.responses import RedirectResponse
|
from starlette.responses import RedirectResponse
|
||||||
@@ -65,6 +65,7 @@ from muxplex.state import (
|
|||||||
state_lock,
|
state_lock,
|
||||||
)
|
)
|
||||||
from muxplex.settings import (
|
from muxplex.settings import (
|
||||||
|
apply_synced_settings,
|
||||||
get_syncable_settings,
|
get_syncable_settings,
|
||||||
load_federation_key,
|
load_federation_key,
|
||||||
load_settings,
|
load_settings,
|
||||||
@@ -359,6 +360,11 @@ class CreateSessionPayload(BaseModel):
|
|||||||
return stripped
|
return stripped
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsSyncPayload(BaseModel):
|
||||||
|
settings: dict
|
||||||
|
settings_updated_at: float
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Frontend directory + hostname
|
# Frontend directory + hostname
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -739,6 +745,36 @@ async def get_settings_sync() -> dict:
|
|||||||
return {"settings": settings, "settings_updated_at": ts}
|
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")
|
@app.get("/api/instance-info")
|
||||||
async def instance_info() -> dict:
|
async def instance_info() -> dict:
|
||||||
"""Return this instance's display name and version.
|
"""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"
|
"use `{...} if key else {}` to skip the header when key is empty:\n"
|
||||||
+ "\n".join(f" {line}" for line in offending)
|
+ "\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