Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b551da084 | ||
|
|
9040f7fc47 | ||
|
|
5dd82d6039 | ||
|
|
2cb6003823 | ||
|
|
2f73bb8c0e | ||
|
|
a79ee941c4 | ||
|
|
fe46ecbc54 | ||
|
|
44b75ccdf0 | ||
|
|
7da1b4cf04 | ||
|
|
82a5af229d | ||
|
|
43373e424f | ||
|
|
c46f2a1b34 | ||
|
|
5d8c9835f9 | ||
|
|
a6f5e3b2f8 | ||
|
|
61434ba03e | ||
|
|
761f1f3988 | ||
|
|
a5368478f5 | ||
|
|
7e5c65175a | ||
|
|
09690fbb7d | ||
|
|
1144060a39 | ||
|
|
6a7ec862b1 |
+2
-1
@@ -221,10 +221,11 @@ def initialize_extensions(app: DifyApp):
|
||||
|
||||
def create_migrations_app() -> DifyApp:
|
||||
app = create_flask_app_with_configs()
|
||||
from extensions import ext_database, ext_migrate
|
||||
from extensions import ext_commands, ext_database, ext_migrate
|
||||
|
||||
# Initialize only required extensions
|
||||
ext_database.init_app(app)
|
||||
ext_migrate.init_app(app)
|
||||
ext_commands.init_app(app)
|
||||
|
||||
return app
|
||||
|
||||
@@ -3,6 +3,7 @@ CLI command modules extracted from `commands.py`.
|
||||
"""
|
||||
|
||||
from .account import create_tenant, reset_email, reset_password
|
||||
from .data_migrate import data_migrate, legacy_model_types
|
||||
from .plugin import (
|
||||
extract_plugins,
|
||||
extract_unique_plugins,
|
||||
@@ -44,6 +45,7 @@ __all__ = [
|
||||
"clear_orphaned_file_records",
|
||||
"convert_to_agent_apps",
|
||||
"create_tenant",
|
||||
"data_migrate",
|
||||
"delete_archived_workflow_runs",
|
||||
"export_app_messages",
|
||||
"extract_plugins",
|
||||
@@ -52,6 +54,7 @@ __all__ = [
|
||||
"fix_app_site_missing",
|
||||
"install_plugins",
|
||||
"install_rag_pipeline_plugins",
|
||||
"legacy_model_types",
|
||||
"migrate_annotation_vector_database",
|
||||
"migrate_data_for_plugin",
|
||||
"migrate_knowledge_vector_database",
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
from contextlib import AbstractContextManager, nullcontext
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import click
|
||||
|
||||
from extensions.ext_database import db
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from services.legacy_model_type_migration import (
|
||||
VALID_TABLE_NAMES,
|
||||
LegacyModelTypeMigrationService,
|
||||
load_tenant_ids_from_file,
|
||||
)
|
||||
|
||||
_SUPPORTED_MODEL_TYPE_CHOICES = (
|
||||
ModelType.LLM.value,
|
||||
ModelType.TEXT_EMBEDDING.value,
|
||||
ModelType.RERANK.value,
|
||||
)
|
||||
_DEFAULT_CONCURRENCY = os.cpu_count() or 1
|
||||
|
||||
|
||||
def _normalize_multi_value_option(
|
||||
values: tuple[str, ...],
|
||||
*,
|
||||
valid_values: tuple[str, ...],
|
||||
option_name: str,
|
||||
) -> tuple[str, ...]:
|
||||
normalized_values: list[str] = []
|
||||
seen_values: set[str] = set()
|
||||
|
||||
for value in values:
|
||||
for item in value.split(","):
|
||||
normalized_item = item.strip()
|
||||
if not normalized_item:
|
||||
continue
|
||||
if normalized_item not in valid_values:
|
||||
raise click.BadParameter(
|
||||
f"invalid value '{normalized_item}'. valid values: {', '.join(valid_values)}",
|
||||
param_hint=option_name,
|
||||
)
|
||||
if normalized_item in seen_values:
|
||||
continue
|
||||
seen_values.add(normalized_item)
|
||||
normalized_values.append(normalized_item)
|
||||
|
||||
return tuple(normalized_values)
|
||||
|
||||
|
||||
@click.group(
|
||||
"data-migrate",
|
||||
help="Online data migration commands.",
|
||||
)
|
||||
def data_migrate() -> None:
|
||||
"""Namespace for production data migration commands."""
|
||||
|
||||
|
||||
@click.command(
|
||||
"legacy-model-types",
|
||||
help=(
|
||||
"Migrate legacy provider model_type values to canonical values. "
|
||||
"Default is dry-run and emits JSON lines only. "
|
||||
"If --tables includes provider_model_credentials, the command may also update "
|
||||
"provider_models and load_balancing_model_configs references so merged credentials stay reachable."
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"--apply",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Apply the migration. Default is dry-run.",
|
||||
)
|
||||
@click.option(
|
||||
"--tables",
|
||||
"tables",
|
||||
multiple=True,
|
||||
type=str,
|
||||
help=(
|
||||
"Limit migration to specific tables. Accepts comma-separated values or repeated flags.\n"
|
||||
"\n"
|
||||
"Options: load_balancing_model_configs, provider_model_credentials, "
|
||||
"provider_model_settings, provider_models, tenant_default_models.\n\n"
|
||||
"When provider_model_credentials is selected, provider_models and "
|
||||
"load_balancing_model_configs may also be updated for credential reference rewrites.\n"
|
||||
"\n"
|
||||
"If unspecified, all relevant tables are migrated."
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"--model-types",
|
||||
"model_types",
|
||||
multiple=True,
|
||||
type=str,
|
||||
help=(
|
||||
"Canonical model types to migrate. Accepts comma-separated values or repeated flags.\n"
|
||||
"\n"
|
||||
"Options: llm,text-embedding,rerank\n"
|
||||
"\n"
|
||||
"If unspecified, all relevant legacy model types are migrated."
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"--tenant-id-file",
|
||||
type=click.Path(exists=True, dir_okay=False, readable=True, resolve_path=True),
|
||||
help="Optional file containing tenant ids, one per line.",
|
||||
)
|
||||
@click.option(
|
||||
"--output",
|
||||
type=click.Path(dir_okay=False, resolve_path=True, path_type=Path),
|
||||
help=(
|
||||
"Optional file path for JSON lines event logs. Defaults to stdout.\n"
|
||||
"It's highly recommended to save the event logs to a file and preserve it for a period of time."
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"--concurrency",
|
||||
type=click.IntRange(min=1),
|
||||
default=_DEFAULT_CONCURRENCY,
|
||||
show_default=True,
|
||||
help="Number of tenant-level worker threads to run in parallel.",
|
||||
)
|
||||
def legacy_model_types(
|
||||
apply: bool,
|
||||
tables: tuple[str, ...],
|
||||
model_types: tuple[str, ...],
|
||||
tenant_id_file: str | None,
|
||||
output: Path | None,
|
||||
concurrency: int = _DEFAULT_CONCURRENCY,
|
||||
) -> None:
|
||||
"""
|
||||
Migrate legacy provider-related model_type values and emit JSON lines events.
|
||||
"""
|
||||
|
||||
normalized_tables = _normalize_multi_value_option(
|
||||
tables,
|
||||
valid_values=VALID_TABLE_NAMES,
|
||||
option_name="--tables",
|
||||
)
|
||||
normalized_model_types = _normalize_multi_value_option(
|
||||
model_types,
|
||||
valid_values=_SUPPORTED_MODEL_TYPE_CHOICES,
|
||||
option_name="--model-types",
|
||||
)
|
||||
selected_model_types = (
|
||||
tuple(ModelType.value_of(model_type) for model_type in normalized_model_types)
|
||||
if normalized_model_types
|
||||
else (
|
||||
ModelType.LLM,
|
||||
ModelType.TEXT_EMBEDDING,
|
||||
ModelType.RERANK,
|
||||
)
|
||||
)
|
||||
tenant_ids = load_tenant_ids_from_file(tenant_id_file) if tenant_id_file else None
|
||||
|
||||
output_context: AbstractContextManager[io.TextIOBase]
|
||||
if output is None:
|
||||
output_context = nullcontext(cast(io.TextIOBase, sys.stdout))
|
||||
else:
|
||||
try:
|
||||
output_context = output.open("w", encoding="utf-8")
|
||||
except OSError as exc:
|
||||
raise click.ClickException(f"failed to open output file '{output}': {exc.strerror or exc}") from exc
|
||||
|
||||
with output_context as output_stream:
|
||||
LegacyModelTypeMigrationService(
|
||||
engine=db.engine,
|
||||
apply=apply,
|
||||
concurrency=concurrency,
|
||||
output=cast(io.TextIOBase, output_stream),
|
||||
tables=normalized_tables or None,
|
||||
model_types=selected_model_types,
|
||||
tenant_ids=tenant_ids,
|
||||
).migrate()
|
||||
|
||||
|
||||
data_migrate.add_command(legacy_model_types)
|
||||
@@ -39,11 +39,8 @@ class HitTestingPayload(BaseModel):
|
||||
|
||||
class DatasetsHitTestingBase:
|
||||
@staticmethod
|
||||
def _normalize_hit_testing_query(query: Any) -> str:
|
||||
"""Return the user-visible query string from legacy and current response shapes."""
|
||||
if isinstance(query, str):
|
||||
return query
|
||||
|
||||
def _extract_hit_testing_query(query: Any) -> str:
|
||||
"""Return the query string from the service response shape."""
|
||||
if isinstance(query, dict):
|
||||
content = query.get("content")
|
||||
if isinstance(content, str):
|
||||
@@ -52,15 +49,15 @@ class DatasetsHitTestingBase:
|
||||
raise ValueError("Invalid hit testing query response")
|
||||
|
||||
@staticmethod
|
||||
def _normalize_hit_testing_records(records: Any) -> list[dict[str, Any]]:
|
||||
"""Coerce nullable collection fields into lists before response validation."""
|
||||
def _prepare_hit_testing_records(records: Any) -> list[dict[str, Any]]:
|
||||
"""Ensure collection fields match the API schema before response validation."""
|
||||
if not isinstance(records, list):
|
||||
return []
|
||||
raise ValueError("Invalid hit testing records response")
|
||||
|
||||
normalized_records: list[dict[str, Any]] = []
|
||||
for record in records:
|
||||
if not isinstance(record, dict):
|
||||
continue
|
||||
raise ValueError("Invalid hit testing record response")
|
||||
|
||||
normalized_record = dict(record)
|
||||
segment = normalized_record.get("segment")
|
||||
@@ -118,8 +115,8 @@ class DatasetsHitTestingBase:
|
||||
limit=10,
|
||||
)
|
||||
return {
|
||||
"query": DatasetsHitTestingBase._normalize_hit_testing_query(response.get("query")),
|
||||
"records": DatasetsHitTestingBase._normalize_hit_testing_records(
|
||||
"query": DatasetsHitTestingBase._extract_hit_testing_query(response.get("query")),
|
||||
"records": DatasetsHitTestingBase._prepare_hit_testing_records(
|
||||
marshal(response.get("records", []), hit_testing_record_fields)
|
||||
),
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@ class TagBasePayload(BaseModel):
|
||||
type: TagType = Field(description="Tag type")
|
||||
|
||||
|
||||
class TagUpdateRequestPayload(BaseModel):
|
||||
name: str = Field(description="Tag name", min_length=1, max_length=50)
|
||||
|
||||
|
||||
class TagBindingPayload(BaseModel):
|
||||
tag_ids: list[str] = Field(description="Tag IDs to bind")
|
||||
target_id: str = Field(description="Target ID to bind tags to")
|
||||
@@ -68,6 +72,7 @@ class TagResponse(ResponseModel):
|
||||
register_schema_models(
|
||||
console_ns,
|
||||
TagBasePayload,
|
||||
TagUpdateRequestPayload,
|
||||
TagBindingPayload,
|
||||
TagBindingRemovePayload,
|
||||
TagListQueryParam,
|
||||
@@ -118,7 +123,7 @@ class TagListApi(Resource):
|
||||
|
||||
@console_ns.route("/tags/<uuid:tag_id>")
|
||||
class TagUpdateDeleteApi(Resource):
|
||||
@console_ns.expect(console_ns.models[TagBasePayload.__name__])
|
||||
@console_ns.expect(console_ns.models[TagUpdateRequestPayload.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@@ -129,8 +134,8 @@ class TagUpdateDeleteApi(Resource):
|
||||
if not (current_user.has_edit_permission or current_user.is_dataset_editor):
|
||||
raise Forbidden()
|
||||
|
||||
payload = TagBasePayload.model_validate(console_ns.payload or {})
|
||||
tag = TagService.update_tags(UpdateTagPayload(name=payload.name, type=payload.type), tag_id)
|
||||
payload = TagUpdateRequestPayload.model_validate(console_ns.payload or {})
|
||||
tag = TagService.update_tags(UpdateTagPayload(name=payload.name), tag_id)
|
||||
|
||||
binding_count = TagService.get_tag_binding_count(tag_id)
|
||||
|
||||
|
||||
@@ -50,6 +50,12 @@ from models.enums import CreatorUserRole
|
||||
from models.model import UploadFile
|
||||
from services.account_service import AccountService
|
||||
from services.billing_service import BillingService
|
||||
from services.entities.auth_entities import (
|
||||
ChangeEmailNewEmailToken,
|
||||
ChangeEmailNewEmailVerifiedToken,
|
||||
ChangeEmailOldEmailToken,
|
||||
ChangeEmailOldEmailVerifiedToken,
|
||||
)
|
||||
from services.errors.account import CurrentPasswordIncorrectError as ServiceCurrentPasswordIncorrectError
|
||||
|
||||
|
||||
@@ -607,8 +613,8 @@ class ChangeEmailSendEmailApi(Resource):
|
||||
language = "zh-Hans"
|
||||
else:
|
||||
language = "en-US"
|
||||
account = None
|
||||
user_email = None
|
||||
account = current_user
|
||||
user_email = current_user.email
|
||||
email_for_sending = args.email.lower()
|
||||
# Default to the initial phase; any legacy/unexpected client input is
|
||||
# coerced back to `old_email` so we never trust the caller to declare
|
||||
@@ -623,24 +629,18 @@ class ChangeEmailSendEmailApi(Resource):
|
||||
if reset_data is None:
|
||||
raise InvalidTokenError()
|
||||
|
||||
# The token used to request a new-email code must come from the
|
||||
# old-email verification step. This prevents the bypass described
|
||||
# in GHSA-4q3w-q5mc-45rq where the phase-1 token was reused here.
|
||||
token_phase = reset_data.get(AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY)
|
||||
if token_phase != AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED:
|
||||
if not isinstance(reset_data, ChangeEmailOldEmailVerifiedToken):
|
||||
raise InvalidTokenError()
|
||||
user_email = reset_data.get("email", "")
|
||||
if not reset_data.is_bound_to_account(current_user.id):
|
||||
raise InvalidTokenError()
|
||||
user_email = reset_data.email
|
||||
|
||||
if user_email.lower() != current_user.email.lower():
|
||||
raise InvalidEmailError()
|
||||
|
||||
user_email = current_user.email
|
||||
else:
|
||||
account = AccountService.get_account_by_email_with_case_fallback(args.email)
|
||||
if account is None:
|
||||
raise AccountNotFound()
|
||||
email_for_sending = account.email
|
||||
user_email = account.email
|
||||
if email_for_sending != current_user.email.lower():
|
||||
raise InvalidEmailError()
|
||||
email_for_sending = current_user.email
|
||||
|
||||
token = AccountService.send_change_email_email(
|
||||
account=account,
|
||||
@@ -660,6 +660,7 @@ class ChangeEmailCheckApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def post(self):
|
||||
current_user, _ = current_account_with_tenant()
|
||||
payload = console_ns.payload or {}
|
||||
args = ChangeEmailValidityPayload.model_validate(payload)
|
||||
|
||||
@@ -672,42 +673,26 @@ class ChangeEmailCheckApi(Resource):
|
||||
token_data = AccountService.get_change_email_data(args.token)
|
||||
if token_data is None:
|
||||
raise InvalidTokenError()
|
||||
if not token_data.is_bound_to_account(current_user.id):
|
||||
raise InvalidTokenError()
|
||||
|
||||
token_email = token_data.get("email")
|
||||
normalized_token_email = token_email.lower() if isinstance(token_email, str) else token_email
|
||||
normalized_token_email = token_data.email.lower()
|
||||
if user_email != normalized_token_email:
|
||||
raise InvalidEmailError()
|
||||
|
||||
if args.code != token_data.get("code"):
|
||||
if args.code != token_data.code:
|
||||
AccountService.add_change_email_error_rate_limit(user_email)
|
||||
raise EmailCodeError()
|
||||
|
||||
# Only advance tokens that were minted by the matching send-code step;
|
||||
# refuse tokens that have already progressed or lack a phase marker so
|
||||
# the chain `old_email -> old_email_verified -> new_email -> new_email_verified`
|
||||
# is strictly enforced.
|
||||
phase_transitions = {
|
||||
AccountService.CHANGE_EMAIL_PHASE_OLD: AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED,
|
||||
AccountService.CHANGE_EMAIL_PHASE_NEW: AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED,
|
||||
}
|
||||
token_phase = token_data.get(AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY)
|
||||
if not isinstance(token_phase, str):
|
||||
raise InvalidTokenError()
|
||||
refreshed_phase = phase_transitions.get(token_phase)
|
||||
if refreshed_phase is None:
|
||||
if isinstance(token_data, ChangeEmailOldEmailToken | ChangeEmailNewEmailToken):
|
||||
refreshed_token_data = token_data.promote()
|
||||
else:
|
||||
raise InvalidTokenError()
|
||||
|
||||
# Verified, revoke the first token
|
||||
AccountService.revoke_change_email_token(args.token)
|
||||
|
||||
# Refresh token data by generating a new token that carries the
|
||||
# upgraded phase so later steps can check it.
|
||||
_, new_token = AccountService.generate_change_email_token(
|
||||
user_email,
|
||||
code=args.code,
|
||||
old_email=token_data.get("old_email"),
|
||||
additional_data={AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: refreshed_phase},
|
||||
)
|
||||
new_token = AccountService.generate_change_email_token(refreshed_token_data, current_user)
|
||||
|
||||
AccountService.reset_change_email_error_rate_limit(user_email)
|
||||
return {"is_valid": True, "email": normalized_token_email, "token": new_token}
|
||||
@@ -732,27 +717,22 @@ class ChangeEmailResetApi(Resource):
|
||||
if not AccountService.check_email_unique(normalized_new_email):
|
||||
raise EmailAlreadyInUseError()
|
||||
|
||||
current_user, _ = current_account_with_tenant()
|
||||
reset_data = AccountService.get_change_email_data(args.token)
|
||||
if not reset_data:
|
||||
raise InvalidTokenError()
|
||||
if not reset_data.is_bound_to_account(current_user.id):
|
||||
raise InvalidTokenError()
|
||||
|
||||
# Only tokens that completed both verification phases may be used to
|
||||
# change the email. This closes GHSA-4q3w-q5mc-45rq where a token from
|
||||
# the initial send-code step could be replayed directly here.
|
||||
token_phase = reset_data.get(AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY)
|
||||
if token_phase != AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED:
|
||||
if not isinstance(reset_data, ChangeEmailNewEmailVerifiedToken):
|
||||
raise InvalidTokenError()
|
||||
|
||||
# Bind the new email to the token that was mailed and verified, so a
|
||||
# verified token cannot be reused with a different `new_email` value.
|
||||
token_email = reset_data.get("email")
|
||||
normalized_token_email = token_email.lower() if isinstance(token_email, str) else token_email
|
||||
if normalized_token_email != normalized_new_email:
|
||||
if reset_data.email.lower() != normalized_new_email:
|
||||
raise InvalidTokenError()
|
||||
|
||||
old_email = reset_data.get("old_email", "")
|
||||
current_user, _ = current_account_with_tenant()
|
||||
if current_user.email.lower() != old_email.lower():
|
||||
if current_user.email.lower() != reset_data.old_email.lower():
|
||||
raise AccountNotFound()
|
||||
|
||||
# Revoke only after all checks pass so failed attempts don't burn a
|
||||
|
||||
@@ -31,7 +31,9 @@ from services.tag_service import (
|
||||
TagBindingCreatePayload,
|
||||
TagBindingDeletePayload,
|
||||
TagService,
|
||||
UpdateTagPayload,
|
||||
)
|
||||
from services.tag_service import (
|
||||
UpdateTagPayload as UpdateTagServicePayload,
|
||||
)
|
||||
|
||||
register_enum_models(service_api_ns, DatasetPermissionEnum)
|
||||
@@ -556,7 +558,7 @@ class DatasetTagsApi(DatasetApiResource):
|
||||
|
||||
payload = TagUpdatePayload.model_validate(service_api_ns.payload or {})
|
||||
tag_id = payload.tag_id
|
||||
tag = TagService.update_tags(UpdateTagPayload(name=payload.name, type=TagType.KNOWLEDGE), tag_id)
|
||||
tag = TagService.update_tags(UpdateTagServicePayload(name=payload.name), tag_id)
|
||||
|
||||
binding_count = TagService.get_tag_binding_count(tag_id)
|
||||
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
from .controller import DatabaseFileAccessController
|
||||
from .protocols import FileAccessControllerProtocol
|
||||
from .scope import FileAccessScope, bind_file_access_scope, get_current_file_access_scope
|
||||
from .scope import (
|
||||
FileAccessScope,
|
||||
bind_file_access_scope,
|
||||
get_current_file_access_scope,
|
||||
grant_retriever_segment_access,
|
||||
grant_upload_file_access,
|
||||
is_retriever_segment_access_granted,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DatabaseFileAccessController",
|
||||
@@ -8,4 +15,7 @@ __all__ = [
|
||||
"FileAccessScope",
|
||||
"bind_file_access_scope",
|
||||
"get_current_file_access_scope",
|
||||
"grant_retriever_segment_access",
|
||||
"grant_upload_file_access",
|
||||
"is_retriever_segment_access_granted",
|
||||
]
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import and_, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.sql import Select
|
||||
|
||||
@@ -18,7 +18,8 @@ class DatabaseFileAccessController(FileAccessControllerProtocol):
|
||||
|
||||
Tenant scoping remains mandatory. When the current execution belongs to an
|
||||
end user, the lookup is additionally constrained to that end user's file
|
||||
ownership markers.
|
||||
ownership markers, plus upload files explicitly granted by the current
|
||||
execution context.
|
||||
"""
|
||||
|
||||
_scope_getter: Callable[[], FileAccessScope | None]
|
||||
@@ -47,10 +48,19 @@ class DatabaseFileAccessController(FileAccessControllerProtocol):
|
||||
if not resolved_scope.requires_user_ownership:
|
||||
return scoped_stmt
|
||||
|
||||
return scoped_stmt.where(
|
||||
user_owned_filter = and_(
|
||||
UploadFile.created_by_role == CreatorUserRole.END_USER,
|
||||
UploadFile.created_by == resolved_scope.user_id,
|
||||
)
|
||||
if not resolved_scope.granted_upload_file_ids:
|
||||
return scoped_stmt.where(user_owned_filter)
|
||||
|
||||
return scoped_stmt.where(
|
||||
or_(
|
||||
user_owned_filter,
|
||||
UploadFile.id.in_(resolved_scope.granted_upload_file_ids),
|
||||
)
|
||||
)
|
||||
|
||||
def apply_tool_file_filters(
|
||||
self,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator # Changed from Iterator
|
||||
from collections.abc import Generator, Iterable
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field, replace
|
||||
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
|
||||
|
||||
@@ -15,12 +15,23 @@ _current_file_access_scope: ContextVar[FileAccessScope | None] = ContextVar(
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FileAccessScope:
|
||||
"""Request-scoped ownership context used by workflow-layer file lookups."""
|
||||
"""Request-scoped ownership context used by workflow-layer file lookups.
|
||||
|
||||
``granted_upload_file_ids`` is execution-local: callers may add upload files
|
||||
that were returned by trusted retrieval paths without changing persistent
|
||||
ownership markers.
|
||||
|
||||
``granted_retriever_segment_ids`` gates lazy attachment loading by segment
|
||||
ID, so user-provided context cannot make a later LLM node load arbitrary
|
||||
same-tenant knowledge attachments.
|
||||
"""
|
||||
|
||||
tenant_id: str
|
||||
user_id: str
|
||||
user_from: UserFrom
|
||||
invoke_from: InvokeFrom
|
||||
granted_upload_file_ids: frozenset[str] = field(default_factory=frozenset)
|
||||
granted_retriever_segment_ids: frozenset[str] = field(default_factory=frozenset)
|
||||
|
||||
@property
|
||||
def requires_user_ownership(self) -> bool:
|
||||
@@ -31,8 +42,49 @@ def get_current_file_access_scope() -> FileAccessScope | None:
|
||||
return _current_file_access_scope.get()
|
||||
|
||||
|
||||
def grant_upload_file_access(upload_file_ids: Iterable[str]) -> None:
|
||||
scope = _current_file_access_scope.get()
|
||||
if scope is None:
|
||||
return
|
||||
|
||||
granted_upload_file_ids = frozenset(str(file_id) for file_id in upload_file_ids if file_id)
|
||||
if not granted_upload_file_ids:
|
||||
return
|
||||
|
||||
_current_file_access_scope.set(
|
||||
replace(
|
||||
scope,
|
||||
granted_upload_file_ids=scope.granted_upload_file_ids | granted_upload_file_ids,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def grant_retriever_segment_access(segment_ids: Iterable[str]) -> None:
|
||||
scope = _current_file_access_scope.get()
|
||||
if scope is None:
|
||||
return
|
||||
|
||||
granted_segment_ids = frozenset(str(segment_id) for segment_id in segment_ids if segment_id)
|
||||
if not granted_segment_ids:
|
||||
return
|
||||
|
||||
_current_file_access_scope.set(
|
||||
replace(
|
||||
scope,
|
||||
granted_retriever_segment_ids=scope.granted_retriever_segment_ids | granted_segment_ids,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def is_retriever_segment_access_granted(segment_id: str) -> bool:
|
||||
scope = _current_file_access_scope.get()
|
||||
if scope is None or not scope.requires_user_ownership:
|
||||
return True
|
||||
return str(segment_id) in scope.granted_retriever_segment_ids
|
||||
|
||||
|
||||
@contextmanager
|
||||
def bind_file_access_scope(scope: FileAccessScope) -> Generator[None, None, None]: # Changed from Iterator[None]
|
||||
def bind_file_access_scope(scope: FileAccessScope) -> Generator[None, None, None]:
|
||||
token = _current_file_access_scope.set(scope)
|
||||
try:
|
||||
yield
|
||||
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, load_only
|
||||
|
||||
from configs import dify_config
|
||||
from core.app.file_access import grant_upload_file_access
|
||||
from core.db.session_factory import session_factory
|
||||
from core.model_manager import ModelManager
|
||||
from core.rag.data_post_processor.data_post_processor import DataPostProcessor, RerankingModelDict, WeightsDict
|
||||
@@ -890,6 +891,7 @@ class RetrievalService:
|
||||
.limit(1)
|
||||
)
|
||||
if attachment_binding:
|
||||
grant_upload_file_access([str(upload_file.id)])
|
||||
attachment_info: AttachmentInfoDict = {
|
||||
"id": upload_file.id,
|
||||
"name": upload_file.name,
|
||||
@@ -906,6 +908,7 @@ class RetrievalService:
|
||||
cls, attachment_ids: list[str], session: Session
|
||||
) -> list[SegmentAttachmentInfoResult]:
|
||||
attachment_infos: list[SegmentAttachmentInfoResult] = []
|
||||
granted_upload_file_ids: list[str] = []
|
||||
upload_files = session.scalars(select(UploadFile).where(UploadFile.id.in_(attachment_ids))).all()
|
||||
if upload_files:
|
||||
upload_file_ids = [upload_file.id for upload_file in upload_files]
|
||||
@@ -926,6 +929,7 @@ class RetrievalService:
|
||||
"size": upload_file.size,
|
||||
}
|
||||
if attachment_binding:
|
||||
granted_upload_file_ids.append(str(upload_file.id))
|
||||
attachment_infos.append(
|
||||
{
|
||||
"attachment_id": attachment_binding.attachment_id,
|
||||
@@ -933,4 +937,5 @@ class RetrievalService:
|
||||
"segment_id": attachment_binding.segment_id,
|
||||
}
|
||||
)
|
||||
grant_upload_file_access(granted_upload_file_ids)
|
||||
return attachment_infos
|
||||
|
||||
@@ -19,6 +19,7 @@ from core.app.app_config.entities import (
|
||||
ModelConfig,
|
||||
)
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom, ModelConfigWithCredentialsEntity
|
||||
from core.app.file_access import grant_retriever_segment_access, grant_upload_file_access
|
||||
from core.callback_handler.index_tool_callback_handler import DatasetIndexToolCallbackHandler
|
||||
from core.db.session_factory import session_factory
|
||||
from core.entities.agent_entities import PlanningStrategy
|
||||
@@ -326,6 +327,7 @@ class DatasetRetrieval:
|
||||
if record.summary:
|
||||
source.summary = record.summary
|
||||
|
||||
grant_retriever_segment_access([str(segment.id)])
|
||||
retrieval_resource_list.append(source)
|
||||
|
||||
if retrieval_resource_list:
|
||||
@@ -515,6 +517,9 @@ class DatasetRetrieval:
|
||||
)
|
||||
).all()
|
||||
if attachments_with_bindings:
|
||||
grant_upload_file_access(
|
||||
str(upload_file.id) for _, upload_file in attachments_with_bindings
|
||||
)
|
||||
for _, upload_file in attachments_with_bindings:
|
||||
attachment_info = File(
|
||||
file_id=upload_file.id,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import importlib
|
||||
import pkgutil
|
||||
from collections.abc import Callable, Iterator, Mapping, MutableMapping
|
||||
from collections.abc import Callable, Iterator, Mapping, MutableMapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from typing import TYPE_CHECKING, Any, cast, final, override
|
||||
@@ -56,6 +56,7 @@ from graphon.nodes.http_request import build_http_request_config
|
||||
from graphon.nodes.llm.entities import LLMNodeData
|
||||
from graphon.nodes.parameter_extractor.entities import ParameterExtractorNodeData
|
||||
from graphon.nodes.question_classifier.entities import QuestionClassifierNodeData
|
||||
from graphon.variables.segments import ArrayObjectSegment
|
||||
from models.model import Conversation
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -496,13 +497,47 @@ class DifyNodeFactory(NodeFactory):
|
||||
if include_prompt_message_serializer:
|
||||
node_init_kwargs["prompt_message_serializer"] = self._prompt_message_serializer
|
||||
if include_retriever_attachment_loader:
|
||||
node_init_kwargs["retriever_attachment_loader"] = self._retriever_attachment_loader
|
||||
node_init_kwargs["retriever_attachment_loader"] = self._build_retriever_attachment_loader(
|
||||
cast(LLMNodeData, validated_node_data)
|
||||
)
|
||||
if include_jinja2_template_renderer:
|
||||
node_init_kwargs["jinja2_template_renderer"] = self._jinja2_template_renderer
|
||||
if validated_node_data.type == BuiltinNodeTypes.LLM:
|
||||
node_init_kwargs["default_query_selector"] = system_variable_selector(SystemVariableKey.QUERY)
|
||||
return node_init_kwargs
|
||||
|
||||
def _build_retriever_attachment_loader(self, node_data: LLMNodeData) -> DifyRetrieverAttachmentLoader:
|
||||
return DifyRetrieverAttachmentLoader(
|
||||
file_reference_factory=self._file_reference_factory,
|
||||
segment_access_checker=self._build_retriever_segment_access_checker(
|
||||
node_data.context.variable_selector if node_data.context.enabled else None
|
||||
),
|
||||
)
|
||||
|
||||
def _build_retriever_segment_access_checker(
|
||||
self,
|
||||
context_variable_selector: Sequence[str] | None,
|
||||
) -> Callable[[str], bool]:
|
||||
def checker(segment_id: str) -> bool:
|
||||
if not context_variable_selector:
|
||||
return False
|
||||
|
||||
context_value = self.graph_runtime_state.variable_pool.get(context_variable_selector)
|
||||
if not isinstance(context_value, ArrayObjectSegment):
|
||||
return False
|
||||
|
||||
for item in context_value.value:
|
||||
if not isinstance(item, Mapping):
|
||||
continue
|
||||
metadata = item.get("metadata")
|
||||
if not isinstance(metadata, Mapping):
|
||||
continue
|
||||
if metadata.get("_source") == "knowledge" and str(metadata.get("segment_id")) == str(segment_id):
|
||||
return True
|
||||
return False
|
||||
|
||||
return checker
|
||||
|
||||
def _build_model_instance_for_llm_node(self, node_data: LLMCompatibleNodeData) -> ModelInstance:
|
||||
node_data_model = node_data.model
|
||||
model_instance, _ = fetch_model_config(
|
||||
|
||||
@@ -8,7 +8,11 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext
|
||||
from core.app.file_access import DatabaseFileAccessController
|
||||
from core.app.file_access import (
|
||||
DatabaseFileAccessController,
|
||||
grant_upload_file_access,
|
||||
is_retriever_segment_access_granted,
|
||||
)
|
||||
from core.callback_handler.workflow_tool_callback_handler import DifyWorkflowCallbackHandler
|
||||
from core.helper.trace_id_helper import ParentTraceContext
|
||||
from core.llm_generator.output_parser.errors import OutputParserError
|
||||
@@ -275,10 +279,23 @@ class DifyPromptMessageSerializer(PromptMessageSerializerProtocol):
|
||||
class DifyRetrieverAttachmentLoader(RetrieverAttachmentLoaderProtocol):
|
||||
"""Resolve retriever attachments through Dify persistence and return graph file references."""
|
||||
|
||||
def __init__(self, *, file_reference_factory: FileReferenceFactoryProtocol) -> None:
|
||||
_segment_access_checker: Callable[[str], bool] | None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
file_reference_factory: FileReferenceFactoryProtocol,
|
||||
segment_access_checker: Callable[[str], bool] | None = None,
|
||||
) -> None:
|
||||
self._file_reference_factory = file_reference_factory
|
||||
self._segment_access_checker = segment_access_checker
|
||||
|
||||
def load(self, *, segment_id: str) -> Sequence[File]:
|
||||
if not is_retriever_segment_access_granted(segment_id):
|
||||
return []
|
||||
if self._segment_access_checker is not None and not self._segment_access_checker(segment_id):
|
||||
return []
|
||||
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
attachments_with_bindings = session.execute(
|
||||
select(SegmentAttachmentBinding, UploadFile)
|
||||
@@ -286,6 +303,7 @@ class DifyRetrieverAttachmentLoader(RetrieverAttachmentLoaderProtocol):
|
||||
.where(SegmentAttachmentBinding.segment_id == segment_id)
|
||||
).all()
|
||||
|
||||
grant_upload_file_access(str(upload_file.id) for _, upload_file in attachments_with_bindings)
|
||||
return [
|
||||
self._file_reference_factory.build_from_mapping(
|
||||
mapping={
|
||||
|
||||
@@ -12,6 +12,7 @@ def init_app(app: DifyApp):
|
||||
clear_orphaned_file_records,
|
||||
convert_to_agent_apps,
|
||||
create_tenant,
|
||||
data_migrate,
|
||||
delete_archived_workflow_runs,
|
||||
export_app_messages,
|
||||
extract_plugins,
|
||||
@@ -44,6 +45,7 @@ def init_app(app: DifyApp):
|
||||
convert_to_agent_apps,
|
||||
add_qdrant_index,
|
||||
create_tenant,
|
||||
data_migrate,
|
||||
upgrade_db,
|
||||
fix_app_site_missing,
|
||||
migrate_data_for_plugin,
|
||||
|
||||
+20
-4
@@ -16,7 +16,7 @@ from zoneinfo import available_timezones
|
||||
|
||||
from flask import Response, stream_with_context
|
||||
from flask_restx import fields
|
||||
from pydantic import BaseModel, TypeAdapter
|
||||
from pydantic import BaseModel, ConfigDict, TypeAdapter, with_config
|
||||
from pydantic.functional_validators import AfterValidator
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
@@ -33,12 +33,29 @@ if TYPE_CHECKING:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@with_config(ConfigDict(extra="allow"))
|
||||
class _TokenData(TypedDict, total=False):
|
||||
"""Shared baseline token payload.
|
||||
|
||||
`extra='allow'` keeps TokenManager from silently stripping business-
|
||||
specific metadata keys while still validating the common auth fields.
|
||||
Business flows that need stronger guarantees should validate again at
|
||||
their own boundary with a dedicated Pydantic model.
|
||||
|
||||
For the change-email flow specifically, `email_change_phase` is the
|
||||
discriminator used by `services.entities.auth_entities.ChangeEmailTokenData`.
|
||||
It is declared here so the shared token adapter can still provide baseline
|
||||
validation for the state-machine key without taking over the full business
|
||||
model.
|
||||
"""
|
||||
|
||||
account_id: str | None
|
||||
email: str
|
||||
token_type: str
|
||||
code: str
|
||||
old_email: str
|
||||
phase: str
|
||||
email_change_phase: str
|
||||
|
||||
|
||||
_token_data_adapter: TypeAdapter[_TokenData] = TypeAdapter(_TokenData)
|
||||
@@ -428,7 +445,7 @@ class TokenManager:
|
||||
raise ValueError("Account or email must be provided")
|
||||
|
||||
account_id = account.id if account else None
|
||||
account_email = account.email if account else email
|
||||
account_email = email if email is not None else account.email if account else None
|
||||
|
||||
if account_id:
|
||||
old_token = cls._get_current_token_for_account(account_id, token_type)
|
||||
@@ -470,8 +487,7 @@ class TokenManager:
|
||||
if token_data_json is None:
|
||||
logger.warning("%s token %s not found with key %s", token_type, token, key)
|
||||
return None
|
||||
token_data = dict(_token_data_adapter.validate_json(token_data_json))
|
||||
return token_data
|
||||
return dict(_token_data_adapter.validate_json(token_data_json))
|
||||
|
||||
@classmethod
|
||||
def _get_current_token_for_account(cls, account_id: str, token_type: str) -> str | None:
|
||||
|
||||
@@ -7625,7 +7625,7 @@ Remove one or more tag bindings from a target.
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| tag_id | path | | Yes | string |
|
||||
| payload | body | | Yes | [TagBasePayload](#tagbasepayload) |
|
||||
| payload | body | | Yes | [TagUpdateRequestPayload](#tagupdaterequestpayload) |
|
||||
|
||||
##### Responses
|
||||
|
||||
@@ -13610,6 +13610,12 @@ Tag type
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| TagType | string | Tag type | |
|
||||
|
||||
#### TagUpdateRequestPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| name | string | Tag name | Yes |
|
||||
|
||||
#### TenantAccountRole
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
|
||||
@@ -13,6 +13,8 @@ from langfuse.api import (
|
||||
TraceBody,
|
||||
)
|
||||
from langfuse.api.commons.types.usage import Usage
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from core.ops.base_trace_instance import BaseTraceInstance
|
||||
@@ -52,13 +54,40 @@ class LangFuseDataTrace(BaseTraceInstance):
|
||||
langfuse_config: LangfuseConfig,
|
||||
):
|
||||
super().__init__(langfuse_config)
|
||||
# Isolated TracerProvider prevents the langfuse v3 SDK from attaching its
|
||||
# SpanProcessor to the global OpenTelemetry TracerProvider, which would
|
||||
# otherwise siphon every Flask/Celery/SQLAlchemy span in the process into
|
||||
# this tenant's Langfuse project. See langfuse upgrade guide v2 -> v3.
|
||||
self._tracer_provider = TracerProvider(
|
||||
resource=Resource.create({"service.name": "dify-langfuse-app-trace"}),
|
||||
)
|
||||
self.langfuse_client = Langfuse(
|
||||
public_key=langfuse_config.public_key,
|
||||
secret_key=langfuse_config.secret_key,
|
||||
host=langfuse_config.host,
|
||||
tracer_provider=self._tracer_provider,
|
||||
)
|
||||
self.file_base_url = os.getenv("FILES_URL", "http://127.0.0.1:5001")
|
||||
|
||||
def close(self) -> None:
|
||||
"""Flush and shut down the isolated TracerProvider.
|
||||
|
||||
Called explicitly when the trace instance is evicted from the cache, or
|
||||
implicitly via ``__del__`` on garbage collection. Idempotent.
|
||||
"""
|
||||
provider = getattr(self, "_tracer_provider", None)
|
||||
if provider is None:
|
||||
return
|
||||
try:
|
||||
provider.shutdown()
|
||||
except Exception:
|
||||
logger.debug("Failed to shut down Langfuse TracerProvider", exc_info=True)
|
||||
finally:
|
||||
self._tracer_provider = None
|
||||
|
||||
def __del__(self) -> None:
|
||||
self.close()
|
||||
|
||||
@staticmethod
|
||||
def _get_completion_start_time(
|
||||
start_time: datetime | None, time_to_first_token: float | int | None
|
||||
|
||||
+79
-5
@@ -50,20 +50,94 @@ def trace_instance(langfuse_config, monkeypatch: pytest.MonkeyPatch):
|
||||
|
||||
|
||||
def test_init(langfuse_config, monkeypatch: pytest.MonkeyPatch):
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
|
||||
mock_langfuse = MagicMock()
|
||||
monkeypatch.setattr("dify_trace_langfuse.langfuse_trace.Langfuse", mock_langfuse)
|
||||
monkeypatch.setenv("FILES_URL", "http://test.url")
|
||||
|
||||
instance = LangFuseDataTrace(langfuse_config)
|
||||
|
||||
mock_langfuse.assert_called_once_with(
|
||||
public_key=langfuse_config.public_key,
|
||||
secret_key=langfuse_config.secret_key,
|
||||
host=langfuse_config.host,
|
||||
)
|
||||
mock_langfuse.assert_called_once()
|
||||
kwargs = mock_langfuse.call_args.kwargs
|
||||
assert kwargs["public_key"] == langfuse_config.public_key
|
||||
assert kwargs["secret_key"] == langfuse_config.secret_key
|
||||
assert kwargs["host"] == langfuse_config.host
|
||||
assert isinstance(kwargs["tracer_provider"], TracerProvider)
|
||||
assert kwargs["tracer_provider"] is instance._tracer_provider
|
||||
assert instance.file_base_url == "http://test.url"
|
||||
|
||||
|
||||
def test_init_passes_isolated_tracer_provider_to_langfuse(
|
||||
langfuse_config, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""Regression test for langfuse v3 SDK side effect.
|
||||
|
||||
Without an explicit ``tracer_provider=`` kwarg, the Langfuse v3 SDK
|
||||
attaches a ``LangfuseSpanProcessor`` to the *global* OpenTelemetry
|
||||
TracerProvider — siphoning every Flask / Celery / SQLAlchemy span in the
|
||||
process into the tenant's Langfuse project. See langfuse upgrade-path
|
||||
docs (v2 -> v3) and GitHub discussion #9136.
|
||||
|
||||
The fix is to construct an isolated ``TracerProvider`` and pass it via
|
||||
``tracer_provider=`` so the SDK never touches the global one.
|
||||
"""
|
||||
from opentelemetry import trace as otel_trace_api
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_langfuse(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return MagicMock()
|
||||
|
||||
monkeypatch.setattr("dify_trace_langfuse.langfuse_trace.Langfuse", fake_langfuse)
|
||||
|
||||
instance = LangFuseDataTrace(langfuse_config)
|
||||
|
||||
# 1. tracer_provider kwarg must be supplied (drives the no-pollution branch
|
||||
# in langfuse.LangfuseResourceManager._init_tracer_provider).
|
||||
assert "tracer_provider" in captured, (
|
||||
"Langfuse() must receive an explicit tracer_provider=; without it the "
|
||||
"v3 SDK attaches its SpanProcessor to the global OTEL TracerProvider."
|
||||
)
|
||||
|
||||
passed_provider = captured["tracer_provider"]
|
||||
assert isinstance(passed_provider, TracerProvider)
|
||||
assert passed_provider is instance._tracer_provider
|
||||
|
||||
# 2. The instance's provider must not be the global one.
|
||||
global_provider = otel_trace_api.get_tracer_provider()
|
||||
assert passed_provider is not global_provider
|
||||
|
||||
|
||||
def test_close_shuts_down_tracer_provider(langfuse_config, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr("dify_trace_langfuse.langfuse_trace.Langfuse", lambda **kwargs: MagicMock())
|
||||
|
||||
instance = LangFuseDataTrace(langfuse_config)
|
||||
provider = instance._tracer_provider
|
||||
provider_shutdown = MagicMock()
|
||||
monkeypatch.setattr(provider, "shutdown", provider_shutdown)
|
||||
|
||||
instance.close()
|
||||
|
||||
provider_shutdown.assert_called_once()
|
||||
assert instance._tracer_provider is None
|
||||
|
||||
|
||||
def test_close_is_idempotent(langfuse_config, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr("dify_trace_langfuse.langfuse_trace.Langfuse", lambda **kwargs: MagicMock())
|
||||
|
||||
instance = LangFuseDataTrace(langfuse_config)
|
||||
provider_shutdown = MagicMock()
|
||||
monkeypatch.setattr(instance._tracer_provider, "shutdown", provider_shutdown)
|
||||
|
||||
instance.close()
|
||||
instance.close()
|
||||
|
||||
provider_shutdown.assert_called_once()
|
||||
|
||||
|
||||
def test_trace_dispatch(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
methods = [
|
||||
"workflow_trace",
|
||||
|
||||
@@ -7,7 +7,7 @@ from datetime import UTC, datetime, timedelta
|
||||
from hashlib import sha256
|
||||
from typing import Any, TypedDict, cast
|
||||
|
||||
from pydantic import BaseModel, TypeAdapter
|
||||
from pydantic import BaseModel, TypeAdapter, ValidationError
|
||||
from sqlalchemy import delete, func, select, update
|
||||
|
||||
from core.db.session_factory import session_factory
|
||||
@@ -45,6 +45,12 @@ from models.account import (
|
||||
)
|
||||
from models.model import DifySetup
|
||||
from services.billing_service import BillingService
|
||||
from services.entities.auth_entities import (
|
||||
ChangeEmailNewEmailToken,
|
||||
ChangeEmailOldEmailToken,
|
||||
ChangeEmailPhase,
|
||||
ChangeEmailTokenData,
|
||||
)
|
||||
from services.errors.account import (
|
||||
AccountAlreadyInTenantError,
|
||||
AccountLoginError,
|
||||
@@ -83,6 +89,8 @@ from tasks.mail_reset_password_task import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_change_email_token_adapter: TypeAdapter[ChangeEmailTokenData] = TypeAdapter(ChangeEmailTokenData)
|
||||
|
||||
|
||||
class InvitationDetailDict(TypedDict):
|
||||
account: Account
|
||||
@@ -112,13 +120,10 @@ REFRESH_TOKEN_EXPIRY = timedelta(days=dify_config.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
|
||||
|
||||
class AccountService:
|
||||
# Phase-bound token metadata for the change-email flow. Tokens carry the
|
||||
# current phase so that downstream endpoints can enforce proper progression
|
||||
CHANGE_EMAIL_TOKEN_PHASE_KEY = "email_change_phase"
|
||||
CHANGE_EMAIL_PHASE_OLD = "old_email"
|
||||
CHANGE_EMAIL_PHASE_OLD_VERIFIED = "old_email_verified"
|
||||
CHANGE_EMAIL_PHASE_NEW = "new_email"
|
||||
CHANGE_EMAIL_PHASE_NEW_VERIFIED = "new_email_verified"
|
||||
CHANGE_EMAIL_PHASE_OLD = ChangeEmailPhase.OLD_EMAIL
|
||||
CHANGE_EMAIL_PHASE_OLD_VERIFIED = ChangeEmailPhase.OLD_EMAIL_VERIFIED
|
||||
CHANGE_EMAIL_PHASE_NEW = ChangeEmailPhase.NEW_EMAIL
|
||||
CHANGE_EMAIL_PHASE_NEW_VERIFIED = ChangeEmailPhase.NEW_EMAIL_VERIFIED
|
||||
|
||||
reset_password_rate_limiter = RateLimiter(prefix="reset_password_rate_limit", max_attempts=1, time_window=60 * 1)
|
||||
email_register_rate_limiter = RateLimiter(prefix="email_register_rate_limit", max_attempts=1, time_window=60 * 1)
|
||||
@@ -573,31 +578,42 @@ class AccountService:
|
||||
@classmethod
|
||||
def send_change_email_email(
|
||||
cls,
|
||||
account: Account | None = None,
|
||||
account: Account,
|
||||
email: str | None = None,
|
||||
old_email: str | None = None,
|
||||
language: str = "en-US",
|
||||
phase: str | None = None,
|
||||
):
|
||||
account_email = account.email if account else email
|
||||
if account_email is None:
|
||||
raise ValueError("Email must be provided.")
|
||||
account_email = email if email is not None else account.email
|
||||
if not phase:
|
||||
raise ValueError("phase must be provided.")
|
||||
if phase not in (cls.CHANGE_EMAIL_PHASE_OLD, cls.CHANGE_EMAIL_PHASE_NEW):
|
||||
raise ValueError("phase must be one of old_email or new_email.")
|
||||
if old_email is None:
|
||||
raise ValueError("old_email must be provided.")
|
||||
|
||||
if cls.change_email_rate_limiter.is_rate_limited(account_email):
|
||||
from controllers.console.auth.error import EmailChangeRateLimitExceededError
|
||||
|
||||
raise EmailChangeRateLimitExceededError(int(cls.change_email_rate_limiter.time_window / 60))
|
||||
|
||||
code, token = cls.generate_change_email_token(
|
||||
account_email,
|
||||
account,
|
||||
old_email=old_email,
|
||||
additional_data={cls.CHANGE_EMAIL_TOKEN_PHASE_KEY: phase},
|
||||
)
|
||||
code = "".join([str(secrets.randbelow(exclusive_upper_bound=10)) for _ in range(6)])
|
||||
token_data: ChangeEmailTokenData
|
||||
if phase == cls.CHANGE_EMAIL_PHASE_OLD:
|
||||
token_data = ChangeEmailOldEmailToken(
|
||||
account_id=account.id,
|
||||
email=account_email,
|
||||
old_email=old_email,
|
||||
code=code,
|
||||
)
|
||||
else:
|
||||
token_data = ChangeEmailNewEmailToken(
|
||||
account_id=account.id,
|
||||
email=account_email,
|
||||
old_email=old_email,
|
||||
code=code,
|
||||
)
|
||||
token = cls.generate_change_email_token(token_data, account)
|
||||
|
||||
send_change_mail_task.delay(
|
||||
language=language,
|
||||
@@ -725,20 +741,16 @@ class AccountService:
|
||||
@classmethod
|
||||
def generate_change_email_token(
|
||||
cls,
|
||||
email: str,
|
||||
account: Account | None = None,
|
||||
code: str | None = None,
|
||||
old_email: str | None = None,
|
||||
additional_data: dict[str, Any] = {},
|
||||
):
|
||||
if not code:
|
||||
code = "".join([str(secrets.randbelow(exclusive_upper_bound=10)) for _ in range(6)])
|
||||
additional_data["code"] = code
|
||||
additional_data["old_email"] = old_email
|
||||
token_data: ChangeEmailTokenData,
|
||||
account: Account,
|
||||
) -> str:
|
||||
token = TokenManager.generate_token(
|
||||
account=account, email=email, token_type="change_email", additional_data=additional_data
|
||||
account=account,
|
||||
email=token_data.email,
|
||||
token_type="change_email",
|
||||
additional_data=token_data.to_token_manager_payload(),
|
||||
)
|
||||
return code, token
|
||||
return token
|
||||
|
||||
@classmethod
|
||||
def generate_owner_transfer_token(
|
||||
@@ -781,8 +793,15 @@ class AccountService:
|
||||
return TokenManager.get_token_data(token, "email_register")
|
||||
|
||||
@classmethod
|
||||
def get_change_email_data(cls, token: str) -> dict[str, Any] | None:
|
||||
return TokenManager.get_token_data(token, "change_email")
|
||||
def get_change_email_data(cls, token: str) -> ChangeEmailTokenData | None:
|
||||
token_data = TokenManager.get_token_data(token, "change_email")
|
||||
if token_data is None:
|
||||
return None
|
||||
try:
|
||||
return _change_email_token_adapter.validate_python(token_data)
|
||||
except ValidationError:
|
||||
logger.warning("change_email token %s has invalid payload", token, exc_info=True)
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_owner_transfer_data(cls, token: str) -> dict[str, Any] | None:
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import logging
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from configs import dify_config
|
||||
from core.db.session_factory import session_factory
|
||||
from core.errors.error import QuotaExceededError
|
||||
from extensions.ext_database import db
|
||||
from models import TenantCreditPool
|
||||
@@ -41,7 +42,7 @@ class CreditPoolService:
|
||||
@classmethod
|
||||
def get_pool(cls, tenant_id: str, pool_type: str = "trial") -> TenantCreditPool | None:
|
||||
"""get tenant credit pool"""
|
||||
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
|
||||
with session_factory.get_session_maker().begin() as session:
|
||||
return session.scalar(
|
||||
select(TenantCreditPool)
|
||||
.where(
|
||||
@@ -76,7 +77,7 @@ class CreditPoolService:
|
||||
return 0
|
||||
|
||||
try:
|
||||
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
|
||||
with session_factory.get_session_maker().begin() as session:
|
||||
pool = cls._get_locked_pool(session=session, tenant_id=tenant_id, pool_type=pool_type)
|
||||
if not pool:
|
||||
raise QuotaExceededError("Credit pool not found")
|
||||
@@ -108,7 +109,7 @@ class CreditPoolService:
|
||||
return 0
|
||||
|
||||
try:
|
||||
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
|
||||
with session_factory.get_session_maker().begin() as session:
|
||||
pool = cls._get_locked_pool(session=session, tenant_id=tenant_id, pool_type=pool_type)
|
||||
if not pool:
|
||||
logger.warning("Credit pool not found, tenant_id=%s, pool_type=%s", tenant_id, pool_type)
|
||||
|
||||
@@ -49,6 +49,95 @@ class DatasourceProviderService:
|
||||
def __init__(self) -> None:
|
||||
self.provider_manager = PluginDatasourceManager()
|
||||
|
||||
@staticmethod
|
||||
def _should_refresh_credentials(datasource_provider: DatasourceProvider, now: int | None = None) -> bool:
|
||||
current_time = int(time.time()) if now is None else now
|
||||
if datasource_provider.expires_at == -1:
|
||||
return False
|
||||
return (datasource_provider.expires_at - 60) < current_time
|
||||
|
||||
def _refresh_datasource_credentials(
|
||||
self,
|
||||
tenant_id: str,
|
||||
provider: str,
|
||||
plugin_id: str,
|
||||
datasource_provider: DatasourceProvider,
|
||||
current_user: Any,
|
||||
) -> tuple[dict[str, Any], int]:
|
||||
datasource_provider_id = DatasourceProviderID(f"{plugin_id}/{provider}")
|
||||
provider_name = datasource_provider_id.provider_name
|
||||
credential_id = getattr(datasource_provider, "id", None)
|
||||
credential_name = getattr(datasource_provider, "name", None)
|
||||
logger.info(
|
||||
"Refreshing datasource credentials for provider %s",
|
||||
provider_name,
|
||||
extra={
|
||||
"tenant_id": tenant_id,
|
||||
"plugin_id": datasource_provider_id.plugin_id,
|
||||
"provider": provider_name,
|
||||
"credential_id": credential_id,
|
||||
"credential_name": credential_name,
|
||||
"expires_at": datasource_provider.expires_at,
|
||||
},
|
||||
)
|
||||
decrypted_credentials = self.decrypt_datasource_provider_credentials(
|
||||
tenant_id=tenant_id,
|
||||
datasource_provider=datasource_provider,
|
||||
plugin_id=plugin_id,
|
||||
provider=provider,
|
||||
)
|
||||
redirect_uri = (
|
||||
f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/"
|
||||
f"{datasource_provider_id}/datasource/callback"
|
||||
)
|
||||
system_credentials = self.get_oauth_client(tenant_id, datasource_provider_id)
|
||||
try:
|
||||
refreshed_credentials = OAuthHandler().refresh_credentials(
|
||||
tenant_id=tenant_id,
|
||||
user_id=current_user.id,
|
||||
plugin_id=datasource_provider_id.plugin_id,
|
||||
provider=provider_name,
|
||||
redirect_uri=redirect_uri,
|
||||
system_credentials=system_credentials or {},
|
||||
credentials=decrypted_credentials,
|
||||
)
|
||||
except Exception as exc:
|
||||
message = (
|
||||
f"Failed to refresh datasource credentials for provider {provider_name}"
|
||||
f" (credential: {credential_name or credential_id or 'unknown'})"
|
||||
)
|
||||
logger.exception(
|
||||
message,
|
||||
extra={
|
||||
"tenant_id": tenant_id,
|
||||
"plugin_id": datasource_provider_id.plugin_id,
|
||||
"provider": provider_name,
|
||||
"credential_id": credential_id,
|
||||
"credential_name": credential_name,
|
||||
},
|
||||
)
|
||||
raise ValueError(f"{message}: {exc}") from exc
|
||||
encrypted_credentials = self.encrypt_datasource_provider_credentials(
|
||||
tenant_id=tenant_id,
|
||||
raw_credentials=refreshed_credentials.credentials,
|
||||
provider=provider,
|
||||
plugin_id=plugin_id,
|
||||
datasource_provider=datasource_provider,
|
||||
)
|
||||
logger.info(
|
||||
"Refreshed datasource credentials for provider %s",
|
||||
provider_name,
|
||||
extra={
|
||||
"tenant_id": tenant_id,
|
||||
"plugin_id": datasource_provider_id.plugin_id,
|
||||
"provider": provider_name,
|
||||
"credential_id": credential_id,
|
||||
"credential_name": credential_name,
|
||||
"expires_at": refreshed_credentials.expires_at,
|
||||
},
|
||||
)
|
||||
return encrypted_credentials, refreshed_credentials.expires_at
|
||||
|
||||
def remove_oauth_custom_client_params(self, tenant_id: str, datasource_provider_id: DatasourceProviderID):
|
||||
"""
|
||||
remove oauth custom client params
|
||||
@@ -108,7 +197,10 @@ class DatasourceProviderService:
|
||||
credential_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
get credential by id
|
||||
Return decrypted datasource credentials.
|
||||
|
||||
If the stored credential is expired or about to expire, this method refreshes
|
||||
it through plugin-daemon and persists the refreshed credential before returning.
|
||||
"""
|
||||
with sessionmaker(bind=db.engine).begin() as session:
|
||||
if credential_id:
|
||||
@@ -130,39 +222,17 @@ class DatasourceProviderService:
|
||||
)
|
||||
if not datasource_provider:
|
||||
return {}
|
||||
# refresh the credentials
|
||||
if datasource_provider.expires_at != -1 and (datasource_provider.expires_at - 60) < int(time.time()):
|
||||
if self._should_refresh_credentials(datasource_provider):
|
||||
current_user = get_current_user()
|
||||
decrypted_credentials = self.decrypt_datasource_provider_credentials(
|
||||
encrypted_credentials, expires_at = self._refresh_datasource_credentials(
|
||||
tenant_id=tenant_id,
|
||||
datasource_provider=datasource_provider,
|
||||
plugin_id=plugin_id,
|
||||
provider=provider,
|
||||
)
|
||||
datasource_provider_id = DatasourceProviderID(f"{plugin_id}/{provider}")
|
||||
provider_name = datasource_provider_id.provider_name
|
||||
redirect_uri = (
|
||||
f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/"
|
||||
f"{datasource_provider_id}/datasource/callback"
|
||||
)
|
||||
system_credentials = self.get_oauth_client(tenant_id, datasource_provider_id)
|
||||
refreshed_credentials = OAuthHandler().refresh_credentials(
|
||||
tenant_id=tenant_id,
|
||||
user_id=current_user.id,
|
||||
plugin_id=datasource_provider_id.plugin_id,
|
||||
provider=provider_name,
|
||||
redirect_uri=redirect_uri,
|
||||
system_credentials=system_credentials or {},
|
||||
credentials=decrypted_credentials,
|
||||
)
|
||||
datasource_provider.encrypted_credentials = self.encrypt_datasource_provider_credentials(
|
||||
tenant_id=tenant_id,
|
||||
raw_credentials=refreshed_credentials.credentials,
|
||||
provider=provider,
|
||||
plugin_id=plugin_id,
|
||||
datasource_provider=datasource_provider,
|
||||
current_user=current_user,
|
||||
)
|
||||
datasource_provider.expires_at = refreshed_credentials.expires_at
|
||||
datasource_provider.encrypted_credentials = encrypted_credentials
|
||||
datasource_provider.expires_at = expires_at
|
||||
|
||||
return self.decrypt_datasource_provider_credentials(
|
||||
tenant_id=tenant_id,
|
||||
@@ -178,7 +248,10 @@ class DatasourceProviderService:
|
||||
plugin_id: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
get all datasource credentials by provider
|
||||
Return all decrypted datasource credentials for a provider.
|
||||
|
||||
Expired credentials are refreshed independently. A failed credential refresh is
|
||||
logged and skipped so one broken authorization does not block other credentials.
|
||||
"""
|
||||
with sessionmaker(bind=db.engine).begin() as session:
|
||||
datasource_providers = session.scalars(
|
||||
@@ -193,46 +266,39 @@ class DatasourceProviderService:
|
||||
if not datasource_providers:
|
||||
return []
|
||||
current_user = get_current_user()
|
||||
# refresh the credentials
|
||||
real_credentials_list = []
|
||||
for datasource_provider in datasource_providers:
|
||||
decrypted_credentials = self.decrypt_datasource_provider_credentials(
|
||||
tenant_id=tenant_id,
|
||||
datasource_provider=datasource_provider,
|
||||
plugin_id=plugin_id,
|
||||
provider=provider,
|
||||
)
|
||||
datasource_provider_id = DatasourceProviderID(f"{plugin_id}/{provider}")
|
||||
provider_name = datasource_provider_id.provider_name
|
||||
redirect_uri = (
|
||||
f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/"
|
||||
f"{datasource_provider_id}/datasource/callback"
|
||||
)
|
||||
system_credentials = self.get_oauth_client(tenant_id, datasource_provider_id)
|
||||
refreshed_credentials = OAuthHandler().refresh_credentials(
|
||||
tenant_id=tenant_id,
|
||||
user_id=current_user.id,
|
||||
plugin_id=datasource_provider_id.plugin_id,
|
||||
provider=provider_name,
|
||||
redirect_uri=redirect_uri,
|
||||
system_credentials=system_credentials or {},
|
||||
credentials=decrypted_credentials,
|
||||
)
|
||||
datasource_provider.encrypted_credentials = self.encrypt_datasource_provider_credentials(
|
||||
tenant_id=tenant_id,
|
||||
raw_credentials=refreshed_credentials.credentials,
|
||||
provider=provider,
|
||||
plugin_id=plugin_id,
|
||||
datasource_provider=datasource_provider,
|
||||
)
|
||||
datasource_provider.expires_at = refreshed_credentials.expires_at
|
||||
real_credentials = self.decrypt_datasource_provider_credentials(
|
||||
tenant_id=tenant_id,
|
||||
datasource_provider=datasource_provider,
|
||||
plugin_id=plugin_id,
|
||||
provider=provider,
|
||||
)
|
||||
real_credentials_list.append(real_credentials)
|
||||
try:
|
||||
if self._should_refresh_credentials(datasource_provider):
|
||||
encrypted_credentials, expires_at = self._refresh_datasource_credentials(
|
||||
tenant_id=tenant_id,
|
||||
provider=provider,
|
||||
plugin_id=plugin_id,
|
||||
datasource_provider=datasource_provider,
|
||||
current_user=current_user,
|
||||
)
|
||||
datasource_provider.encrypted_credentials = encrypted_credentials
|
||||
datasource_provider.expires_at = expires_at
|
||||
real_credentials = self.decrypt_datasource_provider_credentials(
|
||||
tenant_id=tenant_id,
|
||||
datasource_provider=datasource_provider,
|
||||
plugin_id=plugin_id,
|
||||
provider=provider,
|
||||
)
|
||||
real_credentials_list.append(real_credentials)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Skipping datasource credentials for provider %s after refresh or decrypt failure",
|
||||
provider,
|
||||
extra={
|
||||
"tenant_id": tenant_id,
|
||||
"plugin_id": plugin_id,
|
||||
"provider": provider,
|
||||
"credential_id": getattr(datasource_provider, "id", None),
|
||||
"credential_name": getattr(datasource_provider, "name", None),
|
||||
"expires_at": getattr(datasource_provider, "expires_at", None),
|
||||
},
|
||||
)
|
||||
|
||||
return real_credentials_list
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from enum import StrEnum, auto
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from libs.helper import EmailStr
|
||||
from libs.password import valid_password
|
||||
@@ -20,6 +21,24 @@ class LoginFailureReason(StrEnum):
|
||||
LOGIN_RATE_LIMITED = auto()
|
||||
|
||||
|
||||
class ChangeEmailPhase(StrEnum):
|
||||
"""Change-email token state machine.
|
||||
|
||||
Allowed transitions:
|
||||
|
||||
`OLD_EMAIL -> OLD_EMAIL_VERIFIED -> NEW_EMAIL -> NEW_EMAIL_VERIFIED`
|
||||
|
||||
The flow starts by sending a code to the current email address. Only a
|
||||
token in `OLD_EMAIL_VERIFIED` may request the new-email code, and only a
|
||||
token in `NEW_EMAIL_VERIFIED` may perform the final email reset.
|
||||
"""
|
||||
|
||||
OLD_EMAIL = "old_email"
|
||||
OLD_EMAIL_VERIFIED = "old_email_verified"
|
||||
NEW_EMAIL = "new_email"
|
||||
NEW_EMAIL_VERIFIED = "new_email_verified"
|
||||
|
||||
|
||||
class LoginPayloadBase(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
@@ -45,3 +64,122 @@ class ForgotPasswordResetPayload(BaseModel):
|
||||
@classmethod
|
||||
def validate_password(cls, value: str) -> str:
|
||||
return valid_password(value)
|
||||
|
||||
|
||||
class ChangeEmailTokenBase(BaseModel):
|
||||
"""Stored change-email token payload.
|
||||
|
||||
The discriminator lives in `email_change_phase`; callers use the concrete
|
||||
model type to decide which transitions are legal.
|
||||
|
||||
The full progression is:
|
||||
|
||||
`old_email -> old_email_verified -> new_email -> new_email_verified`
|
||||
|
||||
Every state is bound to the initiating `account_id` so the change-email
|
||||
flow cannot be replayed across accounts.
|
||||
"""
|
||||
|
||||
token_type: Literal["change_email"] = "change_email"
|
||||
account_id: str = Field(min_length=1)
|
||||
email: EmailStr
|
||||
old_email: EmailStr
|
||||
code: str = Field(min_length=1)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
def to_token_manager_payload(self) -> dict[str, str]:
|
||||
return self.model_dump(exclude={"token_type", "account_id", "email"})
|
||||
|
||||
def is_bound_to_account(self, account_id: str) -> bool:
|
||||
return self.account_id == account_id
|
||||
|
||||
|
||||
class _ChangeEmailOldAddressMixin(ChangeEmailTokenBase):
|
||||
"""States whose `email` must still be the account's current address."""
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_old_address_binding(self) -> "_ChangeEmailOldAddressMixin":
|
||||
if self.email.lower() != self.old_email.lower():
|
||||
raise ValueError("old-email token payload must bind email to old_email")
|
||||
return self
|
||||
|
||||
|
||||
class ChangeEmailOldEmailToken(_ChangeEmailOldAddressMixin):
|
||||
"""Phase-1 token minted when sending a code to the old email address.
|
||||
|
||||
This token proves only that the flow started for the current account. It
|
||||
must not unlock the new-email send step or the final reset step until the
|
||||
old-email verification code has been checked.
|
||||
"""
|
||||
|
||||
email_change_phase: Literal[ChangeEmailPhase.OLD_EMAIL] = ChangeEmailPhase.OLD_EMAIL
|
||||
|
||||
def promote(self) -> "ChangeEmailOldEmailVerifiedToken":
|
||||
"""Advance to the state that is allowed to request the new-email code."""
|
||||
return ChangeEmailOldEmailVerifiedToken(
|
||||
**self.model_dump(exclude={"email_change_phase"}),
|
||||
email_change_phase=ChangeEmailPhase.OLD_EMAIL_VERIFIED,
|
||||
)
|
||||
|
||||
|
||||
class ChangeEmailOldEmailVerifiedToken(_ChangeEmailOldAddressMixin):
|
||||
"""Token returned after the old email verification code succeeds.
|
||||
|
||||
The token used to request a new-email code must come from this state. This
|
||||
blocks the GHSA-4q3w-q5mc-45rq bypass where a phase-1 token was replayed to
|
||||
skip the old-email verification step.
|
||||
"""
|
||||
|
||||
email_change_phase: Literal[ChangeEmailPhase.OLD_EMAIL_VERIFIED] = ChangeEmailPhase.OLD_EMAIL_VERIFIED
|
||||
|
||||
|
||||
class ChangeEmailNewEmailToken(ChangeEmailTokenBase):
|
||||
"""Token minted when sending a code to the target new email address.
|
||||
|
||||
At this point the account binding is already fixed, but the new address has
|
||||
not been verified yet, so the token may only be promoted by a successful
|
||||
new-email verification code check.
|
||||
"""
|
||||
|
||||
email_change_phase: Literal[ChangeEmailPhase.NEW_EMAIL] = ChangeEmailPhase.NEW_EMAIL
|
||||
|
||||
def promote(self) -> "ChangeEmailNewEmailVerifiedToken":
|
||||
"""Advance to the only state that may perform the final email reset."""
|
||||
return ChangeEmailNewEmailVerifiedToken(
|
||||
**self.model_dump(exclude={"email_change_phase"}),
|
||||
email_change_phase=ChangeEmailPhase.NEW_EMAIL_VERIFIED,
|
||||
)
|
||||
|
||||
|
||||
class ChangeEmailNewEmailVerifiedToken(ChangeEmailTokenBase):
|
||||
"""Final verified token for the change-email flow.
|
||||
|
||||
Only this state may change the account email, and the reset endpoint must
|
||||
additionally require that the request's `new_email` matches this token's
|
||||
`email` so a verified token for address A cannot be replayed for address B.
|
||||
"""
|
||||
|
||||
email_change_phase: Literal[ChangeEmailPhase.NEW_EMAIL_VERIFIED] = ChangeEmailPhase.NEW_EMAIL_VERIFIED
|
||||
|
||||
|
||||
# Tokens that can still advance by verifying a code.
|
||||
ChangeEmailPendingTokenData = Annotated[
|
||||
ChangeEmailOldEmailToken | ChangeEmailNewEmailToken,
|
||||
Field(discriminator="email_change_phase"),
|
||||
]
|
||||
|
||||
# Tokens that already completed a verification step.
|
||||
ChangeEmailVerifiedTokenData = Annotated[
|
||||
ChangeEmailOldEmailVerifiedToken | ChangeEmailNewEmailVerifiedToken,
|
||||
Field(discriminator="email_change_phase"),
|
||||
]
|
||||
|
||||
# Complete change-email token state machine.
|
||||
ChangeEmailTokenData = Annotated[
|
||||
ChangeEmailOldEmailToken
|
||||
| ChangeEmailOldEmailVerifiedToken
|
||||
| ChangeEmailNewEmailToken
|
||||
| ChangeEmailNewEmailVerifiedToken,
|
||||
Field(discriminator="email_change_phase"),
|
||||
]
|
||||
|
||||
@@ -3,6 +3,8 @@ import logging
|
||||
import time
|
||||
from typing import Any, TypedDict, cast
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from core.app.app_config.entities import ModelConfig
|
||||
from core.rag.datasource.retrieval_service import DefaultRetrievalModelDict, RetrievalService
|
||||
from core.rag.index_processor.constant.query_type import QueryType
|
||||
@@ -13,6 +15,7 @@ from extensions.ext_database import db
|
||||
from graphon.model_runtime.entities import LLMMode
|
||||
from models import Account
|
||||
from models.dataset import Dataset, DatasetQuery
|
||||
from models.dataset import Document as DatasetDocument
|
||||
from models.enums import CreatorUserRole, DatasetQuerySource
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -41,6 +44,59 @@ class HitTestingRetrievalModelDict(DefaultRetrievalModelDict, total=False):
|
||||
|
||||
|
||||
class HitTestingService:
|
||||
@staticmethod
|
||||
def _dump_dataset_document(document: DatasetDocument) -> dict[str, Any]:
|
||||
return {
|
||||
"id": document.id,
|
||||
"data_source_type": document.data_source_type,
|
||||
"name": document.name,
|
||||
"doc_type": document.doc_type,
|
||||
"doc_metadata": document.doc_metadata,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _dump_retrieval_records(cls, records: list[Any]) -> list[dict[str, Any]]:
|
||||
dumped_records = [record.model_dump() for record in records]
|
||||
document_ids = {
|
||||
segment.get("document_id")
|
||||
for record in dumped_records
|
||||
if isinstance(record, dict)
|
||||
for segment in [record.get("segment")]
|
||||
if isinstance(segment, dict) and segment.get("document_id")
|
||||
}
|
||||
if not document_ids:
|
||||
return dumped_records
|
||||
|
||||
documents = {
|
||||
document.id: cls._dump_dataset_document(document)
|
||||
for document in db.session.scalars(
|
||||
select(DatasetDocument).where(DatasetDocument.id.in_(document_ids))
|
||||
).all()
|
||||
}
|
||||
|
||||
records_with_documents: list[dict[str, Any]] = []
|
||||
missing_document_ids: set[str] = set()
|
||||
for record in dumped_records:
|
||||
segment = record.get("segment")
|
||||
if not isinstance(segment, dict):
|
||||
records_with_documents.append(record)
|
||||
continue
|
||||
|
||||
document_id = segment.get("document_id")
|
||||
if document_id in documents:
|
||||
segment["document"] = documents[document_id]
|
||||
records_with_documents.append(record)
|
||||
elif document_id:
|
||||
missing_document_ids.add(document_id)
|
||||
|
||||
if missing_document_ids:
|
||||
logger.warning(
|
||||
"Skipping hit-testing records with missing documents, document_ids=%s",
|
||||
sorted(missing_document_ids),
|
||||
)
|
||||
|
||||
return records_with_documents
|
||||
|
||||
@classmethod
|
||||
def retrieve(
|
||||
cls,
|
||||
@@ -174,7 +230,7 @@ class HitTestingService:
|
||||
"query": {
|
||||
"content": query,
|
||||
},
|
||||
"records": [record.model_dump() for record in records],
|
||||
"records": cls._dump_retrieval_records(records),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@ from pydantic import TypeAdapter
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from configs import dify_config
|
||||
from core.agent.entities import AgentToolEntity
|
||||
from core.helper import marketplace
|
||||
from core.plugin.entities.plugin import PluginInstallationSource
|
||||
@@ -310,6 +311,8 @@ class PluginMigration:
|
||||
"""
|
||||
Fetch plugin unique identifier using plugin id.
|
||||
"""
|
||||
if not dify_config.MARKETPLACE_ENABLED:
|
||||
return None
|
||||
plugin_manifest = marketplace.batch_fetch_plugin_manifests([plugin_id])
|
||||
if not plugin_manifest:
|
||||
return None
|
||||
@@ -542,6 +545,11 @@ class PluginMigration:
|
||||
"""
|
||||
Install plugins for a tenant.
|
||||
"""
|
||||
if plugin_identifiers_map and not dify_config.MARKETPLACE_ENABLED:
|
||||
raise ValueError(
|
||||
"Marketplace disabled in offline mode; cannot bulk-install plugins. "
|
||||
"Pre-upload plugin packages via Console first."
|
||||
)
|
||||
manager = PluginInstaller()
|
||||
|
||||
# download all the plugins and upload
|
||||
|
||||
@@ -73,35 +73,43 @@ class PluginService:
|
||||
cache_not_exists.append(plugin_id)
|
||||
|
||||
if cache_not_exists:
|
||||
manifests = {
|
||||
manifest.plugin_id: manifest
|
||||
for manifest in marketplace.batch_fetch_plugin_manifests(cache_not_exists)
|
||||
}
|
||||
|
||||
for plugin_id, manifest in manifests.items():
|
||||
latest_plugin = PluginService.LatestPluginCache(
|
||||
plugin_id=plugin_id,
|
||||
version=manifest.latest_version,
|
||||
unique_identifier=manifest.latest_package_identifier,
|
||||
status=manifest.status,
|
||||
deprecated_reason=manifest.deprecated_reason,
|
||||
alternative_plugin_id=manifest.alternative_plugin_id,
|
||||
if not dify_config.MARKETPLACE_ENABLED:
|
||||
logger.info(
|
||||
"Marketplace disabled; skipping latest-plugins metadata fetch for %d ids",
|
||||
len(cache_not_exists),
|
||||
)
|
||||
for plugin_id in cache_not_exists:
|
||||
result[plugin_id] = None
|
||||
else:
|
||||
manifests = {
|
||||
manifest.plugin_id: manifest
|
||||
for manifest in marketplace.batch_fetch_plugin_manifests(cache_not_exists)
|
||||
}
|
||||
|
||||
# Store in Redis
|
||||
redis_client.setex(
|
||||
f"{PluginService.REDIS_KEY_PREFIX}{plugin_id}",
|
||||
PluginService.REDIS_TTL,
|
||||
latest_plugin.model_dump_json(),
|
||||
)
|
||||
for plugin_id, manifest in manifests.items():
|
||||
latest_plugin = PluginService.LatestPluginCache(
|
||||
plugin_id=plugin_id,
|
||||
version=manifest.latest_version,
|
||||
unique_identifier=manifest.latest_package_identifier,
|
||||
status=manifest.status,
|
||||
deprecated_reason=manifest.deprecated_reason,
|
||||
alternative_plugin_id=manifest.alternative_plugin_id,
|
||||
)
|
||||
|
||||
result[plugin_id] = latest_plugin
|
||||
# Store in Redis
|
||||
redis_client.setex(
|
||||
f"{PluginService.REDIS_KEY_PREFIX}{plugin_id}",
|
||||
PluginService.REDIS_TTL,
|
||||
latest_plugin.model_dump_json(),
|
||||
)
|
||||
|
||||
# pop plugin_id from cache_not_exists
|
||||
cache_not_exists.remove(plugin_id)
|
||||
result[plugin_id] = latest_plugin
|
||||
|
||||
for plugin_id in cache_not_exists:
|
||||
result[plugin_id] = None
|
||||
# pop plugin_id from cache_not_exists
|
||||
cache_not_exists.remove(plugin_id)
|
||||
|
||||
for plugin_id in cache_not_exists:
|
||||
result[plugin_id] = None
|
||||
|
||||
return result
|
||||
except Exception:
|
||||
|
||||
@@ -1350,6 +1350,12 @@ class RagPipelineService:
|
||||
)
|
||||
return workflow_node_execution_db_model
|
||||
|
||||
def _fetch_recommended_plugin_manifests(self, plugin_ids: list[str]) -> list[Any]:
|
||||
if not dify_config.MARKETPLACE_ENABLED:
|
||||
logger.info("Marketplace disabled; recommended-plugins list empty")
|
||||
return []
|
||||
return marketplace.batch_fetch_plugin_by_ids(plugin_ids)
|
||||
|
||||
def get_recommended_plugins(self, type: str) -> dict[str, Any]:
|
||||
# Query active recommended plugins
|
||||
stmt = select(PipelineRecommendedPlugin).where(PipelineRecommendedPlugin.active == True)
|
||||
@@ -1372,7 +1378,7 @@ class RagPipelineService:
|
||||
)
|
||||
providers_map = {provider.plugin_id: provider.to_dict() for provider in providers}
|
||||
|
||||
plugin_manifests = marketplace.batch_fetch_plugin_by_ids(plugin_ids)
|
||||
plugin_manifests = self._fetch_recommended_plugin_manifests(plugin_ids)
|
||||
plugin_manifests_map = {manifest["plugin_id"]: manifest for manifest in plugin_manifests}
|
||||
|
||||
installed_plugin_list = []
|
||||
|
||||
@@ -100,6 +100,13 @@ class CheckDependenciesPendingData(BaseModel):
|
||||
|
||||
|
||||
class RagPipelineDslService:
|
||||
"""Import, export, and inspect RAG pipeline DSL using the caller-owned session.
|
||||
|
||||
Controllers wrap this service in a SQLAlchemy transaction context, so methods must only flush interim changes when
|
||||
generated IDs are needed. Committing inside the service would close the caller's transaction and break later work in
|
||||
the same context manager.
|
||||
"""
|
||||
|
||||
def __init__(self, session: Session):
|
||||
self._session = session
|
||||
|
||||
@@ -325,7 +332,7 @@ class RagPipelineDslService:
|
||||
type=CollectionBindingType.DATASET,
|
||||
)
|
||||
self._session.add(dataset_collection_binding)
|
||||
self._session.commit()
|
||||
self._session.flush()
|
||||
dataset_collection_binding_id = dataset_collection_binding.id
|
||||
dataset.collection_binding_id = dataset_collection_binding_id
|
||||
dataset.embedding_model = knowledge_configuration.embedding_model
|
||||
@@ -337,7 +344,7 @@ class RagPipelineDslService:
|
||||
dataset.summary_index_setting = knowledge_configuration.summary_index_setting
|
||||
dataset.pipeline_id = pipeline.id
|
||||
self._session.add(dataset)
|
||||
self._session.commit()
|
||||
self._session.flush()
|
||||
dataset_id = dataset.id
|
||||
if not dataset_id:
|
||||
raise ValueError("DSL is not valid, please check the Knowledge Index node.")
|
||||
@@ -462,7 +469,7 @@ class RagPipelineDslService:
|
||||
type=CollectionBindingType.DATASET,
|
||||
)
|
||||
self._session.add(dataset_collection_binding)
|
||||
self._session.commit()
|
||||
self._session.flush()
|
||||
dataset_collection_binding_id = dataset_collection_binding.id
|
||||
dataset.collection_binding_id = dataset_collection_binding_id
|
||||
dataset.embedding_model = knowledge_configuration.embedding_model
|
||||
@@ -474,7 +481,7 @@ class RagPipelineDslService:
|
||||
dataset.summary_index_setting = knowledge_configuration.summary_index_setting
|
||||
dataset.pipeline_id = pipeline.id
|
||||
self._session.add(dataset)
|
||||
self._session.commit()
|
||||
self._session.flush()
|
||||
dataset_id = dataset.id
|
||||
if not dataset_id:
|
||||
raise ValueError("DSL is not valid, please check the Knowledge Index node.")
|
||||
@@ -585,7 +592,7 @@ class RagPipelineDslService:
|
||||
pipeline.id = str(uuid4())
|
||||
|
||||
self._session.add(pipeline)
|
||||
self._session.commit()
|
||||
self._session.flush()
|
||||
# save dependencies
|
||||
if dependencies:
|
||||
redis_client.setex(
|
||||
@@ -627,8 +634,8 @@ class RagPipelineDslService:
|
||||
workflow.environment_variables = environment_variables
|
||||
workflow.conversation_variables = conversation_variables
|
||||
workflow.rag_pipeline_variables = rag_pipeline_variables_list
|
||||
# commit db session changes
|
||||
self._session.commit()
|
||||
# Keep transaction ownership with the caller while materializing IDs and constraint checks before returning.
|
||||
self._session.flush()
|
||||
|
||||
return pipeline
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import logging
|
||||
|
||||
from core.plugin.entities.plugin_daemon import PluginDatasourceProviderEntity
|
||||
from core.plugin.impl.datasource import PluginDatasourceManager
|
||||
from services.datasource_provider_service import DatasourceProviderService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RagPipelineManageService:
|
||||
@staticmethod
|
||||
@@ -15,9 +19,21 @@ class RagPipelineManageService:
|
||||
datasources = manager.fetch_datasource_providers(tenant_id)
|
||||
for datasource in datasources:
|
||||
datasource_provider_service = DatasourceProviderService()
|
||||
credentials = datasource_provider_service.get_datasource_credentials(
|
||||
tenant_id=tenant_id, provider=datasource.provider, plugin_id=datasource.plugin_id
|
||||
)
|
||||
if credentials:
|
||||
datasource.is_authorized = True
|
||||
try:
|
||||
credentials = datasource_provider_service.get_datasource_credentials(
|
||||
tenant_id=tenant_id, provider=datasource.provider, plugin_id=datasource.plugin_id
|
||||
)
|
||||
if credentials:
|
||||
datasource.is_authorized = True
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Skipping datasource credentials for provider %s after refresh or decrypt failure",
|
||||
datasource.provider,
|
||||
extra={
|
||||
"tenant_id": tenant_id,
|
||||
"plugin_id": datasource.plugin_id,
|
||||
"provider": datasource.provider,
|
||||
},
|
||||
)
|
||||
|
||||
return datasources
|
||||
|
||||
@@ -9,6 +9,7 @@ import yaml
|
||||
from flask_login import current_user
|
||||
from sqlalchemy import select
|
||||
|
||||
from configs import dify_config
|
||||
from constants import DOCUMENT_EXTENSIONS
|
||||
from core.plugin.impl.plugin import PluginInstaller
|
||||
from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType
|
||||
@@ -273,6 +274,13 @@ class RagPipelineTransformService:
|
||||
plugin_unique_identifier = dependency.get("value", {}).get("plugin_unique_identifier")
|
||||
plugin_id = plugin_unique_identifier.split(":")[0]
|
||||
if plugin_id not in installed_plugins_ids:
|
||||
if not dify_config.MARKETPLACE_ENABLED:
|
||||
logger.warning(
|
||||
"Marketplace disabled; skipping auto-install of %s. "
|
||||
"Pre-install via Console if pipeline requires it.",
|
||||
plugin_id,
|
||||
)
|
||||
continue
|
||||
plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(plugin_id) # type: ignore
|
||||
if plugin_unique_identifier:
|
||||
need_install_plugin_unique_identifiers.append(plugin_unique_identifier)
|
||||
|
||||
@@ -13,7 +13,10 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class RemoteRecommendAppRetrieval(RecommendAppRetrievalBase):
|
||||
"""
|
||||
Retrieval recommended app from dify official
|
||||
Retrieval recommended app from dify official.
|
||||
|
||||
The remote `/apps` payload is already curated for display, including category order.
|
||||
Keep the response order intact so Explore matches the template service.
|
||||
"""
|
||||
|
||||
def get_recommend_app_detail(self, app_id: str):
|
||||
@@ -64,8 +67,4 @@ class RemoteRecommendAppRetrieval(RecommendAppRetrievalBase):
|
||||
raise ValueError(f"fetch recommended apps failed, status code: {response.status_code}")
|
||||
|
||||
result: dict[str, Any] = response.json()
|
||||
|
||||
if "categories" in result:
|
||||
result["categories"] = sorted(result["categories"])
|
||||
|
||||
return result
|
||||
|
||||
@@ -21,7 +21,6 @@ class SaveTagPayload(BaseModel):
|
||||
|
||||
class UpdateTagPayload(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=50)
|
||||
type: TagType
|
||||
|
||||
|
||||
class TagBindingCreatePayload(BaseModel):
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Shared test helpers for backend migration tests."""
|
||||
@@ -0,0 +1,366 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
from models.account import Tenant
|
||||
from models.enums import CredentialSourceType
|
||||
from models.provider import (
|
||||
LoadBalancingModelConfig,
|
||||
ProviderModel,
|
||||
ProviderModelCredential,
|
||||
ProviderModelSetting,
|
||||
TenantDefaultModel,
|
||||
)
|
||||
|
||||
LEGACY_TO_CANONICAL: dict[str, str] = {
|
||||
"text-generation": "llm",
|
||||
"embeddings": "text-embedding",
|
||||
"reranking": "rerank",
|
||||
}
|
||||
UNCHANGED_MODEL_TYPES: tuple[str, ...] = ("speech2text", "moderation", "tts")
|
||||
ALL_TABLE_NAMES: tuple[str, ...] = (
|
||||
ProviderModel.__tablename__,
|
||||
TenantDefaultModel.__tablename__,
|
||||
ProviderModelSetting.__tablename__,
|
||||
LoadBalancingModelConfig.__tablename__,
|
||||
ProviderModelCredential.__tablename__,
|
||||
)
|
||||
DEFAULT_PRIMARY_TENANT_ID = "00000000-0000-0000-0000-000000000101"
|
||||
DEFAULT_SECONDARY_TENANT_ID = "00000000-0000-0000-0000-000000000202"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DirtyTenantFixture:
|
||||
tenant_id: str
|
||||
winner_credential_id: str
|
||||
loser_credential_id: str
|
||||
distinct_credential_id: str
|
||||
provider_model_id: str
|
||||
load_balancing_config_id: str
|
||||
provider_model_setting_id: str
|
||||
tenant_default_model_id: str
|
||||
embedding_provider_model_id: str
|
||||
embedding_setting_id: str
|
||||
loser_credential_name: str
|
||||
distinct_credential_name: str
|
||||
loser_encrypted_config: str
|
||||
winner_encrypted_config: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DirtyDataFixture:
|
||||
primary: DirtyTenantFixture
|
||||
secondary: DirtyTenantFixture
|
||||
|
||||
|
||||
def create_minimal_legacy_model_type_schema(engine: Engine) -> None:
|
||||
metadata = Tenant.__table__.metadata
|
||||
metadata.create_all(
|
||||
engine,
|
||||
tables=[
|
||||
Tenant.__table__,
|
||||
ProviderModel.__table__,
|
||||
TenantDefaultModel.__table__,
|
||||
ProviderModelSetting.__table__,
|
||||
LoadBalancingModelConfig.__table__,
|
||||
ProviderModelCredential.__table__,
|
||||
],
|
||||
checkfirst=True,
|
||||
)
|
||||
|
||||
|
||||
def drop_minimal_legacy_model_type_schema(engine: Engine) -> None:
|
||||
metadata = Tenant.__table__.metadata
|
||||
metadata.drop_all(
|
||||
engine,
|
||||
tables=[
|
||||
LoadBalancingModelConfig.__table__,
|
||||
ProviderModelSetting.__table__,
|
||||
TenantDefaultModel.__table__,
|
||||
ProviderModel.__table__,
|
||||
ProviderModelCredential.__table__,
|
||||
Tenant.__table__,
|
||||
],
|
||||
checkfirst=True,
|
||||
)
|
||||
|
||||
|
||||
def seed_legacy_model_type_dirty_data(
|
||||
engine: Engine,
|
||||
*,
|
||||
primary_tenant_id: str = DEFAULT_PRIMARY_TENANT_ID,
|
||||
secondary_tenant_id: str = DEFAULT_SECONDARY_TENANT_ID,
|
||||
) -> DirtyDataFixture:
|
||||
create_minimal_legacy_model_type_schema(engine)
|
||||
primary = _seed_tenant(engine, tenant_id=primary_tenant_id, provider_name="openai")
|
||||
secondary = _seed_tenant(engine, tenant_id=secondary_tenant_id, provider_name="openai")
|
||||
return DirtyDataFixture(primary=primary, secondary=secondary)
|
||||
|
||||
|
||||
def snapshot_legacy_model_type_state(engine: Engine) -> dict[str, list[dict[str, object]]]:
|
||||
snapshots: dict[str, list[dict[str, object]]] = {}
|
||||
for table_name in ALL_TABLE_NAMES:
|
||||
snapshots[table_name] = fetch_table_rows(engine, table_name)
|
||||
return snapshots
|
||||
|
||||
|
||||
def fetch_table_rows(
|
||||
engine: Engine,
|
||||
table_name: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
) -> list[dict[str, object]]:
|
||||
sql = f"SELECT * FROM {table_name}"
|
||||
params: dict[str, object] = {}
|
||||
if tenant_id is not None:
|
||||
sql += " WHERE tenant_id = :tenant_id"
|
||||
params["tenant_id"] = tenant_id
|
||||
sql += " ORDER BY id ASC"
|
||||
|
||||
with engine.begin() as conn:
|
||||
rows = conn.execute(sa.text(sql), params).mappings().all()
|
||||
|
||||
result: list[dict[str, object]] = []
|
||||
for row in rows:
|
||||
normalized = dict(row)
|
||||
for key, value in normalized.items():
|
||||
if isinstance(value, datetime):
|
||||
normalized[key] = value.isoformat()
|
||||
elif isinstance(value, uuid.UUID):
|
||||
normalized[key] = str(value)
|
||||
result.append(normalized)
|
||||
return result
|
||||
|
||||
|
||||
def fetch_model_types_for_tenant(engine: Engine, table_name: str, tenant_id: str) -> list[str]:
|
||||
rows = fetch_table_rows(engine, table_name, tenant_id=tenant_id)
|
||||
return [str(row["model_type"]) for row in rows]
|
||||
|
||||
|
||||
def assert_tenant_rows_use_only_canonical_model_types(engine: Engine, tenant_id: str) -> None:
|
||||
for table_name in ALL_TABLE_NAMES:
|
||||
model_types = fetch_model_types_for_tenant(engine, table_name, tenant_id)
|
||||
assert set(model_types) <= set(LEGACY_TO_CANONICAL.values()) | set(UNCHANGED_MODEL_TYPES), (
|
||||
table_name,
|
||||
model_types,
|
||||
)
|
||||
|
||||
|
||||
def count_rows(engine: Engine, table_name: str, *, tenant_id: str) -> int:
|
||||
with engine.begin() as conn:
|
||||
stmt = sa.text(f"SELECT COUNT(*) FROM {table_name} WHERE tenant_id = :tenant_id")
|
||||
return int(conn.execute(stmt, {"tenant_id": tenant_id}).scalar_one())
|
||||
|
||||
|
||||
def _seed_tenant(engine: Engine, *, tenant_id: str, provider_name: str) -> DirtyTenantFixture:
|
||||
now = datetime(2025, 1, 1, 12, 0, 0)
|
||||
winner_credential_id = str(uuid4())
|
||||
loser_credential_id = str(uuid4())
|
||||
distinct_credential_id = str(uuid4())
|
||||
provider_model_id = str(uuid4())
|
||||
load_balancing_config_id = str(uuid4())
|
||||
provider_model_setting_id = str(uuid4())
|
||||
tenant_default_model_id = str(uuid4())
|
||||
embedding_provider_model_id = str(uuid4())
|
||||
embedding_setting_id = str(uuid4())
|
||||
|
||||
loser_credential_name = f"{tenant_id}-shared"
|
||||
distinct_credential_name = f"{tenant_id}-distinct"
|
||||
winner_encrypted_config = json.dumps({"api_key": f"{tenant_id}-winner"})
|
||||
loser_encrypted_config = json.dumps({"api_key": f"{tenant_id}-loser"})
|
||||
distinct_encrypted_config = json.dumps({"api_key": f"{tenant_id}-distinct"})
|
||||
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
Tenant.__table__.insert().values(
|
||||
id=tenant_id,
|
||||
name=f"Tenant {tenant_id}",
|
||||
plan="basic",
|
||||
status="normal",
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO provider_model_credentials
|
||||
(
|
||||
id, tenant_id, provider_name, model_name,
|
||||
model_type, credential_name, encrypted_config,
|
||||
created_at, updated_at
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
:winner_id, :tenant_id, :provider_name, 'gpt-4o-mini',
|
||||
'llm', :shared_name, :winner_config,
|
||||
:created_at, :winner_updated_at
|
||||
),
|
||||
(
|
||||
:loser_id, :tenant_id, :provider_name, 'gpt-4o-mini',
|
||||
'text-generation', :shared_name, :loser_config,
|
||||
:created_at, :loser_updated_at
|
||||
),
|
||||
(
|
||||
:distinct_id, :tenant_id, :provider_name, 'gpt-4o-mini',
|
||||
'text-generation', :distinct_name, :distinct_config,
|
||||
:created_at, :distinct_updated_at
|
||||
)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"winner_id": winner_credential_id,
|
||||
"loser_id": loser_credential_id,
|
||||
"distinct_id": distinct_credential_id,
|
||||
"tenant_id": tenant_id,
|
||||
"provider_name": provider_name,
|
||||
"shared_name": loser_credential_name,
|
||||
"distinct_name": distinct_credential_name,
|
||||
"winner_config": winner_encrypted_config,
|
||||
"loser_config": loser_encrypted_config,
|
||||
"distinct_config": distinct_encrypted_config,
|
||||
"created_at": now - timedelta(days=2),
|
||||
"winner_updated_at": now,
|
||||
"loser_updated_at": now - timedelta(days=1),
|
||||
"distinct_updated_at": now - timedelta(hours=12),
|
||||
},
|
||||
)
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO provider_models
|
||||
(
|
||||
id, tenant_id, provider_name, model_name,
|
||||
model_type, credential_id, is_valid,
|
||||
created_at, updated_at
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
:provider_model_id, :tenant_id, :provider_name, 'gpt-4o-mini',
|
||||
'text-generation', :loser_id, :is_valid,
|
||||
:created_at, :updated_at
|
||||
),
|
||||
(
|
||||
:embedding_provider_model_id, :tenant_id, :provider_name, 'text-embedding-3-large',
|
||||
'embeddings', NULL, :is_valid,
|
||||
:created_at, :updated_at
|
||||
)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"provider_model_id": provider_model_id,
|
||||
"embedding_provider_model_id": embedding_provider_model_id,
|
||||
"tenant_id": tenant_id,
|
||||
"provider_name": provider_name,
|
||||
"loser_id": loser_credential_id,
|
||||
"is_valid": True,
|
||||
"created_at": now - timedelta(days=2),
|
||||
"updated_at": now - timedelta(hours=6),
|
||||
},
|
||||
)
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO tenant_default_models
|
||||
(id, tenant_id, provider_name, model_name, model_type, created_at, updated_at)
|
||||
VALUES
|
||||
(
|
||||
:tenant_default_model_id, :tenant_id, :provider_name, 'gpt-4o-mini',
|
||||
'text-generation', :created_at, :updated_at
|
||||
)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"tenant_default_model_id": tenant_default_model_id,
|
||||
"tenant_id": tenant_id,
|
||||
"provider_name": provider_name,
|
||||
"created_at": now - timedelta(days=2),
|
||||
"updated_at": now - timedelta(hours=4),
|
||||
},
|
||||
)
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO provider_model_settings
|
||||
(
|
||||
id, tenant_id, provider_name, model_name,
|
||||
model_type, enabled, load_balancing_enabled,
|
||||
created_at, updated_at
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
:provider_model_setting_id, :tenant_id, :provider_name, 'gpt-4o-mini',
|
||||
'text-generation', :enabled, :load_balancing_enabled,
|
||||
:created_at, :updated_at
|
||||
),
|
||||
(
|
||||
:embedding_setting_id, :tenant_id, :provider_name, 'text-embedding-3-large',
|
||||
'embeddings', :enabled, :embedding_load_balancing_enabled,
|
||||
:created_at, :updated_at
|
||||
)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"provider_model_setting_id": provider_model_setting_id,
|
||||
"embedding_setting_id": embedding_setting_id,
|
||||
"tenant_id": tenant_id,
|
||||
"provider_name": provider_name,
|
||||
"enabled": True,
|
||||
"load_balancing_enabled": True,
|
||||
"embedding_load_balancing_enabled": False,
|
||||
"created_at": now - timedelta(days=2),
|
||||
"updated_at": now - timedelta(hours=3),
|
||||
},
|
||||
)
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO load_balancing_model_configs
|
||||
(
|
||||
id, tenant_id, provider_name, model_name, model_type,
|
||||
name, encrypted_config, credential_id, credential_source_type,
|
||||
enabled, created_at, updated_at
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
:load_balancing_config_id, :tenant_id, :provider_name, 'gpt-4o-mini', 'text-generation',
|
||||
:lb_name, :loser_config, :loser_id, :credential_source_type,
|
||||
:enabled, :created_at, :updated_at
|
||||
)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"load_balancing_config_id": load_balancing_config_id,
|
||||
"tenant_id": tenant_id,
|
||||
"provider_name": provider_name,
|
||||
"lb_name": loser_credential_name,
|
||||
"loser_config": loser_encrypted_config,
|
||||
"loser_id": loser_credential_id,
|
||||
"credential_source_type": CredentialSourceType.CUSTOM_MODEL.value,
|
||||
"enabled": True,
|
||||
"created_at": now - timedelta(days=2),
|
||||
"updated_at": now - timedelta(hours=2),
|
||||
},
|
||||
)
|
||||
|
||||
return DirtyTenantFixture(
|
||||
tenant_id=tenant_id,
|
||||
winner_credential_id=winner_credential_id,
|
||||
loser_credential_id=loser_credential_id,
|
||||
distinct_credential_id=distinct_credential_id,
|
||||
provider_model_id=provider_model_id,
|
||||
load_balancing_config_id=load_balancing_config_id,
|
||||
provider_model_setting_id=provider_model_setting_id,
|
||||
tenant_default_model_id=tenant_default_model_id,
|
||||
embedding_provider_model_id=embedding_provider_model_id,
|
||||
embedding_setting_id=embedding_setting_id,
|
||||
loser_credential_name=loser_credential_name,
|
||||
distinct_credential_name=distinct_credential_name,
|
||||
loser_encrypted_config=loser_encrypted_config,
|
||||
winner_encrypted_config=winner_encrypted_config,
|
||||
)
|
||||
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
API_PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(API_PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(API_PROJECT_ROOT))
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from tests.helpers.legacy_model_type_migration import (
|
||||
DEFAULT_PRIMARY_TENANT_ID,
|
||||
DEFAULT_SECONDARY_TENANT_ID,
|
||||
create_minimal_legacy_model_type_schema,
|
||||
seed_legacy_model_type_dirty_data,
|
||||
)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Seed dirty legacy model_type rows for manual migration experiments. "
|
||||
"Example: uv run --project api python api/tests/seed_legacy_model_type_dirty_data.py "
|
||||
"--db-url postgresql://postgres:postgres@127.0.0.1:5432/dify"
|
||||
)
|
||||
)
|
||||
parser.add_argument("--db-url", required=True, help="SQLAlchemy database URL for the target database.")
|
||||
parser.add_argument(
|
||||
"--primary-tenant-id",
|
||||
default=DEFAULT_PRIMARY_TENANT_ID,
|
||||
help="Tenant that will contain the main conflict scenario.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--secondary-tenant-id",
|
||||
default=DEFAULT_SECONDARY_TENANT_ID,
|
||||
help="Tenant used to verify tenant filtering behavior.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--create-minimal-schema",
|
||||
action="store_true",
|
||||
help="Create the minimal tables needed for the seed when running against an empty scratch database.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
engine = sa.create_engine(args.db_url)
|
||||
try:
|
||||
if args.create_minimal_schema:
|
||||
create_minimal_legacy_model_type_schema(engine)
|
||||
|
||||
fixture = seed_legacy_model_type_dirty_data(
|
||||
engine,
|
||||
primary_tenant_id=args.primary_tenant_id,
|
||||
secondary_tenant_id=args.secondary_tenant_id,
|
||||
)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"primary_tenant_id": fixture.primary.tenant_id,
|
||||
"secondary_tenant_id": fixture.secondary.tenant_id,
|
||||
"winner_credential_id": fixture.primary.winner_credential_id,
|
||||
"loser_credential_id": fixture.primary.loser_credential_id,
|
||||
"provider_model_id": fixture.primary.provider_model_id,
|
||||
"load_balancing_config_id": fixture.primary.load_balancing_config_id,
|
||||
},
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+408
@@ -0,0 +1,408 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import io
|
||||
import json
|
||||
from collections.abc import Generator
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
|
||||
from tests.helpers.legacy_model_type_migration import (
|
||||
assert_tenant_rows_use_only_canonical_model_types,
|
||||
count_rows,
|
||||
fetch_table_rows,
|
||||
seed_legacy_model_type_dirty_data,
|
||||
)
|
||||
|
||||
|
||||
def _parse_json_lines(output: io.StringIO) -> list[dict[str, object]]:
|
||||
return [json.loads(line) for line in output.getvalue().splitlines() if line.strip()]
|
||||
|
||||
|
||||
def _json_key(value: object) -> str:
|
||||
return json.dumps(value, sort_keys=True)
|
||||
|
||||
|
||||
def _lb_processing_signatures(lines: list[dict[str, object]]) -> set[tuple[object, ...]]:
|
||||
signatures: set[tuple[object, ...]] = set()
|
||||
for line in lines:
|
||||
attrs = line.get("attrs")
|
||||
if not isinstance(attrs, dict):
|
||||
continue
|
||||
if attrs.get("table_name") != "load_balancing_model_configs":
|
||||
continue
|
||||
event = line.get("event")
|
||||
if event == "row_updated":
|
||||
signatures.add(
|
||||
(
|
||||
event,
|
||||
attrs.get("id"),
|
||||
_json_key(attrs.get("old_values")),
|
||||
_json_key(attrs.get("new_values")),
|
||||
)
|
||||
)
|
||||
elif event == "row_deleted":
|
||||
signatures.add(
|
||||
(
|
||||
event,
|
||||
attrs.get("id"),
|
||||
attrs.get("merge_winner_id"),
|
||||
)
|
||||
)
|
||||
elif event == "group_processed":
|
||||
signatures.add(
|
||||
(
|
||||
event,
|
||||
attrs.get("table_name"),
|
||||
_json_key(attrs.get("business_key")),
|
||||
tuple(attrs.get("group_row_ids", [])),
|
||||
)
|
||||
)
|
||||
return signatures
|
||||
|
||||
|
||||
def _insert_load_balancing_model_config(
|
||||
engine: sa.Engine,
|
||||
*,
|
||||
row_id: str,
|
||||
tenant_id: str,
|
||||
provider_name: str,
|
||||
model_name: str,
|
||||
model_type: str,
|
||||
name: str,
|
||||
encrypted_config: str,
|
||||
credential_id: str,
|
||||
enabled: bool,
|
||||
created_at: datetime,
|
||||
updated_at: datetime,
|
||||
) -> None:
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO load_balancing_model_configs
|
||||
(
|
||||
id, tenant_id, provider_name, model_name, model_type, name,
|
||||
encrypted_config, credential_id, credential_source_type, enabled, created_at, updated_at
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
:id, :tenant_id, :provider_name, :model_name, :model_type, :name,
|
||||
:encrypted_config, :credential_id, :credential_source_type, :enabled, :created_at, :updated_at
|
||||
)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": row_id,
|
||||
"tenant_id": tenant_id,
|
||||
"provider_name": provider_name,
|
||||
"model_name": model_name,
|
||||
"model_type": model_type,
|
||||
"name": name,
|
||||
"encrypted_config": encrypted_config,
|
||||
"credential_id": credential_id,
|
||||
"credential_source_type": "custom_model",
|
||||
"enabled": enabled,
|
||||
"created_at": created_at,
|
||||
"updated_at": updated_at,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def migration_module():
|
||||
try:
|
||||
return importlib.import_module("services.legacy_model_type_migration")
|
||||
except ModuleNotFoundError as exc: # pragma: no cover - explicit TDD failure path
|
||||
pytest.fail(
|
||||
"services.legacy_model_type_migration is missing. "
|
||||
"Implement LegacyModelTypeMigrationService before running these tests."
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(params=("postgresql", "mysql"), scope="session")
|
||||
def container_engine(request: pytest.FixtureRequest) -> Generator[tuple[str, sa.Engine], None, None]:
|
||||
backend_name = request.param
|
||||
if backend_name == "postgresql":
|
||||
testcontainers_postgres = pytest.importorskip("testcontainers.postgres")
|
||||
container = testcontainers_postgres.PostgresContainer("postgres:15-alpine")
|
||||
else:
|
||||
testcontainers_mysql = pytest.importorskip("testcontainers.mysql")
|
||||
container = testcontainers_mysql.MySqlContainer("mysql:8.0")
|
||||
|
||||
container.start()
|
||||
raw_url = container.get_connection_url()
|
||||
engine_url = raw_url.replace("mysql://", "mysql+pymysql://", 1)
|
||||
engine = sa.create_engine(engine_url)
|
||||
|
||||
try:
|
||||
yield backend_name, engine
|
||||
finally:
|
||||
engine.dispose()
|
||||
container.stop()
|
||||
|
||||
|
||||
def test_legacy_model_type_migration_end_to_end_across_supported_backends(
|
||||
migration_module,
|
||||
container_engine: tuple[str, sa.Engine],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
backend_name, engine = container_engine
|
||||
helper_module = importlib.import_module("tests.helpers.legacy_model_type_migration")
|
||||
helper_module.drop_minimal_legacy_model_type_schema(engine)
|
||||
fixture = seed_legacy_model_type_dirty_data(engine)
|
||||
|
||||
deleted_cache_keys: list[str] = []
|
||||
|
||||
def _record_delete(self) -> None:
|
||||
deleted_cache_keys.append(self.cache_key)
|
||||
|
||||
monkeypatch.setattr(migration_module.ProviderCredentialsCache, "delete", _record_delete)
|
||||
|
||||
dry_run_output = io.StringIO()
|
||||
migration_module.LegacyModelTypeMigrationService(
|
||||
engine=engine,
|
||||
apply=False,
|
||||
output=dry_run_output,
|
||||
tenant_ids=(fixture.primary.tenant_id,),
|
||||
).migrate()
|
||||
|
||||
assert count_rows(engine, "provider_model_credentials", tenant_id=fixture.primary.tenant_id) == 3
|
||||
assert deleted_cache_keys == []
|
||||
|
||||
apply_output = io.StringIO()
|
||||
migration_module.LegacyModelTypeMigrationService(
|
||||
engine=engine,
|
||||
apply=True,
|
||||
output=apply_output,
|
||||
tenant_ids=(fixture.primary.tenant_id,),
|
||||
).migrate()
|
||||
first_apply_state = {
|
||||
table_name: fetch_table_rows(engine, table_name, tenant_id=fixture.primary.tenant_id)
|
||||
for table_name in (
|
||||
"provider_models",
|
||||
"tenant_default_models",
|
||||
"provider_model_settings",
|
||||
"load_balancing_model_configs",
|
||||
"provider_model_credentials",
|
||||
)
|
||||
}
|
||||
|
||||
assert_tenant_rows_use_only_canonical_model_types(engine, fixture.primary.tenant_id)
|
||||
assert count_rows(engine, "provider_model_credentials", tenant_id=fixture.primary.tenant_id) == 2
|
||||
provider_model_row = next(
|
||||
row for row in first_apply_state["provider_models"] if row["id"] == fixture.primary.provider_model_id
|
||||
)
|
||||
assert provider_model_row["credential_id"] == fixture.primary.winner_credential_id
|
||||
credential_ids = {str(row["id"]) for row in first_apply_state["provider_model_credentials"]}
|
||||
assert credential_ids == {
|
||||
fixture.primary.winner_credential_id,
|
||||
fixture.primary.distinct_credential_id,
|
||||
}
|
||||
lb_row = next(
|
||||
row
|
||||
for row in first_apply_state["load_balancing_model_configs"]
|
||||
if row["id"] == fixture.primary.load_balancing_config_id
|
||||
)
|
||||
assert lb_row["credential_id"] == fixture.primary.winner_credential_id
|
||||
assert lb_row["encrypted_config"] == fixture.primary.winner_encrypted_config
|
||||
assert deleted_cache_keys, f"{backend_name} apply run should clear cache keys"
|
||||
|
||||
migration_module.LegacyModelTypeMigrationService(
|
||||
engine=engine,
|
||||
apply=True,
|
||||
output=io.StringIO(),
|
||||
tenant_ids=(fixture.primary.tenant_id,),
|
||||
).migrate()
|
||||
second_apply_state = {
|
||||
table_name: fetch_table_rows(engine, table_name, tenant_id=fixture.primary.tenant_id)
|
||||
for table_name in first_apply_state
|
||||
}
|
||||
assert second_apply_state == first_apply_state
|
||||
|
||||
|
||||
def test_load_balancing_inherit_deduplication_is_applied_consistently_across_supported_backends(
|
||||
migration_module,
|
||||
container_engine: tuple[str, sa.Engine],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_, engine = container_engine
|
||||
helper_module = importlib.import_module("tests.helpers.legacy_model_type_migration")
|
||||
helper_module.drop_minimal_legacy_model_type_schema(engine)
|
||||
fixture = seed_legacy_model_type_dirty_data(engine)
|
||||
|
||||
tenant_id = fixture.primary.tenant_id
|
||||
older_inherit_row_id = "00000000-0000-0000-0000-00000000ee01"
|
||||
newer_inherit_row_id = "00000000-0000-0000-0000-00000000ee02"
|
||||
canonical_non_inherit_row_id = "00000000-0000-0000-0000-00000000ee03"
|
||||
created_at = datetime(2025, 1, 1, 8, 0, 0)
|
||||
|
||||
_insert_load_balancing_model_config(
|
||||
engine,
|
||||
row_id=older_inherit_row_id,
|
||||
tenant_id=tenant_id,
|
||||
provider_name="openai",
|
||||
model_name="gpt-4o-mini",
|
||||
model_type="llm",
|
||||
name="__inherit__",
|
||||
encrypted_config='{"api_key":"older-inherit"}',
|
||||
credential_id=fixture.primary.winner_credential_id,
|
||||
enabled=True,
|
||||
created_at=created_at,
|
||||
updated_at=created_at + timedelta(minutes=15),
|
||||
)
|
||||
_insert_load_balancing_model_config(
|
||||
engine,
|
||||
row_id=newer_inherit_row_id,
|
||||
tenant_id=tenant_id,
|
||||
provider_name="openai",
|
||||
model_name="gpt-4o-mini",
|
||||
model_type="text-generation",
|
||||
name="__inherit__",
|
||||
encrypted_config='{"api_key":"newer-inherit"}',
|
||||
credential_id=fixture.primary.distinct_credential_id,
|
||||
enabled=True,
|
||||
created_at=created_at,
|
||||
updated_at=created_at + timedelta(minutes=30),
|
||||
)
|
||||
_insert_load_balancing_model_config(
|
||||
engine,
|
||||
row_id=canonical_non_inherit_row_id,
|
||||
tenant_id=tenant_id,
|
||||
provider_name="openai",
|
||||
model_name="gpt-4o-mini",
|
||||
model_type="llm",
|
||||
name=f"{tenant_id}-second-shared",
|
||||
encrypted_config='{"api_key":"non-inherit-canonical"}',
|
||||
credential_id=fixture.primary.distinct_credential_id,
|
||||
enabled=True,
|
||||
created_at=created_at,
|
||||
updated_at=created_at + timedelta(minutes=45),
|
||||
)
|
||||
|
||||
before_dry_run = fetch_table_rows(engine, "load_balancing_model_configs", tenant_id=tenant_id)
|
||||
deleted_cache_keys: list[str] = []
|
||||
|
||||
def _record_delete(self) -> None:
|
||||
deleted_cache_keys.append(self.cache_key)
|
||||
|
||||
monkeypatch.setattr(migration_module.ProviderCredentialsCache, "delete", _record_delete)
|
||||
|
||||
dry_run_output = io.StringIO()
|
||||
migration_module.LegacyModelTypeMigrationService(
|
||||
engine=engine,
|
||||
apply=False,
|
||||
output=dry_run_output,
|
||||
tables=("load_balancing_model_configs",),
|
||||
model_types=(migration_module.ModelType.LLM,),
|
||||
tenant_ids=(tenant_id,),
|
||||
).migrate()
|
||||
|
||||
after_dry_run = fetch_table_rows(engine, "load_balancing_model_configs", tenant_id=tenant_id)
|
||||
dry_run_lines = _parse_json_lines(dry_run_output)
|
||||
dry_run_cache_events = [line["event"] for line in dry_run_lines if str(line.get("event")).startswith("cache_")]
|
||||
dry_run_row_updates = {
|
||||
str(attrs["id"])
|
||||
for line in dry_run_lines
|
||||
if line.get("event") == "row_updated"
|
||||
and isinstance((attrs := line.get("attrs")), dict)
|
||||
and attrs.get("table_name") == "load_balancing_model_configs"
|
||||
}
|
||||
dry_run_row_deletes = {
|
||||
str(attrs["id"])
|
||||
for line in dry_run_lines
|
||||
if line.get("event") == "row_deleted"
|
||||
and isinstance((attrs := line.get("attrs")), dict)
|
||||
and attrs.get("table_name") == "load_balancing_model_configs"
|
||||
}
|
||||
dry_run_group_processed = [
|
||||
attrs
|
||||
for line in dry_run_lines
|
||||
if line.get("event") == "group_processed"
|
||||
and isinstance((attrs := line.get("attrs")), dict)
|
||||
and attrs.get("table_name") == "load_balancing_model_configs"
|
||||
]
|
||||
|
||||
assert after_dry_run == before_dry_run
|
||||
assert deleted_cache_keys == []
|
||||
assert dry_run_row_deletes == {older_inherit_row_id}
|
||||
assert dry_run_row_updates == {
|
||||
fixture.primary.load_balancing_config_id,
|
||||
newer_inherit_row_id,
|
||||
}
|
||||
assert canonical_non_inherit_row_id not in dry_run_row_updates
|
||||
assert "cache_delete_planned" in dry_run_cache_events
|
||||
assert "cache_deleted" not in dry_run_cache_events
|
||||
assert len(dry_run_group_processed) == 1
|
||||
assert dry_run_group_processed[0]["table_name"] == "load_balancing_model_configs"
|
||||
assert dry_run_group_processed[0]["business_key"] == {
|
||||
"tenant_id": tenant_id,
|
||||
"provider_name": "openai",
|
||||
"model_name": "gpt-4o-mini",
|
||||
"model_type": "llm",
|
||||
}
|
||||
assert set(dry_run_group_processed[0]["group_row_ids"]) == {
|
||||
older_inherit_row_id,
|
||||
newer_inherit_row_id,
|
||||
}
|
||||
|
||||
apply_output = io.StringIO()
|
||||
migration_module.LegacyModelTypeMigrationService(
|
||||
engine=engine,
|
||||
apply=True,
|
||||
output=apply_output,
|
||||
tables=("load_balancing_model_configs",),
|
||||
model_types=(migration_module.ModelType.LLM,),
|
||||
tenant_ids=(tenant_id,),
|
||||
).migrate()
|
||||
|
||||
apply_lines = _parse_json_lines(apply_output)
|
||||
apply_cache_events = [line["event"] for line in apply_lines if str(line.get("event")).startswith("cache_")]
|
||||
apply_group_processed = [
|
||||
attrs
|
||||
for line in apply_lines
|
||||
if line.get("event") == "group_processed"
|
||||
and isinstance((attrs := line.get("attrs")), dict)
|
||||
and attrs.get("table_name") == "load_balancing_model_configs"
|
||||
]
|
||||
assert _lb_processing_signatures(apply_lines) == _lb_processing_signatures(dry_run_lines)
|
||||
assert "cache_deleted" in apply_cache_events
|
||||
assert deleted_cache_keys
|
||||
assert len(apply_group_processed) == len(dry_run_group_processed)
|
||||
assert [
|
||||
(
|
||||
attrs["table_name"],
|
||||
_json_key(attrs["business_key"]),
|
||||
tuple(attrs["group_row_ids"]),
|
||||
)
|
||||
for attrs in apply_group_processed
|
||||
] == [
|
||||
(
|
||||
attrs["table_name"],
|
||||
_json_key(attrs["business_key"]),
|
||||
tuple(attrs["group_row_ids"]),
|
||||
)
|
||||
for attrs in dry_run_group_processed
|
||||
]
|
||||
|
||||
lb_rows = fetch_table_rows(engine, "load_balancing_model_configs", tenant_id=tenant_id)
|
||||
surviving_inherit_rows = [row for row in lb_rows if row["name"] == "__inherit__"]
|
||||
surviving_non_inherit_rows = [row for row in lb_rows if row["name"] != "__inherit__"]
|
||||
|
||||
assert {str(row["id"]) for row in surviving_inherit_rows} == {newer_inherit_row_id}
|
||||
assert surviving_inherit_rows[0]["model_type"] == "llm"
|
||||
assert surviving_inherit_rows[0]["credential_id"] == fixture.primary.distinct_credential_id
|
||||
|
||||
assert {
|
||||
str(row["id"])
|
||||
for row in surviving_non_inherit_rows
|
||||
if str(row["id"]) in {fixture.primary.load_balancing_config_id, canonical_non_inherit_row_id}
|
||||
} == {fixture.primary.load_balancing_config_id, canonical_non_inherit_row_id}
|
||||
assert all(
|
||||
row["model_type"] == "llm"
|
||||
for row in surviving_non_inherit_rows
|
||||
if str(row["id"]) in {fixture.primary.load_balancing_config_id, canonical_non_inherit_row_id}
|
||||
)
|
||||
assert count_rows(engine, "load_balancing_model_configs", tenant_id=tenant_id) == len(before_dry_run) - 1
|
||||
@@ -759,7 +759,7 @@ class TestTagService:
|
||||
tag = TagService.save_tags(tag_args)
|
||||
|
||||
# Update args
|
||||
update_args = UpdateTagPayload(name="updated_name", type="knowledge")
|
||||
update_args = UpdateTagPayload(name="updated_name")
|
||||
|
||||
# Act: Execute the method under test
|
||||
result = TagService.update_tags(update_args, tag.id)
|
||||
@@ -799,7 +799,7 @@ class TestTagService:
|
||||
|
||||
non_existent_tag_id = str(uuid.uuid4())
|
||||
|
||||
update_args = UpdateTagPayload(name="updated_name", type="knowledge")
|
||||
update_args = UpdateTagPayload(name="updated_name")
|
||||
|
||||
# Act & Assert: Verify proper error handling
|
||||
with pytest.raises(NotFound) as exc_info:
|
||||
@@ -830,7 +830,7 @@ class TestTagService:
|
||||
tag2 = TagService.save_tags(tag2_args)
|
||||
|
||||
# Try to update second tag with first tag's name
|
||||
update_args = UpdateTagPayload(name="first_tag", type="app")
|
||||
update_args = UpdateTagPayload(name="first_tag")
|
||||
|
||||
# Act & Assert: Verify proper error handling
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -120,7 +120,7 @@ class TestParseArgs:
|
||||
class TestPerformHitTesting:
|
||||
def test_success(self, dataset):
|
||||
response = {
|
||||
"query": "hello",
|
||||
"query": {"content": "hello"},
|
||||
"records": [],
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ class TestPerformHitTesting:
|
||||
assert result["query"] == "hello"
|
||||
assert result["records"] == []
|
||||
|
||||
def test_success_normalizes_legacy_query_and_nullable_list_fields(self, dataset):
|
||||
def test_success_prepares_nullable_list_fields(self, dataset):
|
||||
response = {
|
||||
"query": {"content": "hello"},
|
||||
"records": [
|
||||
@@ -170,6 +170,18 @@ class TestPerformHitTesting:
|
||||
}
|
||||
]
|
||||
|
||||
def test_invalid_query_response_raises_value_error(self):
|
||||
with pytest.raises(ValueError, match="Invalid hit testing query response"):
|
||||
DatasetsHitTestingBase._extract_hit_testing_query("hello")
|
||||
|
||||
def test_invalid_records_response_raises_value_error(self):
|
||||
with pytest.raises(ValueError, match="Invalid hit testing records response"):
|
||||
DatasetsHitTestingBase._prepare_hit_testing_records({"records": []})
|
||||
|
||||
def test_invalid_record_response_raises_value_error(self):
|
||||
with pytest.raises(ValueError, match="Invalid hit testing record response"):
|
||||
DatasetsHitTestingBase._prepare_hit_testing_records(["record"])
|
||||
|
||||
def test_index_not_initialized(self, dataset):
|
||||
with patch.object(
|
||||
HitTestingService,
|
||||
|
||||
@@ -14,6 +14,7 @@ from controllers.console.tag.tags import (
|
||||
TagUpdateDeleteApi,
|
||||
)
|
||||
from models.enums import TagType
|
||||
from services.tag_service import UpdateTagPayload
|
||||
|
||||
|
||||
def unwrap(func):
|
||||
@@ -147,7 +148,7 @@ class TestTagUpdateDeleteApi:
|
||||
api = TagUpdateDeleteApi()
|
||||
method = unwrap(api.patch)
|
||||
|
||||
payload = {"name": "updated", "type": "knowledge"}
|
||||
payload = {"name": "updated"}
|
||||
|
||||
with app.test_request_context("/", json=payload):
|
||||
with (
|
||||
@@ -159,7 +160,7 @@ class TestTagUpdateDeleteApi:
|
||||
patch(
|
||||
"controllers.console.tag.tags.TagService.update_tags",
|
||||
return_value=tag,
|
||||
),
|
||||
) as update_tags_mock,
|
||||
patch(
|
||||
"controllers.console.tag.tags.TagService.get_tag_binding_count",
|
||||
return_value=3,
|
||||
@@ -168,6 +169,9 @@ class TestTagUpdateDeleteApi:
|
||||
result, status = method(api, "tag-1")
|
||||
|
||||
assert status == 200
|
||||
update_payload, tag_id = update_tags_mock.call_args.args
|
||||
assert update_payload == UpdateTagPayload(name="updated")
|
||||
assert tag_id == "tag-1"
|
||||
assert result["binding_count"] == "3"
|
||||
|
||||
def test_patch_forbidden(self, app: Flask, readonly_user, payload_patch):
|
||||
|
||||
@@ -13,6 +13,12 @@ from controllers.console.workspace.account import (
|
||||
)
|
||||
from models import Account, AccountStatus
|
||||
from services.account_service import AccountService
|
||||
from services.entities.auth_entities import (
|
||||
ChangeEmailNewEmailToken,
|
||||
ChangeEmailNewEmailVerifiedToken,
|
||||
ChangeEmailOldEmailToken,
|
||||
ChangeEmailOldEmailVerifiedToken,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -39,7 +45,66 @@ def _set_logged_in_user(account: Account):
|
||||
g._current_tenant = account.current_tenant
|
||||
|
||||
|
||||
def _build_change_email_token(
|
||||
phase: str,
|
||||
*,
|
||||
account_id: str = "acc",
|
||||
email: str,
|
||||
old_email: str,
|
||||
code: str = "1234",
|
||||
):
|
||||
token_kwargs = {
|
||||
"account_id": account_id,
|
||||
"email": email,
|
||||
"old_email": old_email,
|
||||
"code": code,
|
||||
}
|
||||
if phase == AccountService.CHANGE_EMAIL_PHASE_OLD:
|
||||
return ChangeEmailOldEmailToken(**token_kwargs)
|
||||
if phase == AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED:
|
||||
return ChangeEmailOldEmailVerifiedToken(**token_kwargs)
|
||||
if phase == AccountService.CHANGE_EMAIL_PHASE_NEW:
|
||||
return ChangeEmailNewEmailToken(**token_kwargs)
|
||||
if phase == AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED:
|
||||
return ChangeEmailNewEmailVerifiedToken(**token_kwargs)
|
||||
raise AssertionError(f"Unsupported phase for test helper: {phase}")
|
||||
|
||||
|
||||
class TestChangeEmailSend:
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.workspace.account.current_account_with_tenant")
|
||||
@patch("controllers.console.workspace.account.AccountService.send_change_email_email")
|
||||
@patch("controllers.console.workspace.account.AccountService.is_email_send_ip_limit", return_value=False)
|
||||
@patch("controllers.console.workspace.account.extract_remote_ip", return_value="127.0.0.1")
|
||||
@patch("libs.login.check_csrf_token", return_value=None)
|
||||
@patch("controllers.console.wraps.FeatureService.get_system_features")
|
||||
def test_should_reject_old_email_phase_when_request_email_does_not_match_current_user(
|
||||
self,
|
||||
mock_features,
|
||||
mock_csrf,
|
||||
mock_extract_ip,
|
||||
mock_is_ip_limit,
|
||||
mock_send_email,
|
||||
mock_current_account,
|
||||
mock_db,
|
||||
app: Flask,
|
||||
):
|
||||
from controllers.console.auth.error import InvalidEmailError
|
||||
|
||||
mock_features.return_value = SimpleNamespace(enable_change_email=True)
|
||||
mock_current_account.return_value = (_build_account("current@example.com", "acc1"), None)
|
||||
|
||||
with app.test_request_context(
|
||||
"/account/change-email",
|
||||
method="POST",
|
||||
json={"email": "other@example.com", "language": "en-US", "phase": "old_email"},
|
||||
):
|
||||
_set_logged_in_user(_build_account("tester@example.com", "tester"))
|
||||
with pytest.raises(InvalidEmailError):
|
||||
ChangeEmailSendEmailApi().post()
|
||||
|
||||
mock_send_email.assert_not_called()
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.workspace.account.current_account_with_tenant")
|
||||
@patch("controllers.console.workspace.account.AccountService.get_change_email_data")
|
||||
@@ -63,10 +128,12 @@ class TestChangeEmailSend:
|
||||
mock_features.return_value = SimpleNamespace(enable_change_email=True)
|
||||
mock_account = _build_account("current@example.com", "acc1")
|
||||
mock_current_account.return_value = (mock_account, None)
|
||||
mock_get_change_data.return_value = {
|
||||
"email": "current@example.com",
|
||||
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED,
|
||||
}
|
||||
mock_get_change_data.return_value = _build_change_email_token(
|
||||
AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED,
|
||||
account_id="acc1",
|
||||
email="current@example.com",
|
||||
old_email="current@example.com",
|
||||
)
|
||||
mock_send_email.return_value = "token-abc"
|
||||
|
||||
with app.test_request_context(
|
||||
@@ -79,7 +146,7 @@ class TestChangeEmailSend:
|
||||
|
||||
assert response == {"result": "success", "data": "token-abc"}
|
||||
mock_send_email.assert_called_once_with(
|
||||
account=None,
|
||||
account=mock_account,
|
||||
email="new@example.com",
|
||||
old_email="current@example.com",
|
||||
language="en-US",
|
||||
@@ -115,10 +182,12 @@ class TestChangeEmailSend:
|
||||
mock_features.return_value = SimpleNamespace(enable_change_email=True)
|
||||
mock_account = _build_account("current@example.com", "acc1")
|
||||
mock_current_account.return_value = (mock_account, None)
|
||||
mock_get_change_data.return_value = {
|
||||
"email": "current@example.com",
|
||||
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_OLD,
|
||||
}
|
||||
mock_get_change_data.return_value = _build_change_email_token(
|
||||
AccountService.CHANGE_EMAIL_PHASE_OLD,
|
||||
account_id="acc1",
|
||||
email="current@example.com",
|
||||
old_email="current@example.com",
|
||||
)
|
||||
|
||||
with app.test_request_context(
|
||||
"/account/change-email",
|
||||
@@ -131,6 +200,49 @@ class TestChangeEmailSend:
|
||||
|
||||
mock_send_email.assert_not_called()
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.workspace.account.current_account_with_tenant")
|
||||
@patch("controllers.console.workspace.account.AccountService.get_change_email_data")
|
||||
@patch("controllers.console.workspace.account.AccountService.send_change_email_email")
|
||||
@patch("controllers.console.workspace.account.AccountService.is_email_send_ip_limit", return_value=False)
|
||||
@patch("controllers.console.workspace.account.extract_remote_ip", return_value="127.0.0.1")
|
||||
@patch("libs.login.check_csrf_token", return_value=None)
|
||||
@patch("controllers.console.wraps.FeatureService.get_system_features")
|
||||
def test_should_reject_new_email_phase_when_token_account_id_does_not_match_current_user(
|
||||
self,
|
||||
mock_features,
|
||||
mock_csrf,
|
||||
mock_extract_ip,
|
||||
mock_is_ip_limit,
|
||||
mock_send_email,
|
||||
mock_get_change_data,
|
||||
mock_current_account,
|
||||
mock_db,
|
||||
app: Flask,
|
||||
):
|
||||
from controllers.console.auth.error import InvalidTokenError
|
||||
|
||||
mock_features.return_value = SimpleNamespace(enable_change_email=True)
|
||||
mock_account = _build_account("current@example.com", "acc1")
|
||||
mock_current_account.return_value = (mock_account, None)
|
||||
mock_get_change_data.return_value = _build_change_email_token(
|
||||
AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED,
|
||||
account_id="other-account",
|
||||
email="current@example.com",
|
||||
old_email="current@example.com",
|
||||
)
|
||||
|
||||
with app.test_request_context(
|
||||
"/account/change-email",
|
||||
method="POST",
|
||||
json={"email": "new@example.com", "language": "en-US", "phase": "new_email", "token": "token-123"},
|
||||
):
|
||||
_set_logged_in_user(_build_account("tester@example.com", "tester"))
|
||||
with pytest.raises(InvalidTokenError):
|
||||
ChangeEmailSendEmailApi().post()
|
||||
|
||||
mock_send_email.assert_not_called()
|
||||
|
||||
|
||||
class TestChangeEmailValidity:
|
||||
@patch("controllers.console.wraps.db")
|
||||
@@ -161,13 +273,13 @@ class TestChangeEmailValidity:
|
||||
mock_account = _build_account("user@example.com", "acc2")
|
||||
mock_current_account.return_value = (mock_account, None)
|
||||
mock_is_rate_limit.return_value = False
|
||||
mock_get_data.return_value = {
|
||||
"email": "user@example.com",
|
||||
"code": "1234",
|
||||
"old_email": "old@example.com",
|
||||
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_OLD,
|
||||
}
|
||||
mock_generate_token.return_value = (None, "new-token")
|
||||
mock_get_data.return_value = _build_change_email_token(
|
||||
AccountService.CHANGE_EMAIL_PHASE_OLD,
|
||||
account_id="acc2",
|
||||
email="user@example.com",
|
||||
old_email="user@example.com",
|
||||
)
|
||||
mock_generate_token.return_value = "new-token"
|
||||
|
||||
with app.test_request_context(
|
||||
"/account/change-email/validity",
|
||||
@@ -182,12 +294,13 @@ class TestChangeEmailValidity:
|
||||
mock_add_rate.assert_not_called()
|
||||
mock_revoke_token.assert_called_once_with("token-123")
|
||||
mock_generate_token.assert_called_once_with(
|
||||
"user@example.com",
|
||||
code="1234",
|
||||
old_email="old@example.com",
|
||||
additional_data={
|
||||
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED,
|
||||
},
|
||||
_build_change_email_token(
|
||||
AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED,
|
||||
account_id="acc2",
|
||||
email="user@example.com",
|
||||
old_email="user@example.com",
|
||||
),
|
||||
mock_account,
|
||||
)
|
||||
mock_reset_rate.assert_called_once_with("user@example.com")
|
||||
mock_csrf.assert_called_once()
|
||||
@@ -219,13 +332,13 @@ class TestChangeEmailValidity:
|
||||
mock_features.return_value = SimpleNamespace(enable_change_email=True)
|
||||
mock_current_account.return_value = (_build_account("old@example.com", "acc"), None)
|
||||
mock_is_rate_limit.return_value = False
|
||||
mock_get_data.return_value = {
|
||||
"email": "new@example.com",
|
||||
"code": "1234",
|
||||
"old_email": "old@example.com",
|
||||
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_NEW,
|
||||
}
|
||||
mock_generate_token.return_value = (None, "new-verified-token")
|
||||
mock_get_data.return_value = _build_change_email_token(
|
||||
AccountService.CHANGE_EMAIL_PHASE_NEW,
|
||||
account_id="acc",
|
||||
email="new@example.com",
|
||||
old_email="old@example.com",
|
||||
)
|
||||
mock_generate_token.return_value = "new-verified-token"
|
||||
|
||||
with app.test_request_context(
|
||||
"/account/change-email/validity",
|
||||
@@ -237,12 +350,13 @@ class TestChangeEmailValidity:
|
||||
|
||||
assert response == {"is_valid": True, "email": "new@example.com", "token": "new-verified-token"}
|
||||
mock_generate_token.assert_called_once_with(
|
||||
"new@example.com",
|
||||
code="1234",
|
||||
old_email="old@example.com",
|
||||
additional_data={
|
||||
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED,
|
||||
},
|
||||
_build_change_email_token(
|
||||
AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED,
|
||||
account_id="acc",
|
||||
email="new@example.com",
|
||||
old_email="old@example.com",
|
||||
),
|
||||
mock_current_account.return_value[0],
|
||||
)
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@@ -255,7 +369,7 @@ class TestChangeEmailValidity:
|
||||
@patch("controllers.console.workspace.account.AccountService.is_change_email_error_rate_limit")
|
||||
@patch("libs.login.check_csrf_token", return_value=None)
|
||||
@patch("controllers.console.wraps.FeatureService.get_system_features")
|
||||
def test_should_reject_validity_when_token_phase_is_unknown(
|
||||
def test_should_reject_validity_when_token_is_already_verified(
|
||||
self,
|
||||
mock_features,
|
||||
mock_csrf,
|
||||
@@ -269,23 +383,22 @@ class TestChangeEmailValidity:
|
||||
mock_db,
|
||||
app: Flask,
|
||||
):
|
||||
"""A token whose phase marker is a string but not a known transition must be rejected."""
|
||||
from controllers.console.auth.error import InvalidTokenError
|
||||
|
||||
mock_features.return_value = SimpleNamespace(enable_change_email=True)
|
||||
mock_current_account.return_value = (_build_account("old@example.com", "acc"), None)
|
||||
mock_is_rate_limit.return_value = False
|
||||
mock_get_data.return_value = {
|
||||
"email": "user@example.com",
|
||||
"code": "1234",
|
||||
"old_email": "old@example.com",
|
||||
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: "something_else",
|
||||
}
|
||||
mock_get_data.return_value = _build_change_email_token(
|
||||
AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED,
|
||||
account_id="acc",
|
||||
email="old@example.com",
|
||||
old_email="old@example.com",
|
||||
)
|
||||
|
||||
with app.test_request_context(
|
||||
"/account/change-email/validity",
|
||||
method="POST",
|
||||
json={"email": "user@example.com", "code": "1234", "token": "token-123"},
|
||||
json={"email": "old@example.com", "code": "1234", "token": "token-123"},
|
||||
):
|
||||
_set_logged_in_user(_build_account("tester@example.com", "tester"))
|
||||
with pytest.raises(InvalidTokenError):
|
||||
@@ -304,7 +417,7 @@ class TestChangeEmailValidity:
|
||||
@patch("controllers.console.workspace.account.AccountService.is_change_email_error_rate_limit")
|
||||
@patch("libs.login.check_csrf_token", return_value=None)
|
||||
@patch("controllers.console.wraps.FeatureService.get_system_features")
|
||||
def test_should_reject_validity_when_token_has_no_phase(
|
||||
def test_should_reject_validity_when_token_account_id_does_not_match_current_user(
|
||||
self,
|
||||
mock_features,
|
||||
mock_csrf,
|
||||
@@ -318,22 +431,22 @@ class TestChangeEmailValidity:
|
||||
mock_db,
|
||||
app: Flask,
|
||||
):
|
||||
"""A token minted without a phase marker (e.g. a hand-crafted token) must not validate."""
|
||||
from controllers.console.auth.error import InvalidTokenError
|
||||
|
||||
mock_features.return_value = SimpleNamespace(enable_change_email=True)
|
||||
mock_current_account.return_value = (_build_account("old@example.com", "acc"), None)
|
||||
mock_is_rate_limit.return_value = False
|
||||
mock_get_data.return_value = {
|
||||
"email": "user@example.com",
|
||||
"code": "1234",
|
||||
"old_email": "old@example.com",
|
||||
}
|
||||
mock_get_data.return_value = _build_change_email_token(
|
||||
AccountService.CHANGE_EMAIL_PHASE_NEW,
|
||||
account_id="other-account",
|
||||
email="new@example.com",
|
||||
old_email="old@example.com",
|
||||
)
|
||||
|
||||
with app.test_request_context(
|
||||
"/account/change-email/validity",
|
||||
method="POST",
|
||||
json={"email": "user@example.com", "code": "1234", "token": "token-123"},
|
||||
json={"email": "new@example.com", "code": "1234", "token": "token-123"},
|
||||
):
|
||||
_set_logged_in_user(_build_account("tester@example.com", "tester"))
|
||||
with pytest.raises(InvalidTokenError):
|
||||
@@ -373,11 +486,12 @@ class TestChangeEmailReset:
|
||||
mock_current_account.return_value = (current_user, None)
|
||||
mock_is_freeze.return_value = False
|
||||
mock_check_unique.return_value = True
|
||||
mock_get_data.return_value = {
|
||||
"email": "new@example.com",
|
||||
"old_email": "OLD@example.com",
|
||||
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED,
|
||||
}
|
||||
mock_get_data.return_value = _build_change_email_token(
|
||||
AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED,
|
||||
account_id="acc3",
|
||||
email="new@example.com",
|
||||
old_email="OLD@example.com",
|
||||
)
|
||||
mock_account_after_update = _build_account("new@example.com", "acc3-updated")
|
||||
mock_update_account.return_value = mock_account_after_update
|
||||
|
||||
@@ -428,13 +542,12 @@ class TestChangeEmailReset:
|
||||
mock_current_account.return_value = (current_user, None)
|
||||
mock_is_freeze.return_value = False
|
||||
mock_check_unique.return_value = True
|
||||
# Simulate a token straight out of step #1 (phase=old_email) — exactly
|
||||
# the replay used in the advisory PoC.
|
||||
mock_get_data.return_value = {
|
||||
"email": "old@example.com",
|
||||
"old_email": "old@example.com",
|
||||
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_OLD,
|
||||
}
|
||||
mock_get_data.return_value = _build_change_email_token(
|
||||
AccountService.CHANGE_EMAIL_PHASE_OLD,
|
||||
account_id="acc3",
|
||||
email="old@example.com",
|
||||
old_email="old@example.com",
|
||||
)
|
||||
|
||||
with app.test_request_context(
|
||||
"/account/change-email/reset",
|
||||
@@ -481,11 +594,12 @@ class TestChangeEmailReset:
|
||||
mock_current_account.return_value = (current_user, None)
|
||||
mock_is_freeze.return_value = False
|
||||
mock_check_unique.return_value = True
|
||||
mock_get_data.return_value = {
|
||||
"email": "verified@example.com",
|
||||
"old_email": "old@example.com",
|
||||
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED,
|
||||
}
|
||||
mock_get_data.return_value = _build_change_email_token(
|
||||
AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED,
|
||||
account_id="acc3",
|
||||
email="verified@example.com",
|
||||
old_email="old@example.com",
|
||||
)
|
||||
|
||||
with app.test_request_context(
|
||||
"/account/change-email/reset",
|
||||
@@ -500,6 +614,57 @@ class TestChangeEmailReset:
|
||||
mock_update_account.assert_not_called()
|
||||
mock_send_notify.assert_not_called()
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.workspace.account.current_account_with_tenant")
|
||||
@patch("controllers.console.workspace.account.AccountService.send_change_email_completed_notify_email")
|
||||
@patch("controllers.console.workspace.account.AccountService.update_account_email")
|
||||
@patch("controllers.console.workspace.account.AccountService.revoke_change_email_token")
|
||||
@patch("controllers.console.workspace.account.AccountService.get_change_email_data")
|
||||
@patch("controllers.console.workspace.account.AccountService.check_email_unique")
|
||||
@patch("controllers.console.workspace.account.AccountService.is_account_in_freeze")
|
||||
@patch("libs.login.check_csrf_token", return_value=None)
|
||||
@patch("controllers.console.wraps.FeatureService.get_system_features")
|
||||
def test_should_reject_reset_when_token_account_id_does_not_match_current_user(
|
||||
self,
|
||||
mock_features,
|
||||
mock_csrf,
|
||||
mock_is_freeze,
|
||||
mock_check_unique,
|
||||
mock_get_data,
|
||||
mock_revoke_token,
|
||||
mock_update_account,
|
||||
mock_send_notify,
|
||||
mock_current_account,
|
||||
mock_db,
|
||||
app: Flask,
|
||||
):
|
||||
from controllers.console.auth.error import InvalidTokenError
|
||||
|
||||
mock_features.return_value = SimpleNamespace(enable_change_email=True)
|
||||
current_user = _build_account("old@example.com", "acc3")
|
||||
mock_current_account.return_value = (current_user, None)
|
||||
mock_is_freeze.return_value = False
|
||||
mock_check_unique.return_value = True
|
||||
mock_get_data.return_value = _build_change_email_token(
|
||||
AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED,
|
||||
account_id="other-account",
|
||||
email="new@example.com",
|
||||
old_email="old@example.com",
|
||||
)
|
||||
|
||||
with app.test_request_context(
|
||||
"/account/change-email/reset",
|
||||
method="POST",
|
||||
json={"new_email": "new@example.com", "token": "token-verified"},
|
||||
):
|
||||
_set_logged_in_user(_build_account("tester@example.com", "tester"))
|
||||
with pytest.raises(InvalidTokenError):
|
||||
ChangeEmailResetApi().post()
|
||||
|
||||
mock_revoke_token.assert_not_called()
|
||||
mock_update_account.assert_not_called()
|
||||
mock_send_notify.assert_not_called()
|
||||
|
||||
|
||||
class TestAccountServiceSendChangeEmailEmail:
|
||||
"""Service-level coverage for the phase-bound changes in `send_change_email_email`."""
|
||||
@@ -507,7 +672,8 @@ class TestAccountServiceSendChangeEmailEmail:
|
||||
def test_should_raise_value_error_for_invalid_phase(self):
|
||||
with pytest.raises(ValueError, match="phase must be one of"):
|
||||
AccountService.send_change_email_email(
|
||||
email="user@example.com",
|
||||
account=_build_account("old@example.com", "acc"),
|
||||
email="new@example.com",
|
||||
old_email="user@example.com",
|
||||
phase="old_email_verified",
|
||||
)
|
||||
@@ -515,33 +681,77 @@ class TestAccountServiceSendChangeEmailEmail:
|
||||
@patch("services.account_service.send_change_mail_task")
|
||||
@patch("services.account_service.AccountService.change_email_rate_limiter")
|
||||
@patch("services.account_service.AccountService.generate_change_email_token")
|
||||
def test_should_stamp_phase_into_generated_token(
|
||||
def test_should_bind_account_id_and_target_email_into_generated_token(
|
||||
self,
|
||||
mock_generate_token,
|
||||
mock_rate_limiter,
|
||||
mock_mail_task,
|
||||
):
|
||||
mock_rate_limiter.is_rate_limited.return_value = False
|
||||
mock_generate_token.return_value = ("123456", "the-token")
|
||||
mock_generate_token.return_value = "the-token"
|
||||
account = _build_account("old@example.com", "acc-123")
|
||||
|
||||
returned = AccountService.send_change_email_email(
|
||||
email="user@example.com",
|
||||
old_email="user@example.com",
|
||||
account=account,
|
||||
email="new@example.com",
|
||||
old_email="old@example.com",
|
||||
language="en-US",
|
||||
phase=AccountService.CHANGE_EMAIL_PHASE_NEW,
|
||||
)
|
||||
|
||||
assert returned == "the-token"
|
||||
mock_generate_token.assert_called_once_with(
|
||||
"user@example.com",
|
||||
None,
|
||||
old_email="user@example.com",
|
||||
additional_data={
|
||||
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_NEW,
|
||||
},
|
||||
_build_change_email_token(
|
||||
AccountService.CHANGE_EMAIL_PHASE_NEW,
|
||||
account_id="acc-123",
|
||||
email="new@example.com",
|
||||
old_email="old@example.com",
|
||||
code=mock_mail_task.delay.call_args.kwargs["code"],
|
||||
),
|
||||
account,
|
||||
)
|
||||
mock_mail_task.delay.assert_called_once()
|
||||
mock_rate_limiter.increment_rate_limit.assert_called_once_with("user@example.com")
|
||||
mock_mail_task.delay.assert_called_once_with(
|
||||
language="en-US",
|
||||
to="new@example.com",
|
||||
code=mock_mail_task.delay.call_args.kwargs["code"],
|
||||
phase=AccountService.CHANGE_EMAIL_PHASE_NEW,
|
||||
)
|
||||
mock_rate_limiter.increment_rate_limit.assert_called_once_with("new@example.com")
|
||||
|
||||
|
||||
class TestAccountServiceGetChangeEmailData:
|
||||
@patch("services.account_service.TokenManager.get_token_data")
|
||||
def test_should_parse_change_email_token_into_discriminated_union_model(self, mock_get_token_data):
|
||||
mock_get_token_data.return_value = {
|
||||
"token_type": "change_email",
|
||||
"account_id": "acc-1",
|
||||
"email": "new@example.com",
|
||||
"old_email": "old@example.com",
|
||||
"code": "654321",
|
||||
"email_change_phase": AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED,
|
||||
}
|
||||
|
||||
token_data = AccountService.get_change_email_data("token-123")
|
||||
|
||||
assert token_data == _build_change_email_token(
|
||||
AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED,
|
||||
account_id="acc-1",
|
||||
email="new@example.com",
|
||||
old_email="old@example.com",
|
||||
code="654321",
|
||||
)
|
||||
|
||||
@patch("services.account_service.TokenManager.get_token_data")
|
||||
def test_should_reject_change_email_token_without_account_id(self, mock_get_token_data):
|
||||
mock_get_token_data.return_value = {
|
||||
"token_type": "change_email",
|
||||
"email": "new@example.com",
|
||||
"old_email": "old@example.com",
|
||||
"code": "654321",
|
||||
"email_change_phase": AccountService.CHANGE_EMAIL_PHASE_NEW,
|
||||
}
|
||||
|
||||
assert AccountService.get_change_email_data("token-123") is None
|
||||
|
||||
|
||||
class TestAccountDeletionFeedback:
|
||||
|
||||
@@ -388,6 +388,10 @@ class TestChangeEmailApis:
|
||||
|
||||
with (
|
||||
app.test_request_context("/", json=payload),
|
||||
patch(
|
||||
"controllers.console.workspace.account.current_account_with_tenant",
|
||||
return_value=(MagicMock(id="acc-1"), "t1"),
|
||||
),
|
||||
patch.object(
|
||||
type(console_ns),
|
||||
"payload",
|
||||
@@ -400,7 +404,11 @@ class TestChangeEmailApis:
|
||||
),
|
||||
patch(
|
||||
"controllers.console.workspace.account.AccountService.get_change_email_data",
|
||||
return_value={"email": "a@test.com", "code": "y"},
|
||||
return_value=MagicMock(
|
||||
email="a@test.com",
|
||||
code="y",
|
||||
is_bound_to_account=MagicMock(return_value=True),
|
||||
),
|
||||
),
|
||||
):
|
||||
with pytest.raises(EmailCodeError):
|
||||
|
||||
@@ -103,7 +103,7 @@ class TestHitTestingApiPost:
|
||||
mock_dataset_svc.get_dataset.return_value = mock_dataset
|
||||
mock_dataset_svc.check_dataset_permission.return_value = None
|
||||
|
||||
mock_hit_svc.retrieve.return_value = {"query": "test query", "records": []}
|
||||
mock_hit_svc.retrieve.return_value = {"query": {"content": "test query"}, "records": []}
|
||||
mock_hit_svc.hit_testing_args_check.return_value = None
|
||||
mock_marshal.return_value = []
|
||||
|
||||
@@ -149,7 +149,7 @@ class TestHitTestingApiPost:
|
||||
"score_threshold": 0.8,
|
||||
}
|
||||
|
||||
mock_hit_svc.retrieve.return_value = {"query": "complex query", "records": []}
|
||||
mock_hit_svc.retrieve.return_value = {"query": {"content": "complex query"}, "records": []}
|
||||
mock_hit_svc.hit_testing_args_check.return_value = None
|
||||
mock_marshal.return_value = []
|
||||
|
||||
@@ -194,7 +194,7 @@ class TestHitTestingApiPost:
|
||||
|
||||
mock_dataset_svc.get_dataset.return_value = mock_dataset
|
||||
mock_dataset_svc.check_dataset_permission.return_value = None
|
||||
mock_hit_svc.retrieve.return_value = {"query": "filtered query", "records": []}
|
||||
mock_hit_svc.retrieve.return_value = {"query": {"content": "filtered query"}, "records": []}
|
||||
mock_hit_svc.hit_testing_args_check.return_value = None
|
||||
mock_marshal.return_value = []
|
||||
|
||||
@@ -232,7 +232,7 @@ class TestHitTestingApiPost:
|
||||
@patch("controllers.console.datasets.hit_testing_base.HitTestingService")
|
||||
@patch("controllers.console.datasets.hit_testing_base.DatasetService")
|
||||
@patch("controllers.console.datasets.hit_testing_base.current_user", new_callable=lambda: Mock(spec=Account))
|
||||
def test_post_normalizes_legacy_query_and_nullable_list_fields(
|
||||
def test_post_prepares_nullable_list_fields(
|
||||
self,
|
||||
mock_current_user,
|
||||
mock_dataset_svc,
|
||||
@@ -241,7 +241,7 @@ class TestHitTestingApiPost:
|
||||
mock_ns,
|
||||
app,
|
||||
):
|
||||
"""Test service API normalizes legacy query shape and nullable list fields."""
|
||||
"""Test service API prepares nullable list fields from marshalled records."""
|
||||
dataset_id = str(uuid.uuid4())
|
||||
tenant_id = str(uuid.uuid4())
|
||||
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from configs import dify_config
|
||||
from core.app.llm.quota import (
|
||||
@@ -21,6 +25,13 @@ from models.enums import ProviderQuotaType as ModelProviderQuotaType
|
||||
from models.provider import Provider, ProviderType
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _patched_credit_pool_session_factory(engine: Engine) -> Iterator[None]:
|
||||
session_maker = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
with patch("services.credit_pool_service.session_factory.get_session_maker", return_value=session_maker):
|
||||
yield
|
||||
|
||||
|
||||
def test_ensure_llm_quota_available_for_model_raises_when_system_model_is_exhausted() -> None:
|
||||
provider_configuration = SimpleNamespace(
|
||||
using_provider_type=ProviderType.SYSTEM,
|
||||
@@ -148,7 +159,7 @@ def test_deduct_llm_quota_for_model_caps_trial_pool_when_usage_exceeds_remaining
|
||||
|
||||
with (
|
||||
patch("core.app.llm.quota.create_plugin_provider_manager", return_value=provider_manager),
|
||||
patch("services.credit_pool_service.db", SimpleNamespace(engine=engine)),
|
||||
_patched_credit_pool_session_factory(engine),
|
||||
):
|
||||
deduct_llm_quota_for_model(
|
||||
tenant_id="tenant-id",
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
|
||||
from core.app.file_access import (
|
||||
DatabaseFileAccessController,
|
||||
FileAccessScope,
|
||||
bind_file_access_scope,
|
||||
get_current_file_access_scope,
|
||||
grant_retriever_segment_access,
|
||||
grant_upload_file_access,
|
||||
is_retriever_segment_access_granted,
|
||||
)
|
||||
from core.rag.datasource.retrieval_service import RetrievalService
|
||||
from core.rag.retrieval import dataset_retrieval as dataset_retrieval_module
|
||||
from core.rag.retrieval.dataset_retrieval import DatasetRetrieval
|
||||
from core.workflow.nodes.knowledge_retrieval.retrieval import KnowledgeRetrievalRequest
|
||||
from models import UploadFile
|
||||
|
||||
|
||||
class _ScalarResult:
|
||||
def __init__(self, values):
|
||||
self._values = values
|
||||
|
||||
def all(self):
|
||||
return self._values
|
||||
|
||||
|
||||
class _AttachmentSession:
|
||||
def __init__(self, upload_file, binding):
|
||||
self._results = [
|
||||
_ScalarResult([upload_file]),
|
||||
_ScalarResult([binding]),
|
||||
]
|
||||
|
||||
def scalars(self, _stmt):
|
||||
return self._results.pop(0)
|
||||
|
||||
|
||||
class _DatasetRetrievalSession:
|
||||
def __init__(self, datasets, documents):
|
||||
self._results = [
|
||||
_ScalarResult(datasets),
|
||||
_ScalarResult(documents),
|
||||
]
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def scalars(self, _stmt):
|
||||
return self._results.pop(0)
|
||||
|
||||
|
||||
def test_file_access_grants_ignore_empty_inputs_and_missing_scope() -> None:
|
||||
grant_upload_file_access(["upload-file-id"])
|
||||
grant_retriever_segment_access(["segment-id"])
|
||||
assert is_retriever_segment_access_granted("segment-id") is True
|
||||
|
||||
scope = FileAccessScope(
|
||||
tenant_id=str(uuid4()),
|
||||
user_id=str(uuid4()),
|
||||
user_from=UserFrom.END_USER,
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
)
|
||||
|
||||
with bind_file_access_scope(scope):
|
||||
grant_upload_file_access([""])
|
||||
grant_retriever_segment_access([""])
|
||||
current_scope = get_current_file_access_scope()
|
||||
|
||||
assert current_scope is not None
|
||||
assert current_scope.granted_upload_file_ids == frozenset()
|
||||
assert current_scope.granted_retriever_segment_ids == frozenset()
|
||||
|
||||
|
||||
def test_segment_attachment_lookup_grants_returned_upload_files_to_current_scope() -> None:
|
||||
tenant_id = str(uuid4())
|
||||
upload_file_id = str(uuid4())
|
||||
segment_id = str(uuid4())
|
||||
upload_file = SimpleNamespace(
|
||||
id=upload_file_id,
|
||||
name="chart.png",
|
||||
extension="png",
|
||||
mime_type="image/png",
|
||||
size=1024,
|
||||
)
|
||||
binding = SimpleNamespace(attachment_id=upload_file_id, segment_id=segment_id)
|
||||
session = _AttachmentSession(upload_file, binding)
|
||||
scope = FileAccessScope(
|
||||
tenant_id=tenant_id,
|
||||
user_id=str(uuid4()),
|
||||
user_from=UserFrom.END_USER,
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
)
|
||||
|
||||
with bind_file_access_scope(scope):
|
||||
result = RetrievalService.get_segment_attachment_infos([upload_file_id], session) # type: ignore[arg-type]
|
||||
scoped_stmt = DatabaseFileAccessController().apply_upload_file_filters(
|
||||
select(UploadFile).where(UploadFile.id == upload_file_id)
|
||||
)
|
||||
|
||||
assert result[0]["attachment_id"] == upload_file_id
|
||||
whereclause = str(scoped_stmt.whereclause)
|
||||
assert "upload_files.created_by_role" in whereclause
|
||||
assert "upload_files.id IN" in whereclause
|
||||
|
||||
|
||||
def test_knowledge_retrieval_grants_returned_segments_to_current_scope(monkeypatch) -> None:
|
||||
tenant_id = str(uuid4())
|
||||
dataset_id = str(uuid4())
|
||||
document_id = str(uuid4())
|
||||
segment_id = str(uuid4())
|
||||
segment = SimpleNamespace(
|
||||
id=segment_id,
|
||||
dataset_id=dataset_id,
|
||||
document_id=document_id,
|
||||
hit_count=1,
|
||||
word_count=10,
|
||||
position=1,
|
||||
index_node_hash="hash",
|
||||
answer=None,
|
||||
get_sign_content=lambda: "segment content",
|
||||
)
|
||||
record = SimpleNamespace(segment=segment, score=0.8, child_chunks=None, files=None, summary=None)
|
||||
dataset = SimpleNamespace(id=dataset_id, name="Dataset")
|
||||
document = SimpleNamespace(id=document_id, name="Document", data_source_type="upload_file", doc_metadata={})
|
||||
retrieval = DatasetRetrieval()
|
||||
monkeypatch.setattr(retrieval, "_check_knowledge_rate_limit", lambda tenant_id: None)
|
||||
monkeypatch.setattr(retrieval, "_get_available_datasets", lambda tenant_id, dataset_ids: [dataset])
|
||||
monkeypatch.setattr(retrieval, "multiple_retrieve", lambda **kwargs: [SimpleNamespace(provider="dify")])
|
||||
monkeypatch.setattr(RetrievalService, "format_retrieval_documents", lambda documents: [record])
|
||||
session = _DatasetRetrievalSession([dataset], [document])
|
||||
monkeypatch.setattr(dataset_retrieval_module.session_factory, "create_session", lambda: session)
|
||||
scope = FileAccessScope(
|
||||
tenant_id=tenant_id,
|
||||
user_id=str(uuid4()),
|
||||
user_from=UserFrom.END_USER,
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
)
|
||||
|
||||
with bind_file_access_scope(scope):
|
||||
results = retrieval.knowledge_retrieval(
|
||||
KnowledgeRetrievalRequest(
|
||||
tenant_id=tenant_id,
|
||||
user_id=str(uuid4()),
|
||||
app_id=str(uuid4()),
|
||||
user_from=UserFrom.END_USER.value,
|
||||
dataset_ids=[dataset_id],
|
||||
query="desktop picture",
|
||||
retrieval_mode="multiple",
|
||||
)
|
||||
)
|
||||
current_scope = get_current_file_access_scope()
|
||||
|
||||
assert results[0].metadata.segment_id == segment_id
|
||||
assert current_scope is not None
|
||||
assert segment_id in current_scope.granted_retriever_segment_ids
|
||||
@@ -13,7 +13,8 @@ from graphon.enums import BuiltinNodeTypes, NodeType
|
||||
from graphon.nodes.code.entities import CodeLanguage
|
||||
from graphon.nodes.llm.entities import LLMNodeData
|
||||
from graphon.nodes.llm.node import LLMNode
|
||||
from graphon.variables.segments import StringSegment
|
||||
from graphon.nodes.parameter_extractor.entities import ParameterExtractorNodeData
|
||||
from graphon.variables.segments import ArrayObjectSegment, StringSegment
|
||||
|
||||
|
||||
def _assert_constructor_node_data(data, *, node_id: str, node_type: NodeType, version: str = "1") -> None:
|
||||
@@ -430,6 +431,7 @@ class TestDifyNodeFactoryCreateNode:
|
||||
factory._http_request_config = sentinel.http_request_config
|
||||
factory._llm_credentials_provider = sentinel.credentials_provider
|
||||
factory._llm_model_factory = sentinel.model_factory
|
||||
factory._build_retriever_attachment_loader = MagicMock(return_value=sentinel.retriever_attachment_loader)
|
||||
return factory
|
||||
|
||||
def test_rejects_unknown_node_type(self, factory):
|
||||
@@ -770,6 +772,128 @@ class TestDifyNodeFactoryCreateNode:
|
||||
for key, value in expected_extra_kwargs.items():
|
||||
assert constructor_kwargs[key] is value
|
||||
|
||||
def test_parameter_extractor_init_does_not_require_retriever_context(self, factory):
|
||||
node_data = ParameterExtractorNodeData.model_validate(
|
||||
{
|
||||
"type": BuiltinNodeTypes.PARAMETER_EXTRACTOR,
|
||||
"title": "Parameter Extractor",
|
||||
"model": {"provider": "provider", "name": "model", "mode": "chat", "completion_params": {}},
|
||||
"query": ["sys", "query"],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "topic",
|
||||
"type": "string",
|
||||
"description": "Topic",
|
||||
"required": True,
|
||||
}
|
||||
],
|
||||
"reasoning_mode": "prompt",
|
||||
}
|
||||
)
|
||||
factory._build_model_instance_for_llm_node = MagicMock(return_value=sentinel.model_instance)
|
||||
factory._build_memory_for_llm_node = MagicMock(return_value=sentinel.memory)
|
||||
factory._build_retriever_attachment_loader = MagicMock(side_effect=AssertionError("unexpected loader build"))
|
||||
|
||||
kwargs = factory._build_llm_compatible_node_init_kwargs(
|
||||
node_class=sentinel.node_class,
|
||||
node_data=node_data,
|
||||
wrap_model_instance=True,
|
||||
include_http_client=False,
|
||||
include_llm_file_saver=False,
|
||||
include_prompt_message_serializer=True,
|
||||
include_retriever_attachment_loader=False,
|
||||
include_jinja2_template_renderer=False,
|
||||
)
|
||||
|
||||
assert "retriever_attachment_loader" not in kwargs
|
||||
assert kwargs["prompt_message_serializer"] is sentinel.prompt_message_serializer
|
||||
factory._build_retriever_attachment_loader.assert_not_called()
|
||||
|
||||
|
||||
class TestDifyNodeFactoryRetrieverAttachmentAccess:
|
||||
@pytest.fixture
|
||||
def factory(self):
|
||||
factory = object.__new__(node_factory.DifyNodeFactory)
|
||||
factory.graph_runtime_state = SimpleNamespace(variable_pool=MagicMock())
|
||||
return factory
|
||||
|
||||
def test_retriever_attachment_loader_is_typed_for_llm_node_data_only(self):
|
||||
annotations = node_factory.DifyNodeFactory._build_retriever_attachment_loader.__annotations__
|
||||
|
||||
assert annotations["node_data"] is LLMNodeData
|
||||
|
||||
def test_build_retriever_attachment_loader_uses_llm_context_selector(self, factory):
|
||||
factory._file_reference_factory = sentinel.file_reference_factory
|
||||
factory.graph_runtime_state.variable_pool.get.return_value = ArrayObjectSegment(
|
||||
value=[
|
||||
{
|
||||
"metadata": {
|
||||
"_source": "knowledge",
|
||||
"segment_id": "allowed-segment",
|
||||
}
|
||||
}
|
||||
]
|
||||
)
|
||||
node_data = LLMNodeData.model_validate(
|
||||
{
|
||||
"type": BuiltinNodeTypes.LLM,
|
||||
"title": "LLM",
|
||||
"model": {"provider": "provider", "name": "model", "mode": "chat", "completion_params": {}},
|
||||
"prompt_template": [{"role": "system", "text": "x"}],
|
||||
"context": {"enabled": True, "variable_selector": ["knowledge-node", "result"]},
|
||||
"vision": {"enabled": False},
|
||||
}
|
||||
)
|
||||
|
||||
loader = factory._build_retriever_attachment_loader(node_data)
|
||||
|
||||
assert loader._segment_access_checker is not None
|
||||
assert loader._segment_access_checker("allowed-segment") is True
|
||||
factory.graph_runtime_state.variable_pool.get.assert_called_once_with(["knowledge-node", "result"])
|
||||
|
||||
def test_checker_rejects_missing_context_selector_without_reading_variable_pool(self, factory):
|
||||
checker = factory._build_retriever_segment_access_checker(None)
|
||||
|
||||
assert checker("segment-id") is False
|
||||
factory.graph_runtime_state.variable_pool.get.assert_not_called()
|
||||
|
||||
def test_checker_rejects_non_knowledge_context_items(self, factory):
|
||||
factory.graph_runtime_state.variable_pool.get.return_value = ArrayObjectSegment.model_construct(
|
||||
value=[
|
||||
"plain-text",
|
||||
{"metadata": "not-a-mapping"},
|
||||
]
|
||||
)
|
||||
|
||||
checker = factory._build_retriever_segment_access_checker(["knowledge-node", "result"])
|
||||
|
||||
assert checker("segment-id") is False
|
||||
|
||||
def test_checker_rejects_non_array_context_value(self, factory):
|
||||
factory.graph_runtime_state.variable_pool.get.return_value = StringSegment(value="not knowledge context")
|
||||
|
||||
checker = factory._build_retriever_segment_access_checker(["knowledge-node", "result"])
|
||||
|
||||
assert checker("segment-id") is False
|
||||
|
||||
def test_checker_allows_only_segments_from_selected_knowledge_context(self, factory):
|
||||
factory.graph_runtime_state.variable_pool.get.return_value = ArrayObjectSegment(
|
||||
value=[
|
||||
{
|
||||
"metadata": {
|
||||
"_source": "knowledge",
|
||||
"segment_id": "allowed-segment",
|
||||
}
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
checker = factory._build_retriever_segment_access_checker(["knowledge-node", "result"])
|
||||
|
||||
assert checker("allowed-segment") is True
|
||||
assert checker("other-segment") is False
|
||||
factory.graph_runtime_state.variable_pool.get.assert_any_call(["knowledge-node", "result"])
|
||||
|
||||
|
||||
class TestDifyNodeFactoryModelInstance:
|
||||
@pytest.fixture
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, Mock, sentinel
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext, InvokeFrom, UserFrom
|
||||
from core.app.file_access import FileAccessScope, bind_file_access_scope, grant_retriever_segment_access
|
||||
from core.llm_generator.output_parser.errors import OutputParserError
|
||||
from core.workflow import node_runtime
|
||||
from core.workflow.file_reference import parse_file_reference
|
||||
@@ -268,6 +270,114 @@ def test_dify_retriever_attachment_loader_builds_graph_files(monkeypatch: pytest
|
||||
assert parse_file_reference(mapping["reference"]).storage_key is None
|
||||
|
||||
|
||||
def test_dify_retriever_attachment_loader_grants_upload_files_for_allowed_segment(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from factories.file_factory import builders as file_builders
|
||||
|
||||
upload_file_id = str(uuid4())
|
||||
segment_id = str(uuid4())
|
||||
upload_file = SimpleNamespace(
|
||||
id=upload_file_id,
|
||||
tenant_id="tenant-id",
|
||||
name="diagram.png",
|
||||
extension="png",
|
||||
mime_type="image/png",
|
||||
source_url="https://example.com/diagram.png",
|
||||
key="storage-key",
|
||||
size=128,
|
||||
)
|
||||
attachment_session = MagicMock()
|
||||
attachment_session.execute.return_value.all.return_value = [(None, upload_file)]
|
||||
|
||||
class _AttachmentSessionContext:
|
||||
def __enter__(self):
|
||||
return attachment_session
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
upload_session = MagicMock()
|
||||
upload_session.__enter__.return_value = upload_session
|
||||
upload_session.__exit__.return_value = False
|
||||
upload_session.scalar.return_value = upload_file
|
||||
|
||||
monkeypatch.setattr(node_runtime, "db", SimpleNamespace(engine=object()))
|
||||
monkeypatch.setattr(node_runtime, "Session", MagicMock(return_value=_AttachmentSessionContext()))
|
||||
monkeypatch.setattr(file_builders, "session_factory", SimpleNamespace(create_session=lambda: upload_session))
|
||||
|
||||
loader = DifyRetrieverAttachmentLoader(file_reference_factory=DifyFileReferenceFactory(_build_run_context()))
|
||||
scope = FileAccessScope(
|
||||
tenant_id="tenant-id",
|
||||
user_id="end-user-id",
|
||||
user_from=UserFrom.END_USER,
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
)
|
||||
|
||||
with bind_file_access_scope(scope):
|
||||
grant_retriever_segment_access([segment_id])
|
||||
files = loader.load(segment_id=segment_id)
|
||||
|
||||
assert files[0].related_id == upload_file_id
|
||||
stmt = upload_session.scalar.call_args.args[0]
|
||||
whereclause = str(stmt.whereclause)
|
||||
assert "upload_files.tenant_id" in whereclause
|
||||
assert "upload_files.id IN" in whereclause
|
||||
|
||||
|
||||
def test_dify_retriever_attachment_loader_skips_ungranted_segment_for_end_user(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
build_from_mapping = MagicMock()
|
||||
session_factory = MagicMock()
|
||||
monkeypatch.setattr(node_runtime, "Session", session_factory)
|
||||
loader = DifyRetrieverAttachmentLoader(
|
||||
file_reference_factory=SimpleNamespace(build_from_mapping=build_from_mapping)
|
||||
)
|
||||
scope = FileAccessScope(
|
||||
tenant_id="tenant-id",
|
||||
user_id="end-user-id",
|
||||
user_from=UserFrom.END_USER,
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
)
|
||||
|
||||
with bind_file_access_scope(scope):
|
||||
files = loader.load(segment_id=str(uuid4()))
|
||||
|
||||
assert files == []
|
||||
session_factory.assert_not_called()
|
||||
build_from_mapping.assert_not_called()
|
||||
|
||||
|
||||
def test_dify_retriever_attachment_loader_skips_segment_rejected_by_checker(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
segment_id = str(uuid4())
|
||||
build_from_mapping = MagicMock()
|
||||
session_factory = MagicMock()
|
||||
segment_access_checker = MagicMock(return_value=False)
|
||||
monkeypatch.setattr(node_runtime, "Session", session_factory)
|
||||
loader = DifyRetrieverAttachmentLoader(
|
||||
file_reference_factory=SimpleNamespace(build_from_mapping=build_from_mapping),
|
||||
segment_access_checker=segment_access_checker,
|
||||
)
|
||||
scope = FileAccessScope(
|
||||
tenant_id="tenant-id",
|
||||
user_id="end-user-id",
|
||||
user_from=UserFrom.END_USER,
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
)
|
||||
|
||||
with bind_file_access_scope(scope):
|
||||
grant_retriever_segment_access([segment_id])
|
||||
files = loader.load(segment_id=segment_id)
|
||||
|
||||
assert files == []
|
||||
segment_access_checker.assert_called_once_with(segment_id)
|
||||
session_factory.assert_not_called()
|
||||
build_from_mapping.assert_not_called()
|
||||
|
||||
|
||||
def test_dify_tool_file_manager_resolves_conversation_id_for_tool_files(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
create_file_by_raw = MagicMock(return_value=SimpleNamespace(id="tool-file-id"))
|
||||
manager_instance = SimpleNamespace(create_file_by_raw=create_file_by_raw)
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from core.app.entities.app_invoke_entities import ChatAppGenerateEntity
|
||||
from core.entities.provider_entities import ProviderQuotaType, QuotaUnit
|
||||
@@ -11,6 +15,13 @@ from models import TenantCreditPool
|
||||
from models.provider import ProviderType
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _patched_credit_pool_session_factory(engine: Engine) -> Iterator[None]:
|
||||
session_maker = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
with patch("services.credit_pool_service.session_factory.get_session_maker", return_value=session_maker):
|
||||
yield
|
||||
|
||||
|
||||
def test_message_created_trial_credit_accounting_does_not_raise_when_balance_is_insufficient() -> None:
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
TenantCreditPool.__table__.create(engine)
|
||||
@@ -54,7 +65,7 @@ def test_message_created_trial_credit_accounting_does_not_raise_when_balance_is_
|
||||
message = SimpleNamespace(message_tokens=2, answer_tokens=1)
|
||||
|
||||
with (
|
||||
patch("services.credit_pool_service.db", SimpleNamespace(engine=engine)),
|
||||
_patched_credit_pool_session_factory(engine),
|
||||
patch.object(update_provider_when_message_created, "_execute_provider_updates"),
|
||||
):
|
||||
update_provider_when_message_created.handle(
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
Regression tests for `libs.helper.TokenManager`.
|
||||
|
||||
`TokenManager` is the storage primitive shared by multiple auth flows, so it
|
||||
must preserve every metadata field written by the caller. Business-specific
|
||||
validation now happens at the callsite boundary (for example,
|
||||
`AccountService.get_change_email_data`), not inside `TokenManager`.
|
||||
"""
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
import libs.helper as helper_module
|
||||
from libs.helper import TokenManager
|
||||
|
||||
|
||||
def _build_fake_redis(storage: dict[str, str]):
|
||||
def store_value(key: str, _ttl: int, value: str) -> bool:
|
||||
storage[key] = value
|
||||
return True
|
||||
|
||||
def load_value(key: str) -> str | None:
|
||||
return storage.get(key)
|
||||
|
||||
return SimpleNamespace(
|
||||
setex=store_value,
|
||||
get=load_value,
|
||||
delete=lambda *_args, **_kwargs: None,
|
||||
)
|
||||
|
||||
|
||||
def test_token_manager_roundtrip_preserves_untyped_metadata_keys(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""`TokenManager` must round-trip arbitrary metadata keys without silently
|
||||
dropping fields such as `phase`, `email_change_phase`, or future auth
|
||||
payload extensions.
|
||||
"""
|
||||
|
||||
storage: dict[str, str] = {}
|
||||
monkeypatch.setattr(helper_module, "redis_client", _build_fake_redis(storage))
|
||||
|
||||
token = TokenManager.generate_token(
|
||||
email="user@example.com",
|
||||
token_type="change_email",
|
||||
additional_data={
|
||||
"code": "654321",
|
||||
"old_email": "old@example.com",
|
||||
"phase": "legacy-phase",
|
||||
"email_change_phase": "old_email",
|
||||
"custom_marker": "preserve-me",
|
||||
},
|
||||
)
|
||||
|
||||
data = TokenManager.get_token_data(token, "change_email")
|
||||
|
||||
assert data is not None
|
||||
assert data.get("phase") == "legacy-phase"
|
||||
assert data.get("email_change_phase") == "old_email"
|
||||
assert data.get("custom_marker") == "preserve-me"
|
||||
|
||||
|
||||
def test_token_manager_roundtrip_uses_explicit_email_with_account(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""When both `account` and `email` are supplied, the token should bind the
|
||||
stable `account_id` from the account and the target email from the explicit
|
||||
email argument.
|
||||
"""
|
||||
|
||||
storage: dict[str, str] = {}
|
||||
monkeypatch.setattr(helper_module, "redis_client", _build_fake_redis(storage))
|
||||
|
||||
account = SimpleNamespace(id="acc-1", email="old@example.com")
|
||||
|
||||
token = TokenManager.generate_token(
|
||||
account=account,
|
||||
email="new@example.com",
|
||||
token_type="change_email",
|
||||
additional_data={
|
||||
"code": "654321",
|
||||
"old_email": "old@example.com",
|
||||
"email_change_phase": "new_email",
|
||||
},
|
||||
)
|
||||
|
||||
data = TokenManager.get_token_data(token, "change_email")
|
||||
|
||||
assert data is not None
|
||||
assert data.get("account_id") == "acc-1"
|
||||
assert data.get("email") == "new@example.com"
|
||||
assert data.get("old_email") == "old@example.com"
|
||||
assert data.get("email_change_phase") == "new_email"
|
||||
|
||||
|
||||
def test_token_manager_roundtrip_still_validates_declared_fields(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Unknown fields should be preserved, but declared baseline fields should
|
||||
still be validated by `_token_data_adapter`.
|
||||
"""
|
||||
|
||||
storage = {
|
||||
"change_email:token:token-123": json.dumps(
|
||||
{
|
||||
"token_type": "change_email",
|
||||
"account_id": "acc-1",
|
||||
"email": ["not-a-string"],
|
||||
"code": "654321",
|
||||
"old_email": "old@example.com",
|
||||
"email_change_phase": "old_email",
|
||||
}
|
||||
)
|
||||
}
|
||||
monkeypatch.setattr(helper_module, "redis_client", _build_fake_redis(storage))
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
TokenManager.get_token_data("token-123", "change_email")
|
||||
|
||||
|
||||
def test_token_manager_roundtrip_validates_email_change_phase_as_string(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""`email_change_phase` is part of the shared baseline schema, so obviously
|
||||
malformed discriminator values should fail before the change-email-specific
|
||||
union parsing at the callsite boundary.
|
||||
"""
|
||||
|
||||
storage = {
|
||||
"change_email:token:token-456": json.dumps(
|
||||
{
|
||||
"token_type": "change_email",
|
||||
"account_id": "acc-1",
|
||||
"email": "new@example.com",
|
||||
"code": "654321",
|
||||
"old_email": "old@example.com",
|
||||
"email_change_phase": ["not-a-string"],
|
||||
}
|
||||
)
|
||||
}
|
||||
monkeypatch.setattr(helper_module, "redis_client", _build_fake_redis(storage))
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
TokenManager.get_token_data("token-456", "change_email")
|
||||
@@ -0,0 +1,76 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from services.plugin.plugin_migration import PluginMigration
|
||||
|
||||
MIGRATION_MODULE = "services.plugin.plugin_migration"
|
||||
|
||||
|
||||
def test_fetch_plugin_unique_identifier_returns_none_when_disabled(mocker: MockerFixture) -> None:
|
||||
mocker.patch("services.plugin.plugin_migration.dify_config.MARKETPLACE_ENABLED", False)
|
||||
batch_fetch = mocker.patch("services.plugin.plugin_migration.marketplace.batch_fetch_plugin_manifests")
|
||||
|
||||
result = PluginMigration._fetch_plugin_unique_identifier("langgenius/openai")
|
||||
|
||||
assert result is None
|
||||
batch_fetch.assert_not_called()
|
||||
|
||||
|
||||
def test_fetch_plugin_unique_identifier_calls_marketplace_when_enabled(mocker: MockerFixture) -> None:
|
||||
mocker.patch("services.plugin.plugin_migration.dify_config.MARKETPLACE_ENABLED", True)
|
||||
manifest = mocker.MagicMock()
|
||||
manifest.latest_package_identifier = "langgenius/openai:1.0.0@abc"
|
||||
mocker.patch(
|
||||
"services.plugin.plugin_migration.marketplace.batch_fetch_plugin_manifests",
|
||||
return_value=[manifest],
|
||||
)
|
||||
|
||||
result = PluginMigration._fetch_plugin_unique_identifier("langgenius/openai")
|
||||
|
||||
assert result == "langgenius/openai:1.0.0@abc"
|
||||
|
||||
|
||||
class TestHandlePluginInstanceInstall:
|
||||
def test_raises_when_disabled_and_map_nonempty(self) -> None:
|
||||
with patch(f"{MIGRATION_MODULE}.dify_config") as mock_cfg:
|
||||
mock_cfg.MARKETPLACE_ENABLED = False
|
||||
|
||||
with pytest.raises(ValueError, match="Marketplace disabled"):
|
||||
PluginMigration.handle_plugin_instance_install(
|
||||
"tenant1", {"langgenius/openai": "langgenius/openai:1.0.0@abc"}
|
||||
)
|
||||
|
||||
def test_no_raise_when_disabled_and_map_empty(self) -> None:
|
||||
with (
|
||||
patch(f"{MIGRATION_MODULE}.dify_config") as mock_cfg,
|
||||
patch(f"{MIGRATION_MODULE}.PluginInstaller") as mock_installer_cls,
|
||||
):
|
||||
mock_cfg.MARKETPLACE_ENABLED = False
|
||||
mock_installer = MagicMock()
|
||||
mock_installer_cls.return_value = mock_installer
|
||||
mock_installer.install_from_identifiers.return_value = MagicMock(all_installed=True)
|
||||
|
||||
result = PluginMigration.handle_plugin_instance_install("tenant1", {})
|
||||
|
||||
assert isinstance(result, dict)
|
||||
|
||||
def test_proceeds_when_enabled(self) -> None:
|
||||
with (
|
||||
patch(f"{MIGRATION_MODULE}.dify_config") as mock_cfg,
|
||||
patch(f"{MIGRATION_MODULE}.marketplace") as mock_marketplace,
|
||||
patch(f"{MIGRATION_MODULE}.PluginInstaller") as mock_installer_cls,
|
||||
):
|
||||
mock_cfg.MARKETPLACE_ENABLED = True
|
||||
mock_marketplace.download_plugin_pkg.return_value = b"pkg_data"
|
||||
mock_installer = MagicMock()
|
||||
mock_installer_cls.return_value = mock_installer
|
||||
mock_installer.install_from_identifiers.return_value = MagicMock(all_installed=True)
|
||||
|
||||
result = PluginMigration.handle_plugin_instance_install(
|
||||
"tenant1", {"langgenius/openai": "langgenius/openai:1.0.0@abc"}
|
||||
)
|
||||
|
||||
mock_marketplace.download_plugin_pkg.assert_called_once()
|
||||
assert "success" in result or "failed" in result
|
||||
@@ -0,0 +1,50 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
MODULE = "services.plugin.plugin_service"
|
||||
|
||||
|
||||
class TestFetchLatestPluginVersion:
|
||||
def test_skips_marketplace_fetch_when_disabled(self) -> None:
|
||||
"""Cache misses stay None; marketplace is never called when disabled."""
|
||||
with (
|
||||
patch(f"{MODULE}.dify_config") as mock_cfg,
|
||||
patch(f"{MODULE}.redis_client") as mock_redis,
|
||||
patch(f"{MODULE}.marketplace") as mock_marketplace,
|
||||
):
|
||||
mock_cfg.MARKETPLACE_ENABLED = False
|
||||
mock_redis.get.return_value = None # all cache misses
|
||||
|
||||
from services.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.fetch_latest_plugin_version(["langgenius/openai", "langgenius/anthropic"])
|
||||
|
||||
mock_marketplace.batch_fetch_plugin_manifests.assert_not_called()
|
||||
assert result == {"langgenius/openai": None, "langgenius/anthropic": None}
|
||||
|
||||
def test_calls_marketplace_fetch_when_enabled(self) -> None:
|
||||
"""Cache misses trigger marketplace fetch when enabled."""
|
||||
manifest = MagicMock()
|
||||
manifest.plugin_id = "langgenius/openai"
|
||||
manifest.latest_version = "1.0.0"
|
||||
manifest.latest_package_identifier = "langgenius/openai:1.0.0@abc"
|
||||
manifest.status = "active"
|
||||
manifest.deprecated_reason = ""
|
||||
manifest.alternative_plugin_id = ""
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.dify_config") as mock_cfg,
|
||||
patch(f"{MODULE}.redis_client") as mock_redis,
|
||||
patch(f"{MODULE}.marketplace") as mock_marketplace,
|
||||
):
|
||||
mock_cfg.MARKETPLACE_ENABLED = True
|
||||
mock_redis.get.return_value = None
|
||||
mock_marketplace.batch_fetch_plugin_manifests.return_value = [manifest]
|
||||
|
||||
from services.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.fetch_latest_plugin_version(["langgenius/openai"])
|
||||
|
||||
# The list arg is mutated by remove() after the call, so check call count + result.
|
||||
mock_marketplace.batch_fetch_plugin_manifests.assert_called_once()
|
||||
assert result["langgenius/openai"] is not None
|
||||
assert result["langgenius/openai"].version == "1.0.0"
|
||||
@@ -0,0 +1,36 @@
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from services.rag_pipeline.rag_pipeline import RagPipelineService
|
||||
|
||||
|
||||
def _make_service() -> RagPipelineService:
|
||||
return RagPipelineService.__new__(RagPipelineService)
|
||||
|
||||
|
||||
def test_fetch_recommended_plugin_manifests_returns_empty_when_disabled(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
mocker.patch("services.rag_pipeline.rag_pipeline.dify_config.MARKETPLACE_ENABLED", False)
|
||||
batch_fetch = mocker.patch("services.rag_pipeline.rag_pipeline.marketplace.batch_fetch_plugin_by_ids")
|
||||
|
||||
service = _make_service()
|
||||
result = service._fetch_recommended_plugin_manifests(["langgenius/openai"])
|
||||
|
||||
assert result == []
|
||||
batch_fetch.assert_not_called()
|
||||
|
||||
|
||||
def test_fetch_recommended_plugin_manifests_returns_data_when_enabled(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
mocker.patch("services.rag_pipeline.rag_pipeline.dify_config.MARKETPLACE_ENABLED", True)
|
||||
expected = [{"plugin_id": "langgenius/openai", "name": "OpenAI"}]
|
||||
mocker.patch(
|
||||
"services.rag_pipeline.rag_pipeline.marketplace.batch_fetch_plugin_by_ids",
|
||||
return_value=expected,
|
||||
)
|
||||
|
||||
service = _make_service()
|
||||
result = service._fetch_recommended_plugin_manifests(["langgenius/openai"])
|
||||
|
||||
assert result == expected
|
||||
@@ -259,6 +259,8 @@ workflow:
|
||||
if result.status == ImportStatus.FAILED:
|
||||
print(f"DEBUG: {result.error}")
|
||||
assert result.status == ImportStatus.COMPLETED
|
||||
session.commit.assert_not_called()
|
||||
session.flush.assert_called()
|
||||
|
||||
|
||||
def test_import_rag_pipeline_pending_version(mocker) -> None:
|
||||
@@ -421,6 +423,8 @@ def test_create_or_update_pipeline_create_new(mocker) -> None:
|
||||
|
||||
assert result == pipeline_instance
|
||||
session.add.assert_called()
|
||||
session.commit.assert_not_called()
|
||||
session.flush.assert_called()
|
||||
|
||||
|
||||
# --- export_rag_pipeline_dsl comprehensive ---
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from models.dataset import Dataset
|
||||
from services.entities.knowledge_entities.rag_pipeline_entities import KnowledgeConfiguration
|
||||
@@ -514,3 +516,64 @@ def test_deal_document_data_upload_file_with_existing_file(mocker) -> None:
|
||||
assert document.data_source_type == "local_file"
|
||||
assert "real_file_id" in document.data_source_info
|
||||
assert add_mock.call_count >= 2
|
||||
|
||||
|
||||
def _make_service():
|
||||
return RagPipelineTransformService.__new__(RagPipelineTransformService)
|
||||
|
||||
|
||||
def test_deal_dependencies_skips_marketplace_when_disabled(mocker: MockerFixture, caplog) -> None:
|
||||
mocker.patch(
|
||||
"services.rag_pipeline.rag_pipeline_transform_service.dify_config.MARKETPLACE_ENABLED",
|
||||
False,
|
||||
)
|
||||
installer = mocker.patch("services.rag_pipeline.rag_pipeline_transform_service.PluginInstaller").return_value
|
||||
installer.list_plugins.return_value = []
|
||||
mocker.patch("services.rag_pipeline.rag_pipeline_transform_service.PluginMigration")
|
||||
install_call = mocker.patch(
|
||||
"services.rag_pipeline.rag_pipeline_transform_service.PluginService.install_from_marketplace_pkg"
|
||||
)
|
||||
|
||||
pipeline_yaml = {
|
||||
"dependencies": [
|
||||
{
|
||||
"type": "marketplace",
|
||||
"value": {"plugin_unique_identifier": "langgenius/openai:1.0.0@abc"},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
service = _make_service()
|
||||
with caplog.at_level(logging.WARNING):
|
||||
service._deal_dependencies(pipeline_yaml, "tenant-1")
|
||||
|
||||
install_call.assert_not_called()
|
||||
assert any("Marketplace disabled" in rec.message for rec in caplog.records)
|
||||
|
||||
|
||||
def test_deal_dependencies_installs_when_enabled(mocker: MockerFixture) -> None:
|
||||
mocker.patch(
|
||||
"services.rag_pipeline.rag_pipeline_transform_service.dify_config.MARKETPLACE_ENABLED",
|
||||
True,
|
||||
)
|
||||
installer = mocker.patch("services.rag_pipeline.rag_pipeline_transform_service.PluginInstaller").return_value
|
||||
installer.list_plugins.return_value = []
|
||||
migration = mocker.patch("services.rag_pipeline.rag_pipeline_transform_service.PluginMigration").return_value
|
||||
migration._fetch_plugin_unique_identifier.return_value = "langgenius/openai:1.0.0@abc"
|
||||
install_call = mocker.patch(
|
||||
"services.rag_pipeline.rag_pipeline_transform_service.PluginService.install_from_marketplace_pkg"
|
||||
)
|
||||
|
||||
pipeline_yaml = {
|
||||
"dependencies": [
|
||||
{
|
||||
"type": "marketplace",
|
||||
"value": {"plugin_unique_identifier": "langgenius/openai:1.0.0@abc"},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
service = _make_service()
|
||||
service._deal_dependencies(pipeline_yaml, "tenant-1")
|
||||
|
||||
install_call.assert_called_once_with("tenant-1", ["langgenius/openai:1.0.0@abc"])
|
||||
|
||||
@@ -85,7 +85,7 @@ class TestFetchFromDifyOfficial:
|
||||
|
||||
@patch("services.recommend_app.remote.remote_retrieval.dify_config")
|
||||
@patch("services.recommend_app.remote.remote_retrieval.httpx.get")
|
||||
def test_apps_returns_sorted_categories_on_200(self, mock_get, mock_config):
|
||||
def test_apps_preserves_remote_categories_order_on_200(self, mock_get, mock_config):
|
||||
mock_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN = "https://example.com"
|
||||
mock_response = MagicMock(status_code=200)
|
||||
mock_response.json.return_value = {
|
||||
@@ -96,7 +96,7 @@ class TestFetchFromDifyOfficial:
|
||||
|
||||
result = RemoteRecommendAppRetrieval.fetch_recommended_apps_from_dify_official("en-US")
|
||||
|
||||
assert result["categories"] == ["agent", "chat", "writing"]
|
||||
assert result["categories"] == ["writing", "agent", "chat"]
|
||||
|
||||
@patch("services.recommend_app.remote.remote_retrieval.dify_config")
|
||||
@patch("services.recommend_app.remote.remote_retrieval.httpx.get")
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
from types import SimpleNamespace
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from core.errors.error import QuotaExceededError
|
||||
from models import TenantCreditPool
|
||||
@@ -31,15 +33,33 @@ def _create_engine_with_pool(*, quota_limit: int, quota_used: int) -> tuple[Engi
|
||||
return engine, tenant_id, pool_id
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _patched_session_factory(engine: Engine) -> Iterator[None]:
|
||||
session_maker = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
with patch("services.credit_pool_service.session_factory.get_session_maker", return_value=session_maker):
|
||||
yield
|
||||
|
||||
|
||||
def _get_quota_used(*, engine: Engine, pool_id: str) -> int | None:
|
||||
with engine.connect() as connection:
|
||||
return connection.scalar(select(TenantCreditPool.quota_used).where(TenantCreditPool.id == pool_id))
|
||||
|
||||
|
||||
def test_get_pool_uses_configured_session_factory_without_flask_app_context() -> None:
|
||||
engine, tenant_id, _ = _create_engine_with_pool(quota_limit=10, quota_used=2)
|
||||
|
||||
with _patched_session_factory(engine):
|
||||
pool = CreditPoolService.get_pool(tenant_id=tenant_id, pool_type=ProviderQuotaType.TRIAL)
|
||||
|
||||
assert pool is not None
|
||||
assert pool.tenant_id == tenant_id
|
||||
assert pool.quota_used == 2
|
||||
|
||||
|
||||
def test_check_and_deduct_credits_deducts_exact_amount_when_sufficient() -> None:
|
||||
engine, tenant_id, pool_id = _create_engine_with_pool(quota_limit=10, quota_used=2)
|
||||
|
||||
with patch("services.credit_pool_service.db", SimpleNamespace(engine=engine)):
|
||||
with _patched_session_factory(engine):
|
||||
deducted_credits = CreditPoolService.check_and_deduct_credits(tenant_id=tenant_id, credits_required=3)
|
||||
|
||||
assert deducted_credits == 3
|
||||
@@ -55,7 +75,7 @@ def test_check_and_deduct_credits_raises_when_pool_is_missing() -> None:
|
||||
TenantCreditPool.__table__.create(engine)
|
||||
|
||||
with (
|
||||
patch("services.credit_pool_service.db", SimpleNamespace(engine=engine)),
|
||||
_patched_session_factory(engine),
|
||||
pytest.raises(QuotaExceededError, match="Credit pool not found"),
|
||||
):
|
||||
CreditPoolService.check_and_deduct_credits(tenant_id=str(uuid4()), credits_required=1)
|
||||
@@ -65,7 +85,7 @@ def test_check_and_deduct_credits_raises_when_pool_is_empty() -> None:
|
||||
engine, tenant_id, pool_id = _create_engine_with_pool(quota_limit=10, quota_used=10)
|
||||
|
||||
with (
|
||||
patch("services.credit_pool_service.db", SimpleNamespace(engine=engine)),
|
||||
_patched_session_factory(engine),
|
||||
pytest.raises(QuotaExceededError, match="No credits remaining"),
|
||||
):
|
||||
CreditPoolService.check_and_deduct_credits(tenant_id=tenant_id, credits_required=1)
|
||||
@@ -77,7 +97,7 @@ def test_check_and_deduct_credits_raises_without_partial_deduction_when_insuffic
|
||||
engine, tenant_id, pool_id = _create_engine_with_pool(quota_limit=10, quota_used=9)
|
||||
|
||||
with (
|
||||
patch("services.credit_pool_service.db", SimpleNamespace(engine=engine)),
|
||||
_patched_session_factory(engine),
|
||||
pytest.raises(QuotaExceededError, match="Insufficient credits remaining"),
|
||||
):
|
||||
CreditPoolService.check_and_deduct_credits(tenant_id=tenant_id, credits_required=3)
|
||||
@@ -89,7 +109,7 @@ def test_check_and_deduct_credits_wraps_unexpected_deduction_errors() -> None:
|
||||
engine, tenant_id, pool_id = _create_engine_with_pool(quota_limit=10, quota_used=2)
|
||||
|
||||
with (
|
||||
patch("services.credit_pool_service.db", SimpleNamespace(engine=engine)),
|
||||
_patched_session_factory(engine),
|
||||
patch.object(CreditPoolService, "_get_locked_pool", side_effect=RuntimeError("database unavailable")),
|
||||
pytest.raises(QuotaExceededError, match="Failed to deduct credits"),
|
||||
):
|
||||
@@ -106,7 +126,7 @@ def test_deduct_credits_capped_returns_zero_when_pool_is_missing() -> None:
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
TenantCreditPool.__table__.create(engine)
|
||||
|
||||
with patch("services.credit_pool_service.db", SimpleNamespace(engine=engine)):
|
||||
with _patched_session_factory(engine):
|
||||
deducted_credits = CreditPoolService.deduct_credits_capped(tenant_id=str(uuid4()), credits_required=1)
|
||||
|
||||
assert deducted_credits == 0
|
||||
@@ -115,7 +135,7 @@ def test_deduct_credits_capped_returns_zero_when_pool_is_missing() -> None:
|
||||
def test_deduct_credits_capped_returns_zero_when_pool_is_empty() -> None:
|
||||
engine, tenant_id, pool_id = _create_engine_with_pool(quota_limit=10, quota_used=10)
|
||||
|
||||
with patch("services.credit_pool_service.db", SimpleNamespace(engine=engine)):
|
||||
with _patched_session_factory(engine):
|
||||
deducted_credits = CreditPoolService.deduct_credits_capped(tenant_id=tenant_id, credits_required=1)
|
||||
|
||||
assert deducted_credits == 0
|
||||
@@ -125,7 +145,7 @@ def test_deduct_credits_capped_returns_zero_when_pool_is_empty() -> None:
|
||||
def test_deduct_credits_capped_deducts_only_remaining_balance_when_insufficient() -> None:
|
||||
engine, tenant_id, pool_id = _create_engine_with_pool(quota_limit=10, quota_used=9)
|
||||
|
||||
with patch("services.credit_pool_service.db", SimpleNamespace(engine=engine)):
|
||||
with _patched_session_factory(engine):
|
||||
deducted_credits = CreditPoolService.deduct_credits_capped(tenant_id=tenant_id, credits_required=3)
|
||||
|
||||
assert deducted_credits == 1
|
||||
@@ -136,7 +156,7 @@ def test_deduct_credits_capped_wraps_unexpected_deduction_errors() -> None:
|
||||
engine, tenant_id, pool_id = _create_engine_with_pool(quota_limit=10, quota_used=2)
|
||||
|
||||
with (
|
||||
patch("services.credit_pool_service.db", SimpleNamespace(engine=engine)),
|
||||
_patched_session_factory(engine),
|
||||
patch.object(CreditPoolService, "_get_locked_pool", side_effect=RuntimeError("database unavailable")),
|
||||
pytest.raises(QuotaExceededError, match="Failed to deduct credits"),
|
||||
):
|
||||
@@ -149,7 +169,7 @@ def test_deduct_credits_capped_reraises_quota_exceeded_errors() -> None:
|
||||
engine, tenant_id, pool_id = _create_engine_with_pool(quota_limit=10, quota_used=2)
|
||||
|
||||
with (
|
||||
patch("services.credit_pool_service.db", SimpleNamespace(engine=engine)),
|
||||
_patched_session_factory(engine),
|
||||
patch.object(CreditPoolService, "_get_locked_pool", side_effect=QuotaExceededError("quota unavailable")),
|
||||
pytest.raises(QuotaExceededError, match="quota unavailable"),
|
||||
):
|
||||
|
||||
@@ -243,7 +243,7 @@ class TestDatasourceProviderService:
|
||||
assert service.get_datasource_credentials("t1", "prov", "org/plug") == {}
|
||||
|
||||
def test_should_refresh_oauth_tokens_when_expired(self, service, mock_db_session, mock_user):
|
||||
"""Expired OAuth credential (expires_at near zero) triggers a silent refresh."""
|
||||
"""Expired OAuth credential (expires_at near zero) triggers a refresh."""
|
||||
p = MagicMock(spec=DatasourceProvider)
|
||||
p.auth_type = "oauth2"
|
||||
p.expires_at = 0 # expired
|
||||
@@ -256,6 +256,24 @@ class TestDatasourceProviderService:
|
||||
):
|
||||
service.get_datasource_credentials("t1", "prov", "org/plug")
|
||||
|
||||
def test_should_include_provider_name_when_refresh_fails(self, service, mock_db_session, mock_user):
|
||||
p = MagicMock(spec=DatasourceProvider)
|
||||
p.id = "cred-id"
|
||||
p.name = "Credential"
|
||||
p.auth_type = "oauth2"
|
||||
p.expires_at = 0
|
||||
p.encrypted_credentials = {"tok": "x"}
|
||||
mock_db_session.scalar.return_value = p
|
||||
with (
|
||||
patch("services.datasource_provider_service.get_current_user", return_value=mock_user),
|
||||
patch("services.datasource_provider_service.OAuthHandler") as oauth_handler,
|
||||
patch.object(service, "get_oauth_client", return_value={"oc": "v"}),
|
||||
patch.object(service, "decrypt_datasource_provider_credentials", return_value={"tok": "plain"}),
|
||||
):
|
||||
oauth_handler.return_value.refresh_credentials.side_effect = RuntimeError("token endpoint failed")
|
||||
with pytest.raises(ValueError, match="provider prov"):
|
||||
service.get_datasource_credentials("t1", "prov", "org/plug")
|
||||
|
||||
def test_should_return_decrypted_credentials_when_api_key_not_expired(self, service, mock_db_session, mock_user):
|
||||
"""API key credentials with expires_at=-1 skip refresh and return directly."""
|
||||
p = MagicMock(spec=DatasourceProvider)
|
||||
@@ -307,6 +325,51 @@ class TestDatasourceProviderService:
|
||||
result = service.get_all_datasource_credentials_by_provider("t1", "prov", "org/plug")
|
||||
assert len(result) == 1
|
||||
|
||||
def test_should_skip_failed_provider_when_refreshing_all_credentials(
|
||||
self, service, mock_db_session, mock_user, caplog
|
||||
):
|
||||
failed_provider = MagicMock(spec=DatasourceProvider)
|
||||
failed_provider.id = "failed-cred"
|
||||
failed_provider.name = "Failed"
|
||||
failed_provider.auth_type = "oauth2"
|
||||
failed_provider.expires_at = 0
|
||||
working_provider = MagicMock(spec=DatasourceProvider)
|
||||
working_provider.id = "working-cred"
|
||||
working_provider.name = "Working"
|
||||
working_provider.auth_type = "oauth2"
|
||||
working_provider.expires_at = 0
|
||||
mock_db_session.scalars.return_value.all.return_value = [failed_provider, working_provider]
|
||||
with (
|
||||
patch("services.datasource_provider_service.get_current_user", return_value=mock_user),
|
||||
patch.object(
|
||||
service,
|
||||
"_refresh_datasource_credentials",
|
||||
side_effect=[ValueError("refresh failed"), ({"t": "enc"}, 9999)],
|
||||
) as refresh_credentials,
|
||||
patch.object(service, "decrypt_datasource_provider_credentials", return_value={"t": "plain"}),
|
||||
):
|
||||
result = service.get_all_datasource_credentials_by_provider("t1", "prov", "org/plug")
|
||||
assert result == [{"t": "plain"}]
|
||||
assert refresh_credentials.call_count == 2
|
||||
assert "Skipping datasource credentials for provider prov" in caplog.text
|
||||
|
||||
def test_should_return_valid_credentials_without_refresh_when_getting_all_credentials(
|
||||
self, service, mock_db_session, mock_user
|
||||
):
|
||||
p = MagicMock(spec=DatasourceProvider)
|
||||
p.auth_type = "oauth2"
|
||||
p.expires_at = -1
|
||||
p.encrypted_credentials = {"t": "x"}
|
||||
mock_db_session.scalars.return_value.all.return_value = [p]
|
||||
with (
|
||||
patch("services.datasource_provider_service.get_current_user", return_value=mock_user),
|
||||
patch.object(service, "_refresh_datasource_credentials") as refresh_credentials,
|
||||
patch.object(service, "decrypt_datasource_provider_credentials", return_value={"t": "plain"}),
|
||||
):
|
||||
result = service.get_all_datasource_credentials_by_provider("t1", "prov", "org/plug")
|
||||
assert result == [{"t": "plain"}]
|
||||
refresh_credentials.assert_not_called()
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# update_datasource_provider_name (lines 236-303)
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from services.hit_testing_service import HitTestingService
|
||||
|
||||
|
||||
def _retrieval_record(payload: dict):
|
||||
record = Mock()
|
||||
record.model_dump.return_value = payload
|
||||
return record
|
||||
|
||||
|
||||
def _dataset_document(
|
||||
document_id: str = "document-1",
|
||||
name: str = "guide.md",
|
||||
data_source_type: str = "upload_file",
|
||||
doc_type: str | None = None,
|
||||
doc_metadata: dict | None = None,
|
||||
):
|
||||
document = Mock()
|
||||
document.id = document_id
|
||||
document.name = name
|
||||
document.data_source_type = data_source_type
|
||||
document.doc_type = doc_type
|
||||
document.doc_metadata = doc_metadata
|
||||
return document
|
||||
|
||||
|
||||
class TestHitTestingServiceDumpRecords:
|
||||
def test_dump_dataset_document_returns_frontend_required_fields(self):
|
||||
document = _dataset_document(doc_metadata={"source": "manual"})
|
||||
|
||||
assert HitTestingService._dump_dataset_document(document) == {
|
||||
"id": "document-1",
|
||||
"data_source_type": "upload_file",
|
||||
"name": "guide.md",
|
||||
"doc_type": None,
|
||||
"doc_metadata": {"source": "manual"},
|
||||
}
|
||||
|
||||
def test_dump_retrieval_records_returns_dumped_records_without_document_ids(self):
|
||||
record = _retrieval_record({"segment": None, "score": 0.95})
|
||||
|
||||
assert HitTestingService._dump_retrieval_records([record]) == [{"segment": None, "score": 0.95}]
|
||||
|
||||
def test_dump_retrieval_records_injects_documents_and_keeps_non_segment_records(self):
|
||||
record_without_segment = _retrieval_record({"segment": None, "score": 0.95})
|
||||
record_with_document = _retrieval_record(
|
||||
{
|
||||
"segment": {
|
||||
"id": "segment-1",
|
||||
"document_id": "document-1",
|
||||
},
|
||||
"score": 0.9,
|
||||
}
|
||||
)
|
||||
scalars_result = Mock()
|
||||
scalars_result.all.return_value = [_dataset_document()]
|
||||
|
||||
with patch("services.hit_testing_service.db.session.scalars", return_value=scalars_result):
|
||||
result = HitTestingService._dump_retrieval_records([record_without_segment, record_with_document])
|
||||
|
||||
assert result[0] == {"segment": None, "score": 0.95}
|
||||
assert result[1]["segment"]["document"] == {
|
||||
"id": "document-1",
|
||||
"data_source_type": "upload_file",
|
||||
"name": "guide.md",
|
||||
"doc_type": None,
|
||||
"doc_metadata": None,
|
||||
}
|
||||
|
||||
def test_dump_retrieval_records_skips_records_with_missing_documents(self, caplog):
|
||||
record = _retrieval_record(
|
||||
{
|
||||
"segment": {
|
||||
"id": "segment-1",
|
||||
"document_id": "missing-document",
|
||||
},
|
||||
"score": 0.95,
|
||||
}
|
||||
)
|
||||
scalars_result = Mock()
|
||||
scalars_result.all.return_value = []
|
||||
|
||||
with patch("services.hit_testing_service.db.session.scalars", return_value=scalars_result):
|
||||
result = HitTestingService._dump_retrieval_records([record])
|
||||
|
||||
assert result == []
|
||||
assert "Skipping hit-testing records with missing documents" in caplog.text
|
||||
@@ -4,11 +4,9 @@ import { useStore } from '@/app/components/app/store'
|
||||
import MessageLogModal from '../index'
|
||||
|
||||
let clickAwayHandler: (() => void) | null = null
|
||||
let clickAwayHandlers: (() => void)[] = []
|
||||
vi.mock('ahooks', () => ({
|
||||
useClickAway: (fn: () => void) => {
|
||||
clickAwayHandler = fn
|
||||
clickAwayHandlers.push(fn)
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -40,7 +38,6 @@ describe('MessageLogModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
clickAwayHandler = null
|
||||
clickAwayHandlers = []
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
vi.mocked(useStore).mockImplementation((selector: any) => selector({
|
||||
appDetail: { id: 'app-1' },
|
||||
@@ -76,15 +73,17 @@ describe('MessageLogModal', () => {
|
||||
|
||||
it('sets fixed style when fixedWidth is false (floating)', () => {
|
||||
const { container } = render(<MessageLogModal width={1000} onCancel={onCancel} currentLogItem={mockLog} fixedWidth={false} />)
|
||||
const modal = container.firstChild as HTMLElement
|
||||
expect(modal.style.position).toBe('fixed')
|
||||
expect(modal.style.width).toBe('480px')
|
||||
const modal = screen.getByRole('dialog')
|
||||
expect(container).not.toContainElement(modal)
|
||||
expect(document.body).toContainElement(modal)
|
||||
expect(modal).toHaveClass('fixed', 'z-50', 'w-[480px]!', 'left-[max(8px,calc(100vw-1136px))]!')
|
||||
})
|
||||
|
||||
it('sets fixed width when fixedWidth is true', () => {
|
||||
const { container } = render(<MessageLogModal width={1000} onCancel={onCancel} currentLogItem={mockLog} fixedWidth={true} />)
|
||||
const modal = container.firstChild as HTMLElement
|
||||
expect(modal.style.width).toBe('1000px')
|
||||
const panel = container.firstElementChild as HTMLElement
|
||||
expect(panel).toHaveClass('relative', 'z-10')
|
||||
expect(panel.style.width).toBe('1000px')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -98,16 +97,16 @@ describe('MessageLogModal', () => {
|
||||
})
|
||||
|
||||
it('calls onCancel when clicked away', () => {
|
||||
render(<MessageLogModal width={800} onCancel={onCancel} currentLogItem={mockLog} />)
|
||||
render(<MessageLogModal width={800} onCancel={onCancel} currentLogItem={mockLog} fixedWidth />)
|
||||
expect(clickAwayHandler).toBeTruthy()
|
||||
clickAwayHandler!()
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not call onCancel when clicked away if not mounted', () => {
|
||||
it('does not use click away to close the floating dialog', () => {
|
||||
render(<MessageLogModal width={800} onCancel={onCancel} currentLogItem={mockLog} />)
|
||||
expect(clickAwayHandlers.length).toBeGreaterThan(0)
|
||||
clickAwayHandlers[0]!() // This is the closure from the initial render, where mounted is false
|
||||
expect(clickAwayHandler).toBeTruthy()
|
||||
clickAwayHandler!()
|
||||
expect(onCancel).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import type { FC } from 'react'
|
||||
import type { IChatItem } from '@/app/components/base/chat/chat/type'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { useClickAway } from 'ahooks'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStore } from '@/app/components/app/store'
|
||||
import Run from '@/app/components/workflow/run'
|
||||
|
||||
type RunActiveTab = 'RESULT' | 'DETAIL' | 'TRACING'
|
||||
|
||||
const isRunActiveTab = (tab: string): tab is RunActiveTab =>
|
||||
tab === 'RESULT' || tab === 'DETAIL' || tab === 'TRACING'
|
||||
|
||||
type MessageLogModalProps = {
|
||||
currentLogItem?: IChatItem
|
||||
defaultTab?: string
|
||||
@@ -24,36 +29,65 @@ const MessageLogModal: FC<MessageLogModalProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const ref = useRef(null)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const appDetail = useStore(state => state.appDetail)
|
||||
|
||||
useClickAway(() => {
|
||||
if (mounted)
|
||||
if (fixedWidth)
|
||||
onCancel()
|
||||
}, ref)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
if (!currentLogItem || !currentLogItem.workflow_run_id)
|
||||
return null
|
||||
|
||||
const activeTab = isRunActiveTab(defaultTab) ? defaultTab : 'DETAIL'
|
||||
const modalContent = (
|
||||
<>
|
||||
<DialogTitle className="shrink-0 px-4 py-1 system-xl-semibold text-text-primary">{t('runDetail.title', { ns: 'appLog' })}</DialogTitle>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('operation.close', { ns: 'common' })}
|
||||
className="absolute top-4 right-3 z-20 cursor-pointer border-none bg-transparent p-1 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" aria-hidden="true" />
|
||||
</button>
|
||||
<Run
|
||||
hideResult
|
||||
activeTab={activeTab}
|
||||
runDetailUrl={`/apps/${appDetail?.id}/workflow-runs/${currentLogItem.workflow_run_id}`}
|
||||
tracingListUrl={`/apps/${appDetail?.id}/workflow-runs/${currentLogItem.workflow_run_id}/node-executions`}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
if (!fixedWidth) {
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open)
|
||||
onCancel()
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
backdropClassName="bg-transparent!"
|
||||
className="top-16! bottom-4! left-[max(8px,calc(100vw-1136px))]! flex max-h-none! w-[480px]! max-w-[calc(100vw-16px)]! translate-x-0! translate-y-0! flex-col overflow-hidden! rounded-xl! border-[0.5px]! border-components-panel-border! bg-components-panel-bg! p-0! pt-3! shadow-xl!"
|
||||
>
|
||||
{modalContent}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('relative z-10 flex flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg pt-3 shadow-xl')}
|
||||
className={cn(
|
||||
'relative z-10',
|
||||
'flex flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg pt-3 shadow-xl',
|
||||
)}
|
||||
style={{
|
||||
width: fixedWidth ? width : 480,
|
||||
...(!fixedWidth
|
||||
? {
|
||||
position: 'fixed',
|
||||
top: 56 + 8,
|
||||
left: 8 + (width - 480),
|
||||
bottom: 16,
|
||||
}
|
||||
: {
|
||||
marginRight: 8,
|
||||
}),
|
||||
width,
|
||||
marginRight: 8,
|
||||
}}
|
||||
ref={ref}
|
||||
>
|
||||
@@ -64,11 +98,11 @@ const MessageLogModal: FC<MessageLogModalProps> = ({
|
||||
className="absolute top-4 right-3 z-20 cursor-pointer border-none bg-transparent p-1 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4 text-text-tertiary" aria-hidden="true" />
|
||||
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" aria-hidden="true" />
|
||||
</button>
|
||||
<Run
|
||||
hideResult
|
||||
activeTab={defaultTab as any}
|
||||
activeTab={activeTab}
|
||||
runDetailUrl={`/apps/${appDetail?.id}/workflow-runs/${currentLogItem.workflow_run_id}`}
|
||||
tracingListUrl={`/apps/${appDetail?.id}/workflow-runs/${currentLogItem.workflow_run_id}/node-executions`}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
@reference "../../../../styles/globals.css";
|
||||
|
||||
.modal {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.modalHeader {
|
||||
@apply flex items-center place-content-between h-8;
|
||||
}
|
||||
@@ -19,7 +15,7 @@
|
||||
background-size: 16px;
|
||||
}
|
||||
|
||||
.modal .tip {
|
||||
.tip {
|
||||
@apply mt-1 mb-8 text-text-tertiary;
|
||||
font-weight: 400;
|
||||
font-size: 13px;
|
||||
|
||||
@@ -53,7 +53,7 @@ const EmptyDatasetCreationModal = ({ show = false, onHide }: IProps) => {
|
||||
onHide()
|
||||
}}
|
||||
>
|
||||
<DialogContent className={cn('w-full overflow-hidden! border-none text-left align-middle', cn(s.modal, '!max-w-[520px]', 'px-8'))}>
|
||||
<DialogContent className="w-full max-w-[520px]! overflow-hidden! border-none px-8 text-left align-middle">
|
||||
|
||||
<div className={s.modalHeader}>
|
||||
<div className={s.title}>{t('stepOne.modal.title', { ns: 'datasetCreation' })}</div>
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
@reference "../../../../styles/globals.css";
|
||||
|
||||
.modal {
|
||||
position: relative;
|
||||
}
|
||||
.modal .icon {
|
||||
.icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: rgba(255, 255, 255, 0.9) center no-repeat url(../assets/annotation-info.svg);
|
||||
@@ -12,7 +9,7 @@
|
||||
box-shadow: 0px 20px 24px -4px rgba(16, 24, 40, 0.08), 0px 8px 8px -4px rgba(16, 24, 40, 0.03);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.modal .close {
|
||||
.close {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
top: 16px;
|
||||
@@ -23,14 +20,14 @@
|
||||
background-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.modal .title {
|
||||
.title {
|
||||
@apply mt-3 mb-1;
|
||||
font-weight: 600;
|
||||
font-size: 20px;
|
||||
line-height: 30px;
|
||||
color: #101828;
|
||||
}
|
||||
.modal .content {
|
||||
.content {
|
||||
@apply mb-10;
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
|
||||
@@ -39,7 +39,7 @@ const StopEmbeddingModal = ({
|
||||
onHide()
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent className={cn(s.modal, 'max-w-[480px]! overflow-hidden! border-none px-8 py-6 text-left align-middle shadow-xl')}>
|
||||
<AlertDialogContent className="max-w-[480px]! overflow-hidden! border-none px-8 py-6 text-left align-middle shadow-xl">
|
||||
<div className={s.icon} />
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -57,7 +57,7 @@ const InstallBundle: FC<Props> = ({
|
||||
foldAnimInto()
|
||||
}}
|
||||
>
|
||||
<DialogContent className={cn('relative w-full max-w-[480px] overflow-hidden! text-left align-middle', cn(modalClassName, 'shadows-shadow-xl flex min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0'))}>
|
||||
<DialogContent className={cn('w-full max-w-[480px] overflow-hidden! text-left align-middle', cn(modalClassName, 'shadows-shadow-xl flex min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0'))}>
|
||||
<DialogCloseButton />
|
||||
|
||||
<div className="flex items-start gap-2 self-stretch pt-6 pr-14 pb-3 pl-6">
|
||||
|
||||
+12
@@ -265,6 +265,18 @@ describe('Install Component', () => {
|
||||
expect(screen.getByTestId('all-plugins-count')).toHaveTextContent('2')
|
||||
})
|
||||
|
||||
it('should make the plugin list scrollable inside the modal body', () => {
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('install-multi').parentElement).toHaveClass('overflow-y-auto')
|
||||
})
|
||||
|
||||
it('should constrain the install step so the plugin list can scroll with many items', () => {
|
||||
const { container } = render(<Install {...defaultProps} />)
|
||||
|
||||
expect(container.firstElementChild).toHaveClass('min-h-0', 'flex-1', 'overflow-hidden')
|
||||
})
|
||||
|
||||
it('should show singular text when one plugin is selected', async () => {
|
||||
render(<Install {...defaultProps} />)
|
||||
|
||||
|
||||
@@ -170,12 +170,12 @@ const Install: FC<Props> = ({
|
||||
|
||||
const { canInstallPluginFromMarketplace } = useCanInstallPluginFromMarketplace()
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3">
|
||||
<div className="flex min-h-0 flex-1 flex-col self-stretch overflow-hidden">
|
||||
<div className="flex min-h-0 flex-1 flex-col items-start justify-center gap-4 self-stretch overflow-hidden px-6 py-3">
|
||||
<div className="system-md-regular text-text-secondary">
|
||||
<p>{t(`${i18nPrefix}.${selectedPluginsNum > 1 ? 'readyToInstallPackages' : 'readyToInstallPackage'}`, { ns: 'plugin', num: selectedPluginsNum })}</p>
|
||||
</div>
|
||||
<div className="w-full space-y-1 rounded-2xl bg-background-section-burn p-2">
|
||||
<div className="flex min-h-0 w-full flex-1 flex-col gap-1 overflow-y-auto rounded-2xl bg-background-section-burn p-2">
|
||||
<InstallMulti
|
||||
ref={installMultiRef}
|
||||
allPlugins={allPlugins}
|
||||
@@ -218,7 +218,7 @@ const Install: FC<Props> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(Install)
|
||||
|
||||
+6
@@ -292,6 +292,12 @@ describe('InstallFromLocalPackage', () => {
|
||||
expect(screen.getByTestId('is-bundle')).toHaveTextContent('true')
|
||||
})
|
||||
|
||||
it('should constrain dialog height so bundle dependency lists can scroll', () => {
|
||||
render(<InstallFromLocalPackage {...defaultProps} file={createMockBundleFile()} />)
|
||||
|
||||
expect(screen.getByRole('dialog')).toHaveClass('max-h-[calc(100dvh-48px)]')
|
||||
})
|
||||
|
||||
it('should identify package file correctly', () => {
|
||||
render(<InstallFromLocalPackage {...defaultProps} />)
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ const InstallFromLocalPackage: React.FC<InstallFromLocalPackageProps> = ({
|
||||
foldAnimInto()
|
||||
}}
|
||||
>
|
||||
<DialogContent className={cn('w-[560px] max-w-none! overflow-hidden! text-left align-middle', cn(modalClassName, 'shadows-shadow-xl flex min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0'))}>
|
||||
<DialogContent className={cn('w-[560px] max-w-none! overflow-hidden! text-left align-middle', cn(modalClassName, 'shadows-shadow-xl flex max-h-[calc(100dvh-48px)] min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0'))}>
|
||||
<DialogCloseButton />
|
||||
|
||||
<div className="flex items-start gap-2 self-stretch pt-6 pr-14 pb-3 pl-6">
|
||||
|
||||
+13
@@ -212,6 +212,19 @@ describe('InstallFromMarketplace', () => {
|
||||
expect(screen.getByTestId('bundle-plugins-count')).toHaveTextContent('2')
|
||||
})
|
||||
|
||||
it('should constrain bundle dialog height so dependency lists can scroll', () => {
|
||||
const dependencies = createMockDependencies()
|
||||
render(
|
||||
<InstallFromMarketplace
|
||||
{...defaultProps}
|
||||
isBundle={true}
|
||||
dependencies={dependencies}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('dialog')).toHaveClass('max-h-[calc(100dvh-48px)]')
|
||||
})
|
||||
|
||||
it('should pass isFromMarketPlace as true to bundle component', () => {
|
||||
const dependencies = createMockDependencies()
|
||||
render(
|
||||
|
||||
@@ -77,7 +77,7 @@ const InstallFromMarketplace: React.FC<InstallFromMarketplaceProps> = ({
|
||||
foldAnimInto()
|
||||
}}
|
||||
>
|
||||
<DialogContent className={cn('w-[560px] max-w-none! overflow-hidden! text-left align-middle', cn(modalClassName, 'shadows-shadow-xl flex min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0'))}>
|
||||
<DialogContent className={cn('w-[560px] max-w-none! overflow-hidden! text-left align-middle', cn(modalClassName, 'shadows-shadow-xl flex max-h-[calc(100dvh-48px)] min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0'))}>
|
||||
<DialogCloseButton />
|
||||
|
||||
<div className="flex items-start gap-2 self-stretch pt-6 pr-14 pb-3 pl-6">
|
||||
|
||||
@@ -77,7 +77,7 @@ const PublishAsKnowledgePipelineModal = ({
|
||||
return (
|
||||
<>
|
||||
<Dialog open>
|
||||
<DialogContent className="relative w-full max-w-[480px]! overflow-hidden! border-none p-0! text-left align-middle">
|
||||
<DialogContent className="w-full max-w-[480px]! overflow-hidden! border-none p-0! text-left align-middle">
|
||||
|
||||
<div className="relative flex items-center p-6 pr-14 pb-3 title-2xl-semi-bold text-text-primary">
|
||||
{t('common.publishAs', { ns: 'pipeline' })}
|
||||
|
||||
+53
-8
@@ -480,7 +480,9 @@ describe('publisher', () => {
|
||||
it('should show publish as knowledge pipeline modal when permitted', async () => {
|
||||
mockPublishedAt.mockReturnValue(1700000000)
|
||||
mockIsAllowPublishAsCustomKnowledgePipelineTemplate.mockReturnValue(true)
|
||||
renderWithQueryClient(<Popup />)
|
||||
renderWithQueryClient(<Publisher />)
|
||||
|
||||
fireEvent.click(screen.getByText('workflow.common.publish'))
|
||||
|
||||
const publishAsButton = screen.getAllByRole('button').find(btn =>
|
||||
btn.textContent?.includes('pipeline.common.publishAs'),
|
||||
@@ -495,7 +497,9 @@ describe('publisher', () => {
|
||||
it('should close publish as knowledge pipeline modal when cancel is clicked', async () => {
|
||||
mockPublishedAt.mockReturnValue(1700000000)
|
||||
mockIsAllowPublishAsCustomKnowledgePipelineTemplate.mockReturnValue(true)
|
||||
renderWithQueryClient(<Popup />)
|
||||
renderWithQueryClient(<Publisher />)
|
||||
|
||||
fireEvent.click(screen.getByText('workflow.common.publish'))
|
||||
|
||||
const publishAsButton = screen.getAllByRole('button').find(btn =>
|
||||
btn.textContent?.includes('pipeline.common.publishAs'),
|
||||
@@ -516,7 +520,9 @@ describe('publisher', () => {
|
||||
it('should call publishAsCustomizedPipeline when confirm is clicked in modal', async () => {
|
||||
mockPublishedAt.mockReturnValue(1700000000)
|
||||
mockPublishAsCustomizedPipeline.mockResolvedValue({})
|
||||
renderWithQueryClient(<Popup />)
|
||||
renderWithQueryClient(<Publisher />)
|
||||
|
||||
fireEvent.click(screen.getByText('workflow.common.publish'))
|
||||
|
||||
const publishAsButton = screen.getAllByRole('button').find(btn =>
|
||||
btn.textContent?.includes('pipeline.common.publishAs'),
|
||||
@@ -538,6 +544,35 @@ describe('publisher', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should publish as template with empty pipeline id fallback', async () => {
|
||||
mockPublishedAt.mockReturnValue(1700000000)
|
||||
mockPipelineId.mockReturnValue(undefined as unknown as string)
|
||||
mockPublishAsCustomizedPipeline.mockResolvedValue({})
|
||||
renderWithQueryClient(<Publisher />)
|
||||
|
||||
fireEvent.click(screen.getByText('workflow.common.publish'))
|
||||
|
||||
const publishAsButton = screen.getAllByRole('button').find(btn =>
|
||||
btn.textContent?.includes('pipeline.common.publishAs'),
|
||||
)
|
||||
fireEvent.click(publishAsButton!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('publish-as-knowledge-pipeline-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('modal-confirm'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPublishAsCustomizedPipeline).toHaveBeenCalledWith({
|
||||
pipelineId: '',
|
||||
name: 'Test Pipeline',
|
||||
icon_info: { type: 'emoji', emoji: '📚', background: '#fff' },
|
||||
description: 'Test description',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('API Calls and Async Operations', () => {
|
||||
@@ -607,7 +642,9 @@ describe('publisher', () => {
|
||||
it('should show success notification for publish as template', async () => {
|
||||
mockPublishedAt.mockReturnValue(1700000000)
|
||||
mockPublishAsCustomizedPipeline.mockResolvedValue({})
|
||||
renderWithQueryClient(<Popup />)
|
||||
renderWithQueryClient(<Publisher />)
|
||||
|
||||
fireEvent.click(screen.getByText('workflow.common.publish'))
|
||||
|
||||
const publishAsButton = screen.getAllByRole('button').find(btn =>
|
||||
btn.textContent?.includes('pipeline.common.publishAs'),
|
||||
@@ -633,7 +670,9 @@ describe('publisher', () => {
|
||||
it('should invalidate customized template list after publish as template', async () => {
|
||||
mockPublishedAt.mockReturnValue(1700000000)
|
||||
mockPublishAsCustomizedPipeline.mockResolvedValue({})
|
||||
renderWithQueryClient(<Popup />)
|
||||
renderWithQueryClient(<Publisher />)
|
||||
|
||||
fireEvent.click(screen.getByText('workflow.common.publish'))
|
||||
|
||||
const publishAsButton = screen.getAllByRole('button').find(btn =>
|
||||
btn.textContent?.includes('pipeline.common.publishAs'),
|
||||
@@ -686,7 +725,9 @@ describe('publisher', () => {
|
||||
it('should show error notification when publish as template fails', async () => {
|
||||
mockPublishedAt.mockReturnValue(1700000000)
|
||||
mockPublishAsCustomizedPipeline.mockRejectedValue(new Error('Template publish failed'))
|
||||
renderWithQueryClient(<Popup />)
|
||||
renderWithQueryClient(<Publisher />)
|
||||
|
||||
fireEvent.click(screen.getByText('workflow.common.publish'))
|
||||
|
||||
const publishAsButton = screen.getAllByRole('button').find(btn =>
|
||||
btn.textContent?.includes('pipeline.common.publishAs'),
|
||||
@@ -710,7 +751,9 @@ describe('publisher', () => {
|
||||
it('should close modal after publish as template error', async () => {
|
||||
mockPublishedAt.mockReturnValue(1700000000)
|
||||
mockPublishAsCustomizedPipeline.mockRejectedValue(new Error('Template publish failed'))
|
||||
renderWithQueryClient(<Popup />)
|
||||
renderWithQueryClient(<Publisher />)
|
||||
|
||||
fireEvent.click(screen.getByText('workflow.common.publish'))
|
||||
|
||||
const publishAsButton = screen.getAllByRole('button').find(btn =>
|
||||
btn.textContent?.includes('pipeline.common.publishAs'),
|
||||
@@ -1051,7 +1094,9 @@ describe('publisher', () => {
|
||||
it('should complete full publish as template flow', async () => {
|
||||
mockPublishedAt.mockReturnValue(1700000000)
|
||||
mockPublishAsCustomizedPipeline.mockResolvedValue({})
|
||||
renderWithQueryClient(<Popup />)
|
||||
renderWithQueryClient(<Publisher />)
|
||||
|
||||
fireEvent.click(screen.getByText('workflow.common.publish'))
|
||||
|
||||
const publishAsButton = screen.getAllByRole('button').find(btn =>
|
||||
btn.textContent?.includes('pipeline.common.publishAs'),
|
||||
|
||||
+8
-22
@@ -327,11 +327,18 @@ describe('Popup', () => {
|
||||
|
||||
it('should request closing the outer popover before opening publish-as modal', () => {
|
||||
const onRequestClose = vi.fn()
|
||||
render(<Popup onRequestClose={onRequestClose} />)
|
||||
const onShowPublishAsKnowledgePipelineModal = vi.fn()
|
||||
render(
|
||||
<Popup
|
||||
onRequestClose={onRequestClose}
|
||||
onShowPublishAsKnowledgePipelineModal={onShowPublishAsKnowledgePipelineModal}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('pipeline.common.publishAs'))
|
||||
|
||||
expect(onRequestClose).toHaveBeenCalledTimes(1)
|
||||
expect(onShowPublishAsKnowledgePipelineModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -352,27 +359,6 @@ describe('Popup', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Publish params', () => {
|
||||
it('should publish as template with empty pipeline id fallback', async () => {
|
||||
mockPipelineId = undefined
|
||||
mockUseBoolean
|
||||
.mockImplementationOnce((initial: boolean) => [initial, { setFalse: vi.fn(), setTrue: vi.fn() }])
|
||||
.mockImplementationOnce((initial: boolean) => [initial, { setFalse: vi.fn(), setTrue: vi.fn() }])
|
||||
.mockImplementationOnce(() => [true, { setFalse: vi.fn(), setTrue: vi.fn() }])
|
||||
.mockImplementationOnce((initial: boolean) => [initial, { setFalse: vi.fn(), setTrue: vi.fn() }])
|
||||
render(<Popup />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('publish-as-confirm'))
|
||||
|
||||
expect(mockPublishAsCustomizedPipeline).toHaveBeenCalledWith({
|
||||
pipelineId: '',
|
||||
name: 'My Pipeline',
|
||||
icon_info: { icon_type: 'emoji' },
|
||||
description: 'desc',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Time formatting', () => {
|
||||
it('should format published time', () => {
|
||||
render(<Popup />)
|
||||
|
||||
+89
-28
@@ -1,6 +1,8 @@
|
||||
import type { IconInfo } from '@/models/datasets'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import {
|
||||
@@ -10,6 +12,11 @@ import {
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNodesSyncDraft } from '@/app/components/workflow/hooks'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import Link from '@/next/link'
|
||||
import { useInvalidCustomizedTemplateList, usePublishAsCustomizedPipeline } from '@/service/use-pipeline'
|
||||
import PublishAsKnowledgePipelineModal from '../../publish-as-knowledge-pipeline-modal'
|
||||
import Popup from './popup'
|
||||
|
||||
const Publisher = () => {
|
||||
@@ -17,6 +24,12 @@ const Publisher = () => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [confirmVisible, { setFalse: hideConfirm, setTrue: showConfirm }] = useBoolean(false)
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const docLink = useDocLink()
|
||||
const pipelineId = useStore(s => s.pipelineId)
|
||||
const { mutateAsync: publishAsCustomizedPipeline } = usePublishAsCustomizedPipeline()
|
||||
const invalidCustomizedTemplateList = useInvalidCustomizedTemplateList()
|
||||
const [showPublishAsKnowledgePipelineModal, setShowPublishAsKnowledgePipelineModal] = useState(false)
|
||||
const [isPublishingAsCustomizedPipeline, setIsPublishingAsCustomizedPipeline] = useState(false)
|
||||
|
||||
const handleOpenChange = useCallback((newOpen: boolean) => {
|
||||
if (!newOpen && confirmVisible)
|
||||
@@ -28,38 +41,86 @@ const Publisher = () => {
|
||||
const closePopover = useCallback(() => {
|
||||
setOpen(false)
|
||||
}, [])
|
||||
const openPublishAsKnowledgePipelineModal = useCallback(() => {
|
||||
setShowPublishAsKnowledgePipelineModal(true)
|
||||
}, [])
|
||||
const hidePublishAsKnowledgePipelineModal = useCallback(() => {
|
||||
setShowPublishAsKnowledgePipelineModal(false)
|
||||
}, [])
|
||||
const handlePublishAsKnowledgePipeline = useCallback(async (name: string, icon: IconInfo, description?: string) => {
|
||||
try {
|
||||
setIsPublishingAsCustomizedPipeline(true)
|
||||
await publishAsCustomizedPipeline({
|
||||
pipelineId: pipelineId || '',
|
||||
name,
|
||||
icon_info: icon,
|
||||
description,
|
||||
})
|
||||
toast.success(t('publishTemplate.success.message', { ns: 'datasetPipeline' }), {
|
||||
description: (
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<span className="system-xs-regular text-text-secondary">
|
||||
{t('publishTemplate.success.tip', { ns: 'datasetPipeline' })}
|
||||
</span>
|
||||
<Link href={docLink()} target="_blank" className="inline-block system-xs-medium-uppercase text-text-accent">
|
||||
{t('publishTemplate.success.learnMore', { ns: 'datasetPipeline' })}
|
||||
</Link>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
invalidCustomizedTemplateList()
|
||||
}
|
||||
catch {
|
||||
toast.error(t('publishTemplate.error.message', { ns: 'datasetPipeline' }))
|
||||
}
|
||||
finally {
|
||||
setIsPublishingAsCustomizedPipeline(false)
|
||||
hidePublishAsKnowledgePipelineModal()
|
||||
}
|
||||
}, [docLink, hidePublishAsKnowledgePipelineModal, invalidCustomizedTemplateList, pipelineId, publishAsCustomizedPipeline, t])
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<PopoverTrigger
|
||||
nativeButton
|
||||
render={(
|
||||
<Button
|
||||
className="px-2"
|
||||
variant="primary"
|
||||
>
|
||||
<span className="pl-1">{t('common.publish', { ns: 'workflow' })}</span>
|
||||
<RiArrowDownSLine className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
alignOffset={40}
|
||||
popupClassName={cn('border-none bg-transparent shadow-none', confirmVisible && 'hidden')}
|
||||
<>
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<Popup
|
||||
onRequestClose={closePopover}
|
||||
confirmVisible={confirmVisible}
|
||||
onShowConfirm={showConfirm}
|
||||
onHideConfirm={hideConfirm}
|
||||
<PopoverTrigger
|
||||
nativeButton
|
||||
render={(
|
||||
<Button
|
||||
className="px-2"
|
||||
variant="primary"
|
||||
>
|
||||
<span className="pl-1">{t('common.publish', { ns: 'workflow' })}</span>
|
||||
<RiArrowDownSLine className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<PopoverContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
alignOffset={40}
|
||||
popupClassName={cn('border-none bg-transparent shadow-none', confirmVisible && 'hidden')}
|
||||
>
|
||||
<Popup
|
||||
onRequestClose={closePopover}
|
||||
confirmVisible={confirmVisible}
|
||||
onShowConfirm={showConfirm}
|
||||
onHideConfirm={hideConfirm}
|
||||
isPublishingAsCustomizedPipeline={isPublishingAsCustomizedPipeline}
|
||||
onShowPublishAsKnowledgePipelineModal={openPublishAsKnowledgePipelineModal}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{showPublishAsKnowledgePipelineModal && (
|
||||
<PublishAsKnowledgePipelineModal
|
||||
confirmDisabled={isPublishingAsCustomizedPipeline}
|
||||
onConfirm={handlePublishAsKnowledgePipeline}
|
||||
onCancel={hidePublishAsKnowledgePipelineModal}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { IconInfo } from '@/models/datasets'
|
||||
import type { PublishWorkflowParams } from '@/types/workflow'
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -25,7 +24,6 @@ import ShortcutsName from '@/app/components/workflow/shortcuts-name'
|
||||
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils'
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { useModalContextSelector } from '@/context/modal-context'
|
||||
import { useProviderContextSelector } from '@/context/provider-context'
|
||||
import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url'
|
||||
@@ -34,9 +32,8 @@ import Link from '@/next/link'
|
||||
import { useParams, useRouter } from '@/next/navigation'
|
||||
import { useInvalidDatasetList } from '@/service/knowledge/use-dataset'
|
||||
import { useInvalid } from '@/service/use-base'
|
||||
import { publishedPipelineInfoQueryKeyPrefix, useInvalidCustomizedTemplateList, usePublishAsCustomizedPipeline } from '@/service/use-pipeline'
|
||||
import { publishedPipelineInfoQueryKeyPrefix } from '@/service/use-pipeline'
|
||||
import { usePublishWorkflow } from '@/service/use-workflow'
|
||||
import PublishAsKnowledgePipelineModal from '../../publish-as-knowledge-pipeline-modal'
|
||||
|
||||
const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P']
|
||||
type PopupProps = {
|
||||
@@ -44,6 +41,8 @@ type PopupProps = {
|
||||
confirmVisible?: boolean
|
||||
onShowConfirm?: () => void
|
||||
onHideConfirm?: () => void
|
||||
isPublishingAsCustomizedPipeline?: boolean
|
||||
onShowPublishAsKnowledgePipelineModal?: () => void
|
||||
}
|
||||
|
||||
const Popup = ({
|
||||
@@ -51,11 +50,12 @@ const Popup = ({
|
||||
confirmVisible: controlledConfirmVisible,
|
||||
onShowConfirm,
|
||||
onHideConfirm,
|
||||
isPublishingAsCustomizedPipeline = false,
|
||||
onShowPublishAsKnowledgePipelineModal,
|
||||
}: PopupProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { datasetId } = useParams()
|
||||
const { push } = useRouter()
|
||||
const docLink = useDocLink()
|
||||
const publishedAt = useStore(s => s.publishedAt)
|
||||
const draftUpdatedAt = useStore(s => s.draftUpdatedAt)
|
||||
const pipelineId = useStore(s => s.pipelineId)
|
||||
@@ -73,9 +73,6 @@ const Popup = ({
|
||||
const showConfirm = onShowConfirm ?? showLocalConfirm
|
||||
const hideConfirm = onHideConfirm ?? hideLocalConfirm
|
||||
const [publishing, { setFalse: hidePublishing, setTrue: showPublishing }] = useBoolean(false)
|
||||
const { mutateAsync: publishAsCustomizedPipeline } = usePublishAsCustomizedPipeline()
|
||||
const [showPublishAsKnowledgePipelineModal, { setFalse: hidePublishAsKnowledgePipelineModal, setTrue: setShowPublishAsKnowledgePipelineModal }] = useBoolean(false)
|
||||
const [isPublishingAsCustomizedPipeline, { setFalse: hidePublishingAsCustomizedPipeline, setTrue: showPublishingAsCustomizedPipeline }] = useBoolean(false)
|
||||
const invalidPublishedPipelineInfo = useInvalid([...publishedPipelineInfoQueryKeyPrefix, pipelineId])
|
||||
const invalidDatasetList = useInvalidDatasetList()
|
||||
const handleHideConfirm = useCallback(() => {
|
||||
@@ -145,47 +142,15 @@ const Popup = ({
|
||||
const goToAddDocuments = useCallback(() => {
|
||||
push(`/datasets/${datasetId}/documents/create-from-pipeline`)
|
||||
}, [datasetId, push])
|
||||
const invalidCustomizedTemplateList = useInvalidCustomizedTemplateList()
|
||||
const handlePublishAsKnowledgePipeline = useCallback(async (name: string, icon: IconInfo, description?: string) => {
|
||||
try {
|
||||
showPublishingAsCustomizedPipeline()
|
||||
await publishAsCustomizedPipeline({
|
||||
pipelineId: pipelineId || '',
|
||||
name,
|
||||
icon_info: icon,
|
||||
description,
|
||||
})
|
||||
toast.success(t('publishTemplate.success.message', { ns: 'datasetPipeline' }), {
|
||||
description: (
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<span className="system-xs-regular text-text-secondary">
|
||||
{t('publishTemplate.success.tip', { ns: 'datasetPipeline' })}
|
||||
</span>
|
||||
<Link href={docLink()} target="_blank" className="inline-block system-xs-medium-uppercase text-text-accent">
|
||||
{t('publishTemplate.success.learnMore', { ns: 'datasetPipeline' })}
|
||||
</Link>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
invalidCustomizedTemplateList()
|
||||
}
|
||||
catch {
|
||||
toast.error(t('publishTemplate.error.message', { ns: 'datasetPipeline' }))
|
||||
}
|
||||
finally {
|
||||
hidePublishingAsCustomizedPipeline()
|
||||
hidePublishAsKnowledgePipelineModal()
|
||||
}
|
||||
}, [showPublishingAsCustomizedPipeline, publishAsCustomizedPipeline, pipelineId, t, invalidCustomizedTemplateList, hidePublishingAsCustomizedPipeline, hidePublishAsKnowledgePipelineModal, docLink])
|
||||
const handleClickPublishAsKnowledgePipeline = useCallback(() => {
|
||||
onRequestClose?.()
|
||||
if (!isAllowPublishAsCustomKnowledgePipelineTemplate) {
|
||||
setShowPricingModal()
|
||||
}
|
||||
else {
|
||||
setShowPublishAsKnowledgePipelineModal()
|
||||
onShowPublishAsKnowledgePipelineModal?.()
|
||||
}
|
||||
}, [isAllowPublishAsCustomKnowledgePipelineTemplate, onRequestClose, setShowPublishAsKnowledgePipelineModal, setShowPricingModal])
|
||||
}, [isAllowPublishAsCustomKnowledgePipelineTemplate, onRequestClose, onShowPublishAsKnowledgePipelineModal, setShowPricingModal])
|
||||
return (
|
||||
<div className={cn('rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-5', isAllowPublishAsCustomKnowledgePipelineTemplate ? 'w-[360px]' : 'w-[400px]')}>
|
||||
<div className="p-4 pt-3">
|
||||
@@ -279,7 +244,6 @@ const Popup = ({
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
{showPublishAsKnowledgePipelineModal && (<PublishAsKnowledgePipelineModal confirmDisabled={isPublishingAsCustomizedPipeline} onConfirm={handlePublishAsKnowledgePipeline} onCancel={hidePublishAsKnowledgePipelineModal} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { changePasswordWithToken } from '@/service/common'
|
||||
import { useVerifyForgotPasswordToken } from '@/service/use-common'
|
||||
import ChangePasswordForm from './ChangePasswordForm'
|
||||
|
||||
const mockReplace = vi.fn()
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useSearchParams: () => new URLSearchParams('token=url-token-t1'),
|
||||
useRouter: () => ({ replace: mockReplace }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useVerifyForgotPasswordToken: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/common', () => ({
|
||||
changePasswordWithToken: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/var', () => ({ basePath: '' }))
|
||||
|
||||
type UseVerifyResult = ReturnType<typeof useVerifyForgotPasswordToken>
|
||||
const mockUseVerify = vi.mocked(useVerifyForgotPasswordToken)
|
||||
const mockChangePassword = vi.mocked(changePasswordWithToken)
|
||||
|
||||
const VALID_PASSWORD = 'ValidPass123!'
|
||||
|
||||
describe('ChangePasswordForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('when token is valid', () => {
|
||||
const T2 = 'verified-token-t2'
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseVerify.mockReturnValue({
|
||||
data: { result: 'success', is_valid: true, email: 'user@example.com', token: T2 },
|
||||
refetch: vi.fn(),
|
||||
} as unknown as UseVerifyResult)
|
||||
})
|
||||
|
||||
it('renders the password form', () => {
|
||||
render(<ChangePasswordForm />)
|
||||
expect(screen.getByText('login.changePassword')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('submits with T2 (from validity response), NOT T1 (from URL)', async () => {
|
||||
mockChangePassword.mockResolvedValue({ result: 'success' })
|
||||
|
||||
render(<ChangePasswordForm />)
|
||||
|
||||
const inputs = Array.from(document.querySelectorAll<HTMLInputElement>('input[type="password"]')) as [HTMLInputElement, HTMLInputElement]
|
||||
fireEvent.change(inputs[0], { target: { value: VALID_PASSWORD } })
|
||||
fireEvent.change(inputs[1], { target: { value: VALID_PASSWORD } })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.reset/ }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockChangePassword).toHaveBeenCalledWith({
|
||||
url: '/forgot-password/resets',
|
||||
body: {
|
||||
token: T2,
|
||||
new_password: VALID_PASSWORD,
|
||||
password_confirm: VALID_PASSWORD,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('when token is invalid', () => {
|
||||
beforeEach(() => {
|
||||
mockUseVerify.mockReturnValue({
|
||||
data: { result: 'success', is_valid: false, email: '', token: '' },
|
||||
refetch: vi.fn(),
|
||||
} as unknown as UseVerifyResult)
|
||||
})
|
||||
|
||||
it('shows invalid token state and no form', () => {
|
||||
render(<ChangePasswordForm />)
|
||||
expect(screen.getByText('login.invalid')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /common\.operation\.reset/ })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -49,7 +49,7 @@ const ChangePasswordForm = () => {
|
||||
}, [password, confirmPassword, showErrorMessage, t])
|
||||
|
||||
const handleChangePassword = useCallback(async () => {
|
||||
const token = searchParams.get('token') || ''
|
||||
const resetToken = verifyTokenRes?.token ?? ''
|
||||
|
||||
if (!valid())
|
||||
return
|
||||
@@ -57,7 +57,7 @@ const ChangePasswordForm = () => {
|
||||
await changePasswordWithToken({
|
||||
url: '/forgot-password/resets',
|
||||
body: {
|
||||
token,
|
||||
token: resetToken,
|
||||
new_password: password,
|
||||
password_confirm: confirmPassword,
|
||||
},
|
||||
@@ -67,7 +67,7 @@ const ChangePasswordForm = () => {
|
||||
catch {
|
||||
await revalidateToken()
|
||||
}
|
||||
}, [confirmPassword, password, revalidateToken, searchParams, valid])
|
||||
}, [confirmPassword, password, revalidateToken, verifyTokenRes?.token, valid])
|
||||
|
||||
return (
|
||||
<div className={
|
||||
|
||||
@@ -48,7 +48,7 @@ export const tagUpdateContract = base
|
||||
name: string
|
||||
}
|
||||
}>())
|
||||
.output(type<unknown>())
|
||||
.output(type<Tag>())
|
||||
|
||||
export const tagDeleteContract = base
|
||||
.route({
|
||||
|
||||
@@ -30,7 +30,6 @@ export const TagItemEditor = ({ tag, onTagsChange }: TagItemEditorProps) => {
|
||||
const updateTagMutation = useMutation(consoleQuery.tags.update.mutationOptions())
|
||||
const deleteTagMutation = useMutation(consoleQuery.tags.delete.mutationOptions())
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [name, setName] = useState(tag.name)
|
||||
const editTag = (tagId: string, name: string) => {
|
||||
if (name === tag.name) {
|
||||
setIsEditing(false)
|
||||
@@ -38,7 +37,6 @@ export const TagItemEditor = ({ tag, onTagsChange }: TagItemEditorProps) => {
|
||||
}
|
||||
if (!name) {
|
||||
toast.error('tag name is empty')
|
||||
setName(tag.name)
|
||||
setIsEditing(false)
|
||||
return
|
||||
}
|
||||
@@ -53,13 +51,11 @@ export const TagItemEditor = ({ tag, onTagsChange }: TagItemEditorProps) => {
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
|
||||
setName(name)
|
||||
setIsEditing(false)
|
||||
onTagsChange?.()
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }))
|
||||
setName(tag.name)
|
||||
setIsEditing(false)
|
||||
},
|
||||
})
|
||||
@@ -123,7 +119,22 @@ export const TagItemEditor = ({ tag, onTagsChange }: TagItemEditorProps) => {
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{isEditing && (<input aria-label={`${t('operation.rename', { ns: 'common' })} ${tag.name}`} className="shrink-0 appearance-none caret-primary-600 outline-hidden placeholder:text-text-quaternary" autoFocus value={name} onChange={e => setName(e.target.value)} onKeyDown={e => e.key === 'Enter' && editTag(tag.id, name)} onBlur={() => editTag(tag.id, name)} />)}
|
||||
{isEditing && (
|
||||
<input
|
||||
aria-label={`${t('operation.rename', { ns: 'common' })} ${tag.name}`}
|
||||
className="shrink-0 appearance-none caret-primary-600 outline-hidden placeholder:text-text-quaternary"
|
||||
autoFocus
|
||||
defaultValue={tag.name}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== 'Enter' || e.nativeEvent.isComposing)
|
||||
return
|
||||
|
||||
e.preventDefault()
|
||||
e.currentTarget.blur()
|
||||
}}
|
||||
onBlur={e => editTag(tag.id, e.currentTarget.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<AlertDialog open={showRemoveModal} onOpenChange={open => !open && setShowRemoveModal(false)}>
|
||||
<AlertDialogContent>
|
||||
|
||||
@@ -187,15 +187,20 @@ describe('consoleQuery tag mutation defaults', () => {
|
||||
queryClient.setQueryData(appListKey, [targetTag, otherTag])
|
||||
queryClient.setQueryData(knowledgeListKey, [knowledgeTag])
|
||||
|
||||
const updatedTag = createTag({
|
||||
...targetTag,
|
||||
name: 'After',
|
||||
binding_count: 5,
|
||||
})
|
||||
const mutationOptions = consoleQuery.tags.update.mutationOptions()
|
||||
await mutationOptions.onSuccess?.(
|
||||
undefined,
|
||||
updatedTag,
|
||||
{
|
||||
params: {
|
||||
tagId: targetTag.id,
|
||||
},
|
||||
body: {
|
||||
name: 'After',
|
||||
name: 'Ignored Client Name',
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
@@ -203,10 +208,7 @@ describe('consoleQuery tag mutation defaults', () => {
|
||||
)
|
||||
|
||||
expect(queryClient.getQueryData(appListKey)).toEqual([
|
||||
{
|
||||
...targetTag,
|
||||
name: 'After',
|
||||
},
|
||||
updatedTag,
|
||||
otherTag,
|
||||
])
|
||||
expect(queryClient.getQueryData(knowledgeListKey)).toEqual([knowledgeTag])
|
||||
|
||||
@@ -108,16 +108,13 @@ export const consoleQuery = createTanstackQueryUtils(consoleClient, {
|
||||
},
|
||||
update: {
|
||||
mutationOptions: {
|
||||
onSuccess: (_data, variables, _onMutateResult, context) => {
|
||||
onSuccess: (updatedTag, variables, _onMutateResult, context) => {
|
||||
context.client.setQueriesData(
|
||||
{
|
||||
queryKey: consoleQuery.tags.list.key({ type: 'query' }),
|
||||
},
|
||||
(oldTags: Tag[] | undefined) => oldTags?.map(tag => tag.id === variables.params.tagId
|
||||
? {
|
||||
...tag,
|
||||
name: variables.body.name,
|
||||
}
|
||||
? updatedTag
|
||||
: tag),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -246,7 +246,7 @@ export const useLogout = () => {
|
||||
})
|
||||
}
|
||||
|
||||
type ForgotPasswordValidity = CommonResponse & { is_valid: boolean, email: string }
|
||||
type ForgotPasswordValidity = CommonResponse & { is_valid: boolean, email: string, token: string }
|
||||
export const useVerifyForgotPasswordToken = (token?: string | null) => {
|
||||
return useQuery<ForgotPasswordValidity>({
|
||||
queryKey: commonQueryKeys.forgotPasswordValidity(token),
|
||||
|
||||
Reference in New Issue
Block a user