Files
audita/internal/core/diagnostics/output_session_test.go

491 lines
17 KiB
Go

package diagnostics
import (
"bytes"
"errors"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestPrepareOutputAllowsInPlaceTranscriptButRejectsReportCollisions(t *testing.T) {
dir := t.TempDir()
input := filepath.Join(dir, "input.json")
if err := os.WriteFile(input, []byte("source"), 0600); err != nil {
t.Fatal(err)
}
alias := filepath.Join(dir, "alias.json")
if err := os.Symlink(input, alias); err != nil {
t.Fatal(err)
}
hardlink := filepath.Join(dir, "hardlink.json")
if err := os.Link(input, hardlink); err != nil {
t.Fatal(err)
}
for _, output := range []string{input, alias, hardlink} {
if _, err := PrepareOutput(OutputSelection{TranscriptPath: output, InputTranscriptPath: input}); err != nil {
t.Fatalf("in-place output rejected: %s: %v", output, err)
}
}
if _, err := PrepareOutput(OutputSelection{TranscriptPath: filepath.Join(dir, ErrorsFile), ReportDir: dir, ReportingEnabled: true}); err == nil {
t.Fatal("report/transcript collision accepted")
}
if _, err := PrepareOutput(OutputSelection{ReportDir: dir, ReportingEnabled: true, ProtectedPaths: []string{filepath.Join(dir, ErrorsFile)}}); err == nil {
t.Fatal("report/source collision accepted")
}
if err := os.Symlink(input, filepath.Join(dir, ErrorsFile)); err != nil {
t.Fatal(err)
}
if _, err := PrepareOutput(OutputSelection{ReportDir: dir, ReportingEnabled: true, InputTranscriptPath: input}); err == nil {
t.Fatal("report/input alias accepted")
}
if err := os.Remove(filepath.Join(dir, ErrorsFile)); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(dir, MarkdownFile), 0700); err != nil {
t.Fatal(err)
}
if _, err := PrepareOutput(OutputSelection{ReportDir: dir, ReportingEnabled: true}); err == nil {
t.Fatal("directory report target accepted")
}
if got, err := os.ReadFile(input); err != nil || string(got) != "source" {
t.Fatalf("source changed: %s %v", got, err)
}
}
func TestPrepareOutputPreservesExistingTranscriptAndChecksParent(t *testing.T) {
dir := t.TempDir()
output := filepath.Join(dir, "out.json")
if err := os.WriteFile(output, []byte("old content"), 0600); err != nil {
t.Fatal(err)
}
session, err := PrepareOutput(OutputSelection{TranscriptPath: output})
if err != nil {
t.Fatal(err)
}
if got, err := os.ReadFile(output); err != nil || string(got) != "old content" {
t.Fatalf("preflight truncated output: %s %v", got, err)
}
if failures := session.Finalize([]byte("new content"), ReportBundle{}, func([]FileFailure) ReportBundle { return ReportBundle{} }); len(failures) != 0 {
t.Fatal(failures)
}
if got, err := os.ReadFile(output); err != nil || string(got) != "new content" {
t.Fatalf("publication: %s %v", got, err)
}
if info, _ := os.Stat(output); info.Mode().Perm() != 0600 {
t.Fatalf("permission changed: %v", info.Mode())
}
if _, err := PrepareOutput(OutputSelection{TranscriptPath: filepath.Join(dir, "missing", "out.json")}); err == nil {
t.Fatal("absent parent accepted")
}
if _, err := PrepareOutput(OutputSelection{TranscriptPath: dir}); err == nil {
t.Fatal("directory target accepted")
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), ".audita-") {
t.Fatalf("probe left behind: %s", entry.Name())
}
}
}
func TestAtomicReplacementCleansTemporaryFileAfterRenameFailure(t *testing.T) {
dir := t.TempDir()
blocked := filepath.Join(dir, "directory-target")
if err := os.Mkdir(blocked, 0700); err != nil {
t.Fatal(err)
}
if err := atomicReplace(blocked, []byte("new content")); err == nil {
t.Fatal("directory replacement unexpectedly succeeded")
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), ".audita-write-") {
t.Fatalf("replacement left temporary file: %s", entry.Name())
}
}
}
func TestDisabledReportingIgnoresUnusableDirectory(t *testing.T) {
dir := t.TempDir()
session, err := PrepareOutput(OutputSelection{TranscriptPath: filepath.Join(dir, "out.json"), ReportDir: filepath.Join(dir, "missing"), ReportingEnabled: false})
if err != nil || len(session.ReportPaths()) != 0 {
t.Fatalf("disabled reports probed: %+v %v", session, err)
}
if err := session.Initialize(ReportBundle{}, nil); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, "missing")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("disabled destination created: %v", err)
}
}
func TestEnabledReportingRequiresExistingDirectory(t *testing.T) {
dir := t.TempDir()
if _, err := PrepareOutput(OutputSelection{ReportDir: filepath.Join(dir, "missing"), ReportingEnabled: true}); err == nil {
t.Fatal("missing report directory accepted")
}
if _, err := os.Stat(filepath.Join(dir, "missing")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("report parent created: %v", err)
}
}
func TestStdoutModeInitializesAndFinalizesReports(t *testing.T) {
dir := t.TempDir()
session, err := PrepareOutput(OutputSelection{ReportDir: dir, ReportingEnabled: true})
if err != nil {
t.Fatal(err)
}
initial := ReportBundle{Errors: []byte("in progress errors"), Recoveries: []byte("in progress recoveries"), Markdown: []byte("in progress report")}
finished := ReportBundle{Errors: []byte("completed errors"), Recoveries: []byte("completed recoveries"), Markdown: []byte("completed report")}
if err := session.Initialize(initial, nil); err != nil {
t.Fatal(err)
}
if session.TranscriptPath() != "" {
t.Fatal("stdout mode acquired transcript path")
}
for i, path := range session.ReportPaths() {
got, err := os.ReadFile(path)
if err != nil || string(got) != string(bundleParts(initial)[i]) {
t.Fatalf("initial %s: %s %v", path, got, err)
}
}
if failures := session.Finalize(nil, finished, func([]FileFailure) ReportBundle { return ReportBundle{} }); len(failures) != 0 {
t.Fatal(failures)
}
for i, path := range session.ReportPaths() {
got, err := os.ReadFile(path)
if err != nil || string(got) != string(bundleParts(finished)[i]) {
t.Fatalf("final %s: %s %v", path, got, err)
}
}
}
func TestInitializeFailureMarksPriorReportFailedBeforeModelWork(t *testing.T) {
dir := t.TempDir()
session, err := PrepareOutput(OutputSelection{ReportDir: dir, ReportingEnabled: true})
if err != nil {
t.Fatal(err)
}
actual := session.replace
session.replace = func(path string, payload []byte) error {
if filepath.Base(path) == RecoveriesFile && string(payload) == "initial" {
return os.ErrPermission
}
return actual(path, payload)
}
modelCalled := false
if err := session.Initialize(ReportBundle{Errors: []byte("initial"), Recoveries: []byte("initial"), Markdown: []byte("initial")}, func(failures []FileFailure) ReportBundle {
if len(failures) != 1 || filepath.Base(failures[0].Path) != RecoveriesFile {
t.Fatalf("initialization failures: %+v", failures)
}
return ReportBundle{Errors: []byte("failed"), Recoveries: []byte("failed"), Markdown: []byte("failed")}
}); err == nil {
modelCalled = true
}
if modelCalled {
t.Fatal("model work continued after initialization failure")
}
got, err := os.ReadFile(filepath.Join(dir, ErrorsFile))
if err != nil || string(got) != "failed" {
t.Fatalf("prior report not failed: %s %v", got, err)
}
if got, err := os.ReadFile(filepath.Join(dir, MarkdownFile)); err != nil || string(got) != "failed" {
t.Fatalf("later report not finalized: %q %v", got, err)
}
}
func TestFinalizationCollectsFailuresAndRetainsPublishedTranscript(t *testing.T) {
dir := t.TempDir()
output := filepath.Join(dir, "output.json")
session, err := PrepareOutput(OutputSelection{TranscriptPath: output, ReportDir: dir, ReportingEnabled: true})
if err != nil {
t.Fatal(err)
}
if err := session.Initialize(ReportBundle{Errors: []byte("initial"), Recoveries: []byte("initial"), Markdown: []byte("initial")}, nil); err != nil {
t.Fatal(err)
}
actual := session.replace
session.replace = func(path string, payload []byte) error {
if filepath.Base(path) == ErrorsFile && string(payload) == "complete" {
return os.ErrPermission
}
return actual(path, payload)
}
complete := ReportBundle{Errors: []byte("complete"), Recoveries: []byte("complete"), Markdown: []byte("complete")}
failed := ReportBundle{Errors: []byte("failed"), Recoveries: []byte("failed"), Markdown: []byte("failed")}
failures := session.Finalize([]byte("transcript"), complete, func([]FileFailure) ReportBundle { return failed })
if len(failures) != 1 || failures[0].Path != filepath.Join(dir, ErrorsFile) {
t.Fatalf("failures: %+v", failures)
}
if got, err := os.ReadFile(output); err != nil || string(got) != "transcript" {
t.Fatalf("transcript erased: %s %v", got, err)
}
for _, path := range session.ReportPaths() {
if got, err := os.ReadFile(path); err != nil || string(got) != "failed" {
t.Fatalf("failed-status pass %s: %s %v", path, got, err)
}
}
}
func TestDebugRunDirectoryUsesSuppliedIDAndIsRetained(t *testing.T) {
parent := filepath.Join(t.TempDir(), "debug")
if _, err := os.Stat(parent); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("unexpected parent: %v", err)
}
run, err := NewRunDirectory(parent, "run-123", "secret")
if err != nil {
t.Fatal(err)
}
if run.Path() != filepath.Join(parent, "run-123") {
t.Fatalf("run ID changed: %s", run.Path())
}
if _, err := NewRunDirectory(parent, "run-123"); err == nil {
t.Fatal("duplicate run ID accepted")
}
if err := run.WriteJSONArtifact(ArtifactCorrectionLedger, map[string]string{"proposal": "secret"}); err != nil {
t.Fatal(err)
}
contents, err := os.ReadFile(filepath.Join(run.Path(), ArtifactCorrectionLedger))
if err != nil || strings.Contains(string(contents), "secret") {
t.Fatalf("debug artifact: %s %v", contents, err)
}
if _, err := os.Stat(run.Path()); err != nil {
t.Fatalf("debug directory removed: %v", err)
}
}
func TestSelectionAndAbortPreserveTranscript(t *testing.T) {
dir := t.TempDir()
output := filepath.Join(dir, "output.json")
if err := os.WriteFile(output, []byte("original"), 0600); err != nil {
t.Fatal(err)
}
session, err := SelectOutput(OutputSelection{TranscriptPath: output})
if err != nil {
t.Fatal(err)
}
if entries, err := os.ReadDir(dir); err != nil || len(entries) != 1 {
t.Fatalf("selection changed directory: %v %v", entries, err)
}
if err := session.Prepare(); err != nil {
t.Fatal(err)
}
if session.temp == nil {
t.Fatal("temporary output not open")
}
temp := session.tempPath
if err := session.Close(); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(temp); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("unpublished file remains: %v", err)
}
if data, err := os.ReadFile(output); err != nil || string(data) != "original" {
t.Fatalf("output changed: %q %v", data, err)
}
}
func TestStdoutAndDeviceDelivery(t *testing.T) {
var stdout bytes.Buffer
session, err := PrepareOutput(OutputSelection{Stdout: &stdout})
if err != nil {
t.Fatal(err)
}
if failures := session.Finalize([]byte("transcript"), ReportBundle{}, nil); len(failures) != 0 {
t.Fatal(failures)
}
if stdout.String() != "transcript" {
t.Fatalf("stdout=%q", stdout.String())
}
session, err = PrepareOutput(OutputSelection{TranscriptPath: os.DevNull})
if err != nil {
t.Fatal(err)
}
if !session.direct || session.temp != nil {
t.Fatal("device was not deferred")
}
if failures := session.Finalize([]byte("discard"), ReportBundle{}, nil); len(failures) != 0 {
t.Fatal(failures)
}
}
func TestFinishRetainsPrimaryFailureAndAttemptsReports(t *testing.T) {
dir := t.TempDir()
output := filepath.Join(dir, "transcript.json")
if err := os.WriteFile(output, []byte("original"), 0600); err != nil {
t.Fatal(err)
}
session, err := PrepareOutput(OutputSelection{TranscriptPath: output, ReportDir: dir, ReportingEnabled: true})
if err != nil {
t.Fatal(err)
}
originalReplace := session.replace
session.replace = func(path string, data []byte) error {
if filepath.Base(path) == ErrorsFile {
return os.ErrPermission
}
return originalReplace(path, data)
}
primary := errors.New("processing failed")
bundle := ReportBundle{Errors: []byte("failed"), Recoveries: []byte("failed"), Markdown: []byte("failed")}
result := session.Finish(primary, []byte("unwanted"), bundle, func([]FileFailure) ReportBundle { return bundle })
if !errors.Is(result, primary) || !errors.Is(result, os.ErrPermission) {
t.Fatalf("combined error: %v", result)
}
if data, err := os.ReadFile(output); err != nil || string(data) != "original" {
t.Fatalf("output=%q err=%v", data, err)
}
if data, err := os.ReadFile(filepath.Join(dir, MarkdownFile)); err != nil || string(data) != "failed" {
t.Fatalf("remaining report=%q err=%v", data, err)
}
}
func TestOptionalDebugPreparationReportsProblems(t *testing.T) {
dir := t.TempDir()
session, err := SelectOutput(OutputSelection{})
if err != nil {
t.Fatal(err)
}
file := filepath.Join(dir, "not-directory")
if err := os.WriteFile(file, []byte("x"), 0600); err != nil {
t.Fatal(err)
}
if run, problems := session.PrepareDebug(file, "run", InvocationMetadata{}, nil); run != nil || len(problems) == 0 {
t.Fatalf("run=%v problems=%v", run, problems)
}
run, problems := session.PrepareDebug(filepath.Join(dir, "debug"), "run", InvocationMetadata{Operation: "process"}, []byte("secret: sensitive\n"), "sensitive")
if len(problems) != 0 {
t.Fatal(problems)
}
contents, err := os.ReadFile(filepath.Join(run.Path(), ArtifactEffectiveConfig))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(contents), "sensitive") {
t.Fatal("debug artifact contains secret")
}
}
type shortOutput struct{}
func (shortOutput) Write(b []byte) (int, error) { return len(b) / 2, nil }
func TestShortStdoutWriteIsFailure(t *testing.T) {
session, err := PrepareOutput(OutputSelection{Stdout: shortOutput{}})
if err != nil {
t.Fatal(err)
}
failures := session.Finalize([]byte("transcript"), ReportBundle{}, nil)
if len(failures) != 1 || !errors.Is(failures[0], io.ErrShortWrite) {
t.Fatalf("failures=%v", failures)
}
}
func TestTranscriptWriteFailureCleansTemporaryResource(t *testing.T) {
dir := t.TempDir()
output := filepath.Join(dir, "out.json")
if err := os.WriteFile(output, []byte("original"), 0600); err != nil {
t.Fatal(err)
}
session, err := PrepareOutput(OutputSelection{TranscriptPath: output})
if err != nil {
t.Fatal(err)
}
temp := session.tempPath
if err := session.temp.Close(); err != nil {
t.Fatal(err)
}
failures := session.Finalize([]byte("new"), ReportBundle{}, nil)
if len(failures) == 0 {
t.Fatal("closed output accepted")
}
if _, err := os.Stat(temp); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("temporary output remains: %v", err)
}
if data, err := os.ReadFile(output); err != nil || string(data) != "original" {
t.Fatalf("output=%q err=%v", data, err)
}
}
func TestNamedPipeIsNotOpenedDuringPreparation(t *testing.T) {
pipe := filepath.Join(t.TempDir(), "output-pipe")
makeNamedPipe(t, pipe)
session, err := PrepareOutput(OutputSelection{TranscriptPath: pipe})
if err != nil {
t.Fatal(err)
}
if !session.direct || session.temp != nil {
t.Fatal("pipe was not deferred")
}
if err := session.Close(); err != nil {
t.Fatal(err)
}
}
func TestLateNamedPipeIsPreserved(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "output")
session, err := PrepareOutput(OutputSelection{TranscriptPath: path})
if err != nil {
t.Fatal(err)
}
defer session.Close()
makeNamedPipe(t, path)
err = session.Finish(nil, []byte("transcript"), ReportBundle{}, nil)
if err == nil || !strings.Contains(err.Error(), "changed type") {
t.Fatalf("expected clear delivery failure, got %v", err)
}
info, err := os.Stat(path)
if err != nil || info.Mode()&os.ModeNamedPipe == 0 {
t.Fatalf("late pipe was lost: %v %v", info, err)
}
entries, err := os.ReadDir(dir)
if err != nil || len(entries) != 1 {
t.Fatalf("unpublished output resources remain: %v %v", entries, err)
}
}
func TestStreamChangedToRegularFileIsPreserved(t *testing.T) {
path := filepath.Join(t.TempDir(), "output")
makeNamedPipe(t, path)
session, err := PrepareOutput(OutputSelection{TranscriptPath: path})
if err != nil {
t.Fatal(err)
}
defer session.Close()
if err := os.Remove(path); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("existing content"), 0600); err != nil {
t.Fatal(err)
}
err = session.Finish(nil, []byte("new"), ReportBundle{}, nil)
if err == nil || !strings.Contains(err.Error(), "no longer a stream") {
t.Fatalf("expected clear delivery failure, got %v", err)
}
if data, err := os.ReadFile(path); err != nil || string(data) != "existing content" {
t.Fatalf("replacement file changed: %q %v", data, err)
}
}
func makeNamedPipe(t *testing.T, path string) {
t.Helper()
tool, err := exec.LookPath("mkfifo")
if err != nil {
t.Skip("mkfifo is unavailable")
}
if output, err := exec.Command(tool, path).CombinedOutput(); err != nil {
t.Fatalf("mkfifo: %v %s", err, output)
}
}