feat: isolate external application conversations

This commit is contained in:
2026-08-03 18:57:19 +08:00
parent 26109648af
commit cd3c875106
26 changed files with 843 additions and 110 deletions

View File

@@ -0,0 +1,106 @@
from __future__ import annotations
from fastapi import HTTPException
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app.core.auth_context import ChatAccessScope
from app.core.dependencies import get_current_user_context
from app.models import Base
from app.models.sso import SsoClient
from app.models.user import User
from app.services.chat_service import ChatService
def _db() -> Session:
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(engine)
return Session(engine)
def test_chat_sessions_are_isolated_by_login_source_and_application():
with _db() as db:
user = User(
phone="13800138000",
name="测试学员",
status=1,
daily_chat_limit=100,
daily_chat_used=0,
is_deleted=0,
)
db.add(user)
db.commit()
db.refresh(user)
direct = ChatService.create_session(db, user)
app_a_scope = ChatAccessScope(source_type="sso", source_client_id=101)
app_b_scope = ChatAccessScope(source_type="sso", source_client_id=202)
app_a = ChatService.create_session(db, user, app_a_scope)
app_b = ChatService.create_session(db, user, app_b_scope)
assert [item.id for item in ChatService.list_sessions(db, user)] == [direct.id]
assert [item.id for item in ChatService.list_sessions(db, user, app_a_scope)] == [app_a.id]
assert [item.id for item in ChatService.list_sessions(db, user, app_b_scope)] == [app_b.id]
with pytest.raises(HTTPException) as cross_source:
ChatService.get_history(db, user, app_a.id)
assert cross_source.value.status_code == 404
with pytest.raises(HTTPException) as cross_application:
ChatService.get_history(db, user, app_b.id, app_a_scope)
assert cross_application.value.status_code == 404
def test_auth_context_defaults_old_tokens_to_direct_and_rejects_disabled_sso_client():
with _db() as db:
user = User(
phone="13700137000",
name="登录来源验收",
status=1,
daily_chat_limit=100,
daily_chat_used=0,
is_deleted=0,
)
client = SsoClient(
app_id="context-test-app",
name="鉴权验收应用",
client_secret="encrypted-placeholder",
redirect_uris="[]",
status=1,
)
db.add_all([user, client])
db.commit()
direct = get_current_user_context({"sub": str(user.id), "type": "user"}, db)
assert direct.chat_scope == ChatAccessScope.direct()
sso = get_current_user_context(
{
"sub": str(user.id),
"type": "user",
"auth_source": "sso",
"sso_client_id": client.id,
},
db,
)
assert sso.chat_scope == ChatAccessScope(source_type="sso", source_client_id=client.id)
client.status = 0
db.commit()
with pytest.raises(HTTPException) as disabled:
get_current_user_context(
{
"sub": str(user.id),
"type": "user",
"auth_source": "sso",
"sso_client_id": client.id,
},
db,
)
assert disabled.value.status_code == 401

View File

@@ -15,6 +15,8 @@ from sqlalchemy.pool import StaticPool
from app.models import Base
from app.core.config import get_settings
from app.models.ai_config import SystemConfig
from app.models.entitlement import EntitlementPlan, UserEntitlement
from app.core.security import decode_access_token
from app.models.sso import SsoClient, SsoLoginAudit, UserExternalIdentity
from app.models.user import User
from app.schemas.sso import SsoTicketRequest
@@ -211,3 +213,59 @@ def test_sso_first_login_requires_verified_existing_phone(monkeypatch: pytest.Mo
audit = db.scalar(select(SsoLoginAudit).order_by(SsoLoginAudit.id.desc()))
assert audit is not None
assert audit.status == "FAILED"
def test_sso_can_auto_register_and_assign_configured_entitlement(monkeypatch: pytest.MonkeyPatch):
redis = FakeRedis()
monkeypatch.setattr("app.services.sso_service.get_sync_redis_client", lambda: redis)
monkeypatch.setattr(get_settings(), "jwt_secret_key", "test-sso-jwt-secret-key-32-bytes-long")
with _db() as db:
_user, client, secret = _seed(db)
plan = EntitlementPlan(
name="第三方默认权益",
plan_type="basic",
validity_days=90,
status=1,
sort_order=1,
)
db.add(plan)
db.flush()
client.allow_auto_register = 1
client.default_entitlement_plan_id = plan.id
db.commit()
raw_body = json.dumps(
{
"externalUserId": "external-new-user",
"verifiedPhone": "13900139000",
"displayName": "新接入学员",
},
ensure_ascii=False,
separators=(",", ":"),
).encode()
timestamp = str(int(time.time()))
nonce = "auto-register-nonce-123"
ticket = SsoService.issue_ticket(
db,
app_id=client.app_id,
timestamp=timestamp,
nonce=nonce,
signature=_signature(secret, timestamp, nonce, raw_body),
raw_body=raw_body,
payload=SsoTicketRequest.model_validate_json(raw_body),
ip="127.0.0.1",
)
user = db.scalar(select(User).where(User.phone == "13900139000"))
assert user is not None
assert user.name == "新接入学员"
assert user.registration_source == "sso"
assert user.registration_client_id == client.id
entitlement = db.scalar(select(UserEntitlement).where(UserEntitlement.user_id == user.id))
assert entitlement is not None
assert entitlement.plan_id == plan.id
login = SsoService.exchange(db, code=ticket["code"], ip="127.0.0.1")
claims = decode_access_token(login["token"])
assert claims["auth_source"] == "sso"
assert claims["sso_client_id"] == client.id