Merge origin/dev: لایهٔ realtime (WebSocket/RPC) + فلو دعوت عضو
ادغام ۱۲ کامیت ریموت (لایهٔ realtime روی WebSocket، حذف REST عمومی، رفع
باگهای صفحهٔ مدلها) با ۸ کامیت محلی (ارائهدهندهٔ agy + رفع فلو دعوت).
تنها کانفلیکت واقعی در `app/main.py` بود و ماهوی بود، نه متنی:
ریموت اپ عمومی را به «فقط /health + /api/ws» کاهش داده و همهٔ هندلرهای HTTP
را به اپ داخلی `interfaces/realtime/rpc_app.py` منتقل کرده است. بنابراین
روتر `invites` من نباید در اپ عمومی mount شود.
راهحل:
- `app/main.py`: فقط `health` و `realtime_router` میمانند؛ ایمپورتهای اضافی
حذف شدند (نسخهٔ ریموت).
- `interfaces/realtime/rpc_app.py`: روتر `invites` به اپ داخلی اضافه شد تا
`GET /api/invites/{token}` و `POST /api/invites/accept` از مسیر WS-RPC
قابل دسترس باشند.
`api.ts` و `contracts/index.ts` خودکار و بدون کانفلیکت ادغام شدند: کلاینت
`previewInvite`/`acceptInvite` حالا از `getRealtimeClient().rpc()` رد میشود،
پس هیچ تغییری در فراخوانیها لازم نبود.
رفع تست شکستهٔ ریموت: `src/test/version.test.tsx` مقدار `AppShell` را بدون
`RealtimeProvider` رندر میکرد و `useRealtime` استثنا میداد. حالا اسکلت
همانطور که در `app/layout.tsx` سیمکشی شده رندر میشود و سوکت در تست stub
شده است.
اعتبارسنجی روی درخت ادغامشده:
- بکاند ۲۷۰ تست سبز (شامل ۶ تست دعوت روی معماری جدید)؛ mypy تمیز.
- فرانت ۶۱ تست سبز؛ tsc و eslint تمیز.
- تأیید شد روترهای invite روی اپ داخلی RPC حاضرند و سطح عمومی تغییر نکرده.
نکته: ۵ خطای ruff در `tests/conftest.py`، `tests/test_error_log.py` و
`tests/test_realtime.py` از خود `origin/dev` میآیند و به این ادغام ربطی
ندارند؛ دستنخورده رها شدند.
This commit is contained in:
@@ -24,7 +24,11 @@ from app.core.config import settings
|
||||
from app.core.database import Base, get_session
|
||||
from app.domain.base import new_id
|
||||
from app.infrastructure.db.models import UserRow
|
||||
from app.main import app
|
||||
# The internal "full" app holds every HTTP handler; the public app (app.main.app)
|
||||
# now serves only /health + /api/ws, so REST tests must target the full app.
|
||||
from app.interfaces.realtime.rpc_app import get_rpc_app
|
||||
|
||||
app = get_rpc_app()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
@@ -39,6 +43,18 @@ def _production_shaped_jwt_secret():
|
||||
settings.jwt_secret = "test-only-jwt-secret-with-more-than-32-bytes"
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _hermetic_realtime_transport():
|
||||
"""Pin the realtime bus to in-process delivery.
|
||||
|
||||
``auto`` would make every publish attempt a Redis connection to
|
||||
``localhost:6379`` — the suite has no broker, so each event would add a
|
||||
connection timeout and log noise. The fan-out behaviour under test does not
|
||||
depend on the transport.
|
||||
"""
|
||||
settings.realtime_transport = "local"
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def engine():
|
||||
# StaticPool keeps one connection alive so the in-memory schema survives
|
||||
|
||||
+131
-52
@@ -27,9 +27,7 @@ from tests.conftest import auth_header, signup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
GOOD_GENERATE: dict = {
|
||||
"candidates": [
|
||||
{"content": {"parts": [{"text": "سلام"}]}, "finishReason": "STOP"}
|
||||
],
|
||||
"candidates": [{"content": {"parts": [{"text": "سلام"}]}, "finishReason": "STOP"}],
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 7,
|
||||
"candidatesTokenCount": 3,
|
||||
@@ -192,9 +190,7 @@ async def test_agy_login_flow_end_to_end(client, google, platform_owner_token):
|
||||
headers=auth_header(platform_owner_token),
|
||||
json={
|
||||
"login_id": login_id,
|
||||
"callback_url": (
|
||||
f"http://localhost:51121/oauth-callback?code=auth-code&state={state}"
|
||||
),
|
||||
"callback_url": (f"http://localhost:51121/oauth-callback?code=auth-code&state={state}"),
|
||||
},
|
||||
)
|
||||
assert completed.status_code == 201, completed.text
|
||||
@@ -209,9 +205,7 @@ async def test_agy_login_flow_end_to_end(client, google, platform_owner_token):
|
||||
assert "agy-proj-123" in completed.text # project id is admin metadata
|
||||
|
||||
# 3. the account is listed.
|
||||
listed = await client.get(
|
||||
"/api/admin/agy/accounts", headers=auth_header(platform_owner_token)
|
||||
)
|
||||
listed = await client.get("/api/admin/agy/accounts", headers=auth_header(platform_owner_token))
|
||||
assert listed.status_code == 200
|
||||
emails = [a["email"] for a in listed.json()]
|
||||
assert emails == ["person@gmail.com"]
|
||||
@@ -232,9 +226,7 @@ async def test_agy_complete_login_rejects_unrelated_url(client, google, platform
|
||||
)
|
||||
assert completed.status_code == 422
|
||||
# The failed attempt must not have created an account.
|
||||
listed = await client.get(
|
||||
"/api/admin/agy/accounts", headers=auth_header(platform_owner_token)
|
||||
)
|
||||
listed = await client.get("/api/admin/agy/accounts", headers=auth_header(platform_owner_token))
|
||||
assert listed.json() == []
|
||||
|
||||
|
||||
@@ -249,9 +241,7 @@ async def test_agy_account_test_and_delete(client, google, platform_owner_token)
|
||||
headers=auth_header(platform_owner_token),
|
||||
json={
|
||||
"login_id": started.json()["login_id"],
|
||||
"callback_url": (
|
||||
f"http://localhost:51121/oauth-callback?code=c&state={state}"
|
||||
),
|
||||
"callback_url": (f"http://localhost:51121/oauth-callback?code=c&state={state}"),
|
||||
},
|
||||
)
|
||||
account_id = created.json()["id"]
|
||||
@@ -270,9 +260,7 @@ async def test_agy_account_test_and_delete(client, google, platform_owner_token)
|
||||
headers=auth_header(platform_owner_token),
|
||||
)
|
||||
assert deleted.status_code == 204
|
||||
listed = await client.get(
|
||||
"/api/admin/agy/accounts", headers=auth_header(platform_owner_token)
|
||||
)
|
||||
listed = await client.get("/api/admin/agy/accounts", headers=auth_header(platform_owner_token))
|
||||
assert listed.json() == []
|
||||
|
||||
|
||||
@@ -289,15 +277,11 @@ async def test_agy_same_account_twice_replaces(client, google, platform_owner_to
|
||||
headers=auth_header(platform_owner_token),
|
||||
json={
|
||||
"login_id": started.json()["login_id"],
|
||||
"callback_url": (
|
||||
f"http://localhost:51121/oauth-callback?code=c&state={state}"
|
||||
),
|
||||
"callback_url": (f"http://localhost:51121/oauth-callback?code=c&state={state}"),
|
||||
},
|
||||
)
|
||||
assert done.status_code == 201
|
||||
listed = await client.get(
|
||||
"/api/admin/agy/accounts", headers=auth_header(platform_owner_token)
|
||||
)
|
||||
listed = await client.get("/api/admin/agy/accounts", headers=auth_header(platform_owner_token))
|
||||
assert len(listed.json()) == 1
|
||||
|
||||
|
||||
@@ -390,9 +374,7 @@ async def test_seed_leaves_no_legacy_providers(client, seeded_install, session_f
|
||||
def test_auth_url_shape():
|
||||
challenge = agy.AgyAuthClient().begin()
|
||||
query = parse_qs(urlparse(challenge.auth_url).query)
|
||||
assert challenge.auth_url.startswith(
|
||||
"https://accounts.google.com/o/oauth2/v2/auth"
|
||||
)
|
||||
assert challenge.auth_url.startswith("https://accounts.google.com/o/oauth2/v2/auth")
|
||||
assert query["client_id"][0] == (
|
||||
"1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
|
||||
)
|
||||
@@ -410,9 +392,7 @@ async def test_paste_flow_exchanges_and_resolves_account(google):
|
||||
"""start → paste callback URL → account material, verifier stays server-side."""
|
||||
login_id, _auth_url = await agy.begin_login()
|
||||
challenge = agy._CHALLENGES[login_id]
|
||||
callback = (
|
||||
f"http://localhost:51121/oauth-callback?code=auth-code&state={challenge.state}"
|
||||
)
|
||||
callback = f"http://localhost:51121/oauth-callback?code=auth-code&state={challenge.state}"
|
||||
result = await agy.redeem_login(login_id, callback)
|
||||
assert result.email == "person@gmail.com"
|
||||
assert result.project_id == "agy-proj-123"
|
||||
@@ -446,11 +426,10 @@ async def test_google_error_page_is_rejected(google):
|
||||
|
||||
async def test_refresh_token_missing_is_actionable(monkeypatch):
|
||||
"""A token response without ``refresh_token`` fails loudly and tells why."""
|
||||
|
||||
async def handler(method, url, json_body, headers, data):
|
||||
if url.endswith("/token"):
|
||||
return _FakeResponse(
|
||||
json_data={"access_token": "a", "expires_in": 3600}
|
||||
)
|
||||
return _FakeResponse(json_data={"access_token": "a", "expires_in": 3600})
|
||||
return await default_handler(method, url, json_body, headers, data)
|
||||
|
||||
patch_google(monkeypatch, handler)
|
||||
@@ -617,7 +596,8 @@ def test_response_parsing_skips_thoughts():
|
||||
async def test_complete_happy_path(google, decrypted):
|
||||
adapter = agy.AgyModelAdapter()
|
||||
response = await adapter.complete(
|
||||
model=_MODEL, credentials=[_agy_credential(email="one@gmail.com")],
|
||||
model=_MODEL,
|
||||
credentials=[_agy_credential(email="one@gmail.com")],
|
||||
messages=_MESSAGES,
|
||||
)
|
||||
assert response.content == "سلام"
|
||||
@@ -631,6 +611,7 @@ async def test_complete_happy_path(google, decrypted):
|
||||
|
||||
async def test_rate_limited_account_is_rotated_and_benched(monkeypatch, decrypted):
|
||||
"""A 429 on one account must not fail the request: the pool moves on."""
|
||||
|
||||
async def handler(method, url, json_body, headers, data):
|
||||
if url.endswith("/token"):
|
||||
# Each account's refresh token maps to its own access token.
|
||||
@@ -642,9 +623,7 @@ async def test_rate_limited_account_is_rotated_and_benched(monkeypatch, decrypte
|
||||
auth = headers.get("Authorization", "")
|
||||
if auth.endswith("access-one"):
|
||||
# Both base URLs hit the same quota.
|
||||
return _FakeResponse(
|
||||
status_code=429, text="quota", headers={"Retry-After": "120"}
|
||||
)
|
||||
return _FakeResponse(status_code=429, text="quota", headers={"Retry-After": "120"})
|
||||
return _FakeResponse(json_data=GOOD_GENERATE)
|
||||
return await default_handler(method, url, json_body, headers, data)
|
||||
|
||||
@@ -659,9 +638,7 @@ async def test_rate_limited_account_is_rotated_and_benched(monkeypatch, decrypte
|
||||
adapter._state.cursor = -1
|
||||
one = _agy_credential(email="one@gmail.com", refresh_token="refresh-1")
|
||||
two = _agy_credential(email="two@gmail.com", refresh_token="refresh-2")
|
||||
response = await adapter.complete(
|
||||
model=_MODEL, credentials=[one, two], messages=_MESSAGES
|
||||
)
|
||||
response = await adapter.complete(model=_MODEL, credentials=[one, two], messages=_MESSAGES)
|
||||
assert response.content == "سلام"
|
||||
|
||||
cooldowns = [r for r in reported if r[1] == "cooldown"]
|
||||
@@ -696,6 +673,7 @@ async def test_every_account_rate_limited_raises(monkeypatch, decrypted):
|
||||
|
||||
async def test_rejected_grant_marks_account_error(monkeypatch, decrypted):
|
||||
"""401 means the OAuth grant died — bench as error, move to next account."""
|
||||
|
||||
async def handler(method, url, json_body, headers, data):
|
||||
if url.endswith("/token"):
|
||||
access = {"refresh-1": "access-one", "refresh-2": "access-two"}[
|
||||
@@ -719,9 +697,7 @@ async def test_rejected_grant_marks_account_error(monkeypatch, decrypted):
|
||||
adapter._state.cursor = -1
|
||||
dead = _agy_credential(email="dead@gmail.com", refresh_token="refresh-1")
|
||||
alive = _agy_credential(email="alive@gmail.com", refresh_token="refresh-2")
|
||||
response = await adapter.complete(
|
||||
model=_MODEL, credentials=[dead, alive], messages=_MESSAGES
|
||||
)
|
||||
response = await adapter.complete(model=_MODEL, credentials=[dead, alive], messages=_MESSAGES)
|
||||
assert response.content == "سلام"
|
||||
errors = [r for r in reported if r[1] == "error"]
|
||||
assert errors and errors[0][0] == dead.id
|
||||
@@ -731,6 +707,7 @@ async def test_rejected_grant_marks_account_error(monkeypatch, decrypted):
|
||||
|
||||
async def test_server_error_falls_through_to_next_backend(monkeypatch, decrypted):
|
||||
"""5xx on one backend URL is not the account's fault — try the other."""
|
||||
|
||||
async def handler(method, url, json_body, headers, data):
|
||||
if url.endswith("/token"):
|
||||
return _token_response()
|
||||
@@ -743,7 +720,8 @@ async def test_server_error_falls_through_to_next_backend(monkeypatch, decrypted
|
||||
patch_google(monkeypatch, handler)
|
||||
adapter = agy.AgyModelAdapter()
|
||||
response = await adapter.complete(
|
||||
model=_MODEL, credentials=[_agy_credential(email="one@gmail.com")],
|
||||
model=_MODEL,
|
||||
credentials=[_agy_credential(email="one@gmail.com")],
|
||||
messages=_MESSAGES,
|
||||
)
|
||||
assert response.content == "سلام"
|
||||
@@ -782,10 +760,9 @@ async def test_available_models(google, decrypted):
|
||||
async def test_available_models_for_one_account(google, decrypted):
|
||||
"""The console asks for a specific account, so the fetch is per-account."""
|
||||
adapter = agy.AgyModelAdapter()
|
||||
models = await adapter.available_models_for(
|
||||
_agy_credential(email="one@gmail.com")
|
||||
)
|
||||
assert models == ["claude-sonnet-4-5", "gemini-3-flash"]
|
||||
models = await adapter.available_models_for(_agy_credential(email="one@gmail.com"))
|
||||
assert [m.id for m in models] == ["claude-sonnet-4-5", "gemini-3-flash"]
|
||||
assert models[1].supported_efforts == ["low", "medium", "high"]
|
||||
# The list is fetched from Google every call — no cached catalog.
|
||||
fetches = [c for c in google if c["url"].endswith("fetchAvailableModels")]
|
||||
assert len(fetches) == 1
|
||||
@@ -817,9 +794,7 @@ async def test_account_models_endpoint(client, google, platform_owner_token):
|
||||
headers=auth_header(platform_owner_token),
|
||||
json={
|
||||
"login_id": started.json()["login_id"],
|
||||
"callback_url": (
|
||||
f"http://localhost:51121/oauth-callback?code=c&state={state}"
|
||||
),
|
||||
"callback_url": (f"http://localhost:51121/oauth-callback?code=c&state={state}"),
|
||||
},
|
||||
)
|
||||
).json()["id"]
|
||||
@@ -829,7 +804,9 @@ async def test_account_models_endpoint(client, google, platform_owner_token):
|
||||
headers=auth_header(platform_owner_token),
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json() == ["claude-sonnet-4-5", "gemini-3-flash"]
|
||||
data = response.json()
|
||||
assert [m["id"] for m in data] == ["claude-sonnet-4-5", "gemini-3-flash"]
|
||||
assert data[1]["supported_efforts"] == ["low", "medium", "high"]
|
||||
|
||||
missing = await client.get(
|
||||
"/api/admin/agy/accounts/does-not-exist/models",
|
||||
@@ -840,6 +817,7 @@ async def test_account_models_endpoint(client, google, platform_owner_token):
|
||||
|
||||
async def test_completion_carries_the_model_effort(monkeypatch, decrypted):
|
||||
"""The end-to-end path: effort on the model row reaches the payload."""
|
||||
|
||||
async def handler(method, url, json_body, headers, data):
|
||||
if url.endswith("/token"):
|
||||
return _token_response()
|
||||
@@ -865,3 +843,104 @@ async def test_completion_carries_the_model_effort(monkeypatch, decrypted):
|
||||
"thinkingBudget": 4096,
|
||||
"includeThoughts": True,
|
||||
}
|
||||
|
||||
|
||||
def test_parse_cli_models_output():
|
||||
"""Verify parsing raw output from agy models CLI."""
|
||||
sample_output = (
|
||||
"Fetching available models...\n"
|
||||
"gemini-3.8-flash-high\tGemini 3.8 Flash (High)\n"
|
||||
"gemini-3.8-flash-medium\tGemini 3.8 Flash (Medium)\n"
|
||||
"gemini-3.8-flash-low\tGemini 3.8 Flash (Low)\n"
|
||||
"claude-sonnet-4-6\tClaude Sonnet 4.6 (Thinking)\n"
|
||||
"claude-opus-4-6-thinking\tClaude Opus 4.6 (Thinking)\n"
|
||||
"gpt-oss-120b-medium\tGPT-OSS 120B (Medium)\n"
|
||||
)
|
||||
models = agy.AgyModelAdapter._parse_cli_models_output(sample_output)
|
||||
assert len(models) == 6
|
||||
|
||||
m_high = next(m for m in models if m.id == "gemini-3.8-flash-high")
|
||||
assert m_high.display_name == "Gemini 3.8 Flash (High)"
|
||||
assert m_high.supports_thinking is True
|
||||
assert m_high.supported_efforts == ["high"]
|
||||
assert m_high.default_effort == "high"
|
||||
|
||||
m_med = next(m for m in models if m.id == "gemini-3.8-flash-medium")
|
||||
assert m_med.supported_efforts == ["medium"]
|
||||
assert m_med.default_effort == "medium"
|
||||
|
||||
m_low = next(m for m in models if m.id == "gemini-3.8-flash-low")
|
||||
assert m_low.supported_efforts == ["low"]
|
||||
assert m_low.default_effort == "low"
|
||||
|
||||
m_claude = next(m for m in models if m.id == "claude-sonnet-4-6")
|
||||
assert m_claude.supports_thinking is True
|
||||
|
||||
m_gpt = next(m for m in models if m.id == "gpt-oss-120b-medium")
|
||||
assert m_gpt.supported_efforts == ["medium"]
|
||||
|
||||
|
||||
def test_build_generate_payload_tiered_mapping():
|
||||
"""Verify virtual CLI models map to underlying tiered model with appropriate budget."""
|
||||
account = agy.AgyAccount(
|
||||
credential_id="cred-1",
|
||||
refresh_token="rt-1",
|
||||
email="test@example.com",
|
||||
project_id="proj-123",
|
||||
)
|
||||
messages = [ChatMessage(role="user", content="Hello")]
|
||||
|
||||
payload_low = agy._build_generate_payload(
|
||||
account,
|
||||
"gemini-3.8-flash-low",
|
||||
messages,
|
||||
system="",
|
||||
temperature=0.7,
|
||||
max_tokens=4096,
|
||||
)
|
||||
assert payload_low["model"] == "gemini-3.8-flash-tiered"
|
||||
assert payload_low["request"]["generationConfig"]["thinkingConfig"] == {
|
||||
"thinkingBudget": 4096,
|
||||
"includeThoughts": True,
|
||||
}
|
||||
|
||||
payload_high = agy._build_generate_payload(
|
||||
account,
|
||||
"gemini-3.8-flash-high",
|
||||
messages,
|
||||
system="",
|
||||
temperature=0.7,
|
||||
max_tokens=4096,
|
||||
)
|
||||
assert payload_high["model"] == "gemini-3.8-flash-tiered"
|
||||
assert payload_high["request"]["generationConfig"]["thinkingConfig"] == {
|
||||
"thinkingBudget": 16384,
|
||||
"includeThoughts": True,
|
||||
}
|
||||
|
||||
|
||||
async def test_fetch_models_via_cli_mocked(monkeypatch):
|
||||
"""Verify _fetch_models_via_cli invokes the CLI binary and handles output."""
|
||||
account = agy.AgyAccount(
|
||||
credential_id="cred-1",
|
||||
refresh_token="rt-test-123",
|
||||
email="test@example.com",
|
||||
project_id="proj-123",
|
||||
)
|
||||
|
||||
class FakeProc:
|
||||
returncode = 0
|
||||
|
||||
async def communicate(self):
|
||||
return b"gemini-3.8-flash-high\tGemini 3.8 Flash (High)\n", b""
|
||||
|
||||
async def fake_create_subprocess_exec(*args, **kwargs):
|
||||
return FakeProc()
|
||||
|
||||
monkeypatch.setattr(agy.asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
|
||||
monkeypatch.setattr(agy.shutil, "which", lambda cmd: "/usr/bin/agy")
|
||||
|
||||
models = await agy.AgyModelAdapter._fetch_models_via_cli(account)
|
||||
assert len(models) == 1
|
||||
assert models[0].id == "gemini-3.8-flash-high"
|
||||
assert models[0].display_name == "Gemini 3.8 Flash (High)"
|
||||
|
||||
@@ -22,7 +22,11 @@ from app.core.errors import ProviderUnavailableError
|
||||
from app.infrastructure.db.models import ErrorEventRow
|
||||
from app.interfaces.http.deps import OrgContext, get_org_context
|
||||
from app.interfaces.http.request_context import REQUEST_ID_HEADER
|
||||
from app.main import app
|
||||
# The internal "full" app holds every HTTP handler; the public app serves only
|
||||
# /health + /api/ws, so this module's REST exercises must target the full app.
|
||||
from app.interfaces.realtime.rpc_app import get_rpc_app
|
||||
|
||||
app = get_rpc_app()
|
||||
from tests.conftest import auth_header, signup
|
||||
|
||||
#: The password in this DSN must never reach the database.
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
"""Realtime push layer: authorization, fan-out, and the live socket end-to-end.
|
||||
|
||||
Three levels, mirroring the package's structure:
|
||||
|
||||
* **Authorization** — a channel is only ever granted through
|
||||
``authorize_channel``; every rule is tested for both the allow and the deny
|
||||
side, because a rule that always denies is indistinguishable from a bug.
|
||||
* **Fan-out** — the manager delivers to the subscribers of a channel, keeps
|
||||
ordering, de-duplicates relayed events and cleans up on unsubscribe.
|
||||
* **The socket** — a real WebSocket against the real app: subscribe, act over
|
||||
HTTP, and assert the event arrives without a reload.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
import app.infrastructure.db.models as _orm_models # noqa: F401 - registers metadata
|
||||
from app.application.ports.realtime import RealtimeEvent, set_publisher
|
||||
from app.core.config import settings
|
||||
from app.core.database import Base, get_session
|
||||
from app.core.security import create_access_token
|
||||
from app.domain.base import new_id
|
||||
from app.infrastructure.db.models import UserRow
|
||||
from app.interfaces.realtime.authz import authorize_channel
|
||||
from app.interfaces.realtime.bus import get_bus
|
||||
from app.interfaces.realtime.manager import Connection, ConnectionManager
|
||||
# The internal "full" app holds every HTTP handler; the public app serves only
|
||||
# /health + /api/ws. The socket and the RPC surface both live on the full app.
|
||||
from app.interfaces.realtime.rpc_app import get_rpc_app
|
||||
|
||||
app = get_rpc_app()
|
||||
|
||||
API_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class FakeSocket:
|
||||
"""The manager only needs ``send_text`` and a connected ``client_state``.
|
||||
|
||||
Both matter: the sender task checks ``client_state`` before writing, so a
|
||||
fake without it would silently swallow every frame and make the manager
|
||||
look broken.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.sent: list[str] = []
|
||||
from starlette.websockets import WebSocketState
|
||||
|
||||
self.client_state = WebSocketState.CONNECTED
|
||||
|
||||
async def send_text(self, payload: str) -> None:
|
||||
self.sent.append(payload)
|
||||
|
||||
|
||||
def _event(event_id: str, *channels: str) -> RealtimeEvent:
|
||||
return RealtimeEvent(type="test.event", channels=channels, data={"id": event_id}, id=event_id)
|
||||
|
||||
|
||||
async def _allow(_: Connection, __: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Authorization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _user_with_org(session: AsyncSession, *, platform_owner: bool = False):
|
||||
"""A user, and optionally an organization they belong to."""
|
||||
user_id = new_id()
|
||||
session.add(
|
||||
UserRow(
|
||||
id=user_id,
|
||||
email=f"{user_id}@example.com",
|
||||
password_hash="x",
|
||||
is_platform_owner=platform_owner,
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
return user_id
|
||||
|
||||
|
||||
async def _org_with_member(session: AsyncSession, user_id: str) -> str:
|
||||
from app.domain.identity.models import Membership, Organization, Role
|
||||
from app.infrastructure.repositories.identity import (
|
||||
MembershipRepository,
|
||||
OrganizationRepository,
|
||||
)
|
||||
|
||||
organization = await OrganizationRepository(session).create(
|
||||
Organization(name="سازمان تست", slug=new_id()[:8], owner_user_id=user_id)
|
||||
)
|
||||
await MembershipRepository(session).add(
|
||||
Membership(organization_id=organization.id, user_id=user_id, role=Role.ADMIN)
|
||||
)
|
||||
return organization.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_member_may_subscribe_to_own_org(session_factory):
|
||||
async with session_factory() as session:
|
||||
user_id = await _user_with_org(session)
|
||||
org_id = await _org_with_member(session, user_id)
|
||||
allowed = await authorize_channel(
|
||||
session, user_id=user_id, is_platform_owner=False, channel=f"org:{org_id}"
|
||||
)
|
||||
assert allowed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_outsider_may_not_subscribe_to_foreign_org(session_factory):
|
||||
async with session_factory() as session:
|
||||
insider = await _user_with_org(session)
|
||||
outsider = await _user_with_org(session)
|
||||
org_id = await _org_with_member(session, insider)
|
||||
allowed = await authorize_channel(
|
||||
session, user_id=outsider, is_platform_owner=False, channel=f"org:{org_id}"
|
||||
)
|
||||
assert allowed is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_channel_kinds_are_denied(session_factory):
|
||||
async with session_factory() as session:
|
||||
user_id = await _user_with_org(session)
|
||||
allowed = await authorize_channel(
|
||||
session, user_id=user_id, is_platform_owner=False, channel="nonsense:whatever"
|
||||
)
|
||||
assert allowed is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_channel_needs_platform_owner(session_factory):
|
||||
async with session_factory() as session:
|
||||
owner = await _user_with_org(session, platform_owner=True)
|
||||
member = await _user_with_org(session)
|
||||
owner_ok = await authorize_channel(
|
||||
session, user_id=owner, is_platform_owner=True, channel="admin"
|
||||
)
|
||||
member_ok = await authorize_channel(
|
||||
session, user_id=member, is_platform_owner=False, channel="admin"
|
||||
)
|
||||
assert owner_ok is True
|
||||
assert member_ok is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_channel_is_private(session_factory):
|
||||
async with session_factory() as session:
|
||||
user_id = await _user_with_org(session)
|
||||
other = await _user_with_org(session)
|
||||
own = await authorize_channel(
|
||||
session, user_id=user_id, is_platform_owner=False, channel=f"user:{user_id}"
|
||||
)
|
||||
foreign = await authorize_channel(
|
||||
session, user_id=other, is_platform_owner=False, channel=f"user:{user_id}"
|
||||
)
|
||||
assert own is True
|
||||
assert foreign is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fan-out
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_delivers_to_subscribers_only():
|
||||
manager = ConnectionManager()
|
||||
listening = FakeSocket()
|
||||
other = FakeSocket()
|
||||
|
||||
subscribed = Connection(user_id="u1", is_platform_owner=False, websocket=listening)
|
||||
stranger = Connection(user_id="u2", is_platform_owner=False, websocket=other)
|
||||
for connection in (subscribed, stranger):
|
||||
await manager.register(connection)
|
||||
await manager.subscribe(subscribed, ["org:1"], _allow)
|
||||
|
||||
delivered = await manager.dispatch(_event("e1", "org:1"))
|
||||
assert delivered == 1
|
||||
|
||||
# The write happens on the connection's own sender task; give the loop the
|
||||
# turn it needs to flush the queue.
|
||||
await asyncio.sleep(0.05)
|
||||
assert len(listening.sent) == 1
|
||||
assert other.sent == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_wraps_events_in_the_wire_envelope():
|
||||
"""A browser sees one discriminator: ``{"type": "event", "event": {...}}``."""
|
||||
import json
|
||||
|
||||
manager = ConnectionManager()
|
||||
socket = FakeSocket()
|
||||
connection = Connection(user_id="u1", is_platform_owner=False, websocket=socket)
|
||||
await manager.register(connection)
|
||||
await manager.subscribe(connection, ["org:1"], _allow)
|
||||
await manager.dispatch(_event("e1", "org:1"))
|
||||
|
||||
await asyncio.sleep(0.05)
|
||||
assert len(socket.sent) == 1
|
||||
frame = json.loads(socket.sent[0])
|
||||
assert frame["type"] == "event"
|
||||
assert frame["event"]["id"] == "e1"
|
||||
assert frame["event"]["type"] == "test.event"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_drops_duplicate_events():
|
||||
"""In-process publish plus a relayed copy deliver exactly once."""
|
||||
manager = ConnectionManager()
|
||||
socket = FakeSocket()
|
||||
connection = Connection(user_id="u1", is_platform_owner=False, websocket=socket)
|
||||
await manager.register(connection)
|
||||
await manager.subscribe(connection, ["org:1"], _allow)
|
||||
|
||||
event = _event("dup", "org:1")
|
||||
first = await manager.dispatch(event)
|
||||
second = await manager.dispatch(event)
|
||||
assert (first, second) == (1, 0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsubscribe_stops_delivery():
|
||||
manager = ConnectionManager()
|
||||
socket = FakeSocket()
|
||||
connection = Connection(user_id="u1", is_platform_owner=False, websocket=socket)
|
||||
await manager.register(connection)
|
||||
await manager.subscribe(connection, ["org:1", "org:2"], _allow)
|
||||
await manager.unsubscribe(connection, ["org:1"])
|
||||
|
||||
assert await manager.dispatch(_event("e1", "org:1")) == 0
|
||||
assert await manager.dispatch(_event("e2", "org:2")) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publisher_never_raises_on_bad_listener():
|
||||
"""A listener that explodes must not break the publisher or its peers."""
|
||||
|
||||
async def exploding(event: RealtimeEvent) -> None:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
bus = get_bus()
|
||||
bus.attach(exploding)
|
||||
try:
|
||||
set_publisher(bus)
|
||||
# Must not raise despite the exploding listener.
|
||||
from app.application.ports.realtime import publish
|
||||
|
||||
await publish(_event("e1", "org:1"))
|
||||
finally:
|
||||
bus.detach(exploding)
|
||||
set_publisher(_NullPublisher())
|
||||
|
||||
|
||||
class _NullPublisher:
|
||||
async def publish(self, event: RealtimeEvent) -> None:
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The socket, end to end
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _create_schema(engine) -> None:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def ws_client(monkeypatch):
|
||||
"""A TestClient whose API and socket both talk to one throwaway database.
|
||||
|
||||
File-based SQLite with ``NullPool`` on purpose: the app runs on the test
|
||||
portal's event loop while the schema is created from the test's own, and a
|
||||
pooled connection cannot cross that boundary. The database file lives in
|
||||
the package directory — ``*.db`` is gitignored — so the fixture needs no
|
||||
writable temp directory outside the workspace.
|
||||
"""
|
||||
db_path = API_ROOT / f"realtime-test-{new_id()}.db"
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}", poolclass=NullPool)
|
||||
asyncio.run(_create_schema(engine))
|
||||
factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
monkeypatch.setattr("app.main.engine", engine)
|
||||
monkeypatch.setattr("app.interfaces.realtime.router.SessionFactory", factory)
|
||||
monkeypatch.setattr(settings, "realtime_transport", "local")
|
||||
|
||||
async def _override_session():
|
||||
async with factory() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
app.dependency_overrides[get_session] = _override_session
|
||||
# The manager is module state; start each run from a clean slate so a
|
||||
# connection from another test cannot colour this one's fan-out.
|
||||
from app.interfaces.realtime.router import realtime_manager
|
||||
|
||||
realtime_manager()._connections.clear()
|
||||
realtime_manager()._by_channel.clear()
|
||||
try:
|
||||
with TestClient(app) as client:
|
||||
yield client
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
asyncio.run(engine.dispose())
|
||||
with suppress(OSError):
|
||||
db_path.unlink()
|
||||
|
||||
|
||||
def _frames_until(websocket, wanted_type: str, *, max_frames: int = 20) -> dict:
|
||||
"""Read frames until ``wanted_type`` arrives — hello/subscribed come first."""
|
||||
for _ in range(max_frames):
|
||||
frame = websocket.receive_json()
|
||||
if frame.get("type") == "event" and frame.get("event", {}).get("type") == wanted_type:
|
||||
return frame["event"]
|
||||
raise AssertionError(f"no {wanted_type} event arrived")
|
||||
|
||||
|
||||
def test_socket_allows_anonymous_then_restricts_actions(ws_client):
|
||||
"""No token opens an anonymous socket, but only login/signup may be called.
|
||||
|
||||
A visitor who has not logged in can reach ``/api/auth/login`` and
|
||||
``/api/auth/signup`` over RPC; every other RPC and any subscribe is refused.
|
||||
"""
|
||||
with ws_client.websocket_connect("/api/ws") as socket:
|
||||
hello = socket.receive_json()
|
||||
assert hello["type"] == "hello"
|
||||
assert hello["user_id"] is None
|
||||
|
||||
# A protected RPC is refused for an anonymous connection.
|
||||
socket.send_json({"action": "rpc", "id": "1", "method": "GET", "path": "/api/organizations"})
|
||||
result = socket.receive_json()
|
||||
assert result["type"] == "rpc_result"
|
||||
assert result["status"] == 401
|
||||
|
||||
# Subscribing is refused until the caller logs in.
|
||||
socket.send_json({"action": "subscribe", "channels": ["org:x"]})
|
||||
refused = socket.receive_json()
|
||||
assert refused["type"] == "error"
|
||||
|
||||
|
||||
def test_anonymous_may_call_login_over_rpc(ws_client):
|
||||
with ws_client.websocket_connect("/api/ws") as socket:
|
||||
socket.receive_json() # hello
|
||||
socket.send_json(
|
||||
{
|
||||
"action": "rpc",
|
||||
"id": "login-1",
|
||||
"method": "POST",
|
||||
"path": "/api/auth/login",
|
||||
"body": {"email": "anon@example.com", "password": "password123"},
|
||||
}
|
||||
)
|
||||
result = socket.receive_json()
|
||||
assert result["type"] == "rpc_result"
|
||||
# No such user -> the handler's own auth dependency returns 401, proving
|
||||
# the RPC reached the real login route through the internal app.
|
||||
assert result["status"] == 401
|
||||
|
||||
|
||||
def test_task_creation_is_pushed_live(ws_client):
|
||||
# Sign up, create an organization — the tenant the socket subscribes to.
|
||||
signup = ws_client.post(
|
||||
"/api/auth/signup",
|
||||
json={"email": "live@example.com", "password": "password123", "name": "زنده"},
|
||||
)
|
||||
assert signup.status_code == 201, signup.text
|
||||
token = signup.json()["access_token"]
|
||||
org = ws_client.post(
|
||||
"/api/organizations",
|
||||
json={"name": "سازمان زنده"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert org.status_code == 201, org.text
|
||||
org_id = org.json()["id"]
|
||||
|
||||
with ws_client.websocket_connect(f"/api/ws?token={token}") as socket:
|
||||
hello = socket.receive_json()
|
||||
assert hello["type"] == "hello"
|
||||
|
||||
socket.send_json({"action": "subscribe", "channels": [f"org:{org_id}"]})
|
||||
subscribed = socket.receive_json()
|
||||
assert subscribed["type"] == "subscribed"
|
||||
assert subscribed["accepted"] == [f"org:{org_id}"]
|
||||
|
||||
# A foreign channel is refused, not silently ignored.
|
||||
socket.send_json({"action": "subscribe", "channels": ["org:does-not-exist"]})
|
||||
refused = socket.receive_json()
|
||||
assert refused["type"] == "subscribed"
|
||||
assert refused["rejected"] == ["org:does-not-exist"]
|
||||
|
||||
created = ws_client.post(
|
||||
f"/api/organizations/{org_id}/tasks",
|
||||
json={"title": "کار زنده"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
|
||||
event = _frames_until(socket, "task.created")
|
||||
assert event["data"]["title"] == "کار زنده"
|
||||
assert event["organization_id"] == org_id
|
||||
|
||||
|
||||
def test_second_user_cannot_read_another_org(ws_client):
|
||||
"""Two tenants, one socket each: an outsider's org is out of reach."""
|
||||
tokens = []
|
||||
org_ids = []
|
||||
for index in range(2):
|
||||
signup = ws_client.post(
|
||||
"/api/auth/signup",
|
||||
json={"email": f"user{index}@example.com", "password": "password123"},
|
||||
)
|
||||
assert signup.status_code == 201, signup.text
|
||||
token = signup.json()["access_token"]
|
||||
org = ws_client.post(
|
||||
"/api/organizations",
|
||||
json={"name": f"org {index}"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert org.status_code == 201, org.text
|
||||
tokens.append(token)
|
||||
org_ids.append(org.json()["id"])
|
||||
|
||||
with ws_client.websocket_connect(f"/api/ws?token={tokens[0]}") as socket:
|
||||
socket.receive_json() # hello
|
||||
socket.send_json({"action": "subscribe", "channels": [f"org:{org_ids[1]}"]})
|
||||
refused = socket.receive_json()
|
||||
assert refused["rejected"] == [f"org:{org_ids[1]}"]
|
||||
|
||||
|
||||
def test_socket_token_identifies_a_real_user(ws_client):
|
||||
"""A well-formed token for a deleted user must not open a socket."""
|
||||
user_id = new_id()
|
||||
token = create_access_token(user_id)
|
||||
with pytest.raises(Exception), ws_client.websocket_connect(f"/api/ws?token={token}"): # noqa: B017, SIM117
|
||||
# Closed with 1008: the token is well-formed but names no user.
|
||||
pass
|
||||
Reference in New Issue
Block a user