Files
dingtalk-workspace-cli/internal/auth/filelock_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

354 lines
7.9 KiB
Go

package auth
import (
"context"
"os"
"path/filepath"
"runtime"
"sync"
"sync/atomic"
"testing"
"time"
)
func TestAcquireTokenLock_Basic(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
lock, err := acquireTokenLock(configDir)
if err != nil {
t.Fatalf("acquireTokenLock() error = %v", err)
}
// Lock file should exist while held.
lockPath := filepath.Join(configDir, lockFileName)
if _, err := os.Stat(lockPath); err != nil {
t.Fatalf("lock file should exist while held, stat error = %v", err)
}
lock.release()
// Lock file may still exist on disk after release (flock does not remove
// the file), but we should be able to re-acquire it.
lock2, err := acquireTokenLock(configDir)
if err != nil {
t.Fatalf("re-acquire after release error = %v", err)
}
lock2.release()
}
func TestAcquireTokenLock_CreatesDirectory(t *testing.T) {
t.Parallel()
base := t.TempDir()
configDir := filepath.Join(base, "a", "b", "c")
lock, err := acquireTokenLock(configDir)
if err != nil {
t.Fatalf("acquireTokenLock() error = %v", err)
}
defer lock.release()
info, err := os.Stat(configDir)
if err != nil {
t.Fatalf("Stat(configDir) error = %v", err)
}
if !info.IsDir() {
t.Fatal("configDir should be a directory")
}
}
func TestAcquireTokenLock_DoubleRelease(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
lock, err := acquireTokenLock(configDir)
if err != nil {
t.Fatalf("acquireTokenLock() error = %v", err)
}
// First release should work fine.
lock.release()
// Second release should not panic.
lock.release()
}
func TestAcquireTokenLock_Contention(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
// Goroutine 1 acquires the lock first.
lock1, err := acquireTokenLock(configDir)
if err != nil {
t.Fatalf("acquireTokenLock() g1 error = %v", err)
}
acquired := make(chan struct{})
var g2Err error
var wg sync.WaitGroup
wg.Add(1)
// Goroutine 2 tries to acquire — should block until g1 releases.
go func() {
defer wg.Done()
lock2, err := acquireTokenLock(configDir)
if err != nil {
g2Err = err
close(acquired)
return
}
close(acquired)
lock2.release()
}()
// Give goroutine 2 a moment to start blocking.
time.Sleep(100 * time.Millisecond)
// Verify goroutine 2 has not acquired yet.
select {
case <-acquired:
t.Fatal("goroutine 2 should not have acquired the lock while goroutine 1 holds it")
default:
// Expected: goroutine 2 is still waiting.
}
// Release lock1 so goroutine 2 can proceed.
lock1.release()
// Wait for goroutine 2 to finish.
wg.Wait()
if g2Err != nil {
t.Fatalf("acquireTokenLock() g2 error = %v", g2Err)
}
}
func TestAcquireTokenLock_LockFilePermissions(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows enforces file access through ACLs, not POSIX mode bits")
}
t.Parallel()
configDir := t.TempDir()
lock, err := acquireTokenLock(configDir)
if err != nil {
t.Fatalf("acquireTokenLock() error = %v", err)
}
defer lock.release()
lockPath := filepath.Join(configDir, lockFileName)
info, err := os.Stat(lockPath)
if err != nil {
t.Fatalf("Stat(lock file) error = %v", err)
}
perm := info.Mode().Perm()
if perm != 0o600 {
t.Fatalf("lock file permissions = %o, want 0600", perm)
}
}
// ─── Process-level lock tests ───────────────────────────────────────────
func TestAcquireProcessLock_Basic(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
ctx := context.Background()
release, waited, err := acquireProcessLock(ctx, configDir)
if err != nil {
t.Fatalf("acquireProcessLock() error = %v", err)
}
if waited {
t.Fatal("should not have waited on first acquisition")
}
release()
// Should be able to re-acquire after release
release2, waited2, err := acquireProcessLock(ctx, configDir)
if err != nil {
t.Fatalf("re-acquire after release error = %v", err)
}
if waited2 {
t.Fatal("should not have waited on re-acquisition")
}
release2()
}
func TestAcquireProcessLock_Contention(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
ctx := context.Background()
// Goroutine 1 acquires the lock first
release1, _, err := acquireProcessLock(ctx, configDir)
if err != nil {
t.Fatalf("acquireProcessLock() g1 error = %v", err)
}
acquired := make(chan bool, 1)
var g2Waited bool
var wg sync.WaitGroup
wg.Add(1)
// Goroutine 2 tries to acquire — should block until g1 releases
go func() {
defer wg.Done()
release2, waited, err := acquireProcessLock(ctx, configDir)
if err != nil {
acquired <- false
return
}
g2Waited = waited
acquired <- true
release2()
}()
// Give goroutine 2 a moment to start blocking
time.Sleep(50 * time.Millisecond)
// Verify goroutine 2 has not acquired yet
select {
case <-acquired:
t.Fatal("goroutine 2 should not have acquired the lock while goroutine 1 holds it")
default:
// Expected: goroutine 2 is still waiting
}
// Release lock1 so goroutine 2 can proceed
release1()
// Wait for goroutine 2 to finish
wg.Wait()
if !g2Waited {
t.Fatal("goroutine 2 should have reported that it waited")
}
}
func TestAcquireProcessLock_ContextCancellation(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
ctx, cancel := context.WithCancel(context.Background())
// Goroutine 1 holds the lock
release1, _, err := acquireProcessLock(ctx, configDir)
if err != nil {
t.Fatalf("acquireProcessLock() g1 error = %v", err)
}
defer release1()
// Goroutine 2 tries to acquire with a cancellable context
done := make(chan error, 1)
go func() {
_, _, err := acquireProcessLock(ctx, configDir)
done <- err
}()
// Give goroutine 2 time to start waiting
time.Sleep(50 * time.Millisecond)
// Cancel the context
cancel()
// Goroutine 2 should return with context.Canceled
select {
case err := <-done:
if err != context.Canceled {
t.Fatalf("expected context.Canceled, got %v", err)
}
case <-time.After(time.Second):
t.Fatal("goroutine 2 did not return after context cancellation")
}
}
// ─── Dual-layer lock tests ──────────────────────────────────────────────
func TestAcquireDualLock_Basic(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
ctx := context.Background()
lock, err := AcquireDualLock(ctx, configDir)
if err != nil {
t.Fatalf("AcquireDualLock() error = %v", err)
}
if lock.Waited {
t.Fatal("should not have waited on first acquisition")
}
lock.Release()
// Should be able to re-acquire after release
lock2, err := AcquireDualLock(ctx, configDir)
if err != nil {
t.Fatalf("re-acquire after release error = %v", err)
}
lock2.Release()
}
func TestAcquireDualLock_DoubleRelease(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
ctx := context.Background()
lock, err := AcquireDualLock(ctx, configDir)
if err != nil {
t.Fatalf("AcquireDualLock() error = %v", err)
}
// First release should work fine
lock.Release()
// Second release should not panic
lock.Release()
}
func TestAcquireDualLock_ConcurrentGoroutines(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
ctx := context.Background()
const numGoroutines = 10
var counter int64
var wg sync.WaitGroup
wg.Add(numGoroutines)
// Launch multiple goroutines that all try to increment a counter
// while holding the dual lock. If locking works correctly,
// the final counter value should be numGoroutines.
for i := 0; i < numGoroutines; i++ {
go func() {
defer wg.Done()
lock, err := AcquireDualLock(ctx, configDir)
if err != nil {
t.Errorf("AcquireDualLock() error = %v", err)
return
}
defer lock.Release()
// Critical section: read-modify-write
current := atomic.LoadInt64(&counter)
time.Sleep(time.Millisecond) // Simulate some work
atomic.StoreInt64(&counter, current+1)
}()
}
wg.Wait()
if counter != numGoroutines {
t.Fatalf("counter = %d, want %d (race condition detected)", counter, numGoroutines)
}
}