mirror of
https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git
synced 2026-09-29 16:57:48 +08:00
fix(doc): mention 配对增加双射一致性校验
评审连续三轮卡在「目标互换仍返回 verified=true」。逐目标核验需要 openDingTalkId → staffId 的反查,而该反查在 CLI 面上不存在(openDingTalkId 仅作 输出字段;contact 查询入口是姓名/手机/部门/角色),dws api 又要求自有应用凭证且 把 raw OpenAPI 塞进文档校验路径违背现有架构——故无法在本 PR 内实现。 但按位置配对之外还有两条纯本地可判的约束此前没做,现补上: - 同一个 openDingTalkId 必须解析到同一个 profile 目标 - 两个不同的 openDingTalkId 不得解析到同一个 profile 目标 这能捕捉服务端把身份搞混(同 id 前后不一致、不同 id 撞到同一人)的情形,把不可 判范围收窄到「两个不同 id 的目标互换」这一项——作者侧从不携带解析后的 staffId, (A→X,B→Y) 与 (A→Y,B→X) 在本地信息量下不可区分,属信息不足而非实现薄弱。 验证:新增 4 行表驱动用例覆盖两条约束的正反面,另测 destination 提取的边界; 变更行覆盖率 100%;internal/shortcut/... 与 internal/helpers/... 全绿。
This commit is contained in:
@@ -1281,13 +1281,37 @@ func isProfileLinkToken(token string) bool {
|
||||
return strings.HasPrefix(token, docFingerprintLinkTokenPrefix+docProfileLinkPrefix)
|
||||
}
|
||||
|
||||
// docFingerprintLinkDestination extracts the link destination from a fingerprint
|
||||
// token shaped "open\x00link:<destination>\x00<title>".
|
||||
func docFingerprintLinkDestination(token string) string {
|
||||
rest := strings.TrimPrefix(token, docFingerprintLinkTokenPrefix)
|
||||
if idx := strings.IndexByte(rest, 0); idx >= 0 {
|
||||
return rest[:idx]
|
||||
}
|
||||
return rest
|
||||
}
|
||||
|
||||
// markdownMentionAwareTokensEqual compares two fingerprint token sequences,
|
||||
// tolerating exactly one kind of difference: an authored mention protocol link
|
||||
// may appear as a profile link in the readback.
|
||||
//
|
||||
// Pairs are additionally required to form a consistent bijection: one
|
||||
// openDingTalkId must resolve to one profile target throughout the document, and
|
||||
// two different openDingTalkIds must not resolve to the same target. Both checks
|
||||
// are decidable locally and catch a service that mixed identities up.
|
||||
//
|
||||
// What remains undetectable is a permutation of two *distinct* targets: the
|
||||
// authored side never carries the resolved staffId, so (A→X, B→Y) and
|
||||
// (A→Y, B→X) are indistinguishable here. That is missing information, not a
|
||||
// weaker implementation — closing it needs the service to report what it
|
||||
// rewrote, or a reverse openDingTalkId lookup that the CLI surface does not
|
||||
// expose.
|
||||
func markdownMentionAwareTokensEqual(actual, expected []string) bool {
|
||||
if len(actual) != len(expected) {
|
||||
return false
|
||||
}
|
||||
resolved := map[string]string{}
|
||||
claimed := map[string]string{}
|
||||
for index := range expected {
|
||||
if actual[index] == expected[index] {
|
||||
continue
|
||||
@@ -1295,6 +1319,16 @@ func markdownMentionAwareTokensEqual(actual, expected []string) bool {
|
||||
if !isMentionProtocolLinkToken(expected[index]) || !isProfileLinkToken(actual[index]) {
|
||||
return false
|
||||
}
|
||||
mention := docFingerprintLinkDestination(expected[index])
|
||||
target := docFingerprintLinkDestination(actual[index])
|
||||
if previous, seen := resolved[mention]; seen && previous != target {
|
||||
return false
|
||||
}
|
||||
if previous, seen := claimed[target]; seen && previous != mention {
|
||||
return false
|
||||
}
|
||||
resolved[mention] = target
|
||||
claimed[target] = mention
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -853,3 +853,62 @@ func TestCrossPlatformCoverageDocMentionSuffixComparisonLayers(t *testing.T) {
|
||||
t.Fatal("an oversized readback must not be accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// Mention pairing must form a consistent bijection. Neither check needs an
|
||||
// identity lookup, so both are enforced locally; a permutation of two distinct
|
||||
// targets stays undetectable because the authored side carries no staffId.
|
||||
func TestCrossPlatformCoverageDocMentionPairingRequiresBijection(t *testing.T) {
|
||||
const (
|
||||
mentionA = "alidocs-mcp://doc/mention?openDingTalkId=DEXAMPLEAAAA"
|
||||
mentionB = "alidocs-mcp://doc/mention?openDingTalkId=DEXAMPLEBBBB"
|
||||
profile1 = "dingtalk://dingtalkclient/page/profile?corp_id=dingexamplecorpid&staff_id=100001"
|
||||
profile2 = "dingtalk://dingtalkclient/page/profile?corp_id=dingexamplecorpid&staff_id=100002"
|
||||
)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
expected string
|
||||
actual string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
"one id resolving to one target twice is accepted",
|
||||
"[@甲](" + mentionA + ") 与 [@甲](" + mentionA + ")",
|
||||
"[@甲](" + profile1 + ") 与 [@甲](" + profile1 + ")",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"the same id resolving to two different targets is rejected",
|
||||
"[@甲](" + mentionA + ") 与 [@甲](" + mentionA + ")",
|
||||
"[@甲](" + profile1 + ") 与 [@甲](" + profile2 + ")",
|
||||
false,
|
||||
},
|
||||
{
|
||||
"two different ids collapsing onto one target is rejected",
|
||||
"[@甲](" + mentionA + ") 与 [@乙](" + mentionB + ")",
|
||||
"[@甲](" + profile1 + ") 与 [@乙](" + profile1 + ")",
|
||||
false,
|
||||
},
|
||||
{
|
||||
"two different ids resolving to two different targets is accepted",
|
||||
"[@甲](" + mentionA + ") 与 [@乙](" + mentionB + ")",
|
||||
"[@甲](" + profile1 + ") 与 [@乙](" + profile2 + ")",
|
||||
true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := verifyUpdatedDocumentContent(
|
||||
map[string]any{"markdown": tc.actual}, tc.expected, "overwrite", "markdown")
|
||||
if got != tc.want {
|
||||
t.Fatalf("verify = %v, want %v\nexpected=%q\nactual=%q", got, tc.want, tc.expected, tc.actual)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if got := docFingerprintLinkDestination(docFingerprintLinkTokenPrefix + profile1 + "\x00"); got != profile1 {
|
||||
t.Fatalf("destination extraction = %q", got)
|
||||
}
|
||||
if got := docFingerprintLinkDestination("open\x00link:bare"); got != "bare" {
|
||||
t.Fatalf("a token without a title separator must still yield its destination: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user