* ci(interface): 添加接口完整性和CLI烟雾测试检查 - 在Makefile中新增interface-integrity、update-interface-baseline、cli-smoke和mock-mcp-smoke目标 - 实现接口基线脚本以比较CLI公共命令树与基线文件 - 新增脚本确保二进制文件可渲染所有顶层命令的帮助信息 - 添加mock-mcp-smoke测试验证HTTP和stdio MCP请求/响应传输 - 在GitHub Actions CI工作流中加入interface-integrity、cli-smoke和mock-mcp-smoke检查 - 新增AI行为检查工作流,限制AI生成PR的改动范围和禁改保护文件 - 文档中补充PR质量门禁要求及接口变更流程说明 * feat(policy): 增加多项兼容性和完整性检查 - Makefile中新增reset-interface-baseline、schema-compatibility、update-schema-baseline、skill-command-integrity等目标 - CI流程新增schema-compatibility和skill-command-integrity步骤 - 文档中详细说明接口兼容性基线更新和重置流程及schema基线注意事项 - 实现interface-baseline工具支持兼容性重置和合并,多项接口兼容性检查逻辑完善 - 新增schema-compat工具用于标准化schema列表及兼容性检查与合并 - 新增skill-command-integrity检查确保技能中引用命令存在 - 为interface-baseline和schema-compat添加单元测试覆盖基本兼容性规则 * test(skill-command-check): 增加命令解析和解析结果测试用例 - 补充对 parseReference 函数的边界情况和跳过条件的测试 - 新增 resolveCommandReference 函数测试,覆盖有效、无效及跳过场景 - 增加 isPlaceholder 函数的测试,验证占位符识别准确性 refactor(skill-command-check): 优化命令路径解析和验证逻辑 - 使用 resolveCommandReference 统一处理命令解析结果,清晰区分有效、无效和跳过情况 - 新增 commandResolution 类型及常量,提升代码可读性和扩展性 - 调整 parseReference 增加对 shell 组合符 “ & ” 的跳过处理 docs(mono): 更新最佳实践和产品文档命令示例 - 优化《best_practices/07-minutes.md》中行动项与摘要拉取示例命令,提升准确性 - 修改产品文档中 ALIDOC 表格数据和多维表格记录相关命令,命令路径更规范统一 - 同步多端技能文档,确保命令示例一致且正确 * ci: check interface against PR merge-base * feat(policy): enforce CLI contract compatibility * ci: enforce PR coverage thresholds * ci: add fail-closed CI gate * ci: adapt schema compatibility gate to runtime catalog * fix(ci): close schema compatibility gate gaps * test(auth): skip POSIX mode assertion on Windows * test(auth): scope POSIX file-backend checks * fix(ci): enforce native platform coverage * test(ci): make skill paths portable --------- Co-authored-by: 修雨 <huyizhou.hyz@alibaba-inc.com>
224 lines
7.4 KiB
Go
224 lines
7.4 KiB
Go
// 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 (
|
|
"bytes"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
|
)
|
|
|
|
func TestPortableExportSupported(t *testing.T) {
|
|
if runtime.GOOS != "darwin" {
|
|
if !PortableExportSupported() {
|
|
t.Fatal("PortableExportSupported() should be true on non-darwin")
|
|
}
|
|
return
|
|
}
|
|
t.Setenv(keychain.DisableKeychainEnv, "")
|
|
if PortableExportSupported() {
|
|
t.Fatal("PortableExportSupported() should be false on darwin without file DEK")
|
|
}
|
|
t.Setenv(keychain.DisableKeychainEnv, "1")
|
|
if !PortableExportSupported() {
|
|
t.Fatal("PortableExportSupported() should be true when file DEK is enabled")
|
|
}
|
|
}
|
|
|
|
func TestExportPortableAuthBundleRequiresAuthToken(t *testing.T) {
|
|
t.Setenv(keychain.DisableKeychainEnv, "1")
|
|
keychainRoot := filepath.Join(t.TempDir(), "empty-keychain")
|
|
if err := os.MkdirAll(filepath.Join(keychainRoot, keychain.Service), 0o700); err != nil {
|
|
t.Fatalf("MkdirAll() error = %v", err)
|
|
}
|
|
t.Setenv(keychain.StorageDirEnv, keychainRoot)
|
|
configDir := filepath.Join(t.TempDir(), ".dws")
|
|
|
|
var bundle bytes.Buffer
|
|
err := ExportPortableAuthBundle(configDir, &bundle)
|
|
if err == nil {
|
|
t.Fatal("ExportPortableAuthBundle() should fail without auth-token.enc")
|
|
}
|
|
if bundle.Len() != 0 {
|
|
t.Fatalf("ExportPortableAuthBundle() wrote %d bytes, want 0", bundle.Len())
|
|
}
|
|
}
|
|
|
|
func TestPortableAuthTargetPopulated(t *testing.T) {
|
|
requirePortableFileBackend(t)
|
|
t.Setenv(keychain.DisableKeychainEnv, "1")
|
|
root := t.TempDir()
|
|
configDir := filepath.Join(root, ".dws")
|
|
t.Setenv(keychain.StorageDirEnv, filepath.Join(root, "keychain"))
|
|
|
|
if PortableAuthTargetPopulated(configDir) {
|
|
t.Fatal("PortableAuthTargetPopulated() should be false before save")
|
|
}
|
|
if err := SaveTokenData(configDir, &TokenData{
|
|
AccessToken: "token",
|
|
RefreshToken: "refresh",
|
|
RefreshExpAt: time.Now().Add(time.Hour),
|
|
}); err != nil {
|
|
t.Fatalf("SaveTokenData() error = %v", err)
|
|
}
|
|
if !PortableAuthTargetPopulated(configDir) {
|
|
t.Fatal("PortableAuthTargetPopulated() should be true after save")
|
|
}
|
|
}
|
|
|
|
func TestPortableAuthBundleRoundTripPreservesRefreshToken(t *testing.T) {
|
|
requirePortableFileBackend(t)
|
|
t.Setenv(keychain.DisableKeychainEnv, "1")
|
|
sourceKeychain := filepath.Join(t.TempDir(), "source-keychain")
|
|
t.Setenv(keychain.StorageDirEnv, sourceKeychain)
|
|
sourceConfig := filepath.Join(t.TempDir(), ".dws")
|
|
|
|
original := &TokenData{
|
|
AccessToken: "access-source",
|
|
RefreshToken: "refresh-source",
|
|
ExpiresAt: time.Now().Add(-time.Hour),
|
|
RefreshExpAt: time.Now().Add(30 * 24 * time.Hour),
|
|
CorpID: "dingcorp",
|
|
ClientID: "client-from-mcp",
|
|
Source: "mcp",
|
|
}
|
|
if err := SaveTokenData(sourceConfig, original); err != nil {
|
|
t.Fatalf("SaveTokenData() error = %v", err)
|
|
}
|
|
if err := SaveAppConfig(sourceConfig, &AppConfig{ClientID: "client-from-mcp"}); err != nil {
|
|
t.Fatalf("SaveAppConfig() error = %v", err)
|
|
}
|
|
|
|
var bundle bytes.Buffer
|
|
if err := ExportPortableAuthBundle(sourceConfig, &bundle); err != nil {
|
|
t.Fatalf("ExportPortableAuthBundle() error = %v", err)
|
|
}
|
|
if bundle.Len() == 0 {
|
|
t.Fatal("ExportPortableAuthBundle() wrote an empty bundle")
|
|
}
|
|
|
|
targetKeychain := filepath.Join(t.TempDir(), "target-keychain")
|
|
t.Setenv(keychain.StorageDirEnv, targetKeychain)
|
|
targetConfig := filepath.Join(t.TempDir(), ".dws")
|
|
|
|
if _, err := ImportPortableAuthBundle(targetConfig, bytes.NewReader(bundle.Bytes())); err != nil {
|
|
t.Fatalf("ImportPortableAuthBundle() error = %v", err)
|
|
}
|
|
|
|
loaded, err := LoadTokenData(targetConfig)
|
|
if err != nil {
|
|
t.Fatalf("LoadTokenData() after import error = %v", err)
|
|
}
|
|
if loaded.AccessToken != original.AccessToken {
|
|
t.Fatalf("access token = %q, want %q", loaded.AccessToken, original.AccessToken)
|
|
}
|
|
if loaded.RefreshToken != original.RefreshToken {
|
|
t.Fatalf("refresh token = %q, want %q", loaded.RefreshToken, original.RefreshToken)
|
|
}
|
|
if !loaded.IsRefreshTokenValid() {
|
|
t.Fatal("refresh token should remain valid after import")
|
|
}
|
|
if cfg, err := LoadAppConfig(targetConfig); err != nil {
|
|
t.Fatalf("LoadAppConfig() after import error = %v", err)
|
|
} else if cfg == nil || cfg.ClientID != "client-from-mcp" {
|
|
t.Fatalf("imported app config = %#v, want client ID preserved", cfg)
|
|
}
|
|
}
|
|
|
|
func TestPortableAuthBundleRoundTripPreservesProfiles(t *testing.T) {
|
|
requirePortableFileBackend(t)
|
|
t.Setenv(keychain.DisableKeychainEnv, "1")
|
|
SetRuntimeProfile("")
|
|
t.Cleanup(func() { SetRuntimeProfile("") })
|
|
|
|
sourceKeychain := filepath.Join(t.TempDir(), "source-keychain")
|
|
t.Setenv(keychain.StorageDirEnv, sourceKeychain)
|
|
sourceConfig := filepath.Join(t.TempDir(), ".dws")
|
|
|
|
tokenA := &TokenData{
|
|
AccessToken: "access-a",
|
|
RefreshToken: "refresh-a",
|
|
ExpiresAt: time.Now().Add(time.Hour),
|
|
RefreshExpAt: time.Now().Add(30 * 24 * time.Hour),
|
|
CorpID: "corp_a",
|
|
CorpName: "A Org",
|
|
ClientID: "client-a",
|
|
}
|
|
tokenB := &TokenData{
|
|
AccessToken: "access-b",
|
|
RefreshToken: "refresh-b",
|
|
ExpiresAt: time.Now().Add(time.Hour),
|
|
RefreshExpAt: time.Now().Add(30 * 24 * time.Hour),
|
|
CorpID: "corp_b",
|
|
CorpName: "B Org",
|
|
ClientID: "client-b",
|
|
}
|
|
if err := SaveTokenData(sourceConfig, tokenA); err != nil {
|
|
t.Fatalf("SaveTokenData(A) error = %v", err)
|
|
}
|
|
if err := SaveTokenData(sourceConfig, tokenB); err != nil {
|
|
t.Fatalf("SaveTokenData(B) error = %v", err)
|
|
}
|
|
|
|
var bundle bytes.Buffer
|
|
if err := ExportPortableAuthBundle(sourceConfig, &bundle); err != nil {
|
|
t.Fatalf("ExportPortableAuthBundle() error = %v", err)
|
|
}
|
|
|
|
targetKeychain := filepath.Join(t.TempDir(), "target-keychain")
|
|
t.Setenv(keychain.StorageDirEnv, targetKeychain)
|
|
targetConfig := filepath.Join(t.TempDir(), ".dws")
|
|
if _, err := ImportPortableAuthBundle(targetConfig, bytes.NewReader(bundle.Bytes())); err != nil {
|
|
t.Fatalf("ImportPortableAuthBundle() error = %v", err)
|
|
}
|
|
|
|
cfg, err := LoadProfiles(targetConfig)
|
|
if err != nil {
|
|
t.Fatalf("LoadProfiles() after import error = %v", err)
|
|
}
|
|
if cfg.PrimaryProfile != "corp_a" || cfg.CurrentProfile != "corp_b" || cfg.PreviousProfile != "corp_a" {
|
|
t.Fatalf("profiles after import = %#v", cfg)
|
|
}
|
|
if len(cfg.Profiles) != 2 {
|
|
t.Fatalf("profiles len = %d, want 2: %#v", len(cfg.Profiles), cfg.Profiles)
|
|
}
|
|
|
|
loadedA, err := LoadTokenDataForProfile(targetConfig, "corp_a")
|
|
if err != nil {
|
|
t.Fatalf("LoadTokenDataForProfile(A) after import error = %v", err)
|
|
}
|
|
if loadedA.AccessToken != "access-a" {
|
|
t.Fatalf("profile A token = %q, want access-a", loadedA.AccessToken)
|
|
}
|
|
loadedB, err := LoadTokenDataForProfile(targetConfig, "corp_b")
|
|
if err != nil {
|
|
t.Fatalf("LoadTokenDataForProfile(B) after import error = %v", err)
|
|
}
|
|
if loadedB.AccessToken != "access-b" {
|
|
t.Fatalf("profile B token = %q, want access-b", loadedB.AccessToken)
|
|
}
|
|
}
|
|
|
|
func requirePortableFileBackend(t *testing.T) {
|
|
t.Helper()
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("portable bundle round trips require a file-DEK backend; Windows uses DPAPI registry storage")
|
|
}
|
|
}
|