273 lines
9.8 KiB
Go
273 lines
9.8 KiB
Go
package app
|
|
|
|
import (
|
|
"encoding/csv"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
|
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
|
|
)
|
|
|
|
type auditCoverageSink struct {
|
|
events []*audit.Event
|
|
emitErr error
|
|
closeErr error
|
|
}
|
|
|
|
func (sink *auditCoverageSink) Emit(event *audit.Event) error {
|
|
sink.events = append(sink.events, event)
|
|
return sink.emitErr
|
|
}
|
|
|
|
func (sink *auditCoverageSink) Close() error { return sink.closeErr }
|
|
|
|
func TestCrossPlatformCoverageAuditCommandsAndFileHelpersCoverage(t *testing.T) {
|
|
originalExit, originalVerify := auditExit, auditVerify
|
|
t.Cleanup(func() { auditExit, auditVerify = originalExit, originalVerify })
|
|
dir := t.TempDir()
|
|
t.Setenv(audit.EnvAuditDir, dir)
|
|
if auditDir() != dir {
|
|
t.Fatalf("auditDir() = %q", auditDir())
|
|
}
|
|
|
|
tail := newAuditTailCommand()
|
|
tail.SetArgs([]string{"--lines", "1"})
|
|
if err := tail.Execute(); err == nil || !strings.Contains(err.Error(), "无审计记录") {
|
|
t.Fatalf("audit tail(empty) error = %v", err)
|
|
}
|
|
path := filepath.Join(dir, "audit-20260101.jsonl")
|
|
if err := os.WriteFile(path, []byte("one\ntwo\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tail = newAuditTailCommand()
|
|
tail.SetArgs([]string{"--lines", "1"})
|
|
if err := tail.Execute(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := tailFile(filepath.Join(dir, "missing"), 1); err == nil {
|
|
t.Fatal("tailFile(missing) error = nil")
|
|
}
|
|
tailErrorDir := t.TempDir()
|
|
if err := os.Mkdir(filepath.Join(tailErrorDir, "audit-20260101.jsonl"), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv(audit.EnvAuditDir, tailErrorDir)
|
|
tail = newAuditTailCommand()
|
|
if err := tail.Execute(); err == nil {
|
|
t.Fatal("audit tail(directory record) error = nil")
|
|
}
|
|
oversize := filepath.Join(dir, "oversize")
|
|
if err := os.WriteFile(oversize, []byte(strings.Repeat("x", 2*1024*1024)), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := tailFile(oversize, 1); err == nil {
|
|
t.Fatal("tailFile(oversize) error = nil")
|
|
}
|
|
|
|
exportDir := t.TempDir()
|
|
t.Setenv(audit.EnvAuditDir, exportDir)
|
|
export := newAuditExportCommand()
|
|
if err := export.Execute(); err == nil || !strings.Contains(err.Error(), "无审计文件") {
|
|
t.Fatalf("audit export(empty) error = %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(exportDir, "audit-20260102.jsonl"), []byte("{}\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, format := range []string{"jsonl", "csv"} {
|
|
export = newAuditExportCommand()
|
|
export.SetArgs([]string{"--since", "2026-01-01", "--until", "2026-01-03", "--format", format})
|
|
if err := export.Execute(); err != nil {
|
|
t.Fatalf("audit export(%s) error = %v", format, err)
|
|
}
|
|
}
|
|
export = newAuditExportCommand()
|
|
export.SetArgs([]string{"--format", "xml"})
|
|
if err := export.Execute(); err == nil || !strings.Contains(err.Error(), "不支持的格式") {
|
|
t.Fatalf("audit export(xml) error = %v", err)
|
|
}
|
|
t.Setenv(audit.EnvAuditDir, filepath.Join(exportDir, "missing"))
|
|
export = newAuditExportCommand()
|
|
if err := export.Execute(); err == nil || !strings.Contains(err.Error(), "查找审计文件失败") {
|
|
t.Fatalf("audit export(missing dir) error = %v", err)
|
|
}
|
|
|
|
if err := exportJSONL([]string{filepath.Join(dir, "missing")}); err == nil {
|
|
t.Fatal("exportJSONL(missing) error = nil")
|
|
}
|
|
if err := exportJSONL([]string{oversize}); err == nil {
|
|
t.Fatal("exportJSONL(oversize) error = nil")
|
|
}
|
|
if err := exportCSV([]string{filepath.Join(dir, "missing")}); err == nil {
|
|
t.Fatal("exportCSV(missing) error = nil")
|
|
}
|
|
blank := filepath.Join(dir, "blank")
|
|
if err := os.WriteFile(blank, []byte("\n \n{}\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := exportCSV([]string{blank}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := exportCSV([]string{oversize}); err == nil {
|
|
t.Fatal("exportCSV(oversize) error = nil")
|
|
}
|
|
|
|
originalWrite, originalFlush, originalError := auditCSVWrite, auditCSVFlush, auditCSVError
|
|
t.Cleanup(func() { auditCSVWrite, auditCSVFlush, auditCSVError = originalWrite, originalFlush, originalError })
|
|
auditCSVWrite = func(*csv.Writer, []string) error { return errors.New("write") }
|
|
if err := exportCSV(nil); err == nil || !strings.Contains(err.Error(), "表头") {
|
|
t.Fatalf("exportCSV(header error) = %v", err)
|
|
}
|
|
calls := 0
|
|
auditCSVWrite = func(writer *csv.Writer, row []string) error {
|
|
calls++
|
|
if calls > 1 {
|
|
return errors.New("row")
|
|
}
|
|
return originalWrite(writer, row)
|
|
}
|
|
if err := exportCSV([]string{blank}); err == nil || !strings.Contains(err.Error(), "记录") {
|
|
t.Fatalf("exportCSV(row error) = %v", err)
|
|
}
|
|
auditCSVWrite = originalWrite
|
|
auditCSVError = func(*csv.Writer) error { return errors.New("flush") }
|
|
if err := exportCSV(nil); err == nil || !strings.Contains(err.Error(), "刷新") {
|
|
t.Fatalf("exportCSV(flush error) = %v", err)
|
|
}
|
|
|
|
t.Setenv(audit.EnvAuditDir, t.TempDir())
|
|
verify := newAuditVerifyCommand()
|
|
if err := verify.Execute(); err == nil || !strings.Contains(err.Error(), "无审计文件") {
|
|
t.Fatalf("audit verify(empty) error = %v", err)
|
|
}
|
|
verify = newAuditVerifyCommand()
|
|
verify.SetArgs([]string{"--file", filepath.Join(dir, "missing")})
|
|
if err := verify.Execute(); err == nil || !strings.Contains(err.Error(), "校验失败") {
|
|
t.Fatalf("audit verify(missing) error = %v", err)
|
|
}
|
|
validDir := t.TempDir()
|
|
writer, err := audit.NewDateRotatingWriter(validDir, 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sink := audit.NewFileSink(writer, audit.NewChain(validDir), nil)
|
|
if err := sink.Emit(&audit.Event{Timestamp: time.Now(), Product: "test", Command: "ok"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := sink.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
validFile, err := audit.LatestAuditFile(validDir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
verify = newAuditVerifyCommand()
|
|
verify.SetArgs([]string{"--file", validFile})
|
|
if err := verify.Execute(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
broken := filepath.Join(t.TempDir(), "audit-broken.jsonl")
|
|
if err := os.WriteFile(broken, []byte(`{"prev_hash":"wrong","hash":"wrong"}`+"\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
exitCode := 0
|
|
auditExit = func(code int) { exitCode = code }
|
|
auditVerify = func(string) (bool, int, error) { return false, 1, nil }
|
|
verify = newAuditVerifyCommand()
|
|
verify.SetArgs([]string{"--file", broken})
|
|
if err := verify.Execute(); err != nil || exitCode != 1 {
|
|
t.Fatalf("audit verify(broken) = %v, exit=%d", err, exitCode)
|
|
}
|
|
t.Setenv(audit.EnvAuditDir, "")
|
|
if auditDir() == "" {
|
|
t.Fatal("default auditDir() is empty")
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageAuditRuntimeCoverage(t *testing.T) {
|
|
previousSink, previousLoader := sharedAuditSink, loadTokenForProfile
|
|
t.Cleanup(func() {
|
|
sharedAuditSink = previousSink
|
|
loadTokenForProfile = previousLoader
|
|
auditSinkOnce, auditCloseOnce = sync.Once{}, sync.Once{}
|
|
// The process-wide sink was initialized by TestMain. Preserve that
|
|
// initialized state when restoring it: leaving auditSinkOnce unused
|
|
// lets a later runner overwrite the live sink without closing its
|
|
// .audit.lock handle, which makes TestMain cleanup fail on Windows.
|
|
auditSinkOnce.Do(func() {})
|
|
if got := setupAuditSink(); got != previousSink {
|
|
t.Errorf("restored audit sink = %T, want original %T", got, previousSink)
|
|
}
|
|
resetAuditIdentityCache()
|
|
})
|
|
|
|
sharedAuditSink = nil
|
|
auditCloseOnce = sync.Once{}
|
|
CloseAuditSink()
|
|
failedClose := &auditCoverageSink{closeErr: errors.New("close")}
|
|
sharedAuditSink = failedClose
|
|
auditCloseOnce = sync.Once{}
|
|
CloseAuditSink()
|
|
|
|
bad := filepath.Join(t.TempDir(), "file")
|
|
if err := os.WriteFile(bad, []byte("x"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv(audit.EnvAudit, "1")
|
|
t.Setenv(audit.EnvAuditDir, filepath.Join(bad, "child"))
|
|
auditSinkOnce = sync.Once{}
|
|
sharedAuditSink = nil
|
|
if _, ok := setupAuditSink().(audit.NopSink); !ok {
|
|
t.Fatalf("setupAuditSink(error) = %T", sharedAuditSink)
|
|
}
|
|
|
|
t.Setenv(audit.EnvAuditDebug, "1")
|
|
fileLogger = logging.Setup(t.TempDir())
|
|
t.Cleanup(func() {
|
|
if fileLogger != nil {
|
|
fileLogger.Close()
|
|
fileLogger = nil
|
|
}
|
|
})
|
|
auditReport("coverage %d", 1)
|
|
loadTokenForProfile = func(string, string) (*auth.TokenData, error) { return nil, errors.New("identity") }
|
|
resetAuditIdentityCache()
|
|
if actor, _ := auditIdentity(); actor.UserID != "" {
|
|
t.Fatalf("auditIdentity(error) = %+v", actor)
|
|
}
|
|
|
|
invocation := executor.Invocation{CanonicalProduct: "calendar", Tool: "list", Params: map[string]any{"token": "secret"}}
|
|
emitAudit(nil, "nil", time.Now(), invocation, "https://example.com?token=secret", nil, "test")
|
|
emitAudit(audit.NopSink{}, "nop", time.Now(), invocation, "", nil, "test")
|
|
recording := &auditCoverageSink{}
|
|
emitAudit(recording, "ok", time.Now(), invocation, "https://example.com?token=secret", nil, "test")
|
|
if len(recording.events) != 1 || recording.events[0].Result != "success" {
|
|
t.Fatalf("successful audit events = %#v", recording.events)
|
|
}
|
|
typed := &apperrors.Error{Category: apperrors.CategoryAuth, Reason: "expired"}
|
|
emitAudit(recording, "typed", time.Now(), invocation, "", typed, "test")
|
|
if recording.events[1].ErrReason != "expired" {
|
|
t.Fatalf("typed audit event = %#v", recording.events[1])
|
|
}
|
|
recording.emitErr = errors.New("emit")
|
|
emitAudit(recording, "failed", time.Now(), invocation, "", errors.New("plain"), "test")
|
|
if category, reason := classifyAuditError(nil); category != "" || reason != "" {
|
|
t.Fatalf("classifyAuditError(nil) = %q, %q", category, reason)
|
|
}
|
|
if category, reason := classifyAuditError(fmt.Errorf("wrapped: %w", typed)); category != string(apperrors.CategoryAuth) || reason != "expired" {
|
|
t.Fatalf("classifyAuditError(typed) = %q, %q", category, reason)
|
|
}
|
|
if category, reason := classifyAuditError(errors.New("plain")); category != "unknown" || reason != "plain" {
|
|
t.Fatalf("classifyAuditError(plain) = %q, %q", category, reason)
|
|
}
|
|
}
|