Files

252 lines
5.7 KiB
Go

package audit
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
)
func TestFileSinkEmit(t *testing.T) {
dir := t.TempDir()
writer, err := NewDateRotatingWriter(dir, 90)
if err != nil {
t.Fatal(err)
}
chain := NewChain(dir)
sink := NewFileSink(writer, chain, nil)
defer sink.Close()
evt := &Event{
Timestamp: time.Now(),
ExecutionID: "abc123",
Actor: Actor{UserID: "u1", CorpID: "c1"},
Product: "calendar",
Command: "list_events",
Endpoint: "https://api.example.com/mcp",
Result: "success",
DurationMs: 150,
CLIVersion: "1.0.47",
OS: "darwin",
Arch: "arm64",
}
if err := sink.Emit(evt); err != nil {
t.Fatal(err)
}
if evt.Hash == "" {
t.Error("expected hash to be set")
}
if evt.PrevHash != "" {
t.Error("first event should have empty prev_hash")
}
file, err := LatestAuditFile(dir)
if err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
var decoded Event
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatal(err)
}
if decoded.ExecutionID != "abc123" {
t.Errorf("got execution_id=%s, want abc123", decoded.ExecutionID)
}
if decoded.Hash == "" {
t.Error("decoded hash should not be empty")
}
}
func TestChainIntegrity(t *testing.T) {
dir := t.TempDir()
writer, err := NewDateRotatingWriter(dir, 90)
if err != nil {
t.Fatal(err)
}
chain := NewChain(dir)
sink := NewFileSink(writer, chain, nil)
for i := 0; i < 5; i++ {
evt := &Event{
Timestamp: time.Now(),
ExecutionID: "exec-" + string(rune('a'+i)),
Actor: Actor{UserID: "u1", CorpID: "c1"},
Product: "test",
Command: "cmd",
Result: "success",
DurationMs: int64(i * 10),
CLIVersion: "1.0.0",
OS: "linux",
Arch: "amd64",
}
if err := sink.Emit(evt); err != nil {
t.Fatal(err)
}
}
sink.Close()
file, err := LatestAuditFile(dir)
if err != nil {
t.Fatal(err)
}
valid, brokenAt, err := VerifyFile(file)
if err != nil {
t.Fatalf("verify error: %v", err)
}
if !valid {
t.Errorf("expected valid chain, broken at line %d", brokenAt)
}
}
func TestChainDetectsTampering(t *testing.T) {
dir := t.TempDir()
writer, err := NewDateRotatingWriter(dir, 90)
if err != nil {
t.Fatal(err)
}
chain := NewChain(dir)
sink := NewFileSink(writer, chain, nil)
for i := 0; i < 3; i++ {
evt := &Event{
Timestamp: time.Now(),
ExecutionID: "exec-" + string(rune('0'+i)),
Actor: Actor{UserID: "u1", CorpID: "c1"},
Product: "test",
Command: "cmd",
Result: "success",
DurationMs: 100,
CLIVersion: "1.0.0",
OS: "linux",
Arch: "amd64",
}
if err := sink.Emit(evt); err != nil {
t.Fatal(err)
}
}
sink.Close()
file, err := LatestAuditFile(dir)
if err != nil {
t.Fatal(err)
}
// Tamper with the file: modify a character in the second line
data, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
// Find second newline and change a char after it
lines := splitLines(data)
if len(lines) < 2 {
t.Fatal("expected at least 2 lines")
}
// Corrupt the second line by changing first char of product
var evt2 map[string]any
json.Unmarshal([]byte(lines[1]), &evt2)
evt2["product"] = "tampered"
tampered, _ := json.Marshal(evt2)
lines[1] = string(tampered)
corrupted := []byte(lines[0] + "\n" + lines[1] + "\n" + lines[2] + "\n")
os.WriteFile(file, corrupted, 0o600)
valid, brokenAt, _ := VerifyFile(file)
if valid {
t.Error("expected invalid chain after tampering")
}
if brokenAt != 2 {
t.Errorf("expected break at line 2, got %d", brokenAt)
}
}
func TestRetention(t *testing.T) {
dir := t.TempDir()
// Create old files
oldDate := time.Now().AddDate(0, 0, -100).Format("20060102")
recentDate := time.Now().AddDate(0, 0, -10).Format("20060102")
os.WriteFile(filepath.Join(dir, "audit-"+oldDate+".jsonl"), []byte("old"), 0o600)
os.WriteFile(filepath.Join(dir, "audit-"+recentDate+".jsonl"), []byte("recent"), 0o600)
writer, err := NewDateRotatingWriter(dir, 90)
if err != nil {
t.Fatal(err)
}
defer writer.Close()
// Give async pruning a moment
time.Sleep(50 * time.Millisecond)
if _, err := os.Stat(filepath.Join(dir, "audit-"+oldDate+".jsonl")); !os.IsNotExist(err) {
t.Error("expected old file to be pruned")
}
if _, err := os.Stat(filepath.Join(dir, "audit-"+recentDate+".jsonl")); err != nil {
t.Error("recent file should still exist")
}
}
func TestRedactEvent(t *testing.T) {
evt := Event{
Actor: Actor{UserID: "uid123", Name: "张三", CorpID: "corp1", CorpName: "公司A"},
Product: "calendar",
Command: "list",
ParamsSummary: `{"date":"2026-01-01"}`,
Result: "success",
}
hashed := RedactEvent(evt, RedactHashed)
if hashed.Actor.Name == "张三" {
t.Error("name should be hashed")
}
if hashed.ParamsSummary != "" {
t.Error("params should be cleared in hashed mode")
}
if hashed.Actor.UserID != "uid123" {
t.Error("user_id should remain in hashed mode")
}
minimal := RedactEvent(evt, RedactMinimal)
if minimal.Actor.UserID == "uid123" {
t.Error("user_id should be hashed in minimal mode")
}
if minimal.Endpoint != "" {
t.Error("endpoint should be cleared in minimal mode")
}
}
func TestNopSink(t *testing.T) {
var s NopSink
if err := s.Emit(&Event{}); err != nil {
t.Error("NopSink.Emit should not error")
}
if err := s.Close(); err != nil {
t.Error("NopSink.Close should not error")
}
}
func splitLines(data []byte) []string {
var lines []string
start := 0
for i, b := range data {
if b == '\n' {
if i > start {
lines = append(lines, string(data[start:i]))
}
start = i + 1
}
}
if start < len(data) {
lines = append(lines, string(data[start:]))
}
return lines
}