mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-16 05:10:42 -07:00
Walks the local store and maps it onto the cloud object model per the design doc: captures, generations (+ version wavs), profiles (+ samples, avatar), and the settings singletons. Records carry the full row (paths storage-relative so restores re-anchor under the destination data dir); audio travels as per-asset ciphertext blobs. Envelopes are randomized, so a new cloud_sync_state table remembers both the plaintext fingerprint (did content actually change?) and the ciphertext hash the server holds (re-declare unchanged blobs without re-encrypting or re-uploading). updated_at is excluded from fingerprints - its onupdate trigger fires as a side effect of applying pulls and would bounce synced objects otherwise. Push runs before pull; conflicts are last-writer-wins per object. Own pushes echoing back through the feed are recognized by ciphertext hash and skipped. Tests simulate two machines (own DB, data dir, keychain) syncing through an in-process fake cloud: full backup/restore with byte-identical audio, incremental edit (only the record blob re-uploads), delete propagation, LWW convergence, and the blindness check that server storage only ever holds VBX1 envelopes.
189 lines
7.7 KiB
Python
189 lines
7.7 KiB
Python
"""An in-process fake of the voicebox-cloud API for tests.
|
|
|
|
Implements the surface the desktop sync client uses — devices, the account-key
|
|
escrow, the encrypted object store, the sync feed, and blob storage — behind an
|
|
httpx.MockTransport, mirroring apps/api in the voicebox-cloud repo. Every
|
|
request body is kept in ``seen_bodies`` so tests can assert what the server was
|
|
shown (never key material, never plaintext).
|
|
"""
|
|
|
|
import json
|
|
|
|
import httpx
|
|
|
|
STORAGE_HOST = "http://cloud.test/__storage/"
|
|
|
|
|
|
class FakeCloud:
|
|
def __init__(self):
|
|
self.devices: dict[str, dict] = {}
|
|
self.account_key: dict | None = None
|
|
self.objects: dict[str, dict] = {} # objectId -> row (incl. assets dict)
|
|
self.storage: dict[str, bytes] = {} # key -> ciphertext
|
|
self.seen_bodies: list[bytes] = []
|
|
self._next_device = 0
|
|
self._next_object = 0
|
|
self._seq = 0
|
|
|
|
def transport(self) -> httpx.MockTransport:
|
|
return httpx.MockTransport(self.handle)
|
|
|
|
# -- helpers --------------------------------------------------------------
|
|
|
|
@staticmethod
|
|
def _ok(data, status=200):
|
|
return httpx.Response(status, json={"ok": True, "data": data})
|
|
|
|
def _seq_next(self) -> int:
|
|
self._seq += 1
|
|
return self._seq
|
|
|
|
def _find_object(self, kind: str, client_id: str) -> dict | None:
|
|
return next((o for o in self.objects.values() if o["kind"] == kind and o["clientId"] == client_id), None)
|
|
|
|
# -- request routing --------------------------------------------------------
|
|
|
|
def handle(self, request: httpx.Request) -> httpx.Response:
|
|
if request.content:
|
|
self.seen_bodies.append(request.content)
|
|
path, method = request.url.path, request.method
|
|
|
|
if path.startswith("/__storage/"):
|
|
key = path[len("/__storage/") :]
|
|
if method == "PUT":
|
|
self.storage[key] = request.content
|
|
return httpx.Response(200)
|
|
data = self.storage.get(key)
|
|
return httpx.Response(200, content=data) if data is not None else httpx.Response(404)
|
|
|
|
if path == "/v1/devices" and method == "POST":
|
|
return self._register_device(json.loads(request.content))
|
|
if path == "/v1/devices" and method == "GET":
|
|
return self._ok(list(self.devices.values()))
|
|
if path == "/v1/devices/account-key" and method == "PUT":
|
|
self.account_key = json.loads(request.content)
|
|
return self._ok(None)
|
|
if path == "/v1/devices/account-key" and method == "GET":
|
|
return self._ok(self.account_key)
|
|
if path.startswith("/v1/devices/") and path.endswith("/wrapped-key"):
|
|
device_id = path.split("/")[3]
|
|
if method == "POST":
|
|
self.devices[device_id]["wrappedMasterKey"] = json.loads(request.content)["wrappedMasterKey"]
|
|
return self._ok(None)
|
|
return self._ok({"wrappedMasterKey": self.devices[device_id]["wrappedMasterKey"]})
|
|
|
|
if path == "/v1/objects" and method == "POST":
|
|
return self._upsert_object(json.loads(request.content))
|
|
if path.startswith("/v1/objects/") and path.endswith("/commit"):
|
|
return self._commit(path.split("/")[3])
|
|
if path.startswith("/v1/objects/") and method == "DELETE":
|
|
obj = self.objects.get(path.split("/")[3])
|
|
if obj:
|
|
obj["deleted"] = True
|
|
obj["seq"] = self._seq_next()
|
|
return self._ok(None)
|
|
|
|
if path == "/v1/sync/changes" and method == "GET":
|
|
return self._changes(request.url.params)
|
|
|
|
return httpx.Response(404, json={"ok": False, "error": {"message": f"unhandled {method} {path}"}})
|
|
|
|
# -- endpoint implementations ----------------------------------------------
|
|
|
|
def _register_device(self, body: dict) -> httpx.Response:
|
|
self._next_device += 1
|
|
device_id = f"dev-{self._next_device}"
|
|
self.devices[device_id] = {
|
|
"id": device_id,
|
|
"name": body["name"],
|
|
"publicKey": body["publicKey"],
|
|
"wrappedMasterKey": None,
|
|
"revokedAt": None,
|
|
}
|
|
return self._ok({"deviceId": device_id, "accountHasKey": self.account_key is not None}, 201)
|
|
|
|
def _upsert_object(self, body: dict) -> httpx.Response:
|
|
obj = self._find_object(body["kind"], body["clientId"])
|
|
if obj is None:
|
|
self._next_object += 1
|
|
obj = {
|
|
"id": f"obj-{self._next_object}",
|
|
"kind": body["kind"],
|
|
"clientId": body["clientId"],
|
|
"version": 0,
|
|
"deleted": False,
|
|
"record": None,
|
|
"assets": {},
|
|
}
|
|
self.objects[obj["id"]] = obj
|
|
|
|
obj["version"] = max(obj["version"], body["version"])
|
|
obj["seq"] = self._seq_next()
|
|
obj["deleted"] = False
|
|
|
|
uploads = []
|
|
record = body.get("record")
|
|
if record and (obj["record"] is None or obj["record"]["hash"] != record["hash"]):
|
|
key = f"o/{obj['id']}/record"
|
|
obj["record"] = {**record, "key": key}
|
|
uploads.append({"for": "record", "key": key, "url": STORAGE_HOST + key})
|
|
for asset in body.get("assets", []):
|
|
caid = asset["clientAssetId"]
|
|
existing = obj["assets"].get(caid)
|
|
if existing is None or existing["hash"] != asset["hash"]:
|
|
key = f"o/{obj['id']}/a/{caid}"
|
|
obj["assets"][caid] = {**asset, "key": key}
|
|
uploads.append({"for": f"asset:{caid}", "key": key, "url": STORAGE_HOST + key})
|
|
return self._ok({"objectId": obj["id"], "seq": obj["seq"], "uploads": uploads}, 201)
|
|
|
|
def _commit(self, object_id: str) -> httpx.Response:
|
|
obj = self.objects.get(object_id)
|
|
if obj is None:
|
|
return httpx.Response(404, json={"ok": False, "error": {"message": "object not found"}})
|
|
missing = []
|
|
if obj["record"] and obj["record"]["key"] not in self.storage:
|
|
missing.append("record")
|
|
for caid, asset in obj["assets"].items():
|
|
if asset["key"] not in self.storage:
|
|
missing.append(f"asset:{caid}")
|
|
if missing:
|
|
return httpx.Response(409, json={"ok": False, "error": {"message": f"uploads missing: {missing}"}})
|
|
return self._ok({"objectId": object_id, "committed": True})
|
|
|
|
def _changes(self, params) -> httpx.Response:
|
|
since = int(params.get("since", 0))
|
|
limit = int(params.get("limit", 200))
|
|
rows = sorted((o for o in self.objects.values() if o["seq"] > since), key=lambda o: o["seq"])[:limit]
|
|
changes = [
|
|
{
|
|
"id": o["id"],
|
|
"kind": o["kind"],
|
|
"clientId": o["clientId"],
|
|
"version": o["version"],
|
|
"seq": o["seq"],
|
|
"deleted": o["deleted"],
|
|
"record": (
|
|
{
|
|
"hash": o["record"]["hash"],
|
|
"size": o["record"]["size"],
|
|
"url": STORAGE_HOST + o["record"]["key"],
|
|
}
|
|
if o["record"]
|
|
else None
|
|
),
|
|
"assets": [
|
|
{
|
|
"clientAssetId": caid,
|
|
"role": a["role"],
|
|
"hash": a["hash"],
|
|
"size": a["size"],
|
|
"url": STORAGE_HOST + a["key"] if a["key"] in self.storage else None,
|
|
}
|
|
for caid, a in o["assets"].items()
|
|
],
|
|
}
|
|
for o in rows
|
|
]
|
|
cursor = rows[-1]["seq"] if rows else since
|
|
return self._ok({"changes": changes, "cursor": cursor, "hasMore": len(rows) == limit})
|