Files
dingtalk-workspace-cli/internal/auth/portable_store_test.go
SCzhengand修雨 809d026b7e ci: add CLI compatibility and PR quality gates (#602)
* 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>
2026-07-15 14:53:34 +08:00

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")
}
}