fix(avatar): prevent takeover loops and isolate settings
This commit is contained in:
@@ -103,6 +103,7 @@ def test_avatar_permission_settings_default_and_persist(authorization_context):
|
||||
assert initial["data"] == {
|
||||
"avatarId": context["avatar"].id,
|
||||
"permissions": ["friend", "chat"],
|
||||
"takeoverReplyDelaySeconds": 180,
|
||||
}
|
||||
|
||||
updated = client.put(
|
||||
@@ -115,6 +116,7 @@ def test_avatar_permission_settings_default_and_persist(authorization_context):
|
||||
|
||||
reloaded = client.get(endpoint, headers=context["owner_headers"]).json()
|
||||
assert reloaded["data"]["permissions"] == ["friend", "publish", "interact", "takeover"]
|
||||
assert reloaded["data"]["takeoverReplyDelaySeconds"] == 180
|
||||
|
||||
|
||||
def test_avatar_permission_settings_allow_all_disabled(authorization_context):
|
||||
@@ -156,3 +158,57 @@ def test_avatar_permission_settings_validate_owner_and_permissions(authorization
|
||||
|
||||
unauthenticated = client.get(endpoint)
|
||||
assert unauthenticated.status_code == 401
|
||||
|
||||
|
||||
def test_takeover_delay_minimum_and_single_active_avatar_per_owner(authorization_context):
|
||||
from database import SessionLocal
|
||||
from models import Avatar
|
||||
|
||||
context = authorization_context
|
||||
endpoint = f"/api/avatar/{context['avatar'].id}/permission-settings"
|
||||
invalid = client.put(
|
||||
endpoint,
|
||||
headers=context["owner_headers"],
|
||||
json={"permissions": ["chat"], "takeoverReplyDelaySeconds": 2},
|
||||
).json()
|
||||
assert invalid["code"] == 400
|
||||
|
||||
second_avatar_id = f"second-{context['suffix']}"
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.add(
|
||||
Avatar(
|
||||
id=second_avatar_id,
|
||||
owner_id=context["owner"].huihui_user_id,
|
||||
name="第二个分身",
|
||||
status="active",
|
||||
config={"authorizationPermissions": ["chat", "takeover"]},
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
try:
|
||||
updated = client.put(
|
||||
endpoint,
|
||||
headers=context["owner_headers"],
|
||||
json={"permissions": ["chat", "takeover"], "takeoverReplyDelaySeconds": 3},
|
||||
).json()
|
||||
assert updated["code"] == 200
|
||||
assert updated["data"]["takeoverReplyDelaySeconds"] == 3
|
||||
assert updated["data"]["disabledAvatarIds"] == [second_avatar_id]
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
second = db.query(Avatar).filter(Avatar.id == second_avatar_id).one()
|
||||
assert "takeover" not in second.config["authorizationPermissions"]
|
||||
finally:
|
||||
db.close()
|
||||
finally:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.query(Avatar).filter(Avatar.id == second_avatar_id).delete()
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Ownership and configuration-isolation tests for digital avatars."""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from database import SessionLocal
|
||||
from main import app
|
||||
from models import Avatar
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_avatar_detail_and_update_require_the_owner(authorization_context):
|
||||
context = authorization_context
|
||||
avatar_id = context["avatar"].id
|
||||
|
||||
assert client.get(f"/api/avatar/{avatar_id}").status_code == 401
|
||||
assert client.get(
|
||||
f"/api/avatar/{avatar_id}", headers=context["other_headers"]
|
||||
).status_code == 403
|
||||
|
||||
updated = client.put(
|
||||
f"/api/avatar/{avatar_id}",
|
||||
headers=context["owner_headers"],
|
||||
json={
|
||||
"description": "独立描述",
|
||||
"config": {"replyStyle": "concise"},
|
||||
},
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
assert updated.json()["data"]["description"] == "独立描述"
|
||||
|
||||
forbidden = client.put(
|
||||
f"/api/avatar/{avatar_id}",
|
||||
headers=context["other_headers"],
|
||||
json={"description": "越权修改"},
|
||||
)
|
||||
assert forbidden.status_code == 403
|
||||
|
||||
|
||||
def test_avatar_config_updates_do_not_erase_takeover_or_knowledge_scope(authorization_context):
|
||||
context = authorization_context
|
||||
avatar_id = context["avatar"].id
|
||||
db = SessionLocal()
|
||||
try:
|
||||
avatar = db.query(Avatar).filter(Avatar.id == avatar_id).one()
|
||||
avatar.config = {
|
||||
"authorizationPermissions": ["chat", "takeover"],
|
||||
"takeoverReplyDelaySeconds": 180,
|
||||
}
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
response = client.put(
|
||||
f"/api/avatar/{avatar_id}",
|
||||
headers=context["owner_headers"],
|
||||
json={"config": {"replyStyle": "warm", "creativity": 25}},
|
||||
).json()
|
||||
config = response["data"]["config"]
|
||||
assert config["replyStyle"] == "warm"
|
||||
assert config["creativity"] == 25
|
||||
assert config["authorizationPermissions"] == ["chat", "takeover"]
|
||||
assert config["takeoverReplyDelaySeconds"] == 180
|
||||
|
||||
|
||||
def test_avatar_create_and_delete_require_login_and_ownership(authorization_context):
|
||||
context = authorization_context
|
||||
assert client.post("/api/avatar", json={"name": "匿名分身"}).status_code == 401
|
||||
|
||||
created = client.post(
|
||||
"/api/avatar",
|
||||
headers=context["owner_headers"],
|
||||
json={"name": "待删除分身"},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
avatar_id = created.json()["data"]["id"]
|
||||
|
||||
try:
|
||||
assert client.delete(
|
||||
f"/api/avatar/{avatar_id}", headers=context["other_headers"]
|
||||
).status_code == 403
|
||||
deleted = client.delete(
|
||||
f"/api/avatar/{avatar_id}", headers=context["owner_headers"]
|
||||
).json()
|
||||
assert deleted["code"] == 200
|
||||
finally:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.query(Avatar).filter(Avatar.id == avatar_id).delete()
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
@@ -6,7 +6,7 @@ from fastapi.testclient import TestClient
|
||||
|
||||
from database import SessionLocal
|
||||
from main import app
|
||||
from models import KnowledgeChunk, KnowledgeDoc
|
||||
from models import Avatar, KnowledgeChunk, KnowledgeDoc, QAPair
|
||||
from routers.knowledge import _doc_payload
|
||||
|
||||
|
||||
@@ -92,3 +92,94 @@ def test_markdown_upload_commits_ready_document_and_chunks_together(
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_each_avatar_has_an_independent_document_and_qa_scope(authorization_context):
|
||||
context = authorization_context
|
||||
first_avatar_id = context["avatar"].id
|
||||
second_avatar_id = f"knowledge-second-{context['suffix']}"
|
||||
first_doc_id = f"knowledge-first-doc-{context['suffix']}"
|
||||
second_doc_id = f"knowledge-second-doc-{context['suffix']}"
|
||||
first_qa_id = f"knowledge-first-qa-{context['suffix']}"
|
||||
second_qa_id = f"knowledge-second-qa-{context['suffix']}"
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.add_all(
|
||||
[
|
||||
Avatar(
|
||||
id=second_avatar_id,
|
||||
owner_id=context["owner"].huihui_user_id,
|
||||
name="独立知识库分身",
|
||||
status="active",
|
||||
config={},
|
||||
),
|
||||
KnowledgeDoc(
|
||||
id=first_doc_id,
|
||||
avatar_id=first_avatar_id,
|
||||
filename="first.md",
|
||||
status="ready",
|
||||
vectorized=True,
|
||||
),
|
||||
KnowledgeDoc(
|
||||
id=second_doc_id,
|
||||
avatar_id=second_avatar_id,
|
||||
filename="second.md",
|
||||
status="ready",
|
||||
vectorized=True,
|
||||
),
|
||||
QAPair(
|
||||
id=first_qa_id,
|
||||
avatar_id=first_avatar_id,
|
||||
question="第一个分身问题",
|
||||
answer="第一个分身答案",
|
||||
),
|
||||
QAPair(
|
||||
id=second_qa_id,
|
||||
avatar_id=second_avatar_id,
|
||||
question="第二个分身问题",
|
||||
answer="第二个分身答案",
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
try:
|
||||
first_docs = client.get(
|
||||
f"/api/avatar/{first_avatar_id}/knowledge/docs",
|
||||
headers=context["owner_headers"],
|
||||
).json()["data"]
|
||||
second_docs = client.get(
|
||||
f"/api/avatar/{second_avatar_id}/knowledge/docs",
|
||||
headers=context["owner_headers"],
|
||||
).json()["data"]
|
||||
first_qa = client.get(
|
||||
f"/api/avatar/{first_avatar_id}/knowledge/qa",
|
||||
headers=context["owner_headers"],
|
||||
).json()["data"]
|
||||
second_qa = client.get(
|
||||
f"/api/avatar/{second_avatar_id}/knowledge/qa",
|
||||
headers=context["owner_headers"],
|
||||
).json()["data"]
|
||||
|
||||
assert [item["id"] for item in first_docs if item["id"] == first_doc_id] == [first_doc_id]
|
||||
assert second_doc_id not in {item["id"] for item in first_docs}
|
||||
assert [item["id"] for item in second_docs] == [second_doc_id]
|
||||
assert first_qa_id in {item["id"] for item in first_qa}
|
||||
assert second_qa_id not in {item["id"] for item in first_qa}
|
||||
assert [item["id"] for item in second_qa] == [second_qa_id]
|
||||
finally:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.query(QAPair).filter(QAPair.id.in_([first_qa_id, second_qa_id])).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
db.query(KnowledgeDoc).filter(
|
||||
KnowledgeDoc.id.in_([first_doc_id, second_doc_id])
|
||||
).delete(synchronize_session=False)
|
||||
db.query(Avatar).filter(Avatar.id == second_avatar_id).delete()
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -13,7 +13,7 @@ def test_authorization_takeover_fields():
|
||||
assert hasattr(auth, 'takeover_delay_seconds')
|
||||
assert auth.takeover_enabled == False
|
||||
assert auth.takeover_mode == 'immediate'
|
||||
assert auth.takeover_delay_seconds == 30
|
||||
assert auth.takeover_delay_seconds == 180
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@@ -11,7 +11,12 @@ from sqlalchemy.orm import sessionmaker
|
||||
from database import Base
|
||||
from models import Avatar, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User
|
||||
from services.boxim_client import BoxIMError
|
||||
from services.takeover_service import TakeoverService, _plain_text_reply
|
||||
from services.takeover_service import (
|
||||
AVATAR_LOCAL_ID_PREFIX,
|
||||
TakeoverService,
|
||||
_avatar_local_id,
|
||||
_plain_text_reply,
|
||||
)
|
||||
|
||||
|
||||
class Clock:
|
||||
@@ -77,7 +82,10 @@ def service_context(tmp_path):
|
||||
owner_id=user.huihui_user_id,
|
||||
name="分身",
|
||||
status="active",
|
||||
config={"authorizationPermissions": ["chat", "takeover"]},
|
||||
config={
|
||||
"authorizationPermissions": ["chat", "takeover"],
|
||||
"takeoverReplyDelaySeconds": 3,
|
||||
},
|
||||
)
|
||||
db.add_all([user, avatar])
|
||||
db.commit()
|
||||
@@ -120,8 +128,7 @@ async def test_incoming_message_is_prepared_then_sent_at_three_seconds(service_c
|
||||
{"id": 11, "localId": 2, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "你好"}
|
||||
)
|
||||
|
||||
with patch("routers.chat._resolve_reply", return_value={"answer": "**你好**\n\n很高兴见到你"}):
|
||||
await service.poll_and_process_messages()
|
||||
await service.poll_and_process_messages()
|
||||
assert boxim.sent == []
|
||||
assert boxim.read_receipts == [{"friendId": "200", "messageId": "11"}]
|
||||
|
||||
@@ -130,7 +137,8 @@ async def test_incoming_message_is_prepared_then_sent_at_three_seconds(service_c
|
||||
assert boxim.sent == []
|
||||
|
||||
clock.advance(1)
|
||||
await service.poll_and_process_messages()
|
||||
with patch("routers.chat._resolve_reply", return_value={"answer": "**你好**\n\n很高兴见到你"}):
|
||||
await service.poll_and_process_messages()
|
||||
assert boxim.sent == [{"peerId": "200", "content": "你好\n很高兴见到你", "localId": boxim.sent[0]["localId"]}]
|
||||
|
||||
db = session_factory()
|
||||
@@ -142,6 +150,131 @@ async def test_incoming_message_is_prepared_then_sent_at_three_seconds(service_c
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_reply_delay_is_three_minutes(service_context):
|
||||
session_factory, service, boxim, clock = service_context
|
||||
db = session_factory()
|
||||
try:
|
||||
avatar = db.query(Avatar).one()
|
||||
avatar.config = {"authorizationPermissions": ["chat", "takeover"]}
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
await service.poll_and_process_messages()
|
||||
boxim.messages.append(
|
||||
{"id": 12, "localId": 12, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "三分钟后回复"}
|
||||
)
|
||||
await service.poll_and_process_messages()
|
||||
|
||||
db = session_factory()
|
||||
try:
|
||||
task = db.query(TakeoverReplyTask).one()
|
||||
assert task.scheduled_at == clock.now() + timedelta(seconds=180)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
clock.advance(179)
|
||||
await service.process_reply_tasks()
|
||||
assert boxim.sent == []
|
||||
clock.advance(1)
|
||||
with patch("routers.chat._resolve_reply", return_value={"answer": "好的"}):
|
||||
await service.poll_and_process_messages()
|
||||
assert [item["content"] for item in boxim.sent] == ["好的"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_avatar_origin_message_never_schedules_a_reply(service_context):
|
||||
session_factory, service, boxim, clock = service_context
|
||||
await service.poll_and_process_messages()
|
||||
local_id = _avatar_local_id("peer-owner", "peer-trigger")
|
||||
boxim.messages.append(
|
||||
{"id": 15, "localId": local_id, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "另一端分身回复"}
|
||||
)
|
||||
|
||||
with patch("routers.chat._resolve_reply") as resolver:
|
||||
await service.poll_and_process_messages()
|
||||
|
||||
resolver.assert_not_called()
|
||||
db = session_factory()
|
||||
try:
|
||||
event = db.query(TakeoverMessage).filter(TakeoverMessage.boxim_message_id == "15").one()
|
||||
assert event.is_avatar is True
|
||||
assert db.query(TakeoverReplyTask).count() == 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_peer_avatar_messages_are_excluded_from_later_human_context(service_context):
|
||||
_session_factory, service, boxim, clock = service_context
|
||||
await service.poll_and_process_messages()
|
||||
boxim.messages.append(
|
||||
{
|
||||
"id": 16,
|
||||
"localId": _avatar_local_id("peer-owner", "peer-trigger"),
|
||||
"sendId": 200,
|
||||
"recvId": 100,
|
||||
"sendTime": clock.millis(),
|
||||
"type": 0,
|
||||
"content": "分身生成的夸张长文",
|
||||
}
|
||||
)
|
||||
await service.poll_and_process_messages()
|
||||
|
||||
clock.advance(1)
|
||||
boxim.messages.append(
|
||||
{
|
||||
"id": 17,
|
||||
"localId": 17,
|
||||
"sendId": 200,
|
||||
"recvId": 100,
|
||||
"sendTime": clock.millis(),
|
||||
"type": 0,
|
||||
"content": "真人的新问题",
|
||||
}
|
||||
)
|
||||
await service.poll_and_process_messages()
|
||||
clock.advance(3)
|
||||
with patch("routers.chat._resolve_reply", return_value={"answer": "正常回复"}) as resolver:
|
||||
await service.poll_and_process_messages()
|
||||
|
||||
assert resolver.call_args.args[3] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_owner_message_pauses_future_takeover_for_ten_minutes(service_context):
|
||||
session_factory, service, boxim, clock = service_context
|
||||
await service.poll_and_process_messages()
|
||||
boxim.messages.append(
|
||||
{"id": 18, "localId": 18, "sendId": 100, "recvId": 200, "sendTime": clock.millis(), "type": 0, "content": "我先来回复"}
|
||||
)
|
||||
await service.poll_and_process_messages()
|
||||
|
||||
clock.advance(30)
|
||||
boxim.messages.append(
|
||||
{"id": 19, "localId": 19, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "收到"}
|
||||
)
|
||||
with patch("routers.chat._resolve_reply") as resolver:
|
||||
await service.poll_and_process_messages()
|
||||
|
||||
resolver.assert_not_called()
|
||||
db = session_factory()
|
||||
try:
|
||||
assert db.query(TakeoverReplyTask).count() == 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_avatar_local_id_is_deterministic_and_self_describing():
|
||||
first = _avatar_local_id("owner", "message-1")
|
||||
assert first == _avatar_local_id("owner", "message-1")
|
||||
assert first != _avatar_local_id("owner", "message-2")
|
||||
assert first.startswith(AVATAR_LOCAL_ID_PREFIX)
|
||||
assert len(first) == 18
|
||||
assert first.isdigit()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_contacts_generate_without_blocking_each_other(service_context):
|
||||
session_factory, service, boxim, clock = service_context
|
||||
@@ -158,11 +291,11 @@ async def test_different_contacts_generate_without_blocking_each_other(service_c
|
||||
both_generating.wait()
|
||||
return {"answer": f"回复{prompt[-1]}"}
|
||||
|
||||
with patch("routers.chat._resolve_reply", side_effect=resolve):
|
||||
await service.poll_and_process_messages()
|
||||
await service.poll_and_process_messages()
|
||||
|
||||
clock.advance(3)
|
||||
await service.process_reply_tasks()
|
||||
with patch("routers.chat._resolve_reply", side_effect=resolve):
|
||||
await service.poll_and_process_messages()
|
||||
assert {(item["peerId"], item["content"]) for item in boxim.sent} == {
|
||||
("200", "回复甲"),
|
||||
("300", "回复乙"),
|
||||
@@ -237,6 +370,34 @@ async def test_owner_message_cancels_pending_reply(service_context):
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_owner_message_in_final_second_wins_before_generation(service_context):
|
||||
session_factory, service, boxim, clock = service_context
|
||||
await service.poll_and_process_messages()
|
||||
boxim.messages.append(
|
||||
{"id": 23, "localId": 23, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "还在吗"}
|
||||
)
|
||||
await service.poll_and_process_messages()
|
||||
|
||||
clock.advance(2)
|
||||
boxim.messages.append(
|
||||
{"id": 24, "localId": 24, "sendId": 100, "recvId": 200, "sendTime": clock.millis(), "type": 0, "content": "我来处理"}
|
||||
)
|
||||
clock.advance(1)
|
||||
with patch("routers.chat._resolve_reply") as resolver:
|
||||
await service.poll_and_process_messages()
|
||||
|
||||
resolver.assert_not_called()
|
||||
assert boxim.sent == []
|
||||
db = session_factory()
|
||||
try:
|
||||
task = db.query(TakeoverReplyTask).filter(TakeoverReplyTask.trigger_message_id == "23").one()
|
||||
assert task.status == "cancelled"
|
||||
assert task.cancel_reason == "owner_replied"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_quick_successive_messages_are_coalesced_into_one_reply(service_context):
|
||||
session_factory, service, boxim, clock = service_context
|
||||
@@ -244,19 +405,18 @@ async def test_quick_successive_messages_are_coalesced_into_one_reply(service_co
|
||||
boxim.messages.append(
|
||||
{"id": 31, "localId": 5, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "第一句"}
|
||||
)
|
||||
with patch("routers.chat._resolve_reply", return_value={"answer": "第一版"}):
|
||||
await service.poll_and_process_messages()
|
||||
await service.poll_and_process_messages()
|
||||
|
||||
clock.advance(1)
|
||||
boxim.messages.append(
|
||||
{"id": 32, "localId": 6, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "第二句"}
|
||||
)
|
||||
await service.poll_and_process_messages()
|
||||
|
||||
clock.advance(3)
|
||||
with patch("routers.chat._resolve_reply", return_value={"answer": "合并回复"}) as resolver:
|
||||
await service.poll_and_process_messages()
|
||||
assert resolver.call_args.args[2] == "第一句\n第二句"
|
||||
|
||||
clock.advance(3)
|
||||
await service.poll_and_process_messages()
|
||||
assert [item["content"] for item in boxim.sent] == ["合并回复"]
|
||||
|
||||
db = session_factory()
|
||||
|
||||
Reference in New Issue
Block a user