Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bbb3a9d32 | ||
|
|
0ebd840ba9 | ||
|
|
9d356cd664 | ||
|
|
23167ef974 | ||
|
|
11934eed05 | ||
|
|
a352615e77 | ||
|
|
6288199a93 | ||
|
|
5b34ed1a7e | ||
|
|
3d9a469347 | ||
|
|
e0c49377d6 | ||
|
|
02aad9020a | ||
|
|
35d6f47bd6 | ||
|
|
765b961f4d | ||
|
|
1c8b83ec2f | ||
|
|
bb6f470df5 | ||
|
|
47e3c2b3c5 | ||
|
|
23940e4752 | ||
|
|
bbaf033618 | ||
|
|
3d59411a1a | ||
|
|
fe66ac18a4 | ||
|
|
378b9f67a2 | ||
|
|
e6b06b561d | ||
|
|
e6fe475aea | ||
|
|
e95ac52ff4 | ||
|
|
61f8140f91 | ||
|
|
fbcd8887ee |
@@ -0,0 +1,7 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Calendar empty windows** (#1074) — returns a legitimate empty result when the service emits its exact exhausted empty-event sentinel.
|
||||
- **Task update verification** (#1074) — compares due-time readback as exact milliseconds so committed updates are no longer reported as failures.
|
||||
- **Comment reaction validation** (#1074) — narrows accepted reaction input to reviewed DingTalk emoji names and rejects Unicode emoji and unsupported names such as `like` and `heart` before the RPC.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **OAuth refresh falls back to the organization mirror** — when the server rejects the
|
||||
current identity's `refresh_token` with the reviewed `invalidParameter.authCode.notFound`
|
||||
business code, `dws` now retries once with the still-valid token mirrored in the same
|
||||
organization's slot (same corp, matching or backfilled user identity) before giving up,
|
||||
and writes the rotated credential back to both the identity and the organization slots so
|
||||
the fallback stays usable on later refreshes. Transient failures and direct-mode HTTP
|
||||
rejections without a reviewed business code do not trigger the fallback.
|
||||
@@ -6,6 +6,29 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.60-beta.1] - 2026-08-21
|
||||
|
||||
### Changed
|
||||
|
||||
- **OA, DING, and Report shortcuts** — hardens response, identity, pagination, and confirmation contracts; publishes verified form search, receiver status, and report read workflows while withholding shortcuts that lack trustworthy downstream evidence.
|
||||
|
||||
- **Stable release sealing** — directly preparing a stable release now renders and archives release fragments merged after its beta baseline, avoiding a forced extra beta solely to consume pending notes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Calendar empty windows** (#1074) — returns a legitimate empty result when the service emits its exact exhausted empty-event sentinel.
|
||||
- **Task update verification** (#1074) — compares due-time readback as exact milliseconds so committed updates are no longer reported as failures.
|
||||
- **Comment reaction validation** (#1074) — narrows accepted reaction input to reviewed DingTalk emoji names and rejects Unicode emoji and unsupported names such as `like` and `heart` before the RPC.
|
||||
|
||||
- **OAuth refresh falls back to the organization mirror** — when the server rejects the
|
||||
current identity's `refresh_token` with the reviewed `invalidParameter.authCode.notFound`
|
||||
business code, `dws` now retries once with the still-valid token mirrored in the same
|
||||
organization's slot (same corp, matching or backfilled user identity) before giving up,
|
||||
and writes the rotated credential back to both the identity and the organization slots so
|
||||
the fallback stays usable on later refreshes. Transient failures and direct-mode HTTP
|
||||
rejections without a reviewed business code do not trigger the fallback.
|
||||
|
||||
|
||||
## [1.0.59] - 2026-08-20
|
||||
|
||||
This release promotes the sealed `v1.0.59-beta.5` contents to stable.
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@
|
||||
</p>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **共创阶段**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。欢迎加入钉钉 DWS 共创群获取支持与最新动态。详见下方 [开始使用](#开始使用)。
|
||||
> **钉钉 DWS CLI 已全面开放,欢迎使用**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。欢迎加入钉钉 DWS 共创群获取支持与最新动态。详见下方 [开始使用](#开始使用)。
|
||||
>
|
||||
> <img src="https://img.alicdn.com/imgextra/i1/O1CN01WJyAsJ1prD2ovQACM_!!6000000005413-2-tps-718-720.png" alt="dws 开源沟通群二维码" width="150">
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
# CLI Help / Schema 兼容迁移治理
|
||||
|
||||
本文定义一种受控迁移:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 设为唯一可见入口。迁移必须保持原 flag 的 requiredness:optional 只能迁到 optional,required 只能迁到 required。它只解决这一种精确变更,不是通用 breaking-change 豁免。
|
||||
本文定义两种受控 flag 迁移:
|
||||
|
||||
1. `flag_rename`:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 设为唯一可见入口;rename 必须保持原 flag 的 requiredness,optional 只能迁到 optional,required 只能迁到 required。
|
||||
2. `requiredness_change`:同一个公开 flag 从 optional 精确提升为 required;flag 的名称、类型、作用域、可见性、shorthand、`no_opt` 与 alias 关系必须保持不变。
|
||||
|
||||
两种原语都只放行清单精确登记的变化,不是通用 breaking-change 豁免,也不得在同一 command/flag 上叠加以绕过 rename 的 requiredness 保持规则。
|
||||
|
||||
同一套 base-owned lifecycle 也治理两类跨命令迁移:旧命令保留执行能力但从 Help / Schema 导航隐藏,并迁到新的公开命令路径;或把旧命令中的一个可选 flag 拆成新的专用命令。跨命令迁移只允许清单精确声明的 `command_became_hidden` / `flag_became_hidden` 及其 Schema 投影,不是通用 command-path breaking-change 豁免。
|
||||
|
||||
@@ -44,7 +49,9 @@ scripts/policy/interface-migrations/approved-flag-migrations-v1.json
|
||||
scripts/policy/interface-migrations/approved-command-migrations-v1.json
|
||||
```
|
||||
|
||||
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。清单中的 `pending` 记录只记录已评审计划,并授权其精确列出的后续产品迁移;候选与 merge-base 仍必须精确匹配 `before`,不能授权同一个提交中的接口变化,也不能作为其他命令或参数的通配豁免。
|
||||
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。历史未声明 `kind` 的记录按 `flag_rename` 解释;新增同名 requiredness 迁移必须显式写 `kind: requiredness_change` 和单一 `flag` before/after。清单中的 `pending` 记录只记录已评审计划,并授权其精确列出的后续产品迁移;候选与 merge-base 仍必须精确匹配 `before`,不能授权同一个提交中的接口变化,也不能作为其他命令或参数的通配豁免。
|
||||
|
||||
首次引入一个旧 merge-base 不认识的新 `kind` 时,机制 PR 不得同时写入该 kind 的 pending 记录,因为旧的 base-owned 严格解析器会拒绝未知字段。必须先合入 parser、lifecycle、CLI/Schema adapter 与 hostile tests;待这些实现成为新的 merge-base authority 后,再用独立治理审批 PR 新增 pending,最后才由产品 PR 消费。
|
||||
|
||||
## 跨命令迁移原语
|
||||
|
||||
@@ -103,12 +110,12 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run`
|
||||
|
||||
## 两阶段迁移与回执清理
|
||||
|
||||
每条迁移以 `(command, legacy flag, canonical flag)` 为唯一精确键,并经历以下生命周期:
|
||||
rename 以 `(kind, command, legacy flag, canonical flag)` 为唯一精确键;requiredness change 以 `(kind, command, flag)` 为唯一精确键。二者经历同一生命周期:
|
||||
|
||||
| 阶段 | PR 可以做什么 | 必须满足的快照状态 |
|
||||
|---|---|---|
|
||||
| 1. 治理审批 | 新增 `state: pending` 的精确记录;不得在同一个 PR 修改产品 surface | candidate 和 merge-base 都与记录中的 `before` 完全一致;该记录不改变 stable 的判断 |
|
||||
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`;canonical 的 requiredness 与 legacy 迁移前完全一致 |
|
||||
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | rename 的 legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`,canonical requiredness 保持不变;requiredness change 只把同名 flag 从 optional 提升为 required |
|
||||
| 3. 保留回执 | 产品 PR 合入后,如果 stable 仍是 `before`,继续保留 `consumed` | merge-base 或 stable 仍有任一份尚未达到 `after` |
|
||||
| 4. 惰性保留或清理 | 当 merge-base 和 stable 都已经是 `after`,该记录不再提供任何授权;后续 PR 可以原样保留或删除 | 两份参考快照均精确匹配 `after`;保留时仍必须是不可改写的 `consumed`,接口偏离 `after` 继续失败 |
|
||||
|
||||
@@ -157,6 +164,27 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run`
|
||||
|
||||
产品迁移 PR 必须保持同一条记录的命令、flag、before/after 和 reason 不变,只把 `pending` 改成 `consumed`。
|
||||
|
||||
同名 flag requiredness 迁移的清单结构如下;示例不代表已经审批:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"migrations": [
|
||||
{
|
||||
"kind": "requiredness_change",
|
||||
"command": "dws report entry submit",
|
||||
"flag": {
|
||||
"name": "to-user-ids",
|
||||
"before": {"present": true, "type": "string", "scope": "local"},
|
||||
"after": {"present": true, "type": "string", "required": true, "scope": "local"}
|
||||
},
|
||||
"state": "pending",
|
||||
"reason": "Reject report submissions that have no visible recipient."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## `alias_of` 是框架来源的受评审关系证据
|
||||
|
||||
`alias_of` 不是 Schema 同义词、参数概念词典或任意文字声明。它只能由 `FlagSpec.Aliases` 写入,并与内部 origin `corecmd.flag_spec_aliases.v1` 成对出现;每次 Interface Integrity 都会在已提交的 detached candidate 上执行源码门禁,禁止其他生产文件写入或复刻这些 evidence token。Interface Snapshot 会验证:
|
||||
@@ -177,10 +205,11 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run`
|
||||
|
||||
## 豁免边界
|
||||
|
||||
一条 base-owned、状态正确且前后快照精确匹配的记录,只会从普通兼容报告中移除以下两类预期 finding:
|
||||
一条 base-owned、状态正确且前后快照精确匹配的记录,只会从普通兼容报告中移除以下三类预期 finding:
|
||||
|
||||
1. legacy flag 的 `flag_became_hidden`(visible → hidden);
|
||||
2. required legacy 被新增的 required canonical 替代时产生的 `required_flag_added`;如果 canonical 在 before 阶段只是 hidden 占位符,则允许它在转为公开拼写时继承 legacy 的 requiredness。已有的 visible canonical 不允许借 rename 改变 requiredness。
|
||||
3. `requiredness_change` 中同名 flag 从 optional 提升为 required 时产生的 `flag_became_required`。
|
||||
|
||||
以下变化仍按普通兼容规则阻塞,不能被迁移记录掩盖:
|
||||
|
||||
@@ -188,6 +217,7 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run`
|
||||
- flag 类型或迁移记录中的 scope、shorthand、`no_opt` 漂移;
|
||||
- `alias_of` 缺失、指向变化或 alias chain;
|
||||
- 命令路径及任何无关的阻塞性接口变化;
|
||||
- requiredness change 同时发生的 rename、隐藏、类型、scope、shorthand、`no_opt` 或 alias 漂移;
|
||||
- 不精确、部分完成、超出记录范围的 surface 变化。
|
||||
|
||||
## Schema 投影边界
|
||||
@@ -215,6 +245,12 @@ adapter 先构造经过上述验证的历史 contract 副本,再调用原 Sche
|
||||
canonical-only `after` 状态时不需要再次投影;adapter 保持 baseline 不变,由原 checker
|
||||
验证 candidate 是否仍与该 canonical contract 兼容。
|
||||
|
||||
`requiredness_change` 的 Schema adapter 只把历史同名 parameter 的 `required` 与
|
||||
`cli_required` 提升到 candidate 的 `true` 值,并要求 candidate 两者都为 `true`。parameter
|
||||
不存在、tool/path 不匹配时不制造 Schema surface;type、property、interface type、default、
|
||||
format、enum、`required_when`、constraints、positionals 与 safety 等全部字段仍交给原 checker,
|
||||
任何不相干漂移继续阻塞。
|
||||
|
||||
## 本地验证
|
||||
|
||||
先确保 merge-base 和 stable tag 已在本地,然后运行与 CI 相同的权威门禁:
|
||||
|
||||
@@ -192,6 +192,7 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token
|
||||
updated.CorpID = data.CorpID
|
||||
updated.UserID = data.UserID
|
||||
updated.UserName = data.UserName
|
||||
updated.RepairOrganizationMirror = data.RepairOrganizationMirror
|
||||
if updated.CorpName == "" {
|
||||
updated.CorpName = data.CorpName
|
||||
}
|
||||
@@ -239,6 +240,7 @@ func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*To
|
||||
updated.CorpID = data.CorpID
|
||||
updated.UserID = data.UserID
|
||||
updated.UserName = data.UserName
|
||||
updated.RepairOrganizationMirror = data.RepairOrganizationMirror
|
||||
if updated.CorpName == "" {
|
||||
updated.CorpName = data.CorpName
|
||||
}
|
||||
|
||||
@@ -811,7 +811,84 @@ func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) {
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("refreshing token (dual-locked)")
|
||||
}
|
||||
return oauthRefreshToken(p, ctx, data)
|
||||
refreshed, rErr := oauthRefreshToken(p, ctx, data)
|
||||
if rErr == nil || !isRefreshTokenRejected(rErr) {
|
||||
return refreshed, rErr
|
||||
}
|
||||
// A stale identity slot can survive an older organization-only refresh.
|
||||
// Retry once with the same-corp organization mirror while holding the
|
||||
// existing dual lock; the fallback marks the publication so the rotated
|
||||
// credential is written back into the mirror slot it consumed.
|
||||
logging.AuthDebug(
|
||||
"auth.refresh.fallback.triggered",
|
||||
"corp_id", strings.TrimSpace(data.CorpID),
|
||||
"user_id", strings.TrimSpace(data.UserID),
|
||||
"error", rErr,
|
||||
)
|
||||
fallback, fErr := p.refreshFromOrgSlot(ctx, data)
|
||||
if fErr != nil {
|
||||
logging.AuthDebug("auth.refresh.fallback.unavailable", "error", fErr)
|
||||
return nil, rErr
|
||||
}
|
||||
if p.logger != nil {
|
||||
p.logger.Warn(i18n.T("当前身份的 refresh_token 已失效,已从组织镜像 token 恢复登录态"))
|
||||
}
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
// refreshFromOrgSlot retries a rejected refresh with the token mirrored in
|
||||
// the organization slot. The mirror must match the current corp, be valid,
|
||||
// and differ from the rejected token. When both slots carry user identities,
|
||||
// they must agree; legacy mirrors with an empty UserID are backfilled from the
|
||||
// current identity before refresh.
|
||||
func (p *OAuthProvider) refreshFromOrgSlot(ctx context.Context, current *TokenData) (*TokenData, error) {
|
||||
if current == nil {
|
||||
return nil, fmt.Errorf("no current token data")
|
||||
}
|
||||
corpID := strings.TrimSpace(current.CorpID)
|
||||
if corpID == "" {
|
||||
return nil, fmt.Errorf("current token has no corpId")
|
||||
}
|
||||
orgData, err := tokenLoadKeychainForCorpID(corpID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if orgData == nil {
|
||||
return nil, ErrTokenDataNotFound
|
||||
}
|
||||
if strings.TrimSpace(orgData.CorpID) != corpID {
|
||||
return nil, fmt.Errorf("organization token mirror for corpId %q contains token for corpId %q; refusing refresh fallback", corpID, orgData.CorpID)
|
||||
}
|
||||
if !orgData.IsRefreshTokenValid() {
|
||||
return nil, fmt.Errorf("organization mirror refresh_token 已过期")
|
||||
}
|
||||
if orgData.RefreshToken == current.RefreshToken {
|
||||
return nil, fmt.Errorf("organization mirror holds the same rejected refresh_token")
|
||||
}
|
||||
currentUserID := strings.TrimSpace(current.UserID)
|
||||
orgUserID := strings.TrimSpace(orgData.UserID)
|
||||
if currentUserID != "" && orgUserID != "" && orgUserID != currentUserID {
|
||||
return nil, fmt.Errorf("organization token mirror for corpId %q belongs to userId %q; refusing refresh fallback for userId %q", corpID, orgData.UserID, current.UserID)
|
||||
}
|
||||
if orgUserID == "" {
|
||||
orgData.UserID = current.UserID
|
||||
orgData.UserName = current.UserName
|
||||
}
|
||||
// The refresh below consumes the mirror's refresh_token. Mark the
|
||||
// publication so persistence writes the rotated credential back into the
|
||||
// organization slot even under an explicit runtime selector whose plan
|
||||
// would otherwise skip it (for example a preserved unresolved sibling).
|
||||
orgData.RepairOrganizationMirror = true
|
||||
refreshed, err := oauthRefreshToken(p, ctx, orgData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logging.AuthDebug(
|
||||
"auth.refresh.fallback.success",
|
||||
"corp_id", corpID,
|
||||
"new_at_expires_at", refreshed.ExpiresAt.Format(time.RFC3339),
|
||||
)
|
||||
return refreshed, nil
|
||||
}
|
||||
|
||||
// ExchangeAuthCode takes an AuthCode and an optional UserID provided by an
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageIsRefreshTokenRejected(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{"nil", nil, false},
|
||||
{"mcp authCode.notFound", &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}, true},
|
||||
{"mcp other business code", &MCPTokenExchangeError{Code: "other.error", Message: "boom"}, false},
|
||||
{"wrapped mcp rejection", fmt.Errorf("refresh: %w", &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode}), true},
|
||||
{"http 400 has no reviewed business code", &HTTPStatusError{StatusCode: http.StatusBadRequest}, false},
|
||||
{"http 401 has no reviewed business code", &HTTPStatusError{StatusCode: http.StatusUnauthorized}, false},
|
||||
{"http 403 has no reviewed business code", &HTTPStatusError{StatusCode: http.StatusForbidden}, false},
|
||||
{"http 500 is transient", &HTTPStatusError{StatusCode: http.StatusInternalServerError}, false},
|
||||
{"http 429 is transient", &HTTPStatusError{StatusCode: http.StatusTooManyRequests}, false},
|
||||
{"plain error is unknown", errors.New("boom"), false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isRefreshTokenRejected(tt.err); got != tt.want {
|
||||
t.Fatalf("isRefreshTokenRejected(%v) = %v, want %v", tt.err, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// orgSlotFallbackFixture wires the injectable seams lockedRefresh depends on
|
||||
// and records refresh attempts plus organization slot lookups.
|
||||
type orgSlotFallbackFixture struct {
|
||||
provider *OAuthProvider
|
||||
stale *TokenData
|
||||
orgMirror *TokenData
|
||||
renewed *TokenData
|
||||
rejected *MCPTokenExchangeError
|
||||
refreshErr error
|
||||
orgRefreshErr error
|
||||
refreshCalls []string
|
||||
refreshUserIDs []string
|
||||
orgLoads int
|
||||
}
|
||||
|
||||
func newOrgSlotFallbackFixture(t *testing.T) *orgSlotFallbackFixture {
|
||||
t.Helper()
|
||||
isolateOAuthPersistence(t)
|
||||
|
||||
f := &orgSlotFallbackFixture{
|
||||
provider: &OAuthProvider{configDir: t.TempDir(), logger: slog.New(slog.NewTextHandler(io.Discard, nil)), Output: io.Discard},
|
||||
stale: &TokenData{
|
||||
AccessToken: "old-access",
|
||||
RefreshToken: "stale-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-1",
|
||||
UserID: "user-1",
|
||||
},
|
||||
orgMirror: &TokenData{
|
||||
AccessToken: "org-access",
|
||||
RefreshToken: "org-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Minute),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-1",
|
||||
UserID: "user-1",
|
||||
},
|
||||
renewed: &TokenData{
|
||||
AccessToken: "new-access",
|
||||
RefreshToken: "new-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: "corp-1",
|
||||
UserID: "user-1",
|
||||
},
|
||||
rejected: &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"},
|
||||
}
|
||||
f.refreshErr = f.rejected
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(configDir, _ string) (*TokenData, error) { return oauthLoadToken(configDir) })
|
||||
testseam.Swap(t, &oauthLoadToken, func(string) (*TokenData, error) { return f.stale, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(_ *OAuthProvider, _ context.Context, data *TokenData) (*TokenData, error) {
|
||||
f.refreshCalls = append(f.refreshCalls, data.RefreshToken)
|
||||
f.refreshUserIDs = append(f.refreshUserIDs, data.UserID)
|
||||
switch data.RefreshToken {
|
||||
case "stale-refresh":
|
||||
return nil, f.refreshErr
|
||||
case "org-refresh":
|
||||
return f.renewed, f.orgRefreshErr
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected refresh token %q", data.RefreshToken)
|
||||
})
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(corpID string) (*TokenData, error) {
|
||||
f.orgLoads++
|
||||
if corpID != "corp-1" {
|
||||
return nil, ErrTokenDataNotFound
|
||||
}
|
||||
return f.orgMirror, nil
|
||||
})
|
||||
return f
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLockedRefreshFallsBackToOrgSlot(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
|
||||
got, err := f.provider.lockedRefresh(context.Background())
|
||||
if err != nil || got != f.renewed {
|
||||
t.Fatalf("lockedRefresh() = %#v, %v; want renewed token, nil", got, err)
|
||||
}
|
||||
if len(f.refreshCalls) != 2 || f.refreshCalls[0] != "stale-refresh" || f.refreshCalls[1] != "org-refresh" {
|
||||
t.Fatalf("refresh attempts = %v, want [stale-refresh org-refresh]", f.refreshCalls)
|
||||
}
|
||||
if f.orgLoads != 1 {
|
||||
t.Fatalf("organization slot loads = %d, want 1", f.orgLoads)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRepairMarkerForcesOrganizationSlotWrite(t *testing.T) {
|
||||
cfg := &ProfilesConfig{
|
||||
Version: profilesVersion,
|
||||
Profiles: []Profile{
|
||||
{Name: "legacy", CorpID: "corp-1", UserID: ""},
|
||||
{Name: "user-1", CorpID: "corp-1", UserID: "user-1"},
|
||||
},
|
||||
}
|
||||
selector := profileSelector("corp-1", "user-1")
|
||||
|
||||
without := &TokenData{CorpID: "corp-1", UserID: "user-1"}
|
||||
if plan := planTokenPersistenceWrites(cfg, without, selector); plan.WriteOrganization {
|
||||
t.Fatalf("explicit selector preserved unresolved org slot: WriteOrganization = true, want false")
|
||||
}
|
||||
|
||||
with := &TokenData{CorpID: "corp-1", UserID: "user-1", RepairOrganizationMirror: true}
|
||||
if plan := planTokenPersistenceWrites(cfg, with, selector); !plan.WriteOrganization {
|
||||
t.Fatalf("repair marker did not force the organization slot write")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLockedRefreshFallbackRepairsPersistedSlots(t *testing.T) {
|
||||
isolateOAuthPersistence(t)
|
||||
t.Setenv("DWS_CLIENT_ID", "")
|
||||
t.Setenv("DWS_CLIENT_SECRET", "")
|
||||
|
||||
// Fake MCP refresh endpoint: the first call rejects the stale identity
|
||||
// refresh_token with the reviewed business code; the second call (the
|
||||
// organization mirror) succeeds and returns a rotated credential.
|
||||
var refreshCalls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if refreshCalls.Add(1) == 1 {
|
||||
fmt.Fprint(w, `{"errorCode":"invalidParameter.authCode.notFound","errorMsg":"authCode not found"}`)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, `{"accessToken":"new-access","refreshToken":"new-refresh","expiresIn":7200,"corpId":"corp-1","userId":"user-1","userName":"User One"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
configDir := setupMCPConfigDir(t, srv.URL)
|
||||
resetAppConfigCache()
|
||||
|
||||
// Seed the pre-fallback state: an identity slot whose refresh_token the
|
||||
// server rejects, plus a legacy organization mirror (no userId) with a
|
||||
// still-valid refresh_token and a preserved unresolved sibling profile so
|
||||
// an explicit --profile refresh would normally skip the org slot.
|
||||
cfg := &ProfilesConfig{
|
||||
Version: profilesVersion,
|
||||
Profiles: []Profile{
|
||||
{Name: "corp-1", CorpID: "corp-1", UserID: "", ClientID: "mcp-client"},
|
||||
{Name: "user-1", CorpID: "corp-1", UserID: "user-1", UserName: "User One", ClientID: "mcp-client"},
|
||||
},
|
||||
}
|
||||
if err := SaveProfiles(configDir, cfg); err != nil {
|
||||
t.Fatalf("SaveProfiles() error = %v", err)
|
||||
}
|
||||
orgMirror := &TokenData{
|
||||
AccessToken: "org-access",
|
||||
RefreshToken: "org-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Minute),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-1",
|
||||
Source: "mcp",
|
||||
ClientID: "mcp-client",
|
||||
}
|
||||
if err := SaveTokenDataKeychainForCorpID("corp-1", orgMirror); err != nil {
|
||||
t.Fatalf("SaveTokenDataKeychainForCorpID() error = %v", err)
|
||||
}
|
||||
staleIdentity := &TokenData{
|
||||
AccessToken: "stale-access",
|
||||
RefreshToken: "stale-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-1",
|
||||
UserID: "user-1",
|
||||
UserName: "User One",
|
||||
Source: "mcp",
|
||||
ClientID: "mcp-client",
|
||||
}
|
||||
if err := SaveTokenDataKeychainForIdentity("corp-1", "user-1", staleIdentity); err != nil {
|
||||
t.Fatalf("SaveTokenDataKeychainForIdentity() error = %v", err)
|
||||
}
|
||||
|
||||
SetRuntimeProfile("corp-1:user-1")
|
||||
t.Cleanup(func() { SetRuntimeProfile("") })
|
||||
|
||||
p := &OAuthProvider{
|
||||
configDir: configDir,
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
Output: io.Discard,
|
||||
httpClient: srv.Client(),
|
||||
}
|
||||
got, err := p.lockedRefresh(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("lockedRefresh() error = %v", err)
|
||||
}
|
||||
if got == nil || got.AccessToken != "new-access" || got.RefreshToken != "new-refresh" {
|
||||
t.Fatalf("lockedRefresh() = %#v, want rotated credential", got)
|
||||
}
|
||||
if refreshCalls.Load() != 2 {
|
||||
t.Fatalf("MCP refresh calls = %d, want primary rejection plus fallback", refreshCalls.Load())
|
||||
}
|
||||
|
||||
// The fallback consumed the mirror's refresh_token: both persisted slots
|
||||
// must now carry the rotated credential instead of the consumed one.
|
||||
orgSlot, err := LoadTokenDataKeychainForCorpID("corp-1")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataKeychainForCorpID() error = %v", err)
|
||||
}
|
||||
if orgSlot.RefreshToken != "new-refresh" || orgSlot.UserID != "user-1" {
|
||||
t.Fatalf("organization slot = %#v, want new-refresh for user-1", orgSlot)
|
||||
}
|
||||
identitySlot, err := LoadTokenDataKeychainForIdentity("corp-1", "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataKeychainForIdentity() error = %v", err)
|
||||
}
|
||||
if identitySlot.RefreshToken != "new-refresh" {
|
||||
t.Fatalf("identity slot = %#v, want new-refresh", identitySlot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLockedRefreshOrgSlotFallbackGuardrails(t *testing.T) {
|
||||
t.Run("transient failure does not fall back", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
f.refreshErr = &HTTPStatusError{StatusCode: http.StatusInternalServerError}
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if err == nil || err.Error() != f.refreshErr.Error() {
|
||||
t.Fatalf("lockedRefresh() error = %v, want transient failure", err)
|
||||
}
|
||||
if f.orgLoads != 0 {
|
||||
t.Fatalf("organization slot loads = %d, want 0 for transient failure", f.orgLoads)
|
||||
}
|
||||
if len(f.refreshCalls) != 1 {
|
||||
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing org slot preserves rejection", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) {
|
||||
f.orgLoads++
|
||||
return nil, ErrTokenDataNotFound
|
||||
})
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if !errors.Is(err, f.rejected) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if len(f.refreshCalls) != 1 {
|
||||
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil org data from keychain preserves rejection", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) {
|
||||
f.orgLoads++
|
||||
return nil, nil
|
||||
})
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if !errors.Is(err, f.rejected) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if len(f.refreshCalls) != 1 {
|
||||
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("org slot refresh failure preserves rejection", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
f.orgRefreshErr = fmt.Errorf("org mirror refresh failed")
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if !errors.Is(err, f.rejected) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if len(f.refreshCalls) != 2 {
|
||||
t.Fatalf("refresh attempts = %v, want primary and fallback attempts", f.refreshCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("different user in org slot is rejected", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
f.orgMirror.UserID = "user-2"
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if !errors.Is(err, f.rejected) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if len(f.refreshCalls) != 1 {
|
||||
t.Fatalf("refresh attempts = %v, mismatched user must not refresh", f.refreshCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty org slot user identity is backfilled", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
f.orgMirror.UserID = ""
|
||||
f.orgMirror.UserName = ""
|
||||
|
||||
got, err := f.provider.lockedRefresh(context.Background())
|
||||
if err != nil || got != f.renewed {
|
||||
t.Fatalf("lockedRefresh() = %#v, %v; want renewed token, nil", got, err)
|
||||
}
|
||||
if len(f.refreshCalls) != 2 {
|
||||
t.Fatalf("refresh attempts = %v, want fallback attempt", f.refreshCalls)
|
||||
}
|
||||
if f.refreshUserIDs[1] != "user-1" {
|
||||
t.Fatalf("fallback refresh UserID = %q, want backfilled current identity user-1", f.refreshUserIDs[1])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("same rejected refresh token is skipped", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
f.orgMirror.RefreshToken = "stale-refresh"
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if !errors.Is(err, f.rejected) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if len(f.refreshCalls) != 1 {
|
||||
t.Fatalf("refresh attempts = %v, retrying the rejected token must not run", f.refreshCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("expired org refresh token is skipped", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
f.orgMirror.RefreshExpAt = time.Now().Add(-time.Hour)
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if !errors.Is(err, f.rejected) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if len(f.refreshCalls) != 1 {
|
||||
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing corp id skips fallback", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
f.stale.CorpID = ""
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if !errors.Is(err, f.rejected) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if f.orgLoads != 0 {
|
||||
t.Fatalf("organization slot loads = %d, want 0 without corpId", f.orgLoads)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("direct mode terminal status does not fall back without business code", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
f.refreshErr = &HTTPStatusError{StatusCode: http.StatusBadRequest}
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if err == nil || err.Error() != f.refreshErr.Error() {
|
||||
t.Fatalf("lockedRefresh() error = %v, want direct terminal status", err)
|
||||
}
|
||||
if f.orgLoads != 0 {
|
||||
t.Fatalf("fallback slot loads = %d, want 0 without reviewed business code", f.orgLoads)
|
||||
}
|
||||
if len(f.refreshCalls) != 1 {
|
||||
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRefreshFromOrgSlotBoundaries(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
|
||||
if _, err := f.provider.refreshFromOrgSlot(context.Background(), nil); err == nil {
|
||||
t.Fatal("refreshFromOrgSlot(nil) succeeded")
|
||||
}
|
||||
f.orgMirror.CorpID = "corp-2"
|
||||
if _, err := f.provider.refreshFromOrgSlot(context.Background(), f.stale); err == nil {
|
||||
t.Fatal("refreshFromOrgSlot with mismatched corpId succeeded")
|
||||
}
|
||||
if len(f.refreshCalls) != 0 {
|
||||
t.Fatalf("refresh attempts = %v, corpId mismatch must not refresh", f.refreshCalls)
|
||||
}
|
||||
}
|
||||
@@ -87,3 +87,16 @@ func ClassifyRefreshFailure(err error) RefreshFailureClass {
|
||||
}
|
||||
return RefreshFailureUnknown
|
||||
}
|
||||
|
||||
// isRefreshTokenRejected reports whether the server returned a reviewed
|
||||
// business code that definitively rejects the presented refresh_token.
|
||||
// Only the reviewed MCP business code enables the organization-slot
|
||||
// fallback; direct-mode terminal HTTP rejections (400/401/403) carry no
|
||||
// reviewed business code and deliberately do not trigger the fallback.
|
||||
func isRefreshTokenRejected(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var exchangeErr *MCPTokenExchangeError
|
||||
return errors.As(err, &exchangeErr) && exchangeErr != nil && exchangeErr.requiresReauthorization()
|
||||
}
|
||||
|
||||
+16
-1
@@ -107,6 +107,13 @@ type TokenData struct {
|
||||
// transient marker to reject ambiguous UID-less logins without breaking
|
||||
// legitimate refreshes of unresolved accounts.
|
||||
FreshAuthorization bool `json:"-"`
|
||||
// RepairOrganizationMirror marks a fallback refresh that consumed the
|
||||
// organization mirror's refresh_token. The regular write plan can skip the
|
||||
// organization slot under an explicit runtime selector (for example when an
|
||||
// unresolved sibling profile still owns it), which would strand a
|
||||
// refresh_token the server has already rotated; the marker forces the
|
||||
// rotated credential back into that slot.
|
||||
RepairOrganizationMirror bool `json:"-"`
|
||||
}
|
||||
|
||||
// tokenPersistenceWritePlan is the single source of truth for deciding which
|
||||
@@ -127,6 +134,7 @@ type tokenPersistenceWritePlan struct {
|
||||
ExistingIdentity bool
|
||||
UpgradesLegacyProfile bool
|
||||
PreserveUnresolvedOrganization bool
|
||||
RepairOrganizationMirror bool
|
||||
WriteIdentity bool
|
||||
WriteOrganization bool
|
||||
WriteGlobal bool
|
||||
@@ -167,6 +175,11 @@ func planTokenPersistenceWrites(
|
||||
plan.PreserveUnresolvedOrganization = plan.UserID != "" &&
|
||||
unresolvedProfileForCorp(cfg, plan.CorpID) != nil &&
|
||||
!plan.UpgradesLegacyProfile
|
||||
// A fallback refresh consumed the organization mirror's refresh_token;
|
||||
// the rotated credential must go back into that slot even when the
|
||||
// selector-driven plan would skip it (for example an explicit --profile
|
||||
// that preserves an unresolved sibling profile's slot).
|
||||
plan.RepairOrganizationMirror = data.RepairOrganizationMirror
|
||||
plan.WriteIdentity = plan.UserID != ""
|
||||
orgCurrentSelector := ""
|
||||
if cfg != nil {
|
||||
@@ -177,7 +190,8 @@ func planTokenPersistenceWrites(
|
||||
// reauthorization. Its organization slot must move with the newly exact
|
||||
// identity even when an explicit runtime selector keeps it from becoming
|
||||
// process-global current.
|
||||
plan.WriteOrganization = plan.UserID == "" ||
|
||||
plan.WriteOrganization = plan.RepairOrganizationMirror ||
|
||||
plan.UserID == "" ||
|
||||
plan.UpgradesLegacyProfile ||
|
||||
(!plan.PreserveUnresolvedOrganization &&
|
||||
(plan.MakeCurrent ||
|
||||
@@ -387,6 +401,7 @@ func saveTokenDataLocked(configDir string, data *TokenData) error {
|
||||
"persistence_profile", plan.PersistenceSelector,
|
||||
"write_identity_slot", plan.WriteIdentity,
|
||||
"write_org_mirror", plan.WriteOrganization,
|
||||
"repair_org_mirror", plan.RepairOrganizationMirror,
|
||||
"write_global_mirror", plan.WriteGlobal,
|
||||
"publish_incoming_global", plan.MakeCurrent,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
// Package commentreaction validates the reviewed DingTalk names accepted by
|
||||
// comment emoji replies. The names mirror the bundled default emoji catalog;
|
||||
// arbitrary text and raw Unicode emoji must never be persisted as reactions.
|
||||
package commentreaction
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
// Validate rejects values outside DingTalk's reviewed default reaction-name
|
||||
// catalog. Names are case-sensitive because they are sent verbatim to the
|
||||
// doc-comment service.
|
||||
func Validate(value string) error {
|
||||
name := strings.TrimSpace(value)
|
||||
if value != name || !utf8.ValidString(name) || !supported(name) {
|
||||
return apperrors.NewValidation(
|
||||
"--reaction/--content 必须是受支持的钉钉表情名称(如 憨笑、鼓掌、比心、赞),不要传 Unicode Emoji 或任意文本",
|
||||
apperrors.WithReason("unsupported_comment_reaction"),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func supported(name string) bool {
|
||||
switch name {
|
||||
case "微笑", "可爱", "憨笑", "色", "发呆", "老板", "傻笑", "流泪", "害羞", "闭嘴",
|
||||
"睡", "大哭", "尴尬", "感谢", "拒绝", "赞", "鼓掌", "打招呼", "666", "抱拳",
|
||||
"握手", "OK", "胜利", "向左", "向右", "向上", "向下", "来呀", "一点点", "捏住",
|
||||
"比心", "送花花", "加油干", "调皮", "大笑", "惊讶", "流汗", "奋斗", "口罩", "生病",
|
||||
"吐", "难过", "抓狂", "右哼哼", "太阳", "月亮", "强", "弱", "彩带", "蛋糕",
|
||||
"骷髅", "撇嘴", "鄙视", "嘘", "思考", "亲亲", "无奈", "感冒", "对不起", "再见",
|
||||
"投降", "哼", "欠扁", "拜托", "可怜", "舒服", "爱意", "财迷", "迷惑", "委屈",
|
||||
"灵感", "天使", "鬼脸", "凄凉", "郁闷", "坏笑", "算账", "PK", "忍者", "衰",
|
||||
"炸弹", "笑哭", "嘿嘿", "捂脸哭", "抠鼻", "流鼻血", "呲牙", "吃瓜", "彩虹", "耶",
|
||||
"发怒", "捂眼睛", "推眼镜", "暗中观察", "脑暴", "冷笑", "热", "开心", "惊喜", "回头",
|
||||
"白眼", "一团乱麻", "黑眼圈", "裂开", "恭喜", "费解", "收到", "快来", "敲打", "捧脸",
|
||||
"Get", "客服", "AR", "小蜜蜂", "虎虎生威", "兔飞猛进", "龙头老大", "蛇来运转", "马上来财", "专注",
|
||||
"忙疯了", "等一等", "一脸苦笑", "王之蔑视", "洪荒之力", "向左看", "向右看", "YYDS", "这边请", "弹射下班",
|
||||
"退退退", "在吗", "让人头大", "摊手", "抱抱", "举手", "开车", "抱大腿", "跪了", "鞠躬",
|
||||
"选我", "元气满满", "会议", "猫咪", "二哈", "狗子", "三多", "承让", "撒花", "礼物",
|
||||
"生日快乐", "爱心", "心碎", "嘴唇", "鲜花", "残花", "干杯", "咖啡", "奶茶", "茶",
|
||||
"OKR", "KPI", "100分", "对勾", "打叉", "气泡", "加一", "Done", "钉子", "出差",
|
||||
"高铁", "火箭", "邮件", "文档", "演示", "表格", "废纸篓", "手机", "时间", "静音",
|
||||
"公文包", "地球", "碳减排", "回收标志", "幼苗", "红包", "锦鲤", "福", "灯笼", "爆竹",
|
||||
"烟花", "恭喜发财", "月饼", "鸡腿", "休假", "火", "点赞", "平安健康", "定胜":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package commentreaction
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCrossPlatformCoverageValidate(t *testing.T) {
|
||||
for _, value := range []string{"憨笑", "鼓掌", "比心", "赞", "OK", "Done", "平安健康"} {
|
||||
if err := Validate(value); err != nil {
|
||||
t.Errorf("supported reaction %q rejected: %v", value, err)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"", "😄", "👏", "�", "garbled", "乱码", "憨笑\n"} {
|
||||
if err := Validate(value); err == nil {
|
||||
t.Errorf("unsupported reaction %q accepted", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/commentreaction"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -231,6 +232,9 @@ Unicode Emoji。例如用户要求 😄 时传“憨笑”,要求 👏 时传
|
||||
if err := validateRequiredFlags(cmd, "comment-key", "reaction"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := commentreaction.Validate(mustGetFlag(cmd, "reaction")); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer(commentServer, "reply_comment", map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"replyCommentKey": mustGetFlag(cmd, "comment-key"),
|
||||
|
||||
@@ -135,6 +135,29 @@ func TestCrossPlatformCoverageCommentReactReplyForcesEmojiTrue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommentReactionsRejectUnsupportedValuesBeforeRPC(t *testing.T) {
|
||||
for _, surface := range []string{"doc", "sheet"} {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "react unicode", args: []string{"comment", "react-reply", "--node", "node-1", "--comment-key", "comment-1", "--reaction", "😄"}},
|
||||
{name: "react garbage", args: []string{"comment", "react-reply", "--node", "node-1", "--comment-key", "comment-1", "--reaction", "乱码"}},
|
||||
{name: "reply unicode", args: []string{"comment", "reply", "--node", "node-1", "--comment-key", "comment-1", "--content", "👏", "--emoji"}},
|
||||
} {
|
||||
t.Run(surface+"/"+test.name, func(t *testing.T) {
|
||||
caller := &docCommentMutationCaller{}
|
||||
if err := executeCommentBaseCommand(t, caller, surface, test.args...); err == nil {
|
||||
t.Fatal("unsupported reaction accepted")
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("unsupported reaction reached RPC: %#v", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommentReactReplyGuidesDingTalkEmojiNames(t *testing.T) {
|
||||
for _, surface := range []struct {
|
||||
name string
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/commentreaction"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
@@ -3220,6 +3221,9 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
"replyCommentKey": mustGetFlag(cmd, "comment-key"),
|
||||
}
|
||||
if v, _ := cmd.Flags().GetBool("emoji"); v {
|
||||
if err := commentreaction.Validate(mustGetFlag(cmd, "content")); err != nil {
|
||||
return err
|
||||
}
|
||||
groupMentions, err := commentGroupMentionIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -219,7 +219,7 @@ func TestCrossPlatformCoverageDocCommentGroupMentionValidationAndCompatibility(t
|
||||
err := executeDocGroupMentionCommand(
|
||||
t,
|
||||
caller,
|
||||
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "like",
|
||||
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "赞",
|
||||
"--emoji", "--mentioned-open-conversation-id", "oc-1",
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "emoji replies do not support group mentions") {
|
||||
@@ -235,7 +235,7 @@ func TestCrossPlatformCoverageDocCommentGroupMentionValidationAndCompatibility(t
|
||||
err := executeDocGroupMentionCommand(
|
||||
t,
|
||||
caller,
|
||||
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "like",
|
||||
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "赞",
|
||||
"--emoji", "--mentioned-open-conversation-id", " ",
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "must not be empty") {
|
||||
@@ -251,7 +251,7 @@ func TestCrossPlatformCoverageDocCommentGroupMentionValidationAndCompatibility(t
|
||||
err := executeDocGroupMentionCommand(
|
||||
t,
|
||||
caller,
|
||||
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "like",
|
||||
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "赞",
|
||||
"--emoji",
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/commentreaction"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -154,6 +155,9 @@ func newSheetCommentCmd() *cobra.Command {
|
||||
"replyCommentKey": mustGetFlag(cmd, "comment-key"),
|
||||
}
|
||||
if v, _ := cmd.Flags().GetBool("emoji"); v {
|
||||
if err := commentreaction.Validate(mustGetFlag(cmd, "content")); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["emoji"] = true
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("mention"); v != "" {
|
||||
|
||||
@@ -93,7 +93,7 @@ func TestCrossPlatformCoverageSheetCommentCommands(t *testing.T) {
|
||||
"comment", "reply",
|
||||
"--node", "node-1",
|
||||
"--comment-key", "ck-1",
|
||||
"--content", "heart",
|
||||
"--content", "比心",
|
||||
"--emoji",
|
||||
"--mention", "uid1",
|
||||
},
|
||||
@@ -102,7 +102,7 @@ func TestCrossPlatformCoverageSheetCommentCommands(t *testing.T) {
|
||||
toolName: "reply_comment",
|
||||
args: map[string]any{
|
||||
"nodeId": "node-1",
|
||||
"content": "heart",
|
||||
"content": "比心",
|
||||
"replyCommentKey": "ck-1",
|
||||
"emoji": true,
|
||||
"mentionedUserIds": []string{"uid1"},
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
"工具调用失败;请检查参数和上游服务状态。": "Tool invocation failed; check parameters and upstream service status.",
|
||||
"已取消操作": "Operation cancelled",
|
||||
"当前平台 %s 没有可用的预编译二进制": "No pre-built binary available for platform %s",
|
||||
"当前身份的 refresh_token 已失效,已从组织镜像 token 恢复登录态": "The current identity's refresh_token is invalid; login state was recovered from the organization mirror token.",
|
||||
"待办": "todo",
|
||||
"待办任务 ID (必填)": "Todo task ID (required)",
|
||||
"待办任务管理": "Todo task management",
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
"工具调用失败;请检查参数和上游服务状态。": "工具调用失败;请检查参数和上游服务状态。",
|
||||
"已取消操作": "已取消操作",
|
||||
"当前平台 %s 没有可用的预编译二进制": "当前平台 %s 没有可用的预编译二进制",
|
||||
"当前身份的 refresh_token 已失效,已从组织镜像 token 恢复登录态": "当前身份的 refresh_token 已失效,已从组织镜像 token 恢复登录态",
|
||||
"待办": "待办",
|
||||
"待办任务 ID (必填)": "待办任务 ID (必填)",
|
||||
"待办任务管理": "待办任务管理",
|
||||
|
||||
@@ -17,6 +17,9 @@ const FlagMigrationManifestVersion = 1
|
||||
const (
|
||||
FlagMigrationPending = "pending"
|
||||
FlagMigrationConsumed = "consumed"
|
||||
|
||||
FlagMigrationRename = "flag_rename"
|
||||
FlagMigrationRequirednessChange = "requiredness_change"
|
||||
)
|
||||
|
||||
type FlagMigrationManifest struct {
|
||||
@@ -25,11 +28,13 @@ type FlagMigrationManifest struct {
|
||||
}
|
||||
|
||||
type FlagMigration struct {
|
||||
Command string `json:"command"`
|
||||
Legacy FlagMigrationSide `json:"legacy"`
|
||||
Canonical FlagMigrationSide `json:"canonical"`
|
||||
State string `json:"state"`
|
||||
Reason string `json:"reason"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Command string `json:"command"`
|
||||
Legacy FlagMigrationSide `json:"legacy"`
|
||||
Canonical FlagMigrationSide `json:"canonical"`
|
||||
Flag *FlagMigrationSide `json:"flag,omitempty"`
|
||||
State string `json:"state"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type FlagMigrationSide struct {
|
||||
@@ -287,6 +292,23 @@ func (m FlagMigrationManifest) Validate() error {
|
||||
}
|
||||
legacyTargets[legacyKey] = migration.Canonical.Name
|
||||
}
|
||||
for _, requiredness := range m.Migrations {
|
||||
if requiredness.EffectiveKind() != FlagMigrationRequirednessChange {
|
||||
continue
|
||||
}
|
||||
for _, rename := range m.Migrations {
|
||||
if rename.EffectiveKind() != FlagMigrationRename || rename.Command != requiredness.Command {
|
||||
continue
|
||||
}
|
||||
if rename.Legacy.Name == requiredness.Flag.Name || rename.Canonical.Name == requiredness.Flag.Name {
|
||||
return fmt.Errorf(
|
||||
"flag requiredness migration %s overlaps rename migration %s",
|
||||
requiredness.displayKey(),
|
||||
rename.displayKey(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -294,15 +316,6 @@ func (m FlagMigration) validate() error {
|
||||
if !isExactCommandPath(m.Command) {
|
||||
return fmt.Errorf("command must be an exact command path rooted at dws: %q", m.Command)
|
||||
}
|
||||
if !isExactFlagName(m.Legacy.Name) {
|
||||
return fmt.Errorf("legacy name must be an exact legacy flag: %q", m.Legacy.Name)
|
||||
}
|
||||
if !isExactFlagName(m.Canonical.Name) {
|
||||
return fmt.Errorf("canonical name must be an exact canonical flag: %q", m.Canonical.Name)
|
||||
}
|
||||
if m.Legacy.Name == m.Canonical.Name {
|
||||
return fmt.Errorf("legacy and canonical flags must differ: --%s", m.Legacy.Name)
|
||||
}
|
||||
if strings.TrimSpace(m.Reason) == "" {
|
||||
return fmt.Errorf("migration must include a non-empty reason")
|
||||
}
|
||||
@@ -312,6 +325,30 @@ func (m FlagMigration) validate() error {
|
||||
if m.State != FlagMigrationPending && m.State != FlagMigrationConsumed {
|
||||
return fmt.Errorf("invalid state %q", m.State)
|
||||
}
|
||||
|
||||
switch m.EffectiveKind() {
|
||||
case FlagMigrationRename:
|
||||
return m.validateRename()
|
||||
case FlagMigrationRequirednessChange:
|
||||
return m.validateRequirednessChange()
|
||||
default:
|
||||
return fmt.Errorf("invalid kind %q", m.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func (m FlagMigration) validateRename() error {
|
||||
if m.Flag != nil {
|
||||
return fmt.Errorf("flag rename must not declare flag requiredness fields")
|
||||
}
|
||||
if !isExactFlagName(m.Legacy.Name) {
|
||||
return fmt.Errorf("legacy name must be an exact legacy flag: %q", m.Legacy.Name)
|
||||
}
|
||||
if !isExactFlagName(m.Canonical.Name) {
|
||||
return fmt.Errorf("canonical name must be an exact canonical flag: %q", m.Canonical.Name)
|
||||
}
|
||||
if m.Legacy.Name == m.Canonical.Name {
|
||||
return fmt.Errorf("legacy and canonical flags must differ: --%s", m.Legacy.Name)
|
||||
}
|
||||
if err := m.Legacy.Before.validate("legacy before"); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -392,6 +429,45 @@ func (m FlagMigration) validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m FlagMigration) validateRequirednessChange() error {
|
||||
if m.Legacy != (FlagMigrationSide{}) || m.Canonical != (FlagMigrationSide{}) {
|
||||
return fmt.Errorf("flag requiredness migration must not declare legacy or canonical rename fields")
|
||||
}
|
||||
if m.Flag == nil || !isExactFlagName(m.Flag.Name) {
|
||||
return fmt.Errorf("flag name must be an exact flag: %q", flagMigrationSideName(m.Flag))
|
||||
}
|
||||
if err := m.Flag.Before.validate("flag before"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.Flag.After.validate("flag after"); err != nil {
|
||||
return err
|
||||
}
|
||||
if !m.Flag.Before.Present || !m.Flag.After.Present || m.Flag.Before.Required || !m.Flag.After.Required {
|
||||
return fmt.Errorf("flag requiredness migration must change exactly from optional to required")
|
||||
}
|
||||
if m.Flag.Before.Hidden || m.Flag.After.Hidden {
|
||||
return fmt.Errorf("flag requiredness migration flag must stay visible")
|
||||
}
|
||||
if m.Flag.Before.AliasOf != "" || m.Flag.After.AliasOf != "" {
|
||||
return fmt.Errorf("flag requiredness migration must not declare alias_of")
|
||||
}
|
||||
before := m.Flag.Before
|
||||
after := m.Flag.After
|
||||
before.Required = false
|
||||
after.Required = false
|
||||
if before != after {
|
||||
return fmt.Errorf("flag requiredness migration must preserve every flag attribute except requiredness")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func flagMigrationSideName(side *FlagMigrationSide) string {
|
||||
if side == nil {
|
||||
return ""
|
||||
}
|
||||
return side.Name
|
||||
}
|
||||
|
||||
func (s FlagMigrationState) validate(label string) error {
|
||||
if !s.Present {
|
||||
if s.Type != "" || s.Required || s.Hidden || s.Shorthand != "" || s.NoOpt != "" || s.Scope != "" || s.AliasOf != "" {
|
||||
@@ -409,7 +485,19 @@ func (s FlagMigrationState) validate(label string) error {
|
||||
}
|
||||
|
||||
func (m FlagMigration) key() string {
|
||||
return m.Command + "\x00" + m.Legacy.Name + "\x00" + m.Canonical.Name
|
||||
if m.EffectiveKind() == FlagMigrationRequirednessChange {
|
||||
return m.EffectiveKind() + "\x00" + m.Command + "\x00" + flagMigrationSideName(m.Flag)
|
||||
}
|
||||
return m.EffectiveKind() + "\x00" + m.Command + "\x00" + m.Legacy.Name + "\x00" + m.Canonical.Name
|
||||
}
|
||||
|
||||
// EffectiveKind keeps manifests written before kinds were introduced valid.
|
||||
// An omitted kind is the original flag rename primitive.
|
||||
func (m FlagMigration) EffectiveKind() string {
|
||||
if m.Kind == "" {
|
||||
return FlagMigrationRename
|
||||
}
|
||||
return m.Kind
|
||||
}
|
||||
|
||||
func isExactCommandPath(path string) bool {
|
||||
@@ -428,9 +516,9 @@ func isExactFlagName(name string) bool {
|
||||
}
|
||||
|
||||
// CompareAllWithFlagMigrations applies the ordinary compatibility policy and
|
||||
// then consumes only exact, merge-base-owned flag migrations. Candidate-owned
|
||||
// records participate in the lifecycle check, but never authorize their own
|
||||
// interface change.
|
||||
// then consumes only exact, merge-base-owned flag rename or requiredness
|
||||
// migrations. Candidate-owned records participate in the lifecycle check, but
|
||||
// never authorize their own interface change.
|
||||
func CompareAllWithFlagMigrations(
|
||||
current Snapshot,
|
||||
references map[string]Snapshot,
|
||||
@@ -640,6 +728,16 @@ func matchFlagMigrationPhase(snapshot Snapshot, migration FlagMigration) flagMig
|
||||
if !exists {
|
||||
return flagMigrationPartial
|
||||
}
|
||||
if migration.EffectiveKind() == FlagMigrationRequirednessChange {
|
||||
state := flagMigrationStateForCommand(command, flagMigrationSideName(migration.Flag))
|
||||
if state == migration.Flag.Before {
|
||||
return flagMigrationBefore
|
||||
}
|
||||
if state == migration.Flag.After {
|
||||
return flagMigrationAfter
|
||||
}
|
||||
return flagMigrationPartial
|
||||
}
|
||||
legacy := flagMigrationStateForCommand(command, migration.Legacy.Name)
|
||||
canonical := flagMigrationStateForCommand(command, migration.Canonical.Name)
|
||||
if legacy == migration.Legacy.Before && canonical == migration.Canonical.Before {
|
||||
@@ -694,6 +792,12 @@ func flagMigrationAuthorizesChange(
|
||||
matchFlagMigrationPhase(current, migration) != flagMigrationAfter {
|
||||
continue
|
||||
}
|
||||
if migration.EffectiveKind() == FlagMigrationRequirednessChange {
|
||||
if change.Flag == migration.Flag.Name && change.Kind == "flag_became_required" {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if change.Flag == migration.Legacy.Name && change.Kind == "flag_became_hidden" {
|
||||
return true
|
||||
}
|
||||
@@ -717,5 +821,8 @@ func flagMigrationAuthorizesChange(
|
||||
}
|
||||
|
||||
func (m FlagMigration) displayKey() string {
|
||||
if m.EffectiveKind() == FlagMigrationRequirednessChange {
|
||||
return fmt.Sprintf("%q --%s optional -> required", m.Command, flagMigrationSideName(m.Flag))
|
||||
}
|
||||
return fmt.Sprintf("%q --%s -> --%s", m.Command, m.Legacy.Name, m.Canonical.Name)
|
||||
}
|
||||
|
||||
@@ -38,6 +38,23 @@ const validFlagMigrationManifestJSON = `{
|
||||
]
|
||||
}`
|
||||
|
||||
const validRequirednessMigrationManifestJSON = `{
|
||||
"version": 1,
|
||||
"migrations": [
|
||||
{
|
||||
"kind": "requiredness_change",
|
||||
"command": "dws report entry submit",
|
||||
"flag": {
|
||||
"name": "to-user-ids",
|
||||
"before": {"present": true, "type": "string", "scope": "local"},
|
||||
"after": {"present": true, "type": "string", "required": true, "scope": "local"}
|
||||
},
|
||||
"state": "pending",
|
||||
"reason": "Reject report submissions that have no visible recipient."
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
func optionalFlagMigrationManifestJSON() string {
|
||||
manifest := strings.Replace(
|
||||
validFlagMigrationManifestJSON,
|
||||
@@ -153,6 +170,9 @@ func TestCrossPlatformCoverageReadFlagMigrationManifestValidatesExactEntries(t *
|
||||
if _, err := ReadFlagMigrationManifest(strings.NewReader(hiddenCanonicalFlagMigrationManifestJSON())); err != nil {
|
||||
t.Fatalf("ReadFlagMigrationManifest(hidden canonical promotion) error = %v", err)
|
||||
}
|
||||
if _, err := ReadFlagMigrationManifest(strings.NewReader(validRequirednessMigrationManifestJSON)); err != nil {
|
||||
t.Fatalf("ReadFlagMigrationManifest(requiredness change) error = %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -246,6 +266,229 @@ func TestCrossPlatformCoverageReadFlagMigrationManifestValidatesExactEntries(t *
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRequirednessMigrationRejectsInexactContracts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "unknown kind",
|
||||
input: strings.Replace(validRequirednessMigrationManifestJSON, `"requiredness_change"`, `"anything"`, 1),
|
||||
wantErr: "invalid kind",
|
||||
},
|
||||
{
|
||||
name: "missing flag name",
|
||||
input: strings.Replace(validRequirednessMigrationManifestJSON, `"to-user-ids"`, `""`, 1),
|
||||
wantErr: "exact flag",
|
||||
},
|
||||
{
|
||||
name: "invalid before state",
|
||||
input: strings.Replace(
|
||||
validRequirednessMigrationManifestJSON,
|
||||
`"before": {"present": true, "type": "string", "scope": "local"}`,
|
||||
`"before": {"present": true, "type": "string", "scope": "anything"}`,
|
||||
1,
|
||||
),
|
||||
wantErr: "flag before present state has invalid scope",
|
||||
},
|
||||
{
|
||||
name: "invalid after state",
|
||||
input: strings.Replace(
|
||||
validRequirednessMigrationManifestJSON,
|
||||
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
|
||||
`"after": {"present": true, "type": "string", "required": true, "scope": "anything"}`,
|
||||
1,
|
||||
),
|
||||
wantErr: "flag after present state has invalid scope",
|
||||
},
|
||||
{
|
||||
name: "before already required",
|
||||
input: strings.Replace(
|
||||
validRequirednessMigrationManifestJSON,
|
||||
`"before": {"present": true, "type": "string", "scope": "local"}`,
|
||||
`"before": {"present": true, "type": "string", "required": true, "scope": "local"}`,
|
||||
1,
|
||||
),
|
||||
wantErr: "optional to required",
|
||||
},
|
||||
{
|
||||
name: "after remains optional",
|
||||
input: strings.Replace(
|
||||
validRequirednessMigrationManifestJSON,
|
||||
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
|
||||
`"after": {"present": true, "type": "string", "scope": "local"}`,
|
||||
1,
|
||||
),
|
||||
wantErr: "optional to required",
|
||||
},
|
||||
{
|
||||
name: "type drift",
|
||||
input: strings.Replace(
|
||||
validRequirednessMigrationManifestJSON,
|
||||
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
|
||||
`"after": {"present": true, "type": "stringSlice", "required": true, "scope": "local"}`,
|
||||
1,
|
||||
),
|
||||
wantErr: "must preserve every flag attribute except requiredness",
|
||||
},
|
||||
{
|
||||
name: "visibility drift",
|
||||
input: strings.Replace(
|
||||
validRequirednessMigrationManifestJSON,
|
||||
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
|
||||
`"after": {"present": true, "type": "string", "required": true, "hidden": true, "scope": "local"}`,
|
||||
1,
|
||||
),
|
||||
wantErr: "must stay visible",
|
||||
},
|
||||
{
|
||||
name: "alias relation",
|
||||
input: strings.Replace(
|
||||
validRequirednessMigrationManifestJSON,
|
||||
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
|
||||
`"after": {"present": true, "type": "string", "required": true, "scope": "local", "alias_of": "other"}`,
|
||||
1,
|
||||
),
|
||||
wantErr: "must not declare alias_of",
|
||||
},
|
||||
{
|
||||
name: "rename fields cannot be combined",
|
||||
input: strings.Replace(
|
||||
validRequirednessMigrationManifestJSON,
|
||||
`"flag": {`,
|
||||
`"legacy": {"name":"old","before":{"present":false},"after":{"present":false}}, "flag": {`,
|
||||
1,
|
||||
),
|
||||
wantErr: "must not declare legacy or canonical",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := ReadFlagMigrationManifest(strings.NewReader(test.input))
|
||||
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
||||
t.Fatalf("ReadFlagMigrationManifest() error = %v, want %q", err, test.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
missingFlag := FlagMigration{
|
||||
Kind: FlagMigrationRequirednessChange,
|
||||
Command: "dws report entry submit",
|
||||
State: FlagMigrationPending,
|
||||
Reason: "Missing exact flag state.",
|
||||
}
|
||||
if err := missingFlag.validate(); err == nil || !strings.Contains(err.Error(), "exact flag") {
|
||||
t.Fatalf("missing requiredness flag error = %v", err)
|
||||
}
|
||||
|
||||
rename, err := ReadFlagMigrationManifest(strings.NewReader(validFlagMigrationManifestJSON))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rename.Migrations[0].Kind = FlagMigrationRename
|
||||
rename.Migrations[0].Flag = &FlagMigrationSide{
|
||||
Name: "unrelated",
|
||||
Before: FlagMigrationState{Present: true, Type: "string", Scope: "local"},
|
||||
After: FlagMigrationState{Present: true, Type: "string", Required: true, Scope: "local"},
|
||||
}
|
||||
if err := rename.Validate(); err == nil || !strings.Contains(err.Error(), "must not declare flag requiredness fields") {
|
||||
t.Fatalf("rename with requiredness fields error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRequirednessMigrationLifecycleAndAuthorization(t *testing.T) {
|
||||
pending, err := ReadFlagMigrationManifest(strings.NewReader(validRequirednessMigrationManifestJSON))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
consumed := pending
|
||||
consumed.Migrations = append([]FlagMigration(nil), pending.Migrations...)
|
||||
consumed.Migrations[0].State = FlagMigrationConsumed
|
||||
migration := pending.Migrations[0]
|
||||
before := requirednessMigrationSnapshot(migration, false)
|
||||
after := requirednessMigrationSnapshot(migration, true)
|
||||
|
||||
ordinary := Compare(after, before, "merge-base")
|
||||
if !hasFlagChange(ordinary.Blocking, "flag_became_required", migration.Command, migration.Flag.Name) {
|
||||
t.Fatalf("fixture did not produce flag_became_required: %#v", ordinary.Blocking)
|
||||
}
|
||||
report, err := CompareAllWithFlagMigrations(
|
||||
after,
|
||||
map[string]Snapshot{"merge-base": before, "stable": before},
|
||||
pending,
|
||||
consumed,
|
||||
)
|
||||
if err != nil || !report.Compatible {
|
||||
t.Fatalf("governed requiredness change = (%#v, %v), want compatible", report, err)
|
||||
}
|
||||
partial := after
|
||||
partial.Commands = append([]Command(nil), after.Commands...)
|
||||
partial.Commands[len(partial.Commands)-1].LocalFlags = append(
|
||||
[]Flag(nil),
|
||||
after.Commands[len(after.Commands)-1].LocalFlags...,
|
||||
)
|
||||
partial.Commands[len(partial.Commands)-1].LocalFlags[0].Type = "stringSlice"
|
||||
if phase := matchFlagMigrationPhase(partial, migration); phase != flagMigrationPartial {
|
||||
t.Fatalf("drifted requiredness migration phase = %s, want partial", phase)
|
||||
}
|
||||
|
||||
beforeWithUnrelated := before
|
||||
beforeWithUnrelated.Commands = append([]Command(nil), before.Commands...)
|
||||
beforeWithUnrelated.Commands[len(beforeWithUnrelated.Commands)-1].LocalFlags = append(
|
||||
beforeWithUnrelated.Commands[len(beforeWithUnrelated.Commands)-1].LocalFlags,
|
||||
Flag{Name: "unrelated", Type: "string"},
|
||||
)
|
||||
afterWithUnrelated := after
|
||||
afterWithUnrelated.Commands = append([]Command(nil), after.Commands...)
|
||||
afterWithUnrelated.Commands[len(afterWithUnrelated.Commands)-1].LocalFlags = append(
|
||||
afterWithUnrelated.Commands[len(afterWithUnrelated.Commands)-1].LocalFlags,
|
||||
Flag{Name: "unrelated", Type: "string", Required: true},
|
||||
)
|
||||
report, err = CompareAllWithFlagMigrations(
|
||||
afterWithUnrelated,
|
||||
map[string]Snapshot{"merge-base": beforeWithUnrelated, "stable": beforeWithUnrelated},
|
||||
pending,
|
||||
consumed,
|
||||
)
|
||||
if err != nil || report.Compatible || !hasFlagChange(report.Comparisons[0].Blocking, "flag_became_required", migration.Command, "unrelated") {
|
||||
t.Fatalf("unrelated requiredness change was hidden: report=%#v err=%v", report, err)
|
||||
}
|
||||
|
||||
empty := FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}}
|
||||
if _, err := CompareAllWithFlagMigrations(
|
||||
after,
|
||||
map[string]Snapshot{"merge-base": before, "stable": before},
|
||||
empty,
|
||||
pending,
|
||||
); err == nil || !strings.Contains(err.Error(), "cannot authorize its own interface change") {
|
||||
t.Fatalf("candidate self-authorization error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func requirednessMigrationSnapshot(migration FlagMigration, after bool) Snapshot {
|
||||
state := migration.Flag.Before
|
||||
if after {
|
||||
state = migration.Flag.After
|
||||
}
|
||||
flag := Flag{
|
||||
Name: migration.Flag.Name,
|
||||
Type: state.Type,
|
||||
Required: state.Required,
|
||||
Hidden: state.Hidden,
|
||||
Shorthand: state.Shorthand,
|
||||
NoOpt: state.NoOpt,
|
||||
}
|
||||
command := testCommandWithFlagScopes(migration.Command, nil, nil)
|
||||
if state.Scope == "inherited" {
|
||||
command.InheritedFlags = []Flag{flag}
|
||||
} else {
|
||||
command.LocalFlags = []Flag{flag}
|
||||
}
|
||||
return testSnapshot(testCommand("dws"), command)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFlagMigrationManifestRejectsDuplicateAndInexactContracts(t *testing.T) {
|
||||
manifest, err := ReadFlagMigrationManifest(strings.NewReader(validFlagMigrationManifestJSON))
|
||||
if err != nil {
|
||||
@@ -282,6 +525,22 @@ func TestCrossPlatformCoverageFlagMigrationManifestRejectsDuplicateAndInexactCon
|
||||
if err == nil || !strings.Contains(err.Error(), "trailing") {
|
||||
t.Fatalf("trailing JSON error = %v", err)
|
||||
}
|
||||
|
||||
requiredness, err := ReadFlagMigrationManifest(strings.NewReader(validRequirednessMigrationManifestJSON))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
overlap := manifest
|
||||
overlap.Migrations = append([]FlagMigration(nil), manifest.Migrations...)
|
||||
rename := overlap.Migrations[0]
|
||||
rename.Command = requiredness.Migrations[0].Command
|
||||
rename.Canonical.Name = requiredness.Migrations[0].Flag.Name
|
||||
rename.Legacy.After.AliasOf = rename.Canonical.Name
|
||||
overlap.Migrations[0] = rename
|
||||
overlap.Migrations = append(overlap.Migrations, requiredness.Migrations[0])
|
||||
if err := overlap.Validate(); err == nil || !strings.Contains(err.Error(), "overlaps rename migration") {
|
||||
t.Fatalf("requiredness and rename overlap error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthorizeFlagMigrationsReturnsExactBaseOwnedApproval(t *testing.T) {
|
||||
|
||||
@@ -72,14 +72,24 @@ func TestCrossPlatformCoverageCalendarListRequiresExplicitCollectionAndPaginatio
|
||||
if err != nil || len(events) != 0 || !page.Known || page.HasMore {
|
||||
t.Fatalf("explicit empty: events=%v page=%+v err=%v", events, page, err)
|
||||
}
|
||||
var serviceEmptySentinel map[string]any
|
||||
if err := json.Unmarshal([]byte(`{"success":true,"result":{"events":[{"attendees":null,"categories":null,"meetingRooms":null,"reminders":null}],"hasMore":false}}`), &serviceEmptySentinel); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
events, page, err = eventListProject(serviceEmptySentinel)
|
||||
if err != nil || events == nil || len(events) != 0 || !page.Known || page.HasMore {
|
||||
t.Fatalf("service empty sentinel: events=%#v page=%+v err=%v", events, page, err)
|
||||
}
|
||||
|
||||
for name, payload := range map[string]string{
|
||||
"missing collection": `{"success":true,"result":{"hasMore":false}}`,
|
||||
"bad collection": `{"success":true,"result":{"events":{}}}`,
|
||||
"bad item": `{"success":true,"result":{"events":["bad"],"hasMore":false}}`,
|
||||
"empty item": `{"success":true,"result":{"events":[{}],"hasMore":false}}`,
|
||||
"missing pagination": `{"success":true,"result":{"events":[]}}`,
|
||||
"missing next cursor": `{"success":true,"result":{"events":[],"hasMore":true}}`,
|
||||
"missing collection": `{"success":true,"result":{"hasMore":false}}`,
|
||||
"bad collection": `{"success":true,"result":{"events":{}}}`,
|
||||
"bad item": `{"success":true,"result":{"events":["bad"],"hasMore":false}}`,
|
||||
"empty item": `{"success":true,"result":{"events":[{}],"hasMore":false}}`,
|
||||
"unknown null item": `{"success":true,"result":{"events":[{"summary":null}],"hasMore":false}}`,
|
||||
"sentinel with cursor": `{"success":true,"result":{"events":[{"attendees":null}],"hasMore":false,"nextCursor":"unexpected"}}`,
|
||||
"missing pagination": `{"success":true,"result":{"events":[]}}`,
|
||||
"missing next cursor": `{"success":true,"result":{"events":[],"hasMore":true}}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
var data map[string]any
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/calendarcompat"
|
||||
)
|
||||
|
||||
const calendarCompositeReason = "Reviewed Calendar Shortcut composite: the executable CLI owns strict response validation, truthful pagination, optional multi-step writes, read-back verification, and confirmation; no single MCP interface represents the complete command contract."
|
||||
@@ -205,6 +206,9 @@ func requireCalendarCollection(data map[string]any, operation string, keys ...st
|
||||
if !ok {
|
||||
return nil, nil, calendarResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 字段不是数组", key))
|
||||
}
|
||||
if key == "events" {
|
||||
items, _ = calendarcompat.NormalizeTerminalEmptyEvents(items, container)
|
||||
}
|
||||
if err := validateCalendarCollectionItems(items, operation, key); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
// Package calendarcompat contains narrow compatibility adapters for observed
|
||||
// Calendar wire shapes shared by the calendar and smart Shortcut packages.
|
||||
package calendarcompat
|
||||
|
||||
// NormalizeTerminalEmptyEvents recognizes the Calendar service's legacy empty
|
||||
// page sentinel. Some deployments encode an exhausted empty page as one object
|
||||
// whose collection fields are all null instead of events:[]. Only that exact
|
||||
// terminal shape is normalized; arbitrary id-less or mixed event rows remain
|
||||
// available to the strict caller validation and must fail closed.
|
||||
func NormalizeTerminalEmptyEvents(items []any, container map[string]any) ([]any, bool) {
|
||||
if len(items) != 1 || container == nil {
|
||||
return items, false
|
||||
}
|
||||
hasMore, ok := container["hasMore"].(bool)
|
||||
if !ok || hasMore || hasNonEmptyCursor(container) {
|
||||
return items, false
|
||||
}
|
||||
placeholder, ok := items[0].(map[string]any)
|
||||
knownNullFields := map[string]struct{}{
|
||||
"attendees": {},
|
||||
"categories": {},
|
||||
"meetingRooms": {},
|
||||
"reminders": {},
|
||||
}
|
||||
if !ok || len(placeholder) != len(knownNullFields) {
|
||||
return items, false
|
||||
}
|
||||
for key, value := range placeholder {
|
||||
if _, known := knownNullFields[key]; !known || value != nil {
|
||||
return items, false
|
||||
}
|
||||
}
|
||||
return []any{}, true
|
||||
}
|
||||
|
||||
func hasNonEmptyCursor(container map[string]any) bool {
|
||||
for _, key := range []string{"nextCursor", "nextToken", "pageToken"} {
|
||||
value, present := container[key]
|
||||
if !present || value == nil || value == "" {
|
||||
continue
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package calendarcompat
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCrossPlatformCoverageNormalizeTerminalEmptyEvents(t *testing.T) {
|
||||
sentinel := []any{map[string]any{
|
||||
"attendees": nil, "categories": nil, "meetingRooms": nil, "reminders": nil,
|
||||
}}
|
||||
if got, ok := NormalizeTerminalEmptyEvents(sentinel, map[string]any{"hasMore": false}); !ok || got == nil || len(got) != 0 {
|
||||
t.Fatalf("terminal sentinel = %#v, %v; want explicit empty slice", got, ok)
|
||||
}
|
||||
|
||||
for name, items := range map[string][]any{
|
||||
"empty object": {map[string]any{}},
|
||||
"missing fields": {map[string]any{"attendees": nil}},
|
||||
"unknown field": {map[string]any{
|
||||
"attendees": nil, "categories": nil, "meetingRooms": nil, "summary": nil,
|
||||
}},
|
||||
"non-null field": {map[string]any{
|
||||
"attendees": []any{}, "categories": nil, "meetingRooms": nil, "reminders": nil,
|
||||
}},
|
||||
"mixed rows": {sentinel[0], map[string]any{"id": "event-1"}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, ok := NormalizeTerminalEmptyEvents(items, map[string]any{"hasMore": false}); ok {
|
||||
t.Fatalf("invalid sentinel accepted: %#v", items)
|
||||
}
|
||||
})
|
||||
}
|
||||
for name, container := range map[string]map[string]any{
|
||||
"missing terminal evidence": {},
|
||||
"more pages": {"hasMore": true},
|
||||
"cursor": {"hasMore": false, "nextCursor": "next"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, ok := NormalizeTerminalEmptyEvents(sentinel, container); ok {
|
||||
t.Fatalf("non-terminal sentinel accepted with %#v", container)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ package doc
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/commentreaction"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
|
||||
@@ -564,6 +565,9 @@ var CommentReply = shortcut.Shortcut{
|
||||
"replyCommentKey": rt.Str("comment-key"),
|
||||
}
|
||||
if rt.Bool("emoji") {
|
||||
if err := commentreaction.Validate(rt.Str("content")); err != nil {
|
||||
return err
|
||||
}
|
||||
params["emoji"] = true
|
||||
}
|
||||
if rt.Changed("mention") {
|
||||
|
||||
@@ -132,6 +132,31 @@ func runDocCoveragePath(t *testing.T, declaration shortcut.Shortcut, caller *doc
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommentReplyRejectsUnsupportedEmojiBeforeRPC(t *testing.T) {
|
||||
for _, content := range []string{"😄", "乱码"} {
|
||||
caller := &docCoverageCaller{responses: map[string][]map[string]any{}}
|
||||
err := runDocCoverage(t, CommentReply, caller,
|
||||
"--node", "node-1", "--comment-key", "comment-1",
|
||||
"--content", content, "--emoji", "--yes")
|
||||
if err == nil {
|
||||
t.Fatalf("unsupported reaction %q accepted", content)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("unsupported reaction %q reached RPC %d time(s)", content, caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
caller := &docCoverageCaller{responses: map[string][]map[string]any{}}
|
||||
if err := runDocCoverage(t, CommentReply, caller,
|
||||
"--node", "node-1", "--comment-key", "comment-1",
|
||||
"--content", "鼓掌", "--emoji", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 1 || caller.history[0].params["emoji"] != true {
|
||||
t.Fatalf("valid reaction call = %#v", caller.history)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRevisionSelectionAndKeywordUseLiveShapes(t *testing.T) {
|
||||
revisionPayload := map[string]any{"data": map[string]any{"revision": json.Number("9")}}
|
||||
if got, ok := nestedRevision(revisionPayload); !ok || got != 9 {
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/calendarcompat"
|
||||
)
|
||||
|
||||
const calendarSmartMaxPages = 20
|
||||
@@ -215,6 +216,7 @@ func calendarSmartEventPage(data map[string]any) ([]map[string]any, bool, string
|
||||
if !hasMore && next != "" {
|
||||
return nil, false, "", calendarSmartError("calendar/list_calendar_events", "inconsistent_pagination", "服务端返回 hasMore=false 但 nextCursor 非空")
|
||||
}
|
||||
list, _ = calendarcompat.NormalizeTerminalEmptyEvents(list, result)
|
||||
events := make([]map[string]any, 0, len(list))
|
||||
for index, item := range list {
|
||||
event, ok := item.(map[string]any)
|
||||
|
||||
@@ -148,6 +148,18 @@ func TestCrossPlatformCoverageCalendarSmartStrictCollections(t *testing.T) {
|
||||
if err != nil || items == nil || len(items) != 0 || hasMore || next != "" {
|
||||
t.Fatalf("explicit empty page items=%#v hasMore=%v next=%q err=%v", items, hasMore, next, err)
|
||||
}
|
||||
items, hasMore, next, err = calendarSmartEventPage(map[string]any{
|
||||
"success": true,
|
||||
"result": map[string]any{
|
||||
"events": []any{map[string]any{
|
||||
"attendees": nil, "categories": nil, "meetingRooms": nil, "reminders": nil,
|
||||
}},
|
||||
"hasMore": false,
|
||||
},
|
||||
})
|
||||
if err != nil || items == nil || len(items) != 0 || hasMore || next != "" {
|
||||
t.Fatalf("service empty sentinel items=%#v hasMore=%v next=%q err=%v", items, hasMore, next, err)
|
||||
}
|
||||
|
||||
invalidPages := []map[string]any{
|
||||
{},
|
||||
@@ -157,6 +169,7 @@ func TestCrossPlatformCoverageCalendarSmartStrictCollections(t *testing.T) {
|
||||
{"success": true, "result": map[string]any{"events": []any{}, "hasMore": true}},
|
||||
{"success": true, "result": map[string]any{"events": []any{}, "hasMore": false, "nextCursor": "unexpected"}},
|
||||
{"success": true, "result": map[string]any{"events": []any{map[string]any{"id": "event-placeholder"}}, "hasMore": false}},
|
||||
{"success": true, "result": map[string]any{"events": []any{map[string]any{"summary": nil}}, "hasMore": false}},
|
||||
{"success": true, "result": map[string]any{"events": []any{"bad-item"}, "hasMore": false}},
|
||||
}
|
||||
for index, page := range invalidPages {
|
||||
|
||||
@@ -6,6 +6,7 @@ package todo
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -159,13 +160,55 @@ func updateTodo(rt *shortcut.RuntimeContext) error {
|
||||
if key == "taskId" {
|
||||
continue
|
||||
}
|
||||
if fmt.Sprint(detail[key]) != fmt.Sprint(expected) {
|
||||
if !todoUpdateFieldMatches(key, detail[key], expected) {
|
||||
return todoResponseError("todo/update_todo_task", "verification_mismatch", "更新后读回字段 "+key+" 不一致")
|
||||
}
|
||||
}
|
||||
return rt.Output(map[string]any{"taskId": taskID, "verified": true, "todo": detail})
|
||||
}
|
||||
|
||||
func todoUpdateFieldMatches(key string, actual, expected any) bool {
|
||||
switch key {
|
||||
case "dueTime", "priority":
|
||||
actualNumber, actualOK := todoExactInteger(actual)
|
||||
expectedNumber, expectedOK := todoExactInteger(expected)
|
||||
return actualOK && expectedOK && actualNumber == expectedNumber
|
||||
case "subject":
|
||||
actualText, actualOK := actual.(string)
|
||||
expectedText, expectedOK := expected.(string)
|
||||
return actualOK && expectedOK && actualText == expectedText
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func todoExactInteger(value any) (int64, bool) {
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
return int64(typed), true
|
||||
case int64:
|
||||
return typed, true
|
||||
case float64:
|
||||
return todoExactFloat(typed)
|
||||
case json.Number:
|
||||
parsed, err := typed.Int64()
|
||||
return parsed, err == nil
|
||||
case string:
|
||||
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
|
||||
return parsed, err == nil
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func todoExactFloat(value float64) (int64, bool) {
|
||||
if math.IsNaN(value) || math.IsInf(value, 0) || math.Trunc(value) != value ||
|
||||
value < float64(math.MinInt64) || value >= float64(math.MaxInt64) {
|
||||
return 0, false
|
||||
}
|
||||
return int64(value), true
|
||||
}
|
||||
|
||||
var Update = shortcut.Shortcut{
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "todo", Command: "+update", Product: "todo",
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -313,11 +314,44 @@ func TestCrossPlatformCoverageTodoCreateAndUpdate(t *testing.T) {
|
||||
}
|
||||
updateSuccess := &todoCoverageCaller{responses: map[string][]string{
|
||||
"update_todo_task": {`{"success":true}`},
|
||||
"get_todo_detail": {`{"success":true,"result":{"todoDetailModel":{"taskId":"task-1","subject":"x","priority":40}}}`},
|
||||
"get_todo_detail": {`{"success":true,"result":{"todoDetailModel":{"taskId":"task-1","subject":"x","priority":40,"dueTime":1786932000000}}}`},
|
||||
}}
|
||||
if err := runTodoCoverage(t, Update, updateSuccess, "--task-id", "task-1", "--title", "x", "--priority", "40", "--yes"); err != nil {
|
||||
if err := runTodoCoverage(t, Update, updateSuccess, "--task-id", "task-1", "--title", "x", "--priority", "40", "--due", todoCoverageTime, "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if todoUpdateFieldMatches("dueTime", float64(1786932000000), int64(1786932000001)) {
|
||||
t.Fatal("different dueTime milliseconds matched")
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
value any
|
||||
want int64
|
||||
ok bool
|
||||
}{
|
||||
{value: int(40), want: 40, ok: true},
|
||||
{value: int64(1786932000000), want: 1786932000000, ok: true},
|
||||
{value: float64(40), want: 40, ok: true},
|
||||
{value: json.Number("40"), want: 40, ok: true},
|
||||
{value: " 40 ", want: 40, ok: true},
|
||||
{value: float64(math.MinInt64), want: math.MinInt64, ok: true},
|
||||
{value: 1786932000000.5},
|
||||
{value: math.NaN()},
|
||||
{value: math.Inf(1)},
|
||||
{value: math.Inf(-1)},
|
||||
{value: float64(math.MaxInt64)},
|
||||
{value: 1e300},
|
||||
{value: -1e300},
|
||||
{value: json.Number("not-a-number")},
|
||||
{value: "not-a-number"},
|
||||
{value: true},
|
||||
} {
|
||||
got, ok := todoExactInteger(tc.value)
|
||||
if got != tc.want || ok != tc.ok {
|
||||
t.Fatalf("todoExactInteger(%#v) = %d/%v, want %d/%v", tc.value, got, ok, tc.want, tc.ok)
|
||||
}
|
||||
}
|
||||
if todoUpdateFieldMatches("unknown", 1, 1) {
|
||||
t.Fatal("unknown update field matched")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTodoDoneBranches(t *testing.T) {
|
||||
|
||||
@@ -1080,6 +1080,29 @@ func TestCrossPlatformCoverageSchemaCommandMigrationLineagePreservesOrdinaryChec
|
||||
t.Fatalf("lineage hid positional drift: %s", failures)
|
||||
}
|
||||
|
||||
requiredness := interfacesnapshot.FlagMigration{
|
||||
Kind: interfacesnapshot.FlagMigrationRequirednessChange,
|
||||
Command: "dws report cli-only",
|
||||
Flag: &interfacesnapshot.FlagMigrationSide{
|
||||
Name: "recipient",
|
||||
Before: interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Scope: "local"},
|
||||
After: interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Required: true, Scope: "local"},
|
||||
},
|
||||
State: interfacesnapshot.FlagMigrationConsumed,
|
||||
Reason: "Exercise requiredness receipts beside command lineage without treating them as renames.",
|
||||
}
|
||||
flagsWithRequiredness := append([]interfacesnapshot.FlagMigration(nil), schemaCommandLineageFlagManifest().Migrations...)
|
||||
flagsWithRequiredness = append(flagsWithRequiredness, requiredness)
|
||||
if _, err := normalizeSchemaCommandMigrationLineage(
|
||||
stable,
|
||||
base,
|
||||
current,
|
||||
flagsWithRequiredness,
|
||||
schemaCommandLineageManifest(interfacesnapshot.CommandMigrationPending).Migrations,
|
||||
); err != nil {
|
||||
t.Fatalf("requiredness receipt interfered with command lineage: %v", err)
|
||||
}
|
||||
|
||||
// Multiple historical names may converge only when every predecessor carries
|
||||
// the exact same contract and every receipt is already consumed.
|
||||
stable, base, current = schemaCommandLineageContracts()
|
||||
|
||||
@@ -1261,9 +1261,9 @@ type schemaToolRef struct {
|
||||
}
|
||||
|
||||
// normalizeSchemaFlagMigrations projects only an already-authorized CLI flag
|
||||
// rename onto a cloned historical Schema contract. The ordinary compatibility
|
||||
// checker still makes the final decision; this adapter never drops findings by
|
||||
// matching their rendered text.
|
||||
// rename or requiredness change onto a cloned historical Schema contract. The
|
||||
// ordinary compatibility checker still makes the final decision; this adapter
|
||||
// never drops findings by matching their rendered text.
|
||||
func normalizeSchemaFlagMigrations(
|
||||
baseline schemaContract,
|
||||
current schemaContract,
|
||||
@@ -1273,6 +1273,14 @@ func normalizeSchemaFlagMigrations(
|
||||
renamesByTool := map[schemaToolRef]map[string]string{}
|
||||
|
||||
for _, migration := range migrations {
|
||||
if migration.EffectiveKind() == interfacesnapshot.FlagMigrationRequirednessChange {
|
||||
var err error
|
||||
normalized, err = normalizeSchemaFlagRequirednessMigration(normalized, baseline, current, migration)
|
||||
if err != nil {
|
||||
return schemaContract{}, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
primaryPath := strings.TrimPrefix(migration.Command, "dws ")
|
||||
matches := schemaToolsByPrimaryPath(baseline, primaryPath)
|
||||
if len(matches) == 0 {
|
||||
@@ -1394,6 +1402,65 @@ func normalizeSchemaFlagMigrations(
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizeSchemaFlagRequirednessMigration(
|
||||
normalized schemaContract,
|
||||
baseline schemaContract,
|
||||
current schemaContract,
|
||||
migration interfacesnapshot.FlagMigration,
|
||||
) (schemaContract, error) {
|
||||
primaryPath := strings.TrimPrefix(migration.Command, "dws ")
|
||||
matches := schemaToolsByPrimaryPath(baseline, primaryPath)
|
||||
if len(matches) == 0 {
|
||||
// A reviewed CLI-only command has no Schema compatibility surface.
|
||||
return normalized, nil
|
||||
}
|
||||
if len(matches) != 1 {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"approved flag requiredness migration %q matches %d historical Schema tools",
|
||||
migration.Command,
|
||||
len(matches),
|
||||
)
|
||||
}
|
||||
|
||||
ref := matches[0]
|
||||
oldTool := baseline.Products[ref.productID].Tools[ref.toolID]
|
||||
oldParameter, existed := oldTool.Parameters[migration.Flag.Name]
|
||||
if !existed {
|
||||
// CLI requiredness is not authority to create or mutate an unrelated
|
||||
// historical Schema parameter.
|
||||
return normalized, nil
|
||||
}
|
||||
newProduct, productExists := current.Products[ref.productID]
|
||||
newTool, toolExists := newProduct.Tools[ref.toolID]
|
||||
if !productExists || !toolExists || newTool.PrimaryCLIPath != primaryPath {
|
||||
// Preserve the baseline so the ordinary checker reports the missing tool
|
||||
// or primary path change.
|
||||
return normalized, nil
|
||||
}
|
||||
newParameter, exists := newTool.Parameters[migration.Flag.Name]
|
||||
if !exists {
|
||||
// Preserve the baseline so the ordinary checker reports parameter loss.
|
||||
return normalized, nil
|
||||
}
|
||||
if !newParameter.Required || !newParameter.CLIRequired {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"approved flag requiredness migration %q Schema parameter %q must be required and cli_required",
|
||||
migration.Command,
|
||||
migration.Flag.Name,
|
||||
)
|
||||
}
|
||||
|
||||
normalizedProduct := normalized.Products[ref.productID]
|
||||
normalizedTool := normalizedProduct.Tools[ref.toolID]
|
||||
normalizedParameter := oldParameter
|
||||
normalizedParameter.Required = newParameter.Required
|
||||
normalizedParameter.CLIRequired = newParameter.CLIRequired
|
||||
normalizedTool.Parameters[migration.Flag.Name] = normalizedParameter
|
||||
normalizedProduct.Tools[ref.toolID] = normalizedTool
|
||||
normalized.Products[ref.productID] = normalizedProduct
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
// normalizeSchemaCommandMigrationLineage composes two independently reviewed
|
||||
// migration receipts without inventing a second alias authority. A consumed
|
||||
// flag migration may supply the historical predecessor of a command_move
|
||||
@@ -1482,6 +1549,9 @@ func stageSchemaCommandMigrationPredecessors(
|
||||
for _, parameter := range migration.Schema.Parameters {
|
||||
if _, direct := oldTool.Parameters[parameter.From]; direct {
|
||||
for _, flagMigration := range flagMigrations {
|
||||
if flagMigration.EffectiveKind() != interfacesnapshot.FlagMigrationRename {
|
||||
continue
|
||||
}
|
||||
if flagMigration.Command != migration.Legacy.Command ||
|
||||
flagMigration.Canonical.Name != parameter.From {
|
||||
continue
|
||||
@@ -1501,6 +1571,9 @@ func stageSchemaCommandMigrationPredecessors(
|
||||
|
||||
predecessors := make([]interfacesnapshot.FlagMigration, 0, 1)
|
||||
for _, flagMigration := range flagMigrations {
|
||||
if flagMigration.EffectiveKind() != interfacesnapshot.FlagMigrationRename {
|
||||
continue
|
||||
}
|
||||
if flagMigration.Command != migration.Legacy.Command ||
|
||||
flagMigration.Canonical.Name != parameter.From {
|
||||
continue
|
||||
|
||||
@@ -1229,6 +1229,144 @@ func TestCrossPlatformCoverageSchemaFlagMigrationNormalizesExactRename(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSchemaFlagRequirednessMigration(t *testing.T) {
|
||||
baseline := schemaContract{
|
||||
Version: schemaContractVersion,
|
||||
Products: map[string]productSchema{
|
||||
"report": {
|
||||
Tools: map[string]toolSchema{
|
||||
"report.entry_submit": {
|
||||
PrimaryCLIPath: "report entry submit",
|
||||
Parameters: map[string]parameterSchema{
|
||||
"to-user-ids": {Type: `"string"`, Property: "toUserIds", InterfaceType: "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
current := cloneContract(baseline)
|
||||
product := current.Products["report"]
|
||||
tool := product.Tools["report.entry_submit"]
|
||||
parameter := tool.Parameters["to-user-ids"]
|
||||
parameter.Required = true
|
||||
parameter.CLIRequired = true
|
||||
tool.Parameters["to-user-ids"] = parameter
|
||||
product.Tools["report.entry_submit"] = tool
|
||||
current.Products["report"] = product
|
||||
|
||||
migration := interfacesnapshot.FlagMigration{
|
||||
Kind: interfacesnapshot.FlagMigrationRequirednessChange,
|
||||
Command: "dws report entry submit",
|
||||
Flag: &interfacesnapshot.FlagMigrationSide{
|
||||
Name: "to-user-ids",
|
||||
Before: interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Scope: "local"},
|
||||
After: interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Required: true, Scope: "local"},
|
||||
},
|
||||
State: interfacesnapshot.FlagMigrationConsumed,
|
||||
Reason: "Reject report submissions that have no visible recipient.",
|
||||
}
|
||||
normalized, err := normalizeSchemaFlagMigrations(baseline, current, []interfacesnapshot.FlagMigration{migration})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if failures := checkCompatibility(normalized, current); len(failures) != 0 {
|
||||
t.Fatalf("exact requiredness migration should pass after normalization: %v", failures)
|
||||
}
|
||||
|
||||
t.Run("non-requiredness drift remains blocked", func(t *testing.T) {
|
||||
drifted := cloneContract(current)
|
||||
product := drifted.Products["report"]
|
||||
tool := product.Tools["report.entry_submit"]
|
||||
parameter := tool.Parameters["to-user-ids"]
|
||||
parameter.Type = `"array"`
|
||||
tool.Parameters["to-user-ids"] = parameter
|
||||
product.Tools["report.entry_submit"] = tool
|
||||
drifted.Products["report"] = product
|
||||
normalized, err := normalizeSchemaFlagMigrations(baseline, drifted, []interfacesnapshot.FlagMigration{migration})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if failures := strings.Join(checkCompatibility(normalized, drifted), "\n"); !strings.Contains(failures, "changed type") {
|
||||
t.Fatalf("requiredness migration hid type drift: %s", failures)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("partial Schema projection fails closed", func(t *testing.T) {
|
||||
partial := cloneContract(current)
|
||||
product := partial.Products["report"]
|
||||
tool := product.Tools["report.entry_submit"]
|
||||
parameter := tool.Parameters["to-user-ids"]
|
||||
parameter.CLIRequired = false
|
||||
tool.Parameters["to-user-ids"] = parameter
|
||||
product.Tools["report.entry_submit"] = tool
|
||||
partial.Products["report"] = product
|
||||
if _, err := normalizeSchemaFlagMigrations(baseline, partial, []interfacesnapshot.FlagMigration{migration}); err == nil || !strings.Contains(err.Error(), "required and cli_required") {
|
||||
t.Fatalf("partial requiredness projection error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CLI-only migration does not manufacture Schema", func(t *testing.T) {
|
||||
cliOnly := migration
|
||||
cliOnly.Command = "dws report cli-only"
|
||||
normalized, err := normalizeSchemaFlagMigrations(baseline, current, []interfacesnapshot.FlagMigration{cliOnly})
|
||||
if err != nil || !reflect.DeepEqual(normalized, baseline) {
|
||||
t.Fatalf("CLI-only requiredness migration = %#v, %v", normalized, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duplicate historical path fails closed", func(t *testing.T) {
|
||||
duplicate := cloneContract(baseline)
|
||||
product := duplicate.Products["report"]
|
||||
product.Tools["report.duplicate"] = product.Tools["report.entry_submit"]
|
||||
duplicate.Products["report"] = product
|
||||
if _, err := normalizeSchemaFlagMigrations(duplicate, current, []interfacesnapshot.FlagMigration{migration}); err == nil || !strings.Contains(err.Error(), "matches 2") {
|
||||
t.Fatalf("duplicate requiredness Schema path error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing historical parameter is not authority", func(t *testing.T) {
|
||||
missing := cloneContract(baseline)
|
||||
product := missing.Products["report"]
|
||||
tool := product.Tools["report.entry_submit"]
|
||||
delete(tool.Parameters, "to-user-ids")
|
||||
product.Tools["report.entry_submit"] = tool
|
||||
missing.Products["report"] = product
|
||||
normalized, err := normalizeSchemaFlagMigrations(missing, current, []interfacesnapshot.FlagMigration{migration})
|
||||
if err != nil || !reflect.DeepEqual(normalized, missing) {
|
||||
t.Fatalf("missing historical requiredness parameter = %#v, %v", normalized, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing current tool stays visible to ordinary checker", func(t *testing.T) {
|
||||
missing := cloneContract(current)
|
||||
delete(missing.Products, "report")
|
||||
normalized, err := normalizeSchemaFlagMigrations(baseline, missing, []interfacesnapshot.FlagMigration{migration})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if failures := checkCompatibility(normalized, missing); len(failures) == 0 {
|
||||
t.Fatal("requiredness migration hid missing current Schema tool")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing current parameter stays visible to ordinary checker", func(t *testing.T) {
|
||||
missing := cloneContract(current)
|
||||
product := missing.Products["report"]
|
||||
tool := product.Tools["report.entry_submit"]
|
||||
delete(tool.Parameters, "to-user-ids")
|
||||
product.Tools["report.entry_submit"] = tool
|
||||
missing.Products["report"] = product
|
||||
normalized, err := normalizeSchemaFlagMigrations(baseline, missing, []interfacesnapshot.FlagMigration{migration})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if failures := strings.Join(checkCompatibility(normalized, missing), "\n"); !strings.Contains(failures, "lost parameter") {
|
||||
t.Fatalf("requiredness migration hid missing current parameter: %s", failures)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSchemaFlagMigrationRejectsSemanticDrift(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
Reference in New Issue
Block a user