Compare commits

...
Author SHA1 Message Date
QuantumGhostandYunlu Wen 7b551da084 chore(api): polishhelp output for legacy-model-types migration script (#36707) 2026-05-27 12:19:59 +08:00
QuantumGhostandYunlu Wen 9040f7fc47 feat(api): introduce model-type migration script (#36520) 2026-05-27 12:19:59 +08:00
QuantumGhostandYunlu Wen 5dd82d6039 fix(api): fix invalid token error while changing email (#36412) 2026-05-21 14:42:22 +08:00
vukoandYunlu Wen 2cb6003823 fix(auth): preserve phase field in _TokenData so reset-password / change-email phase-bound checks don't 400 (#36116) (#36117)
Signed-off-by: vuko <alexander.vukovic@seqis.com>
2026-05-21 14:42:22 +08:00
GareArcandYunlu Wen 2f73bb8c0e fix(auth): use validity-returned token in ChangePasswordForm reset submit [cherry-pick → hotfix/1.14.1-fix.3]
Cherry-pick of #36415.

The two-phase token flow (T1 → /validity → T2 with phase="reset" → /resets)
was broken because ForgotPasswordValidity type was missing the `token` field,
so T2 was discarded and T1 (from URL) was re-sent to /resets. Backend rejects
T1 at /resets because it requires phase="reset". Fix: add `token` to the type
and use verifyTokenRes.token in handleChangePassword.

Adds a test that asserts T2 (validity response token) is submitted, not T1
(URL token).

Fixes ENG-423.
2026-05-21 13:11:35 +08:00
GareArcandYunlu Wen a79ee941c4 fix(offline): guard marketplace I/O paths for ENG-421
Skip marketplace fetches, metadata lookups, and bulk-install
pre-checks when MARKETPLACE_ENABLED=false. Covers plugin_service,
plugin_migration, rag_pipeline, and rag_pipeline_transform_service.
2026-05-20 14:56:42 +08:00
CodingOnStar fe46ecbc54 fix(dialog): constrain height for plugin installation dialogs to improve scrolling behavior 2026-05-15 11:12:05 +08:00
CodingOnStar 44b75ccdf0 refactor(install): improve layout and scrolling behavior for plugin installation step 2026-05-15 11:11:43 +08:00
yyhyyhautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
7da1b4cf04 fix: allow tag rename without type payload (#36182)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-15 10:17:15 +08:00
FFXNGitHubgemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>Copilot Autofix powered by AI-LAN-
82a5af229d fix(api): allow LLM nodes to access retrieved knowledge files (#36195)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: -LAN- <laipz8200@outlook.com>
2026-05-15 10:00:27 +08:00
CodingOnStar 43373e424f feat(MessageLogModal): refactor modal structure and improve tab handling
- Replaced floating modal implementation with a Dialog component for better accessibility and styling.
- Introduced a type guard for active tab validation, ensuring only valid tabs are set.
- Updated modal rendering logic to handle fixed and dynamic widths more effectively.
- Adjusted tests to reflect changes in modal behavior and structure.
2026-05-14 17:03:47 +08:00
非法操作 c46f2a1b34 fix: get recommend_app categories should not re-order it (#36161) 2026-05-14 14:46:54 +08:00
FFXNGitHubgemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>Copilot Autofix powered by AI
5d8c9835f9 fix(api): avoid committing inside RAG pipeline DSL service. (#36150)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-14 11:43:38 +08:00
非法操作 a6f5e3b2f8 fix: credit pool access outside flask context (#36143) 2026-05-14 11:03:07 +08:00
FFXNGitHubgemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>Copilot Autofix powered by AI
61434ba03e fix: knowledge hit-testing render failed. (#36106) (#36146)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-14 10:51:17 +08:00
林玮 (Jade Lin) 761f1f3988 refactor: gracefully handle credential fetch failures in rag pipeline 2026-05-14 10:17:51 +08:00
林玮 (Jade Lin) a5368478f5 refactor: extract credential refresh logic and skip failed providers in bulk fetch 2026-05-14 09:59:53 +08:00
Xiyuan ChenandGitHub 7e5c65175a fix(security): isolate Langfuse v3 SDK TracerProvider to prevent cros… (#36107) 2026-05-13 00:05:15 -07:00
CodingOnStar 09690fbb7d fix: remove unnecessary 'relative' class from DialogContent in install-bundle and publish-as-knowledge-pipeline-modal components 2026-05-12 19:02:13 +08:00
CodingOnStar 1144060a39 refactor: clean up DialogContent styles and enhance Publisher component functionality
- Removed unnecessary 'relative' class from DialogContent in InstallBundle and PublishAsKnowledgePipelineModal components for cleaner styling.
- Enhanced Publisher component to manage the state and functionality for publishing as a knowledge pipeline, including modal handling and API integration.
- Updated tests to reflect changes in the Publisher component and ensure proper functionality of the publish-as modal.
2026-05-12 19:01:20 +08:00
JoelandCodingOnStar 6a7ec862b1 fix: can not create empty knowledge 2026-05-12 18:08:56 +08:00
84 changed files with 8035 additions and 527 deletions
+2 -1
View File
@@ -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
View File
@@ -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",
+179
View File
@@ -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)
),
}
+8 -3
View File
@@ -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)
+30 -50
View File
@@ -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)
+11 -1
View File
@@ -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",
]
+13 -3
View File
@@ -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,
+56 -4
View File
@@ -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,
+37 -2
View File
@@ -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(
+20 -2
View File
@@ -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={
+2
View File
@@ -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
View File
@@ -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:
+7 -1
View File
@@ -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
@@ -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",
+51 -32
View File
@@ -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:
+5 -4
View File
@@ -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)
+133 -67
View File
@@ -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
+139 -1
View File
@@ -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"),
]
+57 -1
View File
@@ -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
+8
View File
@@ -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
+32 -24
View File
@@ -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:
+7 -1
View File
@@ -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
-1
View File
@@ -21,7 +21,6 @@ class SaveTagPayload(BaseModel):
class UpdateTagPayload(BaseModel):
name: str = Field(min_length=1, max_length=50)
type: TagType
class TagBindingCreatePayload(BaseModel):
+1
View File
@@ -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())
@@ -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">
@@ -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)
@@ -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">
@@ -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' })}
@@ -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'),
@@ -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 />)
@@ -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={
+1 -1
View File
@@ -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>
+8 -6
View File
@@ -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])
+2 -5
View File
@@ -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),
)
},
+1 -1
View File
@@ -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),