Compare commits

..

14 Commits

Author SHA1 Message Date
Maries
8a7d997a7f Merge branch 'main' into fix/surface-subscription-deletion-errors 2025-12-31 14:57:55 +08:00
wangxiaolei
fa69cce1e7 fix: fix create app xss issue (#30305)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-31 15:57:39 +09:00
yyh
f28a08a696 fix: correct useEducationStatus query cache configuration (#30416) 2025-12-31 13:51:05 +08:00
QuantumGhost
8129b04143 fix(web): enable JSON_OBJECT type support in console UI (#30412)
Co-authored-by: zhsama <torvalds@linux.do>
2025-12-31 13:38:16 +08:00
DevByteAI
1b8e80a722 fix: Ensure chat history refreshes when switching back to conversations (#30389) 2025-12-31 13:28:25 +08:00
Maries
8295134c18 Merge branch 'main' into fix/surface-subscription-deletion-errors 2025-12-30 18:12:48 +08:00
Maries
bf431fb9d3 Merge branch 'main' into fix/surface-subscription-deletion-errors 2025-12-30 14:49:55 +08:00
Maries
8c8c79b6ba Merge branch 'main' into fix/surface-subscription-deletion-errors 2025-12-30 12:12:46 +08:00
Harry
630b9d0145 refactor(api): remove unsupported credential type test from TriggerProviderService
- Deleted the test for rebuilding trigger subscriptions with unsupported credential types, as it was deemed unnecessary.
- This change helps streamline the test suite by focusing on relevant scenarios.
2025-12-30 12:11:47 +08:00
Harry
b6b7ff0aeb fix(api): fix basedpyright checks 2025-12-29 20:02:25 +08:00
Harry
16aa9254ad doc(api): improve comments for better understanding in TriggerSubscriptionUpdateApi and TriggerProviderService 2025-12-29 19:58:42 +08:00
Harry
1ddaece735 doc(api): enhance comments for clarity in TriggerSubscriptionUpdateApi and TriggerProviderService 2025-12-29 19:57:07 +08:00
Harry
9e990c5ccd feat(api): add validation to ensure at least one field is provided in TriggerSubscriptionUpdateRequest
- Introduced a model validator in TriggerSubscriptionUpdateRequest to enforce that at least one of the fields (name, credentials, parameters, properties) must be provided.
- Refactored the TriggerSubscriptionUpdateApi to use the validated request object and simplified the logic for updating subscriptions based on the credential type.
- Updated the credential type check in TriggerProviderService to use a set for better performance and clarity.
2025-12-29 19:49:24 +08:00
Harry
b61fd8fcff fix(api): surface subscription deletion errors to users
Previously, when rebuilding a trigger subscription, errors from the
unsubscribe operation were silently caught and logged without
propagating to the user. This left users unaware of failures during
subscription management.

Changes:
- Check UnsubscribeResult.success and raise ValueError with the error
  message when unsubscribe fails
- Simplify the rebuild logic by removing unnecessary try/except wrapper
- Refactor update API to use cleaner conditional logic
- Remove redundant test cases that tested silent error handling
2025-12-29 18:01:49 +08:00
42 changed files with 724 additions and 756 deletions

View File

@@ -1,3 +1,4 @@
import re
import uuid
from typing import Literal
@@ -73,6 +74,48 @@ class AppListQuery(BaseModel):
raise ValueError("Invalid UUID format in tag_ids.") from exc
# XSS prevention: patterns that could lead to XSS attacks
# Includes: script tags, iframe tags, javascript: protocol, SVG with onload, etc.
_XSS_PATTERNS = [
r"<script[^>]*>.*?</script>", # Script tags
r"<iframe\b[^>]*?(?:/>|>.*?</iframe>)", # Iframe tags (including self-closing)
r"javascript:", # JavaScript protocol
r"<svg[^>]*?\s+onload\s*=[^>]*>", # SVG with onload handler (attribute-aware, flexible whitespace)
r"<.*?on\s*\w+\s*=", # Event handlers like onclick, onerror, etc.
r"<object\b[^>]*(?:\s*/>|>.*?</object\s*>)", # Object tags (opening tag)
r"<embed[^>]*>", # Embed tags (self-closing)
r"<link[^>]*>", # Link tags with javascript
]
def _validate_xss_safe(value: str | None, field_name: str = "Field") -> str | None:
"""
Validate that a string value doesn't contain potential XSS payloads.
Args:
value: The string value to validate
field_name: Name of the field for error messages
Returns:
The original value if safe
Raises:
ValueError: If the value contains XSS patterns
"""
if value is None:
return None
value_lower = value.lower()
for pattern in _XSS_PATTERNS:
if re.search(pattern, value_lower, re.DOTALL | re.IGNORECASE):
raise ValueError(
f"{field_name} contains invalid characters or patterns. "
"HTML tags, JavaScript, and other potentially dangerous content are not allowed."
)
return value
class CreateAppPayload(BaseModel):
name: str = Field(..., min_length=1, description="App name")
description: str | None = Field(default=None, description="App description (max 400 chars)", max_length=400)
@@ -81,6 +124,11 @@ class CreateAppPayload(BaseModel):
icon: str | None = Field(default=None, description="Icon")
icon_background: str | None = Field(default=None, description="Icon background color")
@field_validator("name", "description", mode="before")
@classmethod
def validate_xss_safe(cls, value: str | None, info) -> str | None:
return _validate_xss_safe(value, info.field_name)
class UpdateAppPayload(BaseModel):
name: str = Field(..., min_length=1, description="App name")
@@ -91,6 +139,11 @@ class UpdateAppPayload(BaseModel):
use_icon_as_answer_icon: bool | None = Field(default=None, description="Use icon as answer icon")
max_active_requests: int | None = Field(default=None, description="Maximum active requests")
@field_validator("name", "description", mode="before")
@classmethod
def validate_xss_safe(cls, value: str | None, info) -> str | None:
return _validate_xss_safe(value, info.field_name)
class CopyAppPayload(BaseModel):
name: str | None = Field(default=None, description="Name for the copied app")
@@ -99,6 +152,11 @@ class CopyAppPayload(BaseModel):
icon: str | None = Field(default=None, description="Icon")
icon_background: str | None = Field(default=None, description="Icon background color")
@field_validator("name", "description", mode="before")
@classmethod
def validate_xss_safe(cls, value: str | None, info) -> str | None:
return _validate_xss_safe(value, info.field_name)
class AppExportQuery(BaseModel):
include_secret: bool = Field(default=False, description="Include secrets in export")

View File

@@ -4,12 +4,11 @@ from typing import Any
from flask import make_response, redirect, request
from flask_restx import Resource, reqparse
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator
from sqlalchemy.orm import Session
from werkzeug.exceptions import BadRequest, Forbidden
from configs import dify_config
from constants import HIDDEN_VALUE, UNKNOWN_VALUE
from controllers.web.error import NotFoundError
from core.model_runtime.utils.encoders import jsonable_encoder
from core.plugin.entities.plugin_daemon import CredentialType
@@ -44,6 +43,12 @@ class TriggerSubscriptionUpdateRequest(BaseModel):
parameters: Mapping[str, Any] | None = Field(default=None, description="The parameters for the subscription")
properties: Mapping[str, Any] | None = Field(default=None, description="The properties for the subscription")
@model_validator(mode="after")
def check_at_least_one_field(self):
if all(v is None for v in (self.name, self.credentials, self.parameters, self.properties)):
raise ValueError("At least one of name, credentials, parameters, or properties must be provided")
return self
class TriggerSubscriptionVerifyRequest(BaseModel):
"""Request payload for verifying subscription credentials."""
@@ -333,7 +338,7 @@ class TriggerSubscriptionUpdateApi(Resource):
user = current_user
assert user.current_tenant_id is not None
args = TriggerSubscriptionUpdateRequest.model_validate(console_ns.payload)
request = TriggerSubscriptionUpdateRequest.model_validate(console_ns.payload)
subscription = TriggerProviderService.get_subscription_by_id(
tenant_id=user.current_tenant_id,
@@ -345,50 +350,32 @@ class TriggerSubscriptionUpdateApi(Resource):
provider_id = TriggerProviderID(subscription.provider_id)
try:
# rename only
if (
args.name is not None
and args.credentials is None
and args.parameters is None
and args.properties is None
):
# For rename only, just update the name
rename = request.name is not None and not any((request.credentials, request.parameters, request.properties))
# When credential type is UNAUTHORIZED, it indicates the subscription was manually created
# For Manually created subscription, they dont have credentials, parameters
# They only have name and properties(which is input by user)
manually_created = subscription.credential_type == CredentialType.UNAUTHORIZED
if rename or manually_created:
TriggerProviderService.update_trigger_subscription(
tenant_id=user.current_tenant_id,
subscription_id=subscription_id,
name=args.name,
name=request.name,
properties=request.properties,
)
return 200
# rebuild for create automatically by the provider
match subscription.credential_type:
case CredentialType.UNAUTHORIZED:
TriggerProviderService.update_trigger_subscription(
tenant_id=user.current_tenant_id,
subscription_id=subscription_id,
name=args.name,
properties=args.properties,
)
return 200
case CredentialType.API_KEY | CredentialType.OAUTH2:
if args.credentials:
new_credentials: dict[str, Any] = {
key: value if value != HIDDEN_VALUE else subscription.credentials.get(key, UNKNOWN_VALUE)
for key, value in args.credentials.items()
}
else:
new_credentials = subscription.credentials
TriggerProviderService.rebuild_trigger_subscription(
tenant_id=user.current_tenant_id,
name=args.name,
provider_id=provider_id,
subscription_id=subscription_id,
credentials=new_credentials,
parameters=args.parameters or subscription.parameters,
)
return 200
case _:
raise BadRequest("Invalid credential type")
# For the rest cases(API_KEY, OAUTH2)
# we need to call third party provider(e.g. GitHub) to rebuild the subscription
TriggerProviderService.rebuild_trigger_subscription(
tenant_id=user.current_tenant_id,
name=request.name,
provider_id=provider_id,
subscription_id=subscription_id,
credentials=request.credentials or subscription.credentials,
parameters=request.parameters or subscription.parameters,
)
return 200
except ValueError as e:
raise BadRequest(str(e))
except Exception as e:

View File

@@ -853,7 +853,7 @@ class TriggerProviderService:
"""
Create a subscription builder for rebuilding an existing subscription.
This method creates a builder pre-filled with data from the rebuild request,
This method rebuild the subscription by call DELETE and CREATE API of the third party provider(e.g. GitHub)
keeping the same subscription_id and endpoint_id so the webhook URL remains unchanged.
:param tenant_id: Tenant ID
@@ -868,111 +868,50 @@ class TriggerProviderService:
if not provider_controller:
raise ValueError(f"Provider {provider_id} not found")
# Use distributed lock to prevent race conditions on the same subscription
lock_key = f"trigger_subscription_rebuild_lock:{tenant_id}_{subscription_id}"
with redis_client.lock(lock_key, timeout=20):
with Session(db.engine, expire_on_commit=False) as session:
try:
# Get subscription within the transaction
subscription: TriggerSubscription | None = (
session.query(TriggerSubscription).filter_by(tenant_id=tenant_id, id=subscription_id).first()
)
if not subscription:
raise ValueError(f"Subscription {subscription_id} not found")
subscription = TriggerProviderService.get_subscription_by_id(
tenant_id=tenant_id,
subscription_id=subscription_id,
)
if not subscription:
raise ValueError(f"Subscription {subscription_id} not found")
credential_type = CredentialType.of(subscription.credential_type)
if credential_type not in [CredentialType.OAUTH2, CredentialType.API_KEY]:
raise ValueError("Credential type not supported for rebuild")
credential_type = CredentialType.of(subscription.credential_type)
if credential_type not in {CredentialType.OAUTH2, CredentialType.API_KEY}:
raise ValueError(f"Credential type {credential_type} not supported for auto creation")
# Decrypt existing credentials for merging
credential_encrypter, _ = create_trigger_provider_encrypter_for_subscription(
tenant_id=tenant_id,
controller=provider_controller,
subscription=subscription,
)
decrypted_credentials = dict(credential_encrypter.decrypt(subscription.credentials))
# Delete the previous subscription
user_id = subscription.user_id
unsubscribe_result = TriggerManager.unsubscribe_trigger(
tenant_id=tenant_id,
user_id=user_id,
provider_id=provider_id,
subscription=subscription.to_entity(),
credentials=subscription.credentials,
credential_type=credential_type,
)
if not unsubscribe_result.success:
raise ValueError(f"Failed to delete previous subscription: {unsubscribe_result.message}")
# Merge credentials: if caller passed HIDDEN_VALUE, retain existing decrypted value
merged_credentials: dict[str, Any] = {
key: value if value != HIDDEN_VALUE else decrypted_credentials.get(key, UNKNOWN_VALUE)
for key, value in credentials.items()
}
user_id = subscription.user_id
# TODO: Trying to invoke update api of the plugin trigger provider
# FALLBACK: If the update api is not implemented,
# delete the previous subscription and create a new one
# Unsubscribe the previous subscription (external call, but we'll handle errors)
try:
TriggerManager.unsubscribe_trigger(
tenant_id=tenant_id,
user_id=user_id,
provider_id=provider_id,
subscription=subscription.to_entity(),
credentials=decrypted_credentials,
credential_type=credential_type,
)
except Exception as e:
logger.exception("Error unsubscribing trigger during rebuild", exc_info=e)
# Continue anyway - the subscription might already be deleted externally
# Create a new subscription with the same subscription_id and endpoint_id (external call)
new_subscription: TriggerSubscriptionEntity = TriggerManager.subscribe_trigger(
tenant_id=tenant_id,
user_id=user_id,
provider_id=provider_id,
endpoint=generate_plugin_trigger_endpoint_url(subscription.endpoint_id),
parameters=parameters,
credentials=merged_credentials,
credential_type=credential_type,
)
# Update the subscription in the same transaction
# Inline update logic to reuse the same session
if name is not None and name != subscription.name:
existing = (
session.query(TriggerSubscription)
.filter_by(tenant_id=tenant_id, provider_id=str(provider_id), name=name)
.first()
)
if existing and existing.id != subscription.id:
raise ValueError(f"Subscription name '{name}' already exists for this provider")
subscription.name = name
# Update parameters
subscription.parameters = dict(parameters)
# Update credentials with merged (and encrypted) values
subscription.credentials = dict(credential_encrypter.encrypt(merged_credentials))
# Update properties
if new_subscription.properties:
properties_encrypter, _ = create_provider_encrypter(
tenant_id=tenant_id,
config=provider_controller.get_properties_schema(),
cache=NoOpProviderCredentialCache(),
)
subscription.properties = dict(properties_encrypter.encrypt(dict(new_subscription.properties)))
# Update expiration timestamp
if new_subscription.expires_at is not None:
subscription.expires_at = new_subscription.expires_at
# Commit the transaction
session.commit()
# Clear subscription cache
delete_cache_for_subscription(
tenant_id=tenant_id,
provider_id=subscription.provider_id,
subscription_id=subscription.id,
)
except Exception as e:
# Rollback on any error
session.rollback()
logger.exception("Failed to rebuild trigger subscription", exc_info=e)
raise
# Create a new subscription with the same subscription_id and endpoint_id
new_credentials: dict[str, Any] = {
key: value if value != HIDDEN_VALUE else subscription.credentials.get(key, UNKNOWN_VALUE)
for key, value in credentials.items()
}
new_subscription: TriggerSubscriptionEntity = TriggerManager.subscribe_trigger(
tenant_id=tenant_id,
user_id=user_id,
provider_id=provider_id,
endpoint=generate_plugin_trigger_endpoint_url(subscription.endpoint_id),
parameters=parameters,
credentials=new_credentials,
credential_type=credential_type,
)
TriggerProviderService.update_trigger_subscription(
tenant_id=tenant_id,
subscription_id=subscription.id,
name=name,
parameters=parameters,
credentials=new_credentials,
properties=new_subscription.properties,
expires_at=new_subscription.expires_at,
)

View File

@@ -474,64 +474,6 @@ class TestTriggerProviderService:
assert subscription.name == original_name
assert subscription.parameters == original_parameters
def test_rebuild_trigger_subscription_unsubscribe_error_continues(
self, db_session_with_containers, mock_external_service_dependencies
):
"""
Test that unsubscribe errors are handled gracefully and operation continues.
This test verifies:
- Unsubscribe errors are caught and logged but don't stop the rebuild
- Rebuild continues even if unsubscribe fails
"""
fake = Faker()
account, tenant = self._create_test_account_and_tenant(
db_session_with_containers, mock_external_service_dependencies
)
provider_id = TriggerProviderID("test_org/test_plugin/test_provider")
credential_type = CredentialType.API_KEY
original_credentials = {"api_key": "original-key"}
subscription = self._create_test_subscription(
db_session_with_containers,
tenant.id,
account.id,
provider_id,
credential_type,
original_credentials,
mock_external_service_dependencies,
)
# Make unsubscribe_trigger raise an error (should be caught and continue)
mock_external_service_dependencies["trigger_manager"].unsubscribe_trigger.side_effect = ValueError(
"Unsubscribe failed"
)
new_subscription_entity = TriggerSubscriptionEntity(
endpoint=subscription.endpoint_id,
parameters={},
properties={},
expires_at=-1,
)
mock_external_service_dependencies["trigger_manager"].subscribe_trigger.return_value = new_subscription_entity
# Execute rebuild - should succeed despite unsubscribe error
TriggerProviderService.rebuild_trigger_subscription(
tenant_id=tenant.id,
provider_id=provider_id,
subscription_id=subscription.id,
credentials={"api_key": "new-key"},
parameters={},
)
# Verify subscribe was still called (operation continued)
mock_external_service_dependencies["trigger_manager"].subscribe_trigger.assert_called_once()
# Verify subscription was updated
db.session.refresh(subscription)
assert subscription.parameters == {}
def test_rebuild_trigger_subscription_subscription_not_found(
self, db_session_with_containers, mock_external_service_dependencies
):
@@ -558,70 +500,6 @@ class TestTriggerProviderService:
parameters={},
)
def test_rebuild_trigger_subscription_provider_not_found(
self, db_session_with_containers, mock_external_service_dependencies
):
"""
Test error when provider is not found.
This test verifies:
- Proper error is raised when provider doesn't exist
"""
fake = Faker()
account, tenant = self._create_test_account_and_tenant(
db_session_with_containers, mock_external_service_dependencies
)
provider_id = TriggerProviderID("non_existent_org/non_existent_plugin/non_existent_provider")
# Make get_trigger_provider return None
mock_external_service_dependencies["trigger_manager"].get_trigger_provider.return_value = None
with pytest.raises(ValueError, match="Provider.*not found"):
TriggerProviderService.rebuild_trigger_subscription(
tenant_id=tenant.id,
provider_id=provider_id,
subscription_id=fake.uuid4(),
credentials={},
parameters={},
)
def test_rebuild_trigger_subscription_unsupported_credential_type(
self, db_session_with_containers, mock_external_service_dependencies
):
"""
Test error when credential type is not supported for rebuild.
This test verifies:
- Proper error is raised for unsupported credential types (not OAUTH2 or API_KEY)
"""
fake = Faker()
account, tenant = self._create_test_account_and_tenant(
db_session_with_containers, mock_external_service_dependencies
)
provider_id = TriggerProviderID("test_org/test_plugin/test_provider")
credential_type = CredentialType.UNAUTHORIZED # Not supported
subscription = self._create_test_subscription(
db_session_with_containers,
tenant.id,
account.id,
provider_id,
credential_type,
{},
mock_external_service_dependencies,
)
with pytest.raises(ValueError, match="Credential type not supported for rebuild"):
TriggerProviderService.rebuild_trigger_subscription(
tenant_id=tenant.id,
provider_id=provider_id,
subscription_id=subscription.id,
credentials={},
parameters={},
)
def test_rebuild_trigger_subscription_name_uniqueness_check(
self, db_session_with_containers, mock_external_service_dependencies
):

View File

@@ -0,0 +1,254 @@
"""
Unit tests for XSS prevention in App payloads.
This test module validates that HTML tags, JavaScript, and other potentially
dangerous content are rejected in App names and descriptions.
"""
import pytest
from controllers.console.app.app import CopyAppPayload, CreateAppPayload, UpdateAppPayload
class TestXSSPreventionUnit:
"""Unit tests for XSS prevention in App payloads."""
def test_create_app_valid_names(self):
"""Test CreateAppPayload with valid app names."""
# Normal app names should be valid
valid_names = [
"My App",
"Test App 123",
"App with - dash",
"App with _ underscore",
"App with + plus",
"App with () parentheses",
"App with [] brackets",
"App with {} braces",
"App with ! exclamation",
"App with @ at",
"App with # hash",
"App with $ dollar",
"App with % percent",
"App with ^ caret",
"App with & ampersand",
"App with * asterisk",
"Unicode: 测试应用",
"Emoji: 🤖",
"Mixed: Test 测试 123",
]
for name in valid_names:
payload = CreateAppPayload(
name=name,
mode="chat",
)
assert payload.name == name
def test_create_app_xss_script_tags(self):
"""Test CreateAppPayload rejects script tags."""
xss_payloads = [
"<script>alert(document.cookie)</script>",
"<Script>alert(1)</Script>",
"<SCRIPT>alert('XSS')</SCRIPT>",
"<script>alert(String.fromCharCode(88,83,83))</script>",
"<script src='evil.js'></script>",
"<script>document.location='http://evil.com'</script>",
]
for name in xss_payloads:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_xss_iframe_tags(self):
"""Test CreateAppPayload rejects iframe tags."""
xss_payloads = [
"<iframe src='evil.com'></iframe>",
"<Iframe srcdoc='<script>alert(1)</script>'></iframe>",
"<IFRAME src='javascript:alert(1)'></iframe>",
]
for name in xss_payloads:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_xss_javascript_protocol(self):
"""Test CreateAppPayload rejects javascript: protocol."""
xss_payloads = [
"javascript:alert(1)",
"JAVASCRIPT:alert(1)",
"JavaScript:alert(document.cookie)",
"javascript:void(0)",
"javascript://comment%0Aalert(1)",
]
for name in xss_payloads:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_xss_svg_onload(self):
"""Test CreateAppPayload rejects SVG with onload."""
xss_payloads = [
"<svg onload=alert(1)>",
"<SVG ONLOAD=alert(1)>",
"<svg/x/onload=alert(1)>",
]
for name in xss_payloads:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_xss_event_handlers(self):
"""Test CreateAppPayload rejects HTML event handlers."""
xss_payloads = [
"<div onclick=alert(1)>",
"<img onerror=alert(1)>",
"<body onload=alert(1)>",
"<input onfocus=alert(1)>",
"<a onmouseover=alert(1)>",
"<DIV ONCLICK=alert(1)>",
"<img src=x onerror=alert(1)>",
]
for name in xss_payloads:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_xss_object_embed(self):
"""Test CreateAppPayload rejects object and embed tags."""
xss_payloads = [
"<object data='evil.swf'></object>",
"<embed src='evil.swf'>",
"<OBJECT data='javascript:alert(1)'></OBJECT>",
]
for name in xss_payloads:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_xss_link_javascript(self):
"""Test CreateAppPayload rejects link tags with javascript."""
xss_payloads = [
"<link href='javascript:alert(1)'>",
"<LINK HREF='javascript:alert(1)'>",
]
for name in xss_payloads:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_xss_in_description(self):
"""Test CreateAppPayload rejects XSS in description."""
xss_descriptions = [
"<script>alert(1)</script>",
"javascript:alert(1)",
"<img onerror=alert(1)>",
]
for description in xss_descriptions:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(
name="Valid Name",
mode="chat",
description=description,
)
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_valid_descriptions(self):
"""Test CreateAppPayload with valid descriptions."""
valid_descriptions = [
"A simple description",
"Description with < and > symbols",
"Description with & ampersand",
"Description with 'quotes' and \"double quotes\"",
"Description with / slashes",
"Description with \\ backslashes",
"Description with ; semicolons",
"Unicode: 这是一个描述",
"Emoji: 🎉🚀",
]
for description in valid_descriptions:
payload = CreateAppPayload(
name="Valid App Name",
mode="chat",
description=description,
)
assert payload.description == description
def test_create_app_none_description(self):
"""Test CreateAppPayload with None description."""
payload = CreateAppPayload(
name="Valid App Name",
mode="chat",
description=None,
)
assert payload.description is None
def test_update_app_xss_prevention(self):
"""Test UpdateAppPayload also prevents XSS."""
xss_names = [
"<script>alert(1)</script>",
"javascript:alert(1)",
"<img onerror=alert(1)>",
]
for name in xss_names:
with pytest.raises(ValueError) as exc_info:
UpdateAppPayload(name=name)
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_update_app_valid_names(self):
"""Test UpdateAppPayload with valid names."""
payload = UpdateAppPayload(name="Valid Updated Name")
assert payload.name == "Valid Updated Name"
def test_copy_app_xss_prevention(self):
"""Test CopyAppPayload also prevents XSS."""
xss_names = [
"<script>alert(1)</script>",
"javascript:alert(1)",
"<img onerror=alert(1)>",
]
for name in xss_names:
with pytest.raises(ValueError) as exc_info:
CopyAppPayload(name=name)
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_copy_app_valid_names(self):
"""Test CopyAppPayload with valid names."""
payload = CopyAppPayload(name="Valid Copy Name")
assert payload.name == "Valid Copy Name"
def test_copy_app_none_name(self):
"""Test CopyAppPayload with None name (should be allowed)."""
payload = CopyAppPayload(name=None)
assert payload.name is None
def test_edge_case_angle_brackets_content(self):
"""Test that angle brackets with actual content are rejected."""
# Angle brackets without valid HTML-like patterns should be checked
# The regex pattern <.*?on\w+\s*= should catch event handlers
# But let's verify other patterns too
# Valid: angle brackets used as symbols (not matched by our patterns)
# Our patterns specifically look for dangerous constructs
# Invalid: actual HTML tags with event handlers
invalid_names = [
"<div onclick=xss>",
"<img src=x onerror=alert(1)>",
]
for name in invalid_names:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()

View File

@@ -7,19 +7,24 @@ export const jsonObjectWrap = {
export const jsonConfigPlaceHolder = JSON.stringify(
{
foo: {
type: 'string',
},
bar: {
type: 'object',
properties: {
sub: {
type: 'number',
},
type: 'object',
properties: {
foo: {
type: 'string',
},
bar: {
type: 'object',
properties: {
sub: {
type: 'number',
},
},
required: [],
additionalProperties: true,
},
required: [],
additionalProperties: true,
},
required: [],
additionalProperties: true,
},
null,
2,

View File

@@ -28,7 +28,7 @@ import { checkKeys, getNewVarInWorkflow, replaceSpaceWithUnderscoreInVarNameInpu
import ConfigSelect from '../config-select'
import ConfigString from '../config-string'
import ModalFoot from '../modal-foot'
import { jsonConfigPlaceHolder, jsonObjectWrap } from './config'
import { jsonConfigPlaceHolder } from './config'
import Field from './field'
import TypeSelector from './type-select'
@@ -78,13 +78,12 @@ const ConfigModal: FC<IConfigModalProps> = ({
const modalRef = useRef<HTMLDivElement>(null)
const appDetail = useAppStore(state => state.appDetail)
const isBasicApp = appDetail?.mode !== AppModeEnum.ADVANCED_CHAT && appDetail?.mode !== AppModeEnum.WORKFLOW
const isSupportJSON = false
const jsonSchemaStr = useMemo(() => {
const isJsonObject = type === InputVarType.jsonObject
if (!isJsonObject || !tempPayload.json_schema)
return ''
try {
return JSON.stringify(JSON.parse(tempPayload.json_schema).properties, null, 2)
return JSON.stringify(JSON.parse(tempPayload.json_schema), null, 2)
}
catch {
return ''
@@ -129,13 +128,14 @@ const ConfigModal: FC<IConfigModalProps> = ({
}, [])
const handleJSONSchemaChange = useCallback((value: string) => {
const isEmpty = value == null || value.trim() === ''
if (isEmpty) {
handlePayloadChange('json_schema')(undefined)
return null
}
try {
const v = JSON.parse(value)
const res = {
...jsonObjectWrap,
properties: v,
}
handlePayloadChange('json_schema')(JSON.stringify(res, null, 2))
handlePayloadChange('json_schema')(JSON.stringify(v, null, 2))
}
catch {
return null
@@ -175,7 +175,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
},
]
: []),
...((!isBasicApp && isSupportJSON)
...((!isBasicApp)
? [{
name: t('variableConfig.json', { ns: 'appDebug' }),
value: InputVarType.jsonObject,
@@ -233,7 +233,28 @@ const ConfigModal: FC<IConfigModalProps> = ({
const checkboxDefaultSelectValue = useMemo(() => getCheckboxDefaultSelectValue(tempPayload.default), [tempPayload.default])
const isJsonSchemaEmpty = (value: InputVar['json_schema']) => {
if (value === null || value === undefined) {
return true
}
if (typeof value !== 'string') {
return false
}
const trimmed = value.trim()
return trimmed === ''
}
const handleConfirm = () => {
const jsonSchemaValue = tempPayload.json_schema
const isSchemaEmpty = isJsonSchemaEmpty(jsonSchemaValue)
const normalizedJsonSchema = isSchemaEmpty ? undefined : jsonSchemaValue
// if the input type is jsonObject and the schema is empty as determined by `isJsonSchemaEmpty`,
// remove the `json_schema` field from the payload by setting its value to `undefined`.
const payloadToSave = tempPayload.type === InputVarType.jsonObject && isSchemaEmpty
? { ...tempPayload, json_schema: undefined }
: tempPayload
const moreInfo = tempPayload.variable === payload?.variable
? undefined
: {
@@ -250,7 +271,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
return
}
if (isStringInput || type === InputVarType.number) {
onConfirm(tempPayload, moreInfo)
onConfirm(payloadToSave, moreInfo)
}
else if (type === InputVarType.select) {
if (options?.length === 0) {
@@ -270,7 +291,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
Toast.notify({ type: 'error', message: t('variableConfig.errorMsg.optionRepeat', { ns: 'appDebug' }) })
return
}
onConfirm(tempPayload, moreInfo)
onConfirm(payloadToSave, moreInfo)
}
else if ([InputVarType.singleFile, InputVarType.multiFiles].includes(type)) {
if (tempPayload.allowed_file_types?.length === 0) {
@@ -283,10 +304,26 @@ const ConfigModal: FC<IConfigModalProps> = ({
Toast.notify({ type: 'error', message: errorMessages })
return
}
onConfirm(tempPayload, moreInfo)
onConfirm(payloadToSave, moreInfo)
}
else if (type === InputVarType.jsonObject) {
if (!isSchemaEmpty && typeof normalizedJsonSchema === 'string') {
try {
const schema = JSON.parse(normalizedJsonSchema)
if (schema?.type !== 'object') {
Toast.notify({ type: 'error', message: t('variableConfig.errorMsg.jsonSchemaMustBeObject', { ns: 'appDebug' }) })
return
}
}
catch {
Toast.notify({ type: 'error', message: t('variableConfig.errorMsg.jsonSchemaInvalid', { ns: 'appDebug' }) })
return
}
}
onConfirm(payloadToSave, moreInfo)
}
else {
onConfirm(tempPayload, moreInfo)
onConfirm(payloadToSave, moreInfo)
}
}

View File

@@ -1,200 +0,0 @@
import type { ReactNode } from 'react'
import type { ChatConversationGeneralDetail, ChatConversationsResponse } from '@/models/log'
import type { App, AppIconType } from '@/types/app'
import { render, screen } from '@testing-library/react'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { APP_PAGE_LIMIT } from '@/config'
import { AppModeEnum } from '@/types/app'
import Logs from './index'
const mockUseChatConversations = vi.fn()
const mockUseCompletionConversations = vi.fn()
const mockUseAnnotationsCount = vi.fn()
const mockRouterPush = vi.fn()
const mockRouterReplace = vi.fn()
const mockAppStoreState = {
setShowPromptLogModal: vi.fn(),
setShowAgentLogModal: vi.fn(),
setShowMessageLogModal: vi.fn(),
}
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockRouterPush,
replace: mockRouterReplace,
}),
usePathname: () => '/apps/app-123/logs',
useSearchParams: () => new URLSearchParams(),
}))
vi.mock('@/service/use-log', () => ({
useChatConversations: (args: unknown) => mockUseChatConversations(args),
useCompletionConversations: (args: unknown) => mockUseCompletionConversations(args),
useAnnotationsCount: () => mockUseAnnotationsCount(),
useChatConversationDetail: () => ({ data: undefined }),
useCompletionConversationDetail: () => ({ data: undefined }),
}))
vi.mock('@/service/log', () => ({
fetchChatMessages: vi.fn(),
updateLogMessageAnnotations: vi.fn(),
updateLogMessageFeedbacks: vi.fn(),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
userProfile: { timezone: 'UTC' },
}),
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: typeof mockAppStoreState) => unknown) => selector(mockAppStoreState),
}))
const renderWithAdapter = (ui: ReactNode, searchParams = '') => {
return render(
<NuqsTestingAdapter searchParams={searchParams}>
{ui}
</NuqsTestingAdapter>,
)
}
const createMockApp = (overrides: Partial<App> = {}): App => ({
id: 'app-123',
name: 'Test App',
description: 'Test app description',
author_name: 'Test Author',
icon_type: 'emoji' as AppIconType,
icon: ':icon:',
icon_background: '#FFEAD5',
icon_url: null,
use_icon_as_answer_icon: false,
mode: AppModeEnum.CHAT,
enable_site: true,
enable_api: true,
api_rpm: 60,
api_rph: 3600,
is_demo: false,
model_config: {} as App['model_config'],
app_model_config: {} as App['app_model_config'],
created_at: Date.now(),
updated_at: Date.now(),
site: {
access_token: 'token',
app_base_url: 'https://example.com',
} as App['site'],
api_base_url: 'https://api.example.com',
tags: [],
access_mode: 'public_access' as App['access_mode'],
...overrides,
})
const createChatConversation = (overrides: Partial<ChatConversationGeneralDetail> = {}): ChatConversationGeneralDetail => ({
id: 'conversation-1',
status: 'normal',
from_source: 'api',
from_end_user_id: 'user-1',
from_end_user_session_id: 'session-1',
from_account_id: 'account-1',
read_at: new Date(),
created_at: 1700000000,
updated_at: 1700000001,
user_feedback_stats: { like: 0, dislike: 0 },
admin_feedback_stats: { like: 0, dislike: 0 },
model_config: {
provider: 'openai',
model_id: 'gpt-4',
configs: { prompt_template: '' },
},
summary: 'Conversation summary',
message_count: 1,
annotated: false,
...overrides,
})
const createChatConversationsResponse = (overrides: Partial<ChatConversationsResponse> = {}): ChatConversationsResponse => ({
data: [createChatConversation()],
has_more: false,
limit: APP_PAGE_LIMIT,
total: 1,
page: 1,
...overrides,
})
// Logs page: loading, empty, and data states.
describe('Logs', () => {
beforeEach(() => {
vi.clearAllMocks()
globalThis.innerWidth = 1024
mockUseAnnotationsCount.mockReturnValue({
data: { count: 0 },
isLoading: false,
})
mockUseChatConversations.mockReturnValue({
data: undefined,
refetch: vi.fn(),
})
mockUseCompletionConversations.mockReturnValue({
data: undefined,
refetch: vi.fn(),
})
})
// Loading behavior when no data yet.
describe('Rendering', () => {
it('should render loading state when conversations are undefined', () => {
// Arrange
const appDetail = createMockApp()
// Act
renderWithAdapter(<Logs appDetail={appDetail} />)
// Assert
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('should render empty state when there are no conversations', () => {
// Arrange
mockUseChatConversations.mockReturnValue({
data: createChatConversationsResponse({ data: [], total: 0 }),
refetch: vi.fn(),
})
const appDetail = createMockApp()
// Act
renderWithAdapter(<Logs appDetail={appDetail} />)
// Assert
expect(screen.getByText('appLog.table.empty.element.title')).toBeInTheDocument()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
})
// Data rendering behavior.
describe('Props', () => {
it('should render list with pagination when conversations exist', () => {
// Arrange
mockUseChatConversations.mockReturnValue({
data: createChatConversationsResponse({ total: APP_PAGE_LIMIT + 1 }),
refetch: vi.fn(),
})
const appDetail = createMockApp()
// Act
renderWithAdapter(<Logs appDetail={appDetail} />, '?page=0&limit=0')
// Assert
expect(screen.getByText('appLog.table.header.summary')).toBeInTheDocument()
expect(screen.getByText('25')).toBeInTheDocument()
const firstCallArgs = mockUseChatConversations.mock.calls[0]?.[0]
expect(firstCallArgs.params.page).toBe(1)
expect(firstCallArgs.params.limit).toBe(APP_PAGE_LIMIT)
})
})
})

View File

@@ -4,13 +4,9 @@ import type { App } from '@/types/app'
import { useDebounce } from 'ahooks'
import dayjs from 'dayjs'
import { omit } from 'es-toolkit/object'
import {
parseAsInteger,
parseAsString,
useQueryStates,
} from 'nuqs'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import * as React from 'react'
import { useCallback } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import Pagination from '@/app/components/base/pagination'
@@ -32,39 +28,53 @@ export type QueryParam = {
sort_by?: string
}
const defaultQueryParams: QueryParam = {
period: '2',
annotation_status: 'all',
sort_by: '-created_at',
}
const logsStateCache = new Map<string, {
queryParams: QueryParam
currPage: number
limit: number
}>()
const Logs: FC<ILogsProps> = ({ appDetail }) => {
const { t } = useTranslation()
const [queryParams, setQueryParams] = useQueryStates(
{
page: parseAsInteger.withDefault(1),
limit: parseAsInteger.withDefault(APP_PAGE_LIMIT),
period: parseAsString.withDefault('2'),
annotation_status: parseAsString.withDefault('all'),
keyword: parseAsString,
sort_by: parseAsString.withDefault('-created_at'),
},
{
urlKeys: {
page: 'page',
limit: 'limit',
period: 'period',
annotation_status: 'annotation_status',
keyword: 'keyword',
sort_by: 'sort_by',
},
},
)
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const getPageFromParams = useCallback(() => {
const pageParam = Number.parseInt(searchParams.get('page') || '1', 10)
if (Number.isNaN(pageParam) || pageParam < 1)
return 0
return pageParam - 1
}, [searchParams])
const cachedState = logsStateCache.get(appDetail.id)
const [queryParams, setQueryParams] = useState<QueryParam>(cachedState?.queryParams ?? defaultQueryParams)
const [currPage, setCurrPage] = React.useState<number>(() => cachedState?.currPage ?? getPageFromParams())
const [limit, setLimit] = React.useState<number>(cachedState?.limit ?? APP_PAGE_LIMIT)
const debouncedQueryParams = useDebounce(queryParams, { wait: 500 })
const page = queryParams.page > 0 ? queryParams.page : 1
const limit = queryParams.limit > 0 ? queryParams.limit : APP_PAGE_LIMIT
useEffect(() => {
const pageFromParams = getPageFromParams()
setCurrPage(prev => (prev === pageFromParams ? prev : pageFromParams))
}, [getPageFromParams])
useEffect(() => {
logsStateCache.set(appDetail.id, {
queryParams,
currPage,
limit,
})
}, [appDetail.id, currPage, limit, queryParams])
// Get the app type first
const isChatMode = appDetail.mode !== AppModeEnum.COMPLETION
const query = {
page,
page: currPage + 1,
limit,
...((debouncedQueryParams.period !== '9')
? {
@@ -73,8 +83,7 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
}
: {}),
...(isChatMode ? { sort_by: debouncedQueryParams.sort_by } : {}),
...omit(debouncedQueryParams, ['period', 'page', 'limit']),
keyword: debouncedQueryParams.keyword || undefined,
...omit(debouncedQueryParams, ['period']),
}
// When the details are obtained, proceed to the next request
@@ -91,25 +100,27 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
const total = isChatMode ? chatConversations?.total : completionConversations?.total
const handleQueryParamsChange = useCallback((next: QueryParam) => {
setQueryParams({
...next,
page: 1, // Reset to page 1 on filter change
})
}, [setQueryParams])
setCurrPage(0)
setQueryParams(next)
}, [])
const handlePageChange = useCallback((page: number) => {
setQueryParams({ page: page + 1 })
}, [setQueryParams])
const handleLimitChange = useCallback((limit: number) => {
setQueryParams({ limit, page: 1 })
}, [setQueryParams])
setCurrPage(page)
const params = new URLSearchParams(searchParams.toString())
const nextPageValue = page + 1
if (nextPageValue === 1)
params.delete('page')
else
params.set('page', String(nextPageValue))
const queryString = params.toString()
router.replace(queryString ? `${pathname}?${queryString}` : pathname, { scroll: false })
}, [pathname, router, searchParams])
return (
<div className="flex h-full grow flex-col">
<p className="system-sm-regular shrink-0 text-text-tertiary">{t('description', { ns: 'appLog' })}</p>
<div className="flex max-h-[calc(100%-16px)] flex-1 grow flex-col py-4">
<Filter isChatMode={isChatMode} appId={appDetail.id} queryParams={{ ...queryParams, keyword: queryParams.keyword ?? undefined }} setQueryParams={handleQueryParamsChange} />
<Filter isChatMode={isChatMode} appId={appDetail.id} queryParams={queryParams} setQueryParams={handleQueryParamsChange} />
{total === undefined
? <Loading type="app" />
: total > 0
@@ -119,11 +130,11 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
{(total && total > APP_PAGE_LIMIT)
? (
<Pagination
current={page - 1}
current={currPage}
onChange={handlePageChange}
total={total}
limit={limit}
onLimitChange={handleLimitChange}
onLimitChange={setLimit}
/>
)
: null}

View File

@@ -3,7 +3,6 @@ import type { ChatConfig } from '../types'
import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook, waitFor } from '@testing-library/react'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { ToastProvider } from '@/app/components/base/toast'
import {
fetchChatList,
@@ -75,11 +74,9 @@ const createQueryClient = () => new QueryClient({
const createWrapper = (queryClient: QueryClient) => {
return ({ children }: { children: ReactNode }) => (
<NuqsTestingAdapter>
<QueryClientProvider client={queryClient}>
<ToastProvider>{children}</ToastProvider>
</QueryClientProvider>
</NuqsTestingAdapter>
<QueryClientProvider client={queryClient}>
<ToastProvider>{children}</ToastProvider>
</QueryClientProvider>
)
}

View File

@@ -11,7 +11,6 @@ import type {
import { useLocalStorageState } from 'ahooks'
import { noop } from 'es-toolkit/function'
import { produce } from 'immer'
import { parseAsString, useQueryState } from 'nuqs'
import {
useCallback,
useEffect,
@@ -83,10 +82,12 @@ export const useEmbeddedChatbot = () => {
setConversationId(embeddedConversationId || undefined)
}, [embeddedConversationId])
const [localeParam] = useQueryState('locale', parseAsString)
useEffect(() => {
const setLanguageFromParams = async () => {
// Check URL parameters for language override
const urlParams = new URLSearchParams(window.location.search)
const localeParam = urlParams.get('locale')
// Check for encoded system variables
const systemVariables = await getProcessedSystemVariablesFromUrlParams()
const localeFromSysVar = systemVariables.locale
@@ -106,7 +107,7 @@ export const useEmbeddedChatbot = () => {
}
setLanguageFromParams()
}, [appInfo, localeParam])
}, [appInfo])
const [conversationIdInfo, setConversationIdInfo] = useLocalStorageState<Record<string, Record<string, string>>>(CONVERSATION_ID_INFO, {
defaultValue: {},

View File

@@ -1,9 +1,12 @@
import type { ReactNode } from 'react'
import { act, renderHook } from '@testing-library/react'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { PARTNER_STACK_CONFIG } from '@/config'
import usePSInfo from './use-ps-info'
let searchParamsValues: Record<string, string | null> = {}
const setSearchParams = (values: Record<string, string | null>) => {
searchParamsValues = values
}
type PartnerStackGlobal = typeof globalThis & {
__partnerStackCookieMocks?: {
get: ReturnType<typeof vi.fn>
@@ -45,6 +48,11 @@ vi.mock('js-cookie', () => {
remove,
}
})
vi.mock('next/navigation', () => ({
useSearchParams: () => ({
get: (key: string) => searchParamsValues[key] ?? null,
}),
}))
vi.mock('@/service/use-billing', () => {
const mutateAsync = vi.fn()
const globals = getPartnerStackGlobal()
@@ -56,15 +64,6 @@ vi.mock('@/service/use-billing', () => {
}
})
const renderWithAdapter = (searchParams = '') => {
const wrapper = ({ children }: { children: ReactNode }) => (
<NuqsTestingAdapter searchParams={searchParams}>
{children}
</NuqsTestingAdapter>
)
return renderHook(() => usePSInfo(), { wrapper })
}
describe('usePSInfo', () => {
const originalLocationDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'location')
@@ -76,6 +75,7 @@ describe('usePSInfo', () => {
})
beforeEach(() => {
setSearchParams({})
const { get, set, remove } = ensureCookieMocks()
get.mockReset()
set.mockReset()
@@ -94,7 +94,12 @@ describe('usePSInfo', () => {
it('saves partner info when query params change', () => {
const { get, set } = ensureCookieMocks()
get.mockReturnValue(JSON.stringify({ partnerKey: 'old', clickId: 'old-click' }))
const { result } = renderWithAdapter('?ps_partner_key=new-partner&ps_xid=new-click')
setSearchParams({
ps_partner_key: 'new-partner',
ps_xid: 'new-click',
})
const { result } = renderHook(() => usePSInfo())
expect(result.current.psPartnerKey).toBe('new-partner')
expect(result.current.psClickId).toBe('new-click')
@@ -118,13 +123,17 @@ describe('usePSInfo', () => {
})
it('does not overwrite cookie when params do not change', () => {
setSearchParams({
ps_partner_key: 'existing',
ps_xid: 'existing-click',
})
const { get } = ensureCookieMocks()
get.mockReturnValue(JSON.stringify({
partnerKey: 'existing',
clickId: 'existing-click',
}))
const { result } = renderWithAdapter('?ps_partner_key=existing&ps_xid=existing-click')
const { result } = renderHook(() => usePSInfo())
act(() => {
result.current.saveOrUpdate()
@@ -135,7 +144,12 @@ describe('usePSInfo', () => {
})
it('binds partner info and clears cookie once', async () => {
const { result } = renderWithAdapter('?ps_partner_key=bind-partner&ps_xid=bind-click')
setSearchParams({
ps_partner_key: 'bind-partner',
ps_xid: 'bind-click',
})
const { result } = renderHook(() => usePSInfo())
const mutate = ensureMutateAsync()
const { remove } = ensureCookieMocks()
@@ -162,7 +176,12 @@ describe('usePSInfo', () => {
it('still removes cookie when bind fails with status 400', async () => {
const mutate = ensureMutateAsync()
mutate.mockRejectedValueOnce({ status: 400 })
const { result } = renderWithAdapter('?ps_partner_key=bind-partner&ps_xid=bind-click')
setSearchParams({
ps_partner_key: 'bind-partner',
ps_xid: 'bind-click',
})
const { result } = renderHook(() => usePSInfo())
await act(async () => {
await result.current.bind()

View File

@@ -1,13 +1,12 @@
import { useBoolean } from 'ahooks'
import Cookies from 'js-cookie'
import { parseAsString, useQueryState } from 'nuqs'
import { useSearchParams } from 'next/navigation'
import { useCallback } from 'react'
import { PARTNER_STACK_CONFIG } from '@/config'
import { useBindPartnerStackInfo } from '@/service/use-billing'
const usePSInfo = () => {
const [partnerKey] = useQueryState('ps_partner_key', parseAsString)
const [clickId] = useQueryState('ps_xid', parseAsString)
const searchParams = useSearchParams()
const psInfoInCookie = (() => {
try {
return JSON.parse(Cookies.get(PARTNER_STACK_CONFIG.cookieName) || '{}')
@@ -17,8 +16,8 @@ const usePSInfo = () => {
return {}
}
})()
const psPartnerKey = partnerKey || psInfoInCookie?.partnerKey
const psClickId = clickId || psInfoInCookie?.clickId
const psPartnerKey = searchParams.get('ps_partner_key') || psInfoInCookie?.partnerKey
const psClickId = searchParams.get('ps_xid') || psInfoInCookie?.clickId
const isPSChanged = psInfoInCookie?.partnerKey !== psPartnerKey || psInfoInCookie?.clickId !== psClickId
const [hasBind, {
setTrue: setBind,

View File

@@ -1,133 +0,0 @@
import type { ReactNode } from 'react'
import { act, renderHook, waitFor } from '@testing-library/react'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import useDocumentListQueryState from './use-document-list-query-state'
const renderWithAdapter = (searchParams = '') => {
const wrapper = ({ children }: { children: ReactNode }) => (
<NuqsTestingAdapter searchParams={searchParams}>
{children}
</NuqsTestingAdapter>
)
return renderHook(() => useDocumentListQueryState(), { wrapper })
}
// Document list query state: defaults, sanitization, and update actions.
describe('useDocumentListQueryState', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Default query values.
describe('Rendering', () => {
it('should return default query values when URL params are missing', () => {
// Arrange
const { result } = renderWithAdapter()
// Act
const { query } = result.current
// Assert
expect(query).toEqual({
page: 1,
limit: 10,
keyword: '',
status: 'all',
sort: '-created_at',
})
})
})
// URL sanitization behavior.
describe('Edge Cases', () => {
it('should sanitize invalid URL query values', () => {
// Arrange
const { result } = renderWithAdapter('?page=0&limit=500&keyword=%20%20&status=invalid&sort=bad')
// Act
const { query } = result.current
// Assert
expect(query).toEqual({
page: 1,
limit: 10,
keyword: '',
status: 'all',
sort: '-created_at',
})
})
})
// Query update actions.
describe('User Interactions', () => {
it('should normalize query updates', async () => {
// Arrange
const { result } = renderWithAdapter()
// Act
act(() => {
result.current.updateQuery({
page: 0,
limit: 200,
keyword: ' search ',
status: 'invalid',
sort: 'hit_count',
})
})
// Assert
await waitFor(() => {
expect(result.current.query).toEqual({
page: 1,
limit: 10,
keyword: ' search ',
status: 'all',
sort: 'hit_count',
})
})
})
it('should reset query values to defaults', async () => {
// Arrange
const { result } = renderWithAdapter('?page=2&limit=25&keyword=hello&status=enabled&sort=hit_count')
// Act
act(() => {
result.current.resetQuery()
})
// Assert
await waitFor(() => {
expect(result.current.query).toEqual({
page: 1,
limit: 10,
keyword: '',
status: 'all',
sort: '-created_at',
})
})
})
})
// Callback stability.
describe('Performance', () => {
it('should keep action callbacks stable across updates', async () => {
// Arrange
const { result } = renderWithAdapter()
const initialUpdate = result.current.updateQuery
const initialReset = result.current.resetQuery
// Act
act(() => {
result.current.updateQuery({ page: 2 })
})
// Assert
await waitFor(() => {
expect(result.current.updateQuery).toBe(initialUpdate)
expect(result.current.resetQuery).toBe(initialReset)
})
})
})
})

View File

@@ -1,5 +1,6 @@
import type { ReadonlyURLSearchParams } from 'next/navigation'
import type { SortType } from '@/service/datasets'
import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { useCallback, useMemo } from 'react'
import { sanitizeStatusValue } from '../status-filter'
@@ -20,14 +21,6 @@ export type DocumentListQuery = {
sort: SortType
}
type DocumentListQueryInput = {
page?: number
limit?: number
keyword?: string | null
status?: string | null
sort?: string | null
}
const DEFAULT_QUERY: DocumentListQuery = {
page: 1,
limit: 10,
@@ -36,60 +29,89 @@ const DEFAULT_QUERY: DocumentListQuery = {
sort: '-created_at',
}
const normalizeKeywordValue = (value?: string | null) => (value && value.trim() ? value : '')
const normalizeDocumentListQuery = (query: DocumentListQueryInput): DocumentListQuery => {
const page = (query.page && query.page > 0) ? query.page : DEFAULT_QUERY.page
const limit = (query.limit && query.limit > 0 && query.limit <= 100) ? query.limit : DEFAULT_QUERY.limit
const keyword = normalizeKeywordValue(query.keyword ?? DEFAULT_QUERY.keyword)
const status = sanitizeStatusValue(query.status ?? DEFAULT_QUERY.status)
const sort = sanitizeSortValue(query.sort ?? DEFAULT_QUERY.sort)
// Parse the query parameters from the URL search string.
function parseParams(params: ReadonlyURLSearchParams): DocumentListQuery {
const page = Number.parseInt(params.get('page') || '1', 10)
const limit = Number.parseInt(params.get('limit') || '10', 10)
const keyword = params.get('keyword') || ''
const status = sanitizeStatusValue(params.get('status'))
const sort = sanitizeSortValue(params.get('sort'))
return {
page,
limit,
keyword,
page: page > 0 ? page : 1,
limit: (limit > 0 && limit <= 100) ? limit : 10,
keyword: keyword ? decodeURIComponent(keyword) : '',
status,
sort,
}
}
// Update the URL search string with the given query parameters.
function updateSearchParams(query: DocumentListQuery, searchParams: URLSearchParams) {
const { page, limit, keyword, status, sort } = query || {}
const hasNonDefaultParams = (page && page > 1) || (limit && limit !== 10) || (keyword && keyword.trim())
if (hasNonDefaultParams) {
searchParams.set('page', (page || 1).toString())
searchParams.set('limit', (limit || 10).toString())
}
else {
searchParams.delete('page')
searchParams.delete('limit')
}
if (keyword && keyword.trim())
searchParams.set('keyword', encodeURIComponent(keyword))
else
searchParams.delete('keyword')
const sanitizedStatus = sanitizeStatusValue(status)
if (sanitizedStatus && sanitizedStatus !== 'all')
searchParams.set('status', sanitizedStatus)
else
searchParams.delete('status')
const sanitizedSort = sanitizeSortValue(sort)
if (sanitizedSort !== '-created_at')
searchParams.set('sort', sanitizedSort)
else
searchParams.delete('sort')
}
function useDocumentListQueryState() {
const [query, setQuery] = useQueryStates(
{
page: parseAsInteger.withDefault(DEFAULT_QUERY.page),
limit: parseAsInteger.withDefault(DEFAULT_QUERY.limit),
keyword: parseAsString.withDefault(DEFAULT_QUERY.keyword),
status: parseAsString.withDefault(DEFAULT_QUERY.status),
sort: parseAsString.withDefault(DEFAULT_QUERY.sort),
},
{
history: 'push',
urlKeys: {
page: 'page',
limit: 'limit',
keyword: 'keyword',
status: 'status',
sort: 'sort',
},
},
)
const searchParams = useSearchParams()
const query = useMemo(() => parseParams(searchParams), [searchParams])
const finalQuery = useMemo(() => normalizeDocumentListQuery(query), [query])
const router = useRouter()
const pathname = usePathname()
// Helper function to update specific query parameters
const updateQuery = useCallback((updates: Partial<DocumentListQuery>) => {
setQuery(prev => normalizeDocumentListQuery({ ...prev, ...updates }))
}, [setQuery])
const newQuery = { ...query, ...updates }
newQuery.status = sanitizeStatusValue(newQuery.status)
newQuery.sort = sanitizeSortValue(newQuery.sort)
const params = new URLSearchParams()
updateSearchParams(newQuery, params)
const search = params.toString()
const queryString = search ? `?${search}` : ''
router.push(`${pathname}${queryString}`, { scroll: false })
}, [query, router, pathname])
// Helper function to reset query to defaults
const resetQuery = useCallback(() => {
setQuery(DEFAULT_QUERY)
}, [setQuery])
const params = new URLSearchParams()
updateSearchParams(DEFAULT_QUERY, params)
const search = params.toString()
const queryString = search ? `?${search}` : ''
router.push(`${pathname}${queryString}`, { scroll: false })
}, [router, pathname])
return useMemo(() => ({
query: finalQuery,
query,
updateQuery,
resetQuery,
}), [finalQuery, updateQuery, resetQuery])
}), [query, updateQuery, resetQuery])
}
export default useDocumentListQueryState

View File

@@ -1,12 +1,13 @@
'use client'
import { parseAsString, useQueryState } from 'nuqs'
import { useSearchParams } from 'next/navigation'
import { useEffect } from 'react'
import usePSInfo from '../components/billing/partner-stack/use-ps-info'
import NormalForm from './normal-form'
import OneMoreStep from './one-more-step'
const SignIn = () => {
const [step] = useQueryState('step', parseAsString)
const searchParams = useSearchParams()
const step = searchParams.get('step')
const { saveOrUpdate } = usePSInfo()
useEffect(() => {

View File

@@ -134,7 +134,7 @@ export const ProviderContextProvider = ({
const [enableEducationPlan, setEnableEducationPlan] = useState(false)
const [isEducationWorkspace, setIsEducationWorkspace] = useState(false)
const { data: educationAccountInfo, isLoading: isLoadingEducationAccountInfo, isFetching: isFetchingEducationAccountInfo } = useEducationStatus(!enableEducationPlan)
const { data: educationAccountInfo, isLoading: isLoadingEducationAccountInfo, isFetching: isFetchingEducationAccountInfo, isFetchedAfterMount: isEducationDataFetchedAfterMount } = useEducationStatus(!enableEducationPlan)
const [isAllowTransferWorkspace, setIsAllowTransferWorkspace] = useState(false)
const [isAllowPublishAsCustomKnowledgePipelineTemplate, setIsAllowPublishAsCustomKnowledgePipelineTemplate] = useState(false)
@@ -240,9 +240,9 @@ export const ProviderContextProvider = ({
datasetOperatorEnabled,
enableEducationPlan,
isEducationWorkspace,
isEducationAccount: educationAccountInfo?.is_student || false,
allowRefreshEducationVerify: educationAccountInfo?.allow_refresh || false,
educationAccountExpireAt: educationAccountInfo?.expire_at || null,
isEducationAccount: isEducationDataFetchedAfterMount ? (educationAccountInfo?.is_student ?? false) : false,
allowRefreshEducationVerify: isEducationDataFetchedAfterMount ? (educationAccountInfo?.allow_refresh ?? false) : false,
educationAccountExpireAt: isEducationDataFetchedAfterMount ? (educationAccountInfo?.expire_at ?? null) : null,
isLoadingEducationAccountInfo,
isFetchingEducationAccountInfo,
webappCopyrightEnabled,

View File

@@ -306,6 +306,8 @@
"variableConfig.displayName": "اسم العرض",
"variableConfig.editModalTitle": "تعديل حقل إدخال",
"variableConfig.errorMsg.atLeastOneOption": "خيار واحد على الأقل مطلوب",
"variableConfig.errorMsg.jsonSchemaInvalid": "مخطط JSON ليس JSON صالحًا",
"variableConfig.errorMsg.jsonSchemaMustBeObject": "يجب أن يكون نوع مخطط JSON \"object\"",
"variableConfig.errorMsg.labelNameRequired": "اسم التسمية مطلوب",
"variableConfig.errorMsg.optionRepeat": "يوجد خيارات مكررة",
"variableConfig.errorMsg.varNameCanBeRepeat": "اسم المتغير لا يمكن تكراره",

View File

@@ -306,6 +306,8 @@
"variableConfig.displayName": "Anzeigename",
"variableConfig.editModalTitle": "Eingabefeld bearbeiten",
"variableConfig.errorMsg.atLeastOneOption": "Mindestens eine Option ist erforderlich",
"variableConfig.errorMsg.jsonSchemaInvalid": "JSON-Schema ist kein gültiges JSON",
"variableConfig.errorMsg.jsonSchemaMustBeObject": "JSON-Schema muss den Typ \"object\" haben",
"variableConfig.errorMsg.labelNameRequired": "Labelname ist erforderlich",
"variableConfig.errorMsg.optionRepeat": "Hat Wiederholungsoptionen",
"variableConfig.errorMsg.varNameCanBeRepeat": "Variablenname kann nicht wiederholt werden",

View File

@@ -306,6 +306,8 @@
"variableConfig.displayName": "Display Name",
"variableConfig.editModalTitle": "Edit Input Field",
"variableConfig.errorMsg.atLeastOneOption": "At least one option is required",
"variableConfig.errorMsg.jsonSchemaInvalid": "JSON Schema is not valid JSON",
"variableConfig.errorMsg.jsonSchemaMustBeObject": "JSON Schema must have type \"object\"",
"variableConfig.errorMsg.labelNameRequired": "Label name is required",
"variableConfig.errorMsg.optionRepeat": "Has repeat options",
"variableConfig.errorMsg.varNameCanBeRepeat": "Variable name can not be repeated",

View File

@@ -306,6 +306,8 @@
"variableConfig.displayName": "Nombre para mostrar",
"variableConfig.editModalTitle": "Editar Campo de Entrada",
"variableConfig.errorMsg.atLeastOneOption": "Se requiere al menos una opción",
"variableConfig.errorMsg.jsonSchemaInvalid": "El esquema JSON no es un JSON válido",
"variableConfig.errorMsg.jsonSchemaMustBeObject": "El esquema JSON debe tener el tipo \"object\"",
"variableConfig.errorMsg.labelNameRequired": "Nombre de la etiqueta es requerido",
"variableConfig.errorMsg.optionRepeat": "Hay opciones repetidas",
"variableConfig.errorMsg.varNameCanBeRepeat": "El nombre de la variable no puede repetirse",

View File

@@ -306,6 +306,8 @@
"variableConfig.displayName": "نام نمایشی",
"variableConfig.editModalTitle": "ویرایش فیلد ورودی",
"variableConfig.errorMsg.atLeastOneOption": "حداقل یک گزینه مورد نیاز است",
"variableConfig.errorMsg.jsonSchemaInvalid": "JSON Schema یک JSON معتبر نیست",
"variableConfig.errorMsg.jsonSchemaMustBeObject": "نوع JSON Schema باید \"object\" باشد",
"variableConfig.errorMsg.labelNameRequired": "نام برچسب الزامی است",
"variableConfig.errorMsg.optionRepeat": "دارای گزینه های تکرار",
"variableConfig.errorMsg.varNameCanBeRepeat": "نام متغیر را نمی توان تکرار کرد",

View File

@@ -306,6 +306,8 @@
"variableConfig.displayName": "Nom daffichage",
"variableConfig.editModalTitle": "Edit Input Field",
"variableConfig.errorMsg.atLeastOneOption": "At least one option is required",
"variableConfig.errorMsg.jsonSchemaInvalid": "Le schéma JSON nest pas un JSON valide",
"variableConfig.errorMsg.jsonSchemaMustBeObject": "Le schéma JSON doit avoir le type \"object\"",
"variableConfig.errorMsg.labelNameRequired": "Label name is required",
"variableConfig.errorMsg.optionRepeat": "Has repeat options",
"variableConfig.errorMsg.varNameCanBeRepeat": "Variable name can not be repeated",

View File

@@ -306,6 +306,8 @@
"variableConfig.displayName": "प्रदर्शन नाम",
"variableConfig.editModalTitle": "इनपुट फ़ील्ड संपादित करें",
"variableConfig.errorMsg.atLeastOneOption": "कम से कम एक विकल्प आवश्यक है",
"variableConfig.errorMsg.jsonSchemaInvalid": "JSON स्कीमा मान्य JSON नहीं है",
"variableConfig.errorMsg.jsonSchemaMustBeObject": "JSON स्कीमा का प्रकार \"object\" होना चाहिए",
"variableConfig.errorMsg.labelNameRequired": "लेबल नाम आवश्यक है",
"variableConfig.errorMsg.optionRepeat": "विकल्प दोहराए गए हैं",
"variableConfig.errorMsg.varNameCanBeRepeat": "वेरिएबल नाम दोहराया नहीं जा सकता",

View File

@@ -306,6 +306,8 @@
"variableConfig.displayName": "Nama Tampilan",
"variableConfig.editModalTitle": "Edit Bidang Input",
"variableConfig.errorMsg.atLeastOneOption": "Setidaknya satu opsi diperlukan",
"variableConfig.errorMsg.jsonSchemaInvalid": "JSON Schema bukan JSON yang valid",
"variableConfig.errorMsg.jsonSchemaMustBeObject": "JSON Schema harus bertipe \"object\"",
"variableConfig.errorMsg.labelNameRequired": "Nama label diperlukan",
"variableConfig.errorMsg.optionRepeat": "Memiliki opsi pengulangan",
"variableConfig.errorMsg.varNameCanBeRepeat": "Nama variabel tidak dapat diulang",

View File

@@ -306,6 +306,8 @@
"variableConfig.displayName": "Nome visualizzato",
"variableConfig.editModalTitle": "Modifica Campo Input",
"variableConfig.errorMsg.atLeastOneOption": "È richiesta almeno un'opzione",
"variableConfig.errorMsg.jsonSchemaInvalid": "Lo schema JSON non è un JSON valido",
"variableConfig.errorMsg.jsonSchemaMustBeObject": "Lo schema JSON deve avere tipo \"object\"",
"variableConfig.errorMsg.labelNameRequired": "Il nome dell'etichetta è richiesto",
"variableConfig.errorMsg.optionRepeat": "Ci sono opzioni ripetute",
"variableConfig.errorMsg.varNameCanBeRepeat": "Il nome della variabile non può essere ripetuto",

View File

@@ -306,6 +306,8 @@
"variableConfig.displayName": "表示名",
"variableConfig.editModalTitle": "入力フィールドを編集",
"variableConfig.errorMsg.atLeastOneOption": "少なくとも 1 つのオプションが必要です",
"variableConfig.errorMsg.jsonSchemaInvalid": "JSONスキーマが有効なJSONではありません",
"variableConfig.errorMsg.jsonSchemaMustBeObject": "JSONスキーマのtypeは\"object\"である必要があります",
"variableConfig.errorMsg.labelNameRequired": "ラベル名は必須です",
"variableConfig.errorMsg.optionRepeat": "繰り返しオプションがあります",
"variableConfig.errorMsg.varNameCanBeRepeat": "変数名は繰り返すことができません",

View File

@@ -306,6 +306,8 @@
"variableConfig.displayName": "표시 이름",
"variableConfig.editModalTitle": "입력 필드 편집",
"variableConfig.errorMsg.atLeastOneOption": "적어도 하나의 옵션이 필요합니다",
"variableConfig.errorMsg.jsonSchemaInvalid": "JSON 스키마가 올바른 JSON이 아닙니다",
"variableConfig.errorMsg.jsonSchemaMustBeObject": "JSON 스키마의 type은 \"object\"이어야 합니다",
"variableConfig.errorMsg.labelNameRequired": "레이블명은 필수입니다",
"variableConfig.errorMsg.optionRepeat": "옵션이 중복되어 있습니다",
"variableConfig.errorMsg.varNameCanBeRepeat": "변수명은 중복될 수 없습니다",

View File

@@ -306,6 +306,8 @@
"variableConfig.displayName": "Nazwa wyświetlana",
"variableConfig.editModalTitle": "Edytuj Pole Wejściowe",
"variableConfig.errorMsg.atLeastOneOption": "Wymagana jest co najmniej jedna opcja",
"variableConfig.errorMsg.jsonSchemaInvalid": "Schemat JSON nie jest prawidłowym JSON-em",
"variableConfig.errorMsg.jsonSchemaMustBeObject": "Schemat JSON musi mieć typ \"object\"",
"variableConfig.errorMsg.labelNameRequired": "Wymagana nazwa etykiety",
"variableConfig.errorMsg.optionRepeat": "Powtarzają się opcje",
"variableConfig.errorMsg.varNameCanBeRepeat": "Nazwa zmiennej nie może się powtarzać",

View File

@@ -306,6 +306,8 @@
"variableConfig.displayName": "Nome de exibição",
"variableConfig.editModalTitle": "Editar Campo de Entrada",
"variableConfig.errorMsg.atLeastOneOption": "Pelo menos uma opção é obrigatória",
"variableConfig.errorMsg.jsonSchemaInvalid": "O JSON Schema não é um JSON válido",
"variableConfig.errorMsg.jsonSchemaMustBeObject": "O JSON Schema deve ter o tipo \"object\"",
"variableConfig.errorMsg.labelNameRequired": "O nome do rótulo é obrigatório",
"variableConfig.errorMsg.optionRepeat": "Tem opções repetidas",
"variableConfig.errorMsg.varNameCanBeRepeat": "O nome da variável não pode ser repetido",

View File

@@ -306,6 +306,8 @@
"variableConfig.displayName": "Nume afișat",
"variableConfig.editModalTitle": "Editați câmpul de intrare",
"variableConfig.errorMsg.atLeastOneOption": "Este necesară cel puțin o opțiune",
"variableConfig.errorMsg.jsonSchemaInvalid": "Schema JSON nu este un JSON valid",
"variableConfig.errorMsg.jsonSchemaMustBeObject": "Schema JSON trebuie să aibă tipul \"object\"",
"variableConfig.errorMsg.labelNameRequired": "Numele etichetei este obligatoriu",
"variableConfig.errorMsg.optionRepeat": "Există opțiuni repetate",
"variableConfig.errorMsg.varNameCanBeRepeat": "Numele variabilei nu poate fi repetat",

View File

@@ -306,6 +306,8 @@
"variableConfig.displayName": "Отображаемое имя",
"variableConfig.editModalTitle": "Редактировать поле ввода",
"variableConfig.errorMsg.atLeastOneOption": "Требуется хотя бы один вариант",
"variableConfig.errorMsg.jsonSchemaInvalid": "JSON Schema не является корректным JSON",
"variableConfig.errorMsg.jsonSchemaMustBeObject": "JSON Schema должна иметь тип \"object\"",
"variableConfig.errorMsg.labelNameRequired": "Имя метки обязательно",
"variableConfig.errorMsg.optionRepeat": "Есть повторяющиеся варианты",
"variableConfig.errorMsg.varNameCanBeRepeat": "Имя переменной не может повторяться",

View File

@@ -306,6 +306,8 @@
"variableConfig.displayName": "Prikazno ime",
"variableConfig.editModalTitle": "Uredi vnosno polje",
"variableConfig.errorMsg.atLeastOneOption": "Potrebna je vsaj ena možnost",
"variableConfig.errorMsg.jsonSchemaInvalid": "Shema JSON ni veljaven JSON",
"variableConfig.errorMsg.jsonSchemaMustBeObject": "Shema JSON mora imeti tip \"object\"",
"variableConfig.errorMsg.labelNameRequired": "Ime nalepke je obvezno",
"variableConfig.errorMsg.optionRepeat": "Ima možnosti ponavljanja",
"variableConfig.errorMsg.varNameCanBeRepeat": "Imena spremenljivke ni mogoče ponoviti",

View File

@@ -306,6 +306,8 @@
"variableConfig.displayName": "ชื่อที่แสดง",
"variableConfig.editModalTitle": "แก้ไขฟิลด์อินพุต",
"variableConfig.errorMsg.atLeastOneOption": "จําเป็นต้องมีอย่างน้อยหนึ่งตัวเลือก",
"variableConfig.errorMsg.jsonSchemaInvalid": "JSON Schema ไม่ใช่ JSON ที่ถูกต้อง",
"variableConfig.errorMsg.jsonSchemaMustBeObject": "JSON Schema ต้องมีชนิดเป็น \"object\"",
"variableConfig.errorMsg.labelNameRequired": "ต้องมีชื่อฉลาก",
"variableConfig.errorMsg.optionRepeat": "มีตัวเลือกการทําซ้ํา",
"variableConfig.errorMsg.varNameCanBeRepeat": "ไม่สามารถทําซ้ําชื่อตัวแปรได้",

View File

@@ -306,6 +306,8 @@
"variableConfig.displayName": "Görünen Ad",
"variableConfig.editModalTitle": "Giriş Alanı Düzenle",
"variableConfig.errorMsg.atLeastOneOption": "En az bir seçenek gereklidir",
"variableConfig.errorMsg.jsonSchemaInvalid": "JSON Şeması geçerli bir JSON değil",
"variableConfig.errorMsg.jsonSchemaMustBeObject": "JSON Şemasının türü \"object\" olmalı",
"variableConfig.errorMsg.labelNameRequired": "Etiket adı gereklidir",
"variableConfig.errorMsg.optionRepeat": "Yinelenen seçenekler var",
"variableConfig.errorMsg.varNameCanBeRepeat": "Değişken adı tekrar edemez",

View File

@@ -306,6 +306,8 @@
"variableConfig.displayName": "Відображуване ім'я",
"variableConfig.editModalTitle": "Редагувати Поле Введення",
"variableConfig.errorMsg.atLeastOneOption": "Потрібно щонайменше одну опцію",
"variableConfig.errorMsg.jsonSchemaInvalid": "JSON Schema не є коректним JSON",
"variableConfig.errorMsg.jsonSchemaMustBeObject": "JSON Schema має мати тип \"object\"",
"variableConfig.errorMsg.labelNameRequired": "Потрібно вказати назву мітки",
"variableConfig.errorMsg.optionRepeat": "Є повторні опції",
"variableConfig.errorMsg.varNameCanBeRepeat": "Назва змінної не може повторюватися",

View File

@@ -306,6 +306,8 @@
"variableConfig.displayName": "Tên hiển thị",
"variableConfig.editModalTitle": "Chỉnh sửa trường nhập",
"variableConfig.errorMsg.atLeastOneOption": "Cần ít nhất một tùy chọn",
"variableConfig.errorMsg.jsonSchemaInvalid": "JSON Schema không phải là JSON hợp lệ",
"variableConfig.errorMsg.jsonSchemaMustBeObject": "JSON Schema phải có kiểu \"object\"",
"variableConfig.errorMsg.labelNameRequired": "Tên nhãn là bắt buộc",
"variableConfig.errorMsg.optionRepeat": "Có các tùy chọn trùng lặp",
"variableConfig.errorMsg.varNameCanBeRepeat": "Tên biến không được trùng lặp",

View File

@@ -306,6 +306,8 @@
"variableConfig.displayName": "显示名称",
"variableConfig.editModalTitle": "编辑变量",
"variableConfig.errorMsg.atLeastOneOption": "至少需要一个选项",
"variableConfig.errorMsg.jsonSchemaInvalid": "JSON Schema 不是合法的 JSON",
"variableConfig.errorMsg.jsonSchemaMustBeObject": "JSON Schema 的 type 必须为 \"object\"",
"variableConfig.errorMsg.labelNameRequired": "显示名称必填",
"variableConfig.errorMsg.optionRepeat": "选项不能重复",
"variableConfig.errorMsg.varNameCanBeRepeat": "变量名称不能重复",

View File

@@ -306,6 +306,8 @@
"variableConfig.displayName": "顯示名稱",
"variableConfig.editModalTitle": "編輯變數",
"variableConfig.errorMsg.atLeastOneOption": "至少需要一個選項",
"variableConfig.errorMsg.jsonSchemaInvalid": "JSON Schema 不是合法的 JSON",
"variableConfig.errorMsg.jsonSchemaMustBeObject": "JSON Schema 的 type 必須為「object」",
"variableConfig.errorMsg.labelNameRequired": "顯示名稱必填",
"variableConfig.errorMsg.optionRepeat": "選項不能重複",
"variableConfig.errorMsg.varNameCanBeRepeat": "變數名稱不能重複",

View File

@@ -59,7 +59,7 @@ export const useEducationStatus = (disable?: boolean) => {
return get<{ is_student: boolean, allow_refresh: boolean, expire_at: number | null }>('/account/education')
},
retry: false,
gcTime: 0, // No cache. Prevent switch account caused stale data
staleTime: 0, // Data expires immediately, ensuring fresh data on refetch
})
}

View File

@@ -160,6 +160,51 @@ describe('useShareChatList', () => {
})
expect(mockFetchChatList).not.toHaveBeenCalled()
})
it('should always consider data stale to ensure fresh data on conversation switch (GitHub #30378)', async () => {
// This test verifies that chat list data is always considered stale (staleTime: 0)
// which ensures fresh data is fetched when switching back to a conversation.
// Without this, users would see outdated messages until double-switching.
const queryClient = createQueryClient()
const wrapper = createWrapper(queryClient)
const params = {
conversationId: 'conversation-1',
isInstalledApp: false,
appId: undefined,
}
const initialResponse = { data: [{ id: '1', content: 'initial' }] }
const updatedResponse = { data: [{ id: '1', content: 'initial' }, { id: '2', content: 'new message' }] }
// First fetch
mockFetchChatList.mockResolvedValueOnce(initialResponse)
const { result, unmount } = renderHook(() => useShareChatList(params), { wrapper })
await waitFor(() => {
expect(result.current.data).toEqual(initialResponse)
})
expect(mockFetchChatList).toHaveBeenCalledTimes(1)
// Unmount (simulates switching away from conversation)
unmount()
// Remount with same params (simulates switching back)
// With staleTime: 0, this should trigger a background refetch
mockFetchChatList.mockResolvedValueOnce(updatedResponse)
const { result: result2 } = renderHook(() => useShareChatList(params), { wrapper })
// Should immediately return cached data
expect(result2.current.data).toEqual(initialResponse)
// Should trigger background refetch due to staleTime: 0
await waitFor(() => {
expect(mockFetchChatList).toHaveBeenCalledTimes(2)
})
// Should update with fresh data
await waitFor(() => {
expect(result2.current.data).toEqual(updatedResponse)
})
})
})
// Scenario: conversation name queries follow enabled flags and installation constraints.

View File

@@ -122,6 +122,10 @@ export const useShareChatList = (params: ShareChatListParams, options: ShareQuer
enabled: isEnabled,
refetchOnReconnect,
refetchOnWindowFocus,
// Always consider chat list data stale to ensure fresh data when switching
// back to a conversation. This fixes issue where recent messages don't appear
// until switching away and back again (GitHub issue #30378).
staleTime: 0,
})
}