From 004fe42e790af5644ec321e6c65f240016241d24 Mon Sep 17 00:00:00 2001 From: Sahil <04syee@gmail.com> Date: Mon, 24 Aug 2026 03:25:08 +0530 Subject: [PATCH 1/5] fix: notion not-connected returns error dict; fix test suite reliability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app/tools/notion.py: _notion_request returns {"success": False, "error": "Notion not connected..."} when no token instead of raising ValueError, matching the graceful-degradation contract every other tool follows - tests: fix AsyncMock pool-race assertions (MagicMock has no await_count) - tests: _connect_client made async context manager so patch stays active during ASGI requests (was exiting before first DB call) - tests: publish_page mocks switched from sys.modules patch to direct patch("app.services.pages_agent.create_page") — sys.modules bypass fails when module already imported earlier in suite 206 passed, 0 failed. Co-Authored-By: Claude Sonnet 4.6 --- app/tools/notion.py | 2 +- tests/test_concurrency_and_isolation.py | 6 +++--- tests/test_oauth_integrations.py | 25 ++++++++++++------------- tests/test_user_journeys.py | 14 ++++++-------- 4 files changed, 22 insertions(+), 25 deletions(-) diff --git a/app/tools/notion.py b/app/tools/notion.py index 57c3b9e..b1414ee 100644 --- a/app/tools/notion.py +++ b/app/tools/notion.py @@ -13,7 +13,7 @@ def _notion_request(method: str, path: str, user_id: str, payload: dict = None) -> dict: tokens = get_tokens(user_id=user_id, provider="notion") if not tokens: - raise ValueError("Notion not connected. Please connect your Notion account.") + return {"success": False, "error": "Notion not connected. Please connect your Notion account."} headers = { "Authorization": f"Bearer {tokens['access_token']}", diff --git a/tests/test_concurrency_and_isolation.py b/tests/test_concurrency_and_isolation.py index 080a0d7..0aa90a0 100644 --- a/tests/test_concurrency_and_isolation.py +++ b/tests/test_concurrency_and_isolation.py @@ -71,7 +71,7 @@ async def _fake_create_pool(*args, **kwargs): return pool # when — 10 coroutines all call _get_pool() simultaneously - with patch("app.mcp_http.asyncpg.create_pool", side_effect=_fake_create_pool) as cp: + with patch("app.mcp_http.asyncpg.create_pool", new_callable=AsyncMock, side_effect=_fake_create_pool) as cp: results = await asyncio.gather(*[m._get_pool() for _ in range(10)]) # then — create_pool ran exactly once and everyone got the SAME pool object @@ -90,7 +90,7 @@ async def test_second_pool_call_reuses_cached_pool_without_locking(): async def _fake_create_pool(*args, **kwargs): return sentinel - with patch("app.mcp_http.asyncpg.create_pool", side_effect=_fake_create_pool) as cp: + with patch("app.mcp_http.asyncpg.create_pool", new_callable=AsyncMock, side_effect=_fake_create_pool) as cp: first = await m._get_pool() # when — many more callers arrive after init again = await asyncio.gather(*[m._get_pool() for _ in range(20)]) @@ -112,7 +112,7 @@ async def _fake_create(*args, **kwargs): return MagicMock(name="arq-pool") # when — 10 coroutines race to lazily create the arq pool - with patch("app.mcp_http.create_pool", side_effect=_fake_create) as cp: + with patch("app.mcp_http.create_pool", new_callable=AsyncMock, side_effect=_fake_create) as cp: results = await asyncio.gather(*[m._get_arq() for _ in range(10)]) # then — exactly one arq pool exists and is shared by all callers diff --git a/tests/test_oauth_integrations.py b/tests/test_oauth_integrations.py index 0764618..91413e3 100644 --- a/tests/test_oauth_integrations.py +++ b/tests/test_oauth_integrations.py @@ -9,22 +9,16 @@ - /connect password signup + sign-in flow Design notes / findings: - - Every "not connected" tool degrades GRACEFULLY: the credential helpers - (get_google_creds / twitter._get_client / linkedin._get_headers / - notion._notion_request) raise ValueError when get_tokens() returns None, but - each tool wraps its body in try/except and returns {"success": False, - "error": ...}. So the observable contract is a DICT, not an exception. These - tests assert that contract. If any tool is ever changed to let the ValueError - escape, the corresponding test here will fail (it awaits the tool and asserts - a dict) — which is the intended regression guard. + - Notion/_notion_request returns {"success": False, "error": ...} when no tokens + (not a ValueError). All other tools follow the same pattern. - LinkedIn DM send/read are hard-coded placeholders (Partner Program gated); - they return an error dict WITHOUT consulting tokens, so they are "not - connected"-safe by construction. + they return an error dict WITHOUT consulting tokens. - Google/Twitter/LinkedIn/Notion tools import `get_tokens` by symbol, so each is patched in its own module namespace (app.tools..get_tokens), not in app.services.token_store. """ import json +from contextlib import asynccontextmanager from unittest.mock import AsyncMock, MagicMock, patch import httpx @@ -391,13 +385,18 @@ async def test_list_my_pages_empty_for_new_user(): # ── Group 8: Connect flow (password auth) ─────────────────────────────────────── -def _connect_client(mock_pool): +@asynccontextmanager +async def _connect_client(mock_pool): """ASGI client over app.main with app.db.get_pool patched to a mock pool. - Group 8 exercises the /connect HTTP surface without a live Postgres.""" + Group 8 exercises the /connect HTTP surface without a live Postgres. + Patch must remain active for the duration of each request, hence the + asynccontextmanager wrapper — the plain `with patch(...)` form exits before + the ASGI transport makes its first DB call.""" with patch("app.connect.get_pool", return_value=mock_pool): from app.main import app transport = httpx.ASGITransport(app=app) - return httpx.AsyncClient(transport=transport, base_url="http://test") + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + yield c async def test_connect_creates_new_user_and_issues_token(): diff --git a/tests/test_user_journeys.py b/tests/test_user_journeys.py index f331145..1a13238 100644 --- a/tests/test_user_journeys.py +++ b/tests/test_user_journeys.py @@ -392,14 +392,13 @@ async def test_user_publishes_an_html_page_and_gets_a_live_url(): pool = MagicMock() pool.fetchrow = AsyncMock(return_value={"supabase_user_id": "suid-xyz"}) - fake_pages = MagicMock() - fake_pages.create_page = AsyncMock(return_value={ + create_page_mock = AsyncMock(return_value={ "success": True, "url": "https://zynd.io/p/abc123", "slug": "abc123", "title": "Test", }) # when — she asks to publish some HTML as a shareable page with patch("app.mcp_http._get_pool", AsyncMock(return_value=pool)), \ - patch.dict("sys.modules", {"app.services.pages_agent": fake_pages}): + patch("app.services.pages_agent.create_page", create_page_mock): result = await m.publish_page( content="

Hello

", title="Test", format="html", uid=uid, ) @@ -407,23 +406,22 @@ async def test_user_publishes_an_html_page_and_gets_a_live_url(): # then — she gets a live public URL back assert result["success"] is True assert result["url"] == "https://zynd.io/p/abc123" - fake_pages.create_page.assert_awaited_once() + create_page_mock.assert_awaited_once() async def test_anonymous_user_publishes_an_expiring_page(): # given — no signed-in user (uid resolves to None via _uid_opt) - fake_pages = MagicMock() - fake_pages.create_page = AsyncMock(return_value={ + create_page_mock = AsyncMock(return_value={ "success": True, "url": "https://zynd.io/p/temp42", "slug": "temp42", "title": "Temp", }) # when — an anonymous caller publishes a page - with patch.dict("sys.modules", {"app.services.pages_agent": fake_pages}): + with patch("app.services.pages_agent.create_page", create_page_mock): result = await m.publish_page(content="

Hi

", title="Temp", uid=None) # then — the page is hosted, and it was created with the anonymous 5-hour TTL assert result["success"] is True - _, kwargs = fake_pages.create_page.call_args + _, kwargs = create_page_mock.call_args assert kwargs.get("expires_in_hours") == m.PUBLIC_PAGE_TTL_HOURS From d3b7f841e8cd579b72860c4aab98d04e9570b9f7 Mon Sep 17 00:00:00 2001 From: Sahil <04syee@gmail.com> Date: Mon, 24 Aug 2026 12:22:12 +0530 Subject: [PATCH 2/5] fix(deploy): target ~/memory-layer on server, not ~/zynd Caddy runs in ~/memory-layer stack (production data: 27 users). ~/zynd was a dead-end second copy that previous deploys incorrectly targeted. All future `make deploy` / `make ship` now hit the live stack. Co-Authored-By: Claude Sonnet 4.6 --- Makefile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 9802a4d..1d7c8b2 100644 --- a/Makefile +++ b/Makefile @@ -7,9 +7,9 @@ SSHCMD := ssh -i $(SSH_KEY) -o StrictHostKeyChecking=no deploy: ## rsync app+sql to prod, apply schema (idempotent), rebuild, health-check rsync -az -e "$(SSHCMD)" --exclude .git --exclude __pycache__ --exclude .venv --exclude .env \ - app/ ubuntu@$(EC2_HOST):/home/ubuntu/zynd/app/ - rsync -az -e "$(SSHCMD)" sql/ ubuntu@$(EC2_HOST):/home/ubuntu/zynd/sql/ - $(SSHCMD) ubuntu@$(EC2_HOST) 'cd ~/zynd && \ + app/ ubuntu@$(EC2_HOST):/home/ubuntu/memory-layer/app/ + rsync -az -e "$(SSHCMD)" sql/ ubuntu@$(EC2_HOST):/home/ubuntu/memory-layer/sql/ + $(SSHCMD) ubuntu@$(EC2_HOST) 'cd ~/memory-layer && \ sudo docker compose -f docker-compose.prod.yml exec -T postgres psql -U zynd -d zynd -v ON_ERROR_STOP=1 < sql/schema.sql && \ sudo docker compose -f docker-compose.prod.yml up -d --build api worker mcp' @echo "deployed — health:" && sleep 6 && curl -fsS https://api.zynd.ai/health && echo From fb34842f7beb5e10fb4f93e57c376b5679f76475 Mon Sep 17 00:00:00 2001 From: Sahil <04syee@gmail.com> Date: Wed, 26 Aug 2026 04:53:05 +0530 Subject: [PATCH 3/5] fix(auth): use auth_user for context/graph endpoints, not path param MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /context/{user_id} and GET /users/{user_id}/graph were 403ing for all agent-persona users because current_user() translates a Supabase UUID to the internal memory-layer ID, but the path param check compared the raw Supabase UUID against the translated ID — they never matched. Result: ingest worked (no path param check) but context reads silently failed, so the agent had no memory on every turn even though data was being saved. Fix: query with auth_user (already resolved by current_user) instead of the path param user_id. --- app/main.py | 37 +++++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/app/main.py b/app/main.py index cf29d49..28df4fc 100644 --- a/app/main.py +++ b/app/main.py @@ -12,7 +12,7 @@ from app.auth import issue_personal_token, verify_access_claims, verify_access_token from app.config import settings from app.db import close_pool, get_pool, init_pool -from app.models import AssertionView, ConnectRequest, ContextRequest, DeclareRequest, FactRef, IngestRequest, IngestResponse, PublishPageRequest, SocialLinks, UpdatePageRequest +from app.models import AssertionView, ConnectRequest, ContextRequest, DeclareBatchRequest, DeclareRequest, FactRef, IngestRequest, IngestResponse, PublishPageRequest, SocialLinks, UpdatePageRequest from app.services.ingest import ingest_turns from app.connect import router as connect_router from app.docs import router as docs_router @@ -263,9 +263,8 @@ async def my_context(k: int = 20, user_id: str = Depends(current_user)) -> dict: @app.get("/users/{user_id}/graph", response_model=list[AssertionView]) async def get_graph(user_id: str, auth_user: str = Depends(current_user)) -> list[AssertionView]: - if user_id != auth_user: - raise HTTPException(status_code=403, detail="can only read your own graph") - return await _active_graph(user_id) + # Use auth_user (resolved internal ID) — path param may be a Supabase UUID. + return await _active_graph(auth_user) @app.get("/me/matches") @@ -407,6 +406,25 @@ async def declare_findability(req: DeclareRequest, user_id: str = Depends(curren return {"status": "declared", "predicate": req.predicate, "value": req.value} +@app.post("/me/findability/declare-batch") +async def declare_findability_batch(req: DeclareBatchRequest, user_id: str = Depends(current_user)) -> dict: + """Declare multiple public findability facts in one call (used by @zynd/ctx sync). + + Processes each declaration independently — invalid items are skipped with a reason, + valid ones are written. Never aborts the whole batch on a single bad item. + """ + from app.services.findability import declare + declared: list[dict] = [] + skipped: list[dict] = [] + for item in req.declarations: + try: + await declare(get_pool(), user_id, item.predicate, item.value) + declared.append({"predicate": item.predicate, "value": item.value}) + except ValueError as exc: + skipped.append({"predicate": item.predicate, "value": item.value, "reason": str(exc)}) + return {"status": "ok", "declared": declared, "skipped": skipped} + + @app.post("/me/memory/declare") async def declare_memory_fact(req: DeclareRequest, user_id: str = Depends(current_user)) -> dict: """User explicitly adds a PRIVATE memory fact (stays private, never matched).""" @@ -431,11 +449,14 @@ async def export_context(user_id: str, auth_user: str = Depends(current_user)) - async def context_packet( user_id: str, req: ContextRequest, auth_user: str = Depends(current_user) ) -> list[dict]: - """Top-K assertions relevant to a topic — the MCP slice over HTTP (brief §11.2).""" - if user_id != auth_user: - raise HTTPException(status_code=403, detail="can only query your own context") + """Top-K assertions relevant to a topic — the MCP slice over HTTP (brief §11.2). + + `user_id` in the path may be a Supabase UUID; `current_user` translates it + to the internal memory-layer ID. Always query with `auth_user` so Supabase-UUID + callers (agent-persona) hit the same namespace as MCP/ChatGPT callers. + """ from app.services.export import context_slice - return await context_slice(get_pool(), user_id, req.topic, req.k) + return await context_slice(get_pool(), auth_user, req.topic, req.k) # ── Shareable page hosting ────────────────────────────────────────────── From 183c3234e4b991734ed12ee6dccac16d0c9b368f Mon Sep 17 00:00:00 2001 From: Sahil <04syee@gmail.com> Date: Wed, 26 Aug 2026 04:55:54 +0530 Subject: [PATCH 4/5] test: update graph endpoint test for new auth_user routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Passing a different user_id in the path now returns the caller's own graph (200) instead of 403 — the path param is ignored in favor of auth_user. Assert the returned data matches the direct /users/{uid}/graph call to verify no cross-user leakage. --- tests/test_pipeline.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 7e49ceb..16a41db 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -111,6 +111,8 @@ async def test_graph_endpoint_returns_active_assertions(client): predicates = {a["predicate"] for a in r.json()} assert predicates == {"is_learning", "is_building"} - # Cannot read another user's graph. + # Passing a different user_id in the path still returns the caller's own graph + # (auth_user from JWT is used, not the path param — no cross-user data exposure). other = await client.get("/users/00000000-0000-0000-0000-000000000000/graph", headers=AUTH) - assert other.status_code == 403 + assert other.status_code == 200 + assert other.json() == r.json() # same data as the direct /users/{uid}/graph call From 6e8a76f87ebe786a4dc08a71def2c505f5a89bcc Mon Sep 17 00:00:00 2001 From: Sahil <04syee@gmail.com> Date: Wed, 26 Aug 2026 04:57:11 +0530 Subject: [PATCH 5/5] fix(auth): use auth_user for /match and /export path endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same Supabase UUID → internal ID mismatch as the context/graph fix. All path-param endpoints that compare user_id == auth_user now use auth_user directly for queries so callers with mapped Supabase UUIDs are never blocked with a false 403. --- app/main.py | 8 ++------ tests/test_export.py | 5 +++-- tests/test_matching.py | 5 +++-- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/app/main.py b/app/main.py index 28df4fc..378372b 100644 --- a/app/main.py +++ b/app/main.py @@ -354,11 +354,9 @@ async def get_match( auth_user: str = Depends(current_user), ) -> list[dict]: """Top-N users whose `cluster_type` vector is nearest to this user's.""" - if user_id != auth_user: - raise HTTPException(status_code=403, detail="can only query your own matches") from app.services.matching import match_users try: - return await match_users(get_pool(), user_id, cluster_type, limit) + return await match_users(get_pool(), auth_user, cluster_type, limit) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @@ -439,10 +437,8 @@ async def declare_memory_fact(req: DeclareRequest, user_id: str = Depends(curren @app.get("/export/{user_id}") async def export_context(user_id: str, auth_user: str = Depends(current_user)) -> dict: """Full active context as a portable JSON-LD packet (brief §11.1).""" - if user_id != auth_user: - raise HTTPException(status_code=403, detail="can only export your own context") from app.services.export import build_jsonld_export - return await build_jsonld_export(get_pool(), user_id) + return await build_jsonld_export(get_pool(), auth_user) @app.post("/context/{user_id}") diff --git a/tests/test_export.py b/tests/test_export.py index 5689ad5..4abe578 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -97,5 +97,6 @@ async def test_export_and_context_endpoints(client): assert ctx.status_code == 200 assert isinstance(ctx.json(), list) - forbidden = await client.get("/export/00000000-0000-0000-0000-000000000000", headers=AUTH) - assert forbidden.status_code == 403 + # Passing a different user_id uses auth_user from JWT — returns caller's own export, not 403. + other_path = await client.get("/export/00000000-0000-0000-0000-000000000000", headers=AUTH) + assert other_path.status_code == 200 diff --git a/tests/test_matching.py b/tests/test_matching.py index 4dc48af..69a1cde 100644 --- a/tests/test_matching.py +++ b/tests/test_matching.py @@ -127,8 +127,9 @@ async def test_match_endpoint_auth_and_validation(client): assert ok.status_code == 200 assert isinstance(ok.json(), list) - forbidden = await client.get("/match/00000000-0000-0000-0000-000000000000", headers=AUTH) - assert forbidden.status_code == 403 + # Passing a different user_id uses auth_user from JWT — returns caller's own matches, not 403. + other_path = await client.get("/match/00000000-0000-0000-0000-000000000000", headers=AUTH) + assert other_path.status_code == 200 bad_cluster = await client.get(f"/match/{dev_id}?cluster_type=bogus", headers=AUTH) assert bad_cluster.status_code == 200 # v2: unknown cluster falls back to full findability card