Merge branch 'main' into codex/aitable-record-stats
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **Chat IM ID flags** (#954) — standardizes chat command entry points on `--conversation-id` for conversation IDs and `--message-id` for message IDs, so help, Schema, and Agent recommendations use the same canonical flags.
|
||||
- **Legacy chat flag compatibility** (#954) — keeps older chat IM ID flags such as `--group`, `--id`, `--chat`, `--open-conversation-id`, `--msg-id`, and `--open-message-id` working as compatibility aliases where applicable, while hiding migrated aliases from recommended help and Schema surfaces.
|
||||
- **Chat group bots target flag** (#954) — keeps `dws chat group bots` on the visible `--group` flag; this command does not register `--group-name`, and `--group` accepts either an openConversationId or a uniquely resolved group name.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Drive `--latest` refuses incomplete Top-N** (#899) — `dws drive list --latest` used to
|
||||
exit 0 with a "Top-N" computed over a partially scanned tree whenever a directory read
|
||||
failed mid-recursion (permission denied, API error), letting an incomplete set pose as the
|
||||
globally newest files. Truncation at the 2000-item scan cap and mid-recursion directory
|
||||
failures now both fail closed (`LATEST_SCAN_TRUNCATED` / `LATEST_SCAN_INCOMPLETE`), report
|
||||
the first failing folder with its depth and reason, and emit a recovery command that
|
||||
reproduces the original candidate set — query domain, `--folder`, `--pattern`, `--type`,
|
||||
`--start` and `--end` are all carried over. On POSIX shells each user-supplied value is
|
||||
quoted so a URL query string or a shell metacharacter cannot change how the copied command
|
||||
parses. On Windows no quoting form is safe for both `cmd.exe` and PowerShell, so values
|
||||
containing metacharacters are not inlined at all: the command carries a placeholder and the
|
||||
original value is shown on a separate line marked as data rather than an executable command.
|
||||
Unrecoverable errors under `--latest` return the root cause instead of a partial result.
|
||||
Remote-controlled folder names and server error text are stripped of ANSI escapes and
|
||||
control characters before they reach the plain-text stderr message. The internal `sortTime`
|
||||
sort key no longer leaks into `drive list --depth` output on any path.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **International DingTalk region support** — adds `.io` login and MCP routing, pre-release endpoint overrides, and profile-aware gateway selection while preserving the existing `.com` flow.
|
||||
@@ -6,6 +6,70 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.59-beta.1] - 2026-08-14
|
||||
|
||||
### Added
|
||||
|
||||
- **Drive list type/time filtering** (#942) — `dws drive list` gains `--type
|
||||
file|folder`, `--start`, and `--end` for client-side filtering by node type
|
||||
and modification time on both the pan and workspace routes. Filtering runs
|
||||
a bounded full scan of the target directory (2000-entry cap, reported via
|
||||
`truncated=true`), composes with `--latest`/`--pattern`/`--depth`, and is
|
||||
mutually exclusive with `--versions`/`--cursor`/`--order-by`/`--order`/
|
||||
`--limit`. Time values accept relative forms (`24h`/`7d`/`2w`), RFC 3339,
|
||||
zone-less ISO 8601 (Asia/Shanghai), or a plain date.
|
||||
|
||||
- **Drive folder synchronization** — adds `dws drive status`, `dws drive pull`,
|
||||
`dws drive push`, and `dws drive sync` for file-level comparison and transfer
|
||||
between a local folder and a Drive folder. Differences come from exact MD5 by
|
||||
default or from modification time with `--quick`; `status` is read-only, `pull`
|
||||
and `push` are one-directional with `--if-exists skip|smart|overwrite`, and
|
||||
`sync` is bidirectional with `--on-conflict remote-wins|local-wins|keep-both|ask`.
|
||||
Only regular files are transferred — online documents and shortcuts are skipped,
|
||||
neither side deletes extra files, downloads are staged through a temporary file
|
||||
and committed with an atomic rename, and remote names that would escape
|
||||
`--local-folder` are reported as failures instead of being written. Every command
|
||||
prints a structured summary on stdout and exits non-zero when any item fails.
|
||||
|
||||
- **International DingTalk region support** — adds `.io` login and MCP routing, pre-release endpoint overrides, and profile-aware gateway selection while preserving the existing `.com` flow.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Chat identity routing** — validates explicit `openDingTalkId` inputs and improves name, `userId`, and `openDingTalkId` routing for message shortcuts.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Drive `--latest` refuses incomplete Top-N** (#899) — `dws drive list --latest` used to
|
||||
exit 0 with a "Top-N" computed over a partially scanned tree whenever a directory read
|
||||
failed mid-recursion (permission denied, API error), letting an incomplete set pose as the
|
||||
globally newest files. Truncation at the 2000-item scan cap and mid-recursion directory
|
||||
failures now both fail closed (`LATEST_SCAN_TRUNCATED` / `LATEST_SCAN_INCOMPLETE`), report
|
||||
the first failing folder with its depth and reason, and emit a recovery command that
|
||||
reproduces the original candidate set — query domain, `--folder`, `--pattern`, `--type`,
|
||||
`--start` and `--end` are all carried over. On POSIX shells each user-supplied value is
|
||||
quoted so a URL query string or a shell metacharacter cannot change how the copied command
|
||||
parses. On Windows no quoting form is safe for both `cmd.exe` and PowerShell, so values
|
||||
containing metacharacters are not inlined at all: the command carries a placeholder and the
|
||||
original value is shown on a separate line marked as data rather than an executable command.
|
||||
Unrecoverable errors under `--latest` return the root cause instead of a partial result.
|
||||
Remote-controlled folder names and server error text are stripped of ANSI escapes and
|
||||
control characters before they reach the plain-text stderr message. The internal `sortTime`
|
||||
sort key no longer leaks into `drive list --depth` output on any path.
|
||||
|
||||
- **Drive list pattern filtering** (#942) — `dws drive list --pattern` on the
|
||||
single-layer pan route now filters the returned page by name pattern; the
|
||||
flag was previously accepted but silently ignored.
|
||||
|
||||
- **Drive list `--type folder --latest` composition** (#942) — `--latest` now
|
||||
ranks the filtered entries (folders included when `--type folder` is set)
|
||||
instead of unconditionally dropping folders, so the documented combination
|
||||
returns the most recently modified folders rather than an empty list.
|
||||
|
||||
- **Chat message time defaults** (#973) — default omitted `chat message list-all` time bounds in `Asia/Shanghai` when emitting timezone-less `yyyy-MM-dd HH:mm:ss` values, matching parsing semantics and rejecting reversed windows.
|
||||
|
||||
- **Doc and Drive parameter aliases** — normalizes reviewed identifier, pagination, path, version, and role synonyms while blocking ambiguous values before dispatch.
|
||||
|
||||
|
||||
## [1.0.58] - 2026-08-13
|
||||
|
||||
This release promotes the sealed `v1.0.58-beta.6` contents to stable.
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
class DingtalkWorkspaceCliBeta < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.58-beta.6"
|
||||
version "1.0.59-beta.1"
|
||||
license "Apache-2.0"
|
||||
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-darwin-arm64.tar.gz"
|
||||
sha256 "8f55497b84113f81b318e087c723016a02eded1cb5784cbd0811fe527d5852ca"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-darwin-arm64.tar.gz"
|
||||
sha256 "36a30f3496e0f759c15c0b09f67dbd23b8ecdfff2eebe572f88125b26485830f"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-darwin-amd64.tar.gz"
|
||||
sha256 "b1762f1640310fb4100634fe54d9baace46384bb270c59148fe306275554d491"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-darwin-amd64.tar.gz"
|
||||
sha256 "e7a04906380efd8da88cd112e6a512bb6470a3956dc370150037ed6e314db445"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-linux-arm64.tar.gz"
|
||||
sha256 "55393310ef0e1f24ea2c0dc22f00c0eb3b343edc2deb18d0db7838cda65af25d"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-linux-arm64.tar.gz"
|
||||
sha256 "f59ab055f3e841e4cebc964ae3ef969668475548abaaf8bede44afdca9a3e28d"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-linux-amd64.tar.gz"
|
||||
sha256 "3830f77d09b4da4aa39f0c08d772ff3fa1ffb837eb15bb417f97edbccb073b7d"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-linux-amd64.tar.gz"
|
||||
sha256 "2c8f919489d958c7d49262615e81faac70a9fbcae2d589ab54a0bb3c5700a057"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-skills.zip"
|
||||
sha256 "f304a883a4f9e938b26a44692cd5a8d3d8704ba70ee7f33ba7c288434da72b6f"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-skills.zip"
|
||||
sha256 "25f4a7e1d01fa4d771d79201b34b11ee8a24182bdcdc94bfb98d2bd5845bed3b"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -786,6 +786,7 @@ See [`docs/robot-quickstart.md`](./docs/robot-quickstart.md) for the full 4-step
|
||||
|
||||
## Reference & Docs
|
||||
|
||||
- [International DingTalk (`.io`) guide](./docs/international-region-guide.md) — international login, domestic/international profile switching, isolated testing, and troubleshooting
|
||||
- [Command Index](./docs/command-index.md) — every runtime command with description and when-to-use guidance
|
||||
- [Reference](./docs/reference.md) — environment variables, exit codes, output formats, shell completion
|
||||
- [Architecture](./docs/architecture.md) — static endpoint pipeline, command surface, transport layer
|
||||
|
||||
@@ -777,6 +777,7 @@ dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <sec
|
||||
|
||||
## 参考与文档
|
||||
|
||||
- [国际版(`.io`)使用手册](./docs/international-region-guide.zh-CN.md) — 国际版登录、国内/国际 profile 切换、隔离验证与排障
|
||||
- [命令索引](./docs/command-index.md) — 全部运行时命令,带描述与使用场景
|
||||
- [参考手册](./docs/reference.md) — 环境变量、退出码、输出格式、Shell 补全
|
||||
- [架构设计](./docs/architecture.md) — 静态端点管道、命令面、Transport 层
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
# International DingTalk (`.io`) Guide
|
||||
|
||||
This guide explains how to log in to the international DingTalk region and run DWS commands against `*.dingtalk.io` services.
|
||||
|
||||
## Region behavior
|
||||
|
||||
- `dws auth login --intl` creates or refreshes an international login using the `.io` login, OAuth, and MCP services.
|
||||
- Omitting `--intl` keeps the existing domestic `.com` behavior.
|
||||
- `--intl` is a login option, not a global option for business commands. After login, commands such as `contact`, `calendar`, and `doc` derive the region from the selected Token/profile.
|
||||
- Each new Token records its login region. Switching profiles therefore switches the official DingTalk gateway region automatically.
|
||||
- `--international` is a compatibility alias. Prefer `--intl` in new scripts.
|
||||
|
||||
For the complete Chinese guide, see [DWS 国际版(DingTalk `.io`)使用手册](./international-region-guide.zh-CN.md).
|
||||
|
||||
## Check availability
|
||||
|
||||
```bash
|
||||
dws auth login --help
|
||||
```
|
||||
|
||||
The help output must include `--intl` and `--international`.
|
||||
|
||||
When validating a source checkout, build it first and use `./dws` so an older binary on `PATH` is not invoked accidentally:
|
||||
|
||||
```bash
|
||||
make build
|
||||
./dws auth login --help
|
||||
```
|
||||
|
||||
## Log in
|
||||
|
||||
Browser login:
|
||||
|
||||
```bash
|
||||
dws auth login --intl
|
||||
```
|
||||
|
||||
Device flow for SSH, containers, and headless environments:
|
||||
|
||||
```bash
|
||||
dws auth login --intl --device
|
||||
```
|
||||
|
||||
User OAuth with custom application credentials:
|
||||
|
||||
```bash
|
||||
dws auth login --intl \
|
||||
--client-id <APP_KEY> \
|
||||
--client-secret <APP_SECRET>
|
||||
```
|
||||
|
||||
This mode still requires the user to complete OAuth authorization in a browser; it is not a userless `client_credentials` login. The application must be configured on the international developer platform with the required callback and permissions. Never commit an AppSecret to source control or include it in logs.
|
||||
|
||||
## Verify the login
|
||||
|
||||
```bash
|
||||
dws auth status --format json
|
||||
dws profile list --format json
|
||||
dws contact user get-self
|
||||
```
|
||||
|
||||
The last command is a read-only smoke check. If the organization has not enabled CLI access, an organization administrator must enable it or approve the access request on the international developer platform.
|
||||
|
||||
## Use domestic and international profiles together
|
||||
|
||||
```bash
|
||||
# Domestic (.com)
|
||||
dws auth login
|
||||
|
||||
# International (.io)
|
||||
dws auth login --intl
|
||||
|
||||
# Find the stable profile selectors
|
||||
dws profile list --format json
|
||||
```
|
||||
|
||||
Persistently switch profiles:
|
||||
|
||||
```bash
|
||||
dws profile switch <corpId>:<userId>
|
||||
```
|
||||
|
||||
Toggle back to the previous profile:
|
||||
|
||||
```bash
|
||||
dws profile switch -
|
||||
```
|
||||
|
||||
Select a profile for one command without changing the default:
|
||||
|
||||
```bash
|
||||
dws --profile <corpId>:<userId> contact user get-self
|
||||
```
|
||||
|
||||
Do not add `--intl` to business commands. DWS routes official endpoints from the selected profile's Token region.
|
||||
|
||||
## Isolated smoke testing
|
||||
|
||||
Use a separate configuration directory to avoid changing the normal `~/.dws` login state:
|
||||
|
||||
```bash
|
||||
DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws auth login --intl
|
||||
DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws auth status --format json
|
||||
DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws contact user get-self
|
||||
```
|
||||
|
||||
Use the same `DWS_CONFIG_DIR` for every command. Use `./dws` for a source build and `dws` for an installed release.
|
||||
|
||||
## Pre-release overrides (maintainers only)
|
||||
|
||||
Normal international users need only `--intl`; they should not set `--pre-url` or `--mcp-url`.
|
||||
|
||||
Maintainers can test the pre-release login/MCP pair with:
|
||||
|
||||
```bash
|
||||
dws auth login --intl --pre-url https://pre-login.dingtalk.io
|
||||
```
|
||||
|
||||
A corresponding `pre-mcp.*` URL is also accepted, and DWS derives the paired `pre-login.*` / `pre-mcp.*` bases. `--mcp-url` explicitly overrides the MCP base URL for that login.
|
||||
|
||||
Pre-release services may require internal network access or allowlisted accounts. `--pre-url` is intended primarily for the MCP-managed credential flow. Do not combine it with direct custom `--client-id/--client-secret` mode unless the pre-release API contract explicitly supports that combination.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### The browser still opens a `.com` page
|
||||
|
||||
1. Run `dws auth login --help` and confirm `--intl` is present.
|
||||
2. For a source checkout, use `./dws` instead of an older installed binary.
|
||||
3. Confirm the executed command is `dws auth login --intl`.
|
||||
|
||||
### A business command appears to use the wrong region
|
||||
|
||||
Run `dws profile list --format json`, then switch with the exact `<corpId>:<userId>` selector or use the global `--profile` option. For a legacy Token created before region metadata existed, reauthorize it with `dws auth login --intl` for an international account or `dws auth login` for a domestic account.
|
||||
|
||||
### Login succeeds but the command reports missing permission
|
||||
|
||||
This normally means the organization has not enabled CLI access or the application lacks a required permission. It does not by itself indicate a region-routing failure.
|
||||
|
||||
### Should I edit `~/.dws/mcp_url` manually?
|
||||
|
||||
No. Normal users should establish the login with `dws auth login` or `dws auth login --intl`. DWS then routes official endpoints from the selected Token/profile. Manual configuration is reserved for maintainers who explicitly control the target environment.
|
||||
|
||||
## Command reference
|
||||
|
||||
| Scenario | Command |
|
||||
|---|---|
|
||||
| Domestic browser login | `dws auth login` |
|
||||
| International browser login | `dws auth login --intl` |
|
||||
| International device login | `dws auth login --intl --device` |
|
||||
| Check auth state | `dws auth status --format json` |
|
||||
| List profiles | `dws profile list --format json` |
|
||||
| Persistently switch profile | `dws profile switch <corpId>:<userId>` |
|
||||
| Toggle to previous profile | `dws profile switch -` |
|
||||
| Select a profile once | `dws --profile <corpId>:<userId> <command>` |
|
||||
@@ -0,0 +1,185 @@
|
||||
# DWS 国际版(DingTalk `.io`)使用手册
|
||||
|
||||
本手册适用于使用钉钉国际版账号登录并调用国际站服务的用户。
|
||||
|
||||
## 核心规则
|
||||
|
||||
- `dws auth login --intl` 创建或刷新国际版登录,使用 `*.dingtalk.io` 登录、鉴权和 MCP 服务。
|
||||
- 不传 `--intl` 时仍使用国内钉钉 `*.dingtalk.com`,原有链路保持不变。
|
||||
- `--intl` 只用于登录命令。登录完成后,`contact`、`calendar`、`doc` 等业务命令不需要再传该参数。
|
||||
- 每个 Token 会记录登录区域。执行业务命令时,DWS 根据当前或 `--profile` 指定的账号自动选择 `.com` 或 `.io` 网关。
|
||||
- `--international` 是 `--intl` 的兼容别名;新脚本推荐使用较短的 `--intl`。
|
||||
|
||||
## 确认当前版本支持国际版
|
||||
|
||||
运行:
|
||||
|
||||
```bash
|
||||
dws auth login --help
|
||||
```
|
||||
|
||||
帮助中应包含:
|
||||
|
||||
```text
|
||||
--intl
|
||||
--international
|
||||
```
|
||||
|
||||
从源码分支验证时,先在仓库根目录构建,并始终使用本次构建的 `./dws`,避免误用系统中已安装的旧版本:
|
||||
|
||||
```bash
|
||||
make build
|
||||
./dws auth login --help
|
||||
```
|
||||
|
||||
## 国际版登录
|
||||
|
||||
### 浏览器登录
|
||||
|
||||
```bash
|
||||
dws auth login --intl
|
||||
```
|
||||
|
||||
DWS 会打开国际版登录页面。完成扫码或账号授权后,登录结果会保存为本机 profile。
|
||||
|
||||
### 设备码登录
|
||||
|
||||
适用于 SSH、容器或没有可用浏览器的环境:
|
||||
|
||||
```bash
|
||||
dws auth login --intl --device
|
||||
```
|
||||
|
||||
按照终端提示,在另一台可打开浏览器的设备上完成授权。
|
||||
|
||||
### 使用自有应用凭证完成用户 OAuth
|
||||
|
||||
```bash
|
||||
dws auth login --intl \
|
||||
--client-id <APP_KEY> \
|
||||
--client-secret <APP_SECRET>
|
||||
```
|
||||
|
||||
该模式仍然需要用户在浏览器中完成 OAuth 授权,不是无用户授权的 `client_credentials` 登录。应用必须在国际版开放平台正确配置回调地址和所需权限。不要在命令历史、日志或 PR 中提交真实的 AppSecret。
|
||||
|
||||
## 验证登录和业务调用
|
||||
|
||||
查看当前登录状态:
|
||||
|
||||
```bash
|
||||
dws auth status --format json
|
||||
```
|
||||
|
||||
列出本机全部账号并找到当前 profile:
|
||||
|
||||
```bash
|
||||
dws profile list --format json
|
||||
```
|
||||
|
||||
执行一个只读命令验证国际链路,例如:
|
||||
|
||||
```bash
|
||||
dws contact user get-self
|
||||
```
|
||||
|
||||
登录状态正常但业务命令提示组织未开通 CLI 时,需要由国际版组织管理员在国际版开发者平台开启 CLI 访问或完成授权审批。
|
||||
|
||||
## 国内版和国际版账号并存
|
||||
|
||||
可以在同一台机器上分别登录国内版和国际版账号:
|
||||
|
||||
```bash
|
||||
# 国内版(.com)
|
||||
dws auth login
|
||||
|
||||
# 国际版(.io)
|
||||
dws auth login --intl
|
||||
|
||||
# 查看稳定的 profile 选择器
|
||||
dws profile list --format json
|
||||
```
|
||||
|
||||
持久切换账号:
|
||||
|
||||
```bash
|
||||
dws profile switch <corpId>:<userId>
|
||||
```
|
||||
|
||||
切回上一个账号:
|
||||
|
||||
```bash
|
||||
dws profile switch -
|
||||
```
|
||||
|
||||
只为单次命令指定账号,不修改默认账号:
|
||||
|
||||
```bash
|
||||
dws --profile <corpId>:<userId> contact user get-self
|
||||
```
|
||||
|
||||
DWS 会按照选中 profile 的 Token 区域自动选择 `.com` 或 `.io`,不需要在业务命令上追加 `--intl`。
|
||||
|
||||
## 使用独立配置目录进行验证
|
||||
|
||||
如果不希望测试登录影响日常使用的 `~/.dws`,可以指定独立配置目录:
|
||||
|
||||
```bash
|
||||
DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws auth login --intl
|
||||
DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws auth status --format json
|
||||
DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws contact user get-self
|
||||
```
|
||||
|
||||
请在三条命令中使用同一个 `DWS_CONFIG_DIR`。验证源码分支时使用 `./dws`;验证已安装版本时可改为 `dws`。
|
||||
|
||||
## 预发参数(仅维护者)
|
||||
|
||||
普通国际版用户只需要 `--intl`,不要配置 `--pre-url` 或 `--mcp-url`。
|
||||
|
||||
维护者验证预发登录/MCP 链路时可以使用:
|
||||
|
||||
```bash
|
||||
dws auth login --intl --pre-url https://pre-login.dingtalk.io
|
||||
```
|
||||
|
||||
也可以传入对应的 `pre-mcp.*` 地址;DWS 会推导配套的 `pre-login.*` / `pre-mcp.*` 地址。`--mcp-url` 用于显式覆盖本次登录的 MCP base URL。
|
||||
|
||||
预发环境可能只对内网或特定测试账号开放。`--pre-url` 主要服务于 MCP 托管凭证登录流程;除非预发 API 契约已经明确支持,否则不要把它与自有 `--client-id/--client-secret` 直连模式组合使用。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 仍然打开 `.com` 登录页面
|
||||
|
||||
1. 运行 `dws auth login --help`,确认当前二进制包含 `--intl`。
|
||||
2. 从源码验证时使用 `./dws`,不要误用 PATH 中的旧版本。
|
||||
3. 确认实际执行的是 `dws auth login --intl`,而不是普通 `dws auth login`。
|
||||
|
||||
### 业务命令似乎使用了错误区域
|
||||
|
||||
先检查当前账号:
|
||||
|
||||
```bash
|
||||
dws profile list --format json
|
||||
```
|
||||
|
||||
然后使用精确的 `<corpId>:<userId>` 切换或通过全局 `--profile` 单次指定。对于在区域字段引入前生成的历史 Token,建议使用正确的登录方式重新授权:国际账号执行 `dws auth login --intl`,国内账号执行 `dws auth login`。
|
||||
|
||||
### 登录成功但提示没有权限
|
||||
|
||||
这通常是组织 CLI 准入或应用授权问题,不代表区域路由失败。请确认目标组织已开启 CLI 访问,并且当前应用拥有命令所需权限。
|
||||
|
||||
### 是否需要手工修改 `~/.dws/mcp_url`
|
||||
|
||||
不需要。正常使用应通过 `dws auth login` 或 `dws auth login --intl` 建立登录态;业务命令会根据选中的 Token/profile 自动路由。手工修改配置只适用于明确了解目标环境的维护者调试场景。
|
||||
|
||||
## 命令速查
|
||||
|
||||
| 场景 | 命令 |
|
||||
|---|---|
|
||||
| 国内版浏览器登录 | `dws auth login` |
|
||||
| 国际版浏览器登录 | `dws auth login --intl` |
|
||||
| 国际版设备码登录 | `dws auth login --intl --device` |
|
||||
| 查看登录状态 | `dws auth status --format json` |
|
||||
| 查看所有账号 | `dws profile list --format json` |
|
||||
| 持久切换账号 | `dws profile switch <corpId>:<userId>` |
|
||||
| 切回上一个账号 | `dws profile switch -` |
|
||||
| 单次指定账号 | `dws --profile <corpId>:<userId> <command>` |
|
||||
@@ -65,12 +65,16 @@ func TestCrossPlatformCoverageTokenManagerCachesUntilMarkerRevisionChanges(t *te
|
||||
token := "token-a"
|
||||
installTokenManagerFakes(t, func() (*authpkg.TokenData, error) {
|
||||
calls.Add(1)
|
||||
return &authpkg.TokenData{AccessToken: token, ExpiresAt: time.Now().Add(time.Hour)}, nil
|
||||
return &authpkg.TokenData{
|
||||
AccessToken: token,
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
LoginRegion: string(authpkg.LoginRegionInternational),
|
||||
}, nil
|
||||
})
|
||||
|
||||
manager := NewTokenManager()
|
||||
first, err := manager.Get(context.Background(), configDir, "")
|
||||
if err != nil || first.AccessToken != "token-a" {
|
||||
if err != nil || first.AccessToken != "token-a" || first.LoginRegion != authpkg.LoginRegionInternational || !first.LoginRegionKnown {
|
||||
t.Fatalf("first token = %#v, %v", first, err)
|
||||
}
|
||||
second, err := manager.Get(context.Background(), configDir, "")
|
||||
|
||||
@@ -41,9 +41,11 @@ type accessTokenSnapshotGetter interface {
|
||||
// AccessTokenSnapshot is the minimal bearer view needed by the process cache.
|
||||
// Refresh-token material never leaves the auth package.
|
||||
type AccessTokenSnapshot struct {
|
||||
AccessToken string
|
||||
ExpiresAt time.Time
|
||||
Source string
|
||||
AccessToken string
|
||||
ExpiresAt time.Time
|
||||
Source string
|
||||
LoginRegion authpkg.LoginRegion
|
||||
LoginRegionKnown bool
|
||||
}
|
||||
|
||||
type tokenManagerKey struct {
|
||||
@@ -223,9 +225,11 @@ func resolveAccessTokenSnapshotFromDir(ctx context.Context, configDir, profile s
|
||||
data, err := snapshotProvider.GetTokenSnapshot(ctx)
|
||||
if err == nil && data != nil && strings.TrimSpace(data.AccessToken) != "" {
|
||||
return AccessTokenSnapshot{
|
||||
AccessToken: strings.TrimSpace(data.AccessToken),
|
||||
ExpiresAt: data.ExpiresAt,
|
||||
Source: "oauth",
|
||||
AccessToken: strings.TrimSpace(data.AccessToken),
|
||||
ExpiresAt: data.ExpiresAt,
|
||||
Source: "oauth",
|
||||
LoginRegion: authpkg.LoginRegion(strings.TrimSpace(data.LoginRegion)),
|
||||
LoginRegionKnown: true,
|
||||
}, nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, authpkg.ErrTokenDataNotFound) {
|
||||
|
||||
@@ -712,9 +712,9 @@ func TestCrossPlatformCoverageAgentMetadataMCPAndPluginScoping(t *testing.T) {
|
||||
// calls must fail before preflight or transport while anonymous plugins remain
|
||||
// valid above.
|
||||
resolveCalled := false
|
||||
testseam.Swap(t, &runnerResolveAuthToken, func(*runtimeRunner, context.Context) (string, error) {
|
||||
testseam.Swap(t, &runnerResolveAuthSnapshot, func(*runtimeRunner, context.Context) (AccessTokenSnapshot, error) {
|
||||
resolveCalled = true
|
||||
return "", nil
|
||||
return AccessTokenSnapshot{}, nil
|
||||
})
|
||||
callsBefore := len(captured)
|
||||
unauthenticated := &runtimeRunner{
|
||||
|
||||
@@ -20,6 +20,8 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -48,8 +50,24 @@ type authLoginConfig struct {
|
||||
TargetCorpID string
|
||||
HistoryProfileSelector string
|
||||
HistoryProfileSelectorExplicit bool
|
||||
International bool
|
||||
PreURL string
|
||||
MCPURL string
|
||||
}
|
||||
|
||||
type authLoginEndpointOverrides struct {
|
||||
LoginURL string
|
||||
MCPURL string
|
||||
}
|
||||
|
||||
type authLoginMCPPersistence uint8
|
||||
|
||||
const (
|
||||
authLoginMCPUseDefault authLoginMCPPersistence = iota
|
||||
authLoginMCPUseManagedRegion
|
||||
authLoginMCPUseExplicitOverride
|
||||
)
|
||||
|
||||
type authLoginGuideAction string
|
||||
|
||||
const (
|
||||
@@ -103,12 +121,17 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
支持的登录方式:
|
||||
- OAuth Loopback 流 (默认): 本机自动起 127.0.0.1 监听接收回调,浏览器授权后自动完成
|
||||
- OAuth 设备流 (--device): 显示 user_code + 短 URL,适合 SSH 远程 / 容器 / 无头环境
|
||||
- 自有应用 OAuth (--client-id/--client-secret): 使用指定应用完成用户授权
|
||||
- 直接提供 Token (--token): 跳过授权,使用已有 token
|
||||
|
||||
不支持的登录方式:
|
||||
- 邮箱/密码登录
|
||||
- 手机号/验证码登录
|
||||
- 应用凭证 (AppKey/AppSecret) 直接登录
|
||||
- 无用户授权的纯应用凭证 (client_credentials) 登录
|
||||
|
||||
区域:
|
||||
- 默认使用国内钉钉 .com 登录与服务端点
|
||||
- --intl(或 --international)使用国际版 .io 登录;后续业务命令按所选 profile 自动路由
|
||||
|
||||
注意: SSH 远程或无头环境(无本地浏览器可访问远端的 127.0.0.1)请使用 --device,
|
||||
否则 OAuth 回调会跳到本机不可达的 127.0.0.1 链接,授权完成后无法回写 token。
|
||||
@@ -116,6 +139,9 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
示例:
|
||||
dws auth login # 本机登录并新增/刷新一个组织 profile
|
||||
dws auth login --profile <corpId> # 指定本次授权目标组织,不持久切换当前组织
|
||||
dws auth login --intl # 使用钉钉国际版 .io 登录入口
|
||||
dws auth login --intl --pre-url https://pre-login.dingtalk.io
|
||||
dws auth login --intl --pre-url https://pre-mcp.dingtalk.io
|
||||
dws auth login --recommend # 无交互批量授权服务端推荐权限
|
||||
dws auth login --device # SSH 远程 / 无头环境登录 (设备流)
|
||||
dws auth login --force # 兼容保留;login 默认已忽略缓存并进入授权流程
|
||||
@@ -126,6 +152,22 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var preOverrides authLoginEndpointOverrides
|
||||
if cfg.PreURL != "" {
|
||||
var err error
|
||||
preOverrides, err = authLoginEndpointOverridesForPreURL(cfg.PreURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
restoreLoginBaseURL := authpkg.PushLoginBaseURLOverride(preOverrides.LoginURL)
|
||||
defer restoreLoginBaseURL()
|
||||
}
|
||||
mcpBaseURL, mcpPersistence, err := authLoginMCPBaseURLForConfig(cfg, preOverrides)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
restoreMCPBaseURL := authpkg.PushMCPBaseURLOverride(mcpBaseURL)
|
||||
defer restoreMCPBaseURL()
|
||||
configDir := defaultConfigDir()
|
||||
var tokenData *authpkg.TokenData
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
@@ -139,6 +181,9 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
AccessToken: cfg.Token,
|
||||
ExpiresAt: time.Now().Add(config.ManualTokenExpiry),
|
||||
}
|
||||
if cfg.International {
|
||||
tokenData.LoginRegion = string(authpkg.LoginRegionInternational)
|
||||
}
|
||||
if err := authSaveTokenData(configDir, tokenData); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to persist auth token: %v", err))
|
||||
}
|
||||
@@ -149,6 +194,9 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
provider := authpkg.NewDeviceFlowProvider(configDir, nil)
|
||||
provider.Output = cmd.ErrOrStderr()
|
||||
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
|
||||
if cfg.International {
|
||||
provider.SetLoginRegion(authpkg.LoginRegionInternational)
|
||||
}
|
||||
provider.IdentityEnricher = func(ctx context.Context, data *authpkg.TokenData) error {
|
||||
return enrichAuthLoginProfileFromContact(ctx, configDir, patCaller, data, authLoginHistoryHint{
|
||||
Selector: cfg.HistoryProfileSelector,
|
||||
@@ -167,6 +215,9 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
provider.Output = cmd.ErrOrStderr()
|
||||
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
|
||||
provider.TargetCorpID = cfg.TargetCorpID
|
||||
if cfg.International {
|
||||
provider.LoginRegion = authpkg.LoginRegionInternational
|
||||
}
|
||||
provider.IdentityEnricher = func(ctx context.Context, data *authpkg.TokenData) error {
|
||||
return enrichAuthLoginProfileFromContact(ctx, configDir, patCaller, data, authLoginHistoryHint{
|
||||
Selector: cfg.HistoryProfileSelector,
|
||||
@@ -180,6 +231,11 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
if tokenData != nil {
|
||||
if err := persistAuthLoginMCPBaseURL(configDir, mcpBaseURL, mcpPersistence); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to persist MCP URL: %v", err))
|
||||
}
|
||||
}
|
||||
ResetRuntimeTokenCache()
|
||||
clearCompatCache()
|
||||
w := cmd.OutOrStdout()
|
||||
@@ -278,6 +334,10 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
}
|
||||
cmd.Flags().String("token", "", "Access token")
|
||||
cmd.Flags().Bool("device", false, "Use device authorization flow")
|
||||
cmd.Flags().Bool("intl", false, "Use DingTalk international (.io) login and service endpoints")
|
||||
cmd.Flags().Bool("international", false, "Use DingTalk international (.io) login and service endpoints")
|
||||
cmd.Flags().String("pre-url", "", "Override pre-release login/MCP base URL for this login")
|
||||
cmd.Flags().String("mcp-url", "", "Override MCP base URL for this login")
|
||||
cmd.Flags().Bool("force", false, "兼容保留;login 默认已忽略缓存并进入授权流程")
|
||||
cmd.Flags().Bool("recommend", false, "登录成功后无交互批量授权服务端推荐权限")
|
||||
// Hidden compatibility flags
|
||||
@@ -967,6 +1027,7 @@ func newAuthResetCommand() *cobra.Command {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to reset token data: %v", err))
|
||||
}
|
||||
_ = authRemove(filepath.Join(configDir, "mcp_url"))
|
||||
_ = authRemove(filepath.Join(configDir, config.ManagedMCPURLRegionFileName))
|
||||
_ = authRemove(filepath.Join(configDir, "token"))
|
||||
_ = authDeleteAppConfig(configDir)
|
||||
ResetRuntimeTokenCache()
|
||||
@@ -1225,6 +1286,14 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) {
|
||||
if err != nil {
|
||||
return authLoginConfig{}, apperrors.NewInternal("failed to read --device")
|
||||
}
|
||||
intl, err := cmd.Flags().GetBool("intl")
|
||||
if err != nil {
|
||||
return authLoginConfig{}, apperrors.NewInternal("failed to read --intl")
|
||||
}
|
||||
international, err := cmd.Flags().GetBool("international")
|
||||
if err != nil {
|
||||
return authLoginConfig{}, apperrors.NewInternal("failed to read --international")
|
||||
}
|
||||
force, err := cmd.Flags().GetBool("force")
|
||||
if err != nil {
|
||||
return authLoginConfig{}, apperrors.NewInternal("failed to read --force")
|
||||
@@ -1233,6 +1302,14 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) {
|
||||
if err != nil {
|
||||
return authLoginConfig{}, apperrors.NewInternal("failed to read --recommend")
|
||||
}
|
||||
preURL, err := cmd.Flags().GetString("pre-url")
|
||||
if err != nil {
|
||||
return authLoginConfig{}, apperrors.NewInternal("failed to read --pre-url")
|
||||
}
|
||||
mcpURL, err := cmd.Flags().GetString("mcp-url")
|
||||
if err != nil {
|
||||
return authLoginConfig{}, apperrors.NewInternal("failed to read --mcp-url")
|
||||
}
|
||||
yes := false
|
||||
profileSelector := ""
|
||||
if cmd.Root() != nil {
|
||||
@@ -1266,9 +1343,172 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) {
|
||||
TargetCorpID: targetCorpID,
|
||||
HistoryProfileSelector: historyProfileSelector,
|
||||
HistoryProfileSelectorExplicit: historyProfileSelectorExplicit,
|
||||
International: intl || international,
|
||||
PreURL: strings.TrimSpace(preURL),
|
||||
MCPURL: strings.TrimSpace(mcpURL),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func authLoginEndpointOverridesForPreURL(raw string) (authLoginEndpointOverrides, error) {
|
||||
parsed, normalized, err := normalizeAuthLoginBaseURL(raw, "--pre-url")
|
||||
if err != nil {
|
||||
return authLoginEndpointOverrides{}, err
|
||||
}
|
||||
host := strings.ToLower(parsed.Hostname())
|
||||
switch {
|
||||
case strings.HasPrefix(host, "pre-login."):
|
||||
return authLoginEndpointOverrides{
|
||||
LoginURL: normalized,
|
||||
MCPURL: authLoginURLWithHost(parsed, "pre-mcp."+strings.TrimPrefix(host, "pre-login.")),
|
||||
}, nil
|
||||
case strings.HasPrefix(host, "pre-mcp."):
|
||||
return authLoginEndpointOverrides{
|
||||
LoginURL: authLoginURLWithHost(parsed, "pre-login."+strings.TrimPrefix(host, "pre-mcp.")),
|
||||
MCPURL: normalized,
|
||||
}, nil
|
||||
default:
|
||||
return authLoginEndpointOverrides{}, apperrors.NewValidation("--pre-url must be a pre-login.* or pre-mcp.* URL")
|
||||
}
|
||||
}
|
||||
|
||||
func authLoginMCPBaseURLForConfig(cfg authLoginConfig, preOverrides authLoginEndpointOverrides) (string, authLoginMCPPersistence, error) {
|
||||
if cfg.MCPURL != "" {
|
||||
_, normalized, err := normalizeAuthLoginBaseURL(cfg.MCPURL, "--mcp-url")
|
||||
if err != nil {
|
||||
return "", authLoginMCPUseDefault, err
|
||||
}
|
||||
return normalized, authLoginMCPUseExplicitOverride, nil
|
||||
}
|
||||
if cfg.PreURL != "" {
|
||||
if preOverrides.MCPURL == "" {
|
||||
var err error
|
||||
preOverrides, err = authLoginEndpointOverridesForPreURL(cfg.PreURL)
|
||||
if err != nil {
|
||||
return "", authLoginMCPUseDefault, err
|
||||
}
|
||||
}
|
||||
return preOverrides.MCPURL, authLoginMCPUseExplicitOverride, nil
|
||||
}
|
||||
if cfg.International {
|
||||
return authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion, nil
|
||||
}
|
||||
return authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault, nil
|
||||
}
|
||||
|
||||
func persistAuthLoginMCPBaseURL(configDir, mcpBaseURL string, persistence authLoginMCPPersistence) error {
|
||||
mcpURLPath := filepath.Join(configDir, "mcp_url")
|
||||
managedRegionPath := filepath.Join(configDir, config.ManagedMCPURLRegionFileName)
|
||||
|
||||
switch persistence {
|
||||
case authLoginMCPUseExplicitOverride:
|
||||
if err := removeAuthLoginManagedMCPRegion(managedRegionPath); err != nil {
|
||||
return fmt.Errorf("clear managed MCP region: %w", err)
|
||||
}
|
||||
if err := authAtomicWrite(mcpURLPath, []byte(mcpBaseURL), config.FilePerm); err != nil {
|
||||
return fmt.Errorf("save explicit MCP URL: %w", err)
|
||||
}
|
||||
return nil
|
||||
case authLoginMCPUseManagedRegion:
|
||||
managedURL, managedErr := authReadFile(managedRegionPath)
|
||||
if managedErr != nil && !os.IsNotExist(managedErr) {
|
||||
return fmt.Errorf("read managed MCP region: %w", managedErr)
|
||||
}
|
||||
currentURL, currentErr := authReadFile(mcpURLPath)
|
||||
switch {
|
||||
case currentErr == nil && os.IsNotExist(managedErr):
|
||||
return nil
|
||||
case currentErr == nil && strings.TrimSpace(string(currentURL)) != strings.TrimSpace(string(managedURL)):
|
||||
return removeAuthLoginManagedMCPRegion(managedRegionPath)
|
||||
case currentErr != nil && !os.IsNotExist(currentErr):
|
||||
return fmt.Errorf("read MCP URL: %w", currentErr)
|
||||
}
|
||||
if err := authAtomicWrite(managedRegionPath, []byte(mcpBaseURL), config.FilePerm); err != nil {
|
||||
return fmt.Errorf("save managed MCP region: %w", err)
|
||||
}
|
||||
if err := authAtomicWrite(mcpURLPath, []byte(mcpBaseURL), config.FilePerm); err != nil {
|
||||
_ = authRemove(managedRegionPath)
|
||||
return fmt.Errorf("save managed MCP URL: %w", err)
|
||||
}
|
||||
return nil
|
||||
case authLoginMCPUseDefault:
|
||||
managedURL, err := authReadFile(managedRegionPath)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("read managed MCP region: %w", err)
|
||||
}
|
||||
currentURL, err := authReadFile(mcpURLPath)
|
||||
if os.IsNotExist(err) {
|
||||
return removeAuthLoginManagedMCPRegion(managedRegionPath)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("read MCP URL: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(string(currentURL)) != strings.TrimSpace(string(managedURL)) {
|
||||
return removeAuthLoginManagedMCPRegion(managedRegionPath)
|
||||
}
|
||||
if err := authAtomicWrite(mcpURLPath, []byte(mcpBaseURL), config.FilePerm); err != nil {
|
||||
return fmt.Errorf("restore default MCP URL: %w", err)
|
||||
}
|
||||
return removeAuthLoginManagedMCPRegion(managedRegionPath)
|
||||
default:
|
||||
return fmt.Errorf("unsupported MCP persistence mode %d", persistence)
|
||||
}
|
||||
}
|
||||
|
||||
func removeAuthLoginManagedMCPRegion(path string) error {
|
||||
if err := authRemove(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeAuthLoginBaseURL(raw, flagName string) (*url.URL, string, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return nil, "", apperrors.NewValidation(flagName + " cannot be empty")
|
||||
}
|
||||
if !strings.Contains(value, "://") {
|
||||
value = "https://" + value
|
||||
}
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil {
|
||||
return nil, "", apperrors.NewValidation(fmt.Sprintf("invalid %s: %v", flagName, err))
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return nil, "", apperrors.NewValidation(flagName + " must use http or https")
|
||||
}
|
||||
if parsed.Hostname() == "" {
|
||||
return nil, "", apperrors.NewValidation(flagName + " must include a host")
|
||||
}
|
||||
if parsed.Scheme == "http" && !isAuthLoginLoopbackHost(parsed.Hostname()) {
|
||||
return nil, "", apperrors.NewValidation(flagName + " must use HTTPS, except for a loopback HTTP test endpoint")
|
||||
}
|
||||
parsed.RawQuery = ""
|
||||
parsed.Fragment = ""
|
||||
parsed.Path = strings.TrimRight(parsed.Path, "/")
|
||||
return parsed, strings.TrimRight(parsed.String(), "/"), nil
|
||||
}
|
||||
|
||||
func isAuthLoginLoopbackHost(host string) bool {
|
||||
if strings.EqualFold(strings.TrimSpace(host), "localhost") {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(strings.TrimSpace(host))
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
func authLoginURLWithHost(parsed *url.URL, host string) string {
|
||||
copyURL := *parsed
|
||||
if port := parsed.Port(); port != "" {
|
||||
copyURL.Host = net.JoinHostPort(host, port)
|
||||
} else {
|
||||
copyURL.Host = host
|
||||
}
|
||||
return strings.TrimRight(copyURL.String(), "/")
|
||||
}
|
||||
|
||||
func authLoginForcesAuthorization(_ authLoginConfig) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -214,7 +215,8 @@ func TestCrossPlatformCoverageAuthCoverageFormsParentAndTargets(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
oldSave := authSaveTokenData
|
||||
oldDevice := authDeviceLogin
|
||||
oldOAuth := authOAuthLogin
|
||||
@@ -255,6 +257,15 @@ func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) {
|
||||
if out, _, err := authCoverageRunLogin(t, nil, "json", true, map[string]string{"token": "token"}); err != nil || !strings.Contains(out, `"token_valid": true`) {
|
||||
t.Fatalf("json token login = %q, %v", out, err)
|
||||
}
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": "token", "pre-url": "https://example.com"}); err == nil {
|
||||
t.Fatal("invalid pre-release host should fail")
|
||||
}
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": "token", "mcp-url": "http://remote.example.com"}); err == nil {
|
||||
t.Fatal("remote plaintext MCP URL should fail")
|
||||
}
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": "token", "pre-url": "https://pre-login.dingtalk.io"}); err != nil {
|
||||
t.Fatalf("pre-release token login = %v", err)
|
||||
}
|
||||
|
||||
authDeviceLogin = func(*authpkg.DeviceFlowProvider, context.Context) (*authpkg.TokenData, error) {
|
||||
return nil, errors.New("device")
|
||||
@@ -271,6 +282,15 @@ func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) {
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"device": "true", "no-browser": "true"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
authDeviceLogin = func(provider *authpkg.DeviceFlowProvider, _ context.Context) (*authpkg.TokenData, error) {
|
||||
if provider.LoginRegion != authpkg.LoginRegionInternational {
|
||||
t.Errorf("device login region = %q, want international", provider.LoginRegion)
|
||||
}
|
||||
return &authpkg.TokenData{AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour)}, nil
|
||||
}
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"device": "true", "intl": "true"}); err != nil {
|
||||
t.Fatalf("international device login = %v", err)
|
||||
}
|
||||
|
||||
authOAuthLogin = func(*authpkg.OAuthProvider, context.Context, bool) (*authpkg.TokenData, error) {
|
||||
return nil, errors.New("oauth")
|
||||
@@ -291,6 +311,25 @@ func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) {
|
||||
if out, _, err := authCoverageRunLogin(t, caller, "table", true, map[string]string{"no-browser": "true"}); err != nil || !strings.Contains(out, "Corp") {
|
||||
t.Fatalf("oauth success = %q, %v", out, err)
|
||||
}
|
||||
authOAuthLogin = func(provider *authpkg.OAuthProvider, _ context.Context, _ bool) (*authpkg.TokenData, error) {
|
||||
if provider.LoginRegion != authpkg.LoginRegionInternational {
|
||||
t.Errorf("OAuth login region = %q, want international", provider.LoginRegion)
|
||||
}
|
||||
return &authpkg.TokenData{AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour)}, nil
|
||||
}
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"intl": "true"}); err != nil {
|
||||
t.Fatalf("international OAuth login = %v", err)
|
||||
}
|
||||
|
||||
blockedConfigDir := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(blockedConfigDir, "mcp_url"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("DWS_CONFIG_DIR", blockedConfigDir)
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": "token", "intl": "true"}); err == nil || !strings.Contains(err.Error(), "failed to persist MCP URL") {
|
||||
t.Fatalf("MCP URL persist failure = %v", err)
|
||||
}
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
|
||||
authRunLoginRecommend = func(context.Context, edition.ToolCaller, io.Writer, pat.LoginRecommendOptions) error {
|
||||
return errors.New("recommend")
|
||||
@@ -1418,7 +1457,7 @@ func TestCrossPlatformCoverageAuthCoveragePortableExchangeAndReset(t *testing.T)
|
||||
authRemove = func(string) error { removed++; return errors.New("ignored") }
|
||||
authDeleteAppConfig = func(string) error { removed++; return errors.New("ignored") }
|
||||
edition.Override(&edition.Hooks{})
|
||||
if err := reset.RunE(reset, nil); err != nil || removed != 3 || !strings.Contains(out.String(), "重新登录") {
|
||||
if err := reset.RunE(reset, nil); err != nil || removed != 4 || !strings.Contains(out.String(), "重新登录") {
|
||||
t.Fatalf("reset = %q, %v, removed=%d", out.String(), err, removed)
|
||||
}
|
||||
edition.Override(&edition.Hooks{IsEmbedded: true})
|
||||
|
||||
@@ -33,6 +33,8 @@ import (
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -1033,8 +1035,12 @@ func TestResolveAuthLoginConfigReadsInheritedYes(t *testing.T) {
|
||||
login := &cobra.Command{Use: "login"}
|
||||
login.Flags().String("token", "", "")
|
||||
login.Flags().Bool("device", false, "")
|
||||
login.Flags().Bool("intl", false, "")
|
||||
login.Flags().Bool("international", false, "")
|
||||
login.Flags().Bool("force", false, "")
|
||||
login.Flags().Bool("recommend", false, "")
|
||||
login.Flags().String("pre-url", "", "")
|
||||
login.Flags().String("mcp-url", "", "")
|
||||
root.AddCommand(login)
|
||||
|
||||
if err := root.PersistentFlags().Set("yes", "true"); err != nil {
|
||||
@@ -1061,6 +1067,560 @@ func TestResolveAuthLoginConfigReadsInheritedYes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveAuthLoginConfigReadsInternationalAliases(t *testing.T) {
|
||||
for _, flag := range []string{"intl", "international"} {
|
||||
t.Run(flag, func(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
login := &cobra.Command{Use: "login"}
|
||||
login.Flags().String("token", "", "")
|
||||
login.Flags().Bool("device", false, "")
|
||||
login.Flags().Bool("intl", false, "")
|
||||
login.Flags().Bool("international", false, "")
|
||||
login.Flags().Bool("force", false, "")
|
||||
login.Flags().Bool("recommend", false, "")
|
||||
login.Flags().String("pre-url", "", "")
|
||||
login.Flags().String("mcp-url", "", "")
|
||||
root.AddCommand(login)
|
||||
|
||||
if err := login.Flags().Set(flag, "true"); err != nil {
|
||||
t.Fatalf("set %s: %v", flag, err)
|
||||
}
|
||||
|
||||
cfg, err := resolveAuthLoginConfig(login)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveAuthLoginConfig error = %v", err)
|
||||
}
|
||||
if !cfg.International {
|
||||
t.Fatalf("International = false for --%s, want true", flag)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
setup func(*cobra.Command)
|
||||
}{
|
||||
{
|
||||
name: "missing intl",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
cmd.Flags().String("token", "", "")
|
||||
cmd.Flags().Bool("device", false, "")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing international",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
cmd.Flags().String("token", "", "")
|
||||
cmd.Flags().Bool("device", false, "")
|
||||
cmd.Flags().Bool("intl", false, "")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing pre url",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
cmd.Flags().String("token", "", "")
|
||||
cmd.Flags().Bool("device", false, "")
|
||||
cmd.Flags().Bool("intl", false, "")
|
||||
cmd.Flags().Bool("international", false, "")
|
||||
cmd.Flags().Bool("force", false, "")
|
||||
cmd.Flags().Bool("recommend", false, "")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing mcp url",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
cmd.Flags().String("token", "", "")
|
||||
cmd.Flags().Bool("device", false, "")
|
||||
cmd.Flags().Bool("intl", false, "")
|
||||
cmd.Flags().Bool("international", false, "")
|
||||
cmd.Flags().Bool("force", false, "")
|
||||
cmd.Flags().Bool("recommend", false, "")
|
||||
cmd.Flags().String("pre-url", "", "")
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "login"}
|
||||
tc.setup(cmd)
|
||||
if _, err := resolveAuthLoginConfig(cmd); err == nil {
|
||||
t.Fatal("resolveAuthLoginConfig succeeded with an incomplete flag set")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveAuthLoginConfigReadsMCPURL(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
login := &cobra.Command{Use: "login"}
|
||||
login.Flags().String("token", "", "")
|
||||
login.Flags().Bool("device", false, "")
|
||||
login.Flags().Bool("intl", false, "")
|
||||
login.Flags().Bool("international", false, "")
|
||||
login.Flags().Bool("force", false, "")
|
||||
login.Flags().Bool("recommend", false, "")
|
||||
login.Flags().String("pre-url", "", "")
|
||||
login.Flags().String("mcp-url", "", "")
|
||||
root.AddCommand(login)
|
||||
|
||||
if err := login.Flags().Set("mcp-url", " https://pre-mcp.dingtalk.io/ "); err != nil {
|
||||
t.Fatalf("set mcp-url: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := resolveAuthLoginConfig(login)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveAuthLoginConfig error = %v", err)
|
||||
}
|
||||
if cfg.MCPURL != "https://pre-mcp.dingtalk.io/" {
|
||||
t.Fatalf("MCPURL = %q, want trimmed flag value", cfg.MCPURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveAuthLoginConfigReadsPreURL(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
login := &cobra.Command{Use: "login"}
|
||||
login.Flags().String("token", "", "")
|
||||
login.Flags().Bool("device", false, "")
|
||||
login.Flags().Bool("intl", false, "")
|
||||
login.Flags().Bool("international", false, "")
|
||||
login.Flags().Bool("force", false, "")
|
||||
login.Flags().Bool("recommend", false, "")
|
||||
login.Flags().String("pre-url", "", "")
|
||||
login.Flags().String("mcp-url", "", "")
|
||||
root.AddCommand(login)
|
||||
|
||||
if err := login.Flags().Set("pre-url", " pre-login.dingtalk.io "); err != nil {
|
||||
t.Fatalf("set pre-url: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := resolveAuthLoginConfig(login)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveAuthLoginConfig error = %v", err)
|
||||
}
|
||||
if cfg.PreURL != "pre-login.dingtalk.io" {
|
||||
t.Fatalf("PreURL = %q, want trimmed flag value", cfg.PreURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthLoginEndpointOverridesForPreURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantLogin string
|
||||
wantMCP string
|
||||
}{
|
||||
{
|
||||
name: "pre login",
|
||||
raw: "https://pre-login.dingtalk.io/",
|
||||
wantLogin: "https://pre-login.dingtalk.io",
|
||||
wantMCP: "https://pre-mcp.dingtalk.io",
|
||||
},
|
||||
{
|
||||
name: "pre mcp",
|
||||
raw: "pre-mcp.dingtalk.io",
|
||||
wantLogin: "https://pre-login.dingtalk.io",
|
||||
wantMCP: "https://pre-mcp.dingtalk.io",
|
||||
},
|
||||
{
|
||||
name: "pre login with port",
|
||||
raw: "https://pre-login.dingtalk.io:8443/path/",
|
||||
wantLogin: "https://pre-login.dingtalk.io:8443/path",
|
||||
wantMCP: "https://pre-mcp.dingtalk.io:8443/path",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := authLoginEndpointOverridesForPreURL(tc.raw)
|
||||
if err != nil {
|
||||
t.Fatalf("authLoginEndpointOverridesForPreURL error = %v", err)
|
||||
}
|
||||
if got.LoginURL != tc.wantLogin || got.MCPURL != tc.wantMCP {
|
||||
t.Fatalf("overrides = %#v, want login %q mcp %q", got, tc.wantLogin, tc.wantMCP)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, raw := range []string{"https://example.com", "http://pre-login.example.com"} {
|
||||
if _, err := authLoginEndpointOverridesForPreURL(raw); err == nil {
|
||||
t.Fatalf("authLoginEndpointOverridesForPreURL(%q) succeeded", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthLoginMCPBaseURLForConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg authLoginConfig
|
||||
preOverride authLoginEndpointOverrides
|
||||
wantURL string
|
||||
wantPersistence authLoginMCPPersistence
|
||||
}{
|
||||
{
|
||||
name: "default center login uses com and resets only managed region",
|
||||
cfg: authLoginConfig{},
|
||||
wantURL: authpkg.DefaultMCPBaseURL,
|
||||
wantPersistence: authLoginMCPUseDefault,
|
||||
},
|
||||
{
|
||||
name: "international login persists managed io",
|
||||
cfg: authLoginConfig{International: true},
|
||||
wantURL: authpkg.InternationalMCPBaseURL,
|
||||
wantPersistence: authLoginMCPUseManagedRegion,
|
||||
},
|
||||
{
|
||||
name: "pre login persists mapped pre mcp",
|
||||
cfg: authLoginConfig{PreURL: "pre-login.dingtalk.io"},
|
||||
preOverride: authLoginEndpointOverrides{
|
||||
LoginURL: "https://pre-login.dingtalk.io",
|
||||
MCPURL: "https://pre-mcp.dingtalk.io",
|
||||
},
|
||||
wantURL: "https://pre-mcp.dingtalk.io",
|
||||
wantPersistence: authLoginMCPUseExplicitOverride,
|
||||
},
|
||||
{
|
||||
name: "explicit mcp url wins over pre url",
|
||||
cfg: authLoginConfig{
|
||||
PreURL: "pre-login.dingtalk.io",
|
||||
MCPURL: " https://custom-mcp.example.com/ ",
|
||||
},
|
||||
preOverride: authLoginEndpointOverrides{
|
||||
LoginURL: "https://pre-login.dingtalk.io",
|
||||
MCPURL: "https://pre-mcp.dingtalk.io",
|
||||
},
|
||||
wantURL: "https://custom-mcp.example.com",
|
||||
wantPersistence: authLoginMCPUseExplicitOverride,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
gotURL, gotPersistence, err := authLoginMCPBaseURLForConfig(tc.cfg, tc.preOverride)
|
||||
if err != nil {
|
||||
t.Fatalf("authLoginMCPBaseURLForConfig error = %v", err)
|
||||
}
|
||||
if gotURL != tc.wantURL || gotPersistence != tc.wantPersistence {
|
||||
t.Fatalf("got url=%q persistence=%v, want url=%q persistence=%v", gotURL, gotPersistence, tc.wantURL, tc.wantPersistence)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, cfg := range []authLoginConfig{
|
||||
{MCPURL: "http://remote.example.com"},
|
||||
{PreURL: "https://example.com"},
|
||||
} {
|
||||
if _, _, err := authLoginMCPBaseURLForConfig(cfg, authLoginEndpointOverrides{}); err == nil {
|
||||
t.Fatalf("authLoginMCPBaseURLForConfig(%#v) succeeded", cfg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePersistAuthLoginMCPBaseURL(t *testing.T) {
|
||||
t.Run("persists selected io mcp url", func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil {
|
||||
t.Fatalf("persistAuthLoginMCPBaseURL error = %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(configDir, "mcp_url"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(mcp_url) error = %v", err)
|
||||
}
|
||||
if string(data) != authpkg.InternationalMCPBaseURL {
|
||||
t.Fatalf("mcp_url = %q, want %q", string(data), authpkg.InternationalMCPBaseURL)
|
||||
}
|
||||
managed, err := os.ReadFile(filepath.Join(configDir, config.ManagedMCPURLRegionFileName))
|
||||
if err != nil || string(managed) != authpkg.InternationalMCPBaseURL {
|
||||
t.Fatalf("managed MCP region = %q, %v", string(managed), err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("center login preserves previous persisted override", func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
mcpURLPath := filepath.Join(configDir, "mcp_url")
|
||||
const customURL = "https://custom-mcp.example.com"
|
||||
if err := os.WriteFile(mcpURLPath, []byte(customURL), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(mcp_url) error = %v", err)
|
||||
}
|
||||
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); err != nil {
|
||||
t.Fatalf("persistAuthLoginMCPBaseURL error = %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(mcpURLPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(mcp_url) error = %v", err)
|
||||
}
|
||||
if string(data) != customURL {
|
||||
t.Fatalf("mcp_url = %q, want preserved override %q", string(data), customURL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("international login preserves an unmanaged explicit override", func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
mcpURLPath := filepath.Join(configDir, "mcp_url")
|
||||
const customURL = "https://private-mcp.example.com"
|
||||
if err := os.WriteFile(mcpURLPath, []byte(customURL), config.FilePerm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(mcpURLPath)
|
||||
if err != nil || string(data) != customURL {
|
||||
t.Fatalf("explicit mcp_url = %q, %v; want preserved custom URL", string(data), err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(configDir, config.ManagedMCPURLRegionFileName)); !os.IsNotExist(err) {
|
||||
t.Fatalf("unmanaged override acquired a managed marker: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("center login resets a managed international URL", func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(configDir, "mcp_url"))
|
||||
if err != nil || string(data) != authpkg.DefaultMCPBaseURL {
|
||||
t.Fatalf("mcp_url = %q, %v; want domestic default", string(data), err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(configDir, config.ManagedMCPURLRegionFileName)); !os.IsNotExist(err) {
|
||||
t.Fatalf("managed region marker remains after domestic login: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit override clears region ownership", func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const customURL = "https://custom-mcp.example.com"
|
||||
if err := persistAuthLoginMCPBaseURL(configDir, customURL, authLoginMCPUseExplicitOverride); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(configDir, "mcp_url"))
|
||||
if err != nil || string(data) != customURL {
|
||||
t.Fatalf("explicit mcp_url = %q, %v; want preserved custom URL", string(data), err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stale marker never deletes a different custom URL", func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(configDir, "mcp_url"), []byte("https://custom.example.com"), config.FilePerm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(configDir, config.ManagedMCPURLRegionFileName), []byte(authpkg.InternationalMCPBaseURL), config.FilePerm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(configDir, "mcp_url"))
|
||||
if err != nil || string(data) != "https://custom.example.com" {
|
||||
t.Fatalf("custom mcp_url = %q, %v", string(data), err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("international login clears a stale marker without changing a custom URL", func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
mcpURLPath := filepath.Join(configDir, "mcp_url")
|
||||
managedRegionPath := filepath.Join(configDir, config.ManagedMCPURLRegionFileName)
|
||||
const customURL = "https://private-mcp.example.com"
|
||||
if err := os.WriteFile(mcpURLPath, []byte(customURL), config.FilePerm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(managedRegionPath, []byte(authpkg.DefaultMCPBaseURL), config.FilePerm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(mcpURLPath)
|
||||
if err != nil || string(data) != customURL {
|
||||
t.Fatalf("custom mcp_url = %q, %v", string(data), err)
|
||||
}
|
||||
if _, err := os.Stat(managedRegionPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("stale managed marker remains: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePersistAuthLoginMCPBaseURLErrors(t *testing.T) {
|
||||
fail := errors.New("persist failure")
|
||||
|
||||
t.Run("explicit marker cleanup", func(t *testing.T) {
|
||||
testseam.Swap(t, &authRemove, func(string) error { return fail })
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), "https://custom.example.com", authLoginMCPUseExplicitOverride); !errors.Is(err, fail) {
|
||||
t.Fatalf("explicit marker cleanup error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit URL write", func(t *testing.T) {
|
||||
testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error { return fail })
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), "https://custom.example.com", authLoginMCPUseExplicitOverride); !errors.Is(err, fail) {
|
||||
t.Fatalf("explicit URL write error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("managed marker write", func(t *testing.T) {
|
||||
testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error { return fail })
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) {
|
||||
t.Fatalf("managed marker write error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("managed marker read", func(t *testing.T) {
|
||||
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { return nil, fail })
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) {
|
||||
t.Fatalf("managed marker read error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("managed MCP URL read", func(t *testing.T) {
|
||||
reads := 0
|
||||
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) {
|
||||
reads++
|
||||
if reads == 1 {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return nil, fail
|
||||
})
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) {
|
||||
t.Fatalf("managed MCP URL read error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("managed stale marker cleanup", func(t *testing.T) {
|
||||
reads := 0
|
||||
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) {
|
||||
reads++
|
||||
if reads == 1 {
|
||||
return []byte(authpkg.DefaultMCPBaseURL), nil
|
||||
}
|
||||
return []byte("https://private-mcp.example.com"), nil
|
||||
})
|
||||
testseam.Swap(t, &authRemove, func(string) error { return fail })
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) {
|
||||
t.Fatalf("managed stale marker cleanup error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("managed URL write cleans marker", func(t *testing.T) {
|
||||
writes := 0
|
||||
removed := false
|
||||
testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error {
|
||||
writes++
|
||||
if writes == 2 {
|
||||
return fail
|
||||
}
|
||||
return nil
|
||||
})
|
||||
testseam.Swap(t, &authRemove, func(string) error { removed = true; return nil })
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) || !removed {
|
||||
t.Fatalf("managed URL write error = %v, marker removed=%v", err, removed)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("default managed marker read", func(t *testing.T) {
|
||||
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { return nil, fail })
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); !errors.Is(err, fail) {
|
||||
t.Fatalf("managed marker read error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing MCP URL cleans marker", func(t *testing.T) {
|
||||
reads := 0
|
||||
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) {
|
||||
reads++
|
||||
if reads == 1 {
|
||||
return []byte(authpkg.InternationalMCPBaseURL), nil
|
||||
}
|
||||
return nil, os.ErrNotExist
|
||||
})
|
||||
testseam.Swap(t, &authRemove, func(string) error { return nil })
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); err != nil {
|
||||
t.Fatalf("missing MCP URL cleanup error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("MCP URL read", func(t *testing.T) {
|
||||
reads := 0
|
||||
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) {
|
||||
reads++
|
||||
if reads == 1 {
|
||||
return []byte(authpkg.InternationalMCPBaseURL), nil
|
||||
}
|
||||
return nil, fail
|
||||
})
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); !errors.Is(err, fail) {
|
||||
t.Fatalf("MCP URL read error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("default URL write", func(t *testing.T) {
|
||||
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { return []byte(authpkg.InternationalMCPBaseURL), nil })
|
||||
testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error { return fail })
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); !errors.Is(err, fail) {
|
||||
t.Fatalf("default URL write error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("final marker cleanup", func(t *testing.T) {
|
||||
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { return []byte(authpkg.InternationalMCPBaseURL), nil })
|
||||
testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error { return nil })
|
||||
testseam.Swap(t, &authRemove, func(string) error { return fail })
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); !errors.Is(err, fail) {
|
||||
t.Fatalf("final marker cleanup error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPPersistence(255)); err == nil {
|
||||
t.Fatal("unsupported MCP persistence mode succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNormalizeAuthLoginBaseURLTransportSecurity(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantURL string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "https remote", raw: "https://pre-mcp.example.com/path/?secret=drop#fragment", wantURL: "https://pre-mcp.example.com/path"},
|
||||
{name: "http localhost", raw: "http://localhost:8080/", wantURL: "http://localhost:8080"},
|
||||
{name: "http IPv4 loopback", raw: "http://127.0.0.1:8080", wantURL: "http://127.0.0.1:8080"},
|
||||
{name: "http IPv6 loopback", raw: "http://[::1]:8080", wantURL: "http://[::1]:8080"},
|
||||
{name: "http remote", raw: "http://pre-mcp.example.com", wantErr: "must use HTTPS"},
|
||||
{name: "empty", raw: " ", wantErr: "cannot be empty"},
|
||||
{name: "invalid URL", raw: "https://%", wantErr: "invalid --mcp-url"},
|
||||
{name: "invalid scheme", raw: "ftp://pre-mcp.example.com", wantErr: "must use http or https"},
|
||||
{name: "missing host", raw: "https:///path", wantErr: "must include a host"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, got, err := normalizeAuthLoginBaseURL(tc.raw, "--mcp-url")
|
||||
if tc.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("normalizeAuthLoginBaseURL error = %v, want containing %q", err, tc.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeAuthLoginBaseURL error = %v", err)
|
||||
}
|
||||
if got != tc.wantURL {
|
||||
t.Fatalf("normalized URL = %q, want %q", got, tc.wantURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginForcesAuthorizationByDefault(t *testing.T) {
|
||||
if !authLoginForcesAuthorization(authLoginConfig{}) {
|
||||
t.Fatal("auth login should force authorization by default so each login can add an organization profile")
|
||||
|
||||
@@ -36,6 +36,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
upgradepkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
@@ -326,6 +327,29 @@ func TestCrossPlatformCoverageSmallAppRegistryAndRootCoverage(t *testing.T) {
|
||||
func TestCrossPlatformCoverageDirectRuntimeCoverage(t *testing.T) {
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(oldEdition); SetDynamicServers(nil) })
|
||||
for _, tc := range []struct {
|
||||
raw string
|
||||
region authpkg.LoginRegion
|
||||
want string
|
||||
}{
|
||||
{raw: "%", want: "%"},
|
||||
{raw: "https://dingtalk.io/path", want: "https://dingtalk.com/path"},
|
||||
{raw: "https://mcp.dingtalk.com:8443/path", region: authpkg.LoginRegionInternational, want: "https://mcp.dingtalk.io:8443/path"},
|
||||
} {
|
||||
if got := mcpBaseURLForLoginRegion(tc.raw, tc.region); got != tc.want {
|
||||
t.Fatalf("mcpBaseURLForLoginRegion(%q, %q) = %q, want %q", tc.raw, tc.region, got, tc.want)
|
||||
}
|
||||
}
|
||||
if hasDirectRuntimeEndpointOverride("") {
|
||||
t.Fatal("blank product unexpectedly has an endpoint override")
|
||||
}
|
||||
t.Setenv("DINGTALK_COVERAGE_PRODUCT_MCP_URL", "https://override.test")
|
||||
if !hasDirectRuntimeEndpointOverride("coverage-product") {
|
||||
t.Fatal("configured product endpoint override was not detected")
|
||||
}
|
||||
if got := activeDingTalkGatewayEndpointWithBase("https://mcp-gw.dingtalk.com/server/contact", "%"); got != "https://mcp-gw.dingtalk.com/server/contact" {
|
||||
t.Fatalf("invalid gateway base rewrote endpoint to %q", got)
|
||||
}
|
||||
server := mcptypes.ServerDescriptor{
|
||||
Endpoint: "https://one.test",
|
||||
CLI: mcptypes.CLIOverlay{
|
||||
@@ -913,12 +937,21 @@ func TestCrossPlatformCoverageAuthCommandPureCoverage(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthLoginTokenCommandCoverage(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
oldInteractive := authLoginInteractiveTerminal
|
||||
authLoginInteractiveTerminal = func() bool { return false }
|
||||
t.Cleanup(func() { authLoginInteractiveTerminal = oldInteractive; authpkg.SetRuntimeProfile("") })
|
||||
for _, format := range []string{"table", "json"} {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
for _, tc := range []struct {
|
||||
format string
|
||||
international bool
|
||||
}{
|
||||
{format: "table"},
|
||||
{format: "json", international: true},
|
||||
} {
|
||||
t.Run(tc.format, func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().String("format", "table", "")
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
@@ -929,16 +962,90 @@ func TestCrossPlatformCoverageAuthLoginTokenCommandCoverage(t *testing.T) {
|
||||
root.SetOut(&output)
|
||||
root.SetErr(io.Discard)
|
||||
args := []string{"login", "--token", "manual-token", "--yes"}
|
||||
if format == "json" {
|
||||
if tc.international {
|
||||
args = append(args, "--intl")
|
||||
}
|
||||
if tc.format == "json" {
|
||||
args = append(args, "--format", "json")
|
||||
}
|
||||
root.SetArgs(args)
|
||||
if err := root.Execute(); err != nil || output.Len() == 0 {
|
||||
t.Fatalf("token login = %q %v", output.String(), err)
|
||||
}
|
||||
data, err := authpkg.LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData error = %v", err)
|
||||
}
|
||||
wantRegion := ""
|
||||
if tc.international {
|
||||
wantRegion = string(authpkg.LoginRegionInternational)
|
||||
}
|
||||
if data.LoginRegion != wantRegion {
|
||||
t.Fatalf("LoginRegion = %q, want %q", data.LoginRegion, wantRegion)
|
||||
}
|
||||
if tc.international {
|
||||
snapshot, err := resolveAccessTokenSnapshotFromDir(context.Background(), configDir, "")
|
||||
if err != nil {
|
||||
t.Fatalf("resolveAccessTokenSnapshotFromDir error = %v", err)
|
||||
}
|
||||
gotEndpoint := activeDingTalkGatewayEndpointForLoginRegion(
|
||||
"https://mcp-gw.dingtalk.com/server/contact",
|
||||
snapshot.LoginRegion,
|
||||
)
|
||||
if wantEndpoint := "https://mcp-gw.dingtalk.io/server/contact"; gotEndpoint != wantEndpoint {
|
||||
t.Fatalf("international manual-token endpoint = %q, want %q", gotEndpoint, wantEndpoint)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("international then domestic login restores domestic MCP URL", func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
runLogin := func(international bool) {
|
||||
t.Helper()
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().String("format", "table", "")
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().String("profile", "", "")
|
||||
root.AddCommand(newAuthLoginCommand(nil))
|
||||
args := []string{"login", "--token", "manual-token", "--yes"}
|
||||
if international {
|
||||
args = append(args, "--intl")
|
||||
}
|
||||
root.SetArgs(args)
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("international=%v login error = %v", international, err)
|
||||
}
|
||||
}
|
||||
|
||||
runLogin(true)
|
||||
if data, err := os.ReadFile(filepath.Join(configDir, "mcp_url")); err != nil || string(data) != authpkg.InternationalMCPBaseURL {
|
||||
t.Fatalf("international mcp_url = %q, %v", string(data), err)
|
||||
}
|
||||
runLogin(false)
|
||||
if data, err := os.ReadFile(filepath.Join(configDir, "mcp_url")); err != nil || string(data) != authpkg.DefaultMCPBaseURL {
|
||||
t.Fatalf("domestic mcp_url = %q, %v", string(data), err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(configDir, config.ManagedMCPURLRegionFileName)); !os.IsNotExist(err) {
|
||||
t.Fatalf("managed MCP region marker remains: %v", err)
|
||||
}
|
||||
|
||||
const customURL = "https://private-mcp.example.com"
|
||||
if err := os.WriteFile(filepath.Join(configDir, "mcp_url"), []byte(customURL), config.FilePerm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runLogin(true)
|
||||
if data, err := os.ReadFile(filepath.Join(configDir, "mcp_url")); err != nil || string(data) != customURL {
|
||||
t.Fatalf("explicit mcp_url after international login = %q, %v; want preserved custom URL", string(data), err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(configDir, config.ManagedMCPURLRegionFileName)); !os.IsNotExist(err) {
|
||||
t.Fatalf("explicit mcp_url acquired a managed marker: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
for _, hidden := range []bool{false, true} {
|
||||
old := edition.Get()
|
||||
edition.Override(&edition.Hooks{HideAuthLogin: hidden})
|
||||
|
||||
@@ -74,6 +74,20 @@ func defaultPATMCPEndpoint() string {
|
||||
|
||||
func defaultPATGatewayBaseURL() string {
|
||||
raw := strings.TrimSpace(authpkg.GetMCPBaseURL())
|
||||
return mcpGatewayBaseURL(raw)
|
||||
}
|
||||
|
||||
func defaultPATGatewayBaseURLForLoginRegion(region authpkg.LoginRegion) string {
|
||||
raw := strings.TrimSpace(authpkg.GetMCPBaseURL())
|
||||
if override := authpkg.MCPBaseURLOverride(); override != "" {
|
||||
raw = override
|
||||
} else {
|
||||
raw = mcpBaseURLForLoginRegion(raw, region)
|
||||
}
|
||||
return mcpGatewayBaseURL(raw)
|
||||
}
|
||||
|
||||
func mcpGatewayBaseURL(raw string) string {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return strings.TrimRight(raw, "/")
|
||||
@@ -100,6 +114,33 @@ func defaultPATGatewayBaseURL() string {
|
||||
return strings.TrimRight(parsed.String(), "/")
|
||||
}
|
||||
|
||||
func mcpBaseURLForLoginRegion(raw string, region authpkg.LoginRegion) string {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return raw
|
||||
}
|
||||
host := strings.ToLower(parsed.Hostname())
|
||||
fromSuffix, toSuffix := ".dingtalk.io", ".dingtalk.com"
|
||||
if region.IsInternational() {
|
||||
fromSuffix, toSuffix = toSuffix, fromSuffix
|
||||
}
|
||||
bareFrom := strings.TrimPrefix(fromSuffix, ".")
|
||||
if host != bareFrom && !strings.HasSuffix(host, fromSuffix) {
|
||||
return raw
|
||||
}
|
||||
if host == bareFrom {
|
||||
host = strings.TrimPrefix(toSuffix, ".")
|
||||
} else {
|
||||
host = strings.TrimSuffix(host, fromSuffix) + toSuffix
|
||||
}
|
||||
if port := parsed.Port(); port != "" {
|
||||
parsed.Host = net.JoinHostPort(host, port)
|
||||
} else {
|
||||
parsed.Host = host
|
||||
}
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
// SetDynamicServers injects server data discovered from servers.json.
|
||||
// All product endpoints are resolved dynamically from this data.
|
||||
func SetDynamicServers(servers []mcptypes.ServerDescriptor) {
|
||||
@@ -131,7 +172,7 @@ func registerDynamicServer(server mcptypes.ServerDescriptor, endpoints map[strin
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(server.CLI.ID)
|
||||
endpoint := strings.TrimSpace(server.Endpoint)
|
||||
endpoint := activeDingTalkGatewayEndpoint(server.Endpoint)
|
||||
if id != "" && endpoint != "" {
|
||||
endpoints[id] = endpoint
|
||||
products[id] = true
|
||||
@@ -262,6 +303,19 @@ func directRuntimeEndpoint(productID, toolName string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
func hasDirectRuntimeEndpointOverride(productID string) bool {
|
||||
normalized := normalizeDirectRuntimeProductID(productID)
|
||||
for _, candidate := range []string{strings.TrimSpace(productID), normalized} {
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := productEndpointOverride(candidate); ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func editionServerEndpoint(productID string) (string, bool) {
|
||||
productID = strings.TrimSpace(productID)
|
||||
if productID == "" {
|
||||
@@ -282,7 +336,7 @@ func endpointFromEditionServers(productID string, fn func() []edition.ServerInfo
|
||||
return "", false
|
||||
}
|
||||
for _, server := range fn() {
|
||||
endpoint := strings.TrimSpace(server.Endpoint)
|
||||
endpoint := activeDingTalkGatewayEndpoint(server.Endpoint)
|
||||
if endpoint == "" {
|
||||
continue
|
||||
}
|
||||
@@ -298,6 +352,46 @@ func endpointFromEditionServers(productID string, fn func() []edition.ServerInfo
|
||||
return "", false
|
||||
}
|
||||
|
||||
func activeDingTalkGatewayEndpoint(endpoint string) string {
|
||||
return activeDingTalkGatewayEndpointWithBase(endpoint, defaultPATGatewayBaseURL())
|
||||
}
|
||||
|
||||
func activeDingTalkGatewayEndpointForLoginRegion(endpoint string, region authpkg.LoginRegion) string {
|
||||
return activeDingTalkGatewayEndpointWithBase(endpoint, defaultPATGatewayBaseURLForLoginRegion(region))
|
||||
}
|
||||
|
||||
func activeDingTalkGatewayEndpointWithBase(endpoint, gatewayBaseURL string) string {
|
||||
endpoint = strings.TrimSpace(endpoint)
|
||||
parsed, err := url.Parse(endpoint)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return endpoint
|
||||
}
|
||||
if !isDingTalkMCPGatewayHost(parsed.Hostname()) {
|
||||
return endpoint
|
||||
}
|
||||
base, err := url.Parse(gatewayBaseURL)
|
||||
if err != nil || base.Scheme == "" || base.Host == "" {
|
||||
return endpoint
|
||||
}
|
||||
parsed.Scheme = base.Scheme
|
||||
parsed.Host = base.Host
|
||||
return strings.TrimRight(parsed.String(), "/")
|
||||
}
|
||||
|
||||
func isDingTalkMCPGatewayHost(host string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(host)) {
|
||||
case "mcp-gw.dingtalk.com", "pre-mcp-gw.dingtalk.com", "mcp-gw.dingtalk.io", "pre-mcp-gw.dingtalk.io":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isDingTalkMCPGatewayEndpoint(endpoint string) bool {
|
||||
parsed, err := url.Parse(strings.TrimSpace(endpoint))
|
||||
return err == nil && isDingTalkMCPGatewayHost(parsed.Hostname())
|
||||
}
|
||||
|
||||
// DirectRuntimeProductIDs returns product IDs that should stay visible for
|
||||
// direct runtime execution. Dynamic products come from MCP discovery/plugin
|
||||
// registration; built-in helper products such as devapp resolve their endpoint
|
||||
|
||||
@@ -479,7 +479,11 @@ func TestCrossPlatformCoverageChatReactionConversationAliasesReachCanonicalPaylo
|
||||
if err != nil {
|
||||
t.Fatalf("alias execution failed: %v", err)
|
||||
}
|
||||
if ctx == nil || len(ctx.Corrections) != 1 || ctx.Corrections[0].Original != "--"+alias || ctx.Corrections[0].Corrected != "--conversation-id" {
|
||||
if alias == "open-conversation-id" {
|
||||
if ctx == nil || len(ctx.Corrections) != 0 {
|
||||
t.Fatalf("alias corrections = %#v", ctx)
|
||||
}
|
||||
} else if ctx == nil || len(ctx.Corrections) != 1 || ctx.Corrections[0].Original != "--"+alias || ctx.Corrections[0].Corrected != "--conversation-id" {
|
||||
t.Fatalf("alias corrections = %#v", ctx)
|
||||
}
|
||||
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
@@ -93,8 +94,8 @@ func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) {
|
||||
}{
|
||||
{args: []string{"chat", "send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
|
||||
{args: []string{"im", "send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
|
||||
{args: []string{"chat", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
|
||||
{args: []string{"im", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
|
||||
{args: []string{"chat", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --conversation-id <GROUP_OPEN_CONVERSATION_ID>"},
|
||||
{args: []string{"im", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --conversation-id <GROUP_OPEN_CONVERSATION_ID>"},
|
||||
} {
|
||||
command := NewRootCommand()
|
||||
command.SilenceErrors = true
|
||||
@@ -488,6 +489,80 @@ func TestInjectStaticServersMergesStaticAndSupplementServers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageStaticDingTalkEndpointsFollowConfiguredMCPBaseURL(t *testing.T) {
|
||||
previous := edition.Get()
|
||||
defer edition.Override(previous)
|
||||
defer SetDynamicServers(nil)
|
||||
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
if err := os.WriteFile(filepath.Join(configDir, "mcp_url"), []byte("https://pre-mcp.dingtalk.io\n"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(mcp_url) error = %v", err)
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{
|
||||
Name: "test",
|
||||
StaticServers: func() []edition.ServerInfo {
|
||||
return []edition.ServerInfo{{
|
||||
ID: "contact",
|
||||
Name: "Contact",
|
||||
Endpoint: "https://mcp-gw.dingtalk.com/server/contact?key=abc",
|
||||
Prefixes: []string{"user"},
|
||||
}}
|
||||
},
|
||||
})
|
||||
|
||||
injectStaticServers()
|
||||
|
||||
for _, productID := range []string{"contact", "user"} {
|
||||
got, ok := directRuntimeEndpoint(productID, "")
|
||||
want := "https://pre-mcp-gw.dingtalk.io/server/contact?key=abc"
|
||||
if !ok || got != want {
|
||||
t.Fatalf("directRuntimeEndpoint(%q) = %q, %v; want %q, true", productID, got, ok, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDingTalkEndpointsFollowSelectedTokenRegion(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
mcpURLPath := filepath.Join(configDir, "mcp_url")
|
||||
|
||||
if err := os.WriteFile(mcpURLPath, []byte("https://pre-mcp.dingtalk.io\n"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(mcp_url) error = %v", err)
|
||||
}
|
||||
endpoint := "https://pre-mcp-gw.dingtalk.io/server/contact?key=abc"
|
||||
if got, want := activeDingTalkGatewayEndpointForLoginRegion(endpoint, authpkg.LoginRegionDefault), "https://pre-mcp-gw.dingtalk.com/server/contact?key=abc"; got != want {
|
||||
t.Fatalf("domestic profile endpoint = %q, want %q", got, want)
|
||||
}
|
||||
if got := activeDingTalkGatewayEndpointForLoginRegion(endpoint, authpkg.LoginRegionInternational); got != endpoint {
|
||||
t.Fatalf("international profile endpoint = %q, want %q", got, endpoint)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(mcpURLPath, []byte("https://mcp.dingtalk.com\n"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(mcp_url) error = %v", err)
|
||||
}
|
||||
if got, want := activeDingTalkGatewayEndpointForLoginRegion("https://mcp-gw.dingtalk.com/server/contact", authpkg.LoginRegionInternational), "https://mcp-gw.dingtalk.io/server/contact"; got != want {
|
||||
t.Fatalf("international profile endpoint from domestic config = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDingTalkEndpointUsesLoginScopedMCPOverride(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
restore := authpkg.PushMCPBaseURLOverride("https://pre-mcp.dingtalk.io")
|
||||
defer restore()
|
||||
|
||||
got := activeDingTalkGatewayEndpointForLoginRegion(
|
||||
"https://mcp-gw.dingtalk.com/server/contact",
|
||||
authpkg.LoginRegionDefault,
|
||||
)
|
||||
want := "https://pre-mcp-gw.dingtalk.io/server/contact"
|
||||
if got != want {
|
||||
t.Fatalf("login-scoped endpoint = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func mustFindCommand(t *testing.T, root *cobra.Command, path ...string) *cobra.Command {
|
||||
t.Helper()
|
||||
cmd := root
|
||||
|
||||
+29
-12
@@ -162,7 +162,7 @@ var (
|
||||
runnerResolveMultiProfileSelections = resolveMultiProfileSelections
|
||||
runnerResolveProfile = authpkg.ResolveProfile
|
||||
runnerGetCachedRuntimeToken = getCachedRuntimeToken
|
||||
runnerResolveAuthToken = (*runtimeRunner).resolveAuthToken
|
||||
runnerResolveAuthSnapshot = (*runtimeRunner).resolveAuthSnapshot
|
||||
runnerPreflightDocDownload = (*runtimeRunner).preflightDocDownload
|
||||
runnerCallTool = (*transport.Client).CallTool
|
||||
runnerStdioEnsureInitialized = (*transport.StdioClient).EnsureInitialized
|
||||
@@ -552,11 +552,16 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
if hasPluginAuth {
|
||||
authToken = pluginAuth.Token
|
||||
} else if !invocation.DryRun && (r.globalFlags == nil || !r.globalFlags.Mock) {
|
||||
var tokenErr error
|
||||
authToken, tokenErr = runnerResolveAuthToken(r, ctx)
|
||||
snapshot, tokenErr := runnerResolveAuthSnapshot(r, ctx)
|
||||
if tokenErr != nil {
|
||||
return executor.Result{}, tokenResolutionError(tokenErr)
|
||||
}
|
||||
authToken = snapshot.AccessToken
|
||||
if !hasDirectRuntimeEndpointOverride(invocation.CanonicalProduct) &&
|
||||
isDingTalkMCPGatewayEndpoint(endpoint) &&
|
||||
(snapshot.LoginRegionKnown || authpkg.MCPBaseURLOverride() != "") {
|
||||
endpoint = activeDingTalkGatewayEndpointForLoginRegion(endpoint, snapshot.LoginRegion)
|
||||
}
|
||||
}
|
||||
|
||||
var timeoutSec int
|
||||
@@ -877,21 +882,33 @@ func (r *runtimeRunner) executeStdioInvocationAtEndpoint(
|
||||
}
|
||||
|
||||
func (r *runtimeRunner) resolveAuthToken(ctx context.Context) (string, error) {
|
||||
explicitToken := ""
|
||||
if r != nil && r.globalFlags != nil {
|
||||
explicitToken = r.globalFlags.Token
|
||||
}
|
||||
return resolveRuntimeAuthToken(ctx, explicitToken)
|
||||
}
|
||||
|
||||
func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) (string, error) {
|
||||
snapshot, err := runtimeTokenManager.Get(ctx, defaultConfigDir(), explicitToken)
|
||||
snapshot, err := r.resolveAuthSnapshot(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return snapshot.AccessToken, nil
|
||||
}
|
||||
|
||||
func (r *runtimeRunner) resolveAuthSnapshot(ctx context.Context) (AccessTokenSnapshot, error) {
|
||||
explicitToken := ""
|
||||
if r != nil && r.globalFlags != nil {
|
||||
explicitToken = r.globalFlags.Token
|
||||
}
|
||||
return resolveRuntimeAuthSnapshot(ctx, explicitToken)
|
||||
}
|
||||
|
||||
func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) (string, error) {
|
||||
snapshot, err := resolveRuntimeAuthSnapshot(ctx, explicitToken)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return snapshot.AccessToken, nil
|
||||
}
|
||||
|
||||
func resolveRuntimeAuthSnapshot(ctx context.Context, explicitToken string) (AccessTokenSnapshot, error) {
|
||||
return runtimeTokenManager.Get(ctx, defaultConfigDir(), explicitToken)
|
||||
}
|
||||
|
||||
// getCachedRuntimeToken is kept as the prefetch seam used by runner tests. The
|
||||
// cache itself lives exclusively in TokenManager.
|
||||
func getCachedRuntimeToken(ctx context.Context) (string, error) {
|
||||
|
||||
@@ -127,12 +127,14 @@ func TestCrossPlatformCoverageRunnerRemainingRoutingCoverage(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunnerRemainingExecutionCoverage(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
oldEdition := edition.Get()
|
||||
oldPreflight := runnerPreflightDocDownload
|
||||
oldCall := runnerCallTool
|
||||
oldHandle := runnerHandlePatAuthCheck
|
||||
oldRetry := runnerRetryWithPatAuthRetry
|
||||
oldCapture := runnerCaptureRuntimeFailure
|
||||
oldResolveSnapshot := runnerResolveAuthSnapshot
|
||||
t.Cleanup(func() {
|
||||
edition.Override(oldEdition)
|
||||
runnerPreflightDocDownload = oldPreflight
|
||||
@@ -140,6 +142,7 @@ func TestCrossPlatformCoverageRunnerRemainingExecutionCoverage(t *testing.T) {
|
||||
runnerHandlePatAuthCheck = oldHandle
|
||||
runnerRetryWithPatAuthRetry = oldRetry
|
||||
runnerCaptureRuntimeFailure = oldCapture
|
||||
runnerResolveAuthSnapshot = oldResolveSnapshot
|
||||
})
|
||||
|
||||
pluginAuthMu.Lock()
|
||||
@@ -168,6 +171,27 @@ func TestCrossPlatformCoverageRunnerRemainingExecutionCoverage(t *testing.T) {
|
||||
if got, err := r.executeInvocation(context.Background(), "https://example.test", inv); err != nil || !got.Invocation.Implemented {
|
||||
t.Fatalf("default auth execution = %#v, %v", got, err)
|
||||
}
|
||||
runnerResolveAuthSnapshot = func(*runtimeRunner, context.Context) (AccessTokenSnapshot, error) {
|
||||
return AccessTokenSnapshot{
|
||||
AccessToken: "international-token",
|
||||
LoginRegion: authpkg.LoginRegionInternational,
|
||||
LoginRegionKnown: true,
|
||||
}, nil
|
||||
}
|
||||
successfulCall := runnerCallTool
|
||||
routedEndpoint := ""
|
||||
runnerCallTool = func(_ *transport.Client, _ context.Context, endpoint, _ string, _ map[string]any) (transport.ToolCallResult, error) {
|
||||
routedEndpoint = endpoint
|
||||
return transport.ToolCallResult{Content: map[string]any{"value": 1}}, nil
|
||||
}
|
||||
if _, err := r.executeInvocation(context.Background(), "https://mcp-gw.dingtalk.com/server/contact", inv); err != nil {
|
||||
t.Fatalf("international auth execution = %v", err)
|
||||
}
|
||||
if routedEndpoint != "https://mcp-gw.dingtalk.io/server/contact" {
|
||||
t.Fatalf("international routed endpoint = %q", routedEndpoint)
|
||||
}
|
||||
runnerResolveAuthSnapshot = oldResolveSnapshot
|
||||
runnerCallTool = successfulCall
|
||||
|
||||
wantErr := errors.New("preflight")
|
||||
runnerPreflightDocDownload = func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error {
|
||||
@@ -362,6 +386,12 @@ func TestCrossPlatformCoverageRunnerRemainingStdioAuthAndHeadersCoverage(t *test
|
||||
if got, err := resolveRuntimeAuthToken(context.Background(), " runtime "); err != nil || got != "runtime" {
|
||||
t.Fatalf("runtime explicit token = %q, %v", got, err)
|
||||
}
|
||||
edition.Override(&edition.Hooks{TokenProvider: func(context.Context, func() (string, error)) (string, error) {
|
||||
return "", errors.New("snapshot failed")
|
||||
}})
|
||||
if _, err := r.resolveAuthToken(context.Background()); err == nil || !strings.Contains(err.Error(), "snapshot failed") {
|
||||
t.Fatalf("resolveAuthToken error = %v", err)
|
||||
}
|
||||
|
||||
t.Setenv(envDWSChannel, "channel")
|
||||
edition.Override(&edition.Hooks{
|
||||
|
||||
@@ -207,7 +207,11 @@ func assertChatCatalogCompleteLeafContracts(t testing.TB) {
|
||||
})
|
||||
|
||||
auditJoin := executeShortcutSchemaQuery(t, "--cli-path", "chat group audit-join-validation")
|
||||
assertSchemaLeafParameterRequired(t, auditJoin, "chat group audit-join-validation", "conversation-id", true)
|
||||
assertSchemaLeafParameterEnum(t, auditJoin, "chat group audit-join-validation", "status", []string{"AuditApprove", "AuditDelete"})
|
||||
if parameters := schemaContractMap(auditJoin["parameters"]); parameters["group"] != nil {
|
||||
t.Fatalf("chat group audit-join-validation publishes hidden --group alias: %#v", parameters["group"])
|
||||
}
|
||||
}
|
||||
|
||||
func assertSchemaLeafParameterRequired(t testing.TB, leaf map[string]any, cliPath, name string, want bool) {
|
||||
|
||||
@@ -424,6 +424,87 @@ func TestBuildAuthURLIncludesTargetCorpID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBuildAuthURLForInternationalRegion(t *testing.T) {
|
||||
authURL := buildAuthURLForRegion("client-id", "http://127.0.0.1:1234/callback", "", LoginRegionInternational)
|
||||
if !strings.HasPrefix(authURL, InternationalAuthorizeURL+"?") {
|
||||
t.Fatalf("auth URL = %s, want international authorize host", authURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNotEnabledHTMLUsesRegionAwareAuthorizeURL(t *testing.T) {
|
||||
if !strings.Contains(notEnabledHTML, "status.authorizeUrl") {
|
||||
t.Fatal("not-enabled page must read the authorize URL from the regional login status")
|
||||
}
|
||||
if strings.Contains(notEnabledHTML, `"https://login.dingtalk.com/oauth2/auth?client_id="`) {
|
||||
t.Fatal("not-enabled page must not hard-code the domestic authorize URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLoginRegionEndpointDefaults(t *testing.T) {
|
||||
if got := AuthorizeURLForLoginRegion(LoginRegionDefault); got != AuthorizeURL {
|
||||
t.Fatalf("default authorize URL = %q, want %q", got, AuthorizeURL)
|
||||
}
|
||||
if got := DeviceBaseURLForLoginRegion(LoginRegionDefault); got != DefaultDeviceBaseURL {
|
||||
t.Fatalf("default device base URL = %q, want %q", got, DefaultDeviceBaseURL)
|
||||
}
|
||||
if got := UserAccessTokenURLForLoginRegion(LoginRegionInternational); got != InternationalUserAccessTokenURL {
|
||||
t.Fatalf("international user access token URL = %q, want %q", got, InternationalUserAccessTokenURL)
|
||||
}
|
||||
if got := MCPBaseURLForLoginRegion(LoginRegionInternational); got != InternationalMCPBaseURL {
|
||||
t.Fatalf("international MCP base URL = %q, want %q", got, InternationalMCPBaseURL)
|
||||
}
|
||||
if got := DeviceBaseURLForLoginRegion(LoginRegionInternational); got != InternationalDeviceBaseURL {
|
||||
t.Fatalf("international device base URL = %q, want %q", got, InternationalDeviceBaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAuthProviderLoginRegionHelpers(t *testing.T) {
|
||||
var nilProvider *OAuthProvider
|
||||
if got := nilProvider.loginRegion(); got != LoginRegionDefault {
|
||||
t.Fatalf("nil provider login region = %q", got)
|
||||
}
|
||||
nilProvider.useTokenLoginRegion(&TokenData{LoginRegion: string(LoginRegionInternational)})
|
||||
nilProvider.applyLoginRegionToToken(&TokenData{})
|
||||
|
||||
provider := &OAuthProvider{}
|
||||
provider.useTokenLoginRegion(nil)
|
||||
provider.useTokenLoginRegion(&TokenData{LoginRegion: string(LoginRegionInternational)})
|
||||
if provider.LoginRegion != LoginRegionInternational {
|
||||
t.Fatalf("provider login region = %q, want international", provider.LoginRegion)
|
||||
}
|
||||
provider.useTokenLoginRegion(&TokenData{LoginRegion: string(LoginRegionDefault)})
|
||||
provider.applyLoginRegionToToken(nil)
|
||||
token := &TokenData{}
|
||||
provider.applyLoginRegionToToken(token)
|
||||
if token.LoginRegion != string(LoginRegionInternational) {
|
||||
t.Fatalf("token login region = %q, want international", token.LoginRegion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMCPBaseURLOverrideAffectsInternationalRegion(t *testing.T) {
|
||||
restore := PushMCPBaseURLOverride("https://pre-mcp.dingtalk.io/")
|
||||
defer restore()
|
||||
|
||||
if got := MCPBaseURLForLoginRegion(LoginRegionInternational); got != "https://pre-mcp.dingtalk.io" {
|
||||
t.Fatalf("international MCP base URL = %q, want override", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLoginBaseURLOverrideAffectsInternationalRegion(t *testing.T) {
|
||||
restore := PushLoginBaseURLOverride("https://pre-login.dingtalk.io/")
|
||||
defer restore()
|
||||
|
||||
if got := DeviceBaseURLForLoginRegion(LoginRegionInternational); got != "https://pre-login.dingtalk.io" {
|
||||
t.Fatalf("international device base URL = %q, want override", got)
|
||||
}
|
||||
if got := AuthorizeURLForLoginRegion(LoginRegionInternational); got != "https://pre-login.dingtalk.io/oauth2/auth" {
|
||||
t.Fatalf("international authorize URL = %q, want override", got)
|
||||
}
|
||||
if got := UserAccessTokenURLForLoginRegion(LoginRegionInternational); got != "https://pre-login.dingtalk.io/v1.0/oauth2/userAccessToken" {
|
||||
t.Fatalf("international user access token URL = %q, want override", got)
|
||||
}
|
||||
}
|
||||
|
||||
func buildTokenDataFromResponse(resp tokenResponse) *TokenData {
|
||||
if resp.AccessToken == "" {
|
||||
return nil
|
||||
|
||||
@@ -14,10 +14,12 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -206,6 +208,105 @@ func TestCheckCLIAuthEnabled_Enabled(t *testing.T) {
|
||||
t.Logf("✅ Normal enabled response: success=%v, enabled=%v", status.Success, status.Result.CLIAuthEnabled)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCheckCLIAuthEnabledTraceHeadersAndDebugLog(t *testing.T) {
|
||||
t.Setenv("DINGTALK_TRACE_ID", "trace-for-cli-auth-test")
|
||||
applyCLIAuthTraceHeaders(nil, "trace-for-cli-auth-test")
|
||||
applyCLIAuthTraceHeaders(httptest.NewRequest(http.MethodGet, "https://example.com", nil), "")
|
||||
|
||||
var logBuf bytes.Buffer
|
||||
previousLogger := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
||||
defer slog.SetDefault(previousLogger)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("EagleEye-TraceId"); got != "trace-for-cli-auth-test" {
|
||||
t.Errorf("EagleEye-TraceId = %q, want trace-for-cli-auth-test", got)
|
||||
}
|
||||
if got := r.Header.Get("X-Dingtalk-Trace-Id"); got != "trace-for-cli-auth-test" {
|
||||
t.Errorf("X-Dingtalk-Trace-Id = %q, want trace-for-cli-auth-test", got)
|
||||
}
|
||||
w.Header().Set("EagleEye-TraceId", "server-trace-001")
|
||||
w.Header().Set("EagleEye-RpcId", "rpc-001")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: &CLIAuthResult{CLIAuthEnabled: true},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
configDir := setupMCPConfigDir(t, srv.URL)
|
||||
p := &OAuthProvider{configDir: configDir, httpClient: srv.Client()}
|
||||
|
||||
status, err := p.CheckCLIAuthEnabled(context.Background(), "sensitive-token")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if status.Result == nil || !status.Result.CLIAuthEnabled {
|
||||
t.Fatal("expected CLIAuthEnabled=true")
|
||||
}
|
||||
|
||||
logs := logBuf.String()
|
||||
for _, want := range []string{
|
||||
"auth.cli_auth_enabled.request",
|
||||
"auth.cli_auth_enabled.response",
|
||||
"trace-for-cli-auth-test",
|
||||
"server-trace-001",
|
||||
"rpc-001",
|
||||
} {
|
||||
if !strings.Contains(logs, want) {
|
||||
t.Fatalf("debug logs missing %q, got: %s", want, logs)
|
||||
}
|
||||
}
|
||||
if strings.Contains(logs, "sensitive-token") {
|
||||
t.Fatalf("debug logs leaked access token: %s", logs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageInternationalLoginRegionRequestWrappers(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case ClientIDPath:
|
||||
_ = json.NewEncoder(w).Encode(ClientIDResponse{Success: true, Result: "international-client"})
|
||||
case SuperAdminPath:
|
||||
_ = json.NewEncoder(w).Encode(SuperAdminResponse{Success: true, Result: []SuperAdmin{{StaffID: "admin"}}})
|
||||
case SendCliAuthApplyPath:
|
||||
_ = json.NewEncoder(w).Encode(SendApplyResponse{Success: true, Result: true})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldClient := oauthHTTPClient
|
||||
oauthHTTPClient = server.Client()
|
||||
restoreBaseURL := PushMCPBaseURLOverride(server.URL)
|
||||
t.Cleanup(func() {
|
||||
restoreBaseURL()
|
||||
oauthHTTPClient = oldClient
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
for name, fetch := range map[string]func() (string, error){
|
||||
"device": func() (string, error) { return deviceFetchClientIDForLoginRegion(ctx, LoginRegionInternational) },
|
||||
"oauth": func() (string, error) { return oauthFetchClientIDForLoginRegion(ctx, LoginRegionInternational) },
|
||||
} {
|
||||
if got, err := fetch(); err != nil || got != "international-client" {
|
||||
t.Fatalf("%s client ID = %q, %v", name, got, err)
|
||||
}
|
||||
}
|
||||
if got, err := deviceGetAdminsForLoginRegion(ctx, "token", LoginRegionInternational); err != nil || !got.Success {
|
||||
t.Fatalf("device admins = %#v, %v", got, err)
|
||||
}
|
||||
if got, err := oauthGetAdminsForLoginRegion(ctx, "token", LoginRegionInternational); err != nil || !got.Success {
|
||||
t.Fatalf("OAuth admins = %#v, %v", got, err)
|
||||
}
|
||||
if got, err := oauthSendApplyForLoginRegion(ctx, "token", "admin", LoginRegionInternational); err != nil || !got.Success {
|
||||
t.Fatalf("OAuth apply = %#v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckCLIAuthEnabled_Disabled(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
@@ -1211,7 +1212,13 @@ func TestCrossPlatformCoveragePortableAuthBundleCoverageEdges(t *testing.T) {
|
||||
if err := os.Symlink(filepath.Join(keyDir, "dek"), filepath.Join(keyDir, "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, value := range map[string]string{"app.json": "{}", profilesJSONFile: "{}", "mcp_url": "https://mcp.test", "terminal_url": "https://terminal.test"} {
|
||||
for name, value := range map[string]string{
|
||||
"app.json": "{}",
|
||||
profilesJSONFile: "{}",
|
||||
"mcp_url": "https://mcp.test",
|
||||
config.ManagedMCPURLRegionFileName: "https://mcp.test",
|
||||
"terminal_url": "https://terminal.test",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(configDir, name), []byte(value), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1234,6 +1241,9 @@ func TestCrossPlatformCoveragePortableAuthBundleCoverageEdges(t *testing.T) {
|
||||
if _, err := os.Stat(filepath.Join(importDir, "app.json")); err != nil {
|
||||
t.Fatal("config file was not imported")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(importDir, config.ManagedMCPURLRegionFileName)); err != nil {
|
||||
t.Fatal("managed MCP region marker was not imported")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(keychain.StorageDir(keychain.Service), keychain.AccountToken+".enc")); err != nil {
|
||||
t.Fatal("encrypted token was not imported")
|
||||
}
|
||||
|
||||
@@ -74,6 +74,20 @@ var (
|
||||
}
|
||||
)
|
||||
|
||||
func deviceFetchClientIDForLoginRegion(ctx context.Context, region LoginRegion) (string, error) {
|
||||
if region.IsInternational() {
|
||||
return FetchClientIDFromMCPForLoginRegion(ctx, region)
|
||||
}
|
||||
return deviceFetchClientID(ctx)
|
||||
}
|
||||
|
||||
func deviceGetAdminsForLoginRegion(ctx context.Context, accessToken string, region LoginRegion) (*SuperAdminResponse, error) {
|
||||
if region.IsInternational() {
|
||||
return GetSuperAdminsForLoginRegion(ctx, accessToken, region)
|
||||
}
|
||||
return deviceGetAdmins(ctx, accessToken)
|
||||
}
|
||||
|
||||
type DeviceFlowProvider struct {
|
||||
configDir string
|
||||
clientID string
|
||||
@@ -85,6 +99,7 @@ type DeviceFlowProvider struct {
|
||||
httpClient *http.Client
|
||||
NoBrowser bool
|
||||
IdentityEnricher func(context.Context, *TokenData) error
|
||||
LoginRegion LoginRegion
|
||||
}
|
||||
|
||||
func NewDeviceFlowProvider(configDir string, logger *slog.Logger) *DeviceFlowProvider {
|
||||
@@ -104,6 +119,12 @@ func (p *DeviceFlowProvider) SetBaseURL(baseURL string) {
|
||||
p.baseURL = strings.TrimRight(baseURL, "/")
|
||||
}
|
||||
|
||||
func (p *DeviceFlowProvider) SetLoginRegion(region LoginRegion) {
|
||||
p.LoginRegion = region
|
||||
p.baseURL = DeviceBaseURLForLoginRegion(region)
|
||||
p.terminalBaseURL = MCPBaseURLForLoginRegion(region)
|
||||
}
|
||||
|
||||
// SetTerminalBaseURL sets the terminal API base URL for device flow polling.
|
||||
func (p *DeviceFlowProvider) SetTerminalBaseURL(baseURL string) {
|
||||
p.terminalBaseURL = strings.TrimRight(baseURL, "/")
|
||||
@@ -213,7 +234,7 @@ func (p *DeviceFlowProvider) Login(ctx context.Context) (*TokenData, error) {
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetching client ID from MCP server (device flow always re-fetches)")
|
||||
}
|
||||
mcpClientID, mcpErr := deviceFetchClientID(ctx)
|
||||
mcpClientID, mcpErr := deviceFetchClientIDForLoginRegion(ctx, p.LoginRegion)
|
||||
if mcpErr != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
|
||||
}
|
||||
@@ -272,6 +293,7 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
|
||||
clientID: p.clientID,
|
||||
logger: p.logger,
|
||||
IdentityEnricher: p.IdentityEnricher,
|
||||
LoginRegion: p.LoginRegion,
|
||||
}
|
||||
tokenData, err := deviceExchangeCode(oauthProvider, ctx, tokenResult.AuthCode)
|
||||
if err != nil {
|
||||
@@ -331,7 +353,7 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
|
||||
_, _ = fmt.Fprintln(p.output(), i18n.T(" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。"))
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
|
||||
admins, adminErr := deviceGetAdmins(ctx, tokenData.AccessToken)
|
||||
admins, adminErr := deviceGetAdminsForLoginRegion(ctx, tokenData.AccessToken, p.LoginRegion)
|
||||
if adminErr == nil && admins.Success && len(admins.Result) > 0 {
|
||||
maxAdmins := 3
|
||||
if len(admins.Result) < maxAdmins {
|
||||
|
||||
@@ -90,6 +90,17 @@ func TestRequestDeviceCodeSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDeviceFlowSetLoginRegionUsesInternationalBaseURL(t *testing.T) {
|
||||
provider := NewDeviceFlowProvider(t.TempDir(), newDeviceFlowTestLogger())
|
||||
provider.SetLoginRegion(LoginRegionInternational)
|
||||
if provider.baseURL != InternationalDeviceBaseURL {
|
||||
t.Fatalf("baseURL = %q, want %q", provider.baseURL, InternationalDeviceBaseURL)
|
||||
}
|
||||
if provider.terminalBaseURL != InternationalMCPBaseURL {
|
||||
t.Fatalf("terminalBaseURL = %q, want %q", provider.terminalBaseURL, InternationalMCPBaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForAuthorizationSucceedsAfterPending(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+112
-5
@@ -50,9 +50,15 @@ const (
|
||||
// AuthorizeURL is the DingTalk OAuth authorization page.
|
||||
AuthorizeURL = "https://login.dingtalk.com/oauth2/auth"
|
||||
|
||||
// InternationalAuthorizeURL is the DingTalk international OAuth authorization page.
|
||||
InternationalAuthorizeURL = "https://login.dingtalk.io/oauth2/auth"
|
||||
|
||||
// UserAccessTokenURL exchanges an authorization code for user tokens.
|
||||
UserAccessTokenURL = "https://api.dingtalk.com/v1.0/oauth2/userAccessToken"
|
||||
|
||||
// InternationalUserAccessTokenURL exchanges authorization codes for international user tokens.
|
||||
InternationalUserAccessTokenURL = "https://api.dingtalk.io/v1.0/oauth2/userAccessToken"
|
||||
|
||||
// UserInfoURL fetches the authenticated user's profile.
|
||||
UserInfoURL = "https://api.dingtalk.com/v1.0/contact/users/me"
|
||||
|
||||
@@ -75,6 +81,9 @@ const (
|
||||
// DefaultDeviceBaseURL is the login server base URL for device flow.
|
||||
DefaultDeviceBaseURL = "https://login.dingtalk.com"
|
||||
|
||||
// InternationalDeviceBaseURL is the international login server base URL for device flow.
|
||||
InternationalDeviceBaseURL = "https://login.dingtalk.io"
|
||||
|
||||
// DeviceCodePath requests a device_code and user_code.
|
||||
DeviceCodePath = "/oauth2/device/code.json"
|
||||
|
||||
@@ -96,11 +105,12 @@ const (
|
||||
LogoutContinueURL = "https://login.dingtalk.com"
|
||||
|
||||
// MCP API endpoints for CLI authorization management.
|
||||
DefaultMCPBaseURL = config.DefaultMCPBaseURL
|
||||
CLIAuthEnabledPath = "/cli/cliAuthEnabled"
|
||||
SuperAdminPath = "/cli/superAdmin"
|
||||
SendCliAuthApplyPath = "/cli/sendCliAuthApply"
|
||||
ClientIDPath = "/cli/clientId"
|
||||
DefaultMCPBaseURL = config.DefaultMCPBaseURL
|
||||
InternationalMCPBaseURL = "https://mcp.dingtalk.io"
|
||||
CLIAuthEnabledPath = "/cli/cliAuthEnabled"
|
||||
SuperAdminPath = "/cli/superAdmin"
|
||||
SendCliAuthApplyPath = "/cli/sendCliAuthApply"
|
||||
ClientIDPath = "/cli/clientId"
|
||||
|
||||
// MCP OAuth endpoints (used when clientId is fetched from MCP).
|
||||
MCPOAuthTokenPath = "/oauth2/getToken"
|
||||
@@ -114,6 +124,57 @@ const (
|
||||
AppAccessTokenURL = "https://api.dingtalk.com/v1.0/oauth2/accessToken"
|
||||
)
|
||||
|
||||
type LoginRegion string
|
||||
|
||||
const (
|
||||
LoginRegionDefault LoginRegion = ""
|
||||
LoginRegionInternational LoginRegion = "international"
|
||||
)
|
||||
|
||||
func (r LoginRegion) IsInternational() bool {
|
||||
return r == LoginRegionInternational
|
||||
}
|
||||
|
||||
func AuthorizeURLForLoginRegion(region LoginRegion) string {
|
||||
if override := LoginBaseURLOverride(); override != "" {
|
||||
return override + "/oauth2/auth"
|
||||
}
|
||||
if region.IsInternational() {
|
||||
return InternationalAuthorizeURL
|
||||
}
|
||||
return AuthorizeURL
|
||||
}
|
||||
|
||||
func DeviceBaseURLForLoginRegion(region LoginRegion) string {
|
||||
if override := LoginBaseURLOverride(); override != "" {
|
||||
return override
|
||||
}
|
||||
if region.IsInternational() {
|
||||
return InternationalDeviceBaseURL
|
||||
}
|
||||
return DefaultDeviceBaseURL
|
||||
}
|
||||
|
||||
func MCPBaseURLForLoginRegion(region LoginRegion) string {
|
||||
if override := MCPBaseURLOverride(); override != "" {
|
||||
return override
|
||||
}
|
||||
if region.IsInternational() {
|
||||
return InternationalMCPBaseURL
|
||||
}
|
||||
return GetMCPBaseURL()
|
||||
}
|
||||
|
||||
func UserAccessTokenURLForLoginRegion(region LoginRegion) string {
|
||||
if override := LoginBaseURLOverride(); override != "" {
|
||||
return override + "/v1.0/oauth2/userAccessToken"
|
||||
}
|
||||
if region.IsInternational() {
|
||||
return InternationalUserAccessTokenURL
|
||||
}
|
||||
return UserAccessTokenURL
|
||||
}
|
||||
|
||||
// GetTerminalBaseURL returns the terminal base URL with priority:
|
||||
// 1. ~/.dws/terminal_url file content (for pre-release environment)
|
||||
// 2. Default value (https://open-dev.dingtalk.com)
|
||||
@@ -146,8 +207,54 @@ var (
|
||||
// clientIDFromMCP indicates whether the clientID was fetched from MCP server.
|
||||
// When true, MCP OAuth endpoints should be used instead of direct DingTalk API.
|
||||
clientIDFromMCP bool
|
||||
|
||||
loginBaseURLMu sync.RWMutex
|
||||
loginBaseURLOverride string
|
||||
mcpBaseURLMu sync.RWMutex
|
||||
mcpBaseURLOverride string
|
||||
)
|
||||
|
||||
// PushLoginBaseURLOverride sets a process-local DingTalk login base URL
|
||||
// override and returns a restore function.
|
||||
func PushLoginBaseURLOverride(baseURL string) func() {
|
||||
loginBaseURLMu.Lock()
|
||||
previous := loginBaseURLOverride
|
||||
loginBaseURLOverride = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
loginBaseURLMu.Unlock()
|
||||
return func() {
|
||||
loginBaseURLMu.Lock()
|
||||
loginBaseURLOverride = previous
|
||||
loginBaseURLMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func LoginBaseURLOverride() string {
|
||||
loginBaseURLMu.RLock()
|
||||
defer loginBaseURLMu.RUnlock()
|
||||
return loginBaseURLOverride
|
||||
}
|
||||
|
||||
// PushMCPBaseURLOverride sets a process-local MCP base URL override and returns
|
||||
// a restore function. It is intended for one command invocation, such as
|
||||
// pre-release smoke testing.
|
||||
func PushMCPBaseURLOverride(baseURL string) func() {
|
||||
mcpBaseURLMu.Lock()
|
||||
previous := mcpBaseURLOverride
|
||||
mcpBaseURLOverride = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
mcpBaseURLMu.Unlock()
|
||||
return func() {
|
||||
mcpBaseURLMu.Lock()
|
||||
mcpBaseURLOverride = previous
|
||||
mcpBaseURLMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func MCPBaseURLOverride() string {
|
||||
mcpBaseURLMu.RLock()
|
||||
defer mcpBaseURLMu.RUnlock()
|
||||
return mcpBaseURLOverride
|
||||
}
|
||||
|
||||
// SetClientIDFromMCP sets the clientID fetched from MCP server and marks it as MCP-sourced.
|
||||
func SetClientIDFromMCP(id string) {
|
||||
clientMu.Lock()
|
||||
|
||||
+132
-15
@@ -20,6 +20,7 @@ import (
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -28,6 +29,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -51,7 +53,7 @@ func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenDa
|
||||
"code": code,
|
||||
"grantType": "authorization_code",
|
||||
}
|
||||
resp, err := p.postJSON(ctx, UserAccessTokenURL, body)
|
||||
resp, err := p.postJSON(ctx, UserAccessTokenURLForLoginRegion(p.loginRegion()), body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -62,6 +64,7 @@ func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenDa
|
||||
// Snapshot credentials used for this token (for refresh)
|
||||
data.ClientID = clientID
|
||||
data.Source = resolveCredentialSource()
|
||||
p.applyLoginRegionToToken(data)
|
||||
// Save clientSecret for future refresh (even if env changes)
|
||||
if err := oauthSaveClientSecret(clientID, clientSecret); err != nil {
|
||||
// Log warning but don't fail login
|
||||
@@ -91,11 +94,36 @@ func ExchangeCodeForToken(ctx context.Context, configDir, code string) (*TokenDa
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (p *OAuthProvider) loginRegion() LoginRegion {
|
||||
if p == nil {
|
||||
return LoginRegionDefault
|
||||
}
|
||||
return p.LoginRegion
|
||||
}
|
||||
|
||||
func (p *OAuthProvider) useTokenLoginRegion(data *TokenData) {
|
||||
if p == nil || p.LoginRegion != LoginRegionDefault || data == nil {
|
||||
return
|
||||
}
|
||||
if region := LoginRegion(strings.TrimSpace(data.LoginRegion)); region != LoginRegionDefault {
|
||||
p.LoginRegion = region
|
||||
}
|
||||
}
|
||||
|
||||
func (p *OAuthProvider) applyLoginRegionToToken(data *TokenData) {
|
||||
if data == nil {
|
||||
return
|
||||
}
|
||||
if region := p.loginRegion(); region != LoginRegionDefault {
|
||||
data.LoginRegion = string(region)
|
||||
}
|
||||
}
|
||||
|
||||
// exchangeCodeViaMCP exchanges auth code for token via MCP proxy.
|
||||
// This is used when client secret is not available (server-side secret management).
|
||||
func (p *OAuthProvider) exchangeCodeViaMCP(ctx context.Context, code string) (*TokenData, error) {
|
||||
clientID := ClientID()
|
||||
url := GetMCPBaseURL() + MCPOAuthTokenPath
|
||||
url := MCPBaseURLForLoginRegion(p.loginRegion()) + MCPOAuthTokenPath
|
||||
body := map[string]string{
|
||||
"clientId": clientID,
|
||||
"authCode": code,
|
||||
@@ -112,6 +140,7 @@ func (p *OAuthProvider) exchangeCodeViaMCP(ctx context.Context, code string) (*T
|
||||
// Snapshot credentials used for this token (for refresh)
|
||||
data.ClientID = clientID
|
||||
data.Source = "mcp"
|
||||
p.applyLoginRegionToToken(data)
|
||||
// MCP mode doesn't need to save clientSecret (server-side managed)
|
||||
return data, nil
|
||||
}
|
||||
@@ -120,8 +149,10 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token
|
||||
// Use stored Source to determine refresh path (not current runtime state)
|
||||
// This ensures refresh works even if environment variables changed since login
|
||||
if data.Source == "mcp" {
|
||||
p.useTokenLoginRegion(data)
|
||||
return p.refreshViaMCP(ctx, data)
|
||||
}
|
||||
p.useTokenLoginRegion(data)
|
||||
|
||||
// Direct mode: use stored clientId and load saved clientSecret
|
||||
clientID := data.ClientID
|
||||
@@ -145,7 +176,7 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token
|
||||
"refreshToken": data.RefreshToken,
|
||||
"grantType": "refresh_token",
|
||||
}
|
||||
resp, err := p.postJSON(ctx, UserAccessTokenURL, body)
|
||||
resp, err := p.postJSON(ctx, UserAccessTokenURLForLoginRegion(p.loginRegion()), body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -156,6 +187,7 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token
|
||||
// Preserve original credentials info
|
||||
updated.ClientID = data.ClientID
|
||||
updated.Source = data.Source
|
||||
updated.LoginRegion = data.LoginRegion
|
||||
updated.PersistentCode = data.PersistentCode
|
||||
updated.CorpID = data.CorpID
|
||||
updated.UserID = data.UserID
|
||||
@@ -185,7 +217,7 @@ func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*To
|
||||
return nil, fmt.Errorf("无法刷新 token: 缺少 clientId,请重新登录")
|
||||
}
|
||||
|
||||
url := GetMCPBaseURL() + MCPRefreshTokenPath
|
||||
url := MCPBaseURLForLoginRegion(p.loginRegion()) + MCPRefreshTokenPath
|
||||
body := map[string]string{
|
||||
"clientId": clientID,
|
||||
"refreshToken": data.RefreshToken,
|
||||
@@ -202,6 +234,7 @@ func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*To
|
||||
// Preserve original credentials info
|
||||
updated.ClientID = data.ClientID
|
||||
updated.Source = data.Source
|
||||
updated.LoginRegion = data.LoginRegion
|
||||
updated.PersistentCode = data.PersistentCode
|
||||
updated.CorpID = data.CorpID
|
||||
updated.UserID = data.UserID
|
||||
@@ -371,6 +404,10 @@ func firstNonEmpty(values ...string) string {
|
||||
}
|
||||
|
||||
func buildAuthURL(clientID, redirectURI, targetCorpID string) string {
|
||||
return buildAuthURLForRegion(clientID, redirectURI, targetCorpID, LoginRegionDefault)
|
||||
}
|
||||
|
||||
func buildAuthURLForRegion(clientID, redirectURI, targetCorpID string, region LoginRegion) string {
|
||||
params := url.Values{
|
||||
"client_id": {clientID},
|
||||
"redirect_uri": {redirectURI},
|
||||
@@ -381,7 +418,7 @@ func buildAuthURL(clientID, redirectURI, targetCorpID string) string {
|
||||
if targetCorpID = strings.TrimSpace(targetCorpID); targetCorpID != "" {
|
||||
params.Set("corpId", targetCorpID)
|
||||
}
|
||||
return AuthorizeURL + "?" + params.Encode()
|
||||
return AuthorizeURLForLoginRegion(region) + "?" + params.Encode()
|
||||
}
|
||||
|
||||
const successHTML = `<!doctype html>
|
||||
@@ -937,14 +974,15 @@ const notEnabledHTML = `<!doctype html>
|
||||
clientId = status.clientId || "";
|
||||
applySent = status.applySent || false;
|
||||
selectedAdminId = status.selectedAdminId || "";
|
||||
const authorizeUrl = status.authorizeUrl || "";
|
||||
|
||||
if (clientId) {
|
||||
if (clientId && authorizeUrl) {
|
||||
const port = location.port;
|
||||
const redirectUri = encodeURIComponent(
|
||||
"http://127.0.0.1:" + port + "/callback"
|
||||
);
|
||||
backLink.href =
|
||||
"https://login.dingtalk.com/oauth2/auth?client_id=" +
|
||||
authorizeUrl + "?client_id=" +
|
||||
clientId +
|
||||
"&prompt=consent&redirect_uri=" +
|
||||
redirectUri +
|
||||
@@ -1398,6 +1436,7 @@ const mcpRequestMaxRetries = 3
|
||||
// false negatives caused by momentary network issues.
|
||||
func (p *OAuthProvider) CheckCLIAuthEnabled(ctx context.Context, accessToken string) (*CLIAuthStatus, error) {
|
||||
var lastErr error
|
||||
traceID := cliAuthTraceID()
|
||||
for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
select {
|
||||
@@ -1406,7 +1445,7 @@ func (p *OAuthProvider) CheckCLIAuthEnabled(ctx context.Context, accessToken str
|
||||
case <-oauthRetryAfter(time.Duration(attempt) * time.Second):
|
||||
}
|
||||
}
|
||||
status, err := p.doCheckCLIAuthEnabled(ctx, accessToken)
|
||||
status, err := p.doCheckCLIAuthEnabledAttempt(ctx, accessToken, attempt+1, traceID)
|
||||
if err == nil {
|
||||
return status, nil
|
||||
}
|
||||
@@ -1416,16 +1455,27 @@ func (p *OAuthProvider) CheckCLIAuthEnabled(ctx context.Context, accessToken str
|
||||
}
|
||||
|
||||
func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken string) (*CLIAuthStatus, error) {
|
||||
url := GetMCPBaseURL() + CLIAuthEnabledPath
|
||||
return p.doCheckCLIAuthEnabledAttempt(ctx, accessToken, 1, cliAuthTraceID())
|
||||
}
|
||||
|
||||
func (p *OAuthProvider) doCheckCLIAuthEnabledAttempt(ctx context.Context, accessToken string, attempt int, traceID string) (*CLIAuthStatus, error) {
|
||||
url := MCPBaseURLForLoginRegion(p.loginRegion()) + CLIAuthEnabledPath
|
||||
req, err := oauthNewRequest(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
req.Header.Set("x-user-access-token", accessToken)
|
||||
applyCLIAuthTraceHeaders(req, traceID)
|
||||
if ch := os.Getenv("DWS_CHANNEL"); ch != "" {
|
||||
req.Header.Set("x-dws-channel", ch)
|
||||
}
|
||||
applyEditionEnterpriseCredentialHeaders(req)
|
||||
slog.Debug("auth.cli_auth_enabled.request",
|
||||
"attempt", attempt,
|
||||
"url", url,
|
||||
"trace_id", traceID,
|
||||
"channel", os.Getenv("DWS_CHANNEL"),
|
||||
)
|
||||
|
||||
client := p.httpClient
|
||||
if client == nil {
|
||||
@@ -1433,9 +1483,23 @@ func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken s
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
slog.Debug("auth.cli_auth_enabled.error",
|
||||
"attempt", attempt,
|
||||
"url", url,
|
||||
"trace_id", traceID,
|
||||
"error", err,
|
||||
)
|
||||
return nil, fmt.Errorf("sending request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
slog.Debug("auth.cli_auth_enabled.response",
|
||||
"attempt", attempt,
|
||||
"url", url,
|
||||
"status", resp.StatusCode,
|
||||
"trace_id", traceID,
|
||||
"response_trace_id", cliAuthResponseTraceID(resp.Header),
|
||||
"eagleeye_rpc_id", resp.Header.Get("EagleEye-RpcId"),
|
||||
)
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
|
||||
if err != nil {
|
||||
@@ -1449,9 +1513,42 @@ func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken s
|
||||
return &status, nil
|
||||
}
|
||||
|
||||
func cliAuthTraceID() string {
|
||||
if traceID := strings.TrimSpace(os.Getenv("DINGTALK_TRACE_ID")); traceID != "" {
|
||||
return traceID
|
||||
}
|
||||
return strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||
}
|
||||
|
||||
func applyCLIAuthTraceHeaders(req *http.Request, traceID string) {
|
||||
if req == nil || traceID == "" {
|
||||
return
|
||||
}
|
||||
req.Header.Set("EagleEye-TraceId", traceID)
|
||||
req.Header.Set("X-Dingtalk-Trace-Id", traceID)
|
||||
}
|
||||
|
||||
func cliAuthResponseTraceID(headers http.Header) string {
|
||||
for _, key := range []string{
|
||||
"EagleEye-TraceId",
|
||||
"X-Trace-Id",
|
||||
"X-Request-Id",
|
||||
"X-Dingtalk-Trace-Id",
|
||||
} {
|
||||
if value := strings.TrimSpace(headers.Get(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetSuperAdmins fetches the list of corp super admins.
|
||||
// It retries up to mcpRequestMaxRetries times on transient errors.
|
||||
func GetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminResponse, error) {
|
||||
return GetSuperAdminsForLoginRegion(ctx, accessToken, LoginRegionDefault)
|
||||
}
|
||||
|
||||
func GetSuperAdminsForLoginRegion(ctx context.Context, accessToken string, region LoginRegion) (*SuperAdminResponse, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
@@ -1461,7 +1558,7 @@ func GetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminRespons
|
||||
case <-oauthRetryAfter(time.Duration(attempt) * time.Second):
|
||||
}
|
||||
}
|
||||
result, err := doGetSuperAdmins(ctx, accessToken)
|
||||
result, err := doGetSuperAdminsForLoginRegion(ctx, accessToken, region)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
@@ -1471,7 +1568,11 @@ func GetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminRespons
|
||||
}
|
||||
|
||||
func doGetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminResponse, error) {
|
||||
url := GetMCPBaseURL() + SuperAdminPath
|
||||
return doGetSuperAdminsForLoginRegion(ctx, accessToken, LoginRegionDefault)
|
||||
}
|
||||
|
||||
func doGetSuperAdminsForLoginRegion(ctx context.Context, accessToken string, region LoginRegion) (*SuperAdminResponse, error) {
|
||||
url := MCPBaseURLForLoginRegion(region) + SuperAdminPath
|
||||
req, err := oauthNewRequest(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating request: %w", err)
|
||||
@@ -1500,6 +1601,10 @@ func doGetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminRespo
|
||||
// SendCliAuthApply sends a CLI auth apply request to the specified admin.
|
||||
// It retries up to mcpRequestMaxRetries times on transient errors.
|
||||
func SendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*SendApplyResponse, error) {
|
||||
return SendCliAuthApplyForLoginRegion(ctx, accessToken, adminStaffID, LoginRegionDefault)
|
||||
}
|
||||
|
||||
func SendCliAuthApplyForLoginRegion(ctx context.Context, accessToken, adminStaffID string, region LoginRegion) (*SendApplyResponse, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
@@ -1509,7 +1614,7 @@ func SendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*S
|
||||
case <-oauthRetryAfter(time.Duration(attempt) * time.Second):
|
||||
}
|
||||
}
|
||||
result, err := doSendCliAuthApply(ctx, accessToken, adminStaffID)
|
||||
result, err := doSendCliAuthApplyForLoginRegion(ctx, accessToken, adminStaffID, region)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
@@ -1519,7 +1624,11 @@ func SendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*S
|
||||
}
|
||||
|
||||
func doSendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*SendApplyResponse, error) {
|
||||
url := GetMCPBaseURL() + SendCliAuthApplyPath + "?adminStaffId=" + adminStaffID
|
||||
return doSendCliAuthApplyForLoginRegion(ctx, accessToken, adminStaffID, LoginRegionDefault)
|
||||
}
|
||||
|
||||
func doSendCliAuthApplyForLoginRegion(ctx context.Context, accessToken, adminStaffID string, region LoginRegion) (*SendApplyResponse, error) {
|
||||
url := MCPBaseURLForLoginRegion(region) + SendCliAuthApplyPath + "?adminStaffId=" + adminStaffID
|
||||
req, err := oauthNewRequest(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating request: %w", err)
|
||||
@@ -1557,6 +1666,10 @@ type ClientIDResponse struct {
|
||||
// This is used when no client ID is provided via flags, config, or env vars.
|
||||
// It retries up to mcpRequestMaxRetries times on transient errors.
|
||||
func FetchClientIDFromMCP(ctx context.Context) (string, error) {
|
||||
return FetchClientIDFromMCPForLoginRegion(ctx, LoginRegionDefault)
|
||||
}
|
||||
|
||||
func FetchClientIDFromMCPForLoginRegion(ctx context.Context, region LoginRegion) (string, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
@@ -1566,7 +1679,7 @@ func FetchClientIDFromMCP(ctx context.Context) (string, error) {
|
||||
case <-oauthRetryAfter(time.Duration(attempt) * time.Second):
|
||||
}
|
||||
}
|
||||
id, err := doFetchClientIDFromMCP(ctx)
|
||||
id, err := doFetchClientIDFromMCPForLoginRegion(ctx, region)
|
||||
if err == nil {
|
||||
return id, nil
|
||||
}
|
||||
@@ -1576,7 +1689,11 @@ func FetchClientIDFromMCP(ctx context.Context) (string, error) {
|
||||
}
|
||||
|
||||
func doFetchClientIDFromMCP(ctx context.Context) (string, error) {
|
||||
url := GetMCPBaseURL() + ClientIDPath
|
||||
return doFetchClientIDFromMCPForLoginRegion(ctx, LoginRegionDefault)
|
||||
}
|
||||
|
||||
func doFetchClientIDFromMCPForLoginRegion(ctx context.Context, region LoginRegion) (string, error) {
|
||||
url := MCPBaseURLForLoginRegion(region) + ClientIDPath
|
||||
req, err := oauthNewRequest(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("creating request: %w", err)
|
||||
|
||||
@@ -68,6 +68,27 @@ var (
|
||||
oauthSleep = time.Sleep
|
||||
)
|
||||
|
||||
func oauthFetchClientIDForLoginRegion(ctx context.Context, region LoginRegion) (string, error) {
|
||||
if region.IsInternational() {
|
||||
return FetchClientIDFromMCPForLoginRegion(ctx, region)
|
||||
}
|
||||
return oauthFetchClientID(ctx)
|
||||
}
|
||||
|
||||
func oauthGetAdminsForLoginRegion(ctx context.Context, accessToken string, region LoginRegion) (*SuperAdminResponse, error) {
|
||||
if region.IsInternational() {
|
||||
return GetSuperAdminsForLoginRegion(ctx, accessToken, region)
|
||||
}
|
||||
return oauthGetAdmins(ctx, accessToken)
|
||||
}
|
||||
|
||||
func oauthSendApplyForLoginRegion(ctx context.Context, accessToken, adminStaffID string, region LoginRegion) (*SendApplyResponse, error) {
|
||||
if region.IsInternational() {
|
||||
return SendCliAuthApplyForLoginRegion(ctx, accessToken, adminStaffID, region)
|
||||
}
|
||||
return oauthSendApply(ctx, accessToken, adminStaffID)
|
||||
}
|
||||
|
||||
// OAuthProvider handles the DingTalk OAuth 2.0 authorization code flow.
|
||||
type OAuthProvider struct {
|
||||
configDir string
|
||||
@@ -80,6 +101,7 @@ type OAuthProvider struct {
|
||||
// IdentityEnricher resolves userId/userName/corpName while the freshly
|
||||
// exchanged access token is still only in memory.
|
||||
IdentityEnricher func(context.Context, *TokenData) error
|
||||
LoginRegion LoginRegion
|
||||
}
|
||||
|
||||
// NewOAuthProvider creates a new OAuth provider.
|
||||
@@ -173,7 +195,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetching client ID from MCP server (OAuth flow always re-fetches)")
|
||||
}
|
||||
mcpClientID, mcpErr := oauthFetchClientID(ctx)
|
||||
mcpClientID, mcpErr := oauthFetchClientIDForLoginRegion(ctx, p.LoginRegion)
|
||||
if mcpErr != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
|
||||
}
|
||||
@@ -401,7 +423,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
_, _ = w.Write([]byte(`{"success":false,"errorMsg":"授权尚未完成"}`))
|
||||
return
|
||||
}
|
||||
result, err := oauthGetAdmins(ctx, token.AccessToken)
|
||||
result, err := oauthGetAdminsForLoginRegion(ctx, token.AccessToken, p.LoginRegion)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(w, `{"success":false,"errorMsg":"%s"}`, err.Error())
|
||||
return
|
||||
@@ -425,7 +447,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
_, _ = w.Write([]byte(`{"success":false,"errorMsg":"授权尚未完成"}`))
|
||||
return
|
||||
}
|
||||
result, err := oauthSendApply(ctx, token.AccessToken, adminStaffID)
|
||||
result, err := oauthSendApplyForLoginRegion(ctx, token.AccessToken, adminStaffID, p.LoginRegion)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(w, `{"success":false,"errorMsg":"%s"}`, err.Error())
|
||||
return
|
||||
@@ -448,7 +470,13 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
applySent := callbackApplySent
|
||||
selectedAdminId := callbackSelectedAdminId
|
||||
callbackTokenMu.Unlock()
|
||||
_, _ = fmt.Fprintf(w, `{"clientId":"%s","applySent":%t,"selectedAdminId":"%s"}`, p.clientID, applySent, selectedAdminId)
|
||||
data, _ := json.Marshal(map[string]any{
|
||||
"clientId": p.clientID,
|
||||
"authorizeUrl": AuthorizeURLForLoginRegion(p.LoginRegion),
|
||||
"applySent": applySent,
|
||||
"selectedAdminId": selectedAdminId,
|
||||
})
|
||||
_, _ = w.Write(data)
|
||||
})
|
||||
|
||||
// API endpoint: check CLI auth enabled status
|
||||
@@ -491,7 +519,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
_ = server.Shutdown(shutCtx)
|
||||
}()
|
||||
|
||||
authURL := buildAuthURL(p.clientID, redirectURI, p.TargetCorpID)
|
||||
authURL := buildAuthURLForRegion(p.clientID, redirectURI, p.TargetCorpID, p.LoginRegion)
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("authorization URL", "url", authURL)
|
||||
}
|
||||
|
||||
@@ -272,7 +272,7 @@ func ImportPortableAuthBundle(configDir string, r io.Reader) (PortableImportRepo
|
||||
|
||||
func portableConfigFiles(configDir string) ([]string, error) {
|
||||
var files []string
|
||||
patterns := []string{"app*.json", profilesJSONFile, "mcp_url", "terminal_url"}
|
||||
patterns := []string{"app*.json", profilesJSONFile, "mcp_url", config.ManagedMCPURLRegionFileName, "terminal_url"}
|
||||
for _, pattern := range patterns {
|
||||
matches, err := portableGlob(filepath.Join(configDir, pattern))
|
||||
if err != nil {
|
||||
|
||||
@@ -97,6 +97,7 @@ type TokenData struct {
|
||||
ClientID string `json:"client_id,omitempty"` // Associated app client ID for refresh
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
LoginRegion string `json:"login_region,omitempty"`
|
||||
// LegacyOrgScopedProfile is an in-memory destination for an explicitly
|
||||
// matched historical profile whose userId was never resolved. It is never
|
||||
// persisted as token material.
|
||||
|
||||
@@ -177,6 +177,7 @@ func reduceLeafParamAliases(path string, realByMorph map[string][]realFlag, conc
|
||||
aliasMap := make(map[string]string)
|
||||
blockedSet := make(map[string]bool)
|
||||
excludedSet := make(map[string]bool)
|
||||
claimedRealSet := make(map[string]bool)
|
||||
pendingReview := ov.Confirm || ov.Investigate
|
||||
|
||||
for boundFlag, conceptID := range ov.Bind {
|
||||
@@ -218,6 +219,14 @@ func reduceLeafParamAliases(path string, realByMorph map[string][]realFlag, conc
|
||||
if len(candidates) == 0 {
|
||||
continue
|
||||
}
|
||||
for m := range eff {
|
||||
if _, isReal := realByMorph[m]; isReal {
|
||||
claimedRealSet[m] = true
|
||||
}
|
||||
}
|
||||
for _, exclude := range concept.Excludes {
|
||||
excludedSet[cmdutil.Morph(exclude)] = true
|
||||
}
|
||||
visible := distinctRealNames(candidates, true)
|
||||
var canon string
|
||||
switch len(visible) {
|
||||
@@ -267,21 +276,18 @@ func reduceLeafParamAliases(path string, realByMorph map[string][]realFlag, conc
|
||||
}
|
||||
aliasMap[m] = canon
|
||||
}
|
||||
// Excludes are not passive prose: once this concept is active on a
|
||||
// reviewed command, a non-real excluded spelling is protected from
|
||||
// downstream fuzzy correction. A real flag is left alone because it
|
||||
// already has an independently valid command-local meaning.
|
||||
for _, exclude := range concept.Excludes {
|
||||
morphed := cmdutil.Morph(exclude)
|
||||
if _, isReal := realByMorph[morphed]; !isReal {
|
||||
excludedSet[morphed] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for excluded := range excludedSet {
|
||||
if _, isAlias := aliasMap[excluded]; !isAlias {
|
||||
blockedSet[excluded] = true
|
||||
if _, isAlias := aliasMap[excluded]; isAlias {
|
||||
continue
|
||||
}
|
||||
if claimedRealSet[excluded] {
|
||||
continue
|
||||
}
|
||||
if _, isReal := realByMorph[excluded]; isReal {
|
||||
continue
|
||||
}
|
||||
blockedSet[excluded] = true
|
||||
}
|
||||
|
||||
// (b) Command scoped aliases override concept reductions.
|
||||
|
||||
@@ -889,13 +889,9 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
Blocked: []string{"cursor"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat category add-conv",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"category-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
CLIPath: "chat category add-conv",
|
||||
Blocked: []string{"category-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Ambiguous: []string{"chat", "chat-id", "open-conversation-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat category create",
|
||||
@@ -920,13 +916,9 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
Blocked: []string{"category-ids"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat category remove-conv",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"category-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
CLIPath: "chat category remove-conv",
|
||||
Blocked: []string{"category-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Ambiguous: []string{"chat", "chat-id", "open-conversation-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat category rename",
|
||||
@@ -970,23 +962,19 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat conversation-info",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"staff-id": "user",
|
||||
"uid": "user",
|
||||
"userid": "user",
|
||||
"staff-id": "user",
|
||||
"uid": "user",
|
||||
"userid": "user",
|
||||
},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
|
||||
Ambiguous: []string{"chat-id", "open-conversation-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group audit-join-validation",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Ambiguous: []string{"staff-id", "uid", "user", "user-id", "userid"},
|
||||
},
|
||||
{
|
||||
@@ -1006,12 +994,9 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat group dismiss",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group get-by-group-id",
|
||||
@@ -1020,12 +1005,9 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat group invite-url",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group members",
|
||||
@@ -1100,52 +1082,37 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat group notice create",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group notice edit",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group notice get",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group notice list",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group quit",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group rename",
|
||||
@@ -1161,23 +1128,17 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat group set-admin",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"user-ids": "users",
|
||||
"chat-id": "conversation-id",
|
||||
"user-ids": "users",
|
||||
},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "userid"},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "userid"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group set-history",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group share-invite",
|
||||
@@ -1191,151 +1152,111 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat group transfer-owner",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"staff-id": "user",
|
||||
"uid": "user",
|
||||
"userid": "user",
|
||||
"chat-id": "conversation-id",
|
||||
"staff-id": "user",
|
||||
"uid": "user",
|
||||
"userid": "user",
|
||||
},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group update-alias",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group update-icon",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group update-nick",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group update-settings",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group-mute",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group update-icon",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group update-nick",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group update-settings",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group-mute",
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Ambiguous: []string{"chat-id", "open-conversation-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group-mute-member",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"user-ids": "users",
|
||||
"user-ids": "users",
|
||||
},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "userid"},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "userid"},
|
||||
Ambiguous: []string{"chat-id", "open-conversation-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group-role add",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group-role list",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group-role query-user",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"staff-id": "user",
|
||||
"uid": "user",
|
||||
"userid": "user",
|
||||
"chat-id": "conversation-id",
|
||||
"staff-id": "user",
|
||||
"uid": "user",
|
||||
"userid": "user",
|
||||
},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group-role remove",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "role-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "role-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group-role remove-user",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"staff-id": "user",
|
||||
"uid": "user",
|
||||
"userid": "user",
|
||||
"chat-id": "conversation-id",
|
||||
"staff-id": "user",
|
||||
"uid": "user",
|
||||
"userid": "user",
|
||||
},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "role-id", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "role-id", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group-role set-user",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"staff-id": "user",
|
||||
"uid": "user",
|
||||
"userid": "user",
|
||||
"chat-id": "conversation-id",
|
||||
"staff-id": "user",
|
||||
"uid": "user",
|
||||
"userid": "user",
|
||||
},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "role-id", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "role-id", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group-role update",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "open-conversation-ids", "role-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "open-conversation-ids", "role-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat hide",
|
||||
@@ -1369,12 +1290,10 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat message add-emoji",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"message-id": "msg-id",
|
||||
"open-conversation-id": "conversation-id",
|
||||
"open-message-id": "msg-id",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
|
||||
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
|
||||
Ambiguous: []string{"open-message-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message add-favorite",
|
||||
@@ -1391,12 +1310,10 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat message add-text-emotion",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"message-id": "msg-id",
|
||||
"open-conversation-id": "conversation-id",
|
||||
"open-message-id": "msg-id",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
|
||||
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
|
||||
Ambiguous: []string{"open-message-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message combine-forward",
|
||||
@@ -1427,8 +1344,6 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
Aliases: map[string]string{
|
||||
"dest-open-cid": "dest-conversation-id",
|
||||
"destination-conversation-id": "dest-conversation-id",
|
||||
"message-id": "msg-id",
|
||||
"open-message-id": "msg-id",
|
||||
"source-conversation-id": "src-conversation-id",
|
||||
"src-open-cid": "src-conversation-id",
|
||||
"target-conversation-id": "dest-conversation-id",
|
||||
@@ -1453,21 +1368,20 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat message list",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "group",
|
||||
"max-result": "limit",
|
||||
"max-results": "limit",
|
||||
"open-conversation-id": "group",
|
||||
"page-size": "limit",
|
||||
"per-page": "limit",
|
||||
"staff-id": "user",
|
||||
"start": "time",
|
||||
"take": "limit",
|
||||
"top": "limit",
|
||||
"uid": "user",
|
||||
"user-id": "user",
|
||||
"userid": "user",
|
||||
"max-result": "limit",
|
||||
"max-results": "limit",
|
||||
"page-size": "limit",
|
||||
"per-page": "limit",
|
||||
"staff-id": "user",
|
||||
"start": "time",
|
||||
"take": "limit",
|
||||
"top": "limit",
|
||||
"uid": "user",
|
||||
"user-id": "user",
|
||||
"userid": "user",
|
||||
},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "count", "cursor", "dest-conversation-id", "end", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "page", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "count", "cursor", "dest-conversation-id", "end", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "page", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
|
||||
Ambiguous: []string{"chat-id", "open-conversation-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message list-all",
|
||||
@@ -1514,12 +1428,9 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message list-mentions",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
CLIPath: "chat message list-mentions",
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Ambiguous: []string{"chat-id", "open-conversation-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message list-pin-msg",
|
||||
@@ -1534,8 +1445,7 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat message list-topic-replies",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
@@ -1549,11 +1459,10 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat message read-status",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"msg-id": "message-id",
|
||||
"open-conversation-id": "conversation-id",
|
||||
"open-message-id": "message-id",
|
||||
"user-ids": "users",
|
||||
"chat-id": "conversation-id",
|
||||
"msg-id": "message-id",
|
||||
"open-message-id": "message-id",
|
||||
"user-ids": "users",
|
||||
},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "staff-id", "target", "topic-id", "uid", "userid"},
|
||||
},
|
||||
@@ -1561,30 +1470,25 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
CLIPath: "chat message recall",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"message-id": "msg-id",
|
||||
"open-conversation-id": "conversation-id",
|
||||
"open-message-id": "msg-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message recall-by-bot",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"robot": "robot-code",
|
||||
"robot": "robot-code",
|
||||
},
|
||||
Blocked: []string{"bot-code", "bot-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-bot-id", "open-conversation-ids", "robot-id", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"bot-code", "bot-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-bot-id", "open-conversation-ids", "robot-id", "source", "src-conversation-id", "target"},
|
||||
Ambiguous: []string{"chat-id", "open-conversation-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message remove-emoji",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"message-id": "msg-id",
|
||||
"open-conversation-id": "conversation-id",
|
||||
"open-message-id": "msg-id",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
|
||||
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
|
||||
Ambiguous: []string{"open-message-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message remove-favorite",
|
||||
@@ -1601,12 +1505,10 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat message remove-text-emotion",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"message-id": "msg-id",
|
||||
"open-conversation-id": "conversation-id",
|
||||
"open-message-id": "msg-id",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
|
||||
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
|
||||
Ambiguous: []string{"open-message-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message reply",
|
||||
@@ -1620,12 +1522,9 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-id", "msg-id", "msg-ids", "name", "open-conversation-ids", "open-message-id", "source", "src-conversation-id", "src-msg-id", "staff-id", "target", "uid", "user", "user-id", "userid"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message search",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
CLIPath: "chat message search",
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Ambiguous: []string{"chat-id", "open-conversation-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message search-advanced",
|
||||
@@ -1638,28 +1537,25 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat message send",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "group",
|
||||
"file": "file-path",
|
||||
"open-conversation-id": "group",
|
||||
"staff-id": "user",
|
||||
"to-user": "user",
|
||||
"uid": "user",
|
||||
"user-id": "user",
|
||||
"userid": "user",
|
||||
"file": "file-path",
|
||||
"staff-id": "user",
|
||||
"to-user": "user",
|
||||
"uid": "user",
|
||||
"user-id": "user",
|
||||
"userid": "user",
|
||||
},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "user-ids", "users"},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "user-ids", "users"},
|
||||
Ambiguous: []string{"chat-id", "open-conversation-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message send-by-bot",
|
||||
Aliases: map[string]string{
|
||||
"at-users": "at-user-ids",
|
||||
"chat-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"robot": "robot-code",
|
||||
"user-ids": "users",
|
||||
"at-users": "at-user-ids",
|
||||
"robot": "robot-code",
|
||||
"user-ids": "users",
|
||||
},
|
||||
Blocked: []string{"bot-code", "bot-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-bot-id", "open-conversation-ids", "robot-id", "source", "src-conversation-id", "staff-id", "target", "to-user-id", "uid", "user", "user-id", "userid"},
|
||||
Ambiguous: []string{"at-ids"},
|
||||
Ambiguous: []string{"at-ids", "chat-id", "open-conversation-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message send-by-webhook",
|
||||
@@ -1668,14 +1564,9 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message send-card",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "user", "user-id", "userid"},
|
||||
CLIPath: "chat message send-card",
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "user", "user-id", "userid"},
|
||||
Ambiguous: []string{"chat", "chat-id", "open-conversation-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message set-pin-msg",
|
||||
@@ -1684,8 +1575,6 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
"chat-id": "open-conversation-id",
|
||||
"conversation-id": "open-conversation-id",
|
||||
"group": "open-conversation-id",
|
||||
"message-id": "msg-id",
|
||||
"open-message-id": "msg-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"},
|
||||
},
|
||||
@@ -1696,8 +1585,6 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
"chat-id": "open-conversation-id",
|
||||
"conversation-id": "open-conversation-id",
|
||||
"group": "open-conversation-id",
|
||||
"message-id": "msg-id",
|
||||
"open-message-id": "msg-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"},
|
||||
},
|
||||
@@ -1708,8 +1595,6 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
"chat-id": "open-conversation-id",
|
||||
"conversation-id": "open-conversation-id",
|
||||
"group": "open-conversation-id",
|
||||
"message-id": "msg-id",
|
||||
"open-message-id": "msg-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"},
|
||||
},
|
||||
@@ -1720,17 +1605,13 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
"chat-id": "open-conversation-id",
|
||||
"conversation-id": "open-conversation-id",
|
||||
"group": "open-conversation-id",
|
||||
"message-id": "msg-id",
|
||||
"open-message-id": "msg-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat mute",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"group": "conversation-id",
|
||||
"open-conversation-id": "conversation-id",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "group-id", "group-ids", "open-conversation-ids"},
|
||||
},
|
||||
@@ -2548,7 +2429,7 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
"node-id": "node",
|
||||
"url": "node",
|
||||
},
|
||||
Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "folder", "folder-id", "id", "job-id", "name", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"},
|
||||
Blocked: []string{"block-id", "comment-id", "comment-key", "count", "dentry-id", "folder", "folder-id", "id", "job-id", "name", "offset", "page", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"},
|
||||
Ambiguous: []string{"max-result", "max-results", "next-cursor", "next-page-token", "next-token", "per-page", "size", "take", "top"},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -237,6 +237,42 @@ func TestReduceLeafParamAliasesRemainingEdges(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReduceLeafParamAliasesBindExcludesRealFlags(t *testing.T) {
|
||||
entry, problems := reduceLeafParamAliases(
|
||||
"demo cmd",
|
||||
realMap(realFlag{name: "id"}, realFlag{name: "name"}, realFlag{name: "query"}),
|
||||
[]Concept{
|
||||
{ID: "base_id", Members: []string{"base-id", "base-token"}, Excludes: []string{"keyword", "name", "query", "unsafe"}},
|
||||
{ID: "query", Members: []string{"query", "keyword"}},
|
||||
},
|
||||
CommandOverride{Bind: map[string]string{"id": "base_id"}},
|
||||
)
|
||||
if len(problems) != 0 {
|
||||
t.Fatalf("reduceLeafParamAliases() problems = %v", problems)
|
||||
}
|
||||
if entry == nil {
|
||||
t.Fatal("reduceLeafParamAliases() entry = nil")
|
||||
}
|
||||
if entry.Aliases["base-id"] != "id" || entry.Aliases["base-token"] != "id" {
|
||||
t.Fatalf("bound aliases = %#v, want base-id/base-token -> id", entry.Aliases)
|
||||
}
|
||||
if entry.Aliases["keyword"] != "query" {
|
||||
t.Fatalf("query alias = %#v, want keyword -> query", entry.Aliases)
|
||||
}
|
||||
if containsParamAlias(entry.Blocked, "keyword") {
|
||||
t.Fatalf("excluded alias entered blocked list: %#v", entry.Blocked)
|
||||
}
|
||||
if containsParamAlias(entry.Blocked, "name") {
|
||||
t.Fatalf("real excluded flag entered blocked list: %#v", entry.Blocked)
|
||||
}
|
||||
if containsParamAlias(entry.Blocked, "query") {
|
||||
t.Fatalf("claimed real excluded flag entered blocked list: %#v", entry.Blocked)
|
||||
}
|
||||
if !containsParamAlias(entry.Blocked, "unsafe") {
|
||||
t.Fatalf("non-real excluded flag was not blocked: %#v", entry.Blocked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParamAliasEntryLookupMethods(t *testing.T) {
|
||||
entry := ParamAliasEntry{
|
||||
Aliases: map[string]string{"uid": "user"},
|
||||
@@ -457,6 +493,22 @@ func TestReduceLeafParamAliasesExcludesProtectFuzzyButDoNotOverrideAnotherConcep
|
||||
}
|
||||
}
|
||||
|
||||
func TestReduceLeafParamAliasesExcludesDoNotBlockRealFlag(t *testing.T) {
|
||||
concepts := []Concept{
|
||||
{ID: "single_id", Members: []string{"id", "item-id"}, Excludes: []string{"item-ids"}},
|
||||
}
|
||||
entry, problems := reduceLeafParamAliases("demo cmd", realMap(realFlag{name: "id"}, realFlag{name: "item-ids"}), concepts, CommandOverride{})
|
||||
if len(problems) != 0 {
|
||||
t.Fatalf("unexpected problems: %v", problems)
|
||||
}
|
||||
if entry == nil {
|
||||
t.Fatal("expected a reduced entry")
|
||||
}
|
||||
if containsParamAlias(entry.Blocked, "item-ids") {
|
||||
t.Fatalf("real exclude was blocked: %#v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReduceLeafParamAliasesRejectsProtectionOrScopedAliasOnRealFlag(t *testing.T) {
|
||||
real := realMap(realFlag{name: "user-id"}, realFlag{name: "user"})
|
||||
for name, override := range map[string]CommandOverride{
|
||||
@@ -472,6 +524,58 @@ func TestReduceLeafParamAliasesRejectsProtectionOrScopedAliasOnRealFlag(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedParamAliasesBlockPluralListSpellingsOnSingleIDCommands(t *testing.T) {
|
||||
entries := make(map[string]ParamAliasEntry, len(generatedParamAliases))
|
||||
for _, entry := range generatedParamAliases {
|
||||
entries[entry.CLIPath] = entry
|
||||
}
|
||||
assertBlocked := func(path string, names ...string) {
|
||||
t.Helper()
|
||||
entry, ok := entries[path]
|
||||
if !ok {
|
||||
t.Fatalf("missing generated alias entry for %q", path)
|
||||
}
|
||||
for _, name := range names {
|
||||
if !entry.IsBlocked(cmdutil.Morph(name)) {
|
||||
t.Fatalf("%s: %q not blocked; entry = %#v", path, name, entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, path := range []string{
|
||||
"chat message add-emoji",
|
||||
"chat message remove-emoji",
|
||||
"chat message add-text-emotion",
|
||||
"chat message remove-text-emotion",
|
||||
} {
|
||||
assertBlocked(path, "msg-ids", "message-ids")
|
||||
}
|
||||
for _, path := range []string{
|
||||
"chat message send",
|
||||
"chat conversation-info",
|
||||
"chat category add-conv",
|
||||
"chat category remove-conv",
|
||||
"chat message list",
|
||||
"chat message list-mentions",
|
||||
"chat message recall-by-bot",
|
||||
"chat message search",
|
||||
} {
|
||||
assertBlocked(path, "conversation-ids")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedParamAliasesKeepAuditJoinUserRoleAmbiguous(t *testing.T) {
|
||||
entry, ok := LookupParamAlias("chat group audit-join-validation")
|
||||
if !ok {
|
||||
t.Fatal("missing generated alias entry for chat group audit-join-validation")
|
||||
}
|
||||
for _, name := range []string{"user", "user-id", "userid", "uid", "staff-id"} {
|
||||
if !entry.IsAmbiguous(cmdutil.Morph(name)) {
|
||||
t.Fatalf("%q not ambiguous; entry = %#v", name, entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGeneratedParamAliasesAreWellFormed guards the committed generated table
|
||||
// at the Go level, complementing the byte-identity drift gate.
|
||||
func TestGeneratedParamAliasesAreWellFormed(t *testing.T) {
|
||||
|
||||
@@ -60,11 +60,17 @@
|
||||
"chat group members": {"bind": {"id": "open_conversation_id"}},
|
||||
"chat group members add": {"bind": {"id": "open_conversation_id"}, "block": ["user-id", "open-dingtalk-id"], "note": "The real --users is a list and may contain mixed userId/openDingTalkId values; singular inputs are not promoted automatically."},
|
||||
"chat group members remove": {"bind": {"id": "open_conversation_id"}},
|
||||
"chat message add-emoji": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."},
|
||||
"chat message add-text-emotion": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."},
|
||||
"chat message remove-emoji": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."},
|
||||
"chat message remove-text-emotion": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."},
|
||||
"chat mute": {"scoped_aliases": {"group": "conversation-id", "chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --conversation-id/--id/--chat remain unchanged; other reviewed openConversationId spellings reduce to --conversation-id."},
|
||||
"chat conversation-info": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat group-mute": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat group-mute-member": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat message add-emoji": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "ambiguous": ["open-message-id"], "note": "Native --chat/--group/--id/--conversation-id/--open-conversation-id and visible --message-id/--msg-id stay executable; numeric/list spellings are rejected."},
|
||||
"chat message add-text-emotion": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "ambiguous": ["open-message-id"], "note": "Native --chat/--group/--id/--conversation-id/--open-conversation-id and visible --message-id/--msg-id stay executable; numeric/list spellings are rejected."},
|
||||
"chat message list-mentions": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat message recall-by-bot": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat message remove-emoji": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "ambiguous": ["open-message-id"], "note": "Native --chat/--group/--id/--conversation-id/--open-conversation-id and visible --message-id/--msg-id stay executable; numeric/list spellings are rejected."},
|
||||
"chat message remove-text-emotion": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "ambiguous": ["open-message-id"], "note": "Native --chat/--group/--id/--conversation-id/--open-conversation-id and visible --message-id/--msg-id stay executable; numeric/list spellings are rejected."},
|
||||
"chat message search": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat mute": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --conversation-id and hidden compatibility --group/--id/--chat/--open-conversation-id remain unchanged; reviewed chat-id reduces to --conversation-id."},
|
||||
"drive list": {"ambiguous": ["root-id", "space"], "note": "Numeric --space-id and knowledge-base --workspace are distinct routes; bare --space/--root-id cannot select a domain or folder.", "scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "directory-id": "folder", "order-field": "order-by", "sort-by": "order-by", "sort-field": "order-by"}, "scope_strict": true, "block": ["dentry-id"]},
|
||||
"drive upload": {"ambiguous": ["destination-id", "space", "target-id"], "note": "Local file, display name, MIME, overwrite node, folder, storage space, and knowledge-base workspace remain distinct roles.", "scoped_aliases": {"dentry-uuid": "node", "directory-id": "folder", "overwrite-node-id": "node", "target-folder-id": "folder", "target-workspace-id": "workspace", "source-file": "file", "content-type": "mime-type", "filename": "file-name", "name": "file-name", "display-name": "file-name", "upload-name": "file-name"}, "block": ["dentry-id", "document-url", "output-path"], "scope_strict": true},
|
||||
"ding +receiver-status": {"scoped_aliases": {"id": "ding-id"}, "note": "generic id reduces to ding-id"},
|
||||
@@ -84,12 +90,12 @@
|
||||
"chat +unread-chats": {"scoped_aliases": {"limit": "count", "size": "count"}, "scope_strict": true, "note": "On this exact command, limit and size both denote the returned unread-conversation count."},
|
||||
"chat message list-unread-conversations": {"scoped_aliases": {"limit": "count", "size": "count"}, "scope_strict": true, "note": "On this exact command, limit and size both denote the returned unread-conversation count."},
|
||||
"chat +messages-list-direct": {"scoped_aliases": {"start": "time"}, "block": ["end"], "scope_strict": true, "note": "This exact command accepts one start boundary in yyyy-MM-dd HH:mm:ss; an end-only input cannot be represented."},
|
||||
"chat message list": {"scoped_aliases": {"start": "time"}, "block": ["end"], "scope_strict": true, "note": "This exact command accepts one start boundary in yyyy-MM-dd HH:mm:ss; an end-only input cannot be represented."},
|
||||
"chat message list": {"scoped_aliases": {"start": "time"}, "block": ["end"], "ambiguous": ["chat-id", "open-conversation-id"], "scope_strict": true, "note": "This exact command accepts one start boundary in yyyy-MM-dd HH:mm:ss; an end-only input cannot be represented. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat message list-by-sender": {"scoped_aliases": {"user-id": "sender-user-id", "open-dingtalk-id": "sender-open-dingtalk-id"}, "block": ["time"], "scope_strict": true, "note": "Only same-role sender identifiers are mapped; --time cannot supply the required RFC3339 start/end range."},
|
||||
"contact +resolve-dept": {"bind": {"name": "search_query"}, "note": "The real --name is a department-name search keyword and carries the search_query concept on this shortcut."},
|
||||
"contact +list-sub-depts": {"block": ["name", "query"], "note": "--dept is an integer department id; names and search queries require a separate resolution command"},
|
||||
"contact +dept-members": {"bind": {"dept": "search_query"}, "scoped_aliases": {"name": "dept"}, "note": "The real --dept is a department-name search keyword; search spellings come from search_query, while --name remains command-scoped."},
|
||||
"chat message send": {"scoped_aliases": {"to-user": "user", "file": "file-path"}, "note": "Recipient and local-file-path aliases are exact to this command; obsolete file metadata flags remain unsupported."},
|
||||
"chat message send": {"scoped_aliases": {"to-user": "user", "file": "file-path"}, "ambiguous": ["chat-id", "open-conversation-id"], "note": "Recipient and local-file-path aliases are exact to this command; obsolete file metadata flags remain unsupported. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat +group-members": {"bind": {"group": "group_name"}, "note": "The real --group is a group-name search keyword on this shortcut, not an identifier."},
|
||||
"chat +category-create": {"scoped_aliases": {"name": "title"}, "scope_strict": true, "note": "The reviewed name/title mapping preserves the category display-name value on this exact shortcut."},
|
||||
"chat category create": {"scoped_aliases": {"name": "title"}, "scope_strict": true, "note": "The reviewed name/title mapping preserves the category display-name value on this exact command."},
|
||||
@@ -98,8 +104,8 @@
|
||||
"chat +category-delete": {"block": ["category-ids"], "note": "This command requires one category id; list cardinality is not reduced automatically."},
|
||||
"chat category delete": {"block": ["category-ids"], "note": "This command requires one category id; list cardinality is not reduced automatically."},
|
||||
"chat category list-conversations": {"block": ["category-ids"], "note": "This command requires one category id; list cardinality is not reduced automatically."},
|
||||
"chat category add-conv": {"block": ["category-id"], "note": "This command requires a category-id list; one id is not promoted into a batch input."},
|
||||
"chat category remove-conv": {"block": ["category-id"], "note": "This command requires a category-id list; one id is not promoted into a batch input."},
|
||||
"chat category add-conv": {"block": ["category-id"], "ambiguous": ["chat", "chat-id", "open-conversation-id"], "note": "This command requires a category-id list; one id is not promoted into a batch input. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat category remove-conv": {"block": ["category-id"], "ambiguous": ["chat", "chat-id", "open-conversation-id"], "note": "This command requires a category-id list; one id is not promoted into a batch input. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat +chat-role-update": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."},
|
||||
"chat group-role remove": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."},
|
||||
"chat group-role update": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."},
|
||||
@@ -109,7 +115,7 @@
|
||||
"chat +messages-send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact shortcut."},
|
||||
"chat message send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact command."},
|
||||
"doc block insert": {"block": ["before-block-id"], "note": "Parent and reference roles remain distinct. --before-block-id needs both --ref-block and --where before, while role-free --block-id cannot choose parent versus reference.", "scoped_aliases": {"parent-block-id": "parent-block", "ref-block-id": "ref-block", "reference-block-id": "ref-block"}, "ambiguous": ["block-id"], "scope_strict": true},
|
||||
"chat message send-by-bot": {"scoped_aliases": {"at-users": "at-user-ids"}, "block": ["user-id", "to-user-id"], "ambiguous": ["at-ids"], "note": "The reviewed @ userId-list alias is exact; singular recipients are not promoted, and bare --at-ids cannot choose an identifier domain."},
|
||||
"chat message send-by-bot": {"scoped_aliases": {"at-users": "at-user-ids"}, "block": ["user-id", "to-user-id"], "ambiguous": ["at-ids", "chat-id", "open-conversation-id"], "note": "The reviewed @ userId-list alias is exact; singular recipients are not promoted, and bare --at-ids cannot choose an identifier domain. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"doc +export-get": {"block": ["doc-id", "document-id", "file-id", "node", "node-id", "task-id", "url"], "note": "This command queries one export jobId. Document node identifiers and import taskId spellings are different entities and are rejected.", "scoped_aliases": {"export-job-id": "job-id"}, "scope_strict": true},
|
||||
"doc block delete": {"block": ["index"], "note": "index (position) vs node (node id) are different"},
|
||||
"doc +copy": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId. The command-scoped --space/--space-id aliases preserve the compatibility published before workspace and numeric DingDrive storage-space concepts were split; they do not make those value domains globally equivalent."},
|
||||
@@ -142,7 +148,7 @@
|
||||
"chat category create-smart": {"bind": {"members": "open_dingtalk_ids"}, "scoped_aliases": {"title": "name"}, "note": "The real --members is an openDingTalkId list; the reviewed title/name alias is exact to the category display name."},
|
||||
"chat group audit-join-validation": {"ambiguous": ["user", "user-id", "userid", "uid", "staff-id"], "note": "A role-free user identifier cannot choose between the required --applicant and --inviter roles."},
|
||||
"chat message reply": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "The required --ref-sender is a role-specific openDingTalkId and must not accept generic userId spellings."},
|
||||
"chat message send-card": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "The real --receiver is a role-specific openDingTalkId and must not accept generic userId spellings."},
|
||||
"chat message send-card": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "ambiguous": ["chat", "chat-id", "open-conversation-id"], "note": "The real --receiver is a role-specific openDingTalkId and must not accept generic userId spellings. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat +category-add-conversation": {"block": ["category-id"], "note": "The real --category-ids is a list; a singular category ID is not promoted automatically."},
|
||||
"chat +category-list-conversations": {"block": ["category-ids"], "note": "The real --category-id is singular; list cardinality is not reduced automatically."},
|
||||
"chat +category-remove-conversation": {"block": ["category-id"], "note": "The real --category-ids is a list; a singular category ID is not promoted automatically."},
|
||||
@@ -328,7 +334,7 @@
|
||||
{"command": "chat group rename", "emitted": "conversation-id", "expect": "id", "via": "concept:open_conversation_id+bind"},
|
||||
{"command": "chat group rename", "emitted": "group-id", "expect": "did-you-mean:blocked", "via": "guard:group-id-vs-open-conversation-id"},
|
||||
{"command": "chat message send", "emitted": "conversation-id", "expect": "group", "via": "concept:open_conversation_id"},
|
||||
{"command": "chat message add-emoji", "emitted": "open-conversation-id", "expect": "conversation-id", "via": "override:scoped"},
|
||||
{"command": "chat message add-emoji", "emitted": "open-conversation-id", "expect": "conversation-id", "via": "concept:open_conversation_id"},
|
||||
{"command": "chat message add-emoji", "emitted": "group-id", "expect": "did-you-mean:blocked", "via": "guard:group-id-vs-open-conversation-id"},
|
||||
{"command": "chat +group-members", "emitted": "conversation-id", "expect": "did-you-mean:blocked", "via": "guard:group-name-vs-open-conversation-id"},
|
||||
{"command": "chat +send-to-group", "emitted": "group-name", "expect": "group", "via": "concept:group_name+bind"},
|
||||
@@ -466,6 +472,7 @@
|
||||
{"command": "chat +flag-create", "emitted": "group", "expect": "conversation-id", "via": "concept:open_conversation_id"},
|
||||
{"command": "chat +chat-add-bot", "emitted": "conversation-id", "expect": "id", "via": "concept:open_conversation_id+bind"},
|
||||
{"command": "chat +chat-add-bot", "emitted": "robot", "expect": "robot-code", "via": "concept:robot_code"},
|
||||
{"command": "chat group audit-join-validation", "emitted": "user", "expect": "did-you-mean:ambiguous", "via": "guard:applicant-vs-inviter-role"},
|
||||
{"command": "chat +chat-audit-join", "emitted": "applicant-user-id", "expect": "applicant", "via": "override:scoped-user-role"},
|
||||
{"command": "chat +chat-audit-join", "emitted": "user-id", "expect": "did-you-mean:ambiguous", "via": "guard:applicant-vs-inviter-role"},
|
||||
{"command": "chat +chat-create", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"},
|
||||
|
||||
@@ -1354,16 +1354,16 @@ func TestDeliveryCatalogChatParamDeclsFrom87910880Reviewed(t *testing.T) {
|
||||
interfaceType string
|
||||
}{
|
||||
{"chat message edit", "conversation-id", "openConversationId", true, ""},
|
||||
{"chat message edit", "msg-id", "openMessageId", true, ""},
|
||||
{"chat message edit", "message-id", "openMessageId", true, ""},
|
||||
{"chat message edit", "at-open-dingtalk-ids", "atOpenDingTalkIds", false, "array"},
|
||||
{"chat message update-text-emotion", "message-id", "openMsgId", true, ""},
|
||||
{"chat message send", "idempotency-key", "uuid", false, ""},
|
||||
{"chat message send-card", "at-all", "atAll", false, ""},
|
||||
{"chat message send-card", "at-open-dingtalk-ids", "atOpenDingTalkIds", false, "array"},
|
||||
{"chat message update-text-emotion", "msg-id", "openMsgId", true, ""},
|
||||
{"chat message update-text-emotion", "old-emotion-id", "oldEmotionId", true, ""},
|
||||
{"chat category batch-info", "category-ids", "categoryIds", true, "array"},
|
||||
{"chat category list-by-conv", "group", "openConversationId", true, ""},
|
||||
{"chat group update-nick", "group", "openConversationId", true, ""},
|
||||
{"chat category list-by-conv", "conversation-id", "openConversationId", true, ""},
|
||||
{"chat group update-nick", "conversation-id", "", true, ""},
|
||||
{"chat group upgrade-to-external", "extension", "extension", false, "object"},
|
||||
{"chat +messages-send-card", "receiver-open-dingtalk-id", "receiverOpenDingTalkId", false, ""},
|
||||
{"chat message list-favorites", "size", "", false, "string"},
|
||||
@@ -1397,7 +1397,8 @@ func TestDeliveryCatalogChatParamDeclsFrom87910880Reviewed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Hidden conversation aliases must stay unpublished (merge-base parity).
|
||||
// Manifest-covered migrations hide legacy aliases; manifest-external
|
||||
// commands keep their existing visible flags for compatibility.
|
||||
editLeaf, err := queryDeliverySchemaPayload([]string{"chat message edit"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1421,9 +1422,52 @@ func TestDeliveryCatalogChatParamDeclsFrom87910880Reviewed(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listParams := schemaMap(listByConv["parameters"])
|
||||
for _, hidden := range []string{"conversation-id", "id"} {
|
||||
if _, ok := listParams[hidden]; ok {
|
||||
t.Fatalf("chat category list-by-conv unexpectedly publishes hidden alias --%s", hidden)
|
||||
if _, ok := listParams["conversation-id"]; !ok {
|
||||
t.Fatalf("chat category list-by-conv missing public canonical --conversation-id")
|
||||
}
|
||||
if _, ok := listParams["group"]; !ok {
|
||||
t.Fatalf("chat category list-by-conv unexpectedly hides manifest-external --group")
|
||||
}
|
||||
if _, ok := listParams["id"]; ok {
|
||||
t.Fatalf("chat category list-by-conv unexpectedly publishes hidden alias --id")
|
||||
}
|
||||
|
||||
for _, path := range []string{
|
||||
"chat message add-emoji",
|
||||
"chat message remove-emoji",
|
||||
"chat message add-text-emotion",
|
||||
"chat message remove-text-emotion",
|
||||
} {
|
||||
leaf, err := queryDeliverySchemaPayload([]string{path})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
params := schemaMap(leaf["parameters"])
|
||||
if _, ok := params["conversation-id"]; !ok {
|
||||
t.Fatalf("%s missing public canonical --conversation-id", path)
|
||||
}
|
||||
for _, visible := range []string{"group", "id", "chat"} {
|
||||
if _, ok := params[visible]; !ok {
|
||||
t.Fatalf("%s unexpectedly hides manifest-external --%s", path, visible)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
groupBots, err := queryDeliverySchemaPayload([]string{"chat group bots"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
groupBotsParams := schemaMap(groupBots["parameters"])
|
||||
group := groupBotsParams["group"]
|
||||
if group == nil {
|
||||
t.Fatal("chat group bots missing public legacy --group")
|
||||
}
|
||||
if group["property"] != "openConversationId" {
|
||||
t.Fatalf("chat group bots --group property = %#v, want openConversationId", group["property"])
|
||||
}
|
||||
for _, migrated := range []string{"conversation-id", "group-name"} {
|
||||
if _, ok := groupBotsParams[migrated]; ok {
|
||||
t.Fatalf("chat group bots unexpectedly publishes migrated --%s", migrated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,19 +43,14 @@ func init() {
|
||||
RequireOneOf: [][]string{{"conversation-id", "open-dingtalk-id", "user", "permParam"}},
|
||||
})
|
||||
registerExclusiveOneOf("chat.chat_permission_grant_cross_org_data", "target-org-id", "all")
|
||||
registerRequireOneOf("chat.add_emoji_reaction", "conversation-id", "group", "id", "chat")
|
||||
registerRequireOneOf("chat.add_text_emotion", "conversation-id", "group", "id", "chat")
|
||||
registerExclusiveOneOf("chat.clear_conversation_messages", "conversation-id", "id", "chat")
|
||||
registerExclusiveOneOf("chat.clear_conversation_red_point", "conversation-id", "id", "chat")
|
||||
registerRequireOneOf("chat.update_text_emotion", "conversation-id", "group", "id", "chat")
|
||||
registerExclusiveOneOf("chat.get_conversation_info", "group", "user", "open-dingtalk-id")
|
||||
registerExclusiveOneOf("chat.hide_conversation", "conversation-id", "id", "chat")
|
||||
registerExclusiveOneOf("chat.list_conversation_message_v2", "group", "user", "open-dingtalk-id")
|
||||
registerExclusiveOneOf("chat.list_individual_chat_message", "user", "open-dingtalk-id")
|
||||
registerExclusiveOneOf("chat.mark_conversation_unread", "conversation-id", "id", "chat")
|
||||
registerExclusiveOneOf("chat.mark_message_read", "conversation-id", "id", "chat")
|
||||
registerRequireOneOf("chat.remove_emoji_reaction", "conversation-id", "group", "id", "chat")
|
||||
registerRequireOneOf("chat.remove_text_emotion", "conversation-id", "group", "id", "chat")
|
||||
registerRequireOneOf("chat.send_personal_message", "text", "content", "msg-type")
|
||||
registerExclusiveOneOf("chat.send_robot_message", "group", "users")
|
||||
registerRequireOneOf("chat.set_group_member_mute_list", "users", "user")
|
||||
|
||||
@@ -252,7 +252,8 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"chat.batch_query_group_chat_settings --groups": "Reviewed unpinned adapter: chat.batch_query_group_chat_settings has no singular pinned interface_ref; --groups is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.batch_update_group_chat_settings --items": "Reviewed unpinned adapter: chat.batch_update_group_chat_settings has no singular pinned interface_ref; --items is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.create_text_emotion --background-id": "Runtime extension: the executable helper forwards backgroundId to im/create_text_emotion, but the pinned source-revision metadata does not declare that optional property; preserve the compatibility flag without advertising it as a pinned RPC field.",
|
||||
"chat.get_group_mute_config --group": "Reviewed unpinned adapter: chat.get_group_mute_config has no singular pinned interface_ref; --group is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.get_group_mute_config --conversation-id": "Reviewed unpinned adapter: chat.get_group_mute_config has no singular pinned interface_ref; --conversation-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.get_group_mute_config --group": "Reviewed legacy Schema compatibility: the historical visible --group wrapper input was required but did not publish a direct interface property.",
|
||||
"chat.list_conversation_message_v2 --open-dingtalk-id": "selects the alternate list_individual_chat_message branch",
|
||||
"chat.list_conversation_message_v2 --user": "selects the alternate list_individual_chat_message branch",
|
||||
"chat.list_message_favorites --cursor": "Reviewed unpinned adapter: chat.list_message_favorites has no singular pinned interface_ref; --cursor is a CLI wrapper input and does not publish a direct interface property.",
|
||||
@@ -265,6 +266,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"chat.query_msg_read_status --users": "conditional wrapper/alias of --user: parseCSVValues + appendChatIDArgs routes each supplied identifier to targetUserIds or targetOpenDingTalkIds according to its runtime ID shape; there is no single RPC property for this flag",
|
||||
"chat.remove_message_favorite --open-conversation-id": "Reviewed unpinned adapter: chat.remove_message_favorite has no singular pinned interface_ref; --open-conversation-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.remove_message_favorite --open-message-id": "Reviewed unpinned adapter: chat.remove_message_favorite has no singular pinned interface_ref; --open-message-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.update_text_emotion --conversation-id": "Reviewed unpinned adapter: chat.update_text_emotion has no singular pinned interface_ref; --conversation-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.reply_personal_message --ref-msg-id": "serialized into the aggregate content JSON string",
|
||||
"chat.reply_personal_message --ref-sender": "resolved then serialized into the aggregate content JSON string",
|
||||
"chat.reply_personal_message --text": "serialized into the aggregate content JSON string",
|
||||
|
||||
+25
-12
@@ -578,18 +578,7 @@ func RegisterFlags(cmd *cobra.Command, flags []FlagSpec) {
|
||||
for _, alias := range flag.Aliases {
|
||||
RegisterFlag(cmd, flag.Kind, alias, "", flag.Usage+" (alias)")
|
||||
_ = cmd.Flags().MarkHidden(alias)
|
||||
if registered := cmd.Flags().Lookup(alias); registered != nil {
|
||||
runtimeannotate.SetFlagAnnotation(
|
||||
registered,
|
||||
runtimeannotate.AnnotationFlagAliasOf,
|
||||
flag.Name,
|
||||
)
|
||||
runtimeannotate.SetFlagAnnotation(
|
||||
registered,
|
||||
runtimeannotate.AnnotationFlagAliasOrigin,
|
||||
runtimeannotate.FlagAliasOriginCorecmdV1,
|
||||
)
|
||||
}
|
||||
AnnotateFlagAlias(cmd, alias, flag.Name)
|
||||
}
|
||||
if flag.MarkRequired {
|
||||
_ = cmd.MarkFlagRequired(flag.Name)
|
||||
@@ -600,6 +589,30 @@ func RegisterFlags(cmd *cobra.Command, flags []FlagSpec) {
|
||||
}
|
||||
}
|
||||
|
||||
// AnnotateFlagAlias records framework-owned evidence that aliasName is a hidden
|
||||
// compatibility alias for canonicalName. It is for commands that already own
|
||||
// their Cobra flag registration outside FlagSpec but still need the same
|
||||
// interface-snapshot alias contract as FlagSpec.Aliases.
|
||||
func AnnotateFlagAlias(cmd *cobra.Command, aliasName, canonicalName string) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
registered := cmd.Flags().Lookup(aliasName)
|
||||
if registered == nil {
|
||||
return
|
||||
}
|
||||
runtimeannotate.SetFlagAnnotation(
|
||||
registered,
|
||||
runtimeannotate.AnnotationFlagAliasOf,
|
||||
canonicalName,
|
||||
)
|
||||
runtimeannotate.SetFlagAnnotation(
|
||||
registered,
|
||||
runtimeannotate.AnnotationFlagAliasOrigin,
|
||||
runtimeannotate.FlagAliasOriginCorecmdV1,
|
||||
)
|
||||
}
|
||||
|
||||
// RegisterFlag registers one flag by Kind. Default is applied at registration
|
||||
// for every kind so --help DefValue matches the declared fallback.
|
||||
// Malformed KindInt / KindBool Default values panic at registration (fail-closed)
|
||||
|
||||
@@ -116,6 +116,16 @@ func TestCrossPlatformCoverageRegisterFlagsAllKinds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAnnotateFlagAliasIgnoresMissingInputs(t *testing.T) {
|
||||
AnnotateFlagAlias(nil, "alias", "canonical")
|
||||
|
||||
cmd := newTestCommand()
|
||||
AnnotateFlagAlias(cmd, "missing", "canonical")
|
||||
if flag := cmd.Flags().Lookup("missing"); flag != nil {
|
||||
t.Fatalf("unexpected missing flag registered: %#v", flag)
|
||||
}
|
||||
}
|
||||
|
||||
// ── effective value fallback chain ─────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageEffectiveValueFallbackChain(t *testing.T) {
|
||||
|
||||
@@ -15,9 +15,13 @@ package errors
|
||||
|
||||
import (
|
||||
stderrors "errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageExitCodeByCategory(t *testing.T) {
|
||||
@@ -382,6 +386,27 @@ func TestCrossPlatformCoverageServerGuidanceSuppressesUnsafeActionURL(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePrintJSONCLIOrgNotAuthorizedUsesInternationalActionURL(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "mcp_url"), []byte("https://mcp.dingtalk.io\n"), config.FilePerm); err != nil {
|
||||
t.Fatalf("WriteFile(mcp_url) error = %v", err)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, NewAPI(
|
||||
"business error",
|
||||
WithServerDiag(ServerDiagnostics{ServerErrorCode: "CLI_ORG_NOT_AUTHORIZED"}),
|
||||
)); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
|
||||
want := `"action_url": "https://open-dev.dingtalk.io/fe/old#/developerSettings"`
|
||||
if got := b.String(); !strings.Contains(got, want) {
|
||||
t.Fatalf("expected international action_url %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePrintJSONIncludesRPCCodeAndData(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+808
-446
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ package helpers
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func runChatCoverageCommand(t *testing.T, caller edition.ToolCaller, args ...string) error {
|
||||
@@ -32,14 +34,26 @@ func runChatCoverageCommand(t *testing.T, caller edition.ToolCaller, args ...str
|
||||
|
||||
func runChatCoverageDirect(t *testing.T, path []string, flags map[string]string) error {
|
||||
t.Helper()
|
||||
command, _, err := newChatCommand().Find(path)
|
||||
InitDeps(&scriptedToolCaller{})
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
root := newChatCommand()
|
||||
installExampleGlobalFlags(root)
|
||||
root.PersistentFlags().Bool("debug", false, "")
|
||||
root.PersistentFlags().Bool("verbose", false, "")
|
||||
command, _, err := root.Find(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for name, value := range flags {
|
||||
if err := command.Flags().Set(name, value); err != nil {
|
||||
flag := command.Flag(name)
|
||||
if flag == nil {
|
||||
return fmt.Errorf("no such flag -%s", name)
|
||||
}
|
||||
if err := flag.Value.Set(value); err != nil {
|
||||
return err
|
||||
}
|
||||
flag.Changed = true
|
||||
}
|
||||
return command.RunE(command, nil)
|
||||
}
|
||||
@@ -93,7 +107,7 @@ func TestCrossPlatformCoverageChatStableCompatibilityHintsRemainAvailable(t *tes
|
||||
hint string
|
||||
}{
|
||||
{path: "send", args: []string{"send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
|
||||
{path: "history", args: []string{"history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
|
||||
{path: "history", args: []string{"history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --conversation-id <GROUP_OPEN_CONVERSATION_ID>"},
|
||||
} {
|
||||
command, remaining, err := root.Find([]string{tc.path})
|
||||
if err != nil {
|
||||
@@ -113,6 +127,80 @@ func TestCrossPlatformCoverageChatStableCompatibilityHintsRemainAvailable(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatAliasInstallerRemainingEdges(t *testing.T) {
|
||||
restoreChatManifestExternalVisibleFlags(nil)
|
||||
|
||||
mismatchedRoot := &cobra.Command{Use: "chat"}
|
||||
mismatchedRoot.AddCommand(&cobra.Command{Use: "other"})
|
||||
restoreChatManifestExternalVisibleFlags(mismatchedRoot)
|
||||
|
||||
missingPrimary := &cobra.Command{Use: "leaf"}
|
||||
installChatFlagAliases(missingPrimary, "conversation-id", []string{"group"}, requireChatConversationID)
|
||||
if flag := missingPrimary.Flags().Lookup("group"); flag != nil {
|
||||
t.Fatalf("alias registered without canonical flag: %#v", flag)
|
||||
}
|
||||
|
||||
skipGroup := &cobra.Command{Use: "leaf", RunE: func(cmd *cobra.Command, args []string) error { return nil }}
|
||||
skipGroup.Flags().String("conversation-id", "", "")
|
||||
skipGroup.Flags().String("group-name", "", "")
|
||||
installChatFlagAliases(skipGroup, "conversation-id", []string{"group", "chat"}, requireChatConversationID)
|
||||
if flag := skipGroup.Flags().Lookup("group"); flag != nil {
|
||||
t.Fatalf("group alias registered beside group-name: %#v", flag)
|
||||
}
|
||||
if flag := skipGroup.Flags().Lookup("chat"); flag == nil {
|
||||
t.Fatal("non-group alias was not registered")
|
||||
}
|
||||
|
||||
preRunCalled := false
|
||||
withPreRun := &cobra.Command{
|
||||
Use: "leaf",
|
||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
preRunCalled = true
|
||||
return nil
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error { return nil },
|
||||
}
|
||||
withPreRun.Flags().String("conversation-id", "", "")
|
||||
installChatFlagAliases(withPreRun, "conversation-id", []string{"group"}, requireChatConversationID)
|
||||
withPreRun.SetArgs([]string{"--group", "cid"})
|
||||
if err := withPreRun.ExecuteContext(context.Background()); err != nil {
|
||||
t.Fatalf("execute with alias and previous PreRunE: %v", err)
|
||||
}
|
||||
if !preRunCalled {
|
||||
t.Fatal("previous PreRunE was not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessageForwardRequiresMessageID(t *testing.T) {
|
||||
caller := &productExampleCaller{}
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
root := newChatCommand()
|
||||
command, _, err := root.Find([]string{"message", "forward"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"message-id", "msg-id"} {
|
||||
if flag := command.Flags().Lookup(name); flag != nil && flag.Annotations != nil {
|
||||
delete(flag.Annotations, cobra.BashCompOneRequiredFlag)
|
||||
}
|
||||
}
|
||||
if err := command.Flags().Set("src-conversation-id", "src"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := command.Flags().Set("dest-conversation-id", "dest"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = command.RunE(command, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "missing required flag: --message-id") {
|
||||
t.Fatalf("forward missing message id error = %v", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("tool calls = %d, want 0", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupUpdateIconAcceptsUploadedMediaIDPrefixes(t *testing.T) {
|
||||
previousDeps, previousArgs := deps, os.Args
|
||||
os.Args = []string{"dws", "chat"}
|
||||
@@ -199,9 +287,10 @@ func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T)
|
||||
{"category", "remove-conv", "--group=cid", "--category-ids=1,2"},
|
||||
{"message", "list-by-ids", "--msg-ids=" + strings.Repeat("id,", 51) + "last"},
|
||||
{"group", "transfer-owner", "--group=cid", "--new-owner=D-owner"},
|
||||
{"group", "transfer-owner", "--group=cid", "--new-owner=DAAAAAAAAAAAiE"},
|
||||
{"group", "update-icon", "--group=cid", "--icon-media-id=@valid"},
|
||||
{"group", "set-history", "--group=cid", "--option=ALL"},
|
||||
{"group", "audit-join-validation", "--group=cid", "--record-id=1", "--applicant=D1", "--inviter=D2", "--status=AuditApprove", "--description=ok"},
|
||||
{"group", "audit-join-validation", "--conversation-id=cid", "--record-id=1", "--applicant=D1", "--inviter=D2", "--status=AuditApprove", "--description=ok"},
|
||||
{"mark-read", "--conversation-id=cid", "--message-id=mid"},
|
||||
{"text", "translate", "--query=hello", "--to=zh_CN"},
|
||||
{"group-role", "set-user", "--group=cid", "--user=D1", "--role-ids=r1"},
|
||||
@@ -293,7 +382,7 @@ func TestCrossPlatformCoverageChatNativeSendCardMentions(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
err := runChatCoverageCommand(t, caller,
|
||||
"message", "send-card",
|
||||
"--group=cid",
|
||||
"--conversation-id=cid",
|
||||
"--at-open-dingtalk-ids=D1,D2,D1",
|
||||
"--at-all",
|
||||
)
|
||||
@@ -314,13 +403,13 @@ func TestCrossPlatformCoverageChatNativeSendCardMentions(t *testing.T) {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "member mention rejects direct message", args: []string{"--receiver=D1", "--at-open-dingtalk-ids=D2"}},
|
||||
{name: "at all rejects direct message", args: []string{"--receiver=D1", "--at-all"}},
|
||||
{name: "member mention rejects direct message", args: []string{"--open-dingtalk-id=D1", "--at-open-dingtalk-ids=D2"}},
|
||||
{name: "at all rejects direct message", args: []string{"--open-dingtalk-id=D1", "--at-all"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
err := runChatCoverageCommand(t, caller, append([]string{"message", "send-card"}, tc.args...)...)
|
||||
if err == nil || !strings.Contains(err.Error(), "only supported with --group") {
|
||||
if err == nil || !strings.Contains(err.Error(), "only supported with --conversation-id") {
|
||||
t.Fatalf("error = %v, want group-only mention validation", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
@@ -330,6 +419,168 @@ func TestCrossPlatformCoverageChatNativeSendCardMentions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatSendCardHiddenAliasesMapToCanonicalPayload(t *testing.T) {
|
||||
previousDeps, previousArgs := deps, os.Args
|
||||
os.Args = []string{"dws", "chat"}
|
||||
t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
want map[string]any
|
||||
}{
|
||||
{
|
||||
name: "group alias",
|
||||
args: []string{"--group=cid"},
|
||||
want: map[string]any{"openConversationId": "cid"},
|
||||
},
|
||||
{
|
||||
name: "receiver alias",
|
||||
args: []string{"--receiver=DAAAAAAAAAAAiE"},
|
||||
want: map[string]any{"receiverOpenDingTalkId": "DAAAAAAAAAAAiE"},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
err := runChatCoverageCommand(t, caller, append([]string{"message", "send-card"}, tc.args...)...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 1 || caller.server != "im" || caller.tool != "create_and_send_card" || !reflect.DeepEqual(caller.args, tc.want) {
|
||||
t.Fatalf("call = count:%d server:%q tool:%q args:%#v, want %#v", caller.calls, caller.server, caller.tool, caller.args, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupAuditJoinValidationUsesCanonicalAndAliasPayload(t *testing.T) {
|
||||
previousDeps, previousArgs := deps, os.Args
|
||||
os.Args = []string{"dws", "chat"}
|
||||
t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
flag string
|
||||
}{
|
||||
{name: "canonical conversation-id", flag: "--conversation-id=cid"},
|
||||
{name: "hidden group alias", flag: "--group=cid"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
err := runChatCoverageCommand(t, caller,
|
||||
"group", "audit-join-validation",
|
||||
tc.flag,
|
||||
"--record-id=123",
|
||||
"--applicant=D-applicant",
|
||||
"--inviter=D-inviter",
|
||||
"--status=AuditDelete",
|
||||
"--description=deny",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]any{
|
||||
"openConversationId": "cid",
|
||||
"applyRecordId": int64(123),
|
||||
"applicantUid": "D-applicant",
|
||||
"inviterUid": "D-inviter",
|
||||
"status": "AuditDelete",
|
||||
"auditDescription": "deny",
|
||||
}
|
||||
if caller.calls != 1 || caller.server != "im" || caller.tool != "audit_join_group" || !reflect.DeepEqual(caller.args, want) {
|
||||
t.Fatalf("call = count:%d server:%q tool:%q args:%#v, want %#v", caller.calls, caller.server, caller.tool, caller.args, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatIMIDMigrationRequiredFlagErrors(t *testing.T) {
|
||||
previousDeps, previousArgs := deps, os.Args
|
||||
os.Args = []string{"dws", "chat"}
|
||||
t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path []string
|
||||
flag map[string]string
|
||||
want string
|
||||
}{
|
||||
{name: "message list mutually exclusive targets", path: []string{"message", "list"}, flag: map[string]string{"conversation-id": "cid", "user": "u1", "time": "2026-01-01"}, want: "mutually exclusive"},
|
||||
{name: "message list missing target", path: []string{"message", "list"}, flag: map[string]string{"time": "2026-01-01"}, want: "--conversation-id, --user or --open-dingtalk-id is required"},
|
||||
{name: "topic replies missing conversation", path: []string{"message", "list-topic-replies"}, flag: map[string]string{"topic-id": "t1"}, want: "conversation-id"},
|
||||
{name: "read status missing conversation", path: []string{"message", "read-status"}, flag: map[string]string{"message-id": "m1"}, want: "conversation-id"},
|
||||
{name: "read status conflicting aliases", path: []string{"message", "read-status"}, flag: map[string]string{"conversation-id": "cid1", "group": "cid2", "message-id": "m1"}, want: "conflicts"},
|
||||
{name: "read status missing message", path: []string{"message", "read-status"}, flag: map[string]string{"conversation-id": "cid"}, want: "message-id"},
|
||||
{name: "update text emotion missing message", path: []string{"message", "update-text-emotion"}, flag: map[string]string{"conversation-id": "cid", "old-emotion-id": "e1", "emotion-id": "e2", "emotion-name": "n", "text": "t", "background-id": "b"}, want: "message-id"},
|
||||
{name: "update text emotion missing detail flag", path: []string{"message", "update-text-emotion"}, flag: map[string]string{"conversation-id": "cid", "message-id": "m1", "old-emotion-id": "e1", "emotion-id": "e2", "emotion-name": "n", "text": "t"}, want: "background-id"},
|
||||
{name: "transfer owner missing conversation", path: []string{"group", "transfer-owner"}, flag: map[string]string{"new-owner": "D1"}, want: "conversation-id"},
|
||||
{name: "invite url missing conversation", path: []string{"group", "invite-url"}, want: "conversation-id"},
|
||||
{name: "quit missing conversation", path: []string{"group", "quit"}, want: "conversation-id"},
|
||||
{name: "update icon missing conversation", path: []string{"group", "update-icon"}, flag: map[string]string{"icon-media-id": "@media"}, want: "conversation-id"},
|
||||
{name: "update settings missing conversation", path: []string{"group", "update-settings"}, flag: map[string]string{"setting-key": "searchable"}, want: "conversation-id"},
|
||||
{name: "set admin missing conversation", path: []string{"group", "set-admin"}, flag: map[string]string{"users": "D1"}, want: "conversation-id"},
|
||||
{name: "role list missing conversation", path: []string{"group-role", "list"}, want: "group"},
|
||||
{name: "role add missing conversation", path: []string{"group-role", "add"}, flag: map[string]string{"name": "role"}, want: "conversation-id"},
|
||||
{name: "role update missing conversation", path: []string{"group-role", "update"}, flag: map[string]string{"role-id": "r1", "name": "role"}, want: "conversation-id"},
|
||||
{name: "role remove missing conversation", path: []string{"group-role", "remove"}, flag: map[string]string{"role-id": "r1"}, want: "conversation-id"},
|
||||
{name: "role set user missing conversation", path: []string{"group-role", "set-user"}, flag: map[string]string{"user": "D1", "role-ids": "r1"}, want: "conversation-id"},
|
||||
{name: "role remove user missing conversation", path: []string{"group-role", "remove-user"}, flag: map[string]string{"user": "D1", "role-ids": "r1"}, want: "conversation-id"},
|
||||
{name: "role query user missing conversation", path: []string{"group-role", "query-user"}, flag: map[string]string{"user": "D1"}, want: "conversation-id"},
|
||||
{name: "bots missing legacy group", path: []string{"group", "bots"}, want: "group"},
|
||||
{name: "bots rejects migrated conversation id", path: []string{"group", "bots"}, flag: map[string]string{"conversation-id": "cid"}, want: "no such flag"},
|
||||
{name: "dismiss missing conversation", path: []string{"group", "dismiss"}, flag: map[string]string{"yes": "true"}, want: "conversation-id"},
|
||||
{name: "set history missing conversation", path: []string{"group", "set-history"}, flag: map[string]string{"option": "ALL"}, want: "conversation-id"},
|
||||
{name: "set pin missing message", path: []string{"message", "set-pin-msg"}, flag: map[string]string{"open-conversation-id": "cid"}, want: "message-id"},
|
||||
{name: "unset pin missing message", path: []string{"message", "unset-pin-msg"}, flag: map[string]string{"open-conversation-id": "cid"}, want: "message-id"},
|
||||
{name: "audit join missing conversation", path: []string{"group", "audit-join-validation"}, flag: map[string]string{"record-id": "1", "applicant": "D1", "inviter": "D2", "status": "AuditApprove"}, want: "conversation-id"},
|
||||
{name: "set top missing message", path: []string{"message", "set-top-msg"}, flag: map[string]string{"open-conversation-id": "cid"}, want: "message-id"},
|
||||
{name: "unset top missing message", path: []string{"message", "unset-top-msg"}, flag: map[string]string{"open-conversation-id": "cid"}, want: "message-id"},
|
||||
{name: "update alias missing conversation", path: []string{"group", "update-alias"}, flag: map[string]string{"alias-title": "alias"}, want: "conversation-id"},
|
||||
{name: "notice create missing conversation", path: []string{"group", "notice", "create"}, flag: map[string]string{"content": "hello"}, want: "conversation-id"},
|
||||
{name: "notice edit missing conversation", path: []string{"group", "notice", "edit"}, flag: map[string]string{"notice-id": "n1", "content": "hello"}, want: "conversation-id"},
|
||||
{name: "notice get missing conversation", path: []string{"group", "notice", "get"}, flag: map[string]string{"notice-id": "n1"}, want: "conversation-id"},
|
||||
{name: "notice list missing conversation", path: []string{"group", "notice", "list"}, want: "conversation-id"},
|
||||
}
|
||||
|
||||
probe := newChatCommand()
|
||||
messageList, _, err := probe.Find([]string{"message", "list"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := chatConversationID(messageList); err != nil || got != "" {
|
||||
t.Fatalf("empty chatConversationID = %q, %v", got, err)
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := runChatCoverageDirect(t, tc.path, tc.flag)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("error = %v, want containing %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessageReadStatusConversationAliasesExecute(t *testing.T) {
|
||||
for _, alias := range []string{"group", "id", "chat", "open-conversation-id"} {
|
||||
t.Run(alias, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
if err := runChatCoverageCommand(t, caller, "message", "read-status", "--"+alias, "cid-1", "--message-id", "msg-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.server != "im" || caller.tool != "query_msg_read_status" {
|
||||
t.Fatalf("call = %s/%s, want im/query_msg_read_status", caller.server, caller.tool)
|
||||
}
|
||||
if got := caller.args["openConversationId"]; got != "cid-1" {
|
||||
t.Fatalf("openConversationId = %#v, want cid-1", got)
|
||||
}
|
||||
if got := caller.args["openMessageId"]; got != "msg-1" {
|
||||
t.Fatalf("openMessageId = %#v, want msg-1", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatWebhookReplyConversationAndDownloadEdges(t *testing.T) {
|
||||
previousDeps, previousArgs := deps, os.Args
|
||||
os.Args = []string{"dws", "chat"}
|
||||
@@ -343,6 +594,7 @@ func TestCrossPlatformCoverageChatWebhookReplyConversationAndDownloadEdges(t *te
|
||||
_ = runChatCoverageCommand(t, &scriptedToolCaller{}, "conversation-info", "--open-dingtalk-id=D1")
|
||||
_ = runChatCoverageCommand(t, &scriptedToolCaller{}, "conversation-info", "--user=D1")
|
||||
_ = runChatCoverageCommand(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[{"userId":"u1","openDingTalkId":"D1"}]}`}, {text: `{}`}}}, "conversation-info", "--user=u1")
|
||||
_ = runChatCoverageCommand(t, &scriptedToolCaller{}, "message", "send-card", "--open-dingtalk-id=D1")
|
||||
_ = runChatCoverageCommand(t, &scriptedToolCaller{}, "message", "send-card", "--receiver=D1")
|
||||
|
||||
oldGet := httpGetFile
|
||||
|
||||
@@ -17,8 +17,88 @@ import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupAuditJoinValidationAliasContract(t *testing.T) {
|
||||
cmd := newChatCommand()
|
||||
leaf, _, err := cmd.Find([]string{"group", "audit-join-validation"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
canonical := leaf.Flags().Lookup("conversation-id")
|
||||
if canonical == nil || canonical.Hidden {
|
||||
t.Fatalf("conversation-id flag = %#v, want visible canonical", canonical)
|
||||
}
|
||||
legacy := leaf.Flags().Lookup("group")
|
||||
if legacy == nil || !legacy.Hidden {
|
||||
t.Fatalf("group flag = %#v, want hidden compatibility alias", legacy)
|
||||
}
|
||||
if got := legacy.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 1 || got[0] != "conversation-id" {
|
||||
t.Fatalf("group alias_of annotation = %#v", got)
|
||||
}
|
||||
if got := legacy.Annotations[runtimeannotate.AnnotationFlagAliasOrigin]; len(got) != 1 || got[0] != runtimeannotate.FlagAliasOriginCorecmdV1 {
|
||||
t.Fatalf("group alias_origin annotation = %#v", got)
|
||||
}
|
||||
if got := legacy.Annotations[cobra.BashCompOneRequiredFlag]; len(got) != 0 {
|
||||
t.Fatalf("hidden group alias kept required annotation: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupAuditJoinValidationRestoreRequiredNoop(t *testing.T) {
|
||||
restoreChatGroupBotsLegacyRequired(nil)
|
||||
restoreChatPendingMigrationCanonicalRequired(nil)
|
||||
root := &cobra.Command{Use: "chat"}
|
||||
root.AddCommand(&cobra.Command{Use: "other"})
|
||||
restoreChatGroupBotsLegacyRequired(root)
|
||||
restoreChatPendingMigrationCanonicalRequired(root)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupBotsKeepsLegacyGroupFlag(t *testing.T) {
|
||||
cmd := newChatCommand()
|
||||
leaf, _, err := cmd.Find([]string{"group", "bots"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
group := leaf.Flags().Lookup("group")
|
||||
if group == nil || group.Hidden {
|
||||
t.Fatalf("group flag = %#v, want visible legacy flag", group)
|
||||
}
|
||||
if got := group.Annotations[cobra.BashCompOneRequiredFlag]; len(got) == 0 || got[0] != "true" {
|
||||
t.Fatalf("group required annotation = %#v, want true", got)
|
||||
}
|
||||
if leaf.Flags().Lookup("conversation-id") != nil {
|
||||
t.Fatalf("chat group bots still exposes migrated --conversation-id")
|
||||
}
|
||||
if leaf.Flags().Lookup("group-name") != nil {
|
||||
t.Fatalf("chat group bots still exposes migrated --group-name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatPendingMigrationAliasesMatchManifest(t *testing.T) {
|
||||
cmd := newChatCommand()
|
||||
leaf, _, err := cmd.Find([]string{"group", "dismiss"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
canonical := leaf.Flags().Lookup("conversation-id")
|
||||
if canonical == nil {
|
||||
t.Fatal("missing conversation-id flag")
|
||||
}
|
||||
if got := canonical.Annotations[cobra.BashCompOneRequiredFlag]; len(got) == 0 || got[0] != "true" {
|
||||
t.Fatalf("conversation-id required annotation = %#v, want true", got)
|
||||
}
|
||||
legacy := leaf.Flags().Lookup("group")
|
||||
if legacy == nil || !legacy.Hidden {
|
||||
t.Fatalf("group flag = %#v, want hidden legacy alias", legacy)
|
||||
}
|
||||
if got := legacy.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 1 || got[0] != "conversation-id" {
|
||||
t.Fatalf("group alias_of annotation = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessageHelpDocumentsPostSendIDChain(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -51,7 +131,7 @@ func TestCrossPlatformCoverageChatMessageHelpDocumentsPostSendIDChain(t *testing
|
||||
contains: []string{
|
||||
"send -> query-send-status -> edit",
|
||||
"query-send-status --open-task-id <上一步返回的openTaskId>",
|
||||
"edit --conversation-id <上一步返回的openConversationId> --msg-id <上一步返回的openMessageId>",
|
||||
"edit --conversation-id <上一步返回的openConversationId> --message-id <上一步返回的openMessageId>",
|
||||
},
|
||||
notContain: "chat message list",
|
||||
},
|
||||
@@ -61,7 +141,7 @@ func TestCrossPlatformCoverageChatMessageHelpDocumentsPostSendIDChain(t *testing
|
||||
contains: []string{
|
||||
"send -> query-send-status -> recall",
|
||||
"query-send-status --open-task-id <上一步返回的openTaskId>",
|
||||
"recall --conversation-id <上一步返回的openConversationId> --msg-id <上一步返回的openMessageId>",
|
||||
"recall --conversation-id <上一步返回的openConversationId> --message-id <上一步返回的openMessageId>",
|
||||
},
|
||||
notContain: "chat message list",
|
||||
},
|
||||
@@ -183,3 +263,91 @@ func TestCrossPlatformCoverageChatMessageHelpDocumentsOptionalTimeDefaults(t *te
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatReactionHelpKeepsManifestExternalAliasesVisible(t *testing.T) {
|
||||
for _, command := range []string{"add-emoji", "remove-emoji", "add-text-emotion", "remove-text-emotion"} {
|
||||
t.Run(command, func(t *testing.T) {
|
||||
cmd := newChatCommand()
|
||||
var output bytes.Buffer
|
||||
cmd.SetOut(&output)
|
||||
cmd.SetErr(&output)
|
||||
cmd.SetArgs([]string{"message", command, "--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("chat message %s --help: %v\n%s", command, err, output.String())
|
||||
}
|
||||
|
||||
help := output.String()
|
||||
if !strings.Contains(help, "--conversation-id") {
|
||||
t.Fatalf("chat message %s help missing --conversation-id:\n%s", command, help)
|
||||
}
|
||||
for _, visible := range []string{"--group", "--id", "--chat"} {
|
||||
if !strings.Contains(help, visible+" string") {
|
||||
t.Fatalf("chat message %s help hides manifest-external alias %s:\n%s", command, visible, help)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupBotsHelpKeepsLegacyGroup(t *testing.T) {
|
||||
cmd := newChatCommand()
|
||||
var output bytes.Buffer
|
||||
cmd.SetOut(&output)
|
||||
cmd.SetErr(&output)
|
||||
cmd.SetArgs([]string{"group", "bots", "--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("chat group bots --help: %v\n%s", err, output.String())
|
||||
}
|
||||
|
||||
help := output.String()
|
||||
if !strings.Contains(help, "--group string") {
|
||||
t.Fatalf("chat group bots help missing visible --group:\n%s", help)
|
||||
}
|
||||
for _, hidden := range []string{"--conversation-id", "--group-name"} {
|
||||
if strings.Contains(help, hidden) {
|
||||
t.Fatalf("chat group bots help exposes migrated flag %s:\n%s", hidden, help)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatSendCardHelpUsesCanonicalIDFlags(t *testing.T) {
|
||||
cmd := newChatCommand()
|
||||
var output bytes.Buffer
|
||||
cmd.SetOut(&output)
|
||||
cmd.SetErr(&output)
|
||||
cmd.SetArgs([]string{"message", "send-card", "--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("chat message send-card --help: %v\n%s", err, output.String())
|
||||
}
|
||||
|
||||
help := output.String()
|
||||
for _, visible := range []string{"--conversation-id", "--open-dingtalk-id"} {
|
||||
if !strings.Contains(help, visible) {
|
||||
t.Fatalf("send-card help missing %s:\n%s", visible, help)
|
||||
}
|
||||
}
|
||||
for _, visible := range []string{"--group", "--receiver"} {
|
||||
if !strings.Contains(help, visible+" string") {
|
||||
t.Fatalf("send-card help hides manifest-external alias %s:\n%s", visible, help)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupAuditJoinValidationHelpUsesCanonicalConversationID(t *testing.T) {
|
||||
cmd := newChatCommand()
|
||||
var output bytes.Buffer
|
||||
cmd.SetOut(&output)
|
||||
cmd.SetErr(&output)
|
||||
cmd.SetArgs([]string{"group", "audit-join-validation", "--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("chat group audit-join-validation --help: %v\n%s", err, output.String())
|
||||
}
|
||||
|
||||
help := output.String()
|
||||
if !strings.Contains(help, "--conversation-id") {
|
||||
t.Fatalf("audit-join-validation help missing --conversation-id:\n%s", help)
|
||||
}
|
||||
if strings.Contains(help, "--group string") {
|
||||
t.Fatalf("audit-join-validation help exposes hidden --group alias:\n%s", help)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,32 @@ func TestCrossPlatformCoverageChatUpdateTextEmotion(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "open-conversation-id alias",
|
||||
args: []string{
|
||||
"message", "update-text-emotion",
|
||||
"--open-conversation-id", "conv-3",
|
||||
"--message-id", "msg-3",
|
||||
"--old-emotion-id", "old-3",
|
||||
"--emotion-id", "new-3",
|
||||
"--emotion-name", "smile",
|
||||
"--text", "done",
|
||||
"--background-id", "im_bg_2",
|
||||
},
|
||||
want: guardedMutationCall{
|
||||
productID: "im",
|
||||
toolName: "update_text_emotion",
|
||||
args: map[string]any{
|
||||
"openConversationId": "conv-3",
|
||||
"openMsgId": "msg-3",
|
||||
"oldEmotionId": "old-3",
|
||||
"emotionId": "new-3",
|
||||
"emotionName": "smile",
|
||||
"text": "done",
|
||||
"backgroundId": "im_bg_2",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
@@ -127,7 +153,7 @@ func TestCrossPlatformCoverageChatUpdateTextEmotionRequiredFlags(t *testing.T) {
|
||||
{
|
||||
name: "missing conversation-id and aliases",
|
||||
args: dropFlag("--conversation-id"),
|
||||
wantErr: "at least one of the flags in the group [conversation-id group id chat] is required",
|
||||
wantErr: "missing required flag: --conversation-id (or --group / --id / --chat / --open-conversation-id)",
|
||||
},
|
||||
{
|
||||
name: "missing old-emotion-id",
|
||||
@@ -135,7 +161,7 @@ func TestCrossPlatformCoverageChatUpdateTextEmotionRequiredFlags(t *testing.T) {
|
||||
wantErr: `required flag(s) "old-emotion-id" not set`,
|
||||
},
|
||||
{
|
||||
name: "missing msg-id and background-id",
|
||||
name: "missing message-id and background-id",
|
||||
args: []string{
|
||||
"message", "update-text-emotion",
|
||||
"--conversation-id", "conv-1",
|
||||
@@ -144,7 +170,7 @@ func TestCrossPlatformCoverageChatUpdateTextEmotionRequiredFlags(t *testing.T) {
|
||||
"--emotion-name", "like",
|
||||
"--text", "nice",
|
||||
},
|
||||
wantErr: `required flag(s) "background-id", "msg-id" not set`,
|
||||
wantErr: "missing required flag: --message-id (or --msg-id / --open-message-id)",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
@@ -163,19 +189,41 @@ func TestCrossPlatformCoverageChatUpdateTextEmotionRequiredFlags(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupGetMuteConfig(t *testing.T) {
|
||||
caller := &guardedMutationCaller{}
|
||||
err := executeGuardedMutationCommand(t, caller, newChatCommand,
|
||||
"group", "get-mute-config", "--group", "conv-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get-mute-config returned error: %v", err)
|
||||
}
|
||||
want := guardedMutationCall{
|
||||
productID: "im",
|
||||
toolName: "get_group_mute_config",
|
||||
args: map[string]any{"openConversationId": "conv-1"},
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
|
||||
t.Fatalf("tool calls = %#v, want %#v", caller.calls, want)
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
args []string
|
||||
want guardedMutationCall
|
||||
}{
|
||||
{
|
||||
name: "legacy group alias",
|
||||
args: []string{"group", "get-mute-config", "--group", "conv-1"},
|
||||
want: guardedMutationCall{
|
||||
productID: "im",
|
||||
toolName: "get_group_mute_config",
|
||||
args: map[string]any{"openConversationId": "conv-1"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "canonical conversation id",
|
||||
args: []string{"group", "get-mute-config", "--conversation-id", "conv-2"},
|
||||
want: guardedMutationCall{
|
||||
productID: "im",
|
||||
toolName: "get_group_mute_config",
|
||||
args: map[string]any{"openConversationId": "conv-2"},
|
||||
},
|
||||
},
|
||||
} {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
caller := &guardedMutationCaller{}
|
||||
err := executeGuardedMutationCommand(t, caller, newChatCommand, test.args...)
|
||||
if err != nil {
|
||||
t.Fatalf("get-mute-config returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], test.want) {
|
||||
t.Fatalf("tool calls = %#v, want %#v", caller.calls, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,12 +244,12 @@ func TestCrossPlatformCoverageChatGroupGetMuteConfigRecordsRawArgs(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupGetMuteConfigRequiresGroup(t *testing.T) {
|
||||
func TestCrossPlatformCoverageChatGroupGetMuteConfigRequiresConversationID(t *testing.T) {
|
||||
caller := &guardedMutationCaller{}
|
||||
err := executeGuardedMutationCommand(t, caller, newChatCommand,
|
||||
"group", "get-mute-config")
|
||||
if err == nil || !strings.Contains(err.Error(), "--group") {
|
||||
t.Fatalf("err = %v, want message containing --group", err)
|
||||
if err == nil || !strings.Contains(err.Error(), "--conversation-id") {
|
||||
t.Fatalf("err = %v, want message containing --conversation-id", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("tool calls = %#v, want none", caller.calls)
|
||||
|
||||
@@ -1226,7 +1226,7 @@ func newDriveCommand() *cobra.Command {
|
||||
driveListCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (--versions 模式下必填)")
|
||||
driveListCmd.Flags().String("pattern", "", "按名称通配过滤结果,如 \"*日报*\" (客户端过滤) (可选)")
|
||||
driveListCmd.Flags().Int("depth", 1, "递归列出子目录层级,默认 1(仅当前层),最大 5;与 --cursor/--limit 互斥;与 --workspace 组合时走知识库递归 (可选)")
|
||||
driveListCmd.Flags().Int("latest", 0, "按修改时间取最新 N 个文件(1~50);与 --pattern 组合时表示名称匹配的文件中最新 N 个;可与 --workspace/--depth 组合;与 --order-by/--order/--limit/--cursor 互斥 (可选)")
|
||||
driveListCmd.Flags().Int("latest", 0, "按修改时间取最新 N 个文件(1~50);与 --pattern 组合时表示名称匹配的文件中最新 N 个;可与 --workspace/--depth 组合;与 --order-by/--order/--limit/--cursor 互斥;扫描触发 2000 条上限或途中目录读取失败时报错,不产出不完整的 Top-N (可选)")
|
||||
driveListCmd.Flags().Bool("quiet", false, "关闭递归进度输出(stderr),不影响 stdout JSON (--depth>1 或 --latest 多页扫描时有效) (可选)")
|
||||
driveListCmd.Flags().String("type", "", "按节点类型过滤: file|folder(客户端过滤:全量扫描后筛,钉盘/知识库均可用;与 --versions/--cursor/--order-by/--order/--limit 互斥)(可选)")
|
||||
driveListCmd.Flags().String("start", "", "按修改时间过滤·起始: 相对时间如 24h/7d/2w、RFC3339、YYYY-MM-DD(客户端过滤,互斥同 --type)(可选)")
|
||||
|
||||
@@ -10,11 +10,14 @@ import (
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
@@ -296,6 +299,11 @@ bfs:
|
||||
|
||||
if folderErr != nil {
|
||||
if driveDepthUnrecoverable(folderErr) {
|
||||
if latest > 0 {
|
||||
// 不完整集合上的 Top-N 会被误读为全局最新:latest 下不吐 partial,
|
||||
// 直接回根因错误(auth 过期 / 网络不可达比通用 token 更可操作)。
|
||||
return folderErr
|
||||
}
|
||||
// partial 照吐 stdout,错误详情走 stderr,非零退出
|
||||
errs = append(errs, newDriveDepthError(folder, folderErr))
|
||||
if emitErr := emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth, route, filter); emitErr != nil {
|
||||
@@ -331,17 +339,206 @@ bfs:
|
||||
}
|
||||
}
|
||||
|
||||
if truncated && latest > 0 {
|
||||
return &CLIError{
|
||||
Code: CodeContentTruncated,
|
||||
Message: fmt.Sprintf("LATEST_SCAN_TRUNCATED: 扫描在全局上限 %d 条处截断,未扫描区域可能含更新文件,拒绝输出不完整的 Top-%d", driveDepthMaxItems, latest),
|
||||
Suggestion: fmt.Sprintf("缩小扫描范围后重试:--folder 指定子目录,或降低 --depth 层数,如 dws drive list --folder <子目录ID> --latest %d", latest),
|
||||
}
|
||||
// BFS 序与修改时间无关:截断与递归途中目录失败都让未扫区域可能含更新文件,
|
||||
// 此时的 Top-N 不是全局最新,两者同属一条防线——拒绝以成功状态产出。
|
||||
if latest > 0 && (truncated || len(errs) > 0) {
|
||||
return driveLatestIncompleteError(latest, truncated, errs, driveLatestScopeFromCmd(cmd, maxDepth, rootFolderID))
|
||||
}
|
||||
|
||||
return emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth, route, filter)
|
||||
}
|
||||
|
||||
// driveLatestScope 是原调用的完整候选集快照,用于生成不改变候选集的恢复命令。
|
||||
//
|
||||
// 恢复命令若丢掉任何一项,用户照抄后都会在**另一个集合**上拿到一份「看起来对」的 Top-N ——
|
||||
// 比直接报错更难发现:丢 --workspace/--space-id 会从知识库切到普通钉盘(或反之);丢 --folder
|
||||
// 会从子树跳到空间根;丢 --pattern/--type/--start/--end 会把全部条目纳入排序基。
|
||||
type driveLatestScope struct {
|
||||
// domain 是查询域 flag 串(如 "--workspace ws-1" / "--space-id sp-1"),无则空串。
|
||||
domain string
|
||||
// filters 是决定候选集的过滤 flag 串(--pattern/--type/--start/--end),无则空串。
|
||||
filters string
|
||||
// folder 是原调用实际使用的扫描根(已解析的 ID,非用户原始 URL),空则为空间根。
|
||||
folder string
|
||||
// depth 是原调用的 --depth 层数,让「去掉 --latest 重跑」的恢复命令给出确切层数。
|
||||
depth int
|
||||
// notes 收集无法安全内联进可执行命令的原值展示行。POSIX 构建下恒为空(单引号足够);
|
||||
// Windows 构建下含元字符的值走这里,命令里只留占位符。
|
||||
notes []string
|
||||
// 以上 domain/filters/folder 里的值全部经 driveLatestScopeValue 渲染:恢复命令是给用户
|
||||
// 直接复制到 shell 执行的,而 workspace 的常见形态就是带 & 查询串的 URL,pattern 又天然
|
||||
// 含 * 与中文,裸拼接会改变命令解析。
|
||||
}
|
||||
|
||||
// driveLatestValueRenderer 把用户值渲染成可内联的命令片段;ok 为 false 表示该值在目标 shell
|
||||
// 下无法安全内联。取成参数而非直接调用平台绑定函数,是为了让任一平台的测试都能驱动另一平台
|
||||
// 的降级分支 —— 否则「Windows 上降级为占位符」这条路在 POSIX 机器上永不可达、无法验证。
|
||||
type driveLatestValueRenderer func(string) (string, bool)
|
||||
|
||||
// value 渲染单个用户值供内联。值在目标 shell 下无法安全内联时,登记一条展示行并返回占位符
|
||||
// —— 宁可让用户手动粘一次,也不能给出一条粘贴即执行额外命令的「恢复命令」。
|
||||
// 展示行用 strconv.Quote 包裹并显式声明非可执行,与 internal/auth 侧展示 profile 标识一致。
|
||||
func (s *driveLatestScope) value(render driveLatestValueRenderer, label, v string) string {
|
||||
if inline, ok := render(v); ok {
|
||||
return inline
|
||||
}
|
||||
s.notes = append(s.notes, fmt.Sprintf("%s 原值(仅作数据展示,不是可执行命令)%s", label, strconv.Quote(v)))
|
||||
return driveLatestUnsafeValuePlaceholder
|
||||
}
|
||||
|
||||
// driveLatestUnsafeValuePlaceholder 是不可内联值在命令中的占位符。
|
||||
const driveLatestUnsafeValuePlaceholder = "<见下方原值>"
|
||||
|
||||
// driveLatestScopeFromCmd 从原命令抽完整候选集。--workspace 决定路由(知识库 vs 钉盘),判定与
|
||||
// drive list 里的路由分支同源(同一个 flagOrFallback(cmd, "workspace", "workspace-id"));
|
||||
// 钉盘侧的 --space-id 同样必须保留。目录 flag 名无需按路由切换:--folder 两条路由都接受。
|
||||
//
|
||||
// rootFolder 取 runDriveListDepth 实际使用的扫描根而非重新读 flag:用户可能传的是 URL,
|
||||
// 解析后的 ID 才是真正被扫描的目标,也是照抄时更精确的形态。
|
||||
func driveLatestScopeFromCmd(cmd *cobra.Command, depth int, rootFolder string) driveLatestScope {
|
||||
return driveLatestScopeFrom(cmd, depth, rootFolder, driveLatestScopeValue)
|
||||
}
|
||||
|
||||
// driveLatestScopeFrom 是 driveLatestScopeFromCmd 的可注入本体:render 决定用户值以何种形态
|
||||
// 进入恢复命令。生产路径固定传平台绑定的 driveLatestScopeValue;测试可传另一平台的策略,
|
||||
// 从而在单一平台上覆盖两种形态。
|
||||
func driveLatestScopeFrom(cmd *cobra.Command, depth int, rootFolder string, render driveLatestValueRenderer) driveLatestScope {
|
||||
scope := driveLatestScope{depth: depth}
|
||||
if workspaceID := flagOrFallback(cmd, "workspace", "workspace-id"); workspaceID != "" {
|
||||
scope.domain = "--workspace " + scope.value(render, "--workspace", workspaceID)
|
||||
} else if spaceID, _ := cmd.Flags().GetString("space-id"); spaceID != "" {
|
||||
scope.domain = "--space-id " + scope.value(render, "--space-id", spaceID)
|
||||
}
|
||||
if rootFolder != "" {
|
||||
scope.folder = scope.value(render, "--folder", rootFolder)
|
||||
}
|
||||
// 顺序固定为注册顺序,保证同一组入参每次给出同一条恢复命令(便于用户比对与测试断言)。
|
||||
filters := make([]string, 0, 4)
|
||||
for _, name := range []string{"pattern", "type", "start", "end"} {
|
||||
if v, _ := cmd.Flags().GetString(name); v != "" {
|
||||
filters = append(filters, "--"+name+" "+scope.value(render, "--"+name, v))
|
||||
}
|
||||
}
|
||||
scope.filters = strings.Join(filters, " ")
|
||||
return scope
|
||||
}
|
||||
|
||||
// command 拼一条保留原候选集的恢复命令:查询域 + 指定的 --folder + 原过滤条件。
|
||||
// folderArg 传 "" 表示该条命令不带 --folder(原调用就在空间根时不应凭空塞一个)。
|
||||
func (s driveLatestScope) command(folderArg string) string {
|
||||
parts := make([]string, 0, 4)
|
||||
parts = append(parts, "dws drive list")
|
||||
if s.domain != "" {
|
||||
parts = append(parts, s.domain)
|
||||
}
|
||||
if folderArg != "" {
|
||||
parts = append(parts, "--folder "+folderArg)
|
||||
}
|
||||
if s.filters != "" {
|
||||
parts = append(parts, s.filters)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// driveLatestIncompleteError 是排序基不完整时的拒绝产出错误。截断与目录失败共用
|
||||
// CodeContentTruncated(→ ExitAPI),但 token 分开,便于消费方区分「范围太大」与「读不到」;
|
||||
// 二者同真时两个 token 都带。拒绝产出后 errors[] 不再进 stdout,失败详情必须落在错误消息里,
|
||||
// 否则用户完全瞎。调用点已保证 truncated 与 len(errs)>0 至少一真。
|
||||
func driveLatestIncompleteError(latest int, truncated bool, errs []driveDepthError, scope driveLatestScope) error {
|
||||
// 目录失败详情排在截断之前:BFS 可以先记下可恢复目录错误、再在别的目录撞上 2000 上限,
|
||||
// 此时 permission_denied 这类 reason 是用户唯一能动手修的线索,不能被截断提示吞掉。
|
||||
causes := make([]string, 0, 2)
|
||||
if len(errs) > 0 {
|
||||
causes = append(causes, driveLatestFolderFailureCause(errs))
|
||||
}
|
||||
if truncated {
|
||||
causes = append(causes, fmt.Sprintf("LATEST_SCAN_TRUNCATED: 扫描在全局上限 %d 条处截断", driveDepthMaxItems))
|
||||
}
|
||||
return &CLIError{
|
||||
Code: CodeContentTruncated,
|
||||
Message: fmt.Sprintf("%s,未扫描区域可能含更新文件,拒绝输出不完整的 Top-%d",
|
||||
strings.Join(causes, ";同时 "), latest),
|
||||
Suggestion: driveLatestIncompleteSuggestion(latest, truncated, len(errs) > 0, scope),
|
||||
}
|
||||
}
|
||||
|
||||
// driveLatestFolderFailureCause 组装目录失败详情,含首个失败的 folder/depth/reason。
|
||||
// folderName 空回落 folderID,两者都空回落 <root>。
|
||||
//
|
||||
// folderName / folderID / message 三项都是远端可控内容(目录名由共享目录的创建者决定,
|
||||
// message 是服务端错误文本),必须过 driveLatestSafeRemoteText。Reason 不用过:它是
|
||||
// classifyDriveDepthReason 的固定三值映射,与服务端字符串无关。
|
||||
func driveLatestFolderFailureCause(errs []driveDepthError) string {
|
||||
first := errs[0]
|
||||
folder := first.FolderName
|
||||
if folder == "" {
|
||||
folder = first.FolderID
|
||||
}
|
||||
if folder == "" {
|
||||
folder = "<root>"
|
||||
}
|
||||
return fmt.Sprintf("LATEST_SCAN_INCOMPLETE: %d 个目录未读全(首个失败 folder=%s depth=%d reason=%s: %s)",
|
||||
len(errs), driveLatestSafeRemoteText(folder), first.Depth, first.Reason,
|
||||
driveLatestSafeRemoteText(first.Message))
|
||||
}
|
||||
|
||||
// driveLatestSafeRemoteText 把远端可控文本压成可安全嵌进单行 stderr 错误消息的形式。
|
||||
//
|
||||
// 拒绝产出后 errors[] 不再进 stdout,失败详情改走纯文本错误消息 —— 而 JSON 编码会转义的
|
||||
// 控制字符在纯文本里会被终端直接执行:ANSI/OSC 序列可以伪造提示、清屏、隐藏后续内容、改窗口
|
||||
// 标题,在 AI Agent 场景还会污染上下文窗口。latest=0 的既有路径仍把原值放进 errors[] JSON,
|
||||
// 不受影响,也不该受影响(消费方需要原始数据)。
|
||||
//
|
||||
// output.SanitizeForTerminal 负责剥 ANSI/OSC、C0 控制字符与危险 Unicode,但按设计保留 \n 与
|
||||
// \t;本错误消息是单行叙述,故再把这两者折成空格,避免远端换行把一条错误拆成多行伪造输出。
|
||||
func driveLatestSafeRemoteText(s string) string {
|
||||
s = output.SanitizeForTerminal(s)
|
||||
s = strings.ReplaceAll(s, "\n", " ")
|
||||
s = strings.ReplaceAll(s, "\t", " ")
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// driveLatestIncompleteSuggestion 按实际触发的成因给恢复指引。约束两条:
|
||||
// 1. 每条示例命令都带原查询域(scope.base()),照抄不会切换查询域;
|
||||
// 2. 每个子句的示例命令与该子句正文一致——「去掉 --latest」的子句示例不带 --latest,
|
||||
// 否则照抄复现同一错误。
|
||||
func driveLatestIncompleteSuggestion(latest int, truncated, folderFailed bool, scope driveLatestScope) string {
|
||||
// 「缩小范围」类命令要求用户换一个目录,故 --folder 给占位符;查询域与原过滤条件由
|
||||
// command 一并带上,否则照抄后候选集就变了(例如丢掉 --pattern 会对全部条目取 Top-N)。
|
||||
narrowed := scope.command("<可读子目录ID>")
|
||||
clauses := make([]string, 0, 3)
|
||||
if folderFailed {
|
||||
clauses = append(clauses, "确认目录权限后重试")
|
||||
}
|
||||
switch {
|
||||
case folderFailed && truncated:
|
||||
// 两个成因都要解:既要换到可读目录,也要把范围缩到 2000 条以内。
|
||||
clauses = append(clauses, fmt.Sprintf("或用 --folder 缩小到可读子目录、并降低 --depth 层数后重取 Top-%d:%s --latest %d", latest, narrowed, latest))
|
||||
case folderFailed:
|
||||
clauses = append(clauses, fmt.Sprintf("或用 --folder 缩小到可读子目录后重取 Top-%d:%s --latest %d", latest, narrowed, latest))
|
||||
default:
|
||||
clauses = append(clauses, fmt.Sprintf("缩小扫描范围后重试:--folder 指定子目录,或降低 --depth 层数,如 %s --latest %d", narrowed, latest))
|
||||
}
|
||||
// partial+errors[] 承诺限定 --depth>1:单层去掉 --latest 会路由回普通单层 list,本就无
|
||||
// errors[] 契约,故该子句只在多层时给出,并直接带上原层数。这是唯一一条「按原范围」命令,
|
||||
// 必须原样带回原 --folder(原调用在空间根时则不带),照抄即复现同一候选集、只是不取 Top-N。
|
||||
if folderFailed && scope.depth > 1 {
|
||||
clauses = append(clauses, fmt.Sprintf("需要看失败明细请去掉 --latest 按原范围重跑(同时输出已扫到的 partial 与 errors[] 明细):%s --depth %d", scope.command(scope.folder), scope.depth))
|
||||
}
|
||||
// Windows 构建下无法安全引用的值不会进入命令(cmd.exe 不把单引号当引号),原值改在此处以
|
||||
// 数据行给出,由用户手动替换占位符 —— 少一次复制粘贴的便利,换掉一条粘贴即执行的命令。
|
||||
if len(scope.notes) > 0 {
|
||||
clauses = append(clauses, fmt.Sprintf("命令中的 %s 请手动替换为 —— %s",
|
||||
driveLatestUnsafeValuePlaceholder, strings.Join(scope.notes, "、")))
|
||||
}
|
||||
return strings.Join(clauses, ";")
|
||||
}
|
||||
|
||||
// emitDriveDepthCancelled 处理 SIGINT 取消:吐已扫到的 partial(truncated=true)后回退出码 130。
|
||||
//
|
||||
// 这里刻意**不**套用 latest 的拒绝产出防线(只有 BFS 尾部 guard 与 unrecoverable 分支走):
|
||||
// 取消是用户主动发起的,退出码 130 本身已明确告知结果不完整,此时 partial 是用户的预期产物而
|
||||
// 非冒充全局最新的误导。sortTime 仍由 emitDriveDepthResult 统一剥除,取消路径不例外。
|
||||
func emitDriveDepthCancelled(items []map[string]any, errs []driveDepthError, pattern string, latest, reqDepth int, route driveDepthRoute, filter driveListFilter) error {
|
||||
if err := emitDriveDepthResult(items, errs, true, pattern, latest, reqDepth, route, filter); err != nil {
|
||||
return err
|
||||
@@ -389,6 +586,14 @@ func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, trunca
|
||||
maxDepth = d
|
||||
}
|
||||
}
|
||||
// sortTime 是内部排序字段(applyDriveListLatest 排 Top-N、applyDriveListFilter 筛时间区间
|
||||
// 时都已用完),任何输出路径都不得泄露进契约。放在这里一处覆盖三条 emit 路径:正常 emit、
|
||||
// SIGINT 取消、unrecoverable partial。
|
||||
// depth/parentId/rel_path 不在此处删——它们是 depth>1 的既有输出契约,
|
||||
// stripDriveDepthDecorations 仅在单层(reqDepth==1)把这套装饰整体剥掉。
|
||||
for _, item := range items {
|
||||
delete(item, "sortTime")
|
||||
}
|
||||
if (latest > 0 || filter.active()) && reqDepth == 1 {
|
||||
stripDriveDepthDecorations(items)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,731 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// 本文件锁定 drive list --latest 的两个 P1 行为,独立于 pr868_*_test.go / drive_depth_test.go:
|
||||
//
|
||||
// P1-a:sortTime 是内部排序字段,任何输出路径都不得泄露进契约;
|
||||
// P1-b:递归途中目录读取失败时,Top-N 建立在不完整集合上,必须拒绝产出而非吐 partial。
|
||||
//
|
||||
// 改代码前这些断言对 origin/main 应为红:main 采集端无条件写 sortTime、emit 仅在单层
|
||||
// latest/filter 才剥;main 尾部拒绝 guard 只拦截断、不拦目录失败。
|
||||
//
|
||||
// 另锁定评审反馈的三个边界:
|
||||
//
|
||||
// 截断与目录失败同真时,失败详情(permission_denied 等)不得被截断提示吞掉;
|
||||
// 拒绝产出时给的恢复命令必须保留原查询域(--workspace / --space-id),照抄不会切换查询域;
|
||||
// 恢复命令里的用户值必须过 argv 引用,URL 查询串与 shell 元字符都不能改变命令解析。
|
||||
//
|
||||
// 关于 TestCrossPlatformCoverage 前缀:这是门禁约定,不是命名风格。平台覆盖率门禁
|
||||
// scripts/policy/run-platform-coverage-gate.sh 只跑
|
||||
// -run '^(TestAllShortcuts|TestCrossPlatformCoverage)',本包新增的生产代码若没有带该前缀的
|
||||
// 测试覆盖,Coverage (macOS) / (Windows) 会以「changed code coverage 低于 100%」失败
|
||||
// (本 PR 改名前实测 69.0476%,84 条可执行语句)。改名务必保留前缀。
|
||||
|
||||
// assertNoSortTime 断言 stdout 每个 item 都不含内部排序字段 sortTime。
|
||||
func assertNoSortTime(t *testing.T, result map[string]any) {
|
||||
t.Helper()
|
||||
items, ok := result["items"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("items missing or wrong type: %#v", result["items"])
|
||||
}
|
||||
for i, raw := range items {
|
||||
item, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("item[%d] not an object: %#v", i, raw)
|
||||
}
|
||||
if _, leaked := item["sortTime"]; leaked {
|
||||
t.Fatalf("item[%d] leaked internal sortTime into output contract: %#v", i, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestNoSortTimeLeak 覆盖 main 的覆盖漏洞:main 的
|
||||
// TestCrossPlatformCoverageDriveDepthLatestTruncatedAndSortTime 名字带 SortTime,
|
||||
// 却只断言 TRUNCATED、从不检查输出无 sortTime。这里把三条泄露路径都钉住。
|
||||
func TestCrossPlatformCoverageDriveLatestNoSortTimeLeak(t *testing.T) {
|
||||
twoFiles := `{"items":[{"fileId":"f1","name":"a.txt","type":"FILE","modifiedTime":1000},{"fileId":"f2","name":"b.txt","type":"FILE","modifiedTime":2000}]}`
|
||||
|
||||
// 场景 A:depth>1 --latest —— 走 applyDriveListLatest(只读 sortTime、不剥),reqDepth>1 不触发 strip。
|
||||
t.Run("depth_latest", func(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: twoFiles}}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 5, driveListFilter{}); err != nil {
|
||||
t.Fatalf("runDriveListDepth: %v", err)
|
||||
}
|
||||
assertNoSortTime(t, decodeDepthResult(t, out))
|
||||
})
|
||||
|
||||
// 场景 B:--depth 2 无 latest 无 filter —— 走 else 分支树序排序,同样不触发 strip。
|
||||
t.Run("depth_no_latest", func(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: twoFiles}}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 2, "", true, 0, driveListFilter{}); err != nil {
|
||||
t.Fatalf("runDriveListDepth: %v", err)
|
||||
}
|
||||
assertNoSortTime(t, decodeDepthResult(t, out))
|
||||
})
|
||||
|
||||
// 场景 C:--depth 2 --type file —— #971 引入的 filter 也读 sortTime(applyDriveListFilter),
|
||||
// strip 条件 (latest>0||filter.active()) && reqDepth==1 在多层下依旧不成立,泄露面因此变大。
|
||||
t.Run("depth_filter_no_latest", func(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: twoFiles}}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 2, "", true, 0, driveListFilter{nodeType: "file"}); err != nil {
|
||||
t.Fatalf("runDriveListDepth: %v", err)
|
||||
}
|
||||
result := decodeDepthResult(t, out)
|
||||
if len(result["items"].([]any)) != 2 {
|
||||
t.Fatalf("filter 应保留两个 FILE: %#v", result["items"])
|
||||
}
|
||||
assertNoSortTime(t, result)
|
||||
})
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestSigintStillEmitsPartialWithoutSortTime 同时承担两件事:
|
||||
// 1. P1-a 的第四条路径 —— SIGINT 取消也走 emitDriveDepthResult,同样不得泄露 sortTime;
|
||||
// 2. SIGINT 契约锁 —— 本次 P1-b 只在 BFS 尾部 guard 与 unrecoverable 分支生效,**不改**
|
||||
// 取消路径:SIGINT 是用户主动中断、退出码 130 已明确告知不完整,partial 是明确预期。
|
||||
// 这条测试防止后续误把 fail-closed 扩到取消路径,也为外部评测用例
|
||||
// test_sigint_exits_130_with_partial 提供本地对照。
|
||||
func TestCrossPlatformCoverageDriveLatestSigintStillEmitsPartialWithoutSortTime(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
out := installDepthCaller(t, caller)
|
||||
items := []map[string]any{
|
||||
{"fileId": "f1", "name": "a.txt", "type": "FILE", "sortTime": int64(2000), "rel_path": "a.txt", "depth": 2},
|
||||
}
|
||||
errs := []driveDepthError{
|
||||
{Depth: 1, FolderID: "fid-1", FolderName: "半路目录", Reason: "permission_denied", Message: "denied"},
|
||||
}
|
||||
// latest>0 且 reqDepth>1:main 在此不 strip,sortTime 泄露。
|
||||
err := emitDriveDepthCancelled(items, errs, "", 5, 3, newDrivePanDepthRoute(), driveListFilter{})
|
||||
var cancelErr *driveDepthCancelledError
|
||||
if !errors.As(err, &cancelErr) {
|
||||
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
|
||||
}
|
||||
if cancelErr.ExitCode() != 130 {
|
||||
t.Fatalf("exit code = %d, want 130", cancelErr.ExitCode())
|
||||
}
|
||||
result := decodeDepthResult(t, out)
|
||||
// partial 契约不变:items 与 errors 都照吐,truncated 标记为真。
|
||||
if len(result["items"].([]any)) != 1 {
|
||||
t.Fatalf("取消路径应保留 partial items: %#v", result["items"])
|
||||
}
|
||||
if len(result["errors"].([]any)) != 1 {
|
||||
t.Fatalf("取消路径应保留 errors[]: %#v", result["errors"])
|
||||
}
|
||||
if result["truncated"] != true {
|
||||
t.Fatalf("取消结果 truncated = %#v, want true", result["truncated"])
|
||||
}
|
||||
assertNoSortTime(t, result)
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestRefusesOnFolderFailure 覆盖 P1-b:递归途中一个可恢复目录失败(403/business,
|
||||
// 非 auth 非限流 → 记 errs[] 跳过),Top-N 落在不完整集合上,必须拒绝产出。
|
||||
// 构造:根目录成功产出 FOLDER+FILE(collected>0 且 dirA 入队),子目录返回 forbidden.* →
|
||||
// recoverable → errs=[1]。旧代码尾部 `if truncated && latest>0` 不触发 → emit 吐 partial(err=nil);
|
||||
// 新代码 `len(errs)>0` → LATEST_SCAN_INCOMPLETE 且 stdout 无 items。
|
||||
func TestCrossPlatformCoverageDriveLatestRefusesOnFolderFailure(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[{"fileId":"dirA","name":"dirA","type":"FOLDER"},{"fileId":"fX","name":"x.txt","type":"FILE","modifiedTime":1000}]}`},
|
||||
{text: `{"errorCode":"forbidden.noPermission","errorMsg":"denied"}`},
|
||||
}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 5, driveListFilter{})
|
||||
if err == nil || !strings.Contains(err.Error(), "LATEST_SCAN_INCOMPLETE") {
|
||||
t.Fatalf("err = %v, want LATEST_SCAN_INCOMPLETE", err)
|
||||
}
|
||||
// 拒绝产出:stdout 必须没有 items(不是 partial)。旧代码此处会吐 partial,断言随之失败。
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("expected no stdout on refusal, got: %s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestRefusesOnTruncationWithFolderFailure 端到端证明「截断 + 目录失败」组合确实可达:
|
||||
// 根目录出 dirA/dirB 两个子目录 → dirA 权限失败记 errs[] → dirB 返回 2000 条触发全局截断,
|
||||
// 尾部 guard 拿到 truncated=true 且 len(errs)=1。旧实现在此让 truncated 短路,permission_denied
|
||||
// 详情整块丢失(评审反馈的阻塞点);现在两个 token 与失败详情都必须在。
|
||||
func TestCrossPlatformCoverageDriveLatestRefusesOnTruncationWithFolderFailure(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
var bulk strings.Builder
|
||||
bulk.WriteString(`{"items":[`)
|
||||
for i := 0; i < driveDepthMaxItems; i++ {
|
||||
if i > 0 {
|
||||
bulk.WriteString(",")
|
||||
}
|
||||
fmt.Fprintf(&bulk, `{"fileId":"f%d","name":"file-%d.txt","type":"FILE","modifiedTime":%d}`, i, i, 1000+i)
|
||||
}
|
||||
bulk.WriteString(`]}`)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[{"fileId":"dirA","name":"报表","type":"FOLDER"},{"fileId":"dirB","name":"dirB","type":"FOLDER"}]}`},
|
||||
{text: `{"errorCode":"forbidden.noPermission","errorMsg":"denied"}`}, // dirA:可恢复 → 记 errs[]
|
||||
{text: bulk.String()}, // dirB:撞 2000 上限 → truncated
|
||||
}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 5, driveListFilter{})
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) || cliErr.Code != CodeContentTruncated {
|
||||
t.Fatalf("err = %T %v, want CodeContentTruncated", err, err)
|
||||
}
|
||||
msg := cliErr.Message
|
||||
if !strings.Contains(msg, "LATEST_SCAN_TRUNCATED") {
|
||||
t.Fatalf("组合场景缺 TRUNCATED token: %q", msg)
|
||||
}
|
||||
// 旧实现在这里丢掉整段目录失败详情。
|
||||
if !strings.Contains(msg, "LATEST_SCAN_INCOMPLETE") ||
|
||||
!strings.Contains(msg, "folder=报表") ||
|
||||
!strings.Contains(msg, "permission_denied") {
|
||||
t.Fatalf("组合场景丢失目录失败详情: %q", msg)
|
||||
}
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("expected no stdout on refusal, got: %s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestScopeWiredFromCommand 端到端验证查询域从原命令一路带到恢复命令:单测构造器
|
||||
// 拿不到的是 runDriveListDepth 里的接线(driveLatestScopeFromCmd(cmd, maxDepth, rootFolderID)),这里补上。
|
||||
func TestCrossPlatformCoverageDriveLatestScopeWiredFromCommand(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[{"fileId":"dirA","name":"dirA","type":"FOLDER"},{"fileId":"fX","name":"x.txt","type":"FILE","modifiedTime":1000}]}`},
|
||||
{text: `{"errorCode":"forbidden.noPermission","errorMsg":"denied"}`},
|
||||
}}
|
||||
installDepthCaller(t, caller)
|
||||
cmd := newDriveListScopeCmd(t, map[string]string{"space-id": "sp-7"})
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{"spaceId": "sp-7"}, "", 4, "", true, 5, driveListFilter{})
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) {
|
||||
t.Fatalf("err = %T %v", err, err)
|
||||
}
|
||||
// 恢复命令必须带原 --space-id,且给出原 --depth 层数。
|
||||
assertDriveLatestSuggestion(t, cliErr.Suggestion, "--space-id sp-7")
|
||||
if !strings.Contains(cliErr.Suggestion, "--depth 4") {
|
||||
t.Fatalf("恢复命令应保留原层数: %q", cliErr.Suggestion)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestRefusesOnUnrecoverableFailure 覆盖 P1-b 的另一半:递归途中遇不可恢复错误
|
||||
// (auth 过期 / 网络不可达)且 latest>0 时,不吐 partial,直接回根因错误。
|
||||
// 与 latest=0 的既有行为(TestCrossPlatformCoverageRunDriveListDepthUnrecoverable:partial
|
||||
// + errors[] 进 stdout 后非零退出)对照——latest 下 partial 的 Top-N 会被误读为全局最新,
|
||||
// 故必须拒绝产出;回根因错误而非 INCOMPLETE token,因为 auth/网络比通用截断提示更可操作。
|
||||
func TestCrossPlatformCoverageDriveLatestRefusesOnUnrecoverableFailure(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[{"fileId":"dirA","name":"dirA","type":"FOLDER"},{"fileId":"fX","name":"x.txt","type":"FILE","modifiedTime":1000}]}`},
|
||||
{text: `{"errorCode":"DWS_SERVICE_UNAUTHORIZED"}`},
|
||||
}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 5, driveListFilter{})
|
||||
// 回根因错误:Code 仍是 auth 过期,不被包装成 LATEST_SCAN_INCOMPLETE。
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) || cliErr.Code != CodeAuthTokenExpired {
|
||||
t.Fatalf("err = %T %v, want CodeAuthTokenExpired", err, err)
|
||||
}
|
||||
if strings.Contains(cliErr.Message, "LATEST_SCAN_INCOMPLETE") {
|
||||
t.Fatalf("unrecoverable 应回根因错误而非 INCOMPLETE 包装: %q", cliErr.Message)
|
||||
}
|
||||
// 拒绝产出:不吐 partial(对照 latest=0 时会输出 2 条 items + 1 条 errors)。
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("expected no stdout on refusal, got: %s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestIncompleteErrorBranches 直接单测构造器的各条分支。
|
||||
func TestCrossPlatformCoverageDriveLatestIncompleteErrorBranches(t *testing.T) {
|
||||
// 纯截断分支:errs 为空 → 只有 TRUNCATED。
|
||||
t.Run("truncated_only", func(t *testing.T) {
|
||||
err := driveLatestIncompleteError(5, true, nil, driveLatestScope{depth: 3})
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) || cliErr.Code != CodeContentTruncated {
|
||||
t.Fatalf("err = %T %v, want CodeContentTruncated", err, err)
|
||||
}
|
||||
if !strings.Contains(cliErr.Message, "LATEST_SCAN_TRUNCATED") {
|
||||
t.Fatalf("message = %q", cliErr.Message)
|
||||
}
|
||||
if strings.Contains(cliErr.Message, "LATEST_SCAN_INCOMPLETE") {
|
||||
t.Fatalf("无目录失败时不应出现 INCOMPLETE: %q", cliErr.Message)
|
||||
}
|
||||
assertDriveLatestSuggestion(t, cliErr.Suggestion, "")
|
||||
})
|
||||
|
||||
// 纯目录失败分支:未截断 → 只有 INCOMPLETE,Message 含首个失败的 folder/depth/reason。
|
||||
t.Run("folder_failure_only", func(t *testing.T) {
|
||||
err := driveLatestIncompleteError(3, false, twoDriveDepthErrors(), driveLatestScope{depth: 3})
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) || cliErr.Code != CodeContentTruncated {
|
||||
t.Fatalf("err = %T %v, want CodeContentTruncated", err, err)
|
||||
}
|
||||
msg := cliErr.Message
|
||||
if !strings.Contains(msg, "LATEST_SCAN_INCOMPLETE") ||
|
||||
!strings.Contains(msg, "报表") ||
|
||||
!strings.Contains(msg, "depth=2") ||
|
||||
!strings.Contains(msg, "permission_denied") ||
|
||||
!strings.Contains(msg, "2 个目录未读全") {
|
||||
t.Fatalf("message = %q", msg)
|
||||
}
|
||||
if strings.Contains(msg, "LATEST_SCAN_TRUNCATED") {
|
||||
t.Fatalf("未截断时不应出现 TRUNCATED: %q", msg)
|
||||
}
|
||||
assertDriveLatestSuggestion(t, cliErr.Suggestion, "")
|
||||
})
|
||||
|
||||
// 组合分支(评审反馈的阻塞点):截断与目录失败同真时,旧实现让 truncated 短路、
|
||||
// permission_denied 这类目录失败详情整块丢失。现在两个 token 都必须在,且失败详情不得被吞。
|
||||
t.Run("truncated_with_folder_failures", func(t *testing.T) {
|
||||
err := driveLatestIncompleteError(4, true, twoDriveDepthErrors(), driveLatestScope{depth: 3})
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) || cliErr.Code != CodeContentTruncated {
|
||||
t.Fatalf("err = %T %v, want CodeContentTruncated", err, err)
|
||||
}
|
||||
msg := cliErr.Message
|
||||
// 两个成因都要可被消费方 token 匹配到。
|
||||
if !strings.Contains(msg, "LATEST_SCAN_INCOMPLETE") || !strings.Contains(msg, "LATEST_SCAN_TRUNCATED") {
|
||||
t.Fatalf("组合场景需同时带两个 token: %q", msg)
|
||||
}
|
||||
// 目录失败详情必须完整保留——这是用户唯一能动手修的线索。
|
||||
if !strings.Contains(msg, "报表") ||
|
||||
!strings.Contains(msg, "depth=2") ||
|
||||
!strings.Contains(msg, "permission_denied") ||
|
||||
!strings.Contains(msg, "2 个目录未读全") {
|
||||
t.Fatalf("组合场景丢失目录失败详情: %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "拒绝输出不完整的 Top-4") {
|
||||
t.Fatalf("message 缺少拒绝产出结论: %q", msg)
|
||||
}
|
||||
assertDriveLatestSuggestion(t, cliErr.Suggestion, "")
|
||||
// 组合场景的指引要同时覆盖两条补救:换可读目录 + 降层数。
|
||||
if !strings.Contains(cliErr.Suggestion, "确认目录权限") || !strings.Contains(cliErr.Suggestion, "--depth") {
|
||||
t.Fatalf("组合场景 suggestion 需同时给出权限与降层数补救: %q", cliErr.Suggestion)
|
||||
}
|
||||
})
|
||||
|
||||
// folderName 为空回落 folderID;folderID 也空回落 <root>。
|
||||
t.Run("folder_fallback", func(t *testing.T) {
|
||||
byID := driveLatestIncompleteError(1, false, []driveDepthError{{FolderID: "fid-x"}}, driveLatestScope{depth: 2})
|
||||
if !strings.Contains(byID.Error(), "folder=fid-x") {
|
||||
t.Fatalf("fallback to folderID: %v", byID)
|
||||
}
|
||||
byRoot := driveLatestIncompleteError(1, false, []driveDepthError{{}}, driveLatestScope{depth: 2})
|
||||
if !strings.Contains(byRoot.Error(), "folder=<root>") {
|
||||
t.Fatalf("fallback to <root>: %v", byRoot)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// twoDriveDepthErrors 是两条目录失败样本,首条用于断言「首个失败」详情。
|
||||
func twoDriveDepthErrors() []driveDepthError {
|
||||
return []driveDepthError{
|
||||
{Depth: 2, FolderID: "fid-9", FolderName: "报表", Reason: "permission_denied", Message: "denied"},
|
||||
{Depth: 1, FolderID: "fid-3", FolderName: "归档", Reason: "api_error", Message: "boom"},
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestScopePreservedInSuggestion 覆盖评审反馈的第二个阻塞点:恢复命令此前固定
|
||||
// 生成 `dws drive list --folder ...`,把原调用的 --workspace / --space-id 丢掉。用户照抄后
|
||||
// 会从知识库切到普通钉盘(或从指定钉盘空间切到「我的文件」),在另一个查询域里拿到一份
|
||||
// 「看起来对」的 Top-N —— 比直接报错更难发现。
|
||||
func TestCrossPlatformCoverageDriveLatestScopePreservedInSuggestion(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
flags map[string]string
|
||||
want string
|
||||
}{
|
||||
// 知识库路由:--workspace 决定路由,丢了就切到普通钉盘。
|
||||
{name: "workspace", flags: map[string]string{"workspace": "ws-1"}, want: "--workspace ws-1"},
|
||||
// 别名路径:--workspace-id 与 --workspace 同源(flagOrFallback)。
|
||||
{name: "workspace_id_alias", flags: map[string]string{"workspace-id": "ws-alias"}, want: "--workspace ws-alias"},
|
||||
// 钉盘路由:--space-id 丢了就退回「我的文件」。
|
||||
{name: "space_id", flags: map[string]string{"space-id": "sp-7"}, want: "--space-id sp-7"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
scope := driveLatestScopeFromCmd(newDriveListScopeCmd(t, tc.flags), 3, "")
|
||||
// 三条成因组合下的恢复命令都必须带原查询域。
|
||||
for _, variant := range []struct {
|
||||
label string
|
||||
truncated bool
|
||||
errs []driveDepthError
|
||||
}{
|
||||
{"truncated_only", true, nil},
|
||||
{"folder_failure_only", false, twoDriveDepthErrors()},
|
||||
{"both", true, twoDriveDepthErrors()},
|
||||
} {
|
||||
err := driveLatestIncompleteError(5, variant.truncated, variant.errs, scope)
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) {
|
||||
t.Fatalf("%s: err = %T %v", variant.label, err, err)
|
||||
}
|
||||
assertDriveLatestSuggestion(t, cliErr.Suggestion, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --workspace 优先于 --space-id:与 drive list 的路由判定同序(先看 workspace 再看 space-id),
|
||||
// 否则恢复命令会把知识库查询写成钉盘查询。
|
||||
t.Run("workspace_wins_over_space_id", func(t *testing.T) {
|
||||
scope := driveLatestScopeFromCmd(newDriveListScopeCmd(t, map[string]string{
|
||||
"workspace": "ws-1", "space-id": "sp-7",
|
||||
}), 3, "")
|
||||
err := driveLatestIncompleteError(5, false, twoDriveDepthErrors(), scope)
|
||||
suggestion := err.(*CLIError).Suggestion
|
||||
if !strings.Contains(suggestion, "--workspace ws-1") || strings.Contains(suggestion, "--space-id") {
|
||||
t.Fatalf("workspace 应优先且不混入 space-id: %q", suggestion)
|
||||
}
|
||||
})
|
||||
|
||||
// 无查询域时不得凭空造 flag(原调用就是「我的文件」根,硬塞 scope 同样是改查询域)。
|
||||
t.Run("no_scope_adds_nothing", func(t *testing.T) {
|
||||
scope := driveLatestScopeFromCmd(newDriveListScopeCmd(t, nil), 3, "")
|
||||
err := driveLatestIncompleteError(5, false, twoDriveDepthErrors(), scope)
|
||||
suggestion := err.(*CLIError).Suggestion
|
||||
if strings.Contains(suggestion, "--workspace") || strings.Contains(suggestion, "--space-id") {
|
||||
t.Fatalf("无 scope 时不应凭空造查询域 flag: %q", suggestion)
|
||||
}
|
||||
assertDriveLatestSuggestion(t, suggestion, "")
|
||||
})
|
||||
|
||||
// depth==1(知识库 --latest 单层)时不给 --depth 1:partial+errors[] 契约只在多层成立,
|
||||
// 硬塞 --depth 1 会让「去掉 --latest 看明细」的子句自相矛盾。
|
||||
t.Run("single_depth_omits_depth_flag", func(t *testing.T) {
|
||||
err := driveLatestIncompleteError(5, false, twoDriveDepthErrors(), driveLatestScope{domain: "--workspace ws-1", depth: 1})
|
||||
suggestion := err.(*CLIError).Suggestion
|
||||
if strings.Contains(suggestion, "--depth") {
|
||||
t.Fatalf("单层不应出现 --depth: %q", suggestion)
|
||||
}
|
||||
})
|
||||
|
||||
// 多层时给出确切层数,用户无需把 <原层数> 换成数字。
|
||||
t.Run("multi_depth_emits_actual_depth", func(t *testing.T) {
|
||||
err := driveLatestIncompleteError(5, false, twoDriveDepthErrors(), driveLatestScope{domain: "--space-id sp-7", depth: 4})
|
||||
suggestion := err.(*CLIError).Suggestion
|
||||
if !strings.Contains(suggestion, "--depth 4") {
|
||||
t.Fatalf("应给出原层数 --depth 4: %q", suggestion)
|
||||
}
|
||||
if strings.Contains(suggestion, "<原层数>") {
|
||||
t.Fatalf("不应残留占位符: %q", suggestion)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestScopeQuotesHostileValues 覆盖评审的 P1 阻断项:恢复命令是给用户直接复制到
|
||||
// shell 里执行的,查询域的值来自用户输入(workspace 常见形态就是带查询串的 URL)。未引用时
|
||||
// 一个 `&` 就把命令拆成后台任务,`;` / `$()` 更能执行额外内容。
|
||||
// 断言落在「值被完整包在单引号里」而不只是「出现过」——后者对裸拼接也成立,抓不住缺陷。
|
||||
func TestCrossPlatformCoverageDriveLatestScopeQuotesHostileValues(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
flag string
|
||||
value string
|
||||
want string
|
||||
}{
|
||||
// 合法 workspace URL:& 在裸拼接下直接改变 shell 解析(前半段被丢进后台)。
|
||||
{name: "workspace_url", flag: "workspace", value: "https://alidocs.dingtalk.com/i/nodes/abc?spaceId=1&type=doc",
|
||||
want: `--workspace 'https://alidocs.dingtalk.com/i/nodes/abc?spaceId=1&type=doc'`},
|
||||
// 命令替换:裸拼接会在用户复制执行时真的跑起来。
|
||||
{name: "command_substitution", flag: "workspace", value: "$(id)", want: `--workspace '$(id)'`},
|
||||
// 分号拆语句。
|
||||
{name: "semicolon", flag: "space-id", value: "sp-7;id", want: `--space-id 'sp-7;id'`},
|
||||
// 空格拆参:裸拼接会让 --folder 收到错误的值。
|
||||
{name: "space", flag: "space-id", value: "sp 7", want: `--space-id 'sp 7'`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// 显式注入 POSIX 策略:本测试断言的是「危险值被单引号正确包住」这一 POSIX 形态。
|
||||
// Windows 策略下这些值根本不内联(见 ...SuggestionNeverInlinesHostileValue),
|
||||
// 若走平台绑定,这里在 Windows runner 上会因形态不同而误报。
|
||||
scope := driveLatestScopeFrom(newDriveListScopeCmd(t, map[string]string{tc.flag: tc.value}), 3, "", driveLatestPosixScopeValue)
|
||||
err := driveLatestIncompleteError(5, true, twoDriveDepthErrors(), scope)
|
||||
suggestion := err.(*CLIError).Suggestion
|
||||
if !strings.Contains(suggestion, tc.want) {
|
||||
t.Fatalf("查询域未安全引用\n want substring: %s\n got suggestion: %s", tc.want, suggestion)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestScopePreservesFiltersAndFolder 覆盖自动 CR 的 P2:
|
||||
// --pattern/--type/--start/--end 与 --folder 同样决定 --latest 的候选集合。恢复命令丢掉任一项,
|
||||
// 用户照抄后就是在**另一个集合**上取 Top-N —— 原调用带 --pattern 时,缺了它的重试命令会对全部
|
||||
// 条目排序,结果「看起来成功」却答非所问,比直接报错更难发现。
|
||||
func TestCrossPlatformCoverageDriveLatestScopePreservesFiltersAndFolder(t *testing.T) {
|
||||
cmd := newDriveListScopeCmd(t, map[string]string{
|
||||
"workspace": "ws-1",
|
||||
"pattern": "*日报*",
|
||||
"type": "file",
|
||||
"start": "7d",
|
||||
"end": "2026-08-01",
|
||||
})
|
||||
// 注入 POSIX 策略:`--pattern '*日报*'` 这种引用形态是 POSIX 专属,Windows 下该值会降级为
|
||||
// 占位符 + 展示行(见 ...SuggestionNeverInlinesHostileValue)。走平台绑定会在 Windows 误报。
|
||||
scope := driveLatestScopeFrom(cmd, 4, "folder-root", driveLatestPosixScopeValue)
|
||||
err := driveLatestIncompleteError(5, false, twoDriveDepthErrors(), scope)
|
||||
suggestion := err.(*CLIError).Suggestion
|
||||
|
||||
// 每条示例命令都要带齐查询域与全部过滤条件;pattern 含 * 与中文,必须是引用后的形态。
|
||||
for _, want := range []string{
|
||||
"--workspace ws-1",
|
||||
"--pattern '*日报*'",
|
||||
"--type file",
|
||||
"--start 7d",
|
||||
"--end 2026-08-01",
|
||||
} {
|
||||
for _, clause := range strings.Split(suggestion, ";") {
|
||||
cmdText := extractTrailingDwsCommand(clause)
|
||||
if cmdText == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(cmdText, want) {
|
||||
t.Fatalf("恢复命令丢失候选集条件 %q:\n clause: %s", want, cmdText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 「去掉 --latest 按原范围重跑」是唯一的原范围命令,必须原样带回原 --folder 而非占位符,
|
||||
// 否则它就不是「原范围」。
|
||||
var origin string
|
||||
for _, clause := range strings.Split(suggestion, ";") {
|
||||
if strings.Contains(clause, "去掉 --latest") {
|
||||
origin = extractTrailingDwsCommand(clause)
|
||||
}
|
||||
}
|
||||
if origin == "" {
|
||||
t.Fatalf("多层场景应给出「去掉 --latest 按原范围重跑」子句: %q", suggestion)
|
||||
}
|
||||
if !strings.Contains(origin, "--folder folder-root") {
|
||||
t.Fatalf("原范围命令应保留原 --folder: %q", origin)
|
||||
}
|
||||
if strings.Contains(origin, "<可读子目录ID>") {
|
||||
t.Fatalf("原范围命令不应把原 --folder 换成占位符: %q", origin)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestScopeOmitsFolderAtSpaceRoot 原调用就在空间根时不得凭空
|
||||
// 造 --folder:硬塞一个目录同样是改候选集。
|
||||
func TestCrossPlatformCoverageDriveLatestScopeOmitsFolderAtSpaceRoot(t *testing.T) {
|
||||
scope := driveLatestScopeFromCmd(newDriveListScopeCmd(t, map[string]string{"space-id": "sp-7"}), 3, "")
|
||||
err := driveLatestIncompleteError(2, false, twoDriveDepthErrors(), scope)
|
||||
suggestion := err.(*CLIError).Suggestion
|
||||
for _, clause := range strings.Split(suggestion, ";") {
|
||||
if !strings.Contains(clause, "去掉 --latest") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(extractTrailingDwsCommand(clause), "--folder") {
|
||||
t.Fatalf("空间根扫描的原范围命令不应带 --folder: %q", clause)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestErrorStripsRemoteControlChars 覆盖自动 CR 的 P2 安全项:
|
||||
// 拒绝产出后,目录名与服务端错误文本从 JSON(编码时会被转义)挪进了纯文本 stderr。若原样透传,
|
||||
// 其中的 ANSI/OSC 序列会被终端直接执行 —— 可清屏、伪造彩色「成功」、隐藏后续输出、改窗口标题,
|
||||
// 在 AI Agent 场景还会污染上下文窗口。目录名对共享目录而言是他人可控输入。
|
||||
func TestCrossPlatformCoverageDriveLatestErrorStripsRemoteControlChars(t *testing.T) {
|
||||
assertNoControlChars := func(t *testing.T, msg string) {
|
||||
t.Helper()
|
||||
for name, r := range map[string]rune{"ESC": 0x1b, "BEL": 0x07, "CR": '\r', "LF": '\n', "TAB": '\t'} {
|
||||
if strings.ContainsRune(msg, r) {
|
||||
t.Fatalf("错误消息残留 %s 控制字符: %q", name, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// folderName 路径:CSI 清屏 + 变色,外加 OSC 改标题。
|
||||
t.Run("folder_name", func(t *testing.T) {
|
||||
err := driveLatestIncompleteError(3, false, []driveDepthError{{
|
||||
Depth: 2,
|
||||
FolderName: "报表\x1b[2J\x1b[31m看起来成功\x1b[0m",
|
||||
Reason: "permission_denied",
|
||||
Message: "denied\r\n\x1b]0;pwned\x07次行",
|
||||
}}, driveLatestScope{depth: 2})
|
||||
msg := err.(*CLIError).Message
|
||||
assertNoControlChars(t, msg)
|
||||
// 清理不能把诊断信息一起抹掉:可读部分必须留下。
|
||||
if !strings.Contains(msg, "报表") || !strings.Contains(msg, "denied") || !strings.Contains(msg, "次行") {
|
||||
t.Fatalf("清理后应保留可读文本: %q", msg)
|
||||
}
|
||||
})
|
||||
|
||||
// folderName 为空时回落到 folderID,该字段同样来自服务端。
|
||||
t.Run("folder_id_fallback", func(t *testing.T) {
|
||||
err := driveLatestIncompleteError(1, true, []driveDepthError{{
|
||||
FolderID: "fid\x1b[1m-x",
|
||||
Reason: "api_error",
|
||||
Message: "boom",
|
||||
}}, driveLatestScope{depth: 1})
|
||||
msg := err.(*CLIError).Message
|
||||
assertNoControlChars(t, msg)
|
||||
if !strings.Contains(msg, "fid-x") {
|
||||
t.Fatalf("剥离控制序列后 folderID 应连成 fid-x: %q", msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestSafeRemoteText 直接钉住清理函数的各条分支:换行/制表符
|
||||
// 折成空格(SanitizeForTerminal 按设计保留这两者,但单行错误消息里它们会拆出伪造行),
|
||||
// 首尾空白收掉,可打印内容与中文原样保留。
|
||||
func TestCrossPlatformCoverageDriveLatestSafeRemoteText(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{name: "plain", in: "denied", want: "denied"},
|
||||
{name: "cjk", in: "报表目录", want: "报表目录"},
|
||||
{name: "csi", in: "a\x1b[31mb", want: "ab"},
|
||||
{name: "osc", in: "a\x1b]0;t\x07b", want: "ab"},
|
||||
{name: "newline_to_space", in: "a\nb", want: "a b"},
|
||||
{name: "tab_to_space", in: "a\tb", want: "a b"},
|
||||
{name: "carriage_return_dropped", in: "a\rb", want: "ab"},
|
||||
{name: "trim_outer", in: "\n denied \t", want: "denied"},
|
||||
{name: "empty", in: "", want: ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := driveLatestSafeRemoteText(tc.in); got != tc.want {
|
||||
t.Fatalf("driveLatestSafeRemoteText(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestSuggestionNeverInlinesHostileValue 端到端锁定评审提出的
|
||||
// Windows 注入阻断项:`--space-id sp-7&whoami` 这种值,在 POSIX 下靠单引号关停,但 cmd.exe
|
||||
// 不把单引号当引号,粘贴后 `&whoami` 仍会执行。因此不变量必须是「恢复命令里不存在能被目标
|
||||
// shell 解释的裸元字符」,两个平台分别用各自的手段满足:POSIX 引用内联,Windows 不内联。
|
||||
//
|
||||
// 两种形态都通过注入渲染策略在本机验证,不依赖当前 GOOS —— 否则「Windows 上降级为占位符」
|
||||
// 这条路在 POSIX 机器上永不可达,就成了只有 Windows runner 才跑到的盲区(平台覆盖率门禁也会
|
||||
// 因此报未覆盖)。平台绑定本身由 TestCrossPlatformCoverageDriveLatestScopeValueBinding 覆盖。
|
||||
func TestCrossPlatformCoverageDriveLatestSuggestionNeverInlinesHostileValue(t *testing.T) {
|
||||
const hostile = "sp-7&whoami"
|
||||
newSuggestion := func(t *testing.T, render driveLatestValueRenderer) string {
|
||||
t.Helper()
|
||||
scope := driveLatestScopeFrom(newDriveListScopeCmd(t, map[string]string{"space-id": hostile}), 3, "", render)
|
||||
return driveLatestIncompleteError(5, true, twoDriveDepthErrors(), scope).(*CLIError).Suggestion
|
||||
}
|
||||
|
||||
t.Run("windows_never_inlines", func(t *testing.T) {
|
||||
suggestion := newSuggestion(t, driveLatestWindowsScopeValue)
|
||||
for _, clause := range strings.Split(suggestion, ";") {
|
||||
cmdText := extractTrailingDwsCommand(clause)
|
||||
if cmdText == "" {
|
||||
continue
|
||||
}
|
||||
// cmd.exe 里 & 分隔命令,且单引号不是引号,故它压根不能进命令。
|
||||
if strings.Contains(cmdText, "&") {
|
||||
t.Fatalf("windows 形态的命令不得含 &: %s", cmdText)
|
||||
}
|
||||
if strings.Contains(cmdText, hostile) {
|
||||
t.Fatalf("windows 形态的命令不得内联原值: %s", cmdText)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(suggestion, driveLatestUnsafeValuePlaceholder) {
|
||||
t.Fatalf("应降级为占位符: %s", suggestion)
|
||||
}
|
||||
if !strings.Contains(suggestion, strconv.Quote(hostile)) {
|
||||
t.Fatalf("应在展示行给出原值: %s", suggestion)
|
||||
}
|
||||
if !strings.Contains(suggestion, "不是可执行命令") {
|
||||
t.Fatalf("展示行须显式声明非可执行: %s", suggestion)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("posix_quotes_inline", func(t *testing.T) {
|
||||
suggestion := newSuggestion(t, driveLatestPosixScopeValue)
|
||||
if !strings.Contains(suggestion, "'"+hostile+"'") {
|
||||
t.Fatalf("posix 形态应单引号内联原值: %s", suggestion)
|
||||
}
|
||||
// POSIX 下不该无谓降级 —— 那会白白损失可复制体验。
|
||||
if strings.Contains(suggestion, driveLatestUnsafeValuePlaceholder) {
|
||||
t.Fatalf("posix 形态不应降级为占位符: %s", suggestion)
|
||||
}
|
||||
for _, clause := range strings.Split(suggestion, ";") {
|
||||
cmdText := extractTrailingDwsCommand(clause)
|
||||
if cmdText == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(cmdText, "&") && !strings.Contains(cmdText, "'"+hostile+"'") {
|
||||
t.Fatalf("posix 形态出现未引用的元字符: %s", cmdText)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// newDriveListScopeCmd 造一个带 drive list 查询域与过滤 flag 的命令。flag 名与 newDriveCommand
|
||||
// 里 driveListCmd 的注册保持一致;workspace-id 是 cross-product 别名,此处显式注册以覆盖别名路径。
|
||||
func newDriveListScopeCmd(t *testing.T, flags map[string]string) *cobra.Command {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
for _, name := range []string{
|
||||
"workspace", "workspace-id", "space-id", // 查询域
|
||||
"folder", // 扫描根(scope 从 rootFolder 参数取,注册仅为对齐真实命令)
|
||||
"pattern", "type", "start", "end", // 决定候选集的过滤条件
|
||||
} {
|
||||
cmd.Flags().String(name, "", "")
|
||||
}
|
||||
for name, value := range flags {
|
||||
if err := cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set --%s=%s: %v", name, value, err)
|
||||
}
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// assertDriveLatestSuggestion 钉住 Suggestion 的三条约束:
|
||||
// 1. 每条示例命令都带原查询域 wantScope(空串表示原调用无查询域,此时只跳过该项检查);
|
||||
// 2. 含 --latest 的引导子句存在;
|
||||
// 3. 「去掉 --latest」子句给出的示例命令本身不带 --latest(否则照抄复现同一错误)。
|
||||
func assertDriveLatestSuggestion(t *testing.T, suggestion, wantScope string) {
|
||||
t.Helper()
|
||||
clauses := strings.Split(suggestion, ";")
|
||||
sawLatestGuide := false
|
||||
for _, clause := range clauses {
|
||||
cmd := extractTrailingDwsCommand(clause)
|
||||
if cmd == "" {
|
||||
continue
|
||||
}
|
||||
if wantScope != "" && !strings.Contains(cmd, wantScope) {
|
||||
t.Fatalf("示例命令丢失原查询域 %q(照抄会切换查询域): %q", wantScope, cmd)
|
||||
}
|
||||
if strings.Contains(clause, "去掉 --latest") {
|
||||
if strings.Contains(cmd, "--latest") {
|
||||
t.Fatalf("「去掉 --latest」子句的示例命令仍含 --latest: %q", cmd)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.Contains(cmd, "--latest") {
|
||||
sawLatestGuide = true
|
||||
}
|
||||
}
|
||||
if !sawLatestGuide {
|
||||
t.Fatalf("no --latest-bearing guidance clause in suggestion: %q", suggestion)
|
||||
}
|
||||
}
|
||||
|
||||
// extractTrailingDwsCommand 抽子句里以 "dws " 开头的尾部命令片段(到子句末),无则空串。
|
||||
func extractTrailingDwsCommand(clause string) string {
|
||||
idx := strings.LastIndex(clause, "dws ")
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
return clause[idx:]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build !windows
|
||||
|
||||
package helpers
|
||||
|
||||
// driveLatestScopeValue 按**本次构建的目标 shell** 渲染恢复命令里的用户值:返回可内联的片段,
|
||||
// 第二个返回值为 false 时表示该值不能安全进入可执行命令,调用方须改用占位符。
|
||||
//
|
||||
// 非 Windows 构建面向 POSIX shell,单引号可靠地关闭所有展开,故含元字符的值引用后内联即安全。
|
||||
// Windows 构建见 drive_latest_scope_windows.go —— 两个平台的策略本体都是
|
||||
// shell_quote.go 里的纯函数,可在任意平台被测试直接调用;本文件只做编译期绑定。
|
||||
func driveLatestScopeValue(value string) (string, bool) {
|
||||
return driveLatestPosixScopeValue(value)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build windows
|
||||
|
||||
package helpers
|
||||
|
||||
// driveLatestScopeValue 按**本次构建的目标 shell** 渲染恢复命令里的用户值:返回可内联的片段,
|
||||
// 第二个返回值为 false 时表示该值不能安全进入可执行命令,调用方须改用占位符。
|
||||
//
|
||||
// Windows 构建下没有对 cmd.exe 与 PowerShell 同时成立的引用形式(cmd.exe 不认单引号,双引号
|
||||
// 又挡不住 %VAR% 展开),故只内联本身就安全的值;理由与取舍详见
|
||||
// driveLatestWindowsScopeValue 的注释。POSIX 构建见 drive_latest_scope_posix.go。
|
||||
func driveLatestScopeValue(value string) (string, bool) {
|
||||
return driveLatestWindowsScopeValue(value)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package helpers
|
||||
|
||||
import "strings"
|
||||
|
||||
// shellQuoteArg 按 POSIX sh 规则把 s 引用成可安全放进可复制命令的单个 argv 元素。
|
||||
//
|
||||
// 为什么需要它:错误提示里的恢复命令是给用户直接复制到 shell 执行的,其中的查询域取自用户
|
||||
// 输入(--workspace 的常见形态就是带查询串的 URL)。裸拼接下,合法 URL 里的 `&` 就会把命令
|
||||
// 拆成后台任务,空格会拆参,`;` 与 `$()` 还能执行额外内容。
|
||||
//
|
||||
// 为什么不用 strconv.Quote(internal/auth 侧展示 profile 标识的既有做法):那是 Go 语法引号,
|
||||
// 产出双引号串,而 shell 双引号内 `$()`、反引号、`$VAR` 仍会展开——正是要防的场景。auth 那处
|
||||
// 成立是因为它显式声明「仅作数据展示,不是可执行命令」,本函数的产物恰恰要能执行。
|
||||
//
|
||||
// 策略是「必要时才引用」:全由安全字符组成时原样返回,命令保持可读、在 PowerShell/cmd 下同样
|
||||
// 可复制;只要含一个非安全字符就整体单引号包裹——单引号内 POSIX sh 不做任何展开($ ` \ ! 全部
|
||||
// 字面化),是唯一无需逐字符转义的形式。单引号自身无法出现在单引号串内,按 POSIX 标准写法
|
||||
// 先闭合、拼一个反斜杠转义的单引号、再重开——即把每个单引号替换成下面这四个字符:
|
||||
//
|
||||
// '\''
|
||||
//
|
||||
// 空串必须显式引用成一对空单引号,否则该参数会从 argv 里整个消失,后面的 token 会被前一个
|
||||
// flag 吞掉。
|
||||
func shellQuoteArg(s string) string {
|
||||
if s == "" {
|
||||
return "''"
|
||||
}
|
||||
if shellValueIsBare(s) {
|
||||
return s
|
||||
}
|
||||
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||
}
|
||||
|
||||
// shellValueIsBare 报告 s 能否原样出现在命令行里而不改变**任何**常见 shell 的解析:非空,
|
||||
// 且每个字符都落在保守白名单内。这类值在 POSIX sh、PowerShell 与 cmd.exe 下含义一致,
|
||||
// 无需引用即可内联进可复制命令。
|
||||
func shellValueIsBare(s string) bool {
|
||||
return s != "" && !strings.ContainsFunc(s, shellNeedsQuote)
|
||||
}
|
||||
|
||||
// driveLatestPosixScopeValue 是 POSIX shell 下把用户值放进可复制命令的策略:单引号内 sh 不做
|
||||
// 任何展开,因此含元字符的值引用后即可安全内联。第二个返回值恒为 true。
|
||||
func driveLatestPosixScopeValue(value string) (string, bool) {
|
||||
return shellQuoteArg(value), true
|
||||
}
|
||||
|
||||
// driveLatestWindowsScopeValue 是 Windows 构建下的策略:只内联本身就安全的值,其余一律不进
|
||||
// 命令,由调用方降级为占位符 + 展示行。
|
||||
//
|
||||
// Windows 上不存在「一条命令同时对 cmd.exe 与 PowerShell 都安全」的引用形式:
|
||||
// - cmd.exe 根本不把单引号当引号,`--space-id 'sp-7&whoami'` 里的 & 仍然分隔命令,
|
||||
// 粘贴即执行 whoami;
|
||||
// - cmd.exe 的双引号能挡 & | < >,却挡不住 %VAR% 展开;
|
||||
// - PowerShell 的单引号是引号,但内嵌单引号写作两个连续单引号,与 POSIX 的
|
||||
// 闭合-反斜杠转义-重开写法不兼容。
|
||||
//
|
||||
// 而生成命令时无法知道用户会粘贴进哪个 shell。既然引用不可靠,就不把不受信任的值放进可执行
|
||||
// 命令——与 internal/auth 侧「标识仅作数据展示,不是可执行命令」的既有做法同一思路。
|
||||
func driveLatestWindowsScopeValue(value string) (string, bool) {
|
||||
if shellValueIsBare(value) {
|
||||
return value, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// shellNeedsQuote 报告 r 是否落在「无需引用」白名单之外。
|
||||
//
|
||||
// 用白名单而非黑名单:漏掉一个元字符就是一个注入口,而白名单漏一个字符只会多加一对无害的
|
||||
// 引号。非 ASCII 一律视为需引用——中文目录名很常见,保守处理不会错。
|
||||
//
|
||||
// 白名单的门槛是「在 POSIX sh、PowerShell、cmd.exe 下含义都一致」,因此 % 被刻意排除:
|
||||
// cmd.exe 会展开 %VAR%,一个看似普通的值(URL 里的 %20、含 %PATH% 的名字)原样内联后在
|
||||
// cmd 里就会变形甚至泄露环境变量。@ 保留:PowerShell 的 splatting 只在 @( 、@{ 与
|
||||
// @变量名 形态下生效,而那些字符本身都不在白名单里,且值总出现在 --flag 之后而非语句开头。
|
||||
func shellNeedsQuote(r rune) bool {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
return false
|
||||
case r >= 'A' && r <= 'Z':
|
||||
return false
|
||||
case r >= '0' && r <= '9':
|
||||
return false
|
||||
}
|
||||
return !strings.ContainsRune("_@+=:,./-", r)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//go:build !windows
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCrossPlatformCoverageShellQuoteArgRoundTrip 把引用后的串交给真 /bin/sh 求值,证明它被解析回
|
||||
// **恰好一个、且内容完全相同**的参数。上面那条表驱动测试只能证明「我以为的形态」,这条证明
|
||||
// 「shell 认的形态」——两者不是一回事,恢复命令是给用户复制到 shell 里跑的,后者才是契约。
|
||||
//
|
||||
// 它同时是注入的负向对照:一旦引用失效,$(id) / `id` 会真的执行、空格与 & 会拆参,argc 或
|
||||
// 取回的值必然不等于原值,测试立刻红。用 build tag 排除 Windows(无 POSIX sh),跨平台形态
|
||||
// 断言留在 shell_quote_test.go。
|
||||
func TestCrossPlatformCoverageShellQuoteArgRoundTrip(t *testing.T) {
|
||||
sh, err := exec.LookPath("sh")
|
||||
if err != nil {
|
||||
t.Skipf("POSIX sh unavailable: %v", err)
|
||||
}
|
||||
|
||||
values := []string{
|
||||
"ws-1",
|
||||
"https://alidocs.dingtalk.com/i/nodes/x?spaceId=1&type=doc",
|
||||
"sp 7",
|
||||
"a;id",
|
||||
"a&b",
|
||||
"a|b",
|
||||
"$(id)",
|
||||
"`id`",
|
||||
"$HOME",
|
||||
"*.txt",
|
||||
"a>b",
|
||||
`a\b`,
|
||||
`a"b`,
|
||||
"a!b",
|
||||
"{a,b}",
|
||||
"(a)",
|
||||
"~/x",
|
||||
"a#b",
|
||||
"it's",
|
||||
"'",
|
||||
"a'b'c",
|
||||
"",
|
||||
"报表",
|
||||
"a\tb",
|
||||
"a\nb",
|
||||
"--not-a-flag",
|
||||
}
|
||||
|
||||
for _, want := range values {
|
||||
t.Run(want, func(t *testing.T) {
|
||||
quoted := shellQuoteArg(want)
|
||||
|
||||
// 1) 未被拆参也未被吞掉:位置参数个数必须恰好为 1。
|
||||
// 引用失效时 "sp 7" 会变 2 个、"" 会变 0 个、$(id) 会变 id 的输出词数。
|
||||
argc, err := exec.Command(sh, "-c", "set -- "+quoted+`; printf %s "$#"`).Output()
|
||||
if err != nil {
|
||||
t.Fatalf("argc probe failed for %q (quoted %s): %v", want, quoted, err)
|
||||
}
|
||||
if string(argc) != "1" {
|
||||
t.Fatalf("argc = %s, want 1 — %q quoted as %s split or vanished", argc, want, quoted)
|
||||
}
|
||||
|
||||
// 2) 内容逐字节相同:展开、命令替换、转义都不得改动值。
|
||||
got, err := exec.Command(sh, "-c", "set -- "+quoted+`; printf %s "$1"`).Output()
|
||||
if err != nil {
|
||||
t.Fatalf("value probe failed for %q (quoted %s): %v", want, quoted, err)
|
||||
}
|
||||
if string(got) != want {
|
||||
t.Fatalf("round-trip mismatch\n input: %q\n quoted: %s\n shell: %q", want, quoted, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCrossPlatformCoverageShellQuoteArg 表驱动锁定 argv 引用规则。分两类断言:
|
||||
// - 安全值原样返回(保持恢复命令可读,且在 PowerShell/cmd 下同样可复制);
|
||||
// - 含任何 shell 元字符的值整体单引号包裹,内嵌单引号按 POSIX 标准写法处理
|
||||
// (闭合、拼反斜杠转义的单引号、重开)。
|
||||
//
|
||||
// 往返正确性另有 Unix 下用真 sh 求值的对照测试(shell_quote_roundtrip_unix_test.go)。
|
||||
//
|
||||
// TestCrossPlatformCoverage 前缀是门禁约定:平台覆盖率门禁只跑该前缀(与 TestAllShortcuts)的
|
||||
// 测试,shell_quote.go 的覆盖全靠这一条,去掉前缀会让 Coverage (macOS)/(Windows) 直接红。
|
||||
// 详见 drive_latest_incomplete_test.go 头部说明。
|
||||
func TestCrossPlatformCoverageShellQuoteArg(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
// —— 无需引用:白名单字符 ——
|
||||
{name: "plain_id", in: "ws-1", want: "ws-1"},
|
||||
{name: "digits", in: "1234567890", want: "1234567890"},
|
||||
{name: "upper_lower", in: "abcXYZ", want: "abcXYZ"},
|
||||
{name: "all_safe_punct", in: "_@+=:,./-", want: "_@+=:,./-"},
|
||||
{name: "path_like", in: "/tmp/a.b/c-d_e", want: "/tmp/a.b/c-d_e"},
|
||||
|
||||
// —— 必须引用:真实业务形态 ——
|
||||
// workspace 常见形态就是带查询串的 URL:? 与 & 都不在白名单,& 在裸拼接下会拆命令。
|
||||
{name: "url_with_query", in: "https://alidocs.dingtalk.com/i/nodes/x?spaceId=1&type=doc",
|
||||
want: `'https://alidocs.dingtalk.com/i/nodes/x?spaceId=1&type=doc'`},
|
||||
|
||||
// —— 必须引用:shell 元字符 ——
|
||||
{name: "space", in: "sp 7", want: `'sp 7'`},
|
||||
{name: "tab", in: "a\tb", want: "'a\tb'"},
|
||||
{name: "newline", in: "a\nb", want: "'a\nb'"},
|
||||
{name: "semicolon", in: "a;id", want: `'a;id'`},
|
||||
{name: "ampersand", in: "a&b", want: `'a&b'`},
|
||||
{name: "pipe", in: "a|b", want: `'a|b'`},
|
||||
{name: "command_substitution", in: "$(id)", want: `'$(id)'`},
|
||||
{name: "backtick", in: "`id`", want: "'`id`'"},
|
||||
{name: "variable", in: "$HOME", want: `'$HOME'`},
|
||||
// % 不在白名单:cmd.exe 会展开 %VAR%,故含 % 的值一律视为需引用。
|
||||
{name: "windows_variable", in: "%PATH%", want: `'%PATH%'`},
|
||||
{name: "url_percent_escape", in: "a%20b", want: `'a%20b'`},
|
||||
{name: "glob", in: "*.txt", want: `'*.txt'`},
|
||||
{name: "redirect", in: "a>b", want: `'a>b'`},
|
||||
{name: "backslash", in: `a\b`, want: `'a\b'`},
|
||||
{name: "double_quote", in: `a"b`, want: `'a"b'`},
|
||||
{name: "history_expansion", in: "a!b", want: `'a!b'`},
|
||||
{name: "brace", in: "{a,b}", want: `'{a,b}'`},
|
||||
{name: "paren", in: "(a)", want: `'(a)'`},
|
||||
{name: "tilde", in: "~/x", want: `'~/x'`},
|
||||
{name: "hash", in: "a#b", want: `'a#b'`},
|
||||
|
||||
// —— 单引号:唯一无法直接放进单引号串的字符 ——
|
||||
{name: "single_quote", in: "it's", want: `'it'\''s'`},
|
||||
{name: "only_single_quote", in: "'", want: `''\'''`},
|
||||
{name: "two_single_quotes", in: "a'b'c", want: `'a'\''b'\''c'`},
|
||||
|
||||
// —— 边界 ——
|
||||
// 空串必须显式成 '',否则参数会从 argv 里整个消失(--workspace 吞掉下一个 token)。
|
||||
{name: "empty", in: "", want: `''`},
|
||||
// 非 ASCII 一律引用:保守优于漏字符,中文目录名很常见。
|
||||
{name: "cjk", in: "报表", want: `'报表'`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := shellQuoteArg(tc.in); got != tc.want {
|
||||
t.Fatalf("shellQuoteArg(%q)\n got: %s\nwant: %s", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestScopeValueStrategies 直接测两条平台策略的本体。它们是
|
||||
// shell_quote.go 里的纯函数、与构建平台无关,因此**在任一平台都能验证 Windows 侧的行为** ——
|
||||
// 这一点是刻意设计:真 shell 往返测试只能在 Unix 跑,Windows 的安全属性必须由这里钉住,
|
||||
// 否则「Windows 上不会把不受信任值放进可执行命令」就成了无人复核的断言。
|
||||
func TestCrossPlatformCoverageDriveLatestScopeValueStrategies(t *testing.T) {
|
||||
// 这些值在至少一种目标 shell 下会改变命令解析。
|
||||
hostile := []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{"cmd_command_separator", "sp-7&whoami"}, // cmd.exe:& 分隔命令,单引号不是引号
|
||||
{"cmd_variable", "%PATH%"}, // cmd.exe:无条件展开
|
||||
{"workspace_url", "https://x/y?a=1&b=2"}, // 合法 workspace 形态,含 &
|
||||
{"space", "sp 7"},
|
||||
{"pwsh_statement_separator", "a;id"}, // PowerShell:; 分隔语句
|
||||
{"posix_substitution", "$(id)"},
|
||||
{"backtick", "`id`"},
|
||||
{"posix_variable", "$HOME"},
|
||||
{"pipe", "a|b"},
|
||||
{"redirect", "a>b"},
|
||||
{"caret", "a^b"}, // cmd.exe 转义符
|
||||
{"single_quote", "it's"},
|
||||
{"cjk", "报表"},
|
||||
{"empty", ""},
|
||||
}
|
||||
for _, tc := range hostile {
|
||||
t.Run("hostile/"+tc.name, func(t *testing.T) {
|
||||
// POSIX:始终可内联,但必须是引用后的形态(不能等于原值)。
|
||||
got, ok := driveLatestPosixScopeValue(tc.value)
|
||||
if !ok {
|
||||
t.Fatalf("POSIX 策略应始终可内联: %q", tc.value)
|
||||
}
|
||||
if got == tc.value {
|
||||
t.Fatalf("POSIX 策略未引用危险值: %q", tc.value)
|
||||
}
|
||||
// Windows:一律拒绝内联 —— 没有对 cmd.exe 与 PowerShell 同时安全的引用形式。
|
||||
if inline, ok := driveLatestWindowsScopeValue(tc.value); ok {
|
||||
t.Fatalf("Windows 策略不得内联危险值 %q(得到 %q)", tc.value, inline)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 安全值:两条策略都原样内联,命令保持可读、跨 shell 可复制。
|
||||
for _, v := range []string{"ws-1", "sp-7", "folder-root", "modifyTime", "7d", "2026-08-01", "a.b/c-d_e", "a@b"} {
|
||||
t.Run("bare/"+v, func(t *testing.T) {
|
||||
if got, ok := driveLatestPosixScopeValue(v); !ok || got != v {
|
||||
t.Fatalf("POSIX 应原样内联安全值 %q: got %q ok=%v", v, got, ok)
|
||||
}
|
||||
if got, ok := driveLatestWindowsScopeValue(v); !ok || got != v {
|
||||
t.Fatalf("Windows 应原样内联安全值 %q: got %q ok=%v", v, got, ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 空串在 POSIX 下引用成一对空单引号(否则参数会从 argv 消失);Windows 侧归入不可内联。
|
||||
if got, ok := driveLatestPosixScopeValue(""); !ok || got != "''" {
|
||||
t.Fatalf("POSIX 空串应引用成一对空单引号: got %q ok=%v", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageShellBareCharsetHasNoMetacharacters 锁定「安全值可原样内联」的根本
|
||||
// 前提:白名单里不能含任何一种目标 shell 会特殊解释的字符。
|
||||
//
|
||||
// 这比「用某个 shell 跑一遍」更本质 —— 内联路径的安全性不来自引用正确,而来自字符集本身无害;
|
||||
// 而拒绝内联的路径(Windows 侧)连引用都不需要。三套元字符合并检查:POSIX sh、PowerShell、
|
||||
// cmd.exe。`%` 曾因 URL 转义(%20)被误列入白名单,而 cmd.exe 会无条件展开 %VAR%,这条测试
|
||||
// 同时是该缺陷的回归锁。
|
||||
func TestCrossPlatformCoverageShellBareCharsetHasNoMetacharacters(t *testing.T) {
|
||||
// 逐字符列出,避免用字符串字面量时漏掉转义细节。
|
||||
meta := []rune{
|
||||
'&', '|', '<', '>', ';', '(', ')', '{', '}', '[', ']', // 分隔/分组
|
||||
'$', '`', '"', '\'', '\\', // 引用与替换
|
||||
'^', '%', // cmd.exe:转义符与变量展开
|
||||
'*', '?', '~', '#', '!', // 通配、家目录、注释、历史/取反
|
||||
' ', '\t', '\n', '\r', // 空白:拆参与换行注入
|
||||
}
|
||||
for _, r := range meta {
|
||||
if !shellNeedsQuote(r) {
|
||||
t.Fatalf("shell 元字符 %q 落在「无需引用」白名单内,安全值会被原样内联", string(r))
|
||||
}
|
||||
}
|
||||
// 反向哨兵:常规标识符字符必须留在白名单内,否则恢复命令会被无谓地全量引用/降级。
|
||||
for _, r := range []rune{'a', 'Z', '0', '9', '_', '-', '.', '/', ':', ',', '=', '+', '@'} {
|
||||
if shellNeedsQuote(r) {
|
||||
t.Fatalf("常规字符 %q 被判为需引用,会让恢复命令可读性无谓下降", string(r))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestScopeValueBinding 断言当前构建绑定到了正确的策略。
|
||||
// 这条测试在每个平台各自成立,把「哪个平台用哪条策略」也纳入 CI(Coverage (Windows) 会跑它)。
|
||||
func TestCrossPlatformCoverageDriveLatestScopeValueBinding(t *testing.T) {
|
||||
const hostile = "sp-7&whoami"
|
||||
inline, ok := driveLatestScopeValue(hostile)
|
||||
if runtime.GOOS == "windows" {
|
||||
if ok {
|
||||
t.Fatalf("windows 构建不得内联 %q(得到 %q)", hostile, inline)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
t.Fatalf("posix 构建应可内联 %q", hostile)
|
||||
}
|
||||
if inline != `'sp-7&whoami'` {
|
||||
t.Fatalf("posix 构建应单引号包裹: %s", inline)
|
||||
}
|
||||
}
|
||||
@@ -116,8 +116,8 @@ func requireWukongWeeklySyncConfirmation(t *testing.T, err error) {
|
||||
func TestCrossPlatformCoverageWukongWeeklyChatCategoryQueries(t *testing.T) {
|
||||
caller := &wukongWeeklySyncCaller{}
|
||||
_, _, err := executeWukongWeeklySyncCommand(t, "chat", caller, newChatCommand, "category", "list-by-conv")
|
||||
if err == nil || !strings.Contains(err.Error(), "flag --group is required") {
|
||||
t.Fatalf("missing group error = %v", err)
|
||||
if err == nil || !strings.Contains(err.Error(), "missing required flag: --conversation-id") {
|
||||
t.Fatalf("missing conversation ID error = %v", err)
|
||||
}
|
||||
requireWukongWeeklySyncNoCalls(t, caller)
|
||||
|
||||
@@ -378,15 +378,15 @@ func TestCrossPlatformCoverageWukongWeeklyChatUpdateNickClearSemantics(t *testin
|
||||
if findErr != nil {
|
||||
t.Fatal(findErr)
|
||||
}
|
||||
if err := updateNick.RunE(updateNick, nil); err == nil || !strings.Contains(err.Error(), "--group") {
|
||||
t.Fatalf("direct missing group error = %v", err)
|
||||
if err := updateNick.RunE(updateNick, nil); err == nil || !strings.Contains(err.Error(), "--conversation-id") {
|
||||
t.Fatalf("direct missing conversation-id error = %v", err)
|
||||
}
|
||||
|
||||
caller := &wukongWeeklySyncCaller{}
|
||||
_, _, err := executeWukongWeeklySyncCommand(t, "chat", caller, newChatCommand,
|
||||
"group", "update-nick")
|
||||
if err == nil || !strings.Contains(err.Error(), "group") {
|
||||
t.Fatalf("missing group error = %v", err)
|
||||
if err == nil || !strings.Contains(err.Error(), "conversation-id") {
|
||||
t.Fatalf("missing conversation-id error = %v", err)
|
||||
}
|
||||
requireWukongWeeklySyncNoCalls(t, caller)
|
||||
|
||||
@@ -428,8 +428,8 @@ func TestCrossPlatformCoverageWukongWeeklyChatUpgradeValidationAndSafety(t *test
|
||||
upgrade.Flags().Bool("yes", false, "")
|
||||
}
|
||||
_ = upgrade.Flags().Set("yes", "true")
|
||||
if err := upgrade.RunE(upgrade, nil); err == nil || !strings.Contains(err.Error(), "--group") {
|
||||
t.Fatalf("direct missing group error = %v", err)
|
||||
if err := upgrade.RunE(upgrade, nil); err == nil || !strings.Contains(err.Error(), "--conversation-id") {
|
||||
t.Fatalf("direct missing conversation-id error = %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
|
||||
@@ -50,14 +50,14 @@ func init() {
|
||||
Name: "DWS_TRUSTED_DOMAINS",
|
||||
Category: configmeta.CategoryNetwork,
|
||||
Description: "信任的 HTTPS 域名白名单 (逗号分隔,* 信任所有)",
|
||||
DefaultValue: "*.dingtalk.com",
|
||||
Example: "*.dingtalk.com,custom.example.com",
|
||||
DefaultValue: "*.dingtalk.com,*.dingtalk.io",
|
||||
Example: "*.dingtalk.com,*.dingtalk.io,custom.example.com",
|
||||
})
|
||||
}
|
||||
|
||||
const (
|
||||
trustedDomainsEnv = "DWS_TRUSTED_DOMAINS"
|
||||
defaultTrustedDomains = "*.dingtalk.com"
|
||||
defaultTrustedDomains = "*.dingtalk.com,*.dingtalk.io"
|
||||
|
||||
// defaultHTTPTimeout is the default timeout for HTTP transport requests.
|
||||
defaultHTTPTimeout = 30 * time.Second
|
||||
@@ -654,7 +654,7 @@ func shouldPreserveEndpointQuery(parsed *url.URL) bool {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(parsed.Hostname()) {
|
||||
case "mcp-gw.dingtalk.com", "pre-mcp-gw.dingtalk.com":
|
||||
case "mcp-gw.dingtalk.com", "pre-mcp-gw.dingtalk.com", "mcp-gw.dingtalk.io", "pre-mcp-gw.dingtalk.io":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -234,6 +235,23 @@ func TestIsEndpointTrusted_DomainMatch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageIsEndpointTrustedDefaultIncludesDingTalkInternational(t *testing.T) {
|
||||
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "")
|
||||
t.Setenv("DWS_TRUSTED_DOMAINS", "")
|
||||
c := NewClient(nil)
|
||||
if !c.isEndpointTrusted("https://mcp-gw.dingtalk.com/server/contact") {
|
||||
t.Fatal("default trusted domains should trust dingtalk.com")
|
||||
}
|
||||
if !c.isEndpointTrusted("https://pre-mcp-gw.dingtalk.io/server/contact") {
|
||||
t.Fatal("default trusted domains should trust dingtalk.io")
|
||||
}
|
||||
for _, host := range []string{"mcp-gw.dingtalk.io", "pre-mcp-gw.dingtalk.io"} {
|
||||
if !shouldPreserveEndpointQuery(&url.URL{Scheme: "https", Host: host}) {
|
||||
t.Fatalf("international gateway %q should preserve endpoint query", host)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHttpStatusError(t *testing.T) {
|
||||
t.Parallel()
|
||||
codes := []int{
|
||||
|
||||
@@ -562,6 +562,16 @@ func TestSanitizeJSONRPCEndpointPreservesDingTalkMCPGatewayQuery(t *testing.T) {
|
||||
endpoint: "https://mcp-gw.dingtalk.com/server/demo?key=secret#frag",
|
||||
want: "https://mcp-gw.dingtalk.com/server/demo?key=secret",
|
||||
},
|
||||
{
|
||||
name: "prepub international gateway",
|
||||
endpoint: "https://pre-mcp-gw.dingtalk.io/server/demo?key=secret#frag",
|
||||
want: "https://pre-mcp-gw.dingtalk.io/server/demo?key=secret",
|
||||
},
|
||||
{
|
||||
name: "prod international gateway",
|
||||
endpoint: "https://mcp-gw.dingtalk.io/server/demo?key=secret#frag",
|
||||
want: "https://mcp-gw.dingtalk.io/server/demo?key=secret",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range cases {
|
||||
|
||||
+19
-2
@@ -17,6 +17,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -165,10 +166,19 @@ const (
|
||||
// Override at runtime via ~/.dws/mcp_url file.
|
||||
DefaultMCPBaseURL = "https://mcp.dingtalk.com"
|
||||
|
||||
// ManagedMCPURLRegionFileName records an MCP URL written automatically by
|
||||
// login region selection, so a later region change does not delete a user's
|
||||
// explicit MCP override.
|
||||
ManagedMCPURLRegionFileName = "mcp_url.login_region"
|
||||
|
||||
// DefaultTerminalBaseURL is the DingTalk developer platform base URL.
|
||||
// Override at runtime via ~/.dws/terminal_url file.
|
||||
DefaultTerminalBaseURL = "https://open-dev.dingtalk.com"
|
||||
|
||||
// InternationalTerminalBaseURL is the DingTalk international developer
|
||||
// platform base URL.
|
||||
InternationalTerminalBaseURL = "https://open-dev.dingtalk.io"
|
||||
|
||||
// DeveloperSettingsPath is the path to the organization developer
|
||||
// settings page (CLI access management).
|
||||
DeveloperSettingsPath = "/fe/old#/developerSettings"
|
||||
@@ -201,8 +211,9 @@ func GetMCPBaseURL() string {
|
||||
}
|
||||
|
||||
// GetTerminalBaseURL returns the terminal base URL with priority:
|
||||
// 1. ~/.dws/terminal_url file content (for pre-release environment)
|
||||
// 2. Default value (https://open-dev.dingtalk.com)
|
||||
// 1. ~/.dws/terminal_url file content (for custom environments)
|
||||
// 2. International terminal when the configured MCP endpoint uses .io
|
||||
// 3. Default value (https://open-dev.dingtalk.com)
|
||||
func GetTerminalBaseURL() string {
|
||||
terminalURLPath := filepath.Join(DefaultConfigDir(), "terminal_url")
|
||||
if data, err := os.ReadFile(terminalURLPath); err == nil {
|
||||
@@ -210,6 +221,12 @@ func GetTerminalBaseURL() string {
|
||||
return u
|
||||
}
|
||||
}
|
||||
if parsed, err := url.Parse(GetMCPBaseURL()); err == nil {
|
||||
host := strings.ToLower(parsed.Hostname())
|
||||
if host == "dingtalk.io" || strings.HasSuffix(host, ".dingtalk.io") {
|
||||
return InternationalTerminalBaseURL
|
||||
}
|
||||
}
|
||||
return DefaultTerminalBaseURL
|
||||
}
|
||||
|
||||
|
||||
@@ -219,6 +219,34 @@ func TestGetMCPBaseURLUsesConfigFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageGetDeveloperSettingsURLUsesInternationalMCPRegion(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "mcp_url"), []byte("https://mcp.dingtalk.io\n"), FilePerm); err != nil {
|
||||
t.Fatalf("WriteFile(mcp_url) error = %v", err)
|
||||
}
|
||||
|
||||
want := "https://open-dev.dingtalk.io/fe/old#/developerSettings"
|
||||
if got := GetDeveloperSettingsURL(); got != want {
|
||||
t.Fatalf("GetDeveloperSettingsURL() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageGetTerminalBaseURLPrefersExplicitConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "mcp_url"), []byte("https://mcp.dingtalk.io\n"), FilePerm); err != nil {
|
||||
t.Fatalf("WriteFile(mcp_url) error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "terminal_url"), []byte("https://custom-open-dev.example.com\n"), FilePerm); err != nil {
|
||||
t.Fatalf("WriteFile(terminal_url) error = %v", err)
|
||||
}
|
||||
|
||||
if got := GetTerminalBaseURL(); got != "https://custom-open-dev.example.com" {
|
||||
t.Fatalf("GetTerminalBaseURL() = %q, want explicit terminal URL", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxUploadFileSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
var want int64 = 100 * 1024 * 1024
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "pending",
|
||||
"state": "consumed",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -97,7 +97,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "pending",
|
||||
"state": "consumed",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -130,7 +130,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "pending",
|
||||
"state": "consumed",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -163,7 +163,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "pending",
|
||||
"state": "consumed",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -196,7 +196,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "pending",
|
||||
"state": "consumed",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -229,7 +229,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "pending",
|
||||
"state": "consumed",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -262,7 +262,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "pending",
|
||||
"state": "consumed",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -295,7 +295,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "pending",
|
||||
"state": "consumed",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -328,7 +328,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "pending",
|
||||
"state": "consumed",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -361,7 +361,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "pending",
|
||||
"state": "consumed",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -394,7 +394,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "pending",
|
||||
"state": "consumed",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -427,7 +427,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "pending",
|
||||
"state": "consumed",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -460,7 +460,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "pending",
|
||||
"state": "consumed",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -493,7 +493,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "pending",
|
||||
"state": "consumed",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -526,7 +526,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "pending",
|
||||
"state": "consumed",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -559,7 +559,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "pending",
|
||||
"state": "consumed",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -592,7 +592,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "pending",
|
||||
"state": "consumed",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -625,7 +625,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "pending",
|
||||
"state": "consumed",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -658,7 +658,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "pending",
|
||||
"state": "consumed",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -691,7 +691,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "pending",
|
||||
"state": "consumed",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -724,7 +724,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "pending",
|
||||
"state": "consumed",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -757,7 +757,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "pending",
|
||||
"state": "consumed",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -790,7 +790,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "pending",
|
||||
"state": "consumed",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -823,7 +823,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "pending",
|
||||
"state": "consumed",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -856,7 +856,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "pending",
|
||||
"state": "consumed",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -892,7 +892,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "pending",
|
||||
"state": "consumed",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -925,7 +925,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "pending",
|
||||
"state": "consumed",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -991,7 +991,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "pending",
|
||||
"state": "consumed",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -1024,7 +1024,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "pending",
|
||||
"state": "consumed",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -1057,7 +1057,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "pending",
|
||||
"state": "consumed",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -1090,7 +1090,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "pending",
|
||||
"state": "consumed",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -1123,7 +1123,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "pending",
|
||||
"state": "consumed",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -67,6 +67,12 @@ Flags:
|
||||
- 输出形态:带过滤时输出从单页透传变为聚合形态 `{items, maxDepth, truncated, errors}`。
|
||||
- 已知代价:大目录(>2000 条)触顶截断时 `truncated=true`(退出码 0,结果每条都正确但没扫完);
|
||||
建议用 `--folder` 指定子目录缩小扫描范围;带关键词的过滤场景改用 `dws drive search`。
|
||||
- 与 `--latest` 组合时上一条不适用:排序基不完整的 Top-N 不是全局最新,故触顶截断**或**递归途中
|
||||
目录读取失败都拒绝产出并报错(`LATEST_SCAN_TRUNCATED` / `LATEST_SCAN_INCOMPLETE`),不会以
|
||||
退出码 0 交出结果;错误消息里带首个失败目录的 folder/depth/reason,以及一条复现原候选集
|
||||
(查询域 + `--folder` + `--pattern`/`--type`/`--start`/`--end`)的恢复命令。Windows 构建下,
|
||||
若原值含 shell 元字符则命令里只给占位符、原值另起一行以数据形式列出(cmd.exe 与 PowerShell
|
||||
没有共同安全的引用形式),照抄时需手动替换。
|
||||
|
||||
### 获取钉盘空间列表
|
||||
|
||||
|
||||
@@ -61,6 +61,12 @@ Flags:
|
||||
- 输出形态:带过滤时输出从单页透传变为聚合形态 `{items, maxDepth, truncated, errors}`。
|
||||
- 已知代价:大目录(>2000 条)触顶截断时 `truncated=true`(退出码 0,结果每条都正确但没扫完);
|
||||
建议用 `--folder` 指定子目录缩小扫描范围;带关键词的过滤场景改用 `dws drive search`。
|
||||
- 与 `--latest` 组合时上一条不适用:排序基不完整的 Top-N 不是全局最新,故触顶截断**或**递归途中
|
||||
目录读取失败都拒绝产出并报错(`LATEST_SCAN_TRUNCATED` / `LATEST_SCAN_INCOMPLETE`),不会以
|
||||
退出码 0 交出结果;错误消息里带首个失败目录的 folder/depth/reason,以及一条复现原候选集
|
||||
(查询域 + `--folder` + `--pattern`/`--type`/`--start`/`--end`)的恢复命令。Windows 构建下,
|
||||
若原值含 shell 元字符则命令里只给占位符、原值另起一行以数据形式列出(cmd.exe 与 PowerShell
|
||||
没有共同安全的引用形式),照抄时需手动替换。
|
||||
|
||||
### 获取钉盘空间列表
|
||||
|
||||
|
||||
Reference in New Issue
Block a user