package audit import ( "encoding/json" "os" "path/filepath" "strings" "testing" "hauler.dev/go/hauler/v2/internal/flags" "hauler.dev/go/hauler/v2/pkg/consts" ) func TestAppend(t *testing.T) { dir := t.TempDir() e := Entry{ Command: "store add image", Args: []string{"busybox:latest"}, Flags: map[string]any{"platform": "linux/amd64"}, Store: filepath.Join(dir, "store"), } if err := Append(dir, e); err != nil { t.Fatalf("Append: %v", err) } path := filepath.Join(dir, "audit.log") data, err := os.ReadFile(path) if err != nil { t.Fatalf("ReadFile: %v", err) } var got Entry if err := json.Unmarshal(data, &got); err != nil { t.Fatalf("unmarshal audit line: %v\nraw: %s", err, data) } if got.Command != e.Command { t.Errorf("Command = %q, want %q", got.Command, e.Command) } if len(got.Args) != 1 || got.Args[0] != "busybox:latest" { t.Errorf("Args = %v, want [busybox:latest]", got.Args) } if got.Timestamp == "" { t.Error("Timestamp should be set") } } // TestAppend_PortableReferenceOverridesStoreCopy verifies PortableReference only affects the store audit log func TestAppend_PortableReferenceOverridesStoreCopy(t *testing.T) { dir := t.TempDir() absPath := filepath.Join(string(filepath.Separator), "home", "example", "scripts", "install.sh") typedPath := filepath.Join(".", "scripts", "install.sh") e := Entry{ Command: "store add file", Type: "file", Args: []string{typedPath}, Reference: absPath, PortableReference: typedPath, Store: filepath.Join(dir, "store"), } if err := Append(dir, e); err != nil { t.Fatalf("Append: %v", err) } // global log keeps the full path and the as-typed args globalData, err := os.ReadFile(filepath.Join(dir, "audit.log")) if err != nil { t.Fatalf("ReadFile global audit.log: %v", err) } var globalGot Entry if err := json.Unmarshal(globalData, &globalGot); err != nil { t.Fatalf("unmarshal global audit line: %v\nraw: %s", err, globalData) } if globalGot.Reference != absPath { t.Errorf("global Reference = %q, want %q", globalGot.Reference, absPath) } if len(globalGot.Args) != 1 || globalGot.Args[0] != typedPath { t.Errorf("global Args = %v, want [%s]", globalGot.Args, typedPath) } // store log uses PortableReference instead, and carries no args at all storeData, err := os.ReadFile(filepath.Join(dir, "store", "audit.log")) if err != nil { t.Fatalf("ReadFile store audit log: %v", err) } var got portableEntry if err := json.Unmarshal(storeData, &got); err != nil { t.Fatalf("unmarshal store audit line: %v\nraw: %s", err, storeData) } if got.Reference != typedPath { t.Errorf("store Reference = %q, want %q", got.Reference, typedPath) } if strings.Contains(string(storeData), `"args"`) { t.Errorf("store entry should not carry args: %s", storeData) } } // TestAppend_StoreDefaultsToReference verifies the fallback when PortableReference is unset func TestAppend_StoreDefaultsToReference(t *testing.T) { dir := t.TempDir() e := Entry{ Command: "store remove", Type: "file", Args: []string{"install.sh"}, Reference: "hauler/install.sh:latest", Store: filepath.Join(dir, "store"), } if err := Append(dir, e); err != nil { t.Fatalf("Append: %v", err) } storeData, err := os.ReadFile(filepath.Join(dir, "store", "audit.log")) if err != nil { t.Fatalf("ReadFile store audit log: %v", err) } var got portableEntry if err := json.Unmarshal(storeData, &got); err != nil { t.Fatalf("unmarshal store audit line: %v\nraw: %s", err, storeData) } if got.Reference != e.Reference { t.Errorf("store Reference = %q, want %q (unmodified)", got.Reference, e.Reference) } } func TestAppend_StoreWriteSucceedsWhenGlobalWriteFails(t *testing.T) { dir := t.TempDir() // occupy the path so appendLine's MkdirAll fails for the global write only blockedHaulerDir := filepath.Join(dir, "blocked") if err := os.WriteFile(blockedHaulerDir, []byte("not a dir"), 0o644); err != nil { t.Fatalf("WriteFile: %v", err) } storeDir := filepath.Join(dir, "store") e := Entry{ Command: "store add image", Args: []string{"busybox:latest"}, Store: storeDir, } if err := Append(blockedHaulerDir, e); err == nil { t.Fatal("Append: expected error from unwritable global haulerDir, got nil") } storeData, err := os.ReadFile(filepath.Join(storeDir, "audit.log")) if err != nil { t.Fatalf("ReadFile store audit log: %v", err) } var got portableEntry if err := json.Unmarshal(storeData, &got); err != nil { t.Fatalf("unmarshal store audit line: %v\nraw: %s", err, storeData) } if got.Command != e.Command { t.Errorf("store command = %q, want = %q", got.Command, e.Command) } } func TestAppend_MultipleEntries(t *testing.T) { dir := t.TempDir() for i := 0; i < 3; i++ { if err := Append(dir, Entry{Command: "store add image", Args: []string{"img"}}); err != nil { t.Fatalf("Append[%d]: %v", i, err) } } data, err := os.ReadFile(filepath.Join(dir, "audit.log")) if err != nil { t.Fatalf("ReadFile: %v", err) } lines := 0 for _, b := range data { if b == '\n' { lines++ } } if lines != 3 { t.Errorf("expected 3 lines, got %d\nlog:\n%s", lines, data) } } func TestMergeStoreLog_AppendsOntoExisting(t *testing.T) { temp, dest := t.TempDir(), t.TempDir() if err := os.WriteFile(filepath.Join(dest, LogFileName), []byte(`{"command":"original"}`+"\n"), 0o644); err != nil { t.Fatalf("seed dest log: %v", err) } if err := os.WriteFile(filepath.Join(temp, LogFileName), []byte(`{"command":"from-haul"}`+"\n"), 0o644); err != nil { t.Fatalf("seed temp log: %v", err) } if err := MergeStoreLog(temp, dest); err != nil { t.Fatalf("MergeStoreLog: %v", err) } data, err := os.ReadFile(filepath.Join(dest, LogFileName)) if err != nil { t.Fatalf("ReadFile: %v", err) } if !strings.Contains(string(data), "original") || !strings.Contains(string(data), "from-haul") { t.Fatalf("expected both entries preserved, got: %s", data) } } func TestMergeStoreLog_CreatesDestWhenMissing(t *testing.T) { temp := t.TempDir() dest := filepath.Join(t.TempDir(), "not-yet-created") if err := os.WriteFile(filepath.Join(temp, LogFileName), []byte(`{"command":"from-haul"}`+"\n"), 0o644); err != nil { t.Fatalf("seed temp log: %v", err) } if err := MergeStoreLog(temp, dest); err != nil { t.Fatalf("MergeStoreLog: %v", err) } data, err := os.ReadFile(filepath.Join(dest, LogFileName)) if err != nil { t.Fatalf("ReadFile: %v", err) } if !strings.Contains(string(data), "from-haul") { t.Fatalf("expected dest log created with haul's entry, got: %s", data) } } func TestMergeStoreLog_NoopWhenTempHasNoLog(t *testing.T) { temp, dest := t.TempDir(), t.TempDir() if err := MergeStoreLog(temp, dest); err != nil { t.Fatalf("MergeStoreLog: %v", err) } if _, err := os.Stat(filepath.Join(dest, LogFileName)); !os.IsNotExist(err) { t.Fatalf("expected no dest log to be created, stat err: %v", err) } } func TestShortFileRef(t *testing.T) { tests := []struct { name string in string want string }{ {"local relative path", "./scripts/install.sh", "install.sh"}, {"local absolute path", "/home/example/scripts/install.sh", "install.sh"}, {"bare filename", "install.sh", "install.sh"}, {"plain URL", "https://get.rke2.io/install.sh", "install.sh"}, {"URL with credentials", "https://user:pass@get.rke2.io/install.sh", "install.sh"}, {"URL with query token", "https://get.rke2.io/install.sh?token=abc123", "install.sh"}, {"URL with credentials and query", "https://user:pass@get.rke2.io/install.sh?sig=abc123", "install.sh"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { if got := ShortFileRef(tc.in); got != tc.want { t.Errorf("ShortFileRef(%q) = %q, want %q", tc.in, got, tc.want) } }) } } func TestResolveDir_Default(t *testing.T) { got := resolveDir("") if got == "" { t.Error("resolveDir(\"\") returned empty string") } } func TestBuildGlobal_IgnoreErrorsReflectsEnvVar(t *testing.T) { // BuildGlobal must record the *effective* ignore-errors setting (flag OR // env var) via flags.ShouldIgnoreErrors, not just the raw ro.IgnoreErrors // field — callers no longer mutate ro.IgnoreErrors to reflect the env var. ro := &flags.CliRootOpts{IgnoreErrors: false} t.Setenv(consts.HaulerIgnoreErrors, "true") g := BuildGlobal(ro, nil) if !g.IgnoreErrors { t.Fatal("expected BuildGlobal to report IgnoreErrors=true when HAULER_IGNORE_ERRORS is set, even though ro.IgnoreErrors is false") } if ro.IgnoreErrors { t.Fatal("expected BuildGlobal to not mutate ro.IgnoreErrors") } }