mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-09 21:49:37 +00:00
feat(integrations): support switching Lark app credentials (#4703)
* feat(integrations): support switching Lark app credentials * fix(integrations): harden Lark app switching * refactor(integrations): simplify Lark switch flow * fix(integrations): reject superseded Lark flows * test(integrations): pass Lark flow generation * fix(integrations): preserve pending Lark flows --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
e16ef2969b
commit
e401ae2d7b
10
README.md
10
README.md
@ -756,6 +756,16 @@ or extend authorization. If an agent hits missing Lark authorization during a
|
||||
conversation, the managed `lark-shared` guidance points the user back to the
|
||||
same settings entry with `?settings=integrations`.
|
||||
|
||||
Once configured, **Change Lark app** lets a user point their DeerFlow account at
|
||||
a different Lark/Feishu app without a reinstall — either by pasting an existing
|
||||
app's App ID / App Secret or by re-registering an app in the browser. Switching
|
||||
is per-user (it never touches another user's credentials), validates the new
|
||||
credentials through the official CLI's live tenant-token probe before replacing
|
||||
the active app, and revokes/removes the previous app's OAuth tokens. A rejected
|
||||
credential change does not supersede an in-progress setup or authorization flow.
|
||||
DeerFlow then immediately opens browser authorization for the newly bound app so
|
||||
the switch ends in a usable connection.
|
||||
|
||||
Installing the Lark skill pack resolves the latest official `larksuite/cli`
|
||||
release from GitHub and downloads that version's skills at install time, so the
|
||||
Gateway needs outbound internet access for that step (it falls back to a
|
||||
|
||||
@ -556,7 +556,7 @@ Localhost persistence deliberately reads the direct request `Host` and ignores `
|
||||
| **Console** (`/api/console`) | Read-only cross-thread observability for the current user (the data layer for an operations dashboard or external monitoring): `GET /stats` - headline counters (runs/threads/agents/tokens/cost); `GET /runs` - paginated run history joined with thread titles (per-run cost); `GET /usage` - zero-filled daily token series + per-model breakdown with spend. Queries `runs`/`threads_meta` directly as a reporting layer (no new `RunStore` methods); requires a SQL database backend — returns 503 on `database.backend: memory`. Real-cost estimation reads optional `models[*].pricing` (`currency`, `input_per_million`, `output_per_million`, `input_cache_hit_per_million`; `ModelConfig` is `extra="allow"`, so no schema change) and prices each run from its `token_usage_by_model` input/output split. Pricing is **cache-aware**: `RunJournal` accumulates prompt-cache hits from `usage_metadata.input_token_details.cache_read` into a sparse `cache_read_tokens` bucket key (also threaded through `SubagentTokenCollector` → `record_external_llm_usage_records`), and cache-hit input tokens are billed at `input_cache_hit_per_million` (omitted → billed at the miss price, a conservative upper bound). All priced models must use one currency; mixed currencies disable cost reporting and leave cost/currency fields null instead of producing invalid aggregates. Legacy rows fall back to run-level totals at `model_name`; unpriced models yield `cost: null` and cost fields are null when no pricing is configured |
|
||||
| **MCP** (`/api/mcp`) | `GET /config` - get config; `PUT /config` - replace the full config with whole-payload stdio validation; `PATCH /config` - toggle one server while preserving the raw extensions config and validating only an enabled target; both writes reload config and reset the process-local MCP cache |
|
||||
| **Skills** (`/api/skills`) | `GET /` - list skills; `GET /{name}` - details; `PUT /{name}` - update enabled; `POST /install` - install from .skill archive (accepts standard optional frontmatter like `version`, `author`, `compatibility`); `POST /reload` - admin-only process-local prompt-cache invalidation after trusted external filesystem changes |
|
||||
| **Integrations** (`/api/integrations`) | `GET /lark/status` - inspect managed Lark/Feishu CLI integration state, including `sandbox_runtime_mode` / `sandbox_runtime_ready` (whether `lark-cli` will actually be present in the sandbox at chat time); `POST /lark/install` - admin-only install of the official `lark-*` managed skill pack; `POST /lark/config/start` and `/lark/config/complete` - internal first-time Lark connection setup; `POST /lark/auth/start` and `/lark/auth/complete` - browser device-flow user authorization without terminal access, with optional `domains` / exact `scope` for incremental permission grants |
|
||||
| **Integrations** (`/api/integrations`) | `GET /lark/status` - inspect managed Lark/Feishu CLI integration state, including `sandbox_runtime_mode` / `sandbox_runtime_ready` (whether `lark-cli` will actually be present in the sandbox at chat time); `POST /lark/install` - admin-only install of the official `lark-*` managed skill pack; `POST /lark/config/start` and `/lark/config/complete` - internal first-time Lark connection setup; `POST /lark/config/credentials` - atomically switch the caller's per-user Lark app after validating the new `app_id`/`app_secret` through the official CLI's live tenant-token probe, revoke/remove the previous OAuth tokens, and restore the prior credential tree if the switch fails; `POST /lark/auth/start` and `/lark/auth/complete` - browser device-flow user authorization without terminal access, with optional `domains` / exact `scope` for incremental permission grants. Config and auth flows carry a server-issued, per-user generation persisted under the credential lock; a rejected direct switch leaves the current generation unchanged, stale completions return 409, and browser re-registration uses the same token-clearing/revocation transaction as direct credential switches. |
|
||||
| **Memory** (`/api/memory`) | `GET /` - memory data; `POST /reload` - force reload; `GET /config` - config; `GET /status` - config + data |
|
||||
| **Uploads** (`/api/threads/{id}/uploads`) | `POST /` - upload files (auto-converts PDF/PPT/Excel/Word); `GET /list` - list; `DELETE /{filename}` - delete |
|
||||
| **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; `POST /branches` - create a new main-thread branch from a completed assistant turn checkpoint and, when an addressable pre-user replay checkpoint exists, materialize it into the branch namespace so the inherited response remains regeneratable. Workspace files are not checkpointed, so the branch only best-effort copies the current workspace when branching from the **latest** turn (`workspace_clone_mode="current_thread_best_effort"`); branching from an older/historical turn skips the copy (`workspace_clone_mode="skipped_historical_turn"`) so the branch never inherits files that only exist in a later timeline. Thread-scoped runtime channels (`sandbox`, `thread_data`) are not copied onto the branch: the parent's `sandbox_id` binds path mappings and the release lifecycle to the parent's workspace, so the branch lazily acquires its own sandbox instead. Branch creation also seeds the new thread's run-event feed from the branch checkpoint's visible messages (`history_seed_mode` in the response): the thread feed reads run_events, not checkpoints, so without the seed the inherited history disappears from the UI after the branch's first run (#4380). Seeded rows are grouped into one synthetic run per inherited turn (`branch-seed-{thread_id}-{n}`, a new turn opening at every persisted human message, including an allowlisted hidden `ask_clarification` reply) because `run_id` is a turn identity to the feed's consumers, not a provenance tag: regenerating an inherited answer supersedes that row's whole `run_id` in `GET /messages/page`, so one shared id for the entire seed deleted the complete inherited history on a branch's first regenerate (#4458); `GET /goal`, `PUT /goal`, `DELETE /goal` - read, set, and clear the active thread goal; `POST /compact` - manually summarize older active context into `summary_text` and retain the recent message window, blocked while a run is in flight; unexpected failures are logged server-side and return a generic 500 detail |
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
@ -17,12 +18,14 @@ from deerflow.integrations.lark_cli import (
|
||||
LarkCliProbe,
|
||||
LarkConfigCompleteResult,
|
||||
LarkConfigStartResult,
|
||||
LarkFlowSupersededError,
|
||||
LarkInstallResult,
|
||||
LarkIntegrationStatus,
|
||||
complete_lark_auth,
|
||||
complete_lark_config,
|
||||
get_lark_integration_status,
|
||||
install_lark_integration,
|
||||
set_lark_app_credentials,
|
||||
start_lark_auth,
|
||||
start_lark_config,
|
||||
)
|
||||
@ -94,6 +97,7 @@ class LarkAuthStartRequest(BaseModel):
|
||||
recommend: bool = Field(default=False, description="Request the official recommended auto-approve scopes")
|
||||
domains: list[str] = Field(default_factory=list, description="Optional Lark auth domains, e.g. calendar or docs")
|
||||
scope: str | None = Field(default=None, description="Optional explicit OAuth scope string")
|
||||
generation: str | None = Field(default=None, min_length=1, max_length=64, description="Optional current integration flow generation")
|
||||
|
||||
|
||||
class LarkConfigStartRequest(BaseModel):
|
||||
@ -103,6 +107,7 @@ class LarkConfigStartRequest(BaseModel):
|
||||
class LarkConfigStartResponse(BaseModel):
|
||||
verification_url: str = Field(..., description="URL the user should open in a browser to configure the Lark app")
|
||||
device_code: str = Field(..., description="Device code used by config/complete after browser approval")
|
||||
generation: str = Field(..., description="Server generation bound to this configuration flow")
|
||||
expires_in: int | None = Field(None, description="Seconds before the configuration URL expires")
|
||||
interval: int | None = Field(None, description="Suggested polling interval from Lark")
|
||||
user_code: str | None = Field(None, description="Optional user code shown by Lark")
|
||||
@ -111,6 +116,7 @@ class LarkConfigStartResponse(BaseModel):
|
||||
|
||||
class LarkConfigCompleteRequest(BaseModel):
|
||||
device_code: str = Field(..., description="Device code returned by config/start")
|
||||
generation: str = Field(..., min_length=1, max_length=64, description="Generation returned by config/start")
|
||||
brand: str = Field(default="feishu", description="Brand returned by config/start")
|
||||
interval: int | None = Field(default=None, description="Polling interval returned by config/start")
|
||||
expires_in: int | None = Field(default=None, description="Expiration returned by config/start")
|
||||
@ -119,12 +125,20 @@ class LarkConfigCompleteRequest(BaseModel):
|
||||
class LarkConfigCompleteResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
generation: str
|
||||
status: LarkIntegrationStatusResponse
|
||||
|
||||
|
||||
class LarkConfigCredentialsRequest(BaseModel):
|
||||
app_id: str = Field(..., description="Lark/Feishu App ID to switch this user's integration to")
|
||||
app_secret: str = Field(..., description="Lark/Feishu App Secret paired with app_id")
|
||||
brand: Literal["feishu", "lark"] = Field(default="feishu", description="Lark brand: feishu or lark")
|
||||
|
||||
|
||||
class LarkAuthStartResponse(BaseModel):
|
||||
verification_url: str = Field(..., description="URL the user should open in a browser to authorize")
|
||||
device_code: str = Field(..., description="Device code used by the complete endpoint after browser approval")
|
||||
generation: str = Field(..., description="Server generation bound to this authorization flow")
|
||||
expires_in: int | None = Field(None, description="Seconds before the authorization URL expires")
|
||||
user_code: str | None = Field(None, description="Optional user code shown by Lark")
|
||||
hint: str | None = Field(None, description="Optional guidance returned by lark-cli")
|
||||
@ -132,6 +146,7 @@ class LarkAuthStartResponse(BaseModel):
|
||||
|
||||
class LarkAuthCompleteRequest(BaseModel):
|
||||
device_code: str = Field(..., description="Device code returned by auth/start")
|
||||
generation: str = Field(..., min_length=1, max_length=64, description="Generation returned by auth/start")
|
||||
wait_timeout_seconds: int = Field(
|
||||
default=LARK_AUTH_COMPLETE_DEFAULT_WAIT_SECONDS,
|
||||
ge=LARK_AUTH_COMPLETE_MIN_WAIT_SECONDS,
|
||||
@ -205,6 +220,7 @@ def _config_start_to_response(result: LarkConfigStartResult) -> LarkConfigStartR
|
||||
return LarkConfigStartResponse(
|
||||
verification_url=result.verification_url,
|
||||
device_code=result.device_code,
|
||||
generation=result.generation,
|
||||
expires_in=result.expires_in,
|
||||
interval=result.interval,
|
||||
user_code=result.user_code,
|
||||
@ -216,6 +232,7 @@ def _config_complete_to_response(result: LarkConfigCompleteResult, *, include_ho
|
||||
return LarkConfigCompleteResponse(
|
||||
success=result.success,
|
||||
message=result.message,
|
||||
generation=result.generation,
|
||||
status=_status_to_response(result.status, include_host_paths=include_host_paths),
|
||||
)
|
||||
|
||||
@ -224,6 +241,7 @@ def _auth_start_to_response(result: LarkAuthStartResult) -> LarkAuthStartRespons
|
||||
return LarkAuthStartResponse(
|
||||
verification_url=result.verification_url,
|
||||
device_code=result.device_code,
|
||||
generation=result.generation,
|
||||
expires_in=result.expires_in,
|
||||
user_code=result.user_code,
|
||||
hint=result.hint,
|
||||
@ -294,6 +312,7 @@ async def complete_lark_app_config(request: Request, body: LarkConfigCompleteReq
|
||||
get_effective_user_id(),
|
||||
config,
|
||||
device_code=body.device_code,
|
||||
generation=body.generation,
|
||||
brand=body.brand,
|
||||
interval=body.interval,
|
||||
expires_in=body.expires_in,
|
||||
@ -301,13 +320,38 @@ async def complete_lark_app_config(request: Request, body: LarkConfigCompleteReq
|
||||
return _config_complete_to_response(result, include_host_paths=await _is_admin_user(request))
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except LarkFlowSupersededError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except TimeoutError as e:
|
||||
raise HTTPException(status_code=504, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to complete Lark connection setup: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Failed to complete Lark connection setup.")
|
||||
|
||||
|
||||
@router.post("/lark/config/credentials", response_model=LarkConfigCompleteResponse, summary="Switch Lark/Feishu App Credentials")
|
||||
async def switch_lark_app_credentials(request: Request, body: LarkConfigCredentialsRequest, config: AppConfig = Depends(get_config)) -> LarkConfigCompleteResponse:
|
||||
try:
|
||||
result = await asyncio.to_thread(
|
||||
set_lark_app_credentials,
|
||||
get_effective_user_id(),
|
||||
config,
|
||||
app_id=body.app_id,
|
||||
app_secret=body.app_secret,
|
||||
brand=body.brand,
|
||||
)
|
||||
return _config_complete_to_response(result, include_host_paths=await _is_admin_user(request))
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except TimeoutError as e:
|
||||
raise HTTPException(status_code=504, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to complete Lark connection setup: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Failed to complete Lark connection setup.")
|
||||
logger.error("Failed to switch Lark app credentials: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Failed to switch Lark app credentials.")
|
||||
|
||||
|
||||
@router.post("/lark/auth/start", response_model=LarkAuthStartResponse, summary="Start Lark/Feishu Browser Authorization")
|
||||
@ -319,10 +363,13 @@ async def start_lark_browser_auth(body: LarkAuthStartRequest) -> LarkAuthStartRe
|
||||
domains=tuple(body.domains),
|
||||
scope=body.scope,
|
||||
recommend=body.recommend,
|
||||
generation=body.generation,
|
||||
)
|
||||
return _auth_start_to_response(result)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except LarkFlowSupersededError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except TimeoutError as e:
|
||||
@ -340,11 +387,14 @@ async def complete_lark_browser_auth(request: Request, body: LarkAuthCompleteReq
|
||||
get_effective_user_id(),
|
||||
config,
|
||||
device_code=body.device_code,
|
||||
generation=body.generation,
|
||||
wait_timeout_seconds=body.wait_timeout_seconds,
|
||||
)
|
||||
return _auth_complete_to_response(result, include_host_paths=await _is_admin_user(request))
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except LarkFlowSupersededError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except TimeoutError as e:
|
||||
|
||||
@ -64,6 +64,8 @@ from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
from weakref import WeakValueDictionary
|
||||
|
||||
try:
|
||||
import fcntl
|
||||
@ -108,6 +110,7 @@ LARK_CLI_SANDBOX_DATA_DIR = "/mnt/integrations/lark-cli/data"
|
||||
LARK_CLI_SANDBOX_RUNTIME_DIR = "/mnt/integrations/lark-cli/runtime"
|
||||
LARK_CLI_LINUX_ARCHES = ("amd64", "arm64")
|
||||
LARK_CLI_RUNTIME_MANIFEST_FILE = ".deerflow-lark-cli-runtime.json"
|
||||
LARK_CLI_FLOW_STATE_FILE = ".deerflow-lark-cli-flow.json"
|
||||
|
||||
# Pattern B (issue #4338): loopback URL the sandbox shim uses to reach the broker
|
||||
# sidecar. LARK_BROKER_URL_ENV is imported from the broker module so the shim,
|
||||
@ -164,6 +167,8 @@ LARK_SKILL_NAMES: tuple[str, ...] = (
|
||||
LARK_SKILL_NAME_SET = frozenset(LARK_SKILL_NAMES)
|
||||
_LARK_INSTALL_THREAD_LOCK = threading.Lock()
|
||||
_LARK_RUNTIME_INSTALL_THREAD_LOCK = threading.Lock()
|
||||
_LARK_CREDENTIAL_LOCKS_GUARD = threading.Lock()
|
||||
_LARK_CREDENTIAL_LOCKS: WeakValueDictionary[str, threading.Lock] = WeakValueDictionary()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@ -216,6 +221,7 @@ class LarkInstallResult:
|
||||
class LarkConfigStartResult:
|
||||
verification_url: str
|
||||
device_code: str
|
||||
generation: str
|
||||
expires_in: int | None = None
|
||||
interval: int | None = None
|
||||
user_code: str | None = None
|
||||
@ -227,12 +233,14 @@ class LarkConfigCompleteResult:
|
||||
success: bool
|
||||
status: LarkIntegrationStatus
|
||||
message: str
|
||||
generation: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LarkAuthStartResult:
|
||||
verification_url: str
|
||||
device_code: str
|
||||
generation: str
|
||||
expires_in: int | None = None
|
||||
user_code: str | None = None
|
||||
hint: str | None = None
|
||||
@ -245,6 +253,10 @@ class LarkAuthCompleteResult:
|
||||
message: str
|
||||
|
||||
|
||||
class LarkFlowSupersededError(ValueError):
|
||||
"""Raised when a delayed integration flow is no longer current."""
|
||||
|
||||
|
||||
def lark_integration_root(_user_id: str | None = None) -> Path:
|
||||
"""Return the shared root for globally installed managed Lark skills.
|
||||
|
||||
@ -280,6 +292,10 @@ def lark_cli_data_dir(user_id: str) -> Path:
|
||||
return get_paths().user_dir(user_id) / "integrations" / INTEGRATION_ID / "data"
|
||||
|
||||
|
||||
def _lark_cli_credential_root(user_id: str) -> Path:
|
||||
return get_paths().user_dir(user_id) / "integrations" / INTEGRATION_ID
|
||||
|
||||
|
||||
def ensure_lark_cli_credential_tree(user_id: str, *, paths: Paths | None = None) -> None:
|
||||
"""Make the user's secret-bearing Lark CLI tree owner-only.
|
||||
|
||||
@ -456,6 +472,53 @@ def _exclusive_install_lock(lock_path: Path, thread_lock):
|
||||
msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
|
||||
|
||||
def _lark_credential_thread_lock(user_id: str) -> threading.Lock:
|
||||
with _LARK_CREDENTIAL_LOCKS_GUARD:
|
||||
return _LARK_CREDENTIAL_LOCKS.setdefault(user_id, threading.Lock())
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _lark_credential_lock(user_id: str):
|
||||
"""Serialize credential replacement for one user across threads/processes."""
|
||||
root = _lark_cli_credential_root(user_id)
|
||||
root.parent.mkdir(parents=True, exist_ok=True)
|
||||
lock_path = root.parent / f".{INTEGRATION_ID}.credentials.lock"
|
||||
with _exclusive_install_lock(lock_path, _lark_credential_thread_lock(user_id)):
|
||||
yield
|
||||
|
||||
|
||||
def _lark_flow_state_path(user_id: str) -> Path:
|
||||
return _lark_cli_credential_root(user_id) / LARK_CLI_FLOW_STATE_FILE
|
||||
|
||||
|
||||
def _write_lark_flow_generation_locked(user_id: str, generation: str) -> None:
|
||||
ensure_lark_cli_credential_tree(user_id)
|
||||
path = _lark_flow_state_path(user_id)
|
||||
fd, temp_name = tempfile.mkstemp(prefix=f".{LARK_CLI_FLOW_STATE_FILE}.", dir=str(path.parent))
|
||||
os.close(fd)
|
||||
temp_path = Path(temp_name)
|
||||
try:
|
||||
temp_path.write_text(json.dumps({"generation": generation}) + "\n", encoding="utf-8")
|
||||
temp_path.chmod(0o600)
|
||||
os.replace(temp_path, path)
|
||||
finally:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _advance_lark_flow_generation_locked(user_id: str) -> str:
|
||||
generation = uuid4().hex
|
||||
_write_lark_flow_generation_locked(user_id, generation)
|
||||
return generation
|
||||
|
||||
|
||||
def _require_lark_flow_generation_locked(user_id: str, generation: str) -> str:
|
||||
expected = generation.strip()
|
||||
state = _read_json_object_file(_lark_flow_state_path(user_id))
|
||||
if not expected or state is None or state.get("generation") != expected:
|
||||
raise LarkFlowSupersededError("This Lark integration flow was superseded by a newer action.")
|
||||
return expected
|
||||
|
||||
|
||||
def _ensure_managed_sandbox_lark_cli(version: str) -> Path:
|
||||
"""Install verified official Linux binaries for AIO sandbox execution."""
|
||||
tag = _normalize_lark_cli_version_tag(version)
|
||||
@ -974,6 +1037,8 @@ def _probe_provisioner_capabilities(config: AppConfig, *, timeout: float = 5.0)
|
||||
def start_lark_config(user_id: str, *, brand: str = "feishu") -> LarkConfigStartResult:
|
||||
"""Start the browser flow that creates/binds a Lark OAuth app for this user."""
|
||||
parsed_brand = _normalize_lark_brand(brand)
|
||||
with _lark_credential_lock(user_id):
|
||||
generation = _advance_lark_flow_generation_locked(user_id)
|
||||
begin_data = _request_lark_app_registration_begin(parsed_brand)
|
||||
user_code = str(begin_data.get("user_code") or "").strip()
|
||||
device_code = str(begin_data.get("device_code") or "").strip()
|
||||
@ -983,6 +1048,7 @@ def start_lark_config(user_id: str, *, brand: str = "feishu") -> LarkConfigStart
|
||||
return LarkConfigStartResult(
|
||||
verification_url=verification_url,
|
||||
device_code=device_code,
|
||||
generation=generation,
|
||||
expires_in=_int_or_none(begin_data.get("expires_in")),
|
||||
interval=_int_or_none(begin_data.get("interval")),
|
||||
user_code=user_code,
|
||||
@ -995,6 +1061,7 @@ def complete_lark_config(
|
||||
config: AppConfig,
|
||||
*,
|
||||
device_code: str,
|
||||
generation: str,
|
||||
brand: str = "feishu",
|
||||
interval: int | None = None,
|
||||
expires_in: int | None = None,
|
||||
@ -1004,6 +1071,8 @@ def complete_lark_config(
|
||||
if not device_code:
|
||||
raise ValueError("device_code is required.")
|
||||
parsed_brand = _normalize_lark_brand(brand)
|
||||
with _lark_credential_lock(user_id):
|
||||
generation = _require_lark_flow_generation_locked(user_id, generation)
|
||||
result = _poll_lark_app_registration(
|
||||
device_code=device_code,
|
||||
brand=parsed_brand,
|
||||
@ -1028,12 +1097,57 @@ def complete_lark_config(
|
||||
if not app_id or not app_secret:
|
||||
raise ValueError("Lark app registration succeeded but did not return app credentials.")
|
||||
|
||||
_save_lark_app_config_with_cli(user_id, app_id=app_id, app_secret=app_secret, brand=final_brand)
|
||||
status = get_lark_integration_status(user_id, config)
|
||||
with _lark_credential_lock(user_id):
|
||||
generation = _require_lark_flow_generation_locked(user_id, generation)
|
||||
_replace_lark_app_credentials_locked(
|
||||
user_id,
|
||||
app_id=app_id,
|
||||
app_secret=app_secret,
|
||||
brand=final_brand,
|
||||
)
|
||||
status = get_lark_integration_status(user_id, config)
|
||||
return LarkConfigCompleteResult(
|
||||
success=True,
|
||||
status=status,
|
||||
message="Lark/Feishu connection setup completed.",
|
||||
generation=generation,
|
||||
)
|
||||
|
||||
|
||||
def set_lark_app_credentials(
|
||||
user_id: str,
|
||||
config: AppConfig,
|
||||
*,
|
||||
app_id: str,
|
||||
app_secret: str,
|
||||
brand: str = "feishu",
|
||||
) -> LarkConfigCompleteResult:
|
||||
"""Atomically switch this user's app and revoke the previous OAuth token."""
|
||||
app_id = app_id.strip()
|
||||
app_secret = app_secret.strip()
|
||||
if not app_id:
|
||||
raise ValueError("app_id is required.")
|
||||
if not app_secret:
|
||||
raise ValueError("app_secret is required.")
|
||||
parsed_brand = brand.strip().lower()
|
||||
if parsed_brand not in {"feishu", "lark"}:
|
||||
raise ValueError("brand must be feishu or lark.")
|
||||
|
||||
with _lark_credential_lock(user_id):
|
||||
_validate_lark_app_credentials_with_cli(app_id=app_id, app_secret=app_secret, brand=parsed_brand)
|
||||
generation = _advance_lark_flow_generation_locked(user_id)
|
||||
_replace_lark_app_credentials_locked(
|
||||
user_id,
|
||||
app_id=app_id,
|
||||
app_secret=app_secret,
|
||||
brand=parsed_brand,
|
||||
)
|
||||
status = get_lark_integration_status(user_id, config)
|
||||
return LarkConfigCompleteResult(
|
||||
success=True,
|
||||
status=status,
|
||||
message="Lark/Feishu app switched. Reconnect to authorize the new app.",
|
||||
generation=generation,
|
||||
)
|
||||
|
||||
|
||||
@ -1043,6 +1157,7 @@ def start_lark_auth(
|
||||
domains: tuple[str, ...] = (),
|
||||
scope: str | None = None,
|
||||
recommend: bool = False,
|
||||
generation: str | None = None,
|
||||
) -> LarkAuthStartResult:
|
||||
"""Start a non-blocking Lark device authorization flow.
|
||||
|
||||
@ -1060,15 +1175,21 @@ def start_lark_auth(
|
||||
if domain:
|
||||
args.extend(["--domain", domain])
|
||||
|
||||
data = _run_lark_cli_json(args, user_id=user_id, timeout=20)
|
||||
verification_url = str(data.get("verification_url") or data.get("verification_uri_complete") or "").strip()
|
||||
device_code = str(data.get("device_code") or "").strip()
|
||||
if not verification_url or not device_code:
|
||||
raise ValueError("lark-cli did not return a verification_url and device_code.")
|
||||
with _lark_credential_lock(user_id):
|
||||
if generation is None:
|
||||
generation = _advance_lark_flow_generation_locked(user_id)
|
||||
else:
|
||||
generation = _require_lark_flow_generation_locked(user_id, generation)
|
||||
data = _run_lark_cli_json(args, user_id=user_id, timeout=20)
|
||||
verification_url = str(data.get("verification_url") or data.get("verification_uri_complete") or "").strip()
|
||||
device_code = str(data.get("device_code") or "").strip()
|
||||
if not verification_url or not device_code:
|
||||
raise ValueError("lark-cli did not return a verification_url and device_code.")
|
||||
|
||||
return LarkAuthStartResult(
|
||||
verification_url=verification_url,
|
||||
device_code=device_code,
|
||||
generation=generation,
|
||||
expires_in=_int_or_none(data.get("expires_in")),
|
||||
user_code=str(data.get("user_code") or "") or None,
|
||||
hint=str(data.get("hint") or "") or None,
|
||||
@ -1080,6 +1201,7 @@ def complete_lark_auth(
|
||||
config: AppConfig,
|
||||
*,
|
||||
device_code: str,
|
||||
generation: str,
|
||||
wait_timeout_seconds: int = LARK_AUTH_COMPLETE_DEFAULT_WAIT_SECONDS,
|
||||
) -> LarkAuthCompleteResult:
|
||||
"""Complete a Lark device authorization flow after the user approves it."""
|
||||
@ -1089,14 +1211,16 @@ def complete_lark_auth(
|
||||
if not LARK_AUTH_COMPLETE_MIN_WAIT_SECONDS <= wait_timeout_seconds <= LARK_AUTH_COMPLETE_MAX_WAIT_SECONDS:
|
||||
raise ValueError(f"wait_timeout_seconds must be between {LARK_AUTH_COMPLETE_MIN_WAIT_SECONDS} and {LARK_AUTH_COMPLETE_MAX_WAIT_SECONDS}.")
|
||||
|
||||
path = _require_lark_cli_path()
|
||||
_run_lark_cli_json(
|
||||
[path, "auth", "login", "--device-code", device_code, "--json"],
|
||||
user_id=user_id,
|
||||
timeout=wait_timeout_seconds,
|
||||
allow_empty_success=True,
|
||||
)
|
||||
status = get_lark_integration_status(user_id, config, verify_auth=True)
|
||||
with _lark_credential_lock(user_id):
|
||||
_require_lark_flow_generation_locked(user_id, generation)
|
||||
path = _require_lark_cli_path()
|
||||
_run_lark_cli_json(
|
||||
[path, "auth", "login", "--device-code", device_code, "--json"],
|
||||
user_id=user_id,
|
||||
timeout=wait_timeout_seconds,
|
||||
allow_empty_success=True,
|
||||
)
|
||||
status = get_lark_integration_status(user_id, config, verify_auth=True)
|
||||
return LarkAuthCompleteResult(
|
||||
success=status.auth.status == "authenticated",
|
||||
status=status,
|
||||
@ -1292,23 +1416,34 @@ def _tenant_brand(result: dict[str, Any]) -> str | None:
|
||||
return brand if brand in {"feishu", "lark"} else None
|
||||
|
||||
|
||||
def _save_lark_app_config_with_cli(user_id: str, *, app_id: str, app_secret: str, brand: str) -> None:
|
||||
def _lark_cli_env_for_directories(*, config_dir: Path, data_dir: Path) -> dict[str, str]:
|
||||
env = {
|
||||
**os.environ,
|
||||
"LARKSUITE_CLI_CONFIG_DIR": str(config_dir),
|
||||
"LARKSUITE_CLI_DATA_DIR": str(data_dir),
|
||||
"LARKSUITE_CLI_NO_UPDATE_NOTIFIER": "1",
|
||||
"LARKSUITE_CLI_NO_SKILLS_NOTIFIER": "1",
|
||||
}
|
||||
managed_bin = _lark_cli_managed_bin_dir()
|
||||
if _lark_cli_managed_path() is not None:
|
||||
env["PATH"] = f"{managed_bin}{os.pathsep}{os.environ.get('PATH', '')}"
|
||||
return env
|
||||
|
||||
|
||||
def _run_lark_config_init(*, app_id: str, app_secret: str, brand: str, env: dict[str, str]) -> None:
|
||||
path = _require_lark_cli_path()
|
||||
try:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[path, "config", "init", "--app-id", app_id, "--app-secret-stdin", "--brand", _normalize_lark_brand(brand)],
|
||||
input=app_secret + "\n",
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
env=lark_cli_env(user_id),
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise TimeoutError("Timed out while saving Lark connection setup.") from exc
|
||||
finally:
|
||||
ensure_lark_cli_credential_tree(user_id)
|
||||
result = subprocess.run(
|
||||
[path, "config", "init", "--app-id", app_id, "--app-secret-stdin", "--brand", brand],
|
||||
input=app_secret + "\n",
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
env=env,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise TimeoutError("Timed out while saving Lark connection setup.") from exc
|
||||
if result.returncode != 0:
|
||||
raw = (result.stderr or result.stdout or "").strip()
|
||||
parsed = _parse_json_object(raw)
|
||||
@ -1316,6 +1451,98 @@ def _save_lark_app_config_with_cli(user_id: str, *, app_id: str, app_secret: str
|
||||
raise ValueError(message or f"lark-cli config init exited with code {result.returncode}")
|
||||
|
||||
|
||||
def _save_lark_app_config_with_cli(user_id: str, *, app_id: str, app_secret: str, brand: str) -> None:
|
||||
try:
|
||||
_run_lark_config_init(
|
||||
app_id=app_id,
|
||||
app_secret=app_secret,
|
||||
brand=brand,
|
||||
env=lark_cli_env(user_id),
|
||||
)
|
||||
finally:
|
||||
ensure_lark_cli_credential_tree(user_id)
|
||||
|
||||
|
||||
def _validate_lark_app_credentials_with_cli(*, app_id: str, app_secret: str, brand: str) -> None:
|
||||
"""Validate credentials through config init's live tenant-token probe."""
|
||||
with tempfile.TemporaryDirectory(prefix=".validating-lark-app-") as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
config_dir = root / "config"
|
||||
data_dir = root / "data"
|
||||
config_dir.mkdir(mode=0o700)
|
||||
data_dir.mkdir(mode=0o700)
|
||||
_run_lark_config_init(
|
||||
app_id=app_id,
|
||||
app_secret=app_secret,
|
||||
brand=brand,
|
||||
env=_lark_cli_env_for_directories(config_dir=config_dir, data_dir=data_dir),
|
||||
)
|
||||
|
||||
|
||||
def _replace_lark_app_credentials_locked(user_id: str, *, app_id: str, app_secret: str, brand: str) -> None:
|
||||
ensure_lark_cli_credential_tree(user_id)
|
||||
root = _lark_cli_credential_root(user_id)
|
||||
with _lark_credential_transaction(user_id, root) as snapshot:
|
||||
_save_lark_app_config_with_cli(user_id, app_id=app_id, app_secret=app_secret, brand=brand)
|
||||
_clear_directory_contents(lark_cli_data_dir(user_id))
|
||||
_revoke_lark_auth_from_snapshot(snapshot)
|
||||
|
||||
|
||||
def _clear_directory_contents(directory: Path) -> None:
|
||||
if directory.is_symlink():
|
||||
raise ValueError(f"Lark CLI credential path must not be a symlink: {directory}")
|
||||
directory.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
for child in directory.iterdir():
|
||||
if child.is_dir() and not child.is_symlink():
|
||||
shutil.rmtree(child)
|
||||
else:
|
||||
child.unlink()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _lark_credential_transaction(user_id: str, root: Path):
|
||||
"""Restore the active credential tree if a switch step fails."""
|
||||
with tempfile.TemporaryDirectory(prefix=".switching-lark-app-", dir=str(root.parent)) as temp_dir:
|
||||
snapshot = Path(temp_dir) / "credentials"
|
||||
shutil.copytree(root, snapshot, symlinks=False)
|
||||
try:
|
||||
yield snapshot
|
||||
except Exception:
|
||||
_restore_lark_credential_tree(root, snapshot)
|
||||
ensure_lark_cli_credential_tree(user_id)
|
||||
raise
|
||||
|
||||
|
||||
def _restore_lark_credential_tree(root: Path, snapshot: Path) -> None:
|
||||
for name in ("config", "data"):
|
||||
target = root / name
|
||||
_clear_directory_contents(target)
|
||||
shutil.copytree(snapshot / name, target, dirs_exist_ok=True, symlinks=False)
|
||||
|
||||
|
||||
def _revoke_lark_auth_from_snapshot(snapshot: Path) -> None:
|
||||
data_dir = snapshot / "data"
|
||||
if not any(path.is_file() for path in data_dir.rglob("*")):
|
||||
return
|
||||
path = _require_lark_cli_path()
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[path, "auth", "logout", "--json"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
env=_lark_cli_env_for_directories(config_dir=snapshot / "config", data_dir=data_dir),
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise TimeoutError("Timed out while revoking the previous Lark authorization.") from exc
|
||||
if result.returncode != 0:
|
||||
raw = (result.stderr or result.stdout or "").strip()
|
||||
parsed = _parse_json_object(raw)
|
||||
message = _auth_error_message(parsed) if parsed else raw
|
||||
raise ValueError(message or f"lark-cli auth logout exited with code {result.returncode}")
|
||||
|
||||
|
||||
def _run_lark_cli_json(
|
||||
args: list[str],
|
||||
*,
|
||||
|
||||
@ -68,6 +68,11 @@ def _reset_paths(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(paths_module, "_paths", None)
|
||||
|
||||
|
||||
def _advance_lark_flow(user_id: str) -> str:
|
||||
with lark_cli._lark_credential_lock(user_id):
|
||||
return lark_cli._advance_lark_flow_generation_locked(user_id)
|
||||
|
||||
|
||||
async def _config(tmp_path: Path) -> SimpleNamespace:
|
||||
skills_root = tmp_path / "skills"
|
||||
await asyncio.to_thread((skills_root / "public").mkdir, parents=True, exist_ok=True)
|
||||
@ -119,10 +124,11 @@ async def test_lark_auth_complete_route_does_not_block_event_loop(tmp_path: Path
|
||||
|
||||
monkeypatch.setenv("PATH", f"{cli_path.parent}{os.pathsep}{os.environ.get('PATH', '')}")
|
||||
monkeypatch.setattr(integrations, "get_effective_user_id", lambda: "loop-user")
|
||||
generation = await asyncio.to_thread(_advance_lark_flow, "loop-user")
|
||||
|
||||
response = await integrations.complete_lark_browser_auth(
|
||||
request=None,
|
||||
body=integrations.LarkAuthCompleteRequest(device_code="device-code"),
|
||||
body=integrations.LarkAuthCompleteRequest(device_code="device-code", generation=generation),
|
||||
config=config,
|
||||
)
|
||||
|
||||
|
||||
@ -79,6 +79,11 @@ def _patch_paths(monkeypatch, base_dir: Path) -> None:
|
||||
monkeypatch.setattr(paths_module, "_paths", Paths(base_dir=base_dir))
|
||||
|
||||
|
||||
def _advance_lark_flow(user_id: str = "alice") -> str:
|
||||
with lark_cli._lark_credential_lock(user_id):
|
||||
return lark_cli._advance_lark_flow_generation_locked(user_id)
|
||||
|
||||
|
||||
def test_sandbox_lark_cli_env_prepends_managed_linux_runtime() -> None:
|
||||
overlay = lark_cli.lark_cli_env_overlay("alice", sandbox_paths=True)
|
||||
|
||||
@ -968,6 +973,8 @@ def test_start_lark_auth_returns_browser_url(monkeypatch, tmp_path):
|
||||
|
||||
assert result.verification_url == "https://open.feishu.cn/auth/mock"
|
||||
assert result.device_code == "device-code"
|
||||
assert result.generation
|
||||
assert json.loads(lark_cli._lark_flow_state_path("alice").read_text(encoding="utf-8")) == {"generation": result.generation}
|
||||
assert captured["args"] == [
|
||||
"/usr/bin/lark-cli",
|
||||
"auth",
|
||||
@ -1018,6 +1025,25 @@ def test_start_lark_auth_uses_minimal_login_by_default(monkeypatch, tmp_path):
|
||||
]
|
||||
|
||||
|
||||
def test_start_lark_auth_reuses_parent_flow_generation(monkeypatch, tmp_path):
|
||||
_patch_paths(monkeypatch, tmp_path / "home")
|
||||
generation = _advance_lark_flow()
|
||||
monkeypatch.setattr(lark_cli, "_require_lark_cli_path", lambda: "/usr/bin/lark-cli")
|
||||
monkeypatch.setattr(
|
||||
lark_cli,
|
||||
"_run_lark_cli_json",
|
||||
lambda *_args, **_kwargs: {
|
||||
"verification_url": "https://open.feishu.cn/auth/mock",
|
||||
"device_code": "device-code",
|
||||
},
|
||||
)
|
||||
|
||||
result = lark_cli.start_lark_auth("alice", generation=generation)
|
||||
|
||||
assert result.generation == generation
|
||||
assert json.loads(lark_cli._lark_flow_state_path("alice").read_text(encoding="utf-8")) == {"generation": generation}
|
||||
|
||||
|
||||
def test_lark_cli_env_from_runtime_exposes_settings_auth_to_lark_commands(monkeypatch, tmp_path):
|
||||
_patch_paths(monkeypatch, tmp_path / "home")
|
||||
runtime = SimpleNamespace(context={"user_id": "alice"})
|
||||
@ -1085,6 +1111,29 @@ def test_save_lark_app_config_rehardens_files_written_by_cli(monkeypatch, tmp_pa
|
||||
assert stat.S_IMODE(config_file.stat().st_mode) == 0o600
|
||||
|
||||
|
||||
def test_validate_lark_app_credentials_surfaces_cli_probe_rejection(monkeypatch, tmp_path) -> None:
|
||||
_patch_paths(monkeypatch, tmp_path / "home")
|
||||
monkeypatch.setattr(lark_cli, "_require_lark_cli_path", lambda: "/usr/bin/lark-cli")
|
||||
|
||||
def _run(args, **kwargs):
|
||||
assert kwargs["env"]["LARKSUITE_CLI_CONFIG_DIR"] != str(lark_cli.lark_cli_config_dir("alice"))
|
||||
return subprocess.CompletedProcess(
|
||||
args=args,
|
||||
returncode=3,
|
||||
stdout='{"ok":false,"error":{"type":"config","subtype":"invalid_client","message":"The specified app does not exist."}}',
|
||||
stderr="",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(lark_cli.subprocess, "run", _run)
|
||||
|
||||
with pytest.raises(ValueError, match="specified app does not exist"):
|
||||
lark_cli._validate_lark_app_credentials_with_cli(
|
||||
app_id="cli_invalid",
|
||||
app_secret="invalid-secret",
|
||||
brand="feishu",
|
||||
)
|
||||
|
||||
|
||||
def test_lark_cli_json_rehardens_auth_files_written_by_cli(monkeypatch, tmp_path) -> None:
|
||||
_patch_paths(monkeypatch, tmp_path / "home")
|
||||
|
||||
@ -1204,7 +1253,8 @@ def test_complete_lark_auth_polls_device_code_and_returns_status(monkeypatch, tm
|
||||
),
|
||||
)
|
||||
|
||||
result = lark_cli.complete_lark_auth("alice", config, device_code="device-code")
|
||||
generation = _advance_lark_flow()
|
||||
result = lark_cli.complete_lark_auth("alice", config, device_code="device-code", generation=generation)
|
||||
|
||||
assert result.success is True
|
||||
assert captured["args"] == [
|
||||
@ -1256,10 +1306,12 @@ def test_complete_lark_auth_accepts_short_automatic_poll_timeout(monkeypatch, tm
|
||||
),
|
||||
)
|
||||
|
||||
generation = _advance_lark_flow()
|
||||
result = lark_cli.complete_lark_auth(
|
||||
"alice",
|
||||
config,
|
||||
device_code="device-code",
|
||||
generation=generation,
|
||||
wait_timeout_seconds=8,
|
||||
)
|
||||
|
||||
@ -1267,14 +1319,36 @@ def test_complete_lark_auth_accepts_short_automatic_poll_timeout(monkeypatch, tm
|
||||
assert captured["timeout"] == 8
|
||||
|
||||
|
||||
def test_complete_lark_auth_rejects_superseded_generation_before_token_write(monkeypatch, tmp_path) -> None:
|
||||
_patch_paths(monkeypatch, tmp_path / "home")
|
||||
config = _config(tmp_path / "skills")
|
||||
stale_generation = _advance_lark_flow()
|
||||
current_generation = _advance_lark_flow()
|
||||
monkeypatch.setattr(
|
||||
lark_cli,
|
||||
"_run_lark_cli_json",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("stale auth must not write tokens")),
|
||||
)
|
||||
|
||||
with pytest.raises(lark_cli.LarkFlowSupersededError, match="superseded"):
|
||||
lark_cli.complete_lark_auth(
|
||||
"alice",
|
||||
config,
|
||||
device_code="stale-device-code",
|
||||
generation=stale_generation,
|
||||
)
|
||||
|
||||
assert json.loads(lark_cli._lark_flow_state_path("alice").read_text(encoding="utf-8")) == {"generation": current_generation}
|
||||
|
||||
|
||||
def test_auth_complete_request_bounds_poll_timeout() -> None:
|
||||
model = integrations_router.LarkAuthCompleteRequest(device_code="device-code", wait_timeout_seconds=8)
|
||||
model = integrations_router.LarkAuthCompleteRequest(device_code="device-code", generation="flow-generation", wait_timeout_seconds=8)
|
||||
assert "wait_timeout_seconds" in type(model).model_fields
|
||||
assert model.wait_timeout_seconds == 8
|
||||
with pytest.raises(ValueError):
|
||||
integrations_router.LarkAuthCompleteRequest(device_code="device-code", wait_timeout_seconds=4)
|
||||
integrations_router.LarkAuthCompleteRequest(device_code="device-code", generation="flow-generation", wait_timeout_seconds=4)
|
||||
with pytest.raises(ValueError):
|
||||
integrations_router.LarkAuthCompleteRequest(device_code="device-code", wait_timeout_seconds=46)
|
||||
integrations_router.LarkAuthCompleteRequest(device_code="device-code", generation="flow-generation", wait_timeout_seconds=46)
|
||||
|
||||
|
||||
def test_start_lark_config_returns_app_registration_url(monkeypatch, tmp_path):
|
||||
@ -1293,6 +1367,8 @@ def test_start_lark_config_returns_app_registration_url(monkeypatch, tmp_path):
|
||||
result = lark_cli.start_lark_config("alice", brand="feishu")
|
||||
|
||||
assert result.device_code == "config-device-code"
|
||||
assert result.generation
|
||||
assert json.loads(lark_cli._lark_flow_state_path("alice").read_text(encoding="utf-8")) == {"generation": result.generation}
|
||||
assert result.user_code == "abc"
|
||||
assert result.verification_url.startswith("https://open.feishu.cn/page/cli?")
|
||||
assert "user_code=abc" in result.verification_url
|
||||
@ -1305,6 +1381,15 @@ def test_complete_lark_config_saves_app_credentials_and_returns_status(monkeypat
|
||||
(skills_root / "custom").mkdir()
|
||||
config = _config(skills_root)
|
||||
captured: dict[str, object] = {}
|
||||
revoked: list[str] = []
|
||||
config_dir = lark_cli.lark_cli_config_dir("alice")
|
||||
data_dir = lark_cli.lark_cli_data_dir("alice")
|
||||
config_dir.mkdir(parents=True)
|
||||
data_dir.mkdir(parents=True)
|
||||
(config_dir / "config.json").write_text("old-config", encoding="utf-8")
|
||||
token_file = data_dir / "token.json"
|
||||
token_file.write_text("old-token", encoding="utf-8")
|
||||
generation = _advance_lark_flow()
|
||||
|
||||
monkeypatch.setattr(
|
||||
lark_cli,
|
||||
@ -1320,6 +1405,11 @@ def test_complete_lark_config_saves_app_credentials_and_returns_status(monkeypat
|
||||
"_save_lark_app_config_with_cli",
|
||||
lambda user_id, **kwargs: captured.update({"user_id": user_id, **kwargs}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
lark_cli,
|
||||
"_revoke_lark_auth_from_snapshot",
|
||||
lambda snapshot: revoked.append((snapshot / "data" / "token.json").read_text(encoding="utf-8")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
lark_cli,
|
||||
"get_lark_integration_status",
|
||||
@ -1342,9 +1432,18 @@ def test_complete_lark_config_saves_app_credentials_and_returns_status(monkeypat
|
||||
),
|
||||
)
|
||||
|
||||
result = lark_cli.complete_lark_config("alice", config, device_code="config-device-code", brand="feishu")
|
||||
result = lark_cli.complete_lark_config(
|
||||
"alice",
|
||||
config,
|
||||
device_code="config-device-code",
|
||||
generation=generation,
|
||||
brand="feishu",
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.generation == generation
|
||||
assert revoked == ["old-token"]
|
||||
assert not token_file.exists()
|
||||
assert captured == {
|
||||
"user_id": "alice",
|
||||
"app_id": "cli_mock",
|
||||
@ -1361,6 +1460,7 @@ def test_complete_lark_config_repolls_lark_tenant_for_client_secret(monkeypatch,
|
||||
config = _config(skills_root)
|
||||
poll_calls: list[dict[str, object]] = []
|
||||
captured: dict[str, object] = {}
|
||||
generation = _advance_lark_flow()
|
||||
|
||||
def _poll_lark_app_registration(**kwargs):
|
||||
poll_calls.append(kwargs)
|
||||
@ -1403,7 +1503,13 @@ def test_complete_lark_config_repolls_lark_tenant_for_client_secret(monkeypatch,
|
||||
),
|
||||
)
|
||||
|
||||
result = lark_cli.complete_lark_config("alice", config, device_code="config-device-code", brand="feishu")
|
||||
result = lark_cli.complete_lark_config(
|
||||
"alice",
|
||||
config,
|
||||
device_code="config-device-code",
|
||||
generation=generation,
|
||||
brand="feishu",
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert [call["brand"] for call in poll_calls] == ["feishu", "lark"]
|
||||
@ -1415,6 +1521,66 @@ def test_complete_lark_config_repolls_lark_tenant_for_client_secret(monkeypatch,
|
||||
}
|
||||
|
||||
|
||||
def test_complete_lark_config_rejects_registration_superseded_by_direct_switch(monkeypatch, tmp_path):
|
||||
_patch_paths(monkeypatch, tmp_path / "home")
|
||||
config = _config(tmp_path / "skills")
|
||||
stale_generation = _advance_lark_flow()
|
||||
poll_started = threading.Event()
|
||||
release_poll = threading.Event()
|
||||
saved_apps: list[str] = []
|
||||
monkeypatch.setattr(lark_cli, "_validate_lark_app_credentials_with_cli", lambda **_kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
lark_cli,
|
||||
"_save_lark_app_config_with_cli",
|
||||
lambda _user_id, *, app_id, **_kwargs: saved_apps.append(app_id),
|
||||
)
|
||||
monkeypatch.setattr(lark_cli, "_revoke_lark_auth_from_snapshot", lambda _snapshot: None)
|
||||
monkeypatch.setattr(
|
||||
lark_cli,
|
||||
"get_lark_integration_status",
|
||||
lambda _user_id, _config, **_kwargs: _status_stub(
|
||||
app_configured=True,
|
||||
app_id="cli_direct",
|
||||
auth_status="not_authorized",
|
||||
),
|
||||
)
|
||||
|
||||
def _poll(**_kwargs):
|
||||
poll_started.set()
|
||||
assert release_poll.wait(timeout=3)
|
||||
return {
|
||||
"client_id": "cli_stale",
|
||||
"client_secret": "stale-secret",
|
||||
"user_info": {"tenant_brand": "feishu"},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(lark_cli, "_poll_lark_app_registration", _poll)
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
completion = executor.submit(
|
||||
lark_cli.complete_lark_config,
|
||||
"alice",
|
||||
config,
|
||||
device_code="stale-device-code",
|
||||
generation=stale_generation,
|
||||
)
|
||||
assert poll_started.wait(timeout=3)
|
||||
try:
|
||||
switched = lark_cli.set_lark_app_credentials(
|
||||
"alice",
|
||||
config,
|
||||
app_id="cli_direct",
|
||||
app_secret="direct-secret",
|
||||
)
|
||||
finally:
|
||||
release_poll.set()
|
||||
with pytest.raises(lark_cli.LarkFlowSupersededError, match="superseded"):
|
||||
completion.result(timeout=3)
|
||||
|
||||
assert switched.generation != stale_generation
|
||||
assert saved_apps == ["cli_direct"]
|
||||
assert json.loads(lark_cli._lark_flow_state_path("alice").read_text(encoding="utf-8")) == {"generation": switched.generation}
|
||||
|
||||
|
||||
def _make_user(system_role: str) -> User:
|
||||
return User(email=f"{system_role}-integration@example.com", password_hash="x", system_role=system_role, id=uuid4())
|
||||
|
||||
@ -1531,6 +1697,7 @@ def test_lark_config_start_route_returns_browser_url(monkeypatch, tmp_path):
|
||||
lambda _user_id, **_kwargs: lark_cli.LarkConfigStartResult(
|
||||
verification_url="https://open.feishu.cn/page/cli?user_code=config",
|
||||
device_code="config-device-code",
|
||||
generation="config-generation",
|
||||
expires_in=600,
|
||||
interval=5,
|
||||
user_code="config",
|
||||
@ -1544,6 +1711,7 @@ def test_lark_config_start_route_returns_browser_url(monkeypatch, tmp_path):
|
||||
assert response.status_code == 200
|
||||
assert response.json()["verification_url"] == "https://open.feishu.cn/page/cli?user_code=config"
|
||||
assert response.json()["device_code"] == "config-device-code"
|
||||
assert response.json()["generation"] == "config-generation"
|
||||
|
||||
|
||||
def test_lark_config_complete_route_saves_app_credentials(monkeypatch, tmp_path):
|
||||
@ -1556,6 +1724,7 @@ def test_lark_config_complete_route_saves_app_credentials(monkeypatch, tmp_path)
|
||||
lambda _user_id, _config, *, device_code, **_kwargs: lark_cli.LarkConfigCompleteResult(
|
||||
success=True,
|
||||
message=f"configured {device_code}",
|
||||
generation="config-generation",
|
||||
status=lark_cli.LarkIntegrationStatus(
|
||||
installed=True,
|
||||
version="v1.0.65",
|
||||
@ -1579,14 +1748,44 @@ def test_lark_config_complete_route_saves_app_credentials(monkeypatch, tmp_path)
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/integrations/lark/config/complete",
|
||||
json={"device_code": "config-device-code", "brand": "feishu", "interval": 5, "expires_in": 600},
|
||||
json={
|
||||
"device_code": "config-device-code",
|
||||
"generation": "config-generation",
|
||||
"brand": "feishu",
|
||||
"interval": 5,
|
||||
"expires_in": 600,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["success"] is True
|
||||
assert response.json()["generation"] == "config-generation"
|
||||
assert response.json()["status"]["app_configured"] is True
|
||||
|
||||
|
||||
def test_lark_config_complete_route_rejects_superseded_flow(monkeypatch, tmp_path):
|
||||
config = _config(tmp_path / "skills")
|
||||
app = _make_app(system_role="user", config=config)
|
||||
monkeypatch.setattr(
|
||||
integrations_router,
|
||||
"complete_lark_config",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(lark_cli.LarkFlowSupersededError("This Lark integration flow was superseded by a newer action.")),
|
||||
)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/integrations/lark/config/complete",
|
||||
json={
|
||||
"device_code": "stale-device-code",
|
||||
"generation": "stale-generation",
|
||||
"brand": "feishu",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert "superseded" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_lark_auth_start_route_returns_browser_url(monkeypatch, tmp_path):
|
||||
config = _config(tmp_path / "skills")
|
||||
app = _make_app(system_role="user", config=config)
|
||||
@ -1600,6 +1799,7 @@ def test_lark_auth_start_route_returns_browser_url(monkeypatch, tmp_path):
|
||||
or lark_cli.LarkAuthStartResult(
|
||||
verification_url="https://open.feishu.cn/auth/mock",
|
||||
device_code="device-code",
|
||||
generation="auth-generation",
|
||||
expires_in=600,
|
||||
)
|
||||
),
|
||||
@ -1611,7 +1811,8 @@ def test_lark_auth_start_route_returns_browser_url(monkeypatch, tmp_path):
|
||||
assert response.status_code == 200
|
||||
assert response.json()["verification_url"] == "https://open.feishu.cn/auth/mock"
|
||||
assert response.json()["device_code"] == "device-code"
|
||||
assert captured_kwargs == {"domains": (), "scope": None, "recommend": False}
|
||||
assert response.json()["generation"] == "auth-generation"
|
||||
assert captured_kwargs == {"domains": (), "scope": None, "recommend": False, "generation": None}
|
||||
|
||||
|
||||
def test_lark_auth_start_route_passes_explicit_recommend(monkeypatch, tmp_path):
|
||||
@ -1627,6 +1828,7 @@ def test_lark_auth_start_route_passes_explicit_recommend(monkeypatch, tmp_path):
|
||||
or lark_cli.LarkAuthStartResult(
|
||||
verification_url="https://open.feishu.cn/auth/mock",
|
||||
device_code="device-code",
|
||||
generation="auth-generation",
|
||||
expires_in=600,
|
||||
)
|
||||
),
|
||||
@ -1638,7 +1840,7 @@ def test_lark_auth_start_route_passes_explicit_recommend(monkeypatch, tmp_path):
|
||||
assert response.status_code == 200
|
||||
assert response.json()["verification_url"] == "https://open.feishu.cn/auth/mock"
|
||||
assert response.json()["device_code"] == "device-code"
|
||||
assert captured_kwargs == {"domains": (), "scope": None, "recommend": True}
|
||||
assert captured_kwargs == {"domains": (), "scope": None, "recommend": True, "generation": None}
|
||||
|
||||
|
||||
def test_lark_auth_complete_route_polls_device_code(monkeypatch, tmp_path):
|
||||
@ -1673,10 +1875,282 @@ def test_lark_auth_complete_route_polls_device_code(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(integrations_router, "complete_lark_auth", _complete_auth)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post("/api/integrations/lark/auth/complete", json={"device_code": "device-code"})
|
||||
response = client.post(
|
||||
"/api/integrations/lark/auth/complete",
|
||||
json={"device_code": "device-code", "generation": "auth-generation"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["success"] is True
|
||||
assert response.json()["status"]["auth"]["status"] == "authenticated"
|
||||
assert response.json()["status"]["auth"]["verified"] is True
|
||||
assert captured_kwargs == {"device_code": "device-code", "wait_timeout_seconds": 45}
|
||||
assert captured_kwargs == {
|
||||
"device_code": "device-code",
|
||||
"generation": "auth-generation",
|
||||
"wait_timeout_seconds": 45,
|
||||
}
|
||||
|
||||
|
||||
def _status_stub(*, app_configured: bool, app_id: str | None, auth_status: str) -> lark_cli.LarkIntegrationStatus:
|
||||
return lark_cli.LarkIntegrationStatus(
|
||||
installed=True,
|
||||
version="v1.0.65",
|
||||
manifest_version="v1.0.65",
|
||||
latest_available_version=None,
|
||||
runtime_version_mismatch=False,
|
||||
app_configured=app_configured,
|
||||
app_id=app_id,
|
||||
app_brand="feishu",
|
||||
skills_expected=27,
|
||||
skills_installed=27,
|
||||
installed_skills=("lark-doc",),
|
||||
enabled_skills=("lark-doc",),
|
||||
install_path="/tmp/lark",
|
||||
cli=lark_cli.LarkCliProbe(available=True),
|
||||
auth=lark_cli.LarkAuthProbe(status=auth_status, user=None),
|
||||
)
|
||||
|
||||
|
||||
def test_set_lark_app_credentials_validates_switches_and_revokes_prior_auth(monkeypatch, tmp_path):
|
||||
_patch_paths(monkeypatch, tmp_path / "home")
|
||||
config = _config(tmp_path / "skills")
|
||||
calls: list[tuple[str, object]] = []
|
||||
pending_generation = _advance_lark_flow()
|
||||
|
||||
config_dir = lark_cli.lark_cli_config_dir("alice")
|
||||
data_dir = lark_cli.lark_cli_data_dir("alice")
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
(config_dir / "config.json").write_text('{"apps":[{"appId":"cli_old","appSecret":"old-secret"}]}', encoding="utf-8")
|
||||
token_file = data_dir / "token.json"
|
||||
token_file.write_text('{"access_token": "old-app-token"}', encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(
|
||||
lark_cli,
|
||||
"_validate_lark_app_credentials_with_cli",
|
||||
lambda **kwargs: calls.append(
|
||||
(
|
||||
"validate",
|
||||
{
|
||||
**kwargs,
|
||||
"generation": json.loads(lark_cli._lark_flow_state_path("alice").read_text(encoding="utf-8"))["generation"],
|
||||
},
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
def _save(user_id, **kwargs):
|
||||
calls.append(("save", {"user_id": user_id, **kwargs}))
|
||||
(config_dir / "config.json").write_text('{"apps":[{"appId":"cli_new","appSecret":"new-secret"}]}', encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(
|
||||
lark_cli,
|
||||
"_save_lark_app_config_with_cli",
|
||||
_save,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
lark_cli,
|
||||
"_revoke_lark_auth_from_snapshot",
|
||||
lambda snapshot: calls.append(("revoke", (snapshot / "data" / "token.json").read_text(encoding="utf-8"))),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
lark_cli,
|
||||
"get_lark_integration_status",
|
||||
lambda _user_id, _config, **_kwargs: _status_stub(app_configured=True, app_id="cli_new", auth_status="not_authorized"),
|
||||
)
|
||||
|
||||
result = lark_cli.set_lark_app_credentials("alice", config, app_id=" cli_new ", app_secret=" new-secret ", brand="lark")
|
||||
|
||||
assert result.success is True
|
||||
assert calls == [
|
||||
("validate", {"app_id": "cli_new", "app_secret": "new-secret", "brand": "lark", "generation": pending_generation}),
|
||||
("save", {"user_id": "alice", "app_id": "cli_new", "app_secret": "new-secret", "brand": "lark"}),
|
||||
("revoke", '{"access_token": "old-app-token"}'),
|
||||
]
|
||||
assert result.generation != pending_generation
|
||||
assert json.loads(lark_cli._lark_flow_state_path("alice").read_text(encoding="utf-8")) == {"generation": result.generation}
|
||||
assert not token_file.exists()
|
||||
|
||||
|
||||
def test_set_lark_app_credentials_validation_failure_preserves_active_tree(monkeypatch, tmp_path):
|
||||
_patch_paths(monkeypatch, tmp_path / "home")
|
||||
config = _config(tmp_path / "skills")
|
||||
config_dir = lark_cli.lark_cli_config_dir("alice")
|
||||
data_dir = lark_cli.lark_cli_data_dir("alice")
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
config_file = config_dir / "config.json"
|
||||
token_file = data_dir / "token.json"
|
||||
config_file.write_text("old-config", encoding="utf-8")
|
||||
token_file.write_text("old-token", encoding="utf-8")
|
||||
pending_generation = _advance_lark_flow()
|
||||
|
||||
monkeypatch.setattr(
|
||||
lark_cli,
|
||||
"_validate_lark_app_credentials_with_cli",
|
||||
lambda **_kwargs: (_ for _ in ()).throw(ValueError("invalid credentials")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
lark_cli,
|
||||
"_save_lark_app_config_with_cli",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("active config must not be touched")),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="invalid credentials"):
|
||||
lark_cli.set_lark_app_credentials("alice", config, app_id="cli_new", app_secret="bad-secret")
|
||||
|
||||
assert config_file.read_text(encoding="utf-8") == "old-config"
|
||||
assert token_file.read_text(encoding="utf-8") == "old-token"
|
||||
assert json.loads(lark_cli._lark_flow_state_path("alice").read_text(encoding="utf-8")) == {"generation": pending_generation}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure_step", ["save", "revoke"])
|
||||
def test_set_lark_app_credentials_failure_restores_active_tree(monkeypatch, tmp_path, failure_step):
|
||||
_patch_paths(monkeypatch, tmp_path / "home")
|
||||
config = _config(tmp_path / "skills")
|
||||
config_dir = lark_cli.lark_cli_config_dir("alice")
|
||||
data_dir = lark_cli.lark_cli_data_dir("alice")
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
config_file = config_dir / "config.json"
|
||||
token_file = data_dir / "token.json"
|
||||
config_file.write_text("old-config", encoding="utf-8")
|
||||
token_file.write_text("old-token", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(lark_cli, "_validate_lark_app_credentials_with_cli", lambda **_kwargs: None)
|
||||
|
||||
def _save(*_args, **_kwargs):
|
||||
config_file.write_text("new-config", encoding="utf-8")
|
||||
if failure_step == "save":
|
||||
raise ValueError("save failed")
|
||||
|
||||
monkeypatch.setattr(lark_cli, "_save_lark_app_config_with_cli", _save)
|
||||
monkeypatch.setattr(
|
||||
lark_cli,
|
||||
"_revoke_lark_auth_from_snapshot",
|
||||
lambda _snapshot: (_ for _ in ()).throw(ValueError("revoke failed")) if failure_step == "revoke" else None,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match=f"{failure_step} failed"):
|
||||
lark_cli.set_lark_app_credentials("alice", config, app_id="cli_new", app_secret="new-secret")
|
||||
|
||||
assert config_file.read_text(encoding="utf-8") == "old-config"
|
||||
assert token_file.read_text(encoding="utf-8") == "old-token"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("app_id", "app_secret"),
|
||||
[("", "secret"), ("cli_new", "")],
|
||||
)
|
||||
def test_set_lark_app_credentials_rejects_missing_fields(monkeypatch, tmp_path, app_id, app_secret):
|
||||
_patch_paths(monkeypatch, tmp_path / "home")
|
||||
config = _config(tmp_path / "skills")
|
||||
|
||||
def _must_not_run(*_args, **_kwargs):
|
||||
raise AssertionError("credentials must be validated before touching the CLI")
|
||||
|
||||
monkeypatch.setattr(lark_cli, "_save_lark_app_config_with_cli", _must_not_run)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
lark_cli.set_lark_app_credentials("alice", config, app_id=app_id, app_secret=app_secret)
|
||||
|
||||
|
||||
def test_set_lark_app_credentials_rejects_invalid_brand(monkeypatch, tmp_path):
|
||||
_patch_paths(monkeypatch, tmp_path / "home")
|
||||
config = _config(tmp_path / "skills")
|
||||
monkeypatch.setattr(
|
||||
lark_cli,
|
||||
"_validate_lark_app_credentials_with_cli",
|
||||
lambda **_kwargs: (_ for _ in ()).throw(AssertionError("invalid brand must fail first")),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="brand must be feishu or lark"):
|
||||
lark_cli.set_lark_app_credentials("alice", config, app_id="cli_new", app_secret="new-secret", brand="larks")
|
||||
|
||||
|
||||
def test_set_lark_app_credentials_serializes_same_user(monkeypatch, tmp_path):
|
||||
_patch_paths(monkeypatch, tmp_path / "home")
|
||||
config = _config(tmp_path / "skills")
|
||||
active = 0
|
||||
max_active = 0
|
||||
state_lock = threading.Lock()
|
||||
|
||||
def _validate(**_kwargs):
|
||||
nonlocal active, max_active
|
||||
with state_lock:
|
||||
active += 1
|
||||
max_active = max(max_active, active)
|
||||
time.sleep(0.05)
|
||||
with state_lock:
|
||||
active -= 1
|
||||
|
||||
monkeypatch.setattr(lark_cli, "_validate_lark_app_credentials_with_cli", _validate)
|
||||
monkeypatch.setattr(lark_cli, "_save_lark_app_config_with_cli", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(lark_cli, "_revoke_lark_auth_from_snapshot", lambda _snapshot: None)
|
||||
monkeypatch.setattr(
|
||||
lark_cli,
|
||||
"get_lark_integration_status",
|
||||
lambda _user_id, _config, **_kwargs: _status_stub(app_configured=True, app_id="cli_new", auth_status="not_authorized"),
|
||||
)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
results = list(
|
||||
executor.map(
|
||||
lambda suffix: lark_cli.set_lark_app_credentials(
|
||||
"alice",
|
||||
config,
|
||||
app_id=f"cli_{suffix}",
|
||||
app_secret="new-secret",
|
||||
),
|
||||
("one", "two"),
|
||||
)
|
||||
)
|
||||
|
||||
assert all(result.success for result in results)
|
||||
assert max_active == 1
|
||||
|
||||
|
||||
def test_lark_config_credentials_route_switches_app(monkeypatch, tmp_path):
|
||||
config = _config(tmp_path / "skills")
|
||||
app = _make_app(system_role="user", config=config)
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
integrations_router,
|
||||
"set_lark_app_credentials",
|
||||
lambda _user_id, _config, *, app_id, app_secret, brand: (
|
||||
captured.update({"app_id": app_id, "app_secret": app_secret, "brand": brand})
|
||||
or lark_cli.LarkConfigCompleteResult(
|
||||
success=True,
|
||||
message="Lark/Feishu app switched. Reconnect to authorize the new app.",
|
||||
generation="switch-generation",
|
||||
status=_status_stub(app_configured=True, app_id="cli_new", auth_status="not_authorized"),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/integrations/lark/config/credentials",
|
||||
json={"app_id": "cli_new", "app_secret": "new-secret", "brand": "feishu"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["success"] is True
|
||||
assert response.json()["generation"] == "switch-generation"
|
||||
assert response.json()["status"]["app_configured"] is True
|
||||
assert response.json()["status"]["auth"]["status"] == "not_authorized"
|
||||
assert captured == {"app_id": "cli_new", "app_secret": "new-secret", "brand": "feishu"}
|
||||
|
||||
|
||||
def test_lark_config_credentials_route_rejects_invalid_brand(tmp_path):
|
||||
config = _config(tmp_path / "skills")
|
||||
app = _make_app(system_role="user", config=config)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/integrations/lark/config/credentials",
|
||||
json={"app_id": "cli_new", "app_secret": "new-secret", "brand": "larks"},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
@ -83,6 +83,10 @@ The frontend is a stateful chat application. Users create **threads** (conversat
|
||||
mutation, disables switches until that mutation's success refetch completes,
|
||||
displays the backend error `detail` through a toast, and invalidates
|
||||
`["mcpConfig"]` only after success.
|
||||
Settings > Integrations uses a local generation only to suppress stale React
|
||||
callbacks; server-issued Lark flow generations must be passed through every
|
||||
config/auth completion and across switch-or-register to authorization chains
|
||||
so backend cross-tab ordering remains authoritative.
|
||||
6. Components subscribe to thread state and render updates
|
||||
|
||||
The chat header's context-window control is intentionally persistent: while `context_usage` is unavailable, `ContextUsageBadge` renders a gauge placeholder rather than unmounting; once data arrives, the same position shows the percentage. `useThreadTokenUsage` retains placeholder data only when the response `thread_id` still matches the active route, so same-thread refetches do not flicker and cross-thread navigation never displays the previous chat's usage.
|
||||
|
||||
@ -0,0 +1,36 @@
|
||||
export function POST() {
|
||||
return Response.json({
|
||||
success: true,
|
||||
message: "Lark/Feishu app switched. Reconnect to authorize the new app.",
|
||||
status: {
|
||||
installed: true,
|
||||
version: "v1.0.65",
|
||||
manifest_version: "v1.0.65",
|
||||
latest_available_version: "v1.0.65",
|
||||
runtime_version_mismatch: false,
|
||||
app_configured: true,
|
||||
app_id: "cli_switched_mock",
|
||||
app_brand: "feishu",
|
||||
skills_expected: 27,
|
||||
skills_installed: 4,
|
||||
installed_skills: ["lark-doc", "lark-im", "lark-shared", "lark-sheets"],
|
||||
enabled_skills: ["lark-doc", "lark-im", "lark-shared", "lark-sheets"],
|
||||
install_path: "/mock/integrations/skills/lark-cli",
|
||||
cli: {
|
||||
available: true,
|
||||
path: "/usr/bin/lark-cli",
|
||||
version: "lark-cli version v1.0.65",
|
||||
error: null,
|
||||
},
|
||||
auth: {
|
||||
status: "not_authorized",
|
||||
message: "Lark user authorization is not configured",
|
||||
user: null,
|
||||
verified: false,
|
||||
},
|
||||
sandbox_runtime_mode: "none",
|
||||
sandbox_runtime_ready: false,
|
||||
sandbox_runtime_detail: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
CheckCircle2Icon,
|
||||
CopyIcon,
|
||||
@ -26,6 +27,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { useAuth } from "@/core/auth/AuthProvider";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
larkIntegrationQueryKey,
|
||||
LarkIntegrationRequestError,
|
||||
type LarkAuthStartRequest,
|
||||
type LarkAuthStartResponse,
|
||||
@ -35,6 +37,7 @@ import {
|
||||
useCompleteLarkConfiguration,
|
||||
useInstallLarkIntegration,
|
||||
useLarkIntegrationStatus,
|
||||
useSetLarkAppCredentials,
|
||||
useStartLarkAuthorization,
|
||||
useStartLarkConfiguration,
|
||||
} from "@/core/integrations/lark";
|
||||
@ -124,6 +127,7 @@ export function IntegrationsSettingsPage() {
|
||||
|
||||
function LarkIntegrationCard() {
|
||||
const { t } = useI18n();
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user?.system_role === "admin";
|
||||
const { data, isLoading, error, refetch, isFetching } =
|
||||
@ -133,8 +137,15 @@ function LarkIntegrationCard() {
|
||||
const completeConfig = useCompleteLarkConfiguration();
|
||||
const startAuth = useStartLarkAuthorization();
|
||||
const completeAuth = useCompleteLarkAuthorization();
|
||||
const switchApp = useSetLarkAppCredentials();
|
||||
const [pendingFlow, setPendingFlow] = useState<PendingLarkFlow | null>(null);
|
||||
const [isCheckingConnection, setIsCheckingConnection] = useState(false);
|
||||
const [showChangeApp, setShowChangeApp] = useState(false);
|
||||
const [changeAppId, setChangeAppId] = useState("");
|
||||
const [changeAppSecret, setChangeAppSecret] = useState("");
|
||||
const [changeAppBrand, setChangeAppBrand] = useState<"feishu" | "lark">(
|
||||
"feishu",
|
||||
);
|
||||
const [selectedAuthDomains, setSelectedAuthDomains] = useState<
|
||||
LarkAuthDomain[]
|
||||
>([]);
|
||||
@ -147,10 +158,18 @@ function LarkIntegrationCard() {
|
||||
);
|
||||
const authAttemptIdRef = useRef(0);
|
||||
const authDeadlineRef = useRef(0);
|
||||
const isMountedRef = useRef(true);
|
||||
const flowGenerationRef = useRef(0);
|
||||
const connectBusy =
|
||||
startConfig.isPending || completeConfig.isPending || startAuth.isPending;
|
||||
const connectActionBusy = connectBusy || isCheckingConnection;
|
||||
startConfig.isPending ||
|
||||
completeConfig.isPending ||
|
||||
startAuth.isPending ||
|
||||
completeAuth.isPending ||
|
||||
switchApp.isPending;
|
||||
const integrationBusy =
|
||||
connectBusy ||
|
||||
isCheckingConnection ||
|
||||
pendingFlow != null ||
|
||||
install.isPending;
|
||||
const credentialsConfigured = data?.auth.status === "authenticated";
|
||||
const isConnected = credentialsConfigured && data?.auth.verified === true;
|
||||
// The sandbox-runtime readiness row only applies when the sandbox actually
|
||||
@ -180,9 +199,25 @@ function LarkIntegrationCard() {
|
||||
}
|
||||
};
|
||||
|
||||
const beginFlow = () => {
|
||||
clearAuthRetryTimer();
|
||||
authAttemptIdRef.current += 1;
|
||||
flowGenerationRef.current += 1;
|
||||
void queryClient.cancelQueries({ queryKey: larkIntegrationQueryKey });
|
||||
if (authToastIdRef.current != null) {
|
||||
toast.dismiss(authToastIdRef.current);
|
||||
authToastIdRef.current = null;
|
||||
}
|
||||
setPendingFlow(null);
|
||||
return flowGenerationRef.current;
|
||||
};
|
||||
|
||||
const isActiveFlow = (generation: number) =>
|
||||
generation === flowGenerationRef.current;
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
isMountedRef.current = false;
|
||||
flowGenerationRef.current += 1;
|
||||
if (authRetryTimeoutRef.current != null) {
|
||||
clearTimeout(authRetryTimeoutRef.current);
|
||||
}
|
||||
@ -226,39 +261,59 @@ function LarkIntegrationCard() {
|
||||
const startUserAuth = (
|
||||
browserWindow = browserWindowRef.current,
|
||||
request = authRequestRef.current,
|
||||
generation = flowGenerationRef.current,
|
||||
serverGeneration?: string,
|
||||
) => {
|
||||
startAuth.mutate(request, {
|
||||
onSuccess: (result) => {
|
||||
setPendingFlow({ kind: "auth", ...result });
|
||||
openAuthorizationUrl(result.verification_url, browserWindow);
|
||||
authToastIdRef.current = toast.info(
|
||||
t.settings.integrations.lark.authStarted,
|
||||
);
|
||||
startAutomaticAuthorizationCheck(result);
|
||||
startAuth.mutate(
|
||||
{
|
||||
...request,
|
||||
...(serverGeneration ? { generation: serverGeneration } : {}),
|
||||
},
|
||||
onError: (err) => {
|
||||
closePendingBrowserWindow(browserWindow);
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
if (!isActiveFlow(generation)) return;
|
||||
setPendingFlow({ kind: "auth", ...result });
|
||||
openAuthorizationUrl(result.verification_url, browserWindow);
|
||||
authToastIdRef.current = toast.info(
|
||||
t.settings.integrations.lark.authStarted,
|
||||
);
|
||||
startAutomaticAuthorizationCheck(result, generation);
|
||||
},
|
||||
onError: (err) => {
|
||||
if (!isActiveFlow(generation)) return;
|
||||
closePendingBrowserWindow(browserWindow);
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
};
|
||||
|
||||
const handleContinueConnection = () => {
|
||||
if (!pendingFlow || pendingFlow.kind !== "config") return;
|
||||
const generation = flowGenerationRef.current;
|
||||
completeConfig.mutate(
|
||||
{
|
||||
device_code: pendingFlow.device_code,
|
||||
generation: pendingFlow.generation,
|
||||
brand: pendingFlow.brand,
|
||||
interval: pendingFlow.interval,
|
||||
expires_in: pendingFlow.expires_in,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onSuccess: (result) => {
|
||||
if (!isActiveFlow(generation)) return;
|
||||
queryClient.setQueryData(larkIntegrationQueryKey, result.status);
|
||||
toast.success(t.settings.integrations.lark.connectionReady);
|
||||
setPendingFlow(null);
|
||||
startUserAuth(browserWindowRef.current);
|
||||
startUserAuth(
|
||||
browserWindowRef.current,
|
||||
authRequestRef.current,
|
||||
generation,
|
||||
result.generation,
|
||||
);
|
||||
},
|
||||
onError: (err) => {
|
||||
if (!isActiveFlow(generation)) return;
|
||||
setPendingFlow(null);
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
},
|
||||
@ -266,28 +321,85 @@ function LarkIntegrationCard() {
|
||||
);
|
||||
};
|
||||
|
||||
const startBrowserAppRegistration = (
|
||||
brand: "feishu" | "lark",
|
||||
browserWindow: Window | null,
|
||||
generation: number,
|
||||
) => {
|
||||
startConfig.mutate(
|
||||
{ brand },
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
if (!isActiveFlow(generation)) return;
|
||||
setPendingFlow({ kind: "config", ...result });
|
||||
openAuthorizationUrl(result.verification_url, browserWindow);
|
||||
toast.success(t.settings.integrations.lark.connectionStarted);
|
||||
},
|
||||
onError: (err) => {
|
||||
if (!isActiveFlow(generation)) return;
|
||||
closePendingBrowserWindow(browserWindow);
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const startConnectionFlow = (
|
||||
status: LarkIntegrationStatus,
|
||||
browserWindow: Window | null,
|
||||
generation: number,
|
||||
) => {
|
||||
if (!status.app_configured) {
|
||||
startConfig.mutate(
|
||||
{ brand: "feishu" },
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
setPendingFlow({ kind: "config", ...result });
|
||||
openAuthorizationUrl(result.verification_url, browserWindow);
|
||||
toast.success(t.settings.integrations.lark.connectionStarted);
|
||||
},
|
||||
onError: (err) => {
|
||||
closePendingBrowserWindow(browserWindow);
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
},
|
||||
},
|
||||
);
|
||||
startBrowserAppRegistration("feishu", browserWindow, generation);
|
||||
return;
|
||||
}
|
||||
startUserAuth(browserWindow);
|
||||
startUserAuth(browserWindow, authRequestRef.current, generation);
|
||||
};
|
||||
|
||||
const handleReRegisterInBrowser = () => {
|
||||
authRequestRef.current = buildAuthRequest();
|
||||
const browserWindow = openPendingBrowserWindow();
|
||||
const generation = beginFlow();
|
||||
startBrowserAppRegistration(changeAppBrand, browserWindow, generation);
|
||||
};
|
||||
|
||||
const handleChangeAppSubmit = () => {
|
||||
// Pre-open the browser tab synchronously inside the click gesture: the
|
||||
// subsequent user authorization runs only after the switch POST resolves,
|
||||
// and opening the window then would be outside the gesture and blocked by
|
||||
// the browser (same constraint as handleConnect).
|
||||
authRequestRef.current = buildAuthRequest();
|
||||
const browserWindow = openPendingBrowserWindow();
|
||||
const generation = beginFlow();
|
||||
switchApp.mutate(
|
||||
{
|
||||
app_id: changeAppId.trim(),
|
||||
app_secret: changeAppSecret.trim(),
|
||||
brand: changeAppBrand,
|
||||
},
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
if (!isActiveFlow(generation)) return;
|
||||
queryClient.setQueryData(larkIntegrationQueryKey, result.status);
|
||||
toast.success(t.settings.integrations.lark.changeAppSwitched);
|
||||
setChangeAppSecret("");
|
||||
setShowChangeApp(false);
|
||||
// The new app has no user authorization yet; drive the browser auth
|
||||
// flow immediately so the switch ends in a usable connection.
|
||||
startUserAuth(
|
||||
browserWindow,
|
||||
authRequestRef.current,
|
||||
generation,
|
||||
result.generation,
|
||||
);
|
||||
},
|
||||
onError: (err) => {
|
||||
if (!isActiveFlow(generation)) return;
|
||||
closePendingBrowserWindow(browserWindow);
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const buildAuthRequest = (): LarkAuthStartRequest => {
|
||||
@ -325,22 +437,32 @@ function LarkIntegrationCard() {
|
||||
// would run outside the user gesture and be blocked by the browser. Opening
|
||||
// now and closing below when it turns out unneeded keeps the popup reliable.
|
||||
const browserWindow = openPendingBrowserWindow();
|
||||
const generation = beginFlow();
|
||||
setIsCheckingConnection(true);
|
||||
try {
|
||||
const refreshed = await refetch();
|
||||
if (!isActiveFlow(generation)) return;
|
||||
const latestStatus = refreshed.data ?? data;
|
||||
startConnectionFlow(latestStatus, browserWindow);
|
||||
startConnectionFlow(latestStatus, browserWindow, generation);
|
||||
} catch (err) {
|
||||
if (!isActiveFlow(generation)) return;
|
||||
closePendingBrowserWindow(browserWindow);
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setIsCheckingConnection(false);
|
||||
if (isActiveFlow(generation)) {
|
||||
setIsCheckingConnection(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const completeAuthorization = (
|
||||
deviceCode: string,
|
||||
{ automatic, attemptId }: { automatic: boolean; attemptId?: number },
|
||||
serverGeneration: string,
|
||||
{
|
||||
automatic,
|
||||
attemptId,
|
||||
generation,
|
||||
}: { automatic: boolean; attemptId?: number; generation: number },
|
||||
) => {
|
||||
const toastOptions =
|
||||
authToastIdRef.current == null
|
||||
@ -349,6 +471,7 @@ function LarkIntegrationCard() {
|
||||
completeAuth.mutate(
|
||||
{
|
||||
device_code: deviceCode,
|
||||
generation: serverGeneration,
|
||||
...(automatic
|
||||
? { wait_timeout_seconds: AUTOMATIC_LARK_AUTH_WAIT_SECONDS }
|
||||
: {}),
|
||||
@ -358,12 +481,11 @@ function LarkIntegrationCard() {
|
||||
// react-query still fires this after the dialog unmounts; bail so we
|
||||
// don't toast, setState, refetch, or reschedule a retry timer on a
|
||||
// component that is gone.
|
||||
if (!isMountedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!isActiveFlow(generation)) return;
|
||||
if (automatic && attemptId !== authAttemptIdRef.current) {
|
||||
return;
|
||||
}
|
||||
queryClient.setQueryData(larkIntegrationQueryKey, result.status);
|
||||
if (result.success) {
|
||||
clearAuthRetryTimer();
|
||||
toast.success(result.message, toastOptions);
|
||||
@ -378,13 +500,16 @@ function LarkIntegrationCard() {
|
||||
toastOptions,
|
||||
);
|
||||
if (automatic && attemptId != null) {
|
||||
scheduleAuthorizationRetry(deviceCode, attemptId);
|
||||
scheduleAuthorizationRetry(
|
||||
deviceCode,
|
||||
serverGeneration,
|
||||
attemptId,
|
||||
generation,
|
||||
);
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
if (!isMountedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!isActiveFlow(generation)) return;
|
||||
if (automatic && attemptId !== authAttemptIdRef.current) {
|
||||
return;
|
||||
}
|
||||
@ -398,7 +523,12 @@ function LarkIntegrationCard() {
|
||||
toastOptions,
|
||||
);
|
||||
if (attemptId != null) {
|
||||
scheduleAuthorizationRetry(deviceCode, attemptId);
|
||||
scheduleAuthorizationRetry(
|
||||
deviceCode,
|
||||
serverGeneration,
|
||||
attemptId,
|
||||
generation,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@ -414,28 +544,39 @@ function LarkIntegrationCard() {
|
||||
|
||||
const scheduleAuthorizationRetry = (
|
||||
deviceCode: string,
|
||||
serverGeneration: string,
|
||||
attemptId: number,
|
||||
generation: number,
|
||||
) => {
|
||||
clearAuthRetryTimer();
|
||||
if (!isMountedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!isActiveFlow(generation)) return;
|
||||
if (Date.now() >= authDeadlineRef.current) {
|
||||
toast.info(t.settings.integrations.lark.authorizationStillPending);
|
||||
return;
|
||||
}
|
||||
authRetryTimeoutRef.current = setTimeout(() => {
|
||||
completeAuthorization(deviceCode, { automatic: true, attemptId });
|
||||
completeAuthorization(deviceCode, serverGeneration, {
|
||||
automatic: true,
|
||||
attemptId,
|
||||
generation,
|
||||
});
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const startAutomaticAuthorizationCheck = (result: LarkAuthStartResponse) => {
|
||||
const startAutomaticAuthorizationCheck = (
|
||||
result: LarkAuthStartResponse,
|
||||
generation: number,
|
||||
) => {
|
||||
clearAuthRetryTimer();
|
||||
const attemptId = authAttemptIdRef.current + 1;
|
||||
authAttemptIdRef.current = attemptId;
|
||||
authDeadlineRef.current =
|
||||
Date.now() + Math.max(result.expires_in ?? 300, 30) * 1000;
|
||||
completeAuthorization(result.device_code, { automatic: true, attemptId });
|
||||
completeAuthorization(result.device_code, result.generation, {
|
||||
automatic: true,
|
||||
attemptId,
|
||||
generation,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCompleteAuth = () => {
|
||||
@ -445,7 +586,10 @@ function LarkIntegrationCard() {
|
||||
}
|
||||
clearAuthRetryTimer();
|
||||
authAttemptIdRef.current += 1;
|
||||
completeAuthorization(pendingFlow.device_code, { automatic: false });
|
||||
completeAuthorization(pendingFlow.device_code, pendingFlow.generation, {
|
||||
automatic: false,
|
||||
generation: flowGenerationRef.current,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCopyAuthLink = async () => {
|
||||
@ -461,12 +605,12 @@ function LarkIntegrationCard() {
|
||||
const installDisabled =
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
||||
!isAdmin ||
|
||||
install.isPending;
|
||||
integrationBusy;
|
||||
const authDisabled =
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
||||
!data?.installed ||
|
||||
!data?.cli.available ||
|
||||
connectActionBusy;
|
||||
integrationBusy;
|
||||
|
||||
const connectButtonLabel = isCheckingConnection
|
||||
? t.settings.integrations.lark.checkingConnection
|
||||
@ -637,7 +781,7 @@ function LarkIntegrationCard() {
|
||||
size="sm"
|
||||
variant={selected ? "default" : "outline"}
|
||||
onClick={() => toggleAuthDomain(domain.id)}
|
||||
disabled={connectActionBusy}
|
||||
disabled={integrationBusy}
|
||||
title={domain.description}
|
||||
>
|
||||
{domain.label}
|
||||
@ -651,7 +795,7 @@ function LarkIntegrationCard() {
|
||||
onChange={(event) =>
|
||||
setCustomAuthScope(event.currentTarget.value)
|
||||
}
|
||||
disabled={connectActionBusy}
|
||||
disabled={integrationBusy}
|
||||
placeholder={
|
||||
t.settings.integrations.lark.customScopePlaceholder
|
||||
}
|
||||
@ -679,17 +823,110 @@ function LarkIntegrationCard() {
|
||||
onClick={() => void handleConnect()}
|
||||
disabled={authDisabled}
|
||||
>
|
||||
{connectActionBusy ? (
|
||||
{connectBusy || isCheckingConnection ? (
|
||||
<RefreshCwIcon className="size-4 animate-spin" />
|
||||
) : null}
|
||||
{connectButtonLabel}
|
||||
</Button>
|
||||
{data.installed && data.cli.available && data.app_configured && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setShowChangeApp((current) => !current)}
|
||||
disabled={
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
||||
integrationBusy
|
||||
}
|
||||
>
|
||||
{t.settings.integrations.lark.changeAppButton}
|
||||
</Button>
|
||||
)}
|
||||
{!isAdmin && (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t.settings.integrations.adminRequired}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{showChangeApp && data.installed && data.cli.available && (
|
||||
<div className="space-y-3 rounded-lg border p-3">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">
|
||||
{t.settings.integrations.lark.changeAppTitle}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t.settings.integrations.lark.changeAppDescription}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(["feishu", "lark"] as const).map((brand) => (
|
||||
<Button
|
||||
key={brand}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={changeAppBrand === brand ? "default" : "outline"}
|
||||
onClick={() => setChangeAppBrand(brand)}
|
||||
disabled={integrationBusy}
|
||||
>
|
||||
{brand === "feishu"
|
||||
? t.settings.integrations.lark.brandFeishu
|
||||
: t.settings.integrations.lark.brandLark}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
value={changeAppId}
|
||||
onChange={(event) =>
|
||||
setChangeAppId(event.currentTarget.value)
|
||||
}
|
||||
disabled={integrationBusy}
|
||||
placeholder={t.settings.integrations.lark.changeAppIdLabel}
|
||||
aria-label={t.settings.integrations.lark.changeAppIdLabel}
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
value={changeAppSecret}
|
||||
onChange={(event) =>
|
||||
setChangeAppSecret(event.currentTarget.value)
|
||||
}
|
||||
disabled={integrationBusy}
|
||||
placeholder={
|
||||
t.settings.integrations.lark.changeAppSecretLabel
|
||||
}
|
||||
aria-label={
|
||||
t.settings.integrations.lark.changeAppSecretLabel
|
||||
}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t.settings.integrations.lark.changeAppAuthResetNote}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleChangeAppSubmit}
|
||||
disabled={
|
||||
integrationBusy ||
|
||||
!changeAppId.trim() ||
|
||||
!changeAppSecret.trim()
|
||||
}
|
||||
>
|
||||
{switchApp.isPending ? (
|
||||
<RefreshCwIcon className="size-4 animate-spin" />
|
||||
) : null}
|
||||
{t.settings.integrations.lark.changeAppSubmit}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleReRegisterInBrowser}
|
||||
disabled={integrationBusy}
|
||||
>
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
{t.settings.integrations.lark.changeAppReRegister}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{install.isPending && (
|
||||
<Alert>
|
||||
<RefreshCwIcon className="size-4 animate-spin" />
|
||||
|
||||
@ -910,6 +910,20 @@ export const enUS: Translations = {
|
||||
requestPermissions: "Request permissions",
|
||||
alreadyConnected:
|
||||
"Lark is already connected. If authorization expires, refresh the status and reconnect.",
|
||||
changeAppButton: "Change Lark app",
|
||||
changeAppTitle: "Switch to a different Lark app",
|
||||
changeAppDescription:
|
||||
"Point your DeerFlow account at a different Lark/Feishu app. This only affects your account; other users are not changed.",
|
||||
changeAppIdLabel: "App ID",
|
||||
changeAppSecretLabel: "App Secret",
|
||||
changeAppAuthResetNote:
|
||||
"Switching revokes the previous app's authorization. You will authorize the new app next.",
|
||||
changeAppSubmit: "Switch app",
|
||||
changeAppReRegister: "Re-register in browser",
|
||||
changeAppSwitched:
|
||||
"Lark app switched. Reconnect to authorize the new app.",
|
||||
brandFeishu: "Feishu",
|
||||
brandLark: "Lark",
|
||||
connectionStarted: "Connection link opened",
|
||||
connectionReady: "Connection is ready. Opening authorization...",
|
||||
authStarted:
|
||||
|
||||
@ -769,6 +769,17 @@ export interface Translations {
|
||||
connectedAction: string;
|
||||
requestPermissions: string;
|
||||
alreadyConnected: string;
|
||||
changeAppButton: string;
|
||||
changeAppTitle: string;
|
||||
changeAppDescription: string;
|
||||
changeAppIdLabel: string;
|
||||
changeAppSecretLabel: string;
|
||||
changeAppAuthResetNote: string;
|
||||
changeAppSubmit: string;
|
||||
changeAppReRegister: string;
|
||||
changeAppSwitched: string;
|
||||
brandFeishu: string;
|
||||
brandLark: string;
|
||||
connectionStarted: string;
|
||||
connectionReady: string;
|
||||
authStarted: string;
|
||||
|
||||
@ -872,6 +872,19 @@ export const zhCN: Translations = {
|
||||
requestPermissions: "申请新权限",
|
||||
alreadyConnected:
|
||||
"飞书已连接,无需重复授权。如果授权已过期,刷新状态后可重新连接。",
|
||||
changeAppButton: "切换飞书 Bot",
|
||||
changeAppTitle: "切换到其他飞书 App",
|
||||
changeAppDescription:
|
||||
"把你的 DeerFlow 账号指向另一个 Lark/飞书 App。只影响你自己的账号,不影响其他用户。",
|
||||
changeAppIdLabel: "App ID",
|
||||
changeAppSecretLabel: "App Secret",
|
||||
changeAppAuthResetNote:
|
||||
"切换时会撤销旧 App 的授权,随后需要授权新 App。",
|
||||
changeAppSubmit: "切换 App",
|
||||
changeAppReRegister: "在浏览器重新注册",
|
||||
changeAppSwitched: "已切换飞书 App。请重新连接以授权新 App。",
|
||||
brandFeishu: "飞书",
|
||||
brandLark: "Lark",
|
||||
connectionStarted: "连接链接已打开",
|
||||
connectionReady: "连接准备已完成,正在打开授权链接",
|
||||
authStarted: "授权页已打开,DeerFlow 会自动检测授权结果。",
|
||||
|
||||
@ -8,6 +8,7 @@ import type {
|
||||
LarkAuthStartResponse,
|
||||
LarkConfigCompleteRequest,
|
||||
LarkConfigCompleteResponse,
|
||||
LarkConfigCredentialsRequest,
|
||||
LarkConfigStartRequest,
|
||||
LarkConfigStartResponse,
|
||||
LarkInstallResponse,
|
||||
@ -35,9 +36,12 @@ async function readErrorDetail(response: Response): Promise<string> {
|
||||
return data.detail ?? `HTTP ${response.status}: ${response.statusText}`;
|
||||
}
|
||||
|
||||
export async function loadLarkIntegrationStatus(): Promise<LarkIntegrationStatus> {
|
||||
export async function loadLarkIntegrationStatus(
|
||||
signal?: AbortSignal,
|
||||
): Promise<LarkIntegrationStatus> {
|
||||
const response = await fetch(
|
||||
`${getBackendBaseURL()}/api/integrations/lark/status`,
|
||||
{ signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new LarkIntegrationRequestError(
|
||||
@ -130,6 +134,28 @@ export async function completeLarkConfiguration(
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function setLarkAppCredentials(
|
||||
request: LarkConfigCredentialsRequest,
|
||||
): Promise<LarkConfigCompleteResponse> {
|
||||
const response = await fetch(
|
||||
`${getBackendBaseURL()}/api/integrations/lark/config/credentials`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new LarkIntegrationRequestError(
|
||||
response.status,
|
||||
await readErrorDetail(response),
|
||||
);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function completeLarkAuthorization(
|
||||
request: LarkAuthCompleteRequest,
|
||||
): Promise<LarkAuthCompleteResponse> {
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
completeLarkConfiguration,
|
||||
installLarkIntegration,
|
||||
loadLarkIntegrationStatus,
|
||||
setLarkAppCredentials,
|
||||
startLarkAuthorization,
|
||||
startLarkConfiguration,
|
||||
} from "./api";
|
||||
@ -14,7 +15,7 @@ export const larkIntegrationQueryKey = ["integrations", "lark"] as const;
|
||||
export function useLarkIntegrationStatus() {
|
||||
return useQuery({
|
||||
queryKey: larkIntegrationQueryKey,
|
||||
queryFn: loadLarkIntegrationStatus,
|
||||
queryFn: ({ signal }) => loadLarkIntegrationStatus(signal),
|
||||
});
|
||||
}
|
||||
|
||||
@ -45,24 +46,19 @@ export function useStartLarkConfiguration() {
|
||||
}
|
||||
|
||||
export function useCompleteLarkConfiguration() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: completeLarkConfiguration,
|
||||
onSuccess: async (result) => {
|
||||
queryClient.setQueryData(larkIntegrationQueryKey, result.status);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: larkIntegrationQueryKey,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetLarkAppCredentials() {
|
||||
return useMutation({
|
||||
mutationFn: setLarkAppCredentials,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCompleteLarkAuthorization() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: completeLarkAuthorization,
|
||||
onSuccess: (result) => {
|
||||
queryClient.setQueryData(larkIntegrationQueryKey, result.status);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -55,11 +55,13 @@ export interface LarkAuthStartRequest {
|
||||
recommend?: boolean;
|
||||
domains?: string[];
|
||||
scope?: string | null;
|
||||
generation?: string;
|
||||
}
|
||||
|
||||
export interface LarkAuthStartResponse {
|
||||
verification_url: string;
|
||||
device_code: string;
|
||||
generation: string;
|
||||
expires_in: number | null;
|
||||
user_code: string | null;
|
||||
hint: string | null;
|
||||
@ -72,6 +74,7 @@ export interface LarkConfigStartRequest {
|
||||
export interface LarkConfigStartResponse {
|
||||
verification_url: string;
|
||||
device_code: string;
|
||||
generation: string;
|
||||
expires_in: number | null;
|
||||
interval: number | null;
|
||||
user_code: string | null;
|
||||
@ -80,19 +83,28 @@ export interface LarkConfigStartResponse {
|
||||
|
||||
export interface LarkConfigCompleteRequest {
|
||||
device_code: string;
|
||||
generation: string;
|
||||
brand: "feishu" | "lark";
|
||||
interval: number | null;
|
||||
expires_in: number | null;
|
||||
}
|
||||
|
||||
export interface LarkConfigCredentialsRequest {
|
||||
app_id: string;
|
||||
app_secret: string;
|
||||
brand: "feishu" | "lark";
|
||||
}
|
||||
|
||||
export interface LarkConfigCompleteResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
generation: string;
|
||||
status: LarkIntegrationStatus;
|
||||
}
|
||||
|
||||
export interface LarkAuthCompleteRequest {
|
||||
device_code: string;
|
||||
generation: string;
|
||||
wait_timeout_seconds?: number;
|
||||
}
|
||||
|
||||
|
||||
@ -2,6 +2,39 @@ import { expect, test } from "@playwright/test";
|
||||
|
||||
import { mockLangGraphAPI } from "./utils/mock-api";
|
||||
|
||||
function configuredLarkStatus() {
|
||||
return {
|
||||
installed: true,
|
||||
version: "v1.0.65",
|
||||
manifest_version: "v1.0.65",
|
||||
latest_available_version: "v1.0.65",
|
||||
runtime_version_mismatch: false,
|
||||
app_configured: true,
|
||||
app_id: "cli_existing_mock",
|
||||
app_brand: "feishu",
|
||||
skills_expected: 27,
|
||||
skills_installed: 4,
|
||||
installed_skills: ["lark-doc", "lark-im", "lark-shared", "lark-sheets"],
|
||||
enabled_skills: ["lark-doc", "lark-im", "lark-shared", "lark-sheets"],
|
||||
install_path: "/mock/integrations/skills/lark-cli",
|
||||
cli: {
|
||||
available: true,
|
||||
path: "/usr/bin/lark-cli",
|
||||
version: "lark-cli version v1.0.65",
|
||||
error: null,
|
||||
},
|
||||
auth: {
|
||||
status: "authenticated",
|
||||
message: "Lark authorization is live-verified.",
|
||||
user: "existing-user",
|
||||
verified: true,
|
||||
},
|
||||
sandbox_runtime_mode: "none",
|
||||
sandbox_runtime_ready: false,
|
||||
sandbox_runtime_detail: null,
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("Integrations settings", () => {
|
||||
test("opens integrations settings from a query-string deep link", async ({
|
||||
page,
|
||||
@ -73,6 +106,7 @@ test.describe("Integrations settings", () => {
|
||||
body: JSON.stringify({
|
||||
verification_url: "about:blank",
|
||||
device_code: "mock-config-device-code",
|
||||
generation: "config-generation",
|
||||
expires_in: 600,
|
||||
interval: 5,
|
||||
user_code: "config",
|
||||
@ -82,12 +116,14 @@ test.describe("Integrations settings", () => {
|
||||
});
|
||||
await page.route("**/api/integrations/lark/auth/start", async (route) => {
|
||||
authStartRequest = route.request().postDataJSON();
|
||||
const request = authStartRequest as { generation?: string };
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
verification_url: "https://open.feishu.cn/auth/mock-device",
|
||||
device_code: "mock-device-code",
|
||||
generation: request.generation ?? "auth-generation",
|
||||
expires_in: 600,
|
||||
user_code: null,
|
||||
hint: null,
|
||||
@ -141,12 +177,14 @@ test.describe("Integrations settings", () => {
|
||||
recommend: false,
|
||||
domains: ["calendar"],
|
||||
scope: "calendar:calendar.event:read",
|
||||
generation: "config-generation",
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(() => authCompleteRequests)
|
||||
.toContainEqual({
|
||||
device_code: "mock-device-code",
|
||||
generation: "config-generation",
|
||||
wait_timeout_seconds: 8,
|
||||
});
|
||||
await expect(
|
||||
@ -163,4 +201,164 @@ test.describe("Integrations settings", () => {
|
||||
dialog.getByText("https://open.feishu.cn/auth/mock-device"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("can switch the Lark app by entering new credentials", async ({
|
||||
page,
|
||||
}) => {
|
||||
mockLangGraphAPI(page);
|
||||
|
||||
// A configured + CLI-available account is the precondition for surfacing
|
||||
// the "Change Lark app" control.
|
||||
const configuredStatus = configuredLarkStatus();
|
||||
await page.route("**/api/integrations/lark/status", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(configuredStatus),
|
||||
});
|
||||
});
|
||||
|
||||
let credentialsRequest: unknown;
|
||||
let releaseCredentials!: () => void;
|
||||
const credentialsGate = new Promise<void>((resolve) => {
|
||||
releaseCredentials = resolve;
|
||||
});
|
||||
await page.route(
|
||||
"**/api/integrations/lark/config/credentials",
|
||||
async (route) => {
|
||||
credentialsRequest = route.request().postDataJSON();
|
||||
await credentialsGate;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
message:
|
||||
"Lark/Feishu app switched. Reconnect to authorize the new app.",
|
||||
generation: "switch-generation",
|
||||
status: {
|
||||
...configuredStatus,
|
||||
app_id: "cli_new_mock",
|
||||
auth: {
|
||||
status: "not_authorized",
|
||||
message: "not authorized",
|
||||
user: null,
|
||||
verified: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
},
|
||||
);
|
||||
let authStartRequest: unknown;
|
||||
await page.route("**/api/integrations/lark/auth/start", async (route) => {
|
||||
authStartRequest = route.request().postDataJSON();
|
||||
const request = authStartRequest as { generation?: string };
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
verification_url: "https://open.feishu.cn/auth/switched-app",
|
||||
device_code: "switched-device-code",
|
||||
generation: request.generation ?? "auth-generation",
|
||||
expires_in: 600,
|
||||
user_code: null,
|
||||
hint: null,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/workspace/chats/new?settings=integrations");
|
||||
|
||||
const dialog = page.getByRole("dialog", { name: "Settings" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByText("Lark / Feishu CLI")).toBeVisible();
|
||||
|
||||
// Reveal the switch form and submit new app credentials.
|
||||
await dialog.getByRole("button", { name: "Change Lark app" }).click();
|
||||
await expect(
|
||||
dialog.getByText("Switch to a different Lark app"),
|
||||
).toBeVisible();
|
||||
await dialog.getByLabel("App ID").fill("cli_new_mock");
|
||||
await dialog.getByLabel("App Secret").fill("super-secret");
|
||||
const popupPromise = page.waitForEvent("popup");
|
||||
await dialog.getByRole("button", { name: "Switch app" }).click();
|
||||
const popup = await popupPromise;
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: "Opening connection link..." }),
|
||||
).toBeDisabled();
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: "Re-register in browser" }),
|
||||
).toBeDisabled();
|
||||
releaseCredentials();
|
||||
|
||||
await expect
|
||||
.poll(() => credentialsRequest)
|
||||
.toMatchObject({
|
||||
app_id: "cli_new_mock",
|
||||
app_secret: "super-secret",
|
||||
brand: "feishu",
|
||||
});
|
||||
|
||||
// Switching a bot immediately drives the new app's browser authorization.
|
||||
await expect
|
||||
.poll(() => authStartRequest)
|
||||
.toEqual({
|
||||
recommend: false,
|
||||
domains: [],
|
||||
scope: null,
|
||||
generation: "switch-generation",
|
||||
});
|
||||
await expect
|
||||
.poll(() => popup.url())
|
||||
.toBe("https://open.feishu.cn/auth/switched-app");
|
||||
await popup.close();
|
||||
});
|
||||
|
||||
test("keeps selected permissions when re-registering the Lark app", async ({
|
||||
page,
|
||||
}) => {
|
||||
mockLangGraphAPI(page);
|
||||
const configuredStatus = configuredLarkStatus();
|
||||
await page.route("**/api/integrations/lark/status", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(configuredStatus),
|
||||
});
|
||||
});
|
||||
let authStartRequest: unknown;
|
||||
await page.route("**/api/integrations/lark/auth/start", async (route) => {
|
||||
authStartRequest = route.request().postDataJSON();
|
||||
await route.fallback();
|
||||
});
|
||||
|
||||
await page.goto("/workspace/chats/new?settings=integrations");
|
||||
const dialog = page.getByRole("dialog", { name: "Settings" });
|
||||
await dialog.getByRole("button", { name: "Calendar" }).click();
|
||||
await dialog
|
||||
.getByLabel("Exact OAuth scope")
|
||||
.fill("calendar:calendar.event:read");
|
||||
await dialog.getByRole("button", { name: "Change Lark app" }).click();
|
||||
const popupPromise = page.waitForEvent("popup");
|
||||
await dialog
|
||||
.getByRole("button", { name: "Re-register in browser" })
|
||||
.click();
|
||||
const popup = await popupPromise;
|
||||
await dialog
|
||||
.getByRole("button", {
|
||||
name: "I completed browser confirmation, continue",
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect
|
||||
.poll(() => authStartRequest)
|
||||
.toEqual({
|
||||
recommend: false,
|
||||
domains: ["calendar"],
|
||||
scope: "calendar:calendar.event:read",
|
||||
generation: "config-generation",
|
||||
});
|
||||
await popup.close();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1248,6 +1248,7 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
||||
body: JSON.stringify({
|
||||
verification_url: "https://open.feishu.cn/page/cli?user_code=config",
|
||||
device_code: "mock-config-device-code",
|
||||
generation: "config-generation",
|
||||
expires_in: 600,
|
||||
interval: 5,
|
||||
user_code: "config",
|
||||
@ -1278,6 +1279,36 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
message: "Lark/Feishu connection setup completed.",
|
||||
generation: "config-generation",
|
||||
status: larkIntegrationStatus,
|
||||
}),
|
||||
});
|
||||
}
|
||||
return route.fallback();
|
||||
});
|
||||
|
||||
void page.route("**/api/integrations/lark/config/credentials", (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
larkIntegrationStatus = {
|
||||
...larkIntegrationStatus,
|
||||
app_configured: true,
|
||||
app_id: "cli_switched_mock",
|
||||
app_brand: "feishu",
|
||||
auth: {
|
||||
status: "not_authorized",
|
||||
message: "Lark user authorization is not configured",
|
||||
user: null,
|
||||
verified: false,
|
||||
},
|
||||
};
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
message:
|
||||
"Lark/Feishu app switched. Reconnect to authorize the new app.",
|
||||
generation: "switch-generation",
|
||||
status: larkIntegrationStatus,
|
||||
}),
|
||||
});
|
||||
@ -1287,12 +1318,16 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
||||
|
||||
void page.route("**/api/integrations/lark/auth/start", (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
const request = route.request().postDataJSON() as {
|
||||
generation?: string;
|
||||
};
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
verification_url: "https://open.feishu.cn/auth/mock-device",
|
||||
device_code: "mock-device-code",
|
||||
generation: request.generation ?? "auth-generation",
|
||||
expires_in: 600,
|
||||
user_code: null,
|
||||
hint: null,
|
||||
|
||||
@ -15,6 +15,7 @@ import {
|
||||
installLarkIntegration,
|
||||
LarkIntegrationRequestError,
|
||||
loadLarkIntegrationStatus,
|
||||
setLarkAppCredentials,
|
||||
startLarkAuthorization,
|
||||
startLarkConfiguration,
|
||||
} from "@/core/integrations/lark/api";
|
||||
@ -35,6 +36,7 @@ beforeEach(() => {
|
||||
|
||||
describe("lark integration api", () => {
|
||||
test("loads status", async () => {
|
||||
const controller = new AbortController();
|
||||
mockedFetch.mockResolvedValueOnce(
|
||||
jsonResponse(200, {
|
||||
installed: false,
|
||||
@ -58,7 +60,9 @@ describe("lark integration api", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(loadLarkIntegrationStatus()).resolves.toMatchObject({
|
||||
await expect(
|
||||
loadLarkIntegrationStatus(controller.signal),
|
||||
).resolves.toMatchObject({
|
||||
installed: false,
|
||||
version: "v1.0.65",
|
||||
sandbox_runtime_mode: "init-container",
|
||||
@ -66,6 +70,7 @@ describe("lark integration api", () => {
|
||||
});
|
||||
expect(mockedFetch).toHaveBeenCalledWith(
|
||||
"/backend/api/integrations/lark/status",
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
});
|
||||
|
||||
@ -134,6 +139,7 @@ describe("lark integration api", () => {
|
||||
jsonResponse(200, {
|
||||
verification_url: "https://open.feishu.cn/auth/mock",
|
||||
device_code: "device-code",
|
||||
generation: "auth-generation",
|
||||
expires_in: 600,
|
||||
user_code: null,
|
||||
hint: null,
|
||||
@ -149,6 +155,7 @@ describe("lark integration api", () => {
|
||||
).resolves.toEqual({
|
||||
verification_url: "https://open.feishu.cn/auth/mock",
|
||||
device_code: "device-code",
|
||||
generation: "auth-generation",
|
||||
expires_in: 600,
|
||||
user_code: null,
|
||||
hint: null,
|
||||
@ -172,6 +179,7 @@ describe("lark integration api", () => {
|
||||
jsonResponse(200, {
|
||||
verification_url: "https://open.feishu.cn/page/cli?user_code=config",
|
||||
device_code: "config-device-code",
|
||||
generation: "config-generation",
|
||||
expires_in: 600,
|
||||
interval: 5,
|
||||
user_code: "config",
|
||||
@ -182,6 +190,7 @@ describe("lark integration api", () => {
|
||||
await expect(startLarkConfiguration({ brand: "feishu" })).resolves.toEqual({
|
||||
verification_url: "https://open.feishu.cn/page/cli?user_code=config",
|
||||
device_code: "config-device-code",
|
||||
generation: "config-generation",
|
||||
expires_in: 600,
|
||||
interval: 5,
|
||||
user_code: "config",
|
||||
@ -202,6 +211,7 @@ describe("lark integration api", () => {
|
||||
jsonResponse(200, {
|
||||
success: true,
|
||||
message: "Lark/Feishu connection setup completed.",
|
||||
generation: "config-generation",
|
||||
status: {
|
||||
installed: true,
|
||||
version: "v1.0.65",
|
||||
@ -234,6 +244,7 @@ describe("lark integration api", () => {
|
||||
await expect(
|
||||
completeLarkConfiguration({
|
||||
device_code: "config-device-code",
|
||||
generation: "config-generation",
|
||||
brand: "feishu",
|
||||
interval: 5,
|
||||
expires_in: 600,
|
||||
@ -249,6 +260,7 @@ describe("lark integration api", () => {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
device_code: "config-device-code",
|
||||
generation: "config-generation",
|
||||
brand: "feishu",
|
||||
interval: 5,
|
||||
expires_in: 600,
|
||||
@ -257,6 +269,66 @@ describe("lark integration api", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("switches app credentials", async () => {
|
||||
mockedFetch.mockResolvedValueOnce(
|
||||
jsonResponse(200, {
|
||||
success: true,
|
||||
message:
|
||||
"Lark/Feishu app switched. Reconnect to authorize the new app.",
|
||||
generation: "switch-generation",
|
||||
status: {
|
||||
installed: true,
|
||||
version: "v1.0.65",
|
||||
manifest_version: "v1.0.65",
|
||||
latest_available_version: "v1.0.65",
|
||||
runtime_version_mismatch: false,
|
||||
app_configured: true,
|
||||
app_id: "cli_new",
|
||||
app_brand: "feishu",
|
||||
skills_expected: 27,
|
||||
skills_installed: 1,
|
||||
installed_skills: ["lark-doc"],
|
||||
enabled_skills: ["lark-doc"],
|
||||
install_path: "/tmp/lark-cli",
|
||||
cli: {
|
||||
available: true,
|
||||
path: "/usr/bin/lark-cli",
|
||||
version: "v1.0.65",
|
||||
error: null,
|
||||
},
|
||||
auth: {
|
||||
status: "not_authorized",
|
||||
message: "not authorized",
|
||||
user: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
setLarkAppCredentials({
|
||||
app_id: "cli_new",
|
||||
app_secret: "new-secret",
|
||||
brand: "feishu",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
success: true,
|
||||
status: { app_configured: true, app_id: "cli_new" },
|
||||
});
|
||||
expect(mockedFetch).toHaveBeenCalledWith(
|
||||
"/backend/api/integrations/lark/config/credentials",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
app_id: "cli_new",
|
||||
app_secret: "new-secret",
|
||||
brand: "feishu",
|
||||
}),
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("completes browser authorization", async () => {
|
||||
mockedFetch.mockResolvedValueOnce(
|
||||
jsonResponse(200, {
|
||||
@ -292,7 +364,10 @@ describe("lark integration api", () => {
|
||||
);
|
||||
|
||||
await expect(
|
||||
completeLarkAuthorization({ device_code: "device-code" }),
|
||||
completeLarkAuthorization({
|
||||
device_code: "device-code",
|
||||
generation: "auth-generation",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
success: true,
|
||||
status: { auth: { status: "authenticated", user: "Alice" } },
|
||||
@ -302,7 +377,10 @@ describe("lark integration api", () => {
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ device_code: "device-code" }),
|
||||
body: JSON.stringify({
|
||||
device_code: "device-code",
|
||||
generation: "auth-generation",
|
||||
}),
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user