-
-
Notifications
You must be signed in to change notification settings - Fork 17
feat(browser): backend skeleton + multi-user storage tenancy (PR 1/10) #300
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c4384bf
ec15554
be935c0
ddb1ee8
499b9b6
b4f0e6f
180cd9d
dd0fbac
b09b359
42688e4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,148 @@ | ||
| """Tests for BrowserCookieStore — SQLCipher encryption + multi-user isolation.""" | ||
| from __future__ import annotations | ||
|
|
||
| import pytest | ||
| import pytest_asyncio | ||
|
|
||
|
|
||
| # A 64-char hex string = 256-bit key. In production this comes from | ||
| # derive_cookie_key. Tests use a fixed value so they are deterministic. | ||
| TEST_KEY = "a" * 64 | ||
| WRONG_KEY = "b" * 64 | ||
|
|
||
|
|
||
| @pytest_asyncio.fixture | ||
| async def cookie_store(tmp_path): | ||
| from tinyagentos.routes.desktop_browser.store import BrowserCookieStore | ||
|
|
||
| s = BrowserCookieStore(tmp_path / "browser_cookies.sqlite3", key_hex=TEST_KEY) | ||
| await s.init() | ||
| yield s | ||
| await s.close() | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| class TestCookieStoreEncryption: | ||
| async def test_db_is_not_readable_with_wrong_key(self, tmp_path): | ||
| from tinyagentos.routes.desktop_browser.store import BrowserCookieStore | ||
|
|
||
| # Create + close | ||
| s = BrowserCookieStore(tmp_path / "c.sqlite3", key_hex=TEST_KEY) | ||
| await s.init() | ||
| await s.set_cookie( | ||
| user_id="u1", profile_id="p1", | ||
| host="x.test", path="/", name="sid", value="abc", | ||
| expires_at=None, http_only=True, secure=True, same_site="lax", | ||
| ) | ||
| await s.close() | ||
|
|
||
| from sqlcipher3 import dbapi2 as sqlcipher | ||
|
|
||
| # Reopen with wrong key — read must fail (init may also fail | ||
| # depending on SQLCipher version; either way, the data must | ||
| # not be retrievable with the wrong key). | ||
| # Failure modes vary by SQLCipher version: | ||
| # - DatabaseError / OperationalError: explicit key rejection | ||
| # - MemoryError: corrupted page allocation when key is wrong | ||
| # (observed on sqlcipher3 0.5.x / Python 3.14) | ||
| s2 = BrowserCookieStore(tmp_path / "c.sqlite3", key_hex=WRONG_KEY) | ||
| with pytest.raises((sqlcipher.DatabaseError, sqlcipher.OperationalError, MemoryError)): | ||
| try: | ||
| await s2.init() | ||
| except Exception: | ||
| # init() rejected the wrong key — that's an acceptable failure mode. | ||
| # Re-raise so the outer `pytest.raises` records it. | ||
| raise | ||
| # If init() succeeded silently, the read MUST fail | ||
| await s2.get_cookies(user_id="u1", profile_id="p1", host="x.test") | ||
|
|
||
| async def test_rejects_non_hex_key(self, tmp_path): | ||
| from tinyagentos.routes.desktop_browser.store import BrowserCookieStore | ||
|
|
||
| # 64 chars but not valid hex | ||
| with pytest.raises(ValueError, match="hex"): | ||
| BrowserCookieStore(tmp_path / "c.sqlite3", key_hex="z" * 64) | ||
|
|
||
| async def test_rejects_short_key(self, tmp_path): | ||
| from tinyagentos.routes.desktop_browser.store import BrowserCookieStore | ||
|
|
||
| with pytest.raises(ValueError, match="64 hex chars"): | ||
| BrowserCookieStore(tmp_path / "c.sqlite3", key_hex="abc") | ||
|
|
||
| async def test_raw_file_is_not_plaintext(self, tmp_path): | ||
| from tinyagentos.routes.desktop_browser.store import BrowserCookieStore | ||
|
|
||
| s = BrowserCookieStore(tmp_path / "c.sqlite3", key_hex=TEST_KEY) | ||
| await s.init() | ||
| await s.set_cookie( | ||
| user_id="u1", profile_id="p1", | ||
| host="x.test", path="/", name="sid", value="MY-SECRET-VALUE", | ||
| expires_at=None, http_only=True, secure=True, same_site="lax", | ||
| ) | ||
| await s.close() | ||
|
|
||
| raw = (tmp_path / "c.sqlite3").read_bytes() | ||
| # The plain cookie value must not appear in the on-disk file | ||
| assert b"MY-SECRET-VALUE" not in raw | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| class TestCookieStoreCRUD: | ||
| async def test_set_and_get_single_cookie(self, cookie_store): | ||
| await cookie_store.set_cookie( | ||
| user_id="u1", profile_id="p1", | ||
| host="github.com", path="/", name="user_session", value="xyz", | ||
| expires_at=None, http_only=True, secure=True, same_site="lax", | ||
| ) | ||
| result = await cookie_store.get_cookies( | ||
| user_id="u1", profile_id="p1", host="github.com", | ||
| ) | ||
| assert len(result) == 1 | ||
| assert result[0]["name"] == "user_session" | ||
| assert result[0]["value"] == "xyz" | ||
|
|
||
| async def test_user_isolation(self, cookie_store): | ||
| # u1 sets a github.com cookie; u2 must not see it | ||
| await cookie_store.set_cookie( | ||
| user_id="u1", profile_id="p1", | ||
| host="github.com", path="/", name="user_session", value="from-u1", | ||
| expires_at=None, http_only=True, secure=True, same_site="lax", | ||
| ) | ||
| u2_cookies = await cookie_store.get_cookies( | ||
| user_id="u2", profile_id="p1", host="github.com", | ||
| ) | ||
| assert u2_cookies == [] | ||
|
|
||
| async def test_profile_isolation(self, cookie_store): | ||
| # Same user, two profiles, must not cross | ||
| await cookie_store.set_cookie( | ||
| user_id="u1", profile_id="personal", | ||
| host="github.com", path="/", name="user_session", value="personal-token", | ||
| expires_at=None, http_only=True, secure=True, same_site="lax", | ||
| ) | ||
| await cookie_store.set_cookie( | ||
| user_id="u1", profile_id="work", | ||
| host="github.com", path="/", name="user_session", value="work-token", | ||
| expires_at=None, http_only=True, secure=True, same_site="lax", | ||
| ) | ||
|
|
||
| personal = await cookie_store.get_cookies( | ||
| user_id="u1", profile_id="personal", host="github.com", | ||
| ) | ||
| work = await cookie_store.get_cookies( | ||
| user_id="u1", profile_id="work", host="github.com", | ||
| ) | ||
| assert personal[0]["value"] == "personal-token" | ||
| assert work[0]["value"] == "work-token" | ||
|
|
||
| async def test_set_cookie_requires_user_id(self, cookie_store): | ||
| with pytest.raises(ValueError, match="user_id"): | ||
| await cookie_store.set_cookie( | ||
| user_id="", profile_id="p1", | ||
| host="x.test", path="/", name="n", value="v", | ||
| expires_at=None, http_only=True, secure=True, same_site=None, | ||
| ) | ||
|
|
||
| async def test_get_cookies_requires_user_id(self, cookie_store): | ||
| with pytest.raises(ValueError, match="user_id"): | ||
| await cookie_store.get_cookies(user_id="", profile_id="p1", host="x.test") |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| """Tests for browser-cookie key derivation (Argon2id).""" | ||
| from __future__ import annotations | ||
|
|
||
| import pytest | ||
|
|
||
|
|
||
| class TestDeriveCookieKey: | ||
| def test_returns_64_char_hex_string(self): | ||
| from tinyagentos.routes.desktop_browser.crypto import derive_cookie_key | ||
|
|
||
| key = derive_cookie_key(password="hunter2", user_salt=b"u" * 16) | ||
|
|
||
| # SQLCipher needs a 256-bit key, encoded as 64 hex chars | ||
| assert isinstance(key, str) | ||
| assert len(key) == 64 | ||
| assert all(c in "0123456789abcdef" for c in key) | ||
|
|
||
| def test_deterministic_for_same_inputs(self): | ||
| from tinyagentos.routes.desktop_browser.crypto import derive_cookie_key | ||
|
|
||
| a = derive_cookie_key(password="hunter2", user_salt=b"u" * 16) | ||
| b = derive_cookie_key(password="hunter2", user_salt=b"u" * 16) | ||
| assert a == b | ||
|
|
||
| def test_different_passwords_produce_different_keys(self): | ||
| from tinyagentos.routes.desktop_browser.crypto import derive_cookie_key | ||
|
|
||
| a = derive_cookie_key(password="hunter2", user_salt=b"u" * 16) | ||
| b = derive_cookie_key(password="other", user_salt=b"u" * 16) | ||
| assert a != b | ||
|
|
||
| def test_different_salts_produce_different_keys(self): | ||
| from tinyagentos.routes.desktop_browser.crypto import derive_cookie_key | ||
|
|
||
| a = derive_cookie_key(password="hunter2", user_salt=b"a" * 16) | ||
| b = derive_cookie_key(password="hunter2", user_salt=b"b" * 16) | ||
| assert a != b | ||
|
|
||
| def test_rejects_short_salt(self): | ||
| from tinyagentos.routes.desktop_browser.crypto import derive_cookie_key | ||
|
|
||
| with pytest.raises(ValueError, match="salt"): | ||
| derive_cookie_key(password="hunter2", user_salt=b"short") | ||
|
|
||
| def test_rejects_empty_password(self): | ||
| from tinyagentos.routes.desktop_browser.crypto import derive_cookie_key | ||
|
|
||
| with pytest.raises(ValueError, match="password"): | ||
| derive_cookie_key(password="", user_salt=b"u" * 16) | ||
|
Comment on lines
+1
to
+49
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suppress Ruff S106 for this test module. This file intentionally feeds literal test passwords into a Suggested fix+# ruff: noqa: S106
"""Tests for browser-cookie key derivation (Argon2id)."""
from __future__ import annotations🧰 Tools🪛 Ruff (0.15.12)[error] 11-11: Possible hardcoded password assigned to argument: "password" (S106) [error] 21-21: Possible hardcoded password assigned to argument: "password" (S106) [error] 22-22: Possible hardcoded password assigned to argument: "password" (S106) [error] 28-28: Possible hardcoded password assigned to argument: "password" (S106) [error] 29-29: Possible hardcoded password assigned to argument: "password" (S106) [error] 35-35: Possible hardcoded password assigned to argument: "password" (S106) [error] 36-36: Possible hardcoded password assigned to argument: "password" (S106) [error] 43-43: Possible hardcoded password assigned to argument: "password" (S106) 🤖 Prompt for AI Agents |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| """Verify the desktop_browser router is mounted on the FastAPI app. | ||
|
|
||
| PR 1 mounts an empty router so future PRs can add routes against an | ||
| established prefix. This test only checks the router object reaches the | ||
| app — it does not exercise any endpoints. | ||
| """ | ||
| from __future__ import annotations | ||
|
|
||
|
|
||
| def test_desktop_browser_router_present_in_app(app): | ||
| """Verify create_app() actually mounted the desktop_browser router. | ||
|
|
||
| The router is empty in PR 1 (PR 2+ adds routes), so we can't check | ||
| for specific paths yet. What we CAN check: the router object reachable | ||
| via `from tinyagentos.routes.desktop_browser import router` is the same | ||
| object that was passed to one of the app's `include_router` calls. | ||
| The cleanest detection is: `router.routes` (empty in PR 1) is iterable, | ||
| and after `include_router`, FastAPI copies those routes (currently zero) | ||
| into `app.routes`. Without `include_router` the test is no weaker than | ||
| the import test below — but with it, future PRs that add routes to the | ||
| desktop_browser router will see them appear in `app.routes`, which gives | ||
| us a regression marker for "someone deleted the include_router call". | ||
|
|
||
| For PR 1 (empty router), we assert that the router instance has a | ||
| `.routes` attribute (FastAPI APIRouter contract) and that the app | ||
| accepts our import without error — combined with the fact that PR 2+ | ||
| will add routes that will then appear in app.routes, this gives a | ||
| real upgrade path for the assertion. | ||
| """ | ||
| from tinyagentos.routes.desktop_browser import router as browser_router | ||
|
|
||
| # APIRouter contract: must have a .routes attribute that is a list | ||
| assert hasattr(browser_router, "routes") | ||
| assert isinstance(browser_router.routes, list) | ||
|
|
||
| # The app must have been built without error — the fixture proves that. | ||
| # When PR 2 adds a route to browser_router, the assertion below will | ||
| # become non-vacuous because every router-route will be reflected in | ||
| # app.routes after include_router runs. | ||
| browser_route_paths = { | ||
| getattr(r, "path", None) for r in browser_router.routes | ||
| } | ||
| app_route_paths = {getattr(r, "path", None) for r in app.routes} | ||
|
|
||
| # In PR 1 both sets are likely empty for browser_router, so the | ||
| # assertion is trivially true. In PR 2+ this will catch deletion | ||
| # of the include_router call. | ||
| assert browser_route_paths.issubset(app_route_paths) | ||
|
|
||
|
|
||
| def test_desktop_browser_module_importable(): | ||
| """Defensive import test — catches packaging mistakes early.""" | ||
| from tinyagentos.routes.desktop_browser import router | ||
| from tinyagentos.routes.desktop_browser.crypto import derive_cookie_key | ||
| from tinyagentos.routes.desktop_browser.schema import ( | ||
| BROWSER_SCHEMA, | ||
| COOKIE_SCHEMA, | ||
| ) | ||
| from tinyagentos.routes.desktop_browser.store import ( | ||
| BrowserCookieStore, | ||
| BrowserStore, | ||
| ) | ||
|
|
||
| assert all([ | ||
| router, | ||
| derive_cookie_key, | ||
| BROWSER_SCHEMA, | ||
| COOKIE_SCHEMA, | ||
| BrowserCookieStore, | ||
| BrowserStore, | ||
| ]) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| """Tests for BrowserStore — schema applies cleanly, all expected tables exist.""" | ||
| from __future__ import annotations | ||
|
|
||
| import sqlite3 | ||
|
|
||
| import pytest | ||
| import pytest_asyncio | ||
|
|
||
|
|
||
| @pytest_asyncio.fixture | ||
| async def store(tmp_path): | ||
| from tinyagentos.routes.desktop_browser.store import BrowserStore | ||
|
|
||
| s = BrowserStore(tmp_path / "browser.sqlite3") | ||
| await s.init() | ||
| yield s | ||
| await s.close() | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| class TestBrowserStoreSchema: | ||
| async def test_init_creates_db_file(self, store, tmp_path): | ||
| assert (tmp_path / "browser.sqlite3").exists() | ||
|
|
||
| async def test_all_tables_exist(self, store, tmp_path): | ||
| # Use sync sqlite3 to introspect — cheaper than async for a one-shot read | ||
| conn = sqlite3.connect(tmp_path / "browser.sqlite3") | ||
| try: | ||
| rows = conn.execute( | ||
| "SELECT name FROM sqlite_master WHERE type='table' " | ||
| "ORDER BY name" | ||
| ).fetchall() | ||
| tables = {r[0] for r in rows} | ||
| finally: | ||
| conn.close() | ||
|
|
||
| expected = { | ||
| "profiles", | ||
| "history", | ||
| "bookmarks", | ||
| "agent_capabilities", | ||
| "push_subscriptions", | ||
| "browser_windows", | ||
| } | ||
| assert expected <= tables, f"missing: {expected - tables}" | ||
|
|
||
| async def test_idempotent_init(self, tmp_path): | ||
| from tinyagentos.routes.desktop_browser.store import BrowserStore | ||
|
|
||
| s1 = BrowserStore(tmp_path / "browser.sqlite3") | ||
| await s1.init() | ||
| await s1.close() | ||
|
|
||
| # Second init on the same path must not raise | ||
| s2 = BrowserStore(tmp_path / "browser.sqlite3") | ||
| await s2.init() | ||
| await s2.close() |
Uh oh!
There was an error while loading. Please reload this page.