feat(sync): cross-device profile sync module (consent-gated, last-write-wins)
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:
parent
e1793bde12
commit
11e49067a8
13 changed files with 1040 additions and 5 deletions
|
|
@ -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.")
|
||||||
|
|
|
||||||
60
circuitforge_core/sync/__init__.py
Normal file
60
circuitforge_core/sync/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
48
circuitforge_core/sync/db.py
Normal file
48
circuitforge_core/sync/db.py
Normal 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()
|
||||||
26
circuitforge_core/sync/migrations/001_sync.sql
Normal file
26
circuitforge_core/sync/migrations/001_sync.sql
Normal 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)
|
||||||
|
);
|
||||||
0
circuitforge_core/sync/migrations/__init__.py
Normal file
0
circuitforge_core/sync/migrations/__init__.py
Normal file
53
circuitforge_core/sync/models.py
Normal file
53
circuitforge_core/sync/models.py
Normal 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
|
||||||
145
circuitforge_core/sync/router.py
Normal file
145
circuitforge_core/sync/router.py
Normal 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
|
||||||
245
circuitforge_core/sync/store.py
Normal file
245
circuitforge_core/sync/store.py
Normal 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()
|
||||||
|
|
@ -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
0
tests/sync/__init__.py
Normal file
135
tests/sync/test_sync_prefs.py
Normal file
135
tests/sync/test_sync_prefs.py
Normal 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") == {}
|
||||||
152
tests/sync/test_sync_router.py
Normal file
152
tests/sync/test_sync_router.py
Normal 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}
|
||||||
163
tests/sync/test_sync_store.py
Normal file
163
tests/sync/test_sync_store.py
Normal 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"
|
||||||
Loading…
Reference in a new issue