252 lines
5.7 KiB
Go
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
|
|
}
|