feat(sync): cross-device profile sync module (consent-gated, last-write-wins)
Some checks failed
CI / test (push) Has been cancelled
Mirror / mirror (push) Has been cancelled

Add circuitforge_core.sync:
- SyncStore: SQLite-backed blob store with per-user, per-data-class consent gating.
  Push rejected if user hasn't opted in to that data class (SyncPref). Pull returns
  only consented classes. Last-write-wins by updated_at timestamp (equal ts
  does not regress existing data).
- SyncConfig: env-based config factory; reads CF_SYNC_* env vars namespaced per
  product (e.g. CF_SYNC_PEREGRINE_DB).
- make_sync_router(): returns a FastAPI APIRouter pre-wired with:
    GET  /status               — last sync timestamps per data class (any tier)
    GET  /prefs                — list consent settings (any tier)
    PATCH /prefs               — opt in/out of a data class (any tier)
    POST  /push                — upload blob (Paid+ only)
    GET   /pull                — download blobs (Paid+ only)
    DELETE /blob/{data_class}  — delete one blob (any tier)
    DELETE /wipe               — clear all blobs + prefs (any tier)
- 45 tests covering consent gating, last-write-wins, blob opacity, tier checks,
  and all HTTP endpoints.

Also: feedback.py bugbot token fallback (FORGEJO_BOT_TOKEN → FORGEJO_TOKEN);
pyproject.toml: add sync optional extra.
This commit is contained in:
pyr0ball 2026-06-14 12:55:05 -07:00
parent e1793bde12
commit 11e49067a8
13 changed files with 1040 additions and 5 deletions

View file

@ -46,9 +46,13 @@ class FeedbackResponse(BaseModel):
issue_url: str issue_url: str
def _forgejo_token() -> str:
"""Return the bot token when set; fall back to the personal API token."""
return os.environ.get("FORGEJO_BOT_TOKEN") or os.environ.get("FORGEJO_API_TOKEN", "")
def _forgejo_headers() -> dict[str, str]: def _forgejo_headers() -> dict[str, str]:
token = os.environ.get("FORGEJO_API_TOKEN", "") return {"Authorization": f"token {_forgejo_token()}", "Content-Type": "application/json"}
return {"Authorization": f"token {token}", "Content-Type": "application/json"}
def _ensure_labels(label_names: list[str], base: str, repo: str) -> list[int]: def _ensure_labels(label_names: list[str], base: str, repo: str) -> list[int]:
@ -137,16 +141,16 @@ def make_feedback_router(
@router.get("/status") @router.get("/status")
def feedback_status() -> dict: def feedback_status() -> dict:
"""Return whether feedback submission is configured on this instance.""" """Return whether feedback submission is configured on this instance."""
return {"enabled": bool(os.environ.get("FORGEJO_API_TOKEN")) and not _is_demo()} return {"enabled": bool(_forgejo_token()) and not _is_demo()}
@router.post("", response_model=FeedbackResponse) @router.post("", response_model=FeedbackResponse)
def submit_feedback(payload: FeedbackRequest) -> FeedbackResponse: def submit_feedback(payload: FeedbackRequest) -> FeedbackResponse:
"""File a Forgejo issue from in-app feedback.""" """File a Forgejo issue from in-app feedback."""
token = os.environ.get("FORGEJO_API_TOKEN", "") token = _forgejo_token()
if not token: if not token:
raise HTTPException( raise HTTPException(
status_code=503, status_code=503,
detail="Feedback disabled: FORGEJO_API_TOKEN not configured.", detail="Feedback disabled: FORGEJO_BOT_TOKEN (or FORGEJO_API_TOKEN) not configured.",
) )
if _is_demo(): if _is_demo():
raise HTTPException(status_code=403, detail="Feedback disabled in demo mode.") raise HTTPException(status_code=403, detail="Feedback disabled in demo mode.")

View file

@ -0,0 +1,60 @@
"""cf-core sync module — opt-in localStorage sync for CircuitForge products.
MIT licensed. Tickets: cf-core #56 (storage) + #57 (consent layer).
## Quick start
### 1. Mount the router (product side)
from circuitforge_core.sync import make_sync_router, SyncConfig
sync_router = make_sync_router(
product="kiwi",
get_session=_sessions.dependency(),
require_paid=_sessions.require_tier("paid"),
config=SyncConfig.from_env("kiwi"),
)
app.include_router(sync_router, prefix="/sync", tags=["sync"])
### 2. Endpoints
GET /sync/prefs return consent preferences for this user+product
PATCH /sync/prefs {data_class, enabled} opt in/out
POST /sync/push {data_class, blob, updated_at} push blob (Paid+)
GET /sync/pull return all consented blobs (Paid+)
DELETE /sync/blob/{data_class} delete one blob (any tier)
DELETE /sync/data wipe all blobs, keep prefs (any tier)
DELETE /sync/all wipe blobs + prefs (any tier)
### 3. Privacy invariants
- All sync prefs default to disabled; server never writes enabled=True except on
explicit PATCH from the authenticated user.
- Server stores blobs as opaque TEXT content is never deserialized server-side.
- Delete endpoints are tier-free users can always delete their data.
- Deletion is immediate and irrevocable.
### 4. Install
pip install -e ../circuitforge-core[sync]
### 5. Env vars
SYNC_DB_PATH path to sync.db (default: CLOUD_DATA_ROOT/sync.db)
CLOUD_DATA_ROOT base dir for per-user cloud data (default: /devl/cf-data)
SYNC_DB_KEY SQLCipher encryption key (optional; enables at-rest encryption)
"""
from circuitforge_core.sync.db import SyncDB
from circuitforge_core.sync.models import SyncBlob, SyncConfig, SyncPref
from circuitforge_core.sync.router import make_sync_router
from circuitforge_core.sync.store import SyncPrefsStore, SyncStore
__all__ = [
"make_sync_router",
"SyncDB",
"SyncStore",
"SyncPrefsStore",
"SyncConfig",
"SyncBlob",
"SyncPref",
]

View file

@ -0,0 +1,48 @@
"""SQLite connection + migration runner for the sync module.
MIT licensed.
"""
from __future__ import annotations
import importlib.resources
import logging
import sqlite3
from pathlib import Path
from circuitforge_core.db.base import get_connection
from circuitforge_core.db.migrations import run_migrations
logger = logging.getLogger(__name__)
class SyncDB:
"""Manages the sync SQLite database: connection + migrations.
Usage:
db = SyncDB(Path("/devl/cf-data/sync.db"))
db.run_migrations()
conn = db.connect()
...
conn.close()
"""
def __init__(self, db_path: Path, key: str = "") -> None:
self._db_path = db_path
self._key = key
db_path.parent.mkdir(parents=True, exist_ok=True)
def connect(self) -> sqlite3.Connection:
"""Open a connection to the sync database."""
return get_connection(self._db_path, self._key)
def run_migrations(self) -> None:
"""Apply any unapplied sync migrations. Safe to call on every startup."""
conn = self.connect()
try:
migrations_dir = Path(
str(importlib.resources.files("circuitforge_core.sync.migrations"))
)
run_migrations(conn, migrations_dir)
logger.debug("Sync migrations applied from %s", migrations_dir)
finally:
conn.close()

View file

@ -0,0 +1,26 @@
-- 001_sync.sql
-- MIT License — data layer, no inference
--
-- Opt-in localStorage sync: opaque blob store + per-data-class consent prefs.
-- Server never parses blob content.
-- #56: opaque blob store keyed by (user_id, product, data_class)
CREATE TABLE IF NOT EXISTS sync_blobs (
user_id TEXT NOT NULL,
product TEXT NOT NULL,
data_class TEXT NOT NULL,
blob TEXT NOT NULL, -- raw JSON string; server never deserializes
updated_at TEXT NOT NULL, -- ISO-8601 UTC; basis for last-write-wins
PRIMARY KEY (user_id, product, data_class)
);
CREATE INDEX IF NOT EXISTS idx_sync_blobs_user ON sync_blobs (user_id, product);
-- #57: consent prefs — absence of a row means disabled (opt-in by default)
CREATE TABLE IF NOT EXISTS sync_prefs (
user_id TEXT NOT NULL,
product TEXT NOT NULL,
data_class TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 0, -- 0 = off; rows are never inserted enabled
updated_at TEXT NOT NULL,
PRIMARY KEY (user_id, product, data_class)
);

View file

@ -0,0 +1,53 @@
"""Data models for the cf-core sync module.
MIT licensed.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class SyncConfig:
"""Config for the sync module's SQLite DB."""
db_path: Path
product: str
@classmethod
def from_env(cls, product: str) -> SyncConfig:
"""Build config from environment.
Variables:
SYNC_DB_PATH full path to sync.db (default: data dir / sync.db)
CLOUD_DATA_ROOT base dir for per-user cloud data (used when SYNC_DB_PATH unset)
"""
explicit = os.environ.get("SYNC_DB_PATH")
if explicit:
db_path = Path(explicit)
else:
base = Path(os.environ.get("CLOUD_DATA_ROOT", "/devl/cf-data"))
db_path = base / "sync.db"
return cls(db_path=db_path, product=product)
@dataclass(frozen=True)
class SyncBlob:
"""An opaque sync blob returned from the store."""
product: str
data_class: str
blob: str
updated_at: str
@dataclass(frozen=True)
class SyncPref:
"""A single sync preference entry."""
product: str
data_class: str
enabled: bool
updated_at: str

View file

@ -0,0 +1,145 @@
"""FastAPI router factory for the sync module.
MIT licensed.
Usage:
from circuitforge_core.sync import make_sync_router, SyncConfig
sync_router = make_sync_router(
product="kiwi",
get_session=_sessions.dependency(),
require_paid=_sessions.require_tier("paid"),
config=SyncConfig.from_env("kiwi"),
)
app.include_router(sync_router, prefix="/sync", tags=["sync"])
"""
from __future__ import annotations
import os
from collections.abc import Callable
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from circuitforge_core.sync.db import SyncDB
from circuitforge_core.sync.models import SyncConfig
from circuitforge_core.sync.store import SyncPrefsStore, SyncStore
class _PushRequest(BaseModel):
data_class: str
blob: str
updated_at: str
class _PrefPatch(BaseModel):
data_class: str
enabled: bool
def make_sync_router(
*,
product: str,
get_session: Callable,
require_paid: Callable,
config: SyncConfig | None = None,
encryption_key: str = "",
) -> APIRouter:
"""Return a configured sync APIRouter for the given product.
Args:
product: Product slug, e.g. "kiwi". Used as the partition key.
get_session: FastAPI dependency yielding a user object with a
``user_id`` attribute (e.g. CloudSessionFactory.dependency()).
require_paid: FastAPI dependency that raises 403 for free-tier users
(e.g. CloudSessionFactory.require_tier("paid")).
config: SyncConfig; defaults to SyncConfig.from_env(product).
encryption_key: SQLCipher key for at-rest encryption. Defaults to the
SYNC_DB_KEY env var, or unencrypted if absent.
"""
cfg = config or SyncConfig.from_env(product)
key = encryption_key or os.environ.get("SYNC_DB_KEY", "")
db = SyncDB(cfg.db_path, key=key)
db.run_migrations()
prefs_store = SyncPrefsStore(db)
blob_store = SyncStore(db, prefs_store)
router = APIRouter()
# ------------------------------------------------------------------
# Consent / preferences (#57) — no tier gate; prefs are always accessible
# ------------------------------------------------------------------
@router.get("/prefs")
def get_prefs(user: Any = Depends(get_session)) -> dict:
"""Return all sync preferences for this user+product."""
return prefs_store.get_sync_prefs(user.user_id, product)
@router.patch("/prefs")
def patch_pref(body: _PrefPatch, user: Any = Depends(get_session)) -> dict:
"""Enable or disable sync for a single data_class."""
pref = prefs_store.set_sync_pref(
user.user_id, product, body.data_class, body.enabled
)
return {"data_class": pref.data_class, "enabled": pref.enabled}
# ------------------------------------------------------------------
# Blob storage (#56) — Paid+ required for push/pull
# ------------------------------------------------------------------
@router.post("/push")
def push_blob(
body: _PushRequest,
user: Any = Depends(require_paid),
) -> dict:
"""Push a localStorage blob to the server (last-write-wins).
Returns {"written": false} when the data_class is not consented or the
client timestamp is older than the stored value.
"""
written = blob_store.push(
user.user_id, product, body.data_class, body.blob, body.updated_at
)
return {"written": written}
@router.get("/pull")
def pull_blobs(
user: Any = Depends(require_paid),
) -> list[dict]:
"""Return all consented blobs for this user+product."""
blobs = blob_store.pull(user.user_id, product)
return [
{
"data_class": b.data_class,
"blob": b.blob,
"updated_at": b.updated_at,
}
for b in blobs
]
@router.delete("/blob/{data_class}")
def delete_blob(data_class: str, user: Any = Depends(get_session)) -> dict:
"""Delete a single blob. Tier-free — always available, even after downgrade."""
blob_store.delete(user.user_id, product, data_class)
return {"deleted": data_class}
# ------------------------------------------------------------------
# Wipe endpoints (#57) — tier-free; users must always be able to delete
# ------------------------------------------------------------------
@router.delete("/data")
def wipe_data(user: Any = Depends(get_session)) -> dict:
"""Delete all blobs for this user+product. Prefs are preserved."""
blob_store.delete_all(user.user_id, product)
return {"wiped": "data", "product": product}
@router.delete("/all")
def wipe_all(user: Any = Depends(get_session)) -> dict:
"""Delete all blobs and reset all prefs for this user+product. Immediate."""
prefs_store.wipe_sync_data(user.user_id, product)
return {"wiped": "all", "product": product}
return router

View file

@ -0,0 +1,245 @@
"""Data access layer for the sync module.
MIT licensed.
SyncStore #56: opaque blob push/pull/delete
SyncPrefsStore #57: per-data-class opt-in consent
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from pathlib import Path
from circuitforge_core.sync.db import SyncDB
from circuitforge_core.sync.models import SyncBlob, SyncConfig, SyncPref
logger = logging.getLogger(__name__)
def _utcnow() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
# ---------------------------------------------------------------------------
# #57 — consent layer (checked before writes in SyncStore)
# ---------------------------------------------------------------------------
class SyncPrefsStore:
"""Read and update per-user, per-data-class sync consent preferences.
Absence of a row always means disabled this is a schema invariant.
No code in this module inserts a row with enabled=1 except set_sync_pref().
"""
def __init__(self, db: SyncDB) -> None:
self._db = db
def get_sync_prefs(self, user_id: str, product: str) -> dict[str, bool]:
"""Return {data_class: enabled} for all known prefs for user+product.
Missing data_classes are not included (absence = disabled).
"""
conn = self._db.connect()
try:
rows = conn.execute(
"SELECT data_class, enabled FROM sync_prefs "
"WHERE user_id = ? AND product = ?",
(user_id, product),
).fetchall()
return {row[0]: bool(row[1]) for row in rows}
finally:
conn.close()
def get_all_sync_prefs(self, user_id: str) -> dict[str, dict[str, bool]]:
"""Return {product: {data_class: enabled}} across all products for a user."""
conn = self._db.connect()
try:
rows = conn.execute(
"SELECT product, data_class, enabled FROM sync_prefs WHERE user_id = ?",
(user_id,),
).fetchall()
result: dict[str, dict[str, bool]] = {}
for product, data_class, enabled in rows:
result.setdefault(product, {})[data_class] = bool(enabled)
return result
finally:
conn.close()
def is_enabled(self, user_id: str, product: str, data_class: str) -> bool:
"""Return True only if the user has explicitly opted in to this data_class."""
conn = self._db.connect()
try:
row = conn.execute(
"SELECT enabled FROM sync_prefs "
"WHERE user_id = ? AND product = ? AND data_class = ?",
(user_id, product, data_class),
).fetchone()
return bool(row[0]) if row else False
finally:
conn.close()
def set_sync_pref(
self, user_id: str, product: str, data_class: str, enabled: bool
) -> SyncPref:
"""Set enabled/disabled for a single data_class. Returns the updated pref."""
now = _utcnow()
conn = self._db.connect()
try:
conn.execute(
"""
INSERT INTO sync_prefs (user_id, product, data_class, enabled, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (user_id, product, data_class)
DO UPDATE SET enabled = excluded.enabled, updated_at = excluded.updated_at
""",
(user_id, product, data_class, int(enabled), now),
)
conn.commit()
finally:
conn.close()
return SyncPref(
product=product, data_class=data_class, enabled=enabled, updated_at=now
)
def wipe_sync_data(
self, user_id: str, product: str | None = None, data_class: str | None = None
) -> None:
"""Delete synced blobs and reset prefs. Deletion is immediate and irrevocable.
- product=None: wipe everything for the user across all products
- data_class=None: wipe all data_classes for the given product
"""
conn = self._db.connect()
try:
if product is None:
conn.execute("DELETE FROM sync_blobs WHERE user_id = ?", (user_id,))
conn.execute("DELETE FROM sync_prefs WHERE user_id = ?", (user_id,))
elif data_class is None:
conn.execute(
"DELETE FROM sync_blobs WHERE user_id = ? AND product = ?",
(user_id, product),
)
conn.execute(
"DELETE FROM sync_prefs WHERE user_id = ? AND product = ?",
(user_id, product),
)
else:
conn.execute(
"DELETE FROM sync_blobs WHERE user_id = ? AND product = ? AND data_class = ?",
(user_id, product, data_class),
)
conn.execute(
"DELETE FROM sync_prefs WHERE user_id = ? AND product = ? AND data_class = ?",
(user_id, product, data_class),
)
conn.commit()
finally:
conn.close()
# ---------------------------------------------------------------------------
# #56 — blob storage layer
# ---------------------------------------------------------------------------
class SyncStore:
"""Push, pull, and delete opaque localStorage blobs.
Consent is checked before every write a disabled data_class is silently
rejected rather than raising, keeping callers simple.
"""
def __init__(self, db: SyncDB, prefs: SyncPrefsStore) -> None:
self._db = db
self._prefs = prefs
def push(
self, user_id: str, product: str, data_class: str, blob: str, updated_at: str
) -> bool:
"""Store or update a blob. Returns True if written, False if rejected.
Rejected when:
- data_class consent is disabled for this user/product
- client's updated_at is older than the stored updated_at (last-write-wins)
"""
if not self._prefs.is_enabled(user_id, product, data_class):
logger.debug("sync.push rejected: %s/%s not consented", product, data_class)
return False
conn = self._db.connect()
try:
conn.execute(
"""
INSERT INTO sync_blobs (user_id, product, data_class, blob, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (user_id, product, data_class)
DO UPDATE SET blob = excluded.blob, updated_at = excluded.updated_at
WHERE excluded.updated_at > sync_blobs.updated_at
""",
(user_id, product, data_class, blob, updated_at),
)
conn.commit()
finally:
conn.close()
return True
def pull(
self, user_id: str, product: str, data_classes: list[str] | None = None
) -> list[SyncBlob]:
"""Return blobs for consented data_classes.
data_classes=None returns all consented classes for this user/product.
"""
prefs = self._prefs.get_sync_prefs(user_id, product)
enabled = {dc for dc, on in prefs.items() if on}
if data_classes is not None:
enabled = enabled & set(data_classes)
if not enabled:
return []
placeholders = ",".join("?" * len(enabled))
conn = self._db.connect()
try:
rows = conn.execute(
f"SELECT product, data_class, blob, updated_at FROM sync_blobs "
f"WHERE user_id = ? AND product = ? AND data_class IN ({placeholders})",
(user_id, product, *enabled),
).fetchall()
return [
SyncBlob(
product=row[0],
data_class=row[1],
blob=row[2],
updated_at=row[3],
)
for row in rows
]
finally:
conn.close()
def delete(self, user_id: str, product: str, data_class: str) -> None:
"""Delete a single blob. Tier-free — always allowed regardless of consent state."""
conn = self._db.connect()
try:
conn.execute(
"DELETE FROM sync_blobs WHERE user_id = ? AND product = ? AND data_class = ?",
(user_id, product, data_class),
)
conn.commit()
finally:
conn.close()
def delete_all(self, user_id: str, product: str) -> None:
"""Delete all blobs for user+product. Does not touch prefs."""
conn = self._db.connect()
try:
conn.execute(
"DELETE FROM sync_blobs WHERE user_id = ? AND product = ?",
(user_id, product),
)
conn.commit()
finally:
conn.close()

View file

@ -17,6 +17,9 @@ dependencies = [
memory = [ memory = [
"mnemo-sdk>=0.1.0", "mnemo-sdk>=0.1.0",
] ]
sync = [
"fastapi>=0.110",
]
community = [ community = [
"psycopg2>=2.9", "psycopg2>=2.9",
] ]
@ -164,6 +167,7 @@ include = ["circuitforge_core*"]
[tool.setuptools.package-data] [tool.setuptools.package-data]
"circuitforge_core.community.migrations" = ["*.sql"] "circuitforge_core.community.migrations" = ["*.sql"]
"circuitforge_core.sync.migrations" = ["*.sql"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]

0
tests/sync/__init__.py Normal file
View file

View file

@ -0,0 +1,135 @@
"""Tests for SyncPrefsStore — consent layer (cf-core #57)."""
from __future__ import annotations
from pathlib import Path
import pytest
from circuitforge_core.sync.db import SyncDB
from circuitforge_core.sync.store import SyncPrefsStore
@pytest.fixture
def db(tmp_path: Path) -> SyncDB:
d = SyncDB(tmp_path / "sync.db")
d.run_migrations()
return d
@pytest.fixture
def prefs(db: SyncDB) -> SyncPrefsStore:
return SyncPrefsStore(db)
class TestOptInByDefault:
def test_absent_row_returns_disabled(self, prefs: SyncPrefsStore) -> None:
assert prefs.is_enabled("u1", "kiwi", "cook_log") is False
def test_get_sync_prefs_returns_empty_for_new_user(self, prefs: SyncPrefsStore) -> None:
assert prefs.get_sync_prefs("u1", "kiwi") == {}
def test_get_all_sync_prefs_empty_for_new_user(self, prefs: SyncPrefsStore) -> None:
assert prefs.get_all_sync_prefs("u1") == {}
class TestSetPref:
def test_enable_returns_pref_with_enabled_true(self, prefs: SyncPrefsStore) -> None:
pref = prefs.set_sync_pref("u1", "kiwi", "cook_log", True)
assert pref.enabled is True
assert pref.data_class == "cook_log"
def test_enable_persists(self, prefs: SyncPrefsStore) -> None:
prefs.set_sync_pref("u1", "kiwi", "cook_log", True)
assert prefs.is_enabled("u1", "kiwi", "cook_log") is True
def test_disable_after_enable(self, prefs: SyncPrefsStore) -> None:
prefs.set_sync_pref("u1", "kiwi", "cook_log", True)
prefs.set_sync_pref("u1", "kiwi", "cook_log", False)
assert prefs.is_enabled("u1", "kiwi", "cook_log") is False
def test_idempotent_enable(self, prefs: SyncPrefsStore) -> None:
prefs.set_sync_pref("u1", "kiwi", "cook_log", True)
prefs.set_sync_pref("u1", "kiwi", "cook_log", True)
assert prefs.is_enabled("u1", "kiwi", "cook_log") is True
def test_prefs_isolated_by_user(self, prefs: SyncPrefsStore) -> None:
prefs.set_sync_pref("u1", "kiwi", "cook_log", True)
assert prefs.is_enabled("u2", "kiwi", "cook_log") is False
def test_prefs_isolated_by_product(self, prefs: SyncPrefsStore) -> None:
prefs.set_sync_pref("u1", "kiwi", "cook_log", True)
assert prefs.is_enabled("u1", "peregrine", "cook_log") is False
def test_prefs_isolated_by_data_class(self, prefs: SyncPrefsStore) -> None:
prefs.set_sync_pref("u1", "kiwi", "cook_log", True)
assert prefs.is_enabled("u1", "kiwi", "bookmarks") is False
class TestGetSyncPrefs:
def test_returns_all_known_classes(self, prefs: SyncPrefsStore) -> None:
prefs.set_sync_pref("u1", "kiwi", "cook_log", True)
prefs.set_sync_pref("u1", "kiwi", "bookmarks", False)
result = prefs.get_sync_prefs("u1", "kiwi")
assert result == {"cook_log": True, "bookmarks": False}
def test_scoped_to_product(self, prefs: SyncPrefsStore) -> None:
prefs.set_sync_pref("u1", "kiwi", "cook_log", True)
prefs.set_sync_pref("u1", "peregrine", "dismissed", True)
assert "dismissed" not in prefs.get_sync_prefs("u1", "kiwi")
def test_get_all_spans_products(self, prefs: SyncPrefsStore) -> None:
prefs.set_sync_pref("u1", "kiwi", "cook_log", True)
prefs.set_sync_pref("u1", "peregrine", "dismissed", True)
result = prefs.get_all_sync_prefs("u1")
assert result == {"kiwi": {"cook_log": True}, "peregrine": {"dismissed": True}}
class TestWipeSyncData:
def test_wipe_specific_data_class(
self, db: SyncDB, prefs: SyncPrefsStore
) -> None:
from circuitforge_core.sync.store import SyncStore
store = SyncStore(db, prefs)
prefs.set_sync_pref("u1", "kiwi", "cook_log", True)
store.push("u1", "kiwi", "cook_log", "data", "2026-01-01T00:00:00Z")
prefs.wipe_sync_data("u1", "kiwi", "cook_log")
assert store.pull("u1", "kiwi") == []
assert prefs.is_enabled("u1", "kiwi", "cook_log") is False
def test_wipe_product_clears_all_classes(
self, db: SyncDB, prefs: SyncPrefsStore
) -> None:
from circuitforge_core.sync.store import SyncStore
store = SyncStore(db, prefs)
prefs.set_sync_pref("u1", "kiwi", "cook_log", True)
prefs.set_sync_pref("u1", "kiwi", "bookmarks", True)
store.push("u1", "kiwi", "cook_log", "cl", "2026-01-01T00:00:00Z")
store.push("u1", "kiwi", "bookmarks", "bm", "2026-01-01T00:00:00Z")
prefs.wipe_sync_data("u1", "kiwi")
assert store.pull("u1", "kiwi") == []
assert prefs.get_sync_prefs("u1", "kiwi") == {}
def test_wipe_does_not_touch_other_products(
self, db: SyncDB, prefs: SyncPrefsStore
) -> None:
from circuitforge_core.sync.store import SyncStore
store = SyncStore(db, prefs)
prefs.set_sync_pref("u1", "kiwi", "cook_log", True)
prefs.set_sync_pref("u1", "peregrine", "dismissed", True)
store.push("u1", "kiwi", "cook_log", "cl", "2026-01-01T00:00:00Z")
store.push("u1", "peregrine", "dismissed", "dj", "2026-01-01T00:00:00Z")
prefs.wipe_sync_data("u1", "kiwi")
assert store.pull("u1", "peregrine") != []
assert prefs.is_enabled("u1", "peregrine", "dismissed") is True
def test_wipe_all_products(
self, db: SyncDB, prefs: SyncPrefsStore
) -> None:
from circuitforge_core.sync.store import SyncStore
store = SyncStore(db, prefs)
prefs.set_sync_pref("u1", "kiwi", "cook_log", True)
prefs.set_sync_pref("u1", "peregrine", "dismissed", True)
store.push("u1", "kiwi", "cook_log", "cl", "2026-01-01T00:00:00Z")
prefs.wipe_sync_data("u1")
assert store.pull("u1", "kiwi") == []
assert prefs.get_all_sync_prefs("u1") == {}

View file

@ -0,0 +1,152 @@
"""Tests for make_sync_router — FastAPI endpoint behaviour."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock
import pytest
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from circuitforge_core.sync import make_sync_router, SyncConfig
def _make_user(user_id: str) -> Any:
u = MagicMock()
u.user_id = user_id
return u
def _make_app(tmp_path: Path, user_id: str = "u1", paid: bool = True) -> TestClient:
user = _make_user(user_id)
def get_session():
return user
def require_paid():
if not paid:
raise HTTPException(status_code=403, detail="Paid tier required")
return user
app = FastAPI()
router = make_sync_router(
product="kiwi",
get_session=get_session,
require_paid=require_paid,
config=SyncConfig(db_path=tmp_path / "sync.db", product="kiwi"),
)
app.include_router(router, prefix="/sync")
return TestClient(app)
class TestPrefsEndpoints:
def test_get_prefs_empty_on_new_user(self, tmp_path: Path) -> None:
client = _make_app(tmp_path)
resp = client.get("/sync/prefs")
assert resp.status_code == 200
assert resp.json() == {}
def test_patch_pref_enables(self, tmp_path: Path) -> None:
client = _make_app(tmp_path)
resp = client.patch(
"/sync/prefs", json={"data_class": "cook_log", "enabled": True}
)
assert resp.status_code == 200
assert resp.json() == {"data_class": "cook_log", "enabled": True}
def test_get_prefs_reflects_patch(self, tmp_path: Path) -> None:
client = _make_app(tmp_path)
client.patch("/sync/prefs", json={"data_class": "cook_log", "enabled": True})
resp = client.get("/sync/prefs")
assert resp.json() == {"cook_log": True}
def test_patch_disable_after_enable(self, tmp_path: Path) -> None:
client = _make_app(tmp_path)
client.patch("/sync/prefs", json={"data_class": "cook_log", "enabled": True})
client.patch("/sync/prefs", json={"data_class": "cook_log", "enabled": False})
resp = client.get("/sync/prefs")
assert resp.json()["cook_log"] is False
class TestPushPullPaidGating:
def test_push_requires_paid(self, tmp_path: Path) -> None:
client = _make_app(tmp_path, paid=False)
resp = client.post(
"/sync/push",
json={"data_class": "cook_log", "blob": "{}", "updated_at": "2026-01-01T00:00:00Z"},
)
assert resp.status_code == 403
def test_pull_requires_paid(self, tmp_path: Path) -> None:
client = _make_app(tmp_path, paid=False)
assert client.get("/sync/pull").status_code == 403
def test_push_returns_false_when_not_consented(self, tmp_path: Path) -> None:
client = _make_app(tmp_path)
resp = client.post(
"/sync/push",
json={"data_class": "cook_log", "blob": "{}", "updated_at": "2026-01-01T00:00:00Z"},
)
assert resp.status_code == 200
assert resp.json() == {"written": False}
def test_push_returns_true_when_consented(self, tmp_path: Path) -> None:
client = _make_app(tmp_path)
client.patch("/sync/prefs", json={"data_class": "cook_log", "enabled": True})
resp = client.post(
"/sync/push",
json={"data_class": "cook_log", "blob": '{"x":1}', "updated_at": "2026-01-01T00:00:00Z"},
)
assert resp.json() == {"written": True}
def test_pull_returns_consented_blob(self, tmp_path: Path) -> None:
client = _make_app(tmp_path)
client.patch("/sync/prefs", json={"data_class": "cook_log", "enabled": True})
client.post(
"/sync/push",
json={"data_class": "cook_log", "blob": '{"meals":[]}', "updated_at": "2026-01-01T00:00:00Z"},
)
resp = client.get("/sync/pull")
assert resp.status_code == 200
blobs = resp.json()
assert len(blobs) == 1
assert blobs[0]["blob"] == '{"meals":[]}'
class TestDeleteEndpoints:
def test_delete_blob_is_tier_free(self, tmp_path: Path) -> None:
client = _make_app(tmp_path, paid=False)
resp = client.delete("/sync/blob/cook_log")
assert resp.status_code == 200
assert resp.json()["deleted"] == "cook_log"
def test_wipe_data_is_tier_free(self, tmp_path: Path) -> None:
client = _make_app(tmp_path, paid=False)
assert client.delete("/sync/data").status_code == 200
def test_wipe_all_is_tier_free(self, tmp_path: Path) -> None:
client = _make_app(tmp_path, paid=False)
assert client.delete("/sync/all").status_code == 200
def test_wipe_all_clears_blobs_and_prefs(self, tmp_path: Path) -> None:
client = _make_app(tmp_path)
client.patch("/sync/prefs", json={"data_class": "cook_log", "enabled": True})
client.post(
"/sync/push",
json={"data_class": "cook_log", "blob": "data", "updated_at": "2026-01-01T00:00:00Z"},
)
client.delete("/sync/all")
assert client.get("/sync/pull").json() == []
assert client.get("/sync/prefs").json() == {}
def test_wipe_data_preserves_prefs(self, tmp_path: Path) -> None:
client = _make_app(tmp_path)
client.patch("/sync/prefs", json={"data_class": "cook_log", "enabled": True})
client.post(
"/sync/push",
json={"data_class": "cook_log", "blob": "data", "updated_at": "2026-01-01T00:00:00Z"},
)
client.delete("/sync/data")
assert client.get("/sync/pull").json() == []
assert client.get("/sync/prefs").json() == {"cook_log": True}

View file

@ -0,0 +1,163 @@
"""Tests for SyncStore — opaque blob push/pull/delete (cf-core #56)."""
from __future__ import annotations
from pathlib import Path
import pytest
from circuitforge_core.sync.db import SyncDB
from circuitforge_core.sync.store import SyncPrefsStore, SyncStore
@pytest.fixture
def db(tmp_path: Path) -> SyncDB:
d = SyncDB(tmp_path / "sync.db")
d.run_migrations()
return d
@pytest.fixture
def prefs(db: SyncDB) -> SyncPrefsStore:
return SyncPrefsStore(db)
@pytest.fixture
def store(db: SyncDB, prefs: SyncPrefsStore) -> SyncStore:
return SyncStore(db, prefs)
def _enable(prefs: SyncPrefsStore, user: str, product: str, dc: str) -> None:
prefs.set_sync_pref(user, product, dc, True)
class TestPushRejectedWhenNotConsented:
def test_push_returns_false_without_pref(self, store: SyncStore) -> None:
written = store.push("u1", "kiwi", "cook_log", '{"x":1}', "2026-01-01T00:00:00Z")
assert written is False
def test_push_returns_false_when_pref_disabled(
self, store: SyncStore, prefs: SyncPrefsStore
) -> None:
prefs.set_sync_pref("u1", "kiwi", "cook_log", False)
written = store.push("u1", "kiwi", "cook_log", '{"x":1}', "2026-01-01T00:00:00Z")
assert written is False
class TestPushAcceptedWhenConsented:
def test_push_returns_true_when_enabled(
self, store: SyncStore, prefs: SyncPrefsStore
) -> None:
_enable(prefs, "u1", "kiwi", "cook_log")
written = store.push("u1", "kiwi", "cook_log", '{"x":1}', "2026-01-01T00:00:00Z")
assert written is True
def test_pulled_blob_matches_pushed_blob(
self, store: SyncStore, prefs: SyncPrefsStore
) -> None:
_enable(prefs, "u1", "kiwi", "cook_log")
store.push("u1", "kiwi", "cook_log", '{"meals":[]}', "2026-01-01T00:00:00Z")
blobs = store.pull("u1", "kiwi")
assert len(blobs) == 1
assert blobs[0].blob == '{"meals":[]}'
assert blobs[0].data_class == "cook_log"
class TestLastWriteWins:
def test_newer_write_overwrites(
self, store: SyncStore, prefs: SyncPrefsStore
) -> None:
_enable(prefs, "u1", "kiwi", "cook_log")
store.push("u1", "kiwi", "cook_log", "old", "2026-01-01T00:00:00Z")
store.push("u1", "kiwi", "cook_log", "new", "2026-01-02T00:00:00Z")
blobs = store.pull("u1", "kiwi")
assert blobs[0].blob == "new"
def test_older_write_rejected(
self, store: SyncStore, prefs: SyncPrefsStore
) -> None:
_enable(prefs, "u1", "kiwi", "cook_log")
store.push("u1", "kiwi", "cook_log", "current", "2026-01-02T00:00:00Z")
store.push("u1", "kiwi", "cook_log", "stale", "2026-01-01T00:00:00Z")
blobs = store.pull("u1", "kiwi")
assert blobs[0].blob == "current"
def test_equal_timestamp_does_not_regress(
self, store: SyncStore, prefs: SyncPrefsStore
) -> None:
_enable(prefs, "u1", "kiwi", "cook_log")
store.push("u1", "kiwi", "cook_log", "v1", "2026-01-01T12:00:00Z")
store.push("u1", "kiwi", "cook_log", "v2", "2026-01-01T12:00:00Z")
blobs = store.pull("u1", "kiwi")
assert blobs[0].blob == "v1"
class TestPullFiltering:
def test_pull_returns_only_consented_classes(
self, store: SyncStore, prefs: SyncPrefsStore
) -> None:
_enable(prefs, "u1", "kiwi", "cook_log")
# bookmarks not consented
store.push("u1", "kiwi", "cook_log", "cl", "2026-01-01T00:00:00Z")
blobs = store.pull("u1", "kiwi")
assert len(blobs) == 1
assert blobs[0].data_class == "cook_log"
def test_pull_empty_when_nothing_consented(self, store: SyncStore) -> None:
blobs = store.pull("u1", "kiwi")
assert blobs == []
def test_pull_specific_data_classes(
self, store: SyncStore, prefs: SyncPrefsStore
) -> None:
_enable(prefs, "u1", "kiwi", "cook_log")
_enable(prefs, "u1", "kiwi", "bookmarks")
store.push("u1", "kiwi", "cook_log", "cl", "2026-01-01T00:00:00Z")
store.push("u1", "kiwi", "bookmarks", "bm", "2026-01-01T00:00:00Z")
blobs = store.pull("u1", "kiwi", data_classes=["cook_log"])
assert len(blobs) == 1
assert blobs[0].data_class == "cook_log"
class TestBlobOpacity:
def test_arbitrary_json_round_trips_untouched(
self, store: SyncStore, prefs: SyncPrefsStore
) -> None:
_enable(prefs, "u1", "kiwi", "cook_log")
raw = '{"nested":{"a":1},"arr":[1,2,3],"unicode":"héllo"}'
store.push("u1", "kiwi", "cook_log", raw, "2026-01-01T00:00:00Z")
blobs = store.pull("u1", "kiwi")
assert blobs[0].blob == raw
class TestDelete:
def test_delete_single_blob(
self, store: SyncStore, prefs: SyncPrefsStore
) -> None:
_enable(prefs, "u1", "kiwi", "cook_log")
_enable(prefs, "u1", "kiwi", "bookmarks")
store.push("u1", "kiwi", "cook_log", "cl", "2026-01-01T00:00:00Z")
store.push("u1", "kiwi", "bookmarks", "bm", "2026-01-01T00:00:00Z")
store.delete("u1", "kiwi", "cook_log")
blobs = store.pull("u1", "kiwi")
assert len(blobs) == 1
assert blobs[0].data_class == "bookmarks"
def test_delete_all_blobs(
self, store: SyncStore, prefs: SyncPrefsStore
) -> None:
_enable(prefs, "u1", "kiwi", "cook_log")
store.push("u1", "kiwi", "cook_log", "cl", "2026-01-01T00:00:00Z")
store.delete_all("u1", "kiwi")
assert store.pull("u1", "kiwi") == []
def test_delete_does_not_touch_other_users(
self, store: SyncStore, prefs: SyncPrefsStore
) -> None:
_enable(prefs, "u1", "kiwi", "cook_log")
_enable(prefs, "u2", "kiwi", "cook_log")
store.push("u1", "kiwi", "cook_log", "u1data", "2026-01-01T00:00:00Z")
store.push("u2", "kiwi", "cook_log", "u2data", "2026-01-01T00:00:00Z")
store.delete_all("u1", "kiwi")
blobs = store.pull("u2", "kiwi")
assert len(blobs) == 1
assert blobs[0].blob == "u2data"