* 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>
354 lines
7.9 KiB
Go
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)
|
|
}
|
|
}
|