491 lines
17 KiB
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)
|
|
}
|
|
}
|