diff --git a/tests/conftest.py b/tests/conftest.py index e332671c8..7dc4f8d7f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -431,6 +431,13 @@ async def client(app, tmp_data_dir): if council_members._db is not None: await council_members.close() await council_members.init() + # user_shares store (user-to-user resource sharing) is lifespan-owned; + # tests that bypass the lifespan must init it so routes can consult it. + user_shares = getattr(app.state, "user_shares", None) + if user_shares is not None: + if user_shares._db is not None: + await user_shares.close() + await user_shares.init() # BrowserApp v2 stores from tinyagentos.routes.desktop_browser.store import BrowserStore, BrowserCookieStore _browser_store = BrowserStore(tmp_data_dir / "browser.sqlite3") @@ -499,6 +506,8 @@ async def client(app, tmp_data_dir): await _browser_cookie_store.close() await app.state.council_roles.close() await app.state.council_members.close() + if user_shares is not None: + await user_shares.close() def create_test_qmd_db(db_path): diff --git a/tests/test_user_shares_routes.py b/tests/test_user_shares_routes.py new file mode 100644 index 000000000..5fa3b4f8e --- /dev/null +++ b/tests/test_user_shares_routes.py @@ -0,0 +1,273 @@ +"""Tests for the user-to-user resource sharing routes. + +Covers: + POST /api/shares — create share (idempotent, unknown user, self-share) + GET /api/shares — list (out/in) + DELETE /api/shares/{id} — revoke (owner, not-found, non-owner) + +Uses the conftest `client` fixture (admin-authenticated) with the user_shares +store initialised on app.state. +""" + +from __future__ import annotations + +import secrets + +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest_asyncio.fixture +async def shares_client(client, tmp_data_dir): + """Async client with user_shares store initialised + CSRF tokens, as admin.""" + try: + from tinyagentos.user_shares_store import UserSharesStore + except ImportError: + pytest.skip("UserSharesStore not merged yet (depends on #1897)") + return # unreachable; keeps type-checkers happy + + app = client._transport.app + + # Init user_shares store if not already on app.state (idempotent). + if getattr(app.state, "user_shares", None) is None: + store = UserSharesStore(tmp_data_dir / "user_shares.db") + await store.init() + app.state.user_shares = store + + # Set CSRF token so mutating routes pass verify_csrf. + csrf_token = secrets.token_hex(32) + client.cookies["csrf_token"] = csrf_token + client.headers["X-CSRF-Token"] = csrf_token + + # Ensure bob user exists (target for share tests) via invite flow. + auth = app.state.auth + if auth.find_user("bob") is None: + invite_code = auth.add_user_invite("bob", "admin") + auth.complete_invite("bob", invite_code, "Bob", "", "bobpass1") + + yield client + + if hasattr(app.state, "user_shares"): + await app.state.user_shares.close() + + +@pytest_asyncio.fixture +async def bob_client(shares_client): + """Async client authenticated as user 'bob' (non-admin, non-owner).""" + app = shares_client._transport.app + + bob_record = app.state.auth.find_user("bob") + bob_uid = bob_record["id"] + bob_token = app.state.auth.create_session(user_id=bob_uid, long_lived=True) + + csrf_token = secrets.token_hex(32) + + transport = ASGITransport(app=app) + async with AsyncClient( + transport=transport, + base_url="http://test", + cookies={"taos_session": bob_token, "csrf_token": csrf_token}, + headers={"X-CSRF-Token": csrf_token}, + ) as c: + yield c + + +# --------------------------------------------------------------------------- +# Route tests +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +class TestShareRoutes: + + # -- Create ---------------------------------------------------------- + + async def test_create_share(self, shares_client): + """POST /api/shares creates a share and returns the record.""" + resp = await shares_client.post( + "/api/shares", + json={ + "resource_type": "project", + "resource_id": "proj-1", + "to_username": "bob", + "permission": "read", + }, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["resource_type"] == "project" + assert data["resource_id"] == "proj-1" + assert data["permission"] == "read" + assert "id" in data + assert "granted_at" in data + + async def test_create_share_unknown_username(self, shares_client): + """POST /api/shares with unknown username returns 404.""" + resp = await shares_client.post( + "/api/shares", + json={ + "resource_type": "project", + "resource_id": "proj-3", + "to_username": "nosuchuser", + "permission": "read", + }, + ) + assert resp.status_code == 404 + assert "nosuchuser" in resp.json()["detail"] + + async def test_create_share_duplicate_idempotent(self, shares_client): + """Re-sharing the same resource+target+permission is idempotent.""" + body = { + "resource_type": "project", + "resource_id": "proj-2", + "to_username": "bob", + "permission": "read", + } + r1 = await shares_client.post("/api/shares", json=body) + assert r1.status_code == 200 + + r2 = await shares_client.post("/api/shares", json=body) + assert r2.status_code == 200 + + # Verify only one share exists — no duplicates. + resp = await shares_client.get("/api/shares?direction=out") + assert resp.status_code == 200 + matching = [ + s for s in resp.json() + if s["resource_id"] == "proj-2" + ] + assert len(matching) == 1 + + async def test_create_share_to_self_rejected(self, shares_client): + """POST /api/shares to yourself returns 400.""" + app = shares_client._transport.app + session_token = shares_client.cookies.get("taos_session") + me = app.state.auth.get_user(session_token) + my_username = me["username"] if me else "admin" + resp = await shares_client.post( + "/api/shares", + json={ + "resource_type": "project", + "resource_id": "proj-4", + "to_username": my_username, + "permission": "read", + }, + ) + assert resp.status_code == 400 + assert "yourself" in resp.json()["detail"] + + # -- List ------------------------------------------------------------ + + async def test_list_outgoing_shares(self, shares_client, bob_client): + """GET /api/shares?direction=out lists only shares owned by the user.""" + # Create two outgoing shares (admin → bob). + for res_id in ("proj-list-out-1", "proj-list-out-2"): + r = await shares_client.post( + "/api/shares", + json={ + "resource_type": "project", + "resource_id": res_id, + "to_username": "bob", + "permission": "read", + }, + ) + assert r.status_code == 200 + + # Also create an incoming share (bob → admin) to verify direction + # filtering excludes it from the outgoing list. + r_in = await bob_client.post( + "/api/shares", + json={ + "resource_type": "project", + "resource_id": "proj-list-in-from-bob", + "to_username": "admin", + "permission": "read", + }, + ) + assert r_in.status_code == 200 + + resp = await shares_client.get("/api/shares?direction=out") + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + out_ids = [s["resource_id"] for s in data if s["resource_type"] == "project"] + assert "proj-list-out-1" in out_ids + assert "proj-list-out-2" in out_ids + # Direction filtering must exclude incoming shares. + assert "proj-list-in-from-bob" not in out_ids + + async def test_list_incoming_shares(self, shares_client, bob_client): + """GET /api/shares?direction=in lists shares received by the user.""" + # Admin shares with bob. + r = await shares_client.post( + "/api/shares", + json={ + "resource_type": "project", + "resource_id": "proj-list-in", + "to_username": "bob", + "permission": "read", + }, + ) + assert r.status_code == 200 + + # Bob lists incoming shares. + resp = await bob_client.get("/api/shares?direction=in") + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + in_ids = [s["resource_id"] for s in data if s["resource_type"] == "project"] + assert "proj-list-in" in in_ids + + # -- Revoke ---------------------------------------------------------- + + async def test_revoke_share(self, shares_client): + """DELETE /api/shares/{id} revokes the share (owner).""" + r = await shares_client.post( + "/api/shares", + json={ + "resource_type": "project", + "resource_id": "proj-revoke", + "to_username": "bob", + "permission": "read", + }, + ) + assert r.status_code == 200 + share_id = r.json()["id"] + + resp = await shares_client.delete(f"/api/shares/{share_id}") + assert resp.status_code == 200 + assert resp.json() == {"status": "revoked", "share_id": share_id} + + # Share no longer listed. + out = await shares_client.get("/api/shares?direction=out") + assert out.status_code == 200 + matching = [s for s in out.json() if s["id"] == share_id] + assert len(matching) == 0 + + async def test_revoke_nonexistent_share(self, shares_client): + """DELETE /api/shares/{id} on non-existent share returns 404.""" + resp = await shares_client.delete("/api/shares/99999") + assert resp.status_code == 404 + + async def test_revoke_by_non_owner_unauthorized(self, shares_client, bob_client): + """Non-owner (non-admin) cannot revoke someone else's share → 403.""" + # Admin creates a share to bob. + r = await shares_client.post( + "/api/shares", + json={ + "resource_type": "project", + "resource_id": "proj-revoke-403", + "to_username": "bob", + "permission": "read", + }, + ) + assert r.status_code == 200 + share_id = r.json()["id"] + + # Bob tries to revoke it — bob is target, not owner, and not admin. + resp = await bob_client.delete(f"/api/shares/{share_id}") + assert resp.status_code == 403 diff --git a/tests/test_user_shares_store.py b/tests/test_user_shares_store.py new file mode 100644 index 000000000..457fd60e7 --- /dev/null +++ b/tests/test_user_shares_store.py @@ -0,0 +1,197 @@ +"""Tests for UserSharesStore — user-to-user resource sharing persistence.""" + +import pytest + +_user_shares_module = pytest.importorskip( + "tinyagentos.user_shares_store", + reason="UserSharesStore not merged yet (depends on #1897)", +) +UserSharesStore = _user_shares_module.UserSharesStore + + +def _require_method(obj, name: str, pr: str) -> None: + """Skip test if *name* does not exist on *obj* (merge-order guard).""" + if not hasattr(obj, name): + pytest.skip(f"{name}() not available yet (depends on {pr})") + + +@pytest.mark.asyncio +class TestUserSharesStore: + # ── helpers ────────────────────────────────────────────────────── + async def _store(self, tmp_path): + s = UserSharesStore(tmp_path / "shares.db") + await s.init() + return s + + # ── add_share ──────────────────────────────────────────────────── + async def test_add_share_returns_inserted_row(self, tmp_path): + store = await self._store(tmp_path) + try: + row = await store.add_share( + "user-1", "project", "proj-1", "user-2", "read" + ) + assert row["owner_user_id"] == "user-1" + assert row["resource_type"] == "project" + assert row["resource_id"] == "proj-1" + assert row["shared_with_user_id"] == "user-2" + assert row["permission"] == "read" + assert row["tier"] == "once" + assert row["status"] == "pending" + assert "granted_at" in row + assert row["expires_at"] is None + assert isinstance(row["id"], int) + finally: + await store.close() + + async def test_add_share_with_optional_fields(self, tmp_path): + store = await self._store(tmp_path) + try: + row = await store.add_share( + "user-1", + "project", + "proj-2", + "user-2", + "write", + tier="always", + expires_at="2030-01-01T00:00:00+00:00", + ) + assert row["tier"] == "always" + assert row["expires_at"] == "2030-01-01T00:00:00+00:00" + finally: + await store.close() + + async def test_add_share_idempotent_replace(self, tmp_path): + store = await self._store(tmp_path) + try: + await store.add_share( + "user-1", "project", "proj-3", "user-2", "read", tier="once" + ) + second = await store.add_share( + "user-1", "project", "proj-3", "user-2", "read", tier="always" + ) + assert second["tier"] == "always" + shares = await store.list_shares("user-1") + assert len(shares) == 1 + assert shares[0]["tier"] == "always" + finally: + await store.close() + + async def test_add_share_uninitialised_raises_runtime_error(self, tmp_path): + store = UserSharesStore(tmp_path / "shares.db") + try: + with pytest.raises(RuntimeError, match="not initialised"): + await store.add_share( + "user-1", "project", "proj-1", "user-2", "read" + ) + finally: + await store.close() + + # ── list_shares ────────────────────────────────────────────────── + async def test_list_shares_returns_all_for_owner(self, tmp_path): + store = await self._store(tmp_path) + try: + await store.add_share("user-a", "project", "p1", "user-b", "read") + await store.add_share("user-a", "project", "p2", "user-b", "read") + shares = await store.list_shares("user-a") + assert len(shares) == 2 + assert {s["resource_id"] for s in shares} == {"p1", "p2"} + finally: + await store.close() + + async def test_list_shares_empty_for_unknown_owner(self, tmp_path): + store = await self._store(tmp_path) + try: + shares = await store.list_shares("nonexistent") + assert shares == [] + finally: + await store.close() + + async def test_list_shares_scoped_by_owner(self, tmp_path): + store = await self._store(tmp_path) + try: + await store.add_share("user-1", "project", "p1", "user-x", "read") + await store.add_share("user-2", "project", "p2", "user-x", "read") + g1 = await store.list_shares("user-1") + g2 = await store.list_shares("user-2") + assert len(g1) == 1 + assert len(g2) == 1 + assert g1[0]["owner_user_id"] == "user-1" + assert g2[0]["owner_user_id"] == "user-2" + finally: + await store.close() + + async def test_list_shares_received(self, tmp_path): + store = await self._store(tmp_path) + try: + _require_method(store, "list_shares_received", "#1897") + await store.add_share("user-a", "project", "p1", "user-b", "read") + await store.add_share("user-c", "project", "p2", "user-b", "read") + await store.add_share("user-b", "project", "p3", "user-c", "read") + received = await store.list_shares_received("user-b") + assert len(received) == 2 + assert {s["resource_id"] for s in received} == {"p1", "p2"} + finally: + await store.close() + + # ── revoke_share ───────────────────────────────────────────────── + async def test_revoke_share_removes_it(self, tmp_path): + store = await self._store(tmp_path) + try: + row = await store.add_share( + "user-1", "project", "p1", "user-2", "read" + ) + await store.revoke_share(row["id"]) + shares = await store.list_shares("user-1") + assert len(shares) == 0 + finally: + await store.close() + + async def test_revoke_nonexistent_share_does_not_crash(self, tmp_path): + store = await self._store(tmp_path) + try: + # Must not raise. + await store.revoke_share(99999) + finally: + await store.close() + + # ── user_can_access ────────────────────────────────────────────── + async def test_user_can_access_true_for_active_share(self, tmp_path): + store = await self._store(tmp_path) + try: + _require_method(store, "accept_share", "#1897") + row = await store.add_share( + "owner", "project", "proj-access", "target", "read" + ) + # Accept the share so status='accepted' (required by user_can_access). + await store.accept_share(row["id"]) + can = await store.user_can_access("project", "proj-access", "target") + assert can is True + finally: + await store.close() + + async def test_user_can_access_false_for_expired_share(self, tmp_path): + store = await self._store(tmp_path) + try: + _require_method(store, "accept_share", "#1897") + row = await store.add_share( + "owner", + "project", + "proj-expired", + "target", + "read", + expires_at="2000-01-01T00:00:00+00:00", + ) + # Accept so status is 'accepted', but expires_at is in the past. + await store.accept_share(row["id"]) + can = await store.user_can_access("project", "proj-expired", "target") + assert can is False + finally: + await store.close() + + async def test_user_can_access_false_for_no_share(self, tmp_path): + store = await self._store(tmp_path) + try: + can = await store.user_can_access("project", "no-such-resource", "nobody") + assert can is False + finally: + await store.close()