Files
dingtalk-workspace-cli/internal/auth/secure_store_test.go
T
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

195 lines
5.5 KiB
Go

package auth
import (
"os"
"path/filepath"
"runtime"
"sync"
"testing"
"time"
)
func TestSaveSecureTokenData_FixesUnsafePermissions(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows enforces directory access through ACLs, not POSIX mode bits")
}
configDir := filepath.Join(t.TempDir(), "unsafe")
// Create directory with overly permissive mode.
if err := os.MkdirAll(configDir, 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
data := &TokenData{
AccessToken: "at_perm",
RefreshToken: "rt_perm",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: "corp1",
}
if err := SaveSecureTokenData(configDir, data); err != nil {
t.Fatalf("SaveSecureTokenData() error = %v", err)
}
info, err := os.Stat(configDir)
if err != nil {
t.Fatalf("Stat() error = %v", err)
}
perm := info.Mode().Perm()
if perm != 0o700 {
t.Fatalf("directory permissions = %o, want 0700", perm)
}
}
func TestSaveSecureTokenData_PlaintextZeroed(t *testing.T) {
configDir := t.TempDir()
data := &TokenData{
AccessToken: "at_zero_save",
RefreshToken: "rt_zero_save",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: "corp1",
}
// Save should succeed, which exercises the defer that zeros plaintext.
if err := SaveSecureTokenData(configDir, data); err != nil {
t.Fatalf("SaveSecureTokenData() error = %v", err)
}
// Verify the data round-trips correctly, proving the zeroing defer
// runs after encryption (not before).
loaded, err := LoadSecureTokenData(configDir)
if err != nil {
t.Fatalf("LoadSecureTokenData() error = %v", err)
}
if loaded.AccessToken != "at_zero_save" {
t.Fatalf("AccessToken = %q, want at_zero_save", loaded.AccessToken)
}
}
func TestLoadSecureTokenData_PlaintextZeroed(t *testing.T) {
configDir := t.TempDir()
data := &TokenData{
AccessToken: "at_zero_load",
RefreshToken: "rt_zero_load",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: "corp1",
}
if err := SaveSecureTokenData(configDir, data); err != nil {
t.Fatalf("SaveSecureTokenData() error = %v", err)
}
// Load twice to confirm zeroing the first plaintext buffer does not
// corrupt subsequent loads (i.e. each call decrypts independently).
for i := 0; i < 2; i++ {
loaded, err := LoadSecureTokenData(configDir)
if err != nil {
t.Fatalf("LoadSecureTokenData() iteration %d error = %v", i, err)
}
if loaded.AccessToken != "at_zero_load" {
t.Fatalf("iteration %d: AccessToken = %q, want at_zero_load", i, loaded.AccessToken)
}
}
}
func TestSaveSecureTokenData_TmpFileCleanedOnSuccess(t *testing.T) {
configDir := t.TempDir()
data := &TokenData{
AccessToken: "at_tmp",
RefreshToken: "rt_tmp",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: "corp1",
}
if err := SaveSecureTokenData(configDir, data); err != nil {
t.Fatalf("SaveSecureTokenData() error = %v", err)
}
tmpPath := filepath.Join(configDir, secureDataFile+".tmp")
if _, err := os.Stat(tmpPath); !os.IsNotExist(err) {
t.Fatalf(".data.tmp should not remain after successful save, stat err = %v", err)
}
// The final file must exist.
finalPath := filepath.Join(configDir, secureDataFile)
if _, err := os.Stat(finalPath); err != nil {
t.Fatalf(".data should exist after save, stat err = %v", err)
}
}
func TestSaveSecureTokenData_ConcurrentSaves(t *testing.T) {
configDir := t.TempDir()
const goroutines = 10
var wg sync.WaitGroup
errs := make([]error, goroutines)
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
data := &TokenData{
AccessToken: "at_concurrent",
RefreshToken: "rt_concurrent",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: "corp1",
}
errs[idx] = SaveSecureTokenData(configDir, data)
}(i)
}
wg.Wait()
// Under concurrency, some saves may fail due to tmp-file races. That is
// acceptable — the important thing is that at least one succeeds and the
// final file is not corrupted.
successes := 0
for _, err := range errs {
if err == nil {
successes++
}
}
if successes == 0 {
t.Fatal("all concurrent SaveSecureTokenData() calls failed; expected at least one success")
}
// After all concurrent saves, the file should be loadable (not corrupted).
loaded, err := LoadSecureTokenData(configDir)
if err != nil {
t.Fatalf("LoadSecureTokenData() after concurrent saves error = %v", err)
}
if loaded.AccessToken != "at_concurrent" {
t.Fatalf("AccessToken = %q, want at_concurrent", loaded.AccessToken)
}
}
func TestDeleteSecureData_Idempotent(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
// Delete when file does not exist should not return an error.
if err := DeleteSecureData(configDir); err != nil {
t.Fatalf("first DeleteSecureData() on empty dir error = %v", err)
}
// Calling again should still be fine.
if err := DeleteSecureData(configDir); err != nil {
t.Fatalf("second DeleteSecureData() error = %v", err)
}
}
func TestSecureDataExists_FalseWhenMissing(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
if SecureDataExists(configDir) {
t.Fatal("SecureDataExists() = true on empty dir, want false")
}
// Also check a completely non-existent directory.
if SecureDataExists(filepath.Join(configDir, "nonexistent")) {
t.Fatal("SecureDataExists() = true on non-existent dir, want false")
}
}