diff --git a/circuitforge_core/api/feedback.py b/circuitforge_core/api/feedback.py index 495678d..cfa2c33 100644 --- a/circuitforge_core/api/feedback.py +++ b/circuitforge_core/api/feedback.py @@ -46,9 +46,13 @@ class FeedbackResponse(BaseModel): 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]: - token = os.environ.get("FORGEJO_API_TOKEN", "") - return {"Authorization": f"token {token}", "Content-Type": "application/json"} + return {"Authorization": f"token {_forgejo_token()}", "Content-Type": "application/json"} def _ensure_labels(label_names: list[str], base: str, repo: str) -> list[int]: @@ -137,16 +141,16 @@ def make_feedback_router( @router.get("/status") def feedback_status() -> dict: """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) def submit_feedback(payload: FeedbackRequest) -> FeedbackResponse: """File a Forgejo issue from in-app feedback.""" - token = os.environ.get("FORGEJO_API_TOKEN", "") + token = _forgejo_token() if not token: raise HTTPException( 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(): raise HTTPException(status_code=403, detail="Feedback disabled in demo mode.") diff --git a/circuitforge_core/sync/__init__.py b/circuitforge_core/sync/__init__.py new file mode 100644 index 0000000..0d6f88c --- /dev/null +++ b/circuitforge_core/sync/__init__.py @@ -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", +] diff --git a/circuitforge_core/sync/db.py b/circuitforge_core/sync/db.py new file mode 100644 index 0000000..dca5c99 --- /dev/null +++ b/circuitforge_core/sync/db.py @@ -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() diff --git a/circuitforge_core/sync/migrations/001_sync.sql b/circuitforge_core/sync/migrations/001_sync.sql new file mode 100644 index 0000000..7e70b6a --- /dev/null +++ b/circuitforge_core/sync/migrations/001_sync.sql @@ -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) +); diff --git a/circuitforge_core/sync/migrations/__init__.py b/circuitforge_core/sync/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/circuitforge_core/sync/models.py b/circuitforge_core/sync/models.py new file mode 100644 index 0000000..deb06db --- /dev/null +++ b/circuitforge_core/sync/models.py @@ -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 diff --git a/circuitforge_core/sync/router.py b/circuitforge_core/sync/router.py new file mode 100644 index 0000000..0b0def9 --- /dev/null +++ b/circuitforge_core/sync/router.py @@ -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 diff --git a/circuitforge_core/sync/store.py b/circuitforge_core/sync/store.py new file mode 100644 index 0000000..1d92986 --- /dev/null +++ b/circuitforge_core/sync/store.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml index 58157b6..6713b7f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,9 @@ dependencies = [ memory = [ "mnemo-sdk>=0.1.0", ] +sync = [ + "fastapi>=0.110", +] community = [ "psycopg2>=2.9", ] @@ -164,6 +167,7 @@ include = ["circuitforge_core*"] [tool.setuptools.package-data] "circuitforge_core.community.migrations" = ["*.sql"] +"circuitforge_core.sync.migrations" = ["*.sql"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/tests/sync/__init__.py b/tests/sync/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/sync/test_sync_prefs.py b/tests/sync/test_sync_prefs.py new file mode 100644 index 0000000..2c08bc4 --- /dev/null +++ b/tests/sync/test_sync_prefs.py @@ -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") == {} diff --git a/tests/sync/test_sync_router.py b/tests/sync/test_sync_router.py new file mode 100644 index 0000000..112c243 --- /dev/null +++ b/tests/sync/test_sync_router.py @@ -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} diff --git a/tests/sync/test_sync_store.py b/tests/sync/test_sync_store.py new file mode 100644 index 0000000..2a0ecde --- /dev/null +++ b/tests/sync/test_sync_store.py @@ -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"