| """Tests for the v0.3.0 hard-cap enforcement. |
| |
| Three concerns covered: |
| 1. Free-tier writes are blocked once the DB crosses 2 MB |
| 2. The server check fires at the boundary and updates the local tier cache |
| 3. The 7-day grace cache works (paid → uncapped writes without phoning home) |
| """ |
| from __future__ import annotations |
|
|
| import time |
| from pathlib import Path |
|
|
| import pytest |
|
|
| from sibyl_memory_client import ( |
| CapExceededError, |
| CapGate, |
| MemoryClient, |
| TierCache, |
| TierCacheEntry, |
| TierVerificationError, |
| ) |
|
|
|
|
| |
| |
| |
| |
|
|
| class FakeServer: |
| """Mocks the /api/plugin/check-write endpoint.""" |
|
|
| def __init__(self, *, tier: str = "free", offline: bool = False) -> None: |
| self.tier = tier |
| self.offline = offline |
| self.calls: list[dict] = [] |
|
|
| def __call__(self, url, payload, timeout=4.0): |
| if self.offline: |
| raise TierVerificationError("simulated network down") |
| self.calls.append(payload) |
| |
| if self.tier in ("sync", "team", "lifetime", "stake", "enterprise"): |
| return {"ok": True, "tier": self.tier, "cap_bytes": None} |
| |
| new = payload["current_size_bytes"] + payload["proposed_delta_bytes"] |
| cap = 2 * 1024 * 1024 |
| if new <= cap: |
| return {"ok": True, "tier": "free", "cap_bytes": cap, |
| "remaining_bytes": cap - new} |
| return { |
| "ok": False, "tier": "free", "cap_bytes": cap, |
| "upgrade_url": "https://docs.sibyllabs.org/memory/tiers", |
| } |
|
|
|
|
| |
| |
| |
|
|
| def test_under_cap_no_server_call(tmp_path: Path) -> None: |
| server = FakeServer(tier="free") |
| cache = TierCache(tmp_path / "tc.json") |
| gate = CapGate( |
| account_id="acc-1", |
| session_token="sess-1", |
| db_size_fn=lambda: 100_000, |
| local_tier_hint="free", |
| cache=cache, |
| check_fn=server, |
| ) |
| gate.check(proposed_delta_bytes=1000) |
| assert len(server.calls) == 0 |
|
|
|
|
| def test_at_cap_server_says_no(tmp_path: Path) -> None: |
| server = FakeServer(tier="free") |
| cache = TierCache(tmp_path / "tc.json") |
| gate = CapGate( |
| account_id="acc-1", |
| session_token="sess-1", |
| db_size_fn=lambda: 2 * 1024 * 1024 - 100, |
| local_tier_hint="free", |
| cache=cache, |
| check_fn=server, |
| ) |
| with pytest.raises(CapExceededError) as exc: |
| gate.check(proposed_delta_bytes=500) |
| assert exc.value.cap == 2 * 1024 * 1024 |
| assert "sibyllabs.org" in exc.value.upgrade_url |
| assert len(server.calls) == 1 |
|
|
|
|
| def test_at_cap_server_upgrades_user(tmp_path: Path) -> None: |
| """User claims free in credentials but server says they're now paid |
| (upgraded since last activation). The write should be permitted.""" |
| server = FakeServer(tier="lifetime") |
| cache = TierCache(tmp_path / "tc.json") |
| gate = CapGate( |
| account_id="acc-1", |
| session_token="sess-1", |
| db_size_fn=lambda: 2 * 1024 * 1024 + 1000, |
| local_tier_hint="free", |
| cache=cache, |
| check_fn=server, |
| ) |
| gate.check(proposed_delta_bytes=500) |
| |
| |
| cached = cache.load() |
| assert cached is not None |
| assert cached.tier == "lifetime" |
| assert cached.cap_bytes is None |
|
|
|
|
| def test_paid_cache_skips_server(tmp_path: Path) -> None: |
| """If we have a fresh cache saying we're paid, no server call needed.""" |
| server = FakeServer(tier="free") |
| cache = TierCache(tmp_path / "tc.json") |
| |
| cache.store(TierCacheEntry( |
| account_id="acc-1", |
| tier="lifetime", |
| checked_at=time.time(), |
| cap_bytes=None, |
| )) |
| gate = CapGate( |
| account_id="acc-1", |
| session_token="sess-1", |
| db_size_fn=lambda: 100 * 1024 * 1024, |
| local_tier_hint="free", |
| cache=cache, |
| check_fn=server, |
| ) |
| gate.check(proposed_delta_bytes=10_000) |
| assert len(server.calls) == 0 |
|
|
|
|
| def test_stale_paid_cache_triggers_refresh(tmp_path: Path) -> None: |
| """An 8-day-old cache should NOT be honored as fresh.""" |
| server = FakeServer(tier="lifetime") |
| cache = TierCache(tmp_path / "tc.json") |
| |
| cache.store(TierCacheEntry( |
| account_id="acc-1", |
| tier="lifetime", |
| checked_at=time.time() - 8 * 24 * 60 * 60, |
| cap_bytes=None, |
| )) |
| gate = CapGate( |
| account_id="acc-1", |
| session_token="sess-1", |
| db_size_fn=lambda: 5 * 1024 * 1024, |
| local_tier_hint="free", |
| cache=cache, |
| check_fn=server, |
| ) |
| gate.check(proposed_delta_bytes=10_000) |
| |
| assert len(server.calls) == 1 |
|
|
|
|
| def test_offline_at_cap_with_recent_paid_cache(tmp_path: Path) -> None: |
| """Honest paid user goes offline. Should still be allowed to write.""" |
| server = FakeServer(offline=True) |
| cache = TierCache(tmp_path / "tc.json") |
| cache.store(TierCacheEntry( |
| account_id="acc-1", |
| tier="lifetime", |
| checked_at=time.time(), |
| cap_bytes=None, |
| )) |
| gate = CapGate( |
| account_id="acc-1", |
| session_token="sess-1", |
| db_size_fn=lambda: 50 * 1024 * 1024, |
| local_tier_hint="free", |
| cache=cache, |
| check_fn=server, |
| ) |
| |
| gate.check(proposed_delta_bytes=10_000) |
|
|
|
|
| def test_offline_at_cap_no_cache_raises(tmp_path: Path) -> None: |
| """No cache + offline + at the cap = TierVerificationError.""" |
| server = FakeServer(offline=True) |
| cache = TierCache(tmp_path / "tc.json") |
| gate = CapGate( |
| account_id="acc-1", |
| session_token="sess-1", |
| db_size_fn=lambda: 2 * 1024 * 1024 + 100, |
| local_tier_hint="free", |
| cache=cache, |
| check_fn=server, |
| ) |
| with pytest.raises(TierVerificationError): |
| gate.check(proposed_delta_bytes=500) |
|
|
|
|
| def test_no_account_id_under_cap_passes(tmp_path: Path) -> None: |
| """Pre-activation user under the cap should work.""" |
| server = FakeServer(tier="free") |
| cache = TierCache(tmp_path / "tc.json") |
| gate = CapGate( |
| account_id=None, |
| session_token=None, |
| db_size_fn=lambda: 1_000_000, |
| local_tier_hint="free", |
| cache=cache, |
| check_fn=server, |
| ) |
| gate.check(proposed_delta_bytes=1000) |
| assert len(server.calls) == 0 |
|
|
|
|
| def test_no_account_id_at_cap_blocks(tmp_path: Path) -> None: |
| """Pre-activation user past the cap → hard block.""" |
| server = FakeServer(tier="free") |
| cache = TierCache(tmp_path / "tc.json") |
| gate = CapGate( |
| account_id=None, |
| session_token=None, |
| db_size_fn=lambda: 2 * 1024 * 1024 + 100, |
| local_tier_hint="free", |
| cache=cache, |
| check_fn=server, |
| ) |
| with pytest.raises(CapExceededError): |
| gate.check(proposed_delta_bytes=500) |
|
|
|
|
| |
| |
| |
|
|
| def test_e2e_free_tier_blocked_at_cap(tmp_path: Path) -> None: |
| """Writing past the 2 MB cap raises CapExceededError when the server |
| confirms free tier.""" |
| server = FakeServer(tier="free") |
| cache = TierCache(tmp_path / "tc.json") |
| db_path = tmp_path / "memory.db" |
|
|
| |
| |
| from sibyl_memory_client._capcheck import CapGate |
| fake_size = [100] |
|
|
| gate = CapGate( |
| account_id="acc-1", |
| session_token="sess-1", |
| db_size_fn=lambda: fake_size[0], |
| local_tier_hint="free", |
| cache=cache, |
| check_fn=server, |
| ) |
| client = MemoryClient( |
| storage=__import__("sibyl_memory_client").Storage(str(db_path)), |
| tenant_id="alice", |
| tier="free", |
| account_id="acc-1", |
| session_token="sess-1", |
| cap_gate=gate, |
| ) |
|
|
| |
| client.set_entity("project", "atlas", {"status": "active"}) |
|
|
| |
| fake_size[0] = 2 * 1024 * 1024 - 100 |
|
|
| |
| with pytest.raises(CapExceededError): |
| client.set_entity("project", "borealis", {"status": "active", "x": "y" * 500}) |
|
|
| |
| assert len(server.calls) >= 1 |
|
|
|
|
| def test_e2e_paid_tier_no_cap(tmp_path: Path) -> None: |
| """Paid tier bypasses the cap entirely (within grace period).""" |
| server = FakeServer(tier="lifetime") |
| cache = TierCache(tmp_path / "tc.json") |
| db_path = tmp_path / "memory.db" |
|
|
| fake_size = [50 * 1024 * 1024] |
|
|
| from sibyl_memory_client._capcheck import CapGate |
| gate = CapGate( |
| account_id="acc-1", |
| session_token="sess-1", |
| db_size_fn=lambda: fake_size[0], |
| local_tier_hint="lifetime", |
| cache=cache, |
| check_fn=server, |
| ) |
| client = MemoryClient( |
| storage=__import__("sibyl_memory_client").Storage(str(db_path)), |
| tenant_id="alice", |
| tier="lifetime", |
| account_id="acc-1", |
| session_token="sess-1", |
| cap_gate=gate, |
| ) |
| |
| client.set_entity("project", "atlas", {"status": "active"}) |
| client.set_entity("project", "borealis", {"status": "active"}) |
| client.set_state("priorities", {"top": ["ship"]}) |
|
|
|
|
| def test_cache_file_is_0600(tmp_path: Path) -> None: |
| """Tier cache must not be world-readable.""" |
| cache = TierCache(tmp_path / "tc.json") |
| cache.store(TierCacheEntry( |
| account_id="acc-1", |
| tier="free", |
| checked_at=time.time(), |
| cap_bytes=2 * 1024 * 1024, |
| )) |
| mode = oct((tmp_path / "tc.json").stat().st_mode)[-3:] |
| assert mode == "600" |
|
|
|
|
| def test_cap_gate_invalidate_cache(tmp_path: Path) -> None: |
| cache = TierCache(tmp_path / "tc.json") |
| cache.store(TierCacheEntry( |
| account_id="acc-1", tier="free", checked_at=time.time(), cap_bytes=2_000_000, |
| )) |
| assert cache.load() is not None |
| gate = CapGate( |
| account_id="acc-1", |
| session_token="sess-1", |
| db_size_fn=lambda: 0, |
| local_tier_hint="free", |
| cache=cache, |
| check_fn=FakeServer(), |
| ) |
| gate.invalidate_cache() |
| assert cache.load() is None |
|
|