mirror of
https://github.com/langgenius/dify.git
synced 2026-11-12 22:31:24 +08:00
2653 lines
114 KiB
Python
2653 lines
114 KiB
Python
"""Guest migration through real Passport verification and transactional persistence."""
|
|
|
|
import base64
|
|
import hashlib
|
|
from collections.abc import Callable, Iterator
|
|
from dataclasses import dataclass, replace
|
|
from datetime import UTC, datetime, timedelta
|
|
from unittest.mock import MagicMock
|
|
from urllib.parse import parse_qs, urlsplit
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
import sqlalchemy as sa
|
|
from flask import Flask
|
|
from flask.testing import FlaskClient
|
|
from redis.client import Pipeline
|
|
from sqlalchemy import event
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
from werkzeug.test import TestResponse
|
|
|
|
from constants.end_user_connections import ConnectionCredentialStatus, ConnectionCredentialType
|
|
from controllers.console import bp as console_bp
|
|
from controllers.console import wraps as console_wraps
|
|
from controllers.console.auth import login as console_login
|
|
from controllers.web import bp, connections, migration, passport, wraps
|
|
from core.app.app_config.entities import (
|
|
AppAdditionalFeatures,
|
|
EasyUIBasedAppConfig,
|
|
EasyUIBasedAppModelConfigFrom,
|
|
ModelConfigEntity,
|
|
PromptTemplateEntity,
|
|
)
|
|
from core.app.entities.app_invoke_entities import ChatAppGenerateEntity
|
|
from core.app.entities.app_invoke_entities import InvokeFrom as AppInvokeFrom
|
|
from extensions.ext_redis import RedisClientWrapper, redis_client
|
|
from libs.passport import PassportService
|
|
from models.account import Account, AccountStatus
|
|
from models.end_user_connection import (
|
|
AppConnection,
|
|
EndUserConnection,
|
|
EndUserConnectionRevisionAcceptance,
|
|
SessionConnection,
|
|
)
|
|
from models.enums import ConversationFromSource, CustomizeTokenStrategy, EndUserType, InvokeFrom
|
|
from models.model import App, AppMode, Conversation, EndUser, Site
|
|
from models.workflow import Workflow, WorkflowType
|
|
from repositories.account.repository import SQLAlchemyAccountRepository
|
|
from repositories.web_migration_repository import WebMigrationRepository
|
|
from repositories.web_passport_repository import WebPassportRepository
|
|
from services.app_task_service import AppTaskControlService
|
|
from services.end_user_connections.entities import WebConnectionContext
|
|
from services.end_user_connections.errors import ConnectionConflictError
|
|
from services.end_user_connections.management import ConnectionService
|
|
from services.end_user_connections.selections import ConnectionSelectionService, SaveConnectionConfigurationInput
|
|
from services.entities.passport_entities import WebPassportRequest
|
|
from services.web_identity_service import WebIdentityService
|
|
from services.web_login_handoff import WebLoginHandoffService
|
|
from services.web_login_handoff_adapters import RedisWebLoginGrantStore
|
|
from services.web_migration_service import WebMigrationService
|
|
from services.web_passport_gateways import DeploymentWebPassportAuthGateway, PassportTokenGateway
|
|
from services.web_passport_service import WebAppAuthType, WebPassportService
|
|
from tests.helpers.end_user_api_key import PROVIDER
|
|
from tests.helpers.end_user_conversation import make_selection_service
|
|
from tests.helpers.end_user_oauth import OAuthHarness, make_oauth_harness
|
|
from tests.helpers.unit.core.model_fixtures import make_model_config
|
|
|
|
|
|
@dataclass
|
|
class MigrationServices:
|
|
connection_selections: ConnectionSelectionService
|
|
web_identity: WebIdentityService
|
|
web_passport: WebPassportService
|
|
end_user_connections: ConnectionService
|
|
web_migration: WebMigrationService
|
|
web_login_handoff: WebLoginHandoffService
|
|
|
|
|
|
@dataclass
|
|
class ClaimHarness:
|
|
oauth: OAuthHarness
|
|
account: WebConnectionContext
|
|
services: MigrationServices
|
|
signer: PassportService
|
|
proof: str
|
|
guest_passport: str
|
|
client: FlaskClient
|
|
|
|
def headers(self, *, proof: str | None = None) -> dict[str, str]:
|
|
return {
|
|
"X-App-Code": self.oauth.app_code,
|
|
"X-App-Passport": self.guest_passport,
|
|
"Authorization": f"Bearer {proof or self.proof}",
|
|
}
|
|
|
|
def claim(self) -> TestResponse:
|
|
return self.client.post(
|
|
"/api/migration/claim",
|
|
headers={"Authorization": f"Bearer {self.proof}"},
|
|
json={"passports": [self.guest_passport]},
|
|
)
|
|
|
|
|
|
def account_proof(signer: PassportService, account_id: str, *, expired: bool = False) -> str:
|
|
return signer.issue(
|
|
{
|
|
"sub": "Web API Account Login",
|
|
"token_source": "webapp_account_login",
|
|
"auth_type": "internal",
|
|
"user_id": account_id,
|
|
"exp": 1 if expired else int((datetime.now(UTC) + timedelta(hours=1)).timestamp()),
|
|
}
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def harness(
|
|
sqlite_session_factory: sessionmaker[Session],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
config_overrides: Callable[..., None],
|
|
redis_transport: tuple[RedisClientWrapper, MagicMock],
|
|
) -> Iterator[ClaimHarness]:
|
|
config_overrides(
|
|
SECRET_KEY="guest-connection-claim-test-key-with-32-bytes",
|
|
END_USER_AUTH_ALLOW_ANONYMOUS=True,
|
|
WEBAPP_ACCOUNT_PASSPORT_ENABLED=True,
|
|
APP_WEB_URL="https://apps.example.net",
|
|
CONSOLE_WEB_URL="https://console.example.com",
|
|
CONSOLE_API_URL="https://console.example.com",
|
|
COOKIE_DOMAIN="",
|
|
)
|
|
from services import app_task_service, web_migration_service
|
|
|
|
def execute_pipeline(pipeline: "Pipeline[bytes]") -> list[object]:
|
|
for command, _options in pipeline.command_stack:
|
|
redis_transport[1](*command)
|
|
pipeline.command_stack.clear()
|
|
return []
|
|
|
|
monkeypatch.setattr(Pipeline, "execute", execute_pipeline)
|
|
monkeypatch.setattr(type(app_task_service.db), "engine", property(lambda _: sqlite_session_factory.kw["bind"]))
|
|
oauth = make_oauth_harness(sqlite_session_factory, monkeypatch)
|
|
|
|
def finish_cancelled_runs(_delay: float) -> None:
|
|
from graphon.enums import WorkflowExecutionStatus
|
|
from models.workflow import WorkflowRun
|
|
|
|
with sqlite_session_factory.begin() as session:
|
|
session.execute(
|
|
sa.update(WorkflowRun)
|
|
.where(
|
|
WorkflowRun.status == WorkflowExecutionStatus.RUNNING,
|
|
WorkflowRun.stop_requested_at.is_not(None),
|
|
)
|
|
.values(status=WorkflowExecutionStatus.STOPPED, finished_at=datetime.now(UTC))
|
|
)
|
|
|
|
monkeypatch.setattr(web_migration_service, "sleep", finish_cancelled_runs)
|
|
account = oauth.base.context
|
|
with sqlite_session_factory.begin() as session:
|
|
session.execute(sa.update(App).where(App.id == account.app_id).values(mode=AppMode.ADVANCED_CHAT))
|
|
guest = EndUser(
|
|
tenant_id=account.tenant_id,
|
|
app_id=account.app_id,
|
|
type=EndUserType.BROWSER,
|
|
account_id=None,
|
|
session_id=str(uuid4()),
|
|
_is_anonymous=True,
|
|
)
|
|
session.add(guest)
|
|
session.flush()
|
|
guest_id, guest_session = guest.id, guest.session_id
|
|
oauth.base.context = replace(account, end_user_id=guest_id, account_id=None)
|
|
signer = PassportService()
|
|
identity = WebIdentityService(
|
|
accounts=SQLAlchemyAccountRepository(sqlite_session_factory), allow_console_login=True, allow_webapp_login=False
|
|
)
|
|
tokens = PassportTokenGateway(passport=signer)
|
|
passports = WebPassportService(
|
|
passports=WebPassportRepository(
|
|
session_factory=sqlite_session_factory, generate_session_id=lambda: str(uuid4())
|
|
),
|
|
auth=DeploymentWebPassportAuthGateway(
|
|
webapp_auth_enabled=False,
|
|
get_app_access_mode=lambda _: pytest.fail("No Cloud App ACL"),
|
|
is_user_allowed=lambda _user, _app: pytest.fail("No Cloud App ACL"),
|
|
),
|
|
tokens=tokens,
|
|
identities=identity,
|
|
account_passports_enabled=True,
|
|
now=lambda: datetime.now(UTC),
|
|
access_token_expire_minutes=60,
|
|
)
|
|
services = MigrationServices(
|
|
connection_selections=make_selection_service(oauth),
|
|
web_identity=identity,
|
|
web_passport=passports,
|
|
end_user_connections=oauth.base.service,
|
|
web_migration=WebMigrationService(
|
|
store=WebMigrationRepository(session_factory=sqlite_session_factory),
|
|
passports=passports,
|
|
tokens=tokens,
|
|
identities=identity,
|
|
tasks=AppTaskControlService(redis_client=redis_transport[0]),
|
|
),
|
|
web_login_handoff=WebLoginHandoffService(
|
|
grants=RedisWebLoginGrantStore(redis=redis_client),
|
|
apps=WebPassportRepository(
|
|
session_factory=sqlite_session_factory, generate_session_id=lambda: str(uuid4())
|
|
),
|
|
identities=identity,
|
|
tokens=tokens,
|
|
enabled=True,
|
|
app_web_url="https://apps.example.net",
|
|
console_web_url="https://console.example.com",
|
|
now=lambda: datetime.now(UTC),
|
|
),
|
|
)
|
|
for module in (wraps, migration, connections, passport, console_login):
|
|
monkeypatch.setattr(module, "application_services", lambda: services)
|
|
monkeypatch.setattr(wraps.SystemFeatureService, "is_webapp_auth_enabled", lambda: False)
|
|
monkeypatch.setattr(console_wraps, "_is_setup_completed", lambda: True)
|
|
guest_token = passports.issue(WebPassportRequest(oauth.app_code, guest_session, None)).access_token
|
|
app = Flask(__name__)
|
|
app.config["TESTING"] = True
|
|
app.register_blueprint(bp)
|
|
app.register_blueprint(console_bp)
|
|
assert account.account_id is not None
|
|
with app.test_client() as client:
|
|
yield ClaimHarness(
|
|
oauth, account, services, signer, account_proof(signer, account.account_id), guest_token, client
|
|
)
|
|
|
|
|
|
def save_choices(
|
|
harness: ClaimHarness, identity: WebConnectionContext, connection_id: str, conversation_id: str | None = None
|
|
) -> None:
|
|
harness.services.connection_selections.save(
|
|
identity,
|
|
SaveConnectionConfigurationInput.model_validate(
|
|
{
|
|
"connection_revision": harness.oauth.base.connection_revision,
|
|
"conversation_id": conversation_id,
|
|
"connections": {"tool-1": connection_id},
|
|
}
|
|
),
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("credential_type", ["api-key", "oauth2"])
|
|
@pytest.mark.parametrize("account_defaults", [False, True])
|
|
def test_claim_preserves_credentials_ids_and_independent_choices(
|
|
harness: ClaimHarness, credential_type: str, account_defaults: bool, config_overrides: Callable[..., None]
|
|
) -> None:
|
|
base = harness.oauth.base
|
|
existing = base.service.create_api_key(harness.account, base.command())
|
|
if account_defaults:
|
|
save_choices(harness, harness.account, existing.id)
|
|
first = (
|
|
harness.oauth.finish(harness.oauth.start())
|
|
if credential_type == "oauth2"
|
|
else base.service.create_api_key(base.context, base.command())
|
|
)
|
|
second = base.service.create_api_key(base.context, base.command("second-secret"))
|
|
with base.sessions.begin() as session:
|
|
conversations = [
|
|
Conversation(
|
|
app_id=base.context.app_id,
|
|
mode=AppMode.ADVANCED_CHAT,
|
|
name="Guest chat",
|
|
from_source=ConversationFromSource.API,
|
|
from_end_user_id=base.context.end_user_id,
|
|
from_account_id=None,
|
|
invoke_from=InvokeFrom.WEB_APP,
|
|
_inputs={},
|
|
is_deleted=False,
|
|
)
|
|
for _ in range(2)
|
|
]
|
|
session.add_all(conversations)
|
|
session.flush()
|
|
ids = [row.id for row in conversations]
|
|
save_choices(harness, base.context, first.id, ids[0])
|
|
save_choices(harness, base.context, second.id, ids[1])
|
|
with base.sessions() as session:
|
|
before = {
|
|
row.id: (
|
|
row.encrypted_credentials,
|
|
row.revision,
|
|
row.last_active_at,
|
|
row.oauth_client_id,
|
|
row.account_label,
|
|
row.oauth_client_fingerprint,
|
|
row.oauth_plugin_unique_identifier,
|
|
)
|
|
for row in session.scalars(sa.select(EndUserConnection))
|
|
if row.id in (first.id, second.id)
|
|
}
|
|
session_refs = [
|
|
(row.id, row.session_id, row.connection_id) for row in session.scalars(sa.select(SessionConnection))
|
|
]
|
|
checks, exchanges = len(base.auth.calls), harness.oauth.handler.exchanges
|
|
config_overrides(END_USER_AUTH_ALLOW_ANONYMOUS=False)
|
|
preview = harness.client.post("/api/migration/claimable-passports", json={"passports": [harness.guest_passport]})
|
|
assert preview.status_code == 200
|
|
assert preview.get_json()["data"][0]["connection_count"] == 2
|
|
response = harness.claim()
|
|
assert response.status_code == 200
|
|
assert response.headers["Cache-Control"] == "no-store"
|
|
assert response.get_json()["data"][0]["claimed_connections"] == 2
|
|
assert set(response.get_json()) == {"data"}
|
|
assert response.get_json()["data"][0]["status"] == "success"
|
|
claims = harness.signer.verify(response.get_json()["data"][0]["access_token"])
|
|
assert claims["end_user_id"] == harness.account.end_user_id
|
|
assert claims["end_user_id"] != base.context.end_user_id
|
|
assert harness.account.account_id not in claims.values()
|
|
assert len(base.auth.calls) == checks
|
|
assert harness.oauth.handler.exchanges == exchanges
|
|
with base.sessions() as session:
|
|
for connection_id, (ciphertext, revision, activity, client_id, label, fingerprint, artifact) in before.items():
|
|
row = session.get(EndUserConnection, connection_id)
|
|
assert row is not None
|
|
assert (row.account_id, row.end_user_id) == (harness.account.account_id, None)
|
|
assert (
|
|
row.encrypted_credentials,
|
|
row.revision,
|
|
row.last_active_at,
|
|
row.oauth_client_id,
|
|
row.account_label,
|
|
row.oauth_client_fingerprint,
|
|
row.oauth_plugin_unique_identifier,
|
|
) == (ciphertext, revision + 1, activity, client_id, label, fingerprint, artifact)
|
|
assert [
|
|
(row.id, row.session_id, row.connection_id) for row in session.scalars(sa.select(SessionConnection))
|
|
] == session_refs
|
|
for conversation_id in ids:
|
|
conversation = session.get(Conversation, conversation_id)
|
|
assert conversation is not None
|
|
assert conversation.from_end_user_id == harness.account.end_user_id
|
|
defaults = list(session.scalars(sa.select(AppConnection)))
|
|
assert len(defaults) == 1
|
|
assert defaults[0].end_user_id == harness.account.end_user_id
|
|
assert defaults[0].connection_id == (existing.id if account_defaults else second.id)
|
|
assert base.service.get_connection(harness.account, first.id).id == first.id
|
|
assert harness.claim().get_json()["data"][0]["claimed_connections"] == 0
|
|
config_overrides(END_USER_AUTH_ALLOW_ANONYMOUS=True)
|
|
denied = harness.client.get(f"/api/connections/{first.id}", headers=harness.headers())
|
|
assert denied.status_code == 404
|
|
assert denied.get_json()["code"] == "connection_not_found"
|
|
assert harness.client.get("/api/connections", headers=harness.headers()).get_json()["data"] == []
|
|
assert harness.client.get("/api/connections/configuration", headers=harness.headers()).status_code == 200
|
|
created = base.service.create_api_key(base.context, base.command("new-guest-secret"))
|
|
assert base.service.get_connection(base.context, created.id).id == created.id
|
|
with base.sessions() as session:
|
|
stored = session.get(EndUserConnection, first.id)
|
|
assert stored is not None
|
|
assert stored.revision == before[first.id][1] + 1
|
|
|
|
|
|
def test_retries_and_other_accounts_cannot_reclaim_transferred_connections(harness: ClaimHarness) -> None:
|
|
base = harness.oauth.base
|
|
with base.sessions.begin() as session:
|
|
other = Account(name="Other", email=f"{uuid4()}@example.com", status=AccountStatus.ACTIVE)
|
|
session.add(other)
|
|
session.flush()
|
|
other_id = other.id
|
|
guest = EndUser(
|
|
tenant_id=base.context.tenant_id,
|
|
app_id=base.context.app_id,
|
|
type=EndUserType.BROWSER,
|
|
account_id=None,
|
|
session_id=str(uuid4()),
|
|
)
|
|
session.add(guest)
|
|
session.flush()
|
|
other_guest = replace(base.context, end_user_id=guest.id)
|
|
untouched = base.service.create_api_key(other_guest, base.command())
|
|
transferred = base.service.create_api_key(base.context, base.command())
|
|
assert harness.claim().get_json()["data"][0]["claimed_connections"] == 1
|
|
assert harness.claim().get_json()["data"][0]["claimed_connections"] == 0
|
|
response = harness.client.post(
|
|
"/api/migration/claim",
|
|
json={"passports": [harness.guest_passport]},
|
|
headers={"Authorization": f"Bearer {account_proof(harness.signer, other_id)}"},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.get_json()["data"][0]["claimed_connections"] == 0
|
|
assert base.service.get_connection(harness.account, transferred.id).id == transferred.id
|
|
assert base.service.get_connection(other_guest, untouched.id).id == untouched.id
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"failure",
|
|
[
|
|
"missing_account",
|
|
"expired_account",
|
|
"unavailable_account",
|
|
"guest_as_account",
|
|
"console_as_account",
|
|
"expired_guest",
|
|
"account_as_guest",
|
|
"wrong_app",
|
|
"authority_payload",
|
|
"handoff_payload",
|
|
],
|
|
)
|
|
def test_claim_requires_both_valid_scoped_identities(harness: ClaimHarness, failure: str) -> None:
|
|
headers = harness.headers()
|
|
passports = [harness.guest_passport]
|
|
payload: dict[str, object] = {"passports": passports}
|
|
base = harness.oauth.base
|
|
selected = base.service.create_api_key(base.context, base.command())
|
|
if failure == "missing_account":
|
|
headers.pop("Authorization")
|
|
elif failure == "expired_account":
|
|
headers["Authorization"] = (
|
|
f"Bearer {account_proof(harness.signer, harness.account.account_id or '', expired=True)}"
|
|
)
|
|
elif failure == "unavailable_account":
|
|
with base.sessions.begin() as session:
|
|
session.execute(
|
|
sa.update(Account).where(Account.id == harness.account.account_id).values(status=AccountStatus.BANNED)
|
|
)
|
|
elif failure == "guest_as_account":
|
|
headers["Authorization"] = f"Bearer {harness.guest_passport}"
|
|
elif failure == "console_as_account":
|
|
token = harness.signer.issue(
|
|
{
|
|
"sub": "Console API Passport",
|
|
"user_id": harness.account.account_id,
|
|
"exp": int((datetime.now(UTC) + timedelta(hours=1)).timestamp()),
|
|
}
|
|
)
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
elif failure == "expired_guest":
|
|
claims = harness.signer.verify(harness.guest_passport)
|
|
claims["exp"] = 1
|
|
passports[0] = harness.signer.issue(claims)
|
|
elif failure == "account_as_guest":
|
|
passports[0] = harness.services.web_passport.issue_for_account(
|
|
app_code=harness.oauth.app_code, access_token=harness.proof
|
|
).access_token
|
|
elif failure == "wrong_app":
|
|
passports[0] = harness.signer.issue({**harness.signer.verify(harness.guest_passport), "app_code": "other-app"})
|
|
elif failure == "handoff_payload":
|
|
payload.update(login_code="untrusted-secret", code_verifier="v" * 43)
|
|
else:
|
|
payload["account_id"] = "untrusted-secret"
|
|
response = harness.client.post("/api/migration/claim", headers=headers, json=payload)
|
|
if failure in {"authority_payload", "handoff_payload"}:
|
|
assert response.status_code == 422
|
|
elif failure in {"expired_guest", "account_as_guest", "wrong_app"}:
|
|
assert response.status_code == 400
|
|
assert response.get_json()["code"] == "connection_claim_preflight_failed"
|
|
item = response.get_json()["details"]["failures"][0]
|
|
assert item["status"] == "failed"
|
|
assert item["error"]["status"] == (404 if failure == "wrong_app" else 401)
|
|
else:
|
|
assert response.status_code == 401
|
|
assert harness.proof not in response.get_data(as_text=True)
|
|
assert base.service.get_connection(base.context, selected.id).id == selected.id
|
|
|
|
|
|
def test_guest_check_cannot_write_credentials_after_claim(harness: ClaimHarness) -> None:
|
|
base = harness.oauth.base
|
|
selected = base.service.create_api_key(base.context, base.command())
|
|
|
|
def claim_during_check() -> None:
|
|
harness.services.web_migration.claim([harness.guest_passport], access_token=harness.proof)
|
|
|
|
base.auth.on_check = claim_during_check
|
|
with pytest.raises(ConnectionConflictError):
|
|
base.service.check_connection_result(base.context, selected.id, connection_revision=base.connection_revision)
|
|
assert base.service.get_connection(harness.account, selected.id).id == selected.id
|
|
|
|
|
|
def test_claim_rolls_back_owner_and_default_changes_together(harness: ClaimHarness) -> None:
|
|
from models.model import UploadFile
|
|
|
|
base = harness.oauth.base
|
|
selected = base.service.create_api_key(base.context, base.command())
|
|
conversation_id, _, _, upload_id = persist_migration_history(harness, base.context)
|
|
save_choices(harness, base.context, selected.id, conversation_id)
|
|
engine = base.sessions.kw["bind"]
|
|
|
|
def fail_defaults(
|
|
_connection: object, _cursor: object, statement: str, _parameters: object, _context: object, _executemany: bool
|
|
) -> None:
|
|
if statement.startswith("UPDATE app_connections"):
|
|
raise RuntimeError("defaults unavailable")
|
|
|
|
event.listen(engine, "before_cursor_execute", fail_defaults)
|
|
try:
|
|
response = harness.claim()
|
|
assert response.status_code == 200
|
|
item = response.get_json()["data"][0]
|
|
assert item["status"] == "failed"
|
|
assert item["error"]["code"] == "connection_claim_unavailable"
|
|
assert item["error"]["status"] == 503
|
|
assert "defaults unavailable" not in response.get_data(as_text=True)
|
|
finally:
|
|
event.remove(engine, "before_cursor_execute", fail_defaults)
|
|
assert base.service.get_connection(base.context, selected.id).id == selected.id
|
|
with base.sessions() as session:
|
|
assert session.scalar(sa.select(AppConnection.end_user_id)) == base.context.end_user_id
|
|
assert (
|
|
session.scalar(sa.select(Conversation.from_end_user_id).where(Conversation.id == conversation_id))
|
|
== base.context.end_user_id
|
|
)
|
|
assert (
|
|
session.scalar(sa.select(UploadFile.created_by).where(UploadFile.id == upload_id))
|
|
== base.context.end_user_id
|
|
)
|
|
|
|
|
|
def test_login_is_exchanged_before_delayed_claim_confirmation(
|
|
harness: ClaimHarness, redis_transport: tuple[RedisClientWrapper, MagicMock]
|
|
) -> None:
|
|
redis, commands = redis_transport
|
|
records: dict[str, str] = {}
|
|
|
|
def execute(command: str, *args: object, **_kwargs: object) -> object:
|
|
key = str(args[0])
|
|
if command == "SET":
|
|
records[key] = str(args[1])
|
|
return True
|
|
if command == "GET":
|
|
return records.get(key)
|
|
if command == "GETDEL":
|
|
return records.pop(key, None)
|
|
raise AssertionError(command)
|
|
|
|
commands.side_effect = execute
|
|
services = harness.services
|
|
clock = [datetime.now(UTC)]
|
|
services.web_login_handoff = WebLoginHandoffService(
|
|
grants=RedisWebLoginGrantStore(redis=redis),
|
|
apps=WebPassportRepository(
|
|
session_factory=harness.oauth.base.sessions, generate_session_id=lambda: str(uuid4())
|
|
),
|
|
identities=services.web_identity,
|
|
tokens=PassportTokenGateway(passport=harness.signer),
|
|
enabled=True,
|
|
app_web_url="https://apps.example.net",
|
|
console_web_url="https://console.example.com",
|
|
now=lambda: clock[0],
|
|
)
|
|
console_token = harness.signer.issue(
|
|
{
|
|
"sub": "Console API Passport",
|
|
"user_id": harness.account.account_id,
|
|
"exp": int((datetime.now(UTC) + timedelta(hours=1)).timestamp()),
|
|
}
|
|
)
|
|
harness.client.set_cookie("__Host-access_token", console_token, domain="console.example.com", secure=True)
|
|
base = harness.oauth.base
|
|
selected = base.service.create_api_key(base.context, base.command())
|
|
with base.sessions.begin() as session:
|
|
app = App(
|
|
tenant_id=base.context.tenant_id, name="Login App", mode=AppMode.WORKFLOW, enable_site=True, enable_api=True
|
|
)
|
|
session.add(app)
|
|
session.flush()
|
|
workflow = Workflow(
|
|
tenant_id=app.tenant_id,
|
|
app_id=app.id,
|
|
type=WorkflowType.WORKFLOW,
|
|
version="published",
|
|
graph='{"nodes": [], "edges": []}',
|
|
_features="{}",
|
|
created_by=harness.account.account_id or "",
|
|
)
|
|
login_app_code = f"login-{uuid4()}"
|
|
session.add_all(
|
|
[
|
|
workflow,
|
|
Site(
|
|
app_id=app.id,
|
|
code=login_app_code,
|
|
title="Login App",
|
|
default_language="en-US",
|
|
customize_token_strategy=CustomizeTokenStrategy.NOT_ALLOW,
|
|
),
|
|
]
|
|
)
|
|
session.flush()
|
|
app.workflow_id = workflow.id
|
|
verifier = "v" * 43
|
|
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
|
|
response = harness.client.get(
|
|
"/console/api/login",
|
|
base_url="https://console.example.com",
|
|
query_string={
|
|
"app_code": login_app_code,
|
|
"redirect_url": f"https://apps.example.net/workflow/{login_app_code}",
|
|
"code_challenge": challenge,
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
code = parse_qs(urlsplit(response.get_json()["redirect_url"]).query)["login_code"][0]
|
|
login_payload = {"login_code": code, "code_verifier": verifier}
|
|
response = harness.client.post(
|
|
"/api/passport", base_url="https://apps.example.net", headers={"X-App-Code": login_app_code}, json=login_payload
|
|
)
|
|
assert response.status_code == 200
|
|
assert set(response.get_json()) == {"access_token"}
|
|
login_cookie = harness.client.get_cookie("__Host-webapp_access_token", domain="apps.example.net")
|
|
assert login_cookie is not None
|
|
assert login_cookie.http_only
|
|
assert login_cookie.secure
|
|
assert harness.signer.verify(login_cookie.value)["token_source"] == "webapp_account_login"
|
|
assert harness.signer.verify(response.get_json()["access_token"])["app_code"] == login_app_code
|
|
assert harness.client.get_cookie("__Host-access_token", domain="apps.example.net") is None
|
|
assert base.service.get_connection(base.context, selected.id).id == selected.id
|
|
clock[0] += timedelta(minutes=5)
|
|
response = harness.client.post(
|
|
"/api/migration/claim",
|
|
base_url="https://apps.example.net",
|
|
json={"passports": [harness.guest_passport, harness.guest_passport]},
|
|
)
|
|
assert response.status_code == 200
|
|
assert set(response.get_json()) == {"data"}
|
|
assert [item["status"] for item in response.get_json()["data"]] == ["success", "success"]
|
|
assert [item["claimed_connections"] for item in response.get_json()["data"]] == [1, 0]
|
|
assert (
|
|
harness.signer.verify(response.get_json()["data"][0]["access_token"])["end_user_id"]
|
|
== harness.account.end_user_id
|
|
)
|
|
assert base.service.get_connection(harness.account, selected.id).id == selected.id
|
|
replay = harness.client.post(
|
|
"/api/passport", base_url="https://apps.example.net", headers={"X-App-Code": login_app_code}, json=login_payload
|
|
)
|
|
assert replay.status_code == 400
|
|
|
|
|
|
def test_claimed_oauth_connection_refreshes_for_account_without_reauthorization(harness: ClaimHarness) -> None:
|
|
selected = harness.oauth.finish(harness.oauth.start())
|
|
assert harness.claim().status_code == 200
|
|
with harness.oauth.base.sessions.begin() as session:
|
|
session.execute(
|
|
sa.update(EndUserConnection)
|
|
.where(EndUserConnection.id == selected.id)
|
|
.values(expires_at=datetime(2000, 1, 1))
|
|
)
|
|
checked = harness.oauth.base.service.check_connection_result(
|
|
harness.account,
|
|
selected.id,
|
|
connection_revision=harness.oauth.base.service.get_connection_revision(
|
|
harness.account, selected.id
|
|
).connection_revision,
|
|
)
|
|
assert checked.view.connection_id == selected.id
|
|
assert checked.view.check.status == "valid"
|
|
assert harness.oauth.handler.exchanges == 1
|
|
assert len(harness.oauth.handler.refreshes) == 1
|
|
|
|
|
|
def test_claim_creates_account_app_identity_on_first_visit(harness: ClaimHarness) -> None:
|
|
base = harness.oauth.base
|
|
selected = base.service.create_api_key(base.context, base.command())
|
|
with base.sessions.begin() as session:
|
|
session.execute(sa.delete(EndUser).where(EndUser.id == harness.account.end_user_id))
|
|
response = harness.claim()
|
|
assert response.status_code == 200
|
|
target_id = harness.signer.verify(response.get_json()["data"][0]["access_token"])["end_user_id"]
|
|
assert target_id != harness.account.end_user_id
|
|
assert target_id != base.context.end_user_id
|
|
with base.sessions() as session:
|
|
target = session.get(EndUser, target_id)
|
|
assert target is not None
|
|
assert target.account_id == harness.account.account_id
|
|
assert target.app_id == base.context.app_id
|
|
record = session.get(EndUserConnection, selected.id)
|
|
assert record is not None
|
|
assert record.account_id == target.account_id
|
|
assert (
|
|
session.scalar(
|
|
sa.select(sa.func.count())
|
|
.select_from(EndUserConnectionRevisionAcceptance)
|
|
.where(EndUserConnectionRevisionAcceptance.end_user_id == target.id)
|
|
)
|
|
== 0
|
|
)
|
|
guest_acceptance_count = session.scalar(
|
|
sa.select(sa.func.count())
|
|
.select_from(EndUserConnectionRevisionAcceptance)
|
|
.where(EndUserConnectionRevisionAcceptance.end_user_id == base.context.end_user_id)
|
|
)
|
|
assert guest_acceptance_count is not None
|
|
assert guest_acceptance_count > 0
|
|
|
|
|
|
def test_discovery_and_batch_claim_are_scoped_across_apps(
|
|
harness: ClaimHarness, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
base = harness.oauth.base
|
|
selected = base.service.create_api_key(base.context, base.command())
|
|
with base.sessions.begin() as session:
|
|
app = App(tenant_id=str(uuid4()), name="Another App", mode=AppMode.WORKFLOW, enable_site=True, enable_api=True)
|
|
session.add(app)
|
|
session.flush()
|
|
site = Site(
|
|
app_id=app.id,
|
|
code=f"claim-{uuid4()}",
|
|
title="Another App",
|
|
default_language="en-US",
|
|
customize_token_strategy=CustomizeTokenStrategy.NOT_ALLOW,
|
|
)
|
|
workflow = Workflow(
|
|
tenant_id=app.tenant_id,
|
|
app_id=app.id,
|
|
type=WorkflowType.WORKFLOW,
|
|
version="published",
|
|
graph='{"nodes": [], "edges": []}',
|
|
_features="{}",
|
|
created_by=harness.account.account_id or "",
|
|
)
|
|
session.add(workflow)
|
|
session.flush()
|
|
app.workflow_id = workflow.id
|
|
guest = EndUser(tenant_id=app.tenant_id, app_id=app.id, type=EndUserType.BROWSER, session_id=str(uuid4()))
|
|
session.add_all([site, guest])
|
|
session.flush()
|
|
session.add(
|
|
EndUserConnection(
|
|
tenant_id=app.tenant_id,
|
|
end_user_id=guest.id,
|
|
account_id=None,
|
|
provider=PROVIDER,
|
|
name="Other Guest connection",
|
|
credential_type=ConnectionCredentialType.API_KEY,
|
|
encrypted_credentials="other-ciphertext",
|
|
status=ConnectionCredentialStatus.CONNECTED,
|
|
oauth_client_source=None,
|
|
oauth_client_configuration_id=None,
|
|
oauth_client_id=None,
|
|
expires_at=None,
|
|
)
|
|
)
|
|
other_token = harness.signer.issue(
|
|
{"sub": "Web API Passport", "app_id": app.id, "app_code": site.code, "end_user_id": guest.id}
|
|
)
|
|
other_code = site.code
|
|
before = session.scalar(sa.select(sa.func.count()).select_from(EndUser))
|
|
claims = harness.signer.verify(harness.guest_passport)
|
|
expired = harness.signer.issue({**claims, "exp": 1})
|
|
wrong_scope = harness.signer.issue({**claims, "app_code": other_code})
|
|
payload = {
|
|
"passports": [
|
|
"invalid",
|
|
expired,
|
|
harness.proof,
|
|
wrong_scope,
|
|
harness.guest_passport,
|
|
other_token,
|
|
harness.guest_passport,
|
|
]
|
|
}
|
|
response = harness.client.post("/api/migration/claimable-passports", json=payload)
|
|
assert response.status_code == 200
|
|
assert response.headers["Cache-Control"] == "no-store"
|
|
data = response.get_json()["data"]
|
|
assert data == [
|
|
{
|
|
"index": 4,
|
|
"app_code": harness.oauth.app_code,
|
|
"app_name": "App",
|
|
"connection_count": 1,
|
|
"conversation_count": 0,
|
|
},
|
|
{"index": 5, "app_code": other_code, "app_name": "Another App", "connection_count": 1, "conversation_count": 0},
|
|
]
|
|
assert harness.guest_passport not in response.get_data(as_text=True)
|
|
with base.sessions() as session:
|
|
assert session.scalar(sa.select(sa.func.count()).select_from(EndUser)) == before
|
|
assert base.service.get_connection(base.context, selected.id).id == selected.id
|
|
response = harness.client.post(
|
|
"/api/migration/claim",
|
|
headers={"Authorization": f"Bearer {harness.proof}"},
|
|
json={"passports": [harness.guest_passport, "invalid", other_token, expired]},
|
|
)
|
|
assert response.status_code == 400
|
|
assert response.get_json()["code"] == "connection_claim_preflight_failed"
|
|
failures = response.get_json()["details"]["failures"]
|
|
assert [item["index"] for item in failures] == [1, 3]
|
|
assert all(item["error"]["code"] == "unauthorized" for item in failures)
|
|
assert base.service.get_connection(base.context, selected.id).id == selected.id
|
|
with base.sessions() as session:
|
|
assert session.scalar(sa.select(sa.func.count()).select_from(EndUser)) == before
|
|
|
|
auth = harness.services.web_passport._auth
|
|
with monkeypatch.context() as patch:
|
|
patch.setattr(auth, "is_webapp_auth_enabled", lambda: True)
|
|
patch.setattr(
|
|
auth,
|
|
"get_app_auth_type",
|
|
lambda app_id: WebAppAuthType.PUBLIC if app_id == base.context.app_id else WebAppAuthType.INTERNAL,
|
|
)
|
|
patch.setattr(auth, "is_account_allowed", lambda _app, _account: False)
|
|
response = harness.client.post(
|
|
"/api/migration/claim",
|
|
headers={"Authorization": f"Bearer {harness.proof}"},
|
|
json={"passports": [harness.guest_passport, other_token]},
|
|
)
|
|
assert response.status_code == 400
|
|
failures = response.get_json()["details"]["failures"]
|
|
assert len(failures) == 1
|
|
assert failures[0]["index"] == 1
|
|
assert failures[0]["error"]["code"] == "web_app_access_denied"
|
|
assert base.service.get_connection(base.context, selected.id).id == selected.id
|
|
with base.sessions() as session:
|
|
assert session.scalar(sa.select(sa.func.count()).select_from(EndUser)) == before
|
|
|
|
response = harness.client.post(
|
|
"/api/migration/claim",
|
|
headers={"Authorization": f"Bearer {harness.proof}"},
|
|
json={"passports": [harness.guest_passport, other_token, harness.guest_passport]},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.headers["Cache-Control"] == "no-store"
|
|
items = response.get_json()["data"]
|
|
assert [item["index"] for item in items] == [0, 1, 2]
|
|
assert all(item["status"] == "success" for item in items)
|
|
for index, app_code, count in ((0, harness.oauth.app_code, 1), (1, other_code, 1), (2, harness.oauth.app_code, 0)):
|
|
assert items[index]["app_code"] == app_code
|
|
assert items[index]["claimed_connections"] == count
|
|
assert harness.signer.verify(items[index]["access_token"])["app_code"] == app_code
|
|
assert base.service.get_connection(harness.account, selected.id).id == selected.id
|
|
with base.sessions() as session:
|
|
other = session.scalar(
|
|
sa.select(EndUserConnection).where(EndUserConnection.tenant_id != base.context.tenant_id)
|
|
)
|
|
assert other is not None
|
|
assert other.account_id == harness.account.account_id
|
|
assert other.end_user_id is None
|
|
assert harness.client.post(
|
|
"/api/migration/claimable-passports", json={"passports": [harness.guest_passport]}
|
|
).get_json() == {
|
|
"data": [
|
|
{
|
|
"index": 0,
|
|
"app_code": harness.oauth.app_code,
|
|
"app_name": "App",
|
|
"connection_count": 0,
|
|
"conversation_count": 0,
|
|
}
|
|
]
|
|
}
|
|
|
|
|
|
def test_zero_connection_guest_migrates_conversations(harness: ClaimHarness) -> None:
|
|
base = harness.oauth.base
|
|
with base.sessions.begin() as session:
|
|
conversation = Conversation(
|
|
app_id=base.context.app_id,
|
|
mode=AppMode.ADVANCED_CHAT,
|
|
name="Guest chat",
|
|
from_source=ConversationFromSource.API,
|
|
from_end_user_id=base.context.end_user_id,
|
|
from_account_id=None,
|
|
invoke_from=InvokeFrom.WEB_APP,
|
|
_inputs={},
|
|
is_deleted=False,
|
|
)
|
|
session.add(conversation)
|
|
session.flush()
|
|
conversation_id = conversation.id
|
|
payload = {"passports": [harness.guest_passport, harness.guest_passport]}
|
|
expected = {
|
|
"data": [
|
|
{
|
|
"index": 0,
|
|
"app_code": harness.oauth.app_code,
|
|
"app_name": "App",
|
|
"connection_count": 0,
|
|
"conversation_count": 1,
|
|
}
|
|
]
|
|
}
|
|
response = harness.client.post("/api/migration/claimable-passports", json=payload)
|
|
assert response.status_code == 200
|
|
assert response.headers["Cache-Control"] == "no-store"
|
|
assert response.get_json() == expected
|
|
result = harness.claim()
|
|
assert result.status_code == 200
|
|
assert result.get_json()["data"][0]["claimed_connections"] == 0
|
|
assert result.get_json()["data"][0]["claimed_conversations"] == 1
|
|
expected["data"][0]["conversation_count"] = 0
|
|
assert harness.client.post("/api/migration/claimable-passports", json=payload).get_json() == expected
|
|
with base.sessions() as session:
|
|
stored = session.get(Conversation, conversation_id)
|
|
assert stored is not None
|
|
assert stored.from_end_user_id == harness.account.end_user_id
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"unavailable",
|
|
[
|
|
"account",
|
|
"sso",
|
|
"disabled_app",
|
|
"removed_app",
|
|
"removed_site",
|
|
"app_status",
|
|
"site_status",
|
|
"unpublished",
|
|
"draft_workflow",
|
|
"missing_workflow",
|
|
"wrong_workflow_app",
|
|
"wrong_workflow_tenant",
|
|
"unsupported_mode",
|
|
"wrong_owner",
|
|
],
|
|
)
|
|
def test_claimable_passports_omit_ineligible_identities(harness: ClaimHarness, unavailable: str) -> None:
|
|
base = harness.oauth.base
|
|
base.service.create_api_key(base.context, base.command())
|
|
token = harness.guest_passport
|
|
if unavailable == "account":
|
|
token = harness.services.web_passport.issue_for_account(
|
|
app_code=harness.oauth.app_code, access_token=harness.proof
|
|
).access_token
|
|
elif unavailable == "sso":
|
|
token = harness.signer.issue({**harness.signer.verify(token), "token_source": "webapp"})
|
|
else:
|
|
with base.sessions.begin() as session:
|
|
if unavailable == "disabled_app":
|
|
session.execute(sa.update(App).where(App.id == base.context.app_id).values(enable_site=False))
|
|
elif unavailable == "removed_app":
|
|
session.execute(sa.delete(App).where(App.id == base.context.app_id))
|
|
elif unavailable == "removed_site":
|
|
session.execute(sa.delete(Site).where(Site.app_id == base.context.app_id))
|
|
elif unavailable == "app_status":
|
|
session.execute(
|
|
sa.text("UPDATE apps SET status = :status WHERE id = :app_id"),
|
|
{"status": "disabled", "app_id": base.context.app_id},
|
|
)
|
|
elif unavailable == "site_status":
|
|
session.execute(
|
|
sa.text("UPDATE sites SET status = :status WHERE app_id = :app_id"),
|
|
{"status": "disabled", "app_id": base.context.app_id},
|
|
)
|
|
elif unavailable == "unpublished":
|
|
session.execute(sa.update(App).where(App.id == base.context.app_id).values(workflow_id=None))
|
|
elif unavailable == "draft_workflow":
|
|
session.execute(sa.update(Workflow).where(Workflow.id == base.workflow_id).values(version="draft"))
|
|
elif unavailable == "missing_workflow":
|
|
session.execute(sa.delete(Workflow).where(Workflow.id == base.workflow_id))
|
|
elif unavailable == "wrong_workflow_app":
|
|
session.execute(sa.update(Workflow).where(Workflow.id == base.workflow_id).values(app_id=str(uuid4())))
|
|
elif unavailable == "wrong_workflow_tenant":
|
|
session.execute(
|
|
sa.update(Workflow).where(Workflow.id == base.workflow_id).values(tenant_id=str(uuid4()))
|
|
)
|
|
elif unavailable == "unsupported_mode":
|
|
session.execute(sa.update(App).where(App.id == base.context.app_id).values(mode=AppMode.COMPLETION))
|
|
else:
|
|
session.execute(
|
|
sa.update(EndUser).where(EndUser.id == base.context.end_user_id).values(tenant_id=str(uuid4()))
|
|
)
|
|
response = harness.client.post("/api/migration/claimable-passports", json={"passports": [token]})
|
|
assert response.status_code == 200
|
|
assert response.get_json() == {"data": []}
|
|
|
|
|
|
def test_claimable_passports_validate_batch_and_capability(
|
|
harness: ClaimHarness, config_overrides: Callable[..., None]
|
|
) -> None:
|
|
assert (
|
|
harness.client.post("/api/migration/claimable-passports", json={"passports": ["candidate"] * 51}).status_code
|
|
== 422
|
|
)
|
|
config_overrides(WEBAPP_ACCOUNT_PASSPORT_ENABLED=False)
|
|
assert (
|
|
harness.client.post(
|
|
"/api/migration/claimable-passports", json={"passports": [harness.guest_passport]}
|
|
).status_code
|
|
== 403
|
|
)
|
|
|
|
|
|
def test_batch_retry_transfers_remaining_connections_only(harness: ClaimHarness) -> None:
|
|
base = harness.oauth.base
|
|
first = base.service.create_api_key(base.context, base.command())
|
|
with base.sessions.begin() as session:
|
|
guest = EndUser(
|
|
tenant_id=base.context.tenant_id,
|
|
app_id=base.context.app_id,
|
|
type=EndUserType.BROWSER,
|
|
session_id=str(uuid4()),
|
|
)
|
|
session.add(guest)
|
|
session.flush()
|
|
second_context = replace(base.context, end_user_id=guest.id)
|
|
second = base.service.create_api_key(second_context, base.command("second-secret"))
|
|
second_token = harness.signer.issue(
|
|
{**harness.signer.verify(harness.guest_passport), "end_user_id": second_context.end_user_id}
|
|
)
|
|
engine = base.sessions.kw["bind"]
|
|
|
|
def fail_second(
|
|
_connection: object, _cursor: object, statement: str, parameters: object, _context: object, _executemany: bool
|
|
) -> None:
|
|
if statement.startswith("UPDATE end_user_connections") and isinstance(parameters, tuple):
|
|
if second_context.end_user_id in parameters:
|
|
raise RuntimeError("private database failure")
|
|
|
|
payload = {"passports": [harness.guest_passport, second_token, harness.guest_passport]}
|
|
event.listen(engine, "before_cursor_execute", fail_second)
|
|
try:
|
|
response = harness.client.post(
|
|
"/api/migration/claim",
|
|
headers={"Authorization": f"Bearer {harness.proof}"},
|
|
json=payload,
|
|
)
|
|
finally:
|
|
event.remove(engine, "before_cursor_execute", fail_second)
|
|
assert response.status_code == 200
|
|
items = response.get_json()["data"]
|
|
assert [item["status"] for item in items] == ["success", "failed", "success"]
|
|
assert items[0]["claimed_connections"] == 1
|
|
assert items[1]["error"]["code"] == "connection_claim_unavailable"
|
|
assert items[2]["claimed_connections"] == 0
|
|
assert "private database failure" not in response.get_data(as_text=True)
|
|
assert base.service.get_connection(harness.account, first.id).id == first.id
|
|
assert base.service.get_connection(second_context, second.id).id == second.id
|
|
retry = harness.client.post(
|
|
"/api/migration/claim",
|
|
headers={"Authorization": f"Bearer {harness.proof}"},
|
|
json=payload,
|
|
)
|
|
assert [item["claimed_connections"] for item in retry.get_json()["data"]] == [0, 1, 0]
|
|
assert base.service.get_connection(harness.account, second.id).id == second.id
|
|
newest = base.service.create_api_key(base.context, base.command("new-guest-secret"))
|
|
again = harness.client.post(
|
|
"/api/migration/claim", headers={"Authorization": f"Bearer {harness.proof}"}, json=payload
|
|
)
|
|
assert [item["claimed_connections"] for item in again.get_json()["data"]] == [1, 0, 0]
|
|
assert base.service.get_connection(harness.account, newest.id).id == newest.id
|
|
|
|
|
|
@pytest.mark.parametrize("payload", [{"passports": []}, {"passports": ["candidate"] * 51}])
|
|
def test_claim_rejects_invalid_batch_sizes(harness: ClaimHarness, payload: dict[str, object]) -> None:
|
|
assert (
|
|
harness.client.post(
|
|
"/api/migration/claim", headers={"Authorization": f"Bearer {harness.proof}"}, json=payload
|
|
).status_code
|
|
== 422
|
|
)
|
|
|
|
|
|
def test_claim_rejects_disabled_capability_before_any_transfer(
|
|
harness: ClaimHarness, config_overrides: Callable[..., None]
|
|
) -> None:
|
|
base = harness.oauth.base
|
|
selected = base.service.create_api_key(base.context, base.command())
|
|
config_overrides(WEBAPP_ACCOUNT_PASSPORT_ENABLED=False)
|
|
assert harness.claim().status_code == 403
|
|
assert base.service.get_connection(base.context, selected.id).id == selected.id
|
|
|
|
|
|
@pytest.mark.parametrize("auth_type", [WebAppAuthType.INTERNAL, WebAppAuthType.EXTERNAL])
|
|
def test_batch_claim_preserves_enterprise_app_admission(
|
|
harness: ClaimHarness, monkeypatch: pytest.MonkeyPatch, auth_type: WebAppAuthType
|
|
) -> None:
|
|
base = harness.oauth.base
|
|
selected = base.service.create_api_key(base.context, base.command())
|
|
auth = harness.services.web_passport._auth
|
|
monkeypatch.setattr(auth, "is_webapp_auth_enabled", lambda: True)
|
|
monkeypatch.setattr(auth, "get_app_auth_type", lambda _app: auth_type)
|
|
monkeypatch.setattr(auth, "is_account_allowed", lambda _app, _account: False)
|
|
response = harness.claim()
|
|
assert response.status_code == 400
|
|
assert response.get_json()["code"] == "connection_claim_preflight_failed"
|
|
item = response.get_json()["details"]["failures"][0]
|
|
assert item["status"] == "failed"
|
|
assert item["error"]["code"] == (
|
|
"web_app_access_denied" if auth_type == WebAppAuthType.INTERNAL else "end_user_login_required"
|
|
)
|
|
assert base.service.get_connection(base.context, selected.id).id == selected.id
|
|
|
|
|
|
def test_preflight_dependency_failure_starts_no_transfer(harness: ClaimHarness) -> None:
|
|
base = harness.oauth.base
|
|
selected = base.service.create_api_key(base.context, base.command())
|
|
engine = base.sessions.kw["bind"]
|
|
|
|
def fail_preflight(
|
|
_connection: object, _cursor: object, statement: str, _parameters: object, _context: object, _executemany: bool
|
|
) -> None:
|
|
if statement.startswith("SELECT apps.tenant_id, apps.name"):
|
|
raise RuntimeError("private database failure")
|
|
|
|
event.listen(engine, "before_cursor_execute", fail_preflight)
|
|
try:
|
|
response = harness.claim()
|
|
finally:
|
|
event.remove(engine, "before_cursor_execute", fail_preflight)
|
|
assert response.status_code == 503
|
|
assert response.get_json()["code"] == "connection_claim_unavailable"
|
|
assert "private database failure" not in response.get_data(as_text=True)
|
|
assert base.service.get_connection(base.context, selected.id).id == selected.id
|
|
|
|
|
|
def persist_migration_history(harness: ClaimHarness, owner: WebConnectionContext) -> tuple[str, str, str, str]:
|
|
from graphon.enums import WorkflowExecutionStatus
|
|
from graphon.variables import StringVariable
|
|
from models.enums import CreatorUserRole, FeedbackFromSource, FeedbackRating, WorkflowRunTriggeredFrom
|
|
from models.model import Message, MessageFeedback
|
|
from models.web import PinnedConversation, SavedMessage
|
|
from models.workflow import ConversationVariable, WorkflowRun
|
|
from tests.helpers.unit.model_factories import make_upload_file
|
|
|
|
with harness.oauth.base.sessions.begin() as session:
|
|
upload = make_upload_file(
|
|
tenant_id=owner.tenant_id, created_by=owner.end_user_id, created_by_role=CreatorUserRole.END_USER
|
|
)
|
|
session.add(upload)
|
|
session.flush()
|
|
conversation = Conversation(
|
|
app_id=owner.app_id,
|
|
mode=AppMode.ADVANCED_CHAT,
|
|
name="Migrated history",
|
|
from_source=ConversationFromSource.API,
|
|
from_end_user_id=owner.end_user_id,
|
|
from_account_id=None,
|
|
invoke_from=InvokeFrom.WEB_APP,
|
|
_inputs={"attachment": {"transfer_method": "local_file", "upload_file_id": upload.id}},
|
|
is_deleted=False,
|
|
)
|
|
run = WorkflowRun(
|
|
id=str(uuid4()),
|
|
task_id=str(uuid4()),
|
|
tenant_id=owner.tenant_id,
|
|
app_id=owner.app_id,
|
|
workflow_id=harness.oauth.base.workflow_id,
|
|
type=WorkflowType.CHAT,
|
|
triggered_from=WorkflowRunTriggeredFrom.APP_RUN,
|
|
version="published",
|
|
graph='{"nodes": [], "edges": []}',
|
|
inputs="{}",
|
|
outputs="{}",
|
|
status=WorkflowExecutionStatus.SUCCEEDED,
|
|
finished_at=datetime.now(UTC),
|
|
error=None,
|
|
elapsed_time=0,
|
|
total_tokens=0,
|
|
total_steps=0,
|
|
created_by_role=CreatorUserRole.END_USER,
|
|
created_by=owner.end_user_id,
|
|
)
|
|
session.add_all([conversation, run])
|
|
session.flush()
|
|
message = Message(
|
|
app_id=owner.app_id,
|
|
conversation_id=conversation.id,
|
|
query="hello",
|
|
message="",
|
|
answer="original",
|
|
from_source=ConversationFromSource.API,
|
|
from_end_user_id=owner.end_user_id,
|
|
from_account_id=None,
|
|
invoke_from=InvokeFrom.WEB_APP,
|
|
workflow_run_id=run.id,
|
|
)
|
|
session.add(message)
|
|
session.flush()
|
|
session.add_all(
|
|
[
|
|
PinnedConversation(
|
|
app_id=owner.app_id,
|
|
conversation_id=conversation.id,
|
|
created_by_role=CreatorUserRole.END_USER,
|
|
created_by=owner.end_user_id,
|
|
),
|
|
ConversationVariable.from_variable(
|
|
app_id=owner.app_id,
|
|
conversation_id=conversation.id,
|
|
variable=StringVariable(name="remembered", value="state", selector=["conversation", "remembered"]),
|
|
),
|
|
MessageFeedback(
|
|
app_id=owner.app_id,
|
|
conversation_id=conversation.id,
|
|
message_id=message.id,
|
|
rating=FeedbackRating.LIKE,
|
|
from_source=FeedbackFromSource.USER,
|
|
from_end_user_id=owner.end_user_id,
|
|
from_account_id=None,
|
|
),
|
|
SavedMessage(
|
|
app_id=owner.app_id,
|
|
message_id=message.id,
|
|
created_by_role=CreatorUserRole.END_USER,
|
|
created_by=owner.end_user_id,
|
|
),
|
|
]
|
|
)
|
|
return conversation.id, message.id, run.id, upload.id
|
|
|
|
|
|
def configure_chatbot(harness: ClaimHarness) -> str:
|
|
"""Publish an ordinary Chatbot and convert the fixture's retained history."""
|
|
from models.model import AppModelConfig
|
|
|
|
base = harness.oauth.base
|
|
with base.sessions.begin() as session:
|
|
config = AppModelConfig(app_id=base.context.app_id)
|
|
session.add(config)
|
|
session.flush()
|
|
session.execute(
|
|
sa.update(App).where(App.id == base.context.app_id).values(mode=AppMode.CHAT, app_model_config_id=config.id)
|
|
)
|
|
session.execute(
|
|
sa.update(Conversation)
|
|
.where(Conversation.app_id == base.context.app_id)
|
|
.values(mode=AppMode.CHAT, app_model_config_id=config.id)
|
|
)
|
|
return config.id
|
|
|
|
|
|
def chatbot_entity(harness: ClaimHarness) -> ChatAppGenerateEntity:
|
|
"""Keep the admitted Guest actor independent of any later ownership transfer."""
|
|
guest = harness.oauth.base.context
|
|
with harness.oauth.base.sessions() as session:
|
|
config_id = session.scalar(
|
|
sa.select(App.app_model_config_id).where(App.id == guest.app_id, App.tenant_id == guest.tenant_id)
|
|
)
|
|
assert config_id is not None
|
|
return ChatAppGenerateEntity(
|
|
task_id=str(uuid4()),
|
|
app_config=EasyUIBasedAppConfig(
|
|
tenant_id=guest.tenant_id,
|
|
app_id=guest.app_id,
|
|
app_mode=AppMode.CHAT,
|
|
app_model_config_id=config_id,
|
|
app_model_config_from=EasyUIBasedAppModelConfigFrom.APP_LATEST_CONFIG,
|
|
app_model_config_dict={},
|
|
model=ModelConfigEntity(provider="test-provider", model="test-model"),
|
|
prompt_template=PromptTemplateEntity(prompt_type=PromptTemplateEntity.PromptType.SIMPLE),
|
|
additional_features=AppAdditionalFeatures(),
|
|
),
|
|
model_conf=make_model_config(provider="test-provider", model="test-model", mode="chat"),
|
|
inputs={},
|
|
files=[],
|
|
user_id=guest.end_user_id,
|
|
stream=True,
|
|
invoke_from=AppInvokeFrom.WEB_APP,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("mode", [AppMode.ADVANCED_CHAT, AppMode.CHAT])
|
|
def test_full_passport_claim_preserves_provenance_and_transfers_history_access(
|
|
harness: ClaimHarness, mode: AppMode
|
|
) -> None:
|
|
from models.enums import CreatorUserRole, FeedbackRating
|
|
from models.model import Message, MessageFeedback
|
|
from models.web import PinnedConversation, SavedMessage
|
|
from models.workflow import WorkflowRun
|
|
from repositories.conversation_access import (
|
|
ConversationAccessDeniedError,
|
|
may_read_conversation_run,
|
|
require_web_message_access,
|
|
)
|
|
from repositories.conversation_repository import ConversationRepository
|
|
from repositories.conversation_variable_repository import ConversationVariableRepository
|
|
from repositories.message_repository import MessageRepository
|
|
from repositories.saved_message_repository import SQLAlchemySavedMessageRepository
|
|
from services.app_ref_service import AppRef, MessageRef
|
|
from services.audio_service import AudioService
|
|
from services.entities.conversation_entities import EndUserConversationRef
|
|
from services.entities.message_entities import MessageEndUser
|
|
from services.errors.conversation import ConversationNotExistsError
|
|
from services.errors.message import MessageNotExistsError
|
|
from services.saved_message_service import SavedMessageActor
|
|
|
|
base = harness.oauth.base
|
|
connection = base.service.create_api_key(base.context, base.command())
|
|
conversation_id, message_id, run_id, upload_id = persist_migration_history(harness, base.context)
|
|
existing_id, _, _, _ = persist_migration_history(harness, harness.account)
|
|
save_choices(harness, base.context, connection.id, conversation_id)
|
|
if mode == AppMode.CHAT:
|
|
configure_chatbot(harness)
|
|
preview = harness.client.post("/api/migration/claimable-passports", json={"passports": [harness.guest_passport]})
|
|
assert preview.get_json()["data"][0]["conversation_count"] == 1
|
|
response = harness.claim()
|
|
item = response.get_json()["data"][0]
|
|
assert (item["claimed_connections"], item["claimed_conversations"]) == (1, 1)
|
|
with base.sessions() as session:
|
|
app = session.get(App, base.context.app_id)
|
|
guest = session.get(EndUser, base.context.end_user_id)
|
|
account = session.get(EndUser, harness.account.end_user_id)
|
|
assert app is not None
|
|
assert guest is not None
|
|
assert account is not None
|
|
conversations = ConversationRepository(session_factory=base.sessions)
|
|
account_ref = EndUserConversationRef(
|
|
app_id=app.id,
|
|
tenant_id=app.tenant_id,
|
|
end_user_id=account.id,
|
|
conversation_id=conversation_id,
|
|
)
|
|
guest_ref = replace(account_ref, end_user_id=guest.id)
|
|
assert conversations.get(ref=account_ref).id == conversation_id
|
|
assert conversations.get(ref=replace(account_ref, conversation_id=existing_id)).id == existing_id
|
|
with pytest.raises(ConversationNotExistsError):
|
|
conversations.get(ref=guest_ref)
|
|
messages = MessageRepository(session_factory=base.sessions)
|
|
page = messages.get_message_page(
|
|
app_id=app.id,
|
|
app_owner_tenant_id=app.tenant_id,
|
|
actor=MessageEndUser(end_user_id=account.id),
|
|
conversation_id=conversation_id,
|
|
first_id=None,
|
|
limit=10,
|
|
installed_app=None,
|
|
)
|
|
assert page.data[0].record.id == message_id
|
|
message = session.get(Message, message_id)
|
|
assert message is not None
|
|
assert message.from_end_user_id == guest.id
|
|
assert (
|
|
session.scalar(sa.select(MessageFeedback.from_end_user_id).where(MessageFeedback.message_id == message_id))
|
|
== account.id
|
|
)
|
|
variables = ConversationVariableRepository(session_factory=base.sessions)
|
|
variable_page = variables.get_page(ref=account_ref, last_id=None, limit=10, variable_name=None)
|
|
assert variable_page.data[0].variable.value == "state"
|
|
with pytest.raises(ConversationNotExistsError):
|
|
variables.get_page(ref=guest_ref, last_id=None, limit=10, variable_name=None)
|
|
with pytest.raises(ConversationNotExistsError):
|
|
messages.get_message_page(
|
|
app_id=app.id,
|
|
app_owner_tenant_id=app.tenant_id,
|
|
actor=MessageEndUser(end_user_id=guest.id),
|
|
conversation_id=conversation_id,
|
|
first_id=None,
|
|
limit=10,
|
|
installed_app=None,
|
|
)
|
|
assert (
|
|
AudioService._get_message_by_ref(
|
|
session, MessageRef(AppRef(app.tenant_id, app.id), message_id, end_user_id=account.id)
|
|
)
|
|
is not None
|
|
)
|
|
assert (
|
|
AudioService._get_message_by_ref(
|
|
session, MessageRef(AppRef(app.tenant_id, app.id), message_id, end_user_id=guest.id)
|
|
)
|
|
is None
|
|
)
|
|
assert may_read_conversation_run(
|
|
session, app_id=app.id, user=account, run_id=run_id, original_end_user_id=guest.id
|
|
)
|
|
assert not may_read_conversation_run(
|
|
session, app_id=app.id, user=guest, run_id=run_id, original_end_user_id=guest.id
|
|
)
|
|
with pytest.raises(ConversationAccessDeniedError):
|
|
require_web_message_access(
|
|
session, tenant_id=app.tenant_id, app_id=app.id, end_user_id=guest.id, conversation_id=conversation_id
|
|
)
|
|
require_web_message_access(
|
|
session, tenant_id=app.tenant_id, app_id=app.id, end_user_id=account.id, conversation_id=conversation_id
|
|
)
|
|
assert (
|
|
session.scalar(
|
|
sa.select(PinnedConversation.created_by).where(PinnedConversation.conversation_id == conversation_id)
|
|
)
|
|
== account.id
|
|
)
|
|
assert (
|
|
session.scalar(sa.select(SavedMessage.created_by).where(SavedMessage.message_id == message_id))
|
|
== account.id
|
|
)
|
|
run = session.get(WorkflowRun, run_id)
|
|
assert run is not None
|
|
assert run.created_by_role == CreatorUserRole.END_USER
|
|
assert run.created_by == guest.id
|
|
feedback = messages.set_feedback(
|
|
app_id=base.context.app_id,
|
|
app_owner_tenant_id=base.context.tenant_id,
|
|
actor=MessageEndUser(end_user_id=harness.account.end_user_id),
|
|
message_id=message_id,
|
|
rating=FeedbackRating.DISLIKE,
|
|
content="Updated after migration",
|
|
app_wide=False,
|
|
installed_app=None,
|
|
)
|
|
assert feedback is not None
|
|
assert feedback.from_end_user_id == harness.account.end_user_id
|
|
with pytest.raises(MessageNotExistsError):
|
|
messages.set_feedback(
|
|
app_id=base.context.app_id,
|
|
app_owner_tenant_id=base.context.tenant_id,
|
|
actor=MessageEndUser(end_user_id=base.context.end_user_id),
|
|
message_id=message_id,
|
|
rating=FeedbackRating.LIKE,
|
|
content=None,
|
|
app_wide=False,
|
|
installed_app=None,
|
|
)
|
|
saved = SQLAlchemySavedMessageRepository(session_factory=base.sessions)
|
|
assert (
|
|
saved.pagination_by_last_id(
|
|
app_id=base.context.app_id,
|
|
actor=SavedMessageActor.end_user(base.context.end_user_id),
|
|
last_id=None,
|
|
limit=10,
|
|
).data
|
|
== ()
|
|
)
|
|
assert message_id in {
|
|
row.id
|
|
for row in saved.pagination_by_last_id(
|
|
app_id=base.context.app_id,
|
|
actor=SavedMessageActor.end_user(harness.account.end_user_id),
|
|
last_id=None,
|
|
limit=10,
|
|
).data
|
|
}
|
|
repeat = harness.claim().get_json()["data"][0]
|
|
assert (repeat["claimed_connections"], repeat["claimed_conversations"]) == (0, 0)
|
|
|
|
|
|
@pytest.mark.parametrize("terminal", ["answer", "error"])
|
|
def test_chatbot_claim_rejects_inflight_answer_and_error_writes(
|
|
harness: ClaimHarness,
|
|
terminal: str,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
redis_transport: tuple[RedisClientWrapper, MagicMock],
|
|
) -> None:
|
|
from core.app.apps import base_app_queue_manager
|
|
from core.app.apps.base_app_queue_manager import PublishFrom
|
|
from core.app.apps.message_based_app_queue_manager import MessageBasedAppQueueManager
|
|
from core.app.entities.queue_entities import QueueErrorEvent, QueueMessageEndEvent
|
|
from core.app.entities.task_entities import ErrorStreamResponse
|
|
from core.app.task_pipeline.easy_ui_based_generate_task_pipeline import EasyUIBasedGenerateTaskPipeline
|
|
from models.model import Message
|
|
from repositories.conversation_access import ConversationAccessDeniedError
|
|
|
|
base = harness.oauth.base
|
|
monkeypatch.setattr(base_app_queue_manager, "redis_client", redis_transport[0])
|
|
conversation_id, message_id, _, _ = persist_migration_history(harness, base.context)
|
|
configure_chatbot(harness)
|
|
entity = chatbot_entity(harness)
|
|
queue = MessageBasedAppQueueManager(
|
|
task_id=entity.task_id,
|
|
user_id=entity.user_id,
|
|
invoke_from=entity.invoke_from,
|
|
conversation_id=conversation_id,
|
|
app_mode=AppMode.CHAT,
|
|
message_id=message_id,
|
|
)
|
|
with base.sessions() as session:
|
|
conversation = session.get(Conversation, conversation_id)
|
|
message = session.get(Message, message_id)
|
|
assert conversation is not None
|
|
assert message is not None
|
|
original = (message.answer, message.status, message.error)
|
|
pipeline = EasyUIBasedGenerateTaskPipeline(entity, queue, conversation, message, stream=True)
|
|
queue.publish(
|
|
QueueMessageEndEvent() if terminal == "answer" else QueueErrorEvent(error=ValueError("Late failure")),
|
|
PublishFrom.APPLICATION_MANAGER,
|
|
)
|
|
|
|
result = harness.claim().get_json()["data"][0]
|
|
assert (result["status"], result["claimed_conversations"], result["stop_requested_run_count"]) == ("success", 1, 0)
|
|
responses = list(pipeline._wrapper_process_stream_response())
|
|
assert len(responses) == 1
|
|
assert isinstance(responses[0], ErrorStreamResponse)
|
|
assert isinstance(responses[0].err, ConversationAccessDeniedError)
|
|
with base.sessions() as session:
|
|
stored = session.get(Message, message_id)
|
|
assert stored is not None
|
|
assert (stored.answer, stored.status, stored.error) == original
|
|
assert session.scalar(sa.select(Conversation.from_end_user_id).where(Conversation.id == conversation_id)) == (
|
|
harness.account.end_user_id
|
|
)
|
|
|
|
|
|
def test_chatbot_completion_releases_ownership_fence_before_notifying_subscribers(
|
|
harness: ClaimHarness,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
redis_transport: tuple[RedisClientWrapper, MagicMock],
|
|
) -> None:
|
|
from core.app.apps import base_app_queue_manager
|
|
from core.app.apps.base_app_queue_manager import PublishFrom
|
|
from core.app.apps.message_based_app_queue_manager import MessageBasedAppQueueManager
|
|
from core.app.entities.queue_entities import QueueMessageEndEvent
|
|
from core.app.entities.task_entities import MessageEndStreamResponse
|
|
from core.app.task_pipeline.easy_ui_based_generate_task_pipeline import EasyUIBasedGenerateTaskPipeline
|
|
from events.message_event import message_was_created
|
|
from graphon.model_runtime.entities.llm_entities import LLMResult, LLMUsage
|
|
from graphon.model_runtime.entities.message_entities import AssistantPromptMessage
|
|
from models.model import Message
|
|
|
|
base = harness.oauth.base
|
|
conversation_id, message_id, _, _ = persist_migration_history(harness, base.context)
|
|
configure_chatbot(harness)
|
|
entity = chatbot_entity(harness)
|
|
monkeypatch.setattr(base_app_queue_manager, "redis_client", redis_transport[0])
|
|
queue = MessageBasedAppQueueManager(
|
|
task_id=entity.task_id,
|
|
user_id=entity.user_id,
|
|
invoke_from=entity.invoke_from,
|
|
conversation_id=conversation_id,
|
|
app_mode=AppMode.CHAT,
|
|
message_id=message_id,
|
|
)
|
|
with base.sessions() as session:
|
|
conversation = session.get(Conversation, conversation_id)
|
|
message = session.get(Message, message_id)
|
|
assert conversation is not None
|
|
assert message is not None
|
|
pipeline = EasyUIBasedGenerateTaskPipeline(entity, queue, conversation, message, stream=True)
|
|
queue.publish(
|
|
QueueMessageEndEvent(
|
|
llm_result=LLMResult(
|
|
model="test-model",
|
|
prompt_messages=[],
|
|
message=AssistantPromptMessage(content="Committed answer"),
|
|
usage=LLMUsage.empty_usage(),
|
|
)
|
|
),
|
|
PublishFrom.APPLICATION_MANAGER,
|
|
)
|
|
notifications: list[str] = []
|
|
|
|
def after_save(sender: Message, **_kwargs: object) -> None:
|
|
with base.sessions() as session:
|
|
assert session.scalar(sa.select(Message.answer).where(Message.id == sender.id)) == "Committed answer"
|
|
assert harness.claim().get_json()["data"][0]["status"] == "success"
|
|
notifications.append(sender.id)
|
|
|
|
with message_was_created.connected_to(after_save):
|
|
responses = list(pipeline._wrapper_process_stream_response())
|
|
assert isinstance(responses[-1], MessageEndStreamResponse)
|
|
assert notifications == [message_id]
|
|
|
|
|
|
@pytest.mark.parametrize("granted", [False, True])
|
|
def test_chatbot_admission_preserves_trusted_app_default_file_grants(harness: ClaimHarness, granted: bool) -> None:
|
|
from core.app.apps.chat.app_generator import ChatAppGenerator
|
|
from core.app.entities.app_invoke_entities import UserFrom
|
|
from core.app.file_access import FileAccessScope, bind_file_access_scope
|
|
from core.workflow.file_reference import build_file_reference
|
|
from graphon.file import File, FileTransferMethod, FileType
|
|
from models.enums import CreatorUserRole
|
|
from repositories.entities.web_execution import WebExecutionResourcesChangedError
|
|
from tests.helpers.unit.model_factories import make_upload_file
|
|
|
|
base = harness.oauth.base
|
|
configure_chatbot(harness)
|
|
entity = chatbot_entity(harness)
|
|
assert harness.account.account_id is not None
|
|
with base.sessions.begin() as session:
|
|
upload = make_upload_file(
|
|
tenant_id=base.context.tenant_id,
|
|
created_by_role=CreatorUserRole.ACCOUNT,
|
|
created_by=harness.account.account_id,
|
|
)
|
|
session.add(upload)
|
|
session.flush()
|
|
upload_id, storage_key = upload.id, upload.key
|
|
entity.files = [
|
|
File(
|
|
file_type=FileType.DOCUMENT,
|
|
transfer_method=FileTransferMethod.LOCAL_FILE,
|
|
reference=build_file_reference(record_id=upload_id),
|
|
filename="default.txt",
|
|
extension=".txt",
|
|
mime_type="text/plain",
|
|
size=1,
|
|
storage_key=storage_key,
|
|
)
|
|
]
|
|
scope = FileAccessScope(
|
|
tenant_id=base.context.tenant_id,
|
|
user_id=entity.user_id,
|
|
user_from=UserFrom.END_USER,
|
|
invoke_from=entity.invoke_from,
|
|
granted_upload_file_ids=frozenset({upload_id}) if granted else frozenset(),
|
|
)
|
|
with bind_file_access_scope(scope), base.sessions() as session:
|
|
if granted:
|
|
_, message = ChatAppGenerator()._init_generate_records(entity, session=session)
|
|
assert message.from_end_user_id == entity.user_id
|
|
else:
|
|
with pytest.raises(WebExecutionResourcesChangedError):
|
|
ChatAppGenerator()._init_generate_records(entity, session=session)
|
|
|
|
|
|
def test_chatbot_claim_rejects_stale_message_creation_and_history_reads(harness: ClaimHarness) -> None:
|
|
from core.app.apps.chat.app_generator import ChatAppGenerator
|
|
from core.memory.token_buffer_memory import TokenBufferMemory
|
|
from core.model_manager import ModelInstance
|
|
from models.model import Message
|
|
from repositories.conversation_access import ConversationAccessDeniedError
|
|
from services.errors.conversation import ConversationNotExistsError
|
|
|
|
base = harness.oauth.base
|
|
conversation_id, _, _, _ = persist_migration_history(harness, base.context)
|
|
configure_chatbot(harness)
|
|
entity = chatbot_entity(harness)
|
|
with base.sessions() as session:
|
|
conversation = session.get(Conversation, conversation_id)
|
|
assert conversation is not None
|
|
assert harness.claim().get_json()["data"][0]["status"] == "success"
|
|
with base.sessions() as session, pytest.raises(ConversationNotExistsError):
|
|
ChatAppGenerator()._init_generate_records(entity, conversation, session=session)
|
|
memory = TokenBufferMemory(
|
|
conversation=conversation,
|
|
model_instance=ModelInstance(
|
|
entity.model_conf.provider_model_bundle, entity.model_conf.model, credentials=entity.model_conf.credentials
|
|
),
|
|
tenant_id=entity.app_config.tenant_id,
|
|
end_user_id=entity.user_id,
|
|
)
|
|
with pytest.raises(ConversationAccessDeniedError):
|
|
memory.get_history_prompt_messages()
|
|
with base.sessions() as session:
|
|
assert (
|
|
session.scalar(
|
|
sa.select(sa.func.count()).select_from(Message).where(Message.conversation_id == conversation_id)
|
|
)
|
|
== 1
|
|
)
|
|
account_entity = chatbot_entity(harness)
|
|
account_entity.user_id = harness.account.end_user_id
|
|
with base.sessions() as session:
|
|
current = session.get(Conversation, conversation_id)
|
|
assert current is not None
|
|
_, accepted = ChatAppGenerator()._init_generate_records(account_entity, current, session=session)
|
|
assert accepted.from_end_user_id == harness.account.end_user_id
|
|
|
|
|
|
def test_chatbot_generated_attachment_cannot_be_added_after_claim(
|
|
harness: ClaimHarness,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
redis_transport: tuple[RedisClientWrapper, MagicMock],
|
|
) -> None:
|
|
from core.app.apps import base_app_queue_manager
|
|
from core.app.apps.base_app_runner import AppRunner
|
|
from core.app.apps.message_based_app_queue_manager import MessageBasedAppQueueManager
|
|
from core.tools import tool_file_manager
|
|
from graphon.model_runtime.entities.message_entities import ImagePromptMessageContent
|
|
from models.model import MessageFile
|
|
from models.tools import ToolFile
|
|
from repositories.conversation_access import ConversationAccessDeniedError
|
|
|
|
base = harness.oauth.base
|
|
conversation_id, message_id, _, _ = persist_migration_history(harness, base.context)
|
|
configure_chatbot(harness)
|
|
entity = chatbot_entity(harness)
|
|
monkeypatch.setattr(base_app_queue_manager, "redis_client", redis_transport[0])
|
|
queue = MessageBasedAppQueueManager(
|
|
task_id=entity.task_id,
|
|
user_id=entity.user_id,
|
|
invoke_from=entity.invoke_from,
|
|
conversation_id=conversation_id,
|
|
app_mode=AppMode.CHAT,
|
|
message_id=message_id,
|
|
)
|
|
|
|
def save_after_claim(_key: str, _data: bytes) -> None:
|
|
assert harness.claim().get_json()["data"][0]["status"] == "success"
|
|
|
|
storage = MagicMock()
|
|
storage.save.side_effect = save_after_claim
|
|
monkeypatch.setattr(tool_file_manager, "storage", storage)
|
|
with base.sessions() as session, pytest.raises(ConversationAccessDeniedError):
|
|
AppRunner()._handle_multimodal_image_content(
|
|
content=ImagePromptMessageContent(
|
|
base64_data=base64.b64encode(b"generated-image").decode(), format="png", mime_type="image/png"
|
|
),
|
|
message_id=message_id,
|
|
user_id=entity.user_id,
|
|
tenant_id=base.context.tenant_id,
|
|
queue_manager=queue,
|
|
session=session,
|
|
app_id=entity.app_config.app_id,
|
|
)
|
|
with base.sessions() as session:
|
|
assert session.scalar(sa.select(sa.func.count()).select_from(MessageFile)) == 0
|
|
file = session.scalar(sa.select(ToolFile).where(ToolFile.user_id == entity.user_id))
|
|
assert file is not None
|
|
tool_id = file.id
|
|
assert harness.claim().get_json()["data"][0]["claimed_conversations"] == 0
|
|
with base.sessions() as session:
|
|
assert session.scalar(sa.select(ToolFile.user_id).where(ToolFile.id == tool_id)) == harness.account.end_user_id
|
|
|
|
|
|
def test_chatbot_title_generated_during_claim_is_discarded(
|
|
harness: ClaimHarness,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
redis_transport: tuple[RedisClientWrapper, MagicMock],
|
|
) -> None:
|
|
from core.app.entities.task_entities import EasyUITaskState
|
|
from core.app.task_pipeline import message_cycle_manager
|
|
from core.app.task_pipeline.message_cycle_manager import MessageCycleManager
|
|
from core.llm_generator.llm_generator import LLMGenerator
|
|
from graphon.model_runtime.entities.llm_entities import LLMResult, LLMUsage
|
|
from graphon.model_runtime.entities.message_entities import AssistantPromptMessage
|
|
|
|
base = harness.oauth.base
|
|
monkeypatch.setattr(message_cycle_manager, "redis_client", redis_transport[0])
|
|
conversation_id, message_id, _, _ = persist_migration_history(harness, base.context)
|
|
configure_chatbot(harness)
|
|
entity = chatbot_entity(harness)
|
|
manager = MessageCycleManager(
|
|
application_generate_entity=entity,
|
|
task_state=EasyUITaskState(
|
|
llm_result=LLMResult(
|
|
model="test-model",
|
|
prompt_messages=[],
|
|
message=AssistantPromptMessage(content=""),
|
|
usage=LLMUsage.empty_usage(),
|
|
)
|
|
),
|
|
)
|
|
|
|
def complete_model_call(*_args: object, **_kwargs: object) -> str:
|
|
assert harness.claim().get_json()["data"][0]["status"] == "success"
|
|
with base.sessions.begin() as session:
|
|
session.execute(
|
|
sa.update(Conversation).where(Conversation.id == conversation_id).values(name="Account title")
|
|
)
|
|
return "Late Guest title"
|
|
|
|
monkeypatch.setattr(LLMGenerator, "generate_conversation_name", complete_model_call)
|
|
manager._generate_conversation_name_worker(
|
|
harness.client.application, conversation_id, "Original query", message_id
|
|
)
|
|
with base.sessions() as session:
|
|
assert session.scalar(sa.select(Conversation.name).where(Conversation.id == conversation_id)) == "Account title"
|
|
|
|
|
|
@pytest.mark.parametrize("unsupported", ["agent_config", "agent_history"])
|
|
def test_chatbot_claim_excludes_legacy_agent_writers(harness: ClaimHarness, unsupported: str) -> None:
|
|
import json
|
|
|
|
from models.model import AppModelConfig
|
|
|
|
base = harness.oauth.base
|
|
conversation_id, _, _, _ = persist_migration_history(harness, base.context)
|
|
config_id = configure_chatbot(harness)
|
|
with base.sessions.begin() as session:
|
|
if unsupported == "agent_config":
|
|
session.execute(
|
|
sa.update(AppModelConfig)
|
|
.where(AppModelConfig.id == config_id)
|
|
.values(agent_mode=json.dumps({"enabled": True, "strategy": "function_call"}))
|
|
)
|
|
else:
|
|
session.execute(
|
|
sa.update(Conversation).where(Conversation.id == conversation_id).values(mode=AppMode.AGENT_CHAT)
|
|
)
|
|
preview = harness.client.post("/api/migration/claimable-passports", json={"passports": [harness.guest_passport]})
|
|
assert preview.get_json()["data"] == []
|
|
assert harness.claim().status_code == 400
|
|
with base.sessions() as session:
|
|
assert (
|
|
session.scalar(sa.select(Conversation.from_end_user_id).where(Conversation.id == conversation_id))
|
|
== base.context.end_user_id
|
|
)
|
|
|
|
|
|
def test_conversation_failure_rolls_back_entire_passport(harness: ClaimHarness) -> None:
|
|
from graphon.enums import WorkflowExecutionStatus
|
|
from models.model import UploadFile
|
|
from models.web import PinnedConversation, SavedMessage
|
|
from models.workflow import WorkflowRun
|
|
|
|
base = harness.oauth.base
|
|
selected = base.service.create_api_key(base.context, base.command())
|
|
conversation_id, message_id, run_id, upload_id = persist_migration_history(harness, base.context)
|
|
save_choices(harness, base.context, selected.id, conversation_id)
|
|
with base.sessions.begin() as session:
|
|
session.execute(
|
|
sa.update(WorkflowRun)
|
|
.where(WorkflowRun.id == run_id)
|
|
.values(status=WorkflowExecutionStatus.RUNNING, finished_at=None)
|
|
)
|
|
engine = base.sessions.kw["bind"]
|
|
|
|
def fail_conversation(
|
|
_connection: object,
|
|
_cursor: object,
|
|
statement: str,
|
|
_parameters: object,
|
|
_context: object,
|
|
_executemany: bool,
|
|
) -> None:
|
|
if statement.startswith("UPDATE conversations SET"):
|
|
raise RuntimeError("conversation transfer unavailable")
|
|
|
|
event.listen(engine, "before_cursor_execute", fail_conversation)
|
|
try:
|
|
result = harness.claim().get_json()["data"][0]
|
|
finally:
|
|
event.remove(engine, "before_cursor_execute", fail_conversation)
|
|
assert result["status"] == "failed"
|
|
assert base.service.get_connection(base.context, selected.id).id == selected.id
|
|
with base.sessions() as session:
|
|
assert session.scalar(sa.select(WorkflowRun.stop_requested_at).where(WorkflowRun.id == run_id)) is not None
|
|
assert (
|
|
session.scalar(sa.select(UploadFile.created_by).where(UploadFile.id == upload_id))
|
|
== base.context.end_user_id
|
|
)
|
|
assert (
|
|
session.scalar(sa.select(Conversation.from_end_user_id).where(Conversation.id == conversation_id))
|
|
== base.context.end_user_id
|
|
)
|
|
assert session.scalar(sa.select(AppConnection.end_user_id)) == base.context.end_user_id
|
|
assert session.scalar(sa.select(PinnedConversation.created_by)) == base.context.end_user_id
|
|
assert (
|
|
session.scalar(sa.select(SavedMessage.created_by).where(SavedMessage.message_id == message_id))
|
|
== base.context.end_user_id
|
|
)
|
|
retry = harness.claim().get_json()["data"][0]
|
|
assert (retry["claimed_connections"], retry["claimed_conversations"]) == (1, 1)
|
|
|
|
|
|
def test_full_claim_rejects_resource_selection(harness: ClaimHarness) -> None:
|
|
response = harness.client.post(
|
|
"/api/migration/claim",
|
|
headers=harness.headers(),
|
|
json={"passports": [harness.guest_passport], "resources": ["connections"]},
|
|
)
|
|
assert response.status_code == 422
|
|
|
|
|
|
@pytest.mark.parametrize("with_connections", [False, True])
|
|
@pytest.mark.parametrize("state", ["running", "paused"])
|
|
def test_claim_stops_unfinished_runs_and_transfers_all_conversations(
|
|
harness: ClaimHarness, with_connections: bool, state: str
|
|
) -> None:
|
|
from graphon.enums import WorkflowExecutionStatus
|
|
from models.model import MessageFeedback, UploadFile
|
|
from models.web import PinnedConversation, SavedMessage
|
|
from models.workflow import WorkflowRun
|
|
|
|
base = harness.oauth.base
|
|
connection = base.service.create_api_key(base.context, base.command()) if with_connections else None
|
|
active_id, active_message, run_id, active_file = persist_migration_history(harness, base.context)
|
|
idle_id, idle_message, _, idle_file = persist_migration_history(harness, base.context)
|
|
with base.sessions.begin() as session:
|
|
session.execute(
|
|
sa.update(WorkflowRun)
|
|
.where(WorkflowRun.id == run_id)
|
|
.values(
|
|
status=WorkflowExecutionStatus(state),
|
|
finished_at=None,
|
|
)
|
|
)
|
|
response = harness.claim()
|
|
assert response.status_code == 200
|
|
result = response.get_json()["data"][0]
|
|
assert result["status"] == "success"
|
|
assert (result["claimed_connections"], result["claimed_conversations"]) == (int(with_connections), 2)
|
|
assert result["stop_requested_run_count"] == 1
|
|
with base.sessions() as session:
|
|
assert (
|
|
session.scalar(sa.select(Conversation.from_end_user_id).where(Conversation.id == active_id))
|
|
== harness.account.end_user_id
|
|
)
|
|
assert (
|
|
session.scalar(sa.select(Conversation.from_end_user_id).where(Conversation.id == idle_id))
|
|
== harness.account.end_user_id
|
|
)
|
|
for file_id in (active_file, idle_file):
|
|
assert (
|
|
session.scalar(sa.select(UploadFile.created_by).where(UploadFile.id == file_id))
|
|
== harness.account.end_user_id
|
|
)
|
|
for conversation_id, owner in (
|
|
(active_id, harness.account.end_user_id),
|
|
(idle_id, harness.account.end_user_id),
|
|
):
|
|
assert (
|
|
session.scalar(
|
|
sa.select(PinnedConversation.created_by).where(
|
|
PinnedConversation.conversation_id == conversation_id
|
|
)
|
|
)
|
|
== owner
|
|
)
|
|
for message_id, owner in (
|
|
(active_message, harness.account.end_user_id),
|
|
(idle_message, harness.account.end_user_id),
|
|
):
|
|
assert (
|
|
session.scalar(sa.select(SavedMessage.created_by).where(SavedMessage.message_id == message_id)) == owner
|
|
)
|
|
assert (
|
|
session.scalar(
|
|
sa.select(MessageFeedback.from_end_user_id).where(MessageFeedback.message_id == message_id)
|
|
)
|
|
== owner
|
|
)
|
|
assert (
|
|
session.scalar(sa.select(WorkflowRun.created_by).where(WorkflowRun.id == run_id))
|
|
== base.context.end_user_id
|
|
)
|
|
if connection is not None:
|
|
assert base.service.get_connection(harness.account, connection.id).id == connection.id
|
|
with base.sessions() as session:
|
|
run = session.get(WorkflowRun, run_id)
|
|
assert run is not None
|
|
assert run.stop_requested_at is not None
|
|
assert run.status == WorkflowExecutionStatus.STOPPED
|
|
assert run.created_by == base.context.end_user_id
|
|
repeated = harness.claim().get_json()["data"][0]
|
|
assert (repeated["claimed_connections"], repeated["claimed_conversations"]) == (0, 0)
|
|
|
|
|
|
def test_claim_transfers_persistent_file_ownership(harness: ClaimHarness) -> None:
|
|
from core.app.entities.app_invoke_entities import InvokeFrom as AppInvokeFrom
|
|
from core.app.entities.app_invoke_entities import UserFrom
|
|
from core.app.file_access import DatabaseFileAccessController, FileAccessScope
|
|
from models.enums import CreatorUserRole
|
|
from models.model import UploadFile
|
|
from models.tools import ToolFile
|
|
from tests.helpers.unit.model_factories import make_upload_file
|
|
|
|
base = harness.oauth.base
|
|
_, _, _, upload_id = persist_migration_history(harness, base.context)
|
|
with base.sessions.begin() as session:
|
|
unattached = make_upload_file(
|
|
tenant_id=base.context.tenant_id,
|
|
created_by=base.context.end_user_id,
|
|
created_by_role=CreatorUserRole.END_USER,
|
|
)
|
|
tool = ToolFile(
|
|
tenant_id=base.context.tenant_id,
|
|
user_id=base.context.end_user_id,
|
|
conversation_id=None,
|
|
file_key="tools/original.txt",
|
|
mimetype="text/plain",
|
|
)
|
|
session.add_all([unattached, tool])
|
|
session.flush()
|
|
unattached_id, tool_id, original_key = unattached.id, tool.id, unattached.key
|
|
assert harness.claim().get_json()["data"][0]["claimed_conversations"] == 1
|
|
controller = DatabaseFileAccessController()
|
|
scope = FileAccessScope(
|
|
tenant_id=base.context.tenant_id,
|
|
user_id=harness.account.end_user_id,
|
|
user_from=UserFrom.END_USER,
|
|
invoke_from=AppInvokeFrom.WEB_APP,
|
|
)
|
|
with base.sessions() as session:
|
|
for file_id in (upload_id, unattached_id):
|
|
file = controller.get_upload_file(session=session, file_id=file_id, scope=scope)
|
|
assert file is not None
|
|
assert file.created_by == harness.account.end_user_id
|
|
assert file.created_by_role == CreatorUserRole.END_USER
|
|
assert (
|
|
controller.get_upload_file(
|
|
session=session, file_id=file_id, scope=replace(scope, user_id=base.context.end_user_id)
|
|
)
|
|
is None
|
|
)
|
|
stored = session.get(UploadFile, unattached_id)
|
|
assert stored is not None
|
|
assert stored.key == original_key
|
|
file = controller.get_tool_file(session=session, file_id=tool_id, scope=scope)
|
|
assert file is not None
|
|
assert file.user_id == harness.account.end_user_id
|
|
assert file.file_key == "tools/original.txt"
|
|
assert (
|
|
controller.get_tool_file(
|
|
session=session, file_id=tool_id, scope=replace(scope, user_id=base.context.end_user_id)
|
|
)
|
|
is None
|
|
)
|
|
assert harness.claim().get_json()["data"][0]["claimed_conversations"] == 0
|
|
|
|
|
|
def test_claim_transfers_choices_without_transferring_app_authorization(harness: ClaimHarness) -> None:
|
|
base = harness.oauth.base
|
|
selected = base.service.create_api_key(base.context, base.command())
|
|
save_choices(harness, base.context, selected.id)
|
|
response = harness.claim()
|
|
assert response.status_code == 200
|
|
assert response.get_json()["data"][0]["claimed_connections"] == 1
|
|
saved = base.repository.configuration(harness.account, None)
|
|
assert saved[0].connection_id == selected.id
|
|
assert base.repository.configuration(base.context, None) == ()
|
|
service = harness.services.connection_selections
|
|
assert service.get(harness.account, None).reauthorization_required
|
|
save_choices(harness, harness.account, selected.id)
|
|
assert not service.get(harness.account, None).reauthorization_required
|
|
|
|
|
|
@pytest.mark.parametrize("mode", [AppMode.AGENT_CHAT, AppMode.COMPLETION, AppMode.AGENT])
|
|
def test_migration_excludes_app_modes_without_supported_run_tracking(harness: ClaimHarness, mode: AppMode) -> None:
|
|
from models.end_user_connection import EndUserConnection
|
|
from models.model import AppModelConfig
|
|
|
|
base = harness.oauth.base
|
|
connection = base.service.create_api_key(base.context, base.command())
|
|
with base.sessions.begin() as session:
|
|
config = AppModelConfig(app_id=base.context.app_id)
|
|
session.add(config)
|
|
session.flush()
|
|
session.execute(
|
|
sa.update(App).where(App.id == base.context.app_id).values(mode=mode, app_model_config_id=config.id)
|
|
)
|
|
response = harness.client.post("/api/migration/claimable-passports", json={"passports": [harness.guest_passport]})
|
|
assert response.status_code == 200
|
|
assert response.get_json()["data"] == []
|
|
assert harness.claim().status_code == 400
|
|
with base.sessions() as session:
|
|
assert (
|
|
session.scalar(sa.select(EndUserConnection.end_user_id).where(EndUserConnection.id == connection.id))
|
|
== base.context.end_user_id
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("enabled", [False, True])
|
|
@pytest.mark.parametrize("operation", ["rename", "feedback", "unsave", "save"])
|
|
def test_history_write_locks_are_skipped_without_account_login(
|
|
harness: ClaimHarness, monkeypatch: pytest.MonkeyPatch, enabled: bool, operation: str
|
|
) -> None:
|
|
from sqlalchemy.dialects import postgresql
|
|
from sqlalchemy.orm import ORMExecuteState
|
|
|
|
from configs import dify_config
|
|
from models.enums import FeedbackRating
|
|
from repositories.conversation_repository import ConversationRepository
|
|
from repositories.message_repository import MessageRepository
|
|
from repositories.saved_message_repository import SQLAlchemySavedMessageRepository
|
|
from services.entities.conversation_entities import EndUserConversationRef
|
|
from services.entities.message_entities import MessageEndUser
|
|
from services.saved_message_service import SavedMessageActor
|
|
|
|
base = harness.oauth.base
|
|
conversation_id, message_id, _, _ = persist_migration_history(harness, base.context)
|
|
monkeypatch.setattr(dify_config, "WEBAPP_ACCOUNT_PASSPORT_ENABLED", enabled)
|
|
if operation == "save":
|
|
SQLAlchemySavedMessageRepository(session_factory=base.sessions).delete(
|
|
app_id=base.context.app_id,
|
|
actor=SavedMessageActor.end_user(base.context.end_user_id),
|
|
message_id=message_id,
|
|
)
|
|
statements: list[str] = []
|
|
|
|
def record(state: ORMExecuteState) -> None:
|
|
if isinstance(state.statement, sa.sql.Select):
|
|
statements.append(str(state.statement.compile(dialect=postgresql.dialect())))
|
|
|
|
event.listen(Session, "do_orm_execute", record)
|
|
try:
|
|
if operation == "rename":
|
|
ConversationRepository(session_factory=base.sessions).rename(
|
|
ref=EndUserConversationRef(
|
|
app_id=base.context.app_id,
|
|
tenant_id=base.context.tenant_id,
|
|
end_user_id=base.context.end_user_id,
|
|
conversation_id=conversation_id,
|
|
),
|
|
name="Renamed history",
|
|
updated_at=None,
|
|
)
|
|
elif operation == "feedback":
|
|
MessageRepository(session_factory=base.sessions).set_feedback(
|
|
app_id=base.context.app_id,
|
|
app_owner_tenant_id=base.context.tenant_id,
|
|
actor=MessageEndUser(end_user_id=base.context.end_user_id),
|
|
message_id=message_id,
|
|
rating=FeedbackRating.LIKE,
|
|
content=None,
|
|
app_wide=False,
|
|
installed_app=None,
|
|
)
|
|
else:
|
|
saved = SQLAlchemySavedMessageRepository(session_factory=base.sessions)
|
|
mutation = saved.delete if operation == "unsave" else saved.save
|
|
mutation(
|
|
app_id=base.context.app_id,
|
|
actor=SavedMessageActor.end_user(base.context.end_user_id),
|
|
message_id=message_id,
|
|
)
|
|
finally:
|
|
event.remove(Session, "do_orm_execute", record)
|
|
assert any("FOR UPDATE" in statement for statement in statements) is enabled
|
|
|
|
|
|
@pytest.mark.parametrize("enabled", [False, True])
|
|
@pytest.mark.parametrize("operation", ["rename", "delete", "pin", "unpin", "feedback", "variable"])
|
|
def test_current_history_writes_fence_guest_migration(
|
|
harness: ClaimHarness, monkeypatch: pytest.MonkeyPatch, enabled: bool, operation: str
|
|
) -> None:
|
|
from sqlalchemy.dialects import postgresql
|
|
from sqlalchemy.orm import ORMExecuteState
|
|
|
|
from configs import dify_config
|
|
from models.enums import FeedbackRating
|
|
from models.workflow import ConversationVariable
|
|
from repositories.conversation_repository import ConversationRepository
|
|
from repositories.conversation_variable_repository import ConversationVariableRepository
|
|
from repositories.message_repository import MessageRepository
|
|
from services.entities.conversation_entities import EndUserConversationRef
|
|
from services.entities.message_entities import MessageEndUser
|
|
|
|
base = harness.oauth.base
|
|
conversation_id, message_id, _, _ = persist_migration_history(harness, base.context)
|
|
with base.sessions.begin() as session:
|
|
session.execute(sa.update(App).where(App.id == base.context.app_id).values(mode=AppMode.ADVANCED_CHAT))
|
|
variable_id = session.scalar(
|
|
sa.select(ConversationVariable.id).where(ConversationVariable.conversation_id == conversation_id)
|
|
)
|
|
assert variable_id is not None
|
|
monkeypatch.setattr(dify_config, "WEBAPP_ACCOUNT_PASSPORT_ENABLED", enabled)
|
|
ref = EndUserConversationRef(
|
|
tenant_id=base.context.tenant_id,
|
|
app_id=base.context.app_id,
|
|
end_user_id=base.context.end_user_id,
|
|
conversation_id=conversation_id,
|
|
)
|
|
conversations = ConversationRepository(session_factory=base.sessions)
|
|
statements: list[str] = []
|
|
|
|
def record(state: ORMExecuteState) -> None:
|
|
if isinstance(state.statement, sa.sql.Select):
|
|
statements.append(str(state.statement.compile(dialect=postgresql.dialect())))
|
|
|
|
event.listen(Session, "do_orm_execute", record)
|
|
try:
|
|
if operation == "rename":
|
|
conversations.rename(ref=ref, name="Renamed", updated_at=None)
|
|
elif operation == "delete":
|
|
conversations.delete(ref=ref)
|
|
elif operation in {"pin", "unpin"}:
|
|
conversations.set_pinned(ref=ref, is_pinned=operation == "pin")
|
|
elif operation == "feedback":
|
|
MessageRepository(session_factory=base.sessions).set_feedback(
|
|
app_id=ref.app_id,
|
|
app_owner_tenant_id=ref.tenant_id,
|
|
actor=MessageEndUser(ref.end_user_id),
|
|
message_id=message_id,
|
|
rating=FeedbackRating.LIKE,
|
|
content=None,
|
|
app_wide=False,
|
|
installed_app=None,
|
|
)
|
|
else:
|
|
ConversationVariableRepository(session_factory=base.sessions).update(
|
|
ref=ref, variable_id=variable_id, transform=lambda variable: variable
|
|
)
|
|
finally:
|
|
event.remove(Session, "do_orm_execute", record)
|
|
assert any("FOR UPDATE OF conversations" in statement for statement in statements) is enabled
|
|
|
|
|
|
@pytest.mark.parametrize("enabled", [False, True])
|
|
def test_migrated_message_page_uses_current_conversation_owner(
|
|
harness: ClaimHarness, monkeypatch: pytest.MonkeyPatch, enabled: bool
|
|
) -> None:
|
|
from configs import dify_config
|
|
from models.enums import FeedbackRating
|
|
from repositories.message_repository import MessageRepository
|
|
from services.entities.message_entities import MessageEndUser
|
|
from services.errors.conversation import ConversationNotExistsError
|
|
from services.errors.message import MessageNotExistsError
|
|
|
|
base = harness.oauth.base
|
|
conversation_id, message_id, _, _ = persist_migration_history(harness, base.context)
|
|
assert harness.claim().status_code == 200
|
|
monkeypatch.setattr(dify_config, "WEBAPP_ACCOUNT_PASSPORT_ENABLED", enabled)
|
|
messages = MessageRepository(session_factory=base.sessions)
|
|
page = messages.get_message_page(
|
|
app_id=base.context.app_id,
|
|
app_owner_tenant_id=base.context.tenant_id,
|
|
actor=MessageEndUser(harness.account.end_user_id),
|
|
conversation_id=conversation_id,
|
|
first_id=None,
|
|
limit=10,
|
|
installed_app=None,
|
|
)
|
|
assert [source.record.id for source in page.data] == [message_id]
|
|
messages.set_feedback(
|
|
app_id=base.context.app_id,
|
|
app_owner_tenant_id=base.context.tenant_id,
|
|
actor=MessageEndUser(harness.account.end_user_id),
|
|
message_id=message_id,
|
|
rating=FeedbackRating.LIKE,
|
|
content=None,
|
|
app_wide=False,
|
|
installed_app=None,
|
|
)
|
|
with pytest.raises(MessageNotExistsError):
|
|
messages.set_feedback(
|
|
app_id=base.context.app_id,
|
|
app_owner_tenant_id=base.context.tenant_id,
|
|
actor=MessageEndUser(base.context.end_user_id),
|
|
message_id=message_id,
|
|
rating=FeedbackRating.LIKE,
|
|
content=None,
|
|
app_wide=False,
|
|
installed_app=None,
|
|
)
|
|
with pytest.raises(ConversationNotExistsError):
|
|
messages.get_message_page(
|
|
app_id=base.context.app_id,
|
|
app_owner_tenant_id=base.context.tenant_id,
|
|
actor=MessageEndUser(base.context.end_user_id),
|
|
conversation_id=conversation_id,
|
|
first_id=None,
|
|
limit=10,
|
|
installed_app=None,
|
|
)
|
|
|
|
|
|
def test_preparation_without_an_admitted_run_does_not_block_claim(harness: ClaimHarness) -> None:
|
|
from models.workflow import WorkflowRun
|
|
|
|
base = harness.oauth.base
|
|
_, _, run_id, _ = persist_migration_history(harness, base.context)
|
|
with base.sessions.begin() as session:
|
|
session.execute(sa.delete(WorkflowRun).where(WorkflowRun.id == run_id))
|
|
assert harness.claim().status_code == 200
|
|
|
|
|
|
def test_claim_stops_active_run_and_handles_more_than_one_conversation_chunk(harness: ClaimHarness) -> None:
|
|
from graphon.enums import WorkflowExecutionStatus
|
|
from models.workflow import WorkflowRun
|
|
|
|
base = harness.oauth.base
|
|
active_id, _, run_id, _ = persist_migration_history(harness, base.context)
|
|
with base.sessions.begin() as session:
|
|
session.execute(
|
|
sa.update(WorkflowRun)
|
|
.where(WorkflowRun.id == run_id)
|
|
.values(
|
|
status=WorkflowExecutionStatus.RUNNING,
|
|
finished_at=None,
|
|
)
|
|
)
|
|
session.add_all(
|
|
[
|
|
Conversation(
|
|
app_id=base.context.app_id,
|
|
mode=AppMode.ADVANCED_CHAT,
|
|
name=f"Idle {index}",
|
|
from_source=ConversationFromSource.API,
|
|
from_end_user_id=base.context.end_user_id,
|
|
invoke_from=InvokeFrom.WEB_APP,
|
|
_inputs={},
|
|
)
|
|
for index in range(501)
|
|
]
|
|
)
|
|
result = harness.claim().get_json()["data"][0]
|
|
assert result["claimed_conversations"] == 502
|
|
assert result["stop_requested_run_count"] == 1
|
|
with base.sessions() as session:
|
|
assert (
|
|
session.scalar(
|
|
sa.select(sa.func.count())
|
|
.select_from(Conversation)
|
|
.where(
|
|
Conversation.app_id == base.context.app_id,
|
|
Conversation.from_end_user_id == harness.account.end_user_id,
|
|
)
|
|
)
|
|
== 502
|
|
)
|
|
|
|
|
|
def test_batch_preflight_failure_still_prevents_all_transfers_with_an_active_run(harness: ClaimHarness) -> None:
|
|
from graphon.enums import WorkflowExecutionStatus
|
|
from models.model import UploadFile
|
|
from models.workflow import WorkflowRun
|
|
|
|
base = harness.oauth.base
|
|
connection = base.service.create_api_key(base.context, base.command())
|
|
conversation_id, _, run_id, file_id = persist_migration_history(harness, base.context)
|
|
with base.sessions.begin() as session:
|
|
session.execute(
|
|
sa.update(WorkflowRun)
|
|
.where(WorkflowRun.id == run_id)
|
|
.values(
|
|
status=WorkflowExecutionStatus.RUNNING,
|
|
finished_at=None,
|
|
)
|
|
)
|
|
response = harness.client.post(
|
|
"/api/migration/claim",
|
|
headers={"Authorization": f"Bearer {harness.proof}"},
|
|
json={"passports": [harness.guest_passport, "invalid"]},
|
|
)
|
|
assert response.status_code == 400
|
|
assert response.get_json()["code"] == "connection_claim_preflight_failed"
|
|
assert base.service.get_connection(base.context, connection.id).id == connection.id
|
|
with base.sessions() as session:
|
|
assert session.scalar(sa.select(WorkflowRun.stop_requested_at).where(WorkflowRun.id == run_id)) is None
|
|
assert (
|
|
session.scalar(sa.select(Conversation.from_end_user_id).where(Conversation.id == conversation_id))
|
|
== base.context.end_user_id
|
|
)
|
|
assert (
|
|
session.scalar(sa.select(UploadFile.created_by).where(UploadFile.id == file_id)) == base.context.end_user_id
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("stop_failure", [False, True])
|
|
def test_batch_continues_after_stopping_or_rejecting_an_active_passport(
|
|
harness: ClaimHarness, stop_failure: bool
|
|
) -> None:
|
|
from graphon.enums import WorkflowExecutionStatus
|
|
from models.workflow import WorkflowRun
|
|
|
|
base = harness.oauth.base
|
|
active_id, _, run_id, _ = persist_migration_history(harness, base.context)
|
|
with base.sessions.begin() as session:
|
|
session.execute(
|
|
sa.update(WorkflowRun)
|
|
.where(WorkflowRun.id == run_id)
|
|
.values(
|
|
status=WorkflowExecutionStatus.RUNNING,
|
|
task_id=None if stop_failure else str(uuid4()),
|
|
finished_at=None,
|
|
)
|
|
)
|
|
second_guest = EndUser(
|
|
tenant_id=base.context.tenant_id,
|
|
app_id=base.context.app_id,
|
|
type=EndUserType.BROWSER,
|
|
session_id=str(uuid4()),
|
|
)
|
|
session.add(second_guest)
|
|
session.flush()
|
|
second_id = second_guest.id
|
|
second_context = replace(base.context, end_user_id=second_id)
|
|
second_conversation, _, _, _ = persist_migration_history(harness, second_context)
|
|
second_token = harness.signer.issue(
|
|
{
|
|
"sub": "Web API Passport",
|
|
"app_id": base.context.app_id,
|
|
"app_code": harness.oauth.app_code,
|
|
"end_user_id": second_id,
|
|
}
|
|
)
|
|
response = harness.client.post(
|
|
"/api/migration/claim",
|
|
headers={"Authorization": f"Bearer {harness.proof}"},
|
|
json={"passports": [harness.guest_passport, second_token]},
|
|
)
|
|
assert response.status_code == 200
|
|
first, second = response.get_json()["data"]
|
|
if stop_failure:
|
|
assert first["status"] == "failed"
|
|
assert first["error"]["code"] == "connection_claim_unavailable"
|
|
else:
|
|
assert (first["status"], first["stop_requested_run_count"], first["claimed_conversations"]) == ("success", 1, 1)
|
|
assert (second["status"], second["stop_requested_run_count"], second["claimed_conversations"]) == (
|
|
"success",
|
|
0,
|
|
1,
|
|
)
|
|
with base.sessions() as session:
|
|
assert session.scalar(sa.select(Conversation.from_end_user_id).where(Conversation.id == active_id)) == (
|
|
base.context.end_user_id if stop_failure else harness.account.end_user_id
|
|
)
|
|
assert (
|
|
session.scalar(sa.select(Conversation.from_end_user_id).where(Conversation.id == second_conversation))
|
|
== harness.account.end_user_id
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("failure", ["missing_task", "transport", "scheduled"])
|
|
def test_stop_failure_keeps_guest_resources_and_allows_retry(
|
|
harness: ClaimHarness,
|
|
redis_transport: tuple[RedisClientWrapper, MagicMock],
|
|
failure: str,
|
|
) -> None:
|
|
from redis.exceptions import ConnectionError as RedisConnectionError
|
|
|
|
from graphon.enums import WorkflowExecutionStatus
|
|
from models.model import UploadFile
|
|
from models.workflow import WorkflowRun
|
|
|
|
base = harness.oauth.base
|
|
selected = base.service.create_api_key(base.context, base.command())
|
|
conversation_id, _, run_id, file_id = persist_migration_history(harness, base.context)
|
|
task_id = str(uuid4())
|
|
with base.sessions.begin() as session:
|
|
session.execute(
|
|
sa.update(WorkflowRun)
|
|
.where(WorkflowRun.id == run_id)
|
|
.values(
|
|
status=WorkflowExecutionStatus.SCHEDULED if failure == "scheduled" else WorkflowExecutionStatus.RUNNING,
|
|
task_id=None if failure == "missing_task" else task_id,
|
|
finished_at=None,
|
|
)
|
|
)
|
|
if failure == "transport":
|
|
redis_transport[1].side_effect = RedisConnectionError("Cancellation transport unavailable")
|
|
result = harness.claim().get_json()["data"][0]
|
|
assert result["status"] == "failed"
|
|
assert result["error"]["code"] == "connection_claim_unavailable"
|
|
assert base.service.get_connection(base.context, selected.id).id == selected.id
|
|
with base.sessions() as session:
|
|
assert (
|
|
session.scalar(sa.select(Conversation.from_end_user_id).where(Conversation.id == conversation_id))
|
|
== base.context.end_user_id
|
|
)
|
|
assert (
|
|
session.scalar(sa.select(UploadFile.created_by).where(UploadFile.id == file_id)) == base.context.end_user_id
|
|
)
|
|
redis_transport[1].side_effect = None
|
|
with base.sessions.begin() as session:
|
|
session.execute(
|
|
sa.update(WorkflowRun)
|
|
.where(WorkflowRun.id == run_id)
|
|
.values(task_id=task_id, status=WorkflowExecutionStatus.RUNNING)
|
|
)
|
|
retried = harness.claim().get_json()["data"][0]
|
|
assert (
|
|
retried["status"],
|
|
retried["claimed_connections"],
|
|
retried["claimed_conversations"],
|
|
retried["stop_requested_run_count"],
|
|
) == ("success", 1, 1, 1)
|
|
|
|
|
|
def test_claim_stops_only_source_guest_runs(harness: ClaimHarness) -> None:
|
|
from graphon.enums import WorkflowExecutionStatus
|
|
from models.workflow import WorkflowRun
|
|
|
|
base = harness.oauth.base
|
|
_, _, guest_run, _ = persist_migration_history(harness, base.context)
|
|
_, _, account_run, _ = persist_migration_history(harness, harness.account)
|
|
with base.sessions.begin() as session:
|
|
session.execute(
|
|
sa.update(WorkflowRun)
|
|
.where(WorkflowRun.id.in_((guest_run, account_run)))
|
|
.values(status=WorkflowExecutionStatus.RUNNING, finished_at=None)
|
|
)
|
|
result = harness.claim().get_json()["data"][0]
|
|
assert result["stop_requested_run_count"] == 1
|
|
with base.sessions() as session:
|
|
assert session.scalar(sa.select(WorkflowRun.stop_requested_at).where(WorkflowRun.id == guest_run)) is not None
|
|
assert session.scalar(sa.select(WorkflowRun.stop_requested_at).where(WorkflowRun.id == account_run)) is None
|
|
|
|
|
|
def test_claim_stops_standalone_workflow_run(harness: ClaimHarness) -> None:
|
|
from graphon.enums import WorkflowExecutionStatus
|
|
from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom
|
|
from models.workflow import WorkflowRun
|
|
|
|
base = harness.oauth.base
|
|
run_id = str(uuid4())
|
|
with base.sessions.begin() as session:
|
|
session.execute(sa.update(App).where(App.id == base.context.app_id).values(mode=AppMode.WORKFLOW))
|
|
session.add(
|
|
WorkflowRun(
|
|
id=run_id,
|
|
task_id=str(uuid4()),
|
|
tenant_id=base.context.tenant_id,
|
|
app_id=base.context.app_id,
|
|
workflow_id=base.workflow_id,
|
|
type=WorkflowType.WORKFLOW,
|
|
triggered_from=WorkflowRunTriggeredFrom.APP_RUN,
|
|
version="published",
|
|
graph="{}",
|
|
inputs="{}",
|
|
outputs="{}",
|
|
status=WorkflowExecutionStatus.RUNNING,
|
|
created_by_role=CreatorUserRole.END_USER,
|
|
created_by=base.context.end_user_id,
|
|
)
|
|
)
|
|
result = harness.claim().get_json()["data"][0]
|
|
assert (result["status"], result["claimed_conversations"], result["stop_requested_run_count"]) == ("success", 0, 1)
|
|
with base.sessions() as session:
|
|
assert session.scalar(sa.select(WorkflowRun.stop_requested_at).where(WorkflowRun.id == run_id)) is not None
|
|
|
|
|
|
def test_claim_waits_for_run_completion_before_transferring(
|
|
harness: ClaimHarness, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
from graphon.enums import WorkflowExecutionStatus
|
|
from models.workflow import WorkflowRun
|
|
from services import web_migration_service
|
|
|
|
base = harness.oauth.base
|
|
connection = base.service.create_api_key(base.context, base.command())
|
|
conversation_id, _, run_id, _ = persist_migration_history(harness, base.context)
|
|
with base.sessions.begin() as session:
|
|
session.execute(
|
|
sa.update(WorkflowRun)
|
|
.where(WorkflowRun.id == run_id)
|
|
.values(status=WorkflowExecutionStatus.RUNNING, finished_at=None)
|
|
)
|
|
finish = web_migration_service.sleep
|
|
now = [0.0]
|
|
delays: list[float] = []
|
|
|
|
def wait(delay: float) -> None:
|
|
delays.append(delay)
|
|
now[0] += delay
|
|
assert base.service.get_connection(base.context, connection.id).id == connection.id
|
|
with base.sessions() as session:
|
|
assert (
|
|
session.scalar(sa.select(Conversation.from_end_user_id).where(Conversation.id == conversation_id))
|
|
== base.context.end_user_id
|
|
)
|
|
if len(delays) == 2:
|
|
finish(delay)
|
|
|
|
monkeypatch.setattr(web_migration_service, "monotonic", lambda: now[0])
|
|
monkeypatch.setattr(web_migration_service, "sleep", wait)
|
|
item = harness.claim().get_json()["data"][0]
|
|
assert (
|
|
item["status"],
|
|
item["claimed_connections"],
|
|
item["claimed_conversations"],
|
|
item["stop_requested_run_count"],
|
|
) == ("success", 1, 1, 1)
|
|
assert delays == [0.1, 0.1]
|
|
with base.sessions() as session:
|
|
assert (
|
|
session.scalar(sa.select(WorkflowRun.status).where(WorkflowRun.id == run_id))
|
|
== WorkflowExecutionStatus.STOPPED
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("second_active", [False, True])
|
|
def test_readiness_timeout_preserves_resources_and_shares_one_batch_budget(
|
|
harness: ClaimHarness, monkeypatch: pytest.MonkeyPatch, second_active: bool
|
|
) -> None:
|
|
from graphon.enums import WorkflowExecutionStatus
|
|
from models.model import UploadFile
|
|
from models.workflow import WorkflowRun
|
|
from services import web_migration_service
|
|
|
|
base = harness.oauth.base
|
|
connection = base.service.create_api_key(base.context, base.command())
|
|
conversation_id, _, run_id, file_id = persist_migration_history(harness, base.context)
|
|
with base.sessions.begin() as session:
|
|
second_guest = EndUser(
|
|
tenant_id=base.context.tenant_id,
|
|
app_id=base.context.app_id,
|
|
type=EndUserType.BROWSER,
|
|
session_id=str(uuid4()),
|
|
)
|
|
session.add(second_guest)
|
|
session.flush()
|
|
second_id = second_guest.id
|
|
second_context = replace(base.context, end_user_id=second_id)
|
|
second_conversation, _, second_run, _ = persist_migration_history(harness, second_context)
|
|
with base.sessions.begin() as session:
|
|
run_ids = (run_id, second_run) if second_active else (run_id,)
|
|
session.execute(
|
|
sa.update(WorkflowRun)
|
|
.where(WorkflowRun.id.in_(run_ids))
|
|
.values(status=WorkflowExecutionStatus.RUNNING, finished_at=None)
|
|
)
|
|
second_token = harness.signer.issue(
|
|
{
|
|
"sub": "Web API Passport",
|
|
"app_id": base.context.app_id,
|
|
"app_code": harness.oauth.app_code,
|
|
"end_user_id": second_id,
|
|
}
|
|
)
|
|
now = [0.0]
|
|
delays: list[float] = []
|
|
|
|
def wait(delay: float) -> None:
|
|
delays.append(delay)
|
|
now[0] += delay
|
|
|
|
monkeypatch.setattr(web_migration_service, "monotonic", lambda: now[0])
|
|
monkeypatch.setattr(web_migration_service, "sleep", wait)
|
|
response = harness.client.post(
|
|
"/api/migration/claim",
|
|
headers={"Authorization": f"Bearer {harness.proof}"},
|
|
json={"passports": [harness.guest_passport, second_token]},
|
|
)
|
|
assert response.status_code == 200
|
|
first, second = response.get_json()["data"]
|
|
assert first["status"] == "failed"
|
|
assert first["error"]["code"] == "migration_not_ready"
|
|
assert first["error"]["status"] == 409
|
|
if second_active:
|
|
assert second["status"] == "failed"
|
|
assert second["error"]["code"] == "migration_not_ready"
|
|
else:
|
|
assert (second["status"], second["claimed_conversations"]) == ("success", 1)
|
|
assert sum(delays) == pytest.approx(5.0)
|
|
assert all(0 < delay <= 0.1 for delay in delays)
|
|
assert base.service.get_connection(base.context, connection.id).id == connection.id
|
|
with base.sessions() as session:
|
|
assert session.scalar(sa.select(WorkflowRun.stop_requested_at).where(WorkflowRun.id == run_id)) is not None
|
|
assert (
|
|
session.scalar(sa.select(Conversation.from_end_user_id).where(Conversation.id == conversation_id))
|
|
== base.context.end_user_id
|
|
)
|
|
assert (
|
|
session.scalar(sa.select(UploadFile.created_by).where(UploadFile.id == file_id)) == base.context.end_user_id
|
|
)
|
|
assert session.scalar(
|
|
sa.select(Conversation.from_end_user_id).where(Conversation.id == second_conversation)
|
|
) == (second_id if second_active else harness.account.end_user_id)
|
|
with base.sessions.begin() as session:
|
|
session.execute(
|
|
sa.update(WorkflowRun)
|
|
.where(WorkflowRun.id == run_id)
|
|
.values(status=WorkflowExecutionStatus.STOPPED, finished_at=datetime.now(UTC))
|
|
)
|
|
retried = harness.claim().get_json()["data"][0]
|
|
assert (
|
|
retried["status"],
|
|
retried["claimed_connections"],
|
|
retried["claimed_conversations"],
|
|
retried["stop_requested_run_count"],
|
|
) == ("success", 1, 1, 0)
|
|
|
|
|
|
def test_transfer_rejects_run_started_after_readiness(harness: ClaimHarness) -> None:
|
|
from graphon.enums import WorkflowExecutionStatus
|
|
from models.model import UploadFile
|
|
from models.workflow import WorkflowRun
|
|
from services.web_migration_service import GuestMigrationNotReadyError
|
|
|
|
base = harness.oauth.base
|
|
conversation_id, _, run_id, file_id = persist_migration_history(harness, base.context)
|
|
repository = WebMigrationRepository(session_factory=base.sessions)
|
|
source = repository.get_claim_source(
|
|
app_code=harness.oauth.app_code, app_id=base.context.app_id, end_user_id=base.context.end_user_id
|
|
)
|
|
assert source is not None
|
|
assert repository.active_task_ids(source) == ()
|
|
with base.sessions.begin() as session:
|
|
session.execute(
|
|
sa.update(WorkflowRun)
|
|
.where(WorkflowRun.id == run_id)
|
|
.values(status=WorkflowExecutionStatus.RUNNING, finished_at=None)
|
|
)
|
|
with pytest.raises(GuestMigrationNotReadyError):
|
|
repository.claim_guest(source, account_end_user_id=harness.account.end_user_id)
|
|
with base.sessions() as session:
|
|
assert (
|
|
session.scalar(sa.select(Conversation.from_end_user_id).where(Conversation.id == conversation_id))
|
|
== base.context.end_user_id
|
|
)
|
|
assert (
|
|
session.scalar(sa.select(UploadFile.created_by).where(UploadFile.id == file_id)) == base.context.end_user_id
|
|
)
|