package comparison import ( "context" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "runtime" "strings" "testing" "gitea.maximumdirect.net/eric/weatherreporter/internal/testutil" ) func TestPlanDestination(t *testing.T) { workingDirectory := t.TempDir() absent := filepath.Join(workingDirectory, "absent") plan, err := PlanDestination(workingDirectory, absent, false) if err != nil { t.Fatalf("PlanDestination() error = %v", err) } if plan.Target != absent || plan.state != destinationAbsent { t.Fatalf("PlanDestination() = %#v, want absent target", plan) } nested := filepath.Join(workingDirectory, "missing-parent", "comparison") if _, err := PlanDestination(workingDirectory, nested, false); err != nil { t.Fatalf("PlanDestination(nested) error = %v", err) } if _, err := os.Stat(filepath.Dir(nested)); !errors.Is(err, os.ErrNotExist) { t.Fatalf("read-only plan created parent: stat error = %v", err) } empty := filepath.Join(workingDirectory, "empty") if err := os.Mkdir(empty, 0o755); err != nil { t.Fatal(err) } plan, err = PlanDestination(workingDirectory, empty, false) if err != nil || plan.state != destinationEmpty { t.Fatalf("PlanDestination(empty) = %#v, %v", plan, err) } file := filepath.Join(workingDirectory, "file") if err := os.WriteFile(file, []byte("not a directory"), 0o600); err != nil { t.Fatal(err) } assertDestinationErrorKind(t, workingDirectory, file, false, DestinationNotDirectory) assertDestinationErrorKind(t, workingDirectory, workingDirectory, false, DestinationWorkingDirectory) assertDestinationErrorKind(t, workingDirectory, string(os.PathSeparator), false, DestinationFilesystemRoot) assertDestinationErrorKind(t, workingDirectory, "relative", false, DestinationInvalidPath) t.Run("symbolic links", func(t *testing.T) { link := filepath.Join(workingDirectory, "link") testutil.RequireSymlink(t, empty, link) assertDestinationErrorKind(t, workingDirectory, link, false, DestinationSymlink) dangling := filepath.Join(workingDirectory, "dangling") testutil.RequireSymlink(t, filepath.Join(workingDirectory, "missing"), dangling) assertDestinationErrorKind(t, workingDirectory, dangling, false, DestinationSymlink) assertDestinationErrorKind(t, workingDirectory, filepath.Join(dangling, "child"), false, DestinationInspection) }) nonempty := filepath.Join(workingDirectory, "nonempty") if err := os.Mkdir(nonempty, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(nonempty, "extra"), []byte("x"), 0o600); err != nil { t.Fatal(err) } assertDestinationErrorKind(t, workingDirectory, nonempty, false, DestinationNotEmpty) assertDestinationErrorKind(t, workingDirectory, nonempty, true, DestinationUnrecognized) } func TestPlanDestinationRejectsUnreadableDirectory(t *testing.T) { if os.Geteuid() == 0 { t.Skip("root can inspect directories regardless of mode") } workingDirectory := t.TempDir() target := filepath.Join(workingDirectory, "unreadable") if err := os.Mkdir(target, 0o700); err != nil { t.Fatal(err) } if err := os.Chmod(target, 0); err != nil { t.Fatal(err) } t.Cleanup(func() { _ = os.Chmod(target, 0o700) }) assertDestinationErrorKind(t, workingDirectory, target, false, DestinationInspection) } func TestRecognizeBundleRejectsUnsafeAndMalformedContents(t *testing.T) { tests := []struct { name string mutate func(t *testing.T, directory string) }{ {name: "extra file", mutate: func(t *testing.T, directory string) { t.Helper() if err := os.WriteFile(filepath.Join(directory, "extra.txt"), []byte("extra"), 0o600); err != nil { t.Fatal(err) } }}, {name: "subdirectory", mutate: func(t *testing.T, directory string) { t.Helper() if err := os.Mkdir(filepath.Join(directory, "nested"), 0o700); err != nil { t.Fatal(err) } }}, {name: "missing report", mutate: func(t *testing.T, directory string) { t.Helper() if err := os.Remove(filepath.Join(directory, "01-weather-light.md")); err != nil { t.Fatal(err) } }}, {name: "symlink report", mutate: func(t *testing.T, directory string) { t.Helper() path := filepath.Join(directory, "01-weather-light.md") if err := os.Remove(path); err != nil { t.Fatal(err) } testutil.RequireSymlink(t, filepath.Join(directory, DataPackageFilename), path) }}, {name: "digest mismatch", mutate: func(t *testing.T, directory string) { t.Helper() if err := os.WriteFile(filepath.Join(directory, DataPackageFilename), []byte("altered"), 0o600); err != nil { t.Fatal(err) } }}, {name: "unknown manifest field", mutate: func(t *testing.T, directory string) { t.Helper() path := filepath.Join(directory, ManifestFilename) data, err := os.ReadFile(path) if err != nil { t.Fatal(err) } data = append(data[:len(data)-2], []byte(",\n \"unknown\": true\n}\n")...) if err := os.WriteFile(path, data, 0o600); err != nil { t.Fatal(err) } }}, {name: "duplicate manifest field", mutate: func(t *testing.T, directory string) { t.Helper() appendManifestField(t, directory, `"schemaVersion": "weatherreporter.comparison.v1"`) }}, {name: "case variant manifest field", mutate: func(t *testing.T, directory string) { t.Helper() appendManifestField(t, directory, `"SchemaVersion": "weatherreporter.comparison.v1"`) }}, {name: "traversal report path", mutate: func(t *testing.T, directory string) { t.Helper() path := filepath.Join(directory, ManifestFilename) data, err := os.ReadFile(path) if err != nil { t.Fatal(err) } var manifest Manifest if err := json.Unmarshal(data, &manifest); err != nil { t.Fatal(err) } manifest.Results[0].ReportPath = "../outside.md" data, err = json.Marshal(manifest) if err != nil { t.Fatal(err) } if err := os.WriteFile(path, data, 0o600); err != nil { t.Fatal(err) } }}, {name: "noncanonical report path", mutate: func(t *testing.T, directory string) { t.Helper() path := filepath.Join(directory, ManifestFilename) data, err := os.ReadFile(path) if err != nil { t.Fatal(err) } var manifest Manifest if err := json.Unmarshal(data, &manifest); err != nil { t.Fatal(err) } if err := os.Rename(filepath.Join(directory, manifest.Results[0].ReportPath), filepath.Join(directory, "arbitrary.md")); err != nil { t.Fatal(err) } manifest.Results[0].ReportPath = "arbitrary.md" data, err = json.Marshal(manifest) if err != nil { t.Fatal(err) } if err := os.WriteFile(path, data, 0o600); err != nil { t.Fatal(err) } }}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { directory := publishTestBundle(t, testBundle()) test.mutate(t, directory) if _, err := RecognizeBundle(directory); !errors.Is(err, ErrUnrecognizedBundle) { t.Fatalf("RecognizeBundle() error = %v, want ErrUnrecognizedBundle", err) } }) } } func appendManifestField(t *testing.T, directory, field string) { t.Helper() path := filepath.Join(directory, ManifestFilename) data, err := os.ReadFile(path) if err != nil { t.Fatal(err) } data = append(data[:len(data)-2], []byte(",\n "+field+"\n}\n")...) if err := os.WriteFile(path, data, 0o600); err != nil { t.Fatal(err) } } func TestPlanDestinationAcceptsRecognizedReplacement(t *testing.T) { directory := publishTestBundle(t, testBundle()) plan, err := PlanDestination(filepath.Dir(directory), directory, true) if err != nil { t.Fatalf("PlanDestination() error = %v", err) } if plan.state != destinationBundle { t.Fatal("PlanDestination() did not record recognized existing destination") } } func TestRecognizeBundleReadsOnlyRequiredArtifactContents(t *testing.T) { for _, reportCount := range []int{2, 12} { t.Run(fmt.Sprintf("%d reports", reportCount), func(t *testing.T) { bundle := testBundleWithReports(t, reportCount) directory := publishTestBundle(t, bundle) bytesRead := make(map[string]int) opens := make(map[string]int) operations := defaultRecognitionOperations operations.open = func(path string) (io.ReadCloser, error) { file, err := os.Open(path) if err != nil { return nil, err } name := filepath.Base(path) opens[name]++ return &countingReadCloser{ReadCloser: file, count: func(n int) { bytesRead[name] += n }}, nil } if _, err := recognizeBundle(directory, operations); err != nil { t.Fatalf("recognizeBundle() error = %v", err) } for _, name := range []string{ManifestFilename, DataPackageFilename} { data := readFile(t, filepath.Join(directory, name)) if opens[name] != 1 || bytesRead[name] != len(data) { t.Fatalf("%s opens/bytes = %d/%d, want 1/%d", name, opens[name], bytesRead[name], len(data)) } } for _, report := range bundle.Reports { if opens[report.Path] != 1 || bytesRead[report.Path] != 0 { t.Fatalf("report %s opens/bytes = %d/%d, want 1/0", report.Path, opens[report.Path], bytesRead[report.Path]) } } }) } } func TestReplacementUsesEarlyAndFinalRecognition(t *testing.T) { workingDirectory := t.TempDir() target := filepath.Join(workingDirectory, "comparison-daily") bundle := testBundleWithReports(t, 12) initialPlan, err := PlanDestination(workingDirectory, target, false) if err != nil { t.Fatal(err) } if _, err := Publish(context.Background(), initialPlan, bundle); err != nil { t.Fatal(err) } recognitions := 0 recognize := func(directory string) (Manifest, error) { recognitions++ return RecognizeBundle(directory) } plan, err := planDestination(workingDirectory, target, true, recognize) if err != nil { t.Fatal(err) } if _, err := publish(context.Background(), plan, bundle, publishOperations{ rename: os.Rename, removeAll: os.RemoveAll, recognize: recognize, }); err != nil { t.Fatal(err) } if recognitions != 2 { t.Fatalf("full bundle recognitions = %d, want early and final authorization", recognitions) } } func TestPublishReauthorizesMovedDestination(t *testing.T) { tests := []struct { name string replace bool requiresSymlink bool prepare func(t *testing.T, workingDirectory, target string) mutate func(t *testing.T, target string) verify func(t *testing.T, target string) }{ { name: "regular file after absent preflight", mutate: func(t *testing.T, target string) { t.Helper() if err := os.WriteFile(target, []byte("unrelated file"), 0o600); err != nil { t.Fatal(err) } }, verify: func(t *testing.T, target string) { t.Helper() if data := readFile(t, target); string(data) != "unrelated file" { t.Fatalf("unrelated file data = %q", data) } }, }, { name: "nonempty directory after empty preflight", prepare: func(t *testing.T, _, target string) { t.Helper() if err := os.Mkdir(target, 0o700); err != nil { t.Fatal(err) } }, mutate: writeUnrecognizedDirectory, verify: func(t *testing.T, target string) { t.Helper() if data := readFile(t, filepath.Join(target, "unrelated")); string(data) != "unrelated" { t.Fatalf("unrelated data = %q", data) } }, }, { name: "regular file after empty preflight", prepare: func(t *testing.T, _, target string) { t.Helper() if err := os.Mkdir(target, 0o700); err != nil { t.Fatal(err) } }, mutate: func(t *testing.T, target string) { t.Helper() if err := os.WriteFile(target, []byte("unrelated file"), 0o600); err != nil { t.Fatal(err) } }, verify: func(t *testing.T, target string) { t.Helper() if data := readFile(t, target); string(data) != "unrelated file" { t.Fatalf("unrelated file data = %q", data) } }, }, { name: "symlink after empty preflight", requiresSymlink: true, prepare: func(t *testing.T, _, target string) { t.Helper() if err := os.Mkdir(target, 0o700); err != nil { t.Fatal(err) } }, mutate: func(t *testing.T, target string) { t.Helper() testutil.RequireSymlink(t, "unrelated-target", target) }, verify: func(t *testing.T, target string) { t.Helper() info, err := os.Lstat(target) if err != nil || info.Mode()&os.ModeSymlink == 0 { t.Fatalf("symlink stat = %v, %v", info, err) } }, }, { name: "unrecognized directory after bundle preflight", replace: true, prepare: func(t *testing.T, workingDirectory, target string) { t.Helper() plan, err := PlanDestination(workingDirectory, target, false) if err != nil { t.Fatal(err) } if _, err := Publish(context.Background(), plan, testBundle()); err != nil { t.Fatal(err) } }, mutate: writeUnrecognizedDirectory, verify: func(t *testing.T, target string) { t.Helper() if data := readFile(t, filepath.Join(target, "unrelated")); string(data) != "unrelated" { t.Fatalf("unrelated data = %q", data) } }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { if test.requiresSymlink && runtime.GOOS == "windows" { t.Skip("symlink replacement coverage requires Unix symlink semantics") } workingDirectory := t.TempDir() target := filepath.Join(workingDirectory, "comparison-daily") if test.prepare != nil { test.prepare(t, workingDirectory, target) } plan, err := PlanDestination(workingDirectory, target, test.replace) if err != nil { t.Fatal(err) } _, err = publish(context.Background(), plan, testBundle(), publishOperations{ rename: os.Rename, beforeCommit: func() { if err := os.RemoveAll(target); err != nil { t.Fatal(err) } test.mutate(t, target) }, }) if err == nil { t.Fatal("publish() succeeded despite unauthorized replacement") } test.verify(t, target) assertOnlyDestinationEntry(t, workingDirectory, filepath.Base(target)) }) } } func TestPublishRetainsUnauthorizedMovedDestinationWhenRestoreFails(t *testing.T) { workingDirectory := t.TempDir() target := filepath.Join(workingDirectory, "comparison-daily") if err := os.Mkdir(target, 0o700); err != nil { t.Fatal(err) } plan, err := PlanDestination(workingDirectory, target, false) if err != nil { t.Fatal(err) } var backupPath string calls := 0 _, err = publish(context.Background(), plan, testBundle(), publishOperations{ rename: func(oldPath, newPath string) error { calls++ if calls == 1 { backupPath = newPath } if calls == 2 { return errors.New("restore failed") } return os.Rename(oldPath, newPath) }, beforeCommit: func() { if err := os.RemoveAll(target); err != nil { t.Fatal(err) } if err := os.WriteFile(target, []byte("unrelated file"), 0o600); err != nil { t.Fatal(err) } }, }) if err == nil || !strings.Contains(err.Error(), backupPath) { t.Fatalf("publish() error = %v, want retained backup path %q", err, backupPath) } if data := readFile(t, backupPath); string(data) != "unrelated file" { t.Fatalf("retained backup data = %q", data) } if _, err := os.Lstat(target); !errors.Is(err, os.ErrNotExist) { t.Fatalf("target stat error = %v, want not exist", err) } assertOnlyDestinationEntry(t, workingDirectory, filepath.Base(backupPath)) } func TestPublishRetainsMovedDestinationWhenTargetReappears(t *testing.T) { workingDirectory := t.TempDir() target := filepath.Join(workingDirectory, "comparison-daily") if err := os.Mkdir(target, 0o700); err != nil { t.Fatal(err) } plan, err := PlanDestination(workingDirectory, target, false) if err != nil { t.Fatal(err) } var backupPath string _, err = publish(context.Background(), plan, testBundle(), publishOperations{ rename: func(oldPath, newPath string) error { if backupPath != "" { return os.Rename(oldPath, newPath) } backupPath = newPath if err := os.Rename(oldPath, newPath); err != nil { return err } if err := os.WriteFile(filepath.Join(backupPath, "unrelated"), []byte("unrelated"), 0o600); err != nil { t.Fatal(err) } if err := os.WriteFile(target, []byte("reappeared"), 0o600); err != nil { t.Fatal(err) } return nil }, }) if err == nil || !strings.Contains(err.Error(), "reappeared") || !strings.Contains(err.Error(), backupPath) { t.Fatalf("publish() error = %v, want reappeared target and retained backup path %q", err, backupPath) } if data := readFile(t, target); string(data) != "reappeared" { t.Fatalf("reappeared target data = %q", data) } if data := readFile(t, filepath.Join(backupPath, "unrelated")); string(data) != "unrelated" { t.Fatalf("retained backup data = %q", data) } assertDestinationEntries(t, workingDirectory, filepath.Base(target), filepath.Base(backupPath)) } func TestPublishWritesAndReplacesBundle(t *testing.T) { workingDirectory := t.TempDir() target := filepath.Join(workingDirectory, "comparison-daily") plan, err := PlanDestination(workingDirectory, target, false) if err != nil { t.Fatal(err) } if _, err := Publish(context.Background(), plan, testBundle()); err != nil { t.Fatalf("Publish() error = %v", err) } if _, err := RecognizeBundle(target); err != nil { t.Fatalf("RecognizeBundle() error = %v", err) } assertMode(t, target, 0o700) for _, name := range []string{ManifestFilename, DataPackageFilename, "01-weather-light.md"} { assertMode(t, filepath.Join(target, name), 0o600) } next := testBundle() next.Manifest.Results[0].ProfileID = "weather-balanced" next.Manifest.Results[0].ReportPath = "01-weather-balanced.md" next.Reports[0].Path = "01-weather-balanced.md" next.Reports[0].Markdown = []byte("# Replacement\n") plan, err = PlanDestination(workingDirectory, target, true) if err != nil { t.Fatal(err) } if _, err := Publish(context.Background(), plan, next); err != nil { t.Fatalf("Publish(replace) error = %v", err) } if _, err := os.Stat(filepath.Join(target, "01-weather-light.md")); !errors.Is(err, os.ErrNotExist) { t.Fatalf("stale report stat error = %v, want not exist", err) } if data, err := os.ReadFile(filepath.Join(target, "01-weather-balanced.md")); err != nil || string(data) != "# Replacement\n" { t.Fatalf("replacement report = %q, %v", data, err) } } func TestPublishReplacesEmptyDirectory(t *testing.T) { workingDirectory := t.TempDir() target := filepath.Join(workingDirectory, "comparison-daily") if err := os.Mkdir(target, 0o755); err != nil { t.Fatal(err) } plan, err := PlanDestination(workingDirectory, target, false) if err != nil { t.Fatal(err) } if _, err := Publish(context.Background(), plan, testBundle()); err != nil { t.Fatalf("Publish() error = %v", err) } if _, err := RecognizeBundle(target); err != nil { t.Fatalf("RecognizeBundle() error = %v", err) } } func TestComparisonDestinationPreflightsTransactionSiblingNames(t *testing.T) { workingDirectory := t.TempDir() missingParent := filepath.Join(workingDirectory, "missing") target := filepath.Join(missingParent, strings.Repeat("a", maxComparisonComponentBytes+1)) if _, err := PlanDestination(workingDirectory, target, false); err == nil { t.Fatal("PlanDestination() accepted an overlong destination name") } if _, err := os.Lstat(missingParent); !errors.Is(err, os.ErrNotExist) { t.Fatalf("missing parent stat error = %v, want not exist", err) } } func TestPublishSupportsLongestDestinationComponent(t *testing.T) { workingDirectory := t.TempDir() parent := filepath.Join(workingDirectory, "nested") target := filepath.Join(parent, strings.Repeat("a", maxComparisonComponentBytes)) plan, err := PlanDestination(workingDirectory, target, false) if err != nil { t.Fatalf("PlanDestination() error = %v", err) } if _, err := Publish(context.Background(), plan, testBundle()); err != nil { t.Fatalf("Publish() error = %v", err) } if _, err := RecognizeBundle(target); err != nil { t.Fatalf("RecognizeBundle() error = %v", err) } next := testBundle() next.Reports[0].Markdown = []byte("# Replacement\n") plan, err = PlanDestination(workingDirectory, target, true) if err != nil { t.Fatalf("PlanDestination(replace) error = %v", err) } if _, err := Publish(context.Background(), plan, next); err != nil { t.Fatalf("Publish(replace) error = %v", err) } if data := readFile(t, filepath.Join(target, "01-weather-light.md")); string(data) != "# Replacement\n" { t.Fatalf("replacement report = %q", data) } assertOnlyDestinationEntry(t, parent, filepath.Base(target)) } func TestPublishReportsCommittedBundleWhenBackupCleanupFails(t *testing.T) { workingDirectory := t.TempDir() target := filepath.Join(workingDirectory, "comparison-daily") initialPlan, err := PlanDestination(workingDirectory, target, false) if err != nil { t.Fatal(err) } if _, err := Publish(context.Background(), initialPlan, testBundle()); err != nil { t.Fatal(err) } previous := directorySnapshot(t, target) next := testBundle() next.Reports[0].Markdown = []byte("# Next\n") plan, err := PlanDestination(workingDirectory, target, true) if err != nil { t.Fatal(err) } cleanupCause := errors.New("backup removal failed") var backupPath string result, err := publish(context.Background(), plan, next, publishOperations{ rename: os.Rename, removeAll: func(path string) error { backupPath = path return cleanupCause }, }) var cleanupErr *PublicationCleanupError if !result.Committed || result.RecoveryState != BackupRecoveryComplete || result.RecoveryPath != backupPath || !filepath.IsAbs(backupPath) || !errors.As(err, &cleanupErr) || cleanupErr.RecoveryState != BackupRecoveryComplete || cleanupErr.RecoveryPath != backupPath || !errors.Is(err, cleanupCause) { t.Fatalf("publish() result/error = %#v/%v", result, err) } if _, err := RecognizeBundle(target); err != nil { t.Fatalf("new bundle recognition error = %v", err) } if retained := directorySnapshot(t, backupPath); !equalSnapshots(previous, retained) { t.Fatalf("retained backup = %#v, want %#v", retained, previous) } manifestData, err := os.ReadFile(filepath.Join(target, ManifestFilename)) if err != nil || strings.Contains(string(manifestData), backupPath) { t.Fatalf("manifest/backup path = %q/%q, error = %v", manifestData, backupPath, err) } } func TestPublishReportsPartialRecoveryAfterBackupCleanupFailure(t *testing.T) { workingDirectory := t.TempDir() target := filepath.Join(workingDirectory, "comparison-daily") initialPlan, err := PlanDestination(workingDirectory, target, false) if err != nil { t.Fatal(err) } if _, err := Publish(context.Background(), initialPlan, testBundle()); err != nil { t.Fatal(err) } plan, err := PlanDestination(workingDirectory, target, true) if err != nil { t.Fatal(err) } cleanupCause := errors.New("backup removal failed") var backupPath string result, err := publish(context.Background(), plan, testBundle(), publishOperations{ rename: os.Rename, removeAll: func(path string) error { backupPath = path if err := os.Remove(filepath.Join(path, ManifestFilename)); err != nil { t.Fatal(err) } return cleanupCause }, }) var cleanupErr *PublicationCleanupError if !result.Committed || result.RecoveryState != BackupRecoveryPartial || result.RecoveryPath != backupPath || !errors.As(err, &cleanupErr) || cleanupErr.RecoveryState != BackupRecoveryPartial || cleanupErr.RecoveryPath != backupPath || !errors.Is(err, cleanupCause) { t.Fatalf("publish() result/error = %#v/%v", result, err) } if _, err := RecognizeBundle(target); err != nil { t.Fatalf("new bundle recognition error = %v", err) } if _, err := RecognizeBundle(backupPath); err == nil { t.Fatal("partially removed backup was recognized") } } func TestPublishReportsAbsentRecoveryAfterBackupCleanupFailure(t *testing.T) { workingDirectory := t.TempDir() target := filepath.Join(workingDirectory, "comparison-daily") initialPlan, err := PlanDestination(workingDirectory, target, false) if err != nil { t.Fatal(err) } if _, err := Publish(context.Background(), initialPlan, testBundle()); err != nil { t.Fatal(err) } plan, err := PlanDestination(workingDirectory, target, true) if err != nil { t.Fatal(err) } cleanupCause := errors.New("backup removal failed") var backupPath string result, err := publish(context.Background(), plan, testBundle(), publishOperations{ rename: os.Rename, removeAll: func(path string) error { backupPath = path if err := os.RemoveAll(path); err != nil { t.Fatal(err) } return cleanupCause }, }) var cleanupErr *PublicationCleanupError if !result.Committed || result.RecoveryState != BackupRecoveryAbsent || result.RecoveryPath != "" || !errors.As(err, &cleanupErr) || cleanupErr.RecoveryState != BackupRecoveryAbsent || cleanupErr.RecoveryPath != "" || !errors.Is(err, cleanupCause) { t.Fatalf("publish() result/error = %#v/%v", result, err) } if _, err := RecognizeBundle(target); err != nil { t.Fatalf("new bundle recognition error = %v", err) } if _, err := os.Lstat(backupPath); !errors.Is(err, os.ErrNotExist) { t.Fatalf("backup stat error = %v, want not exist", err) } } func TestPublishRestoresExistingBundleAfterReplacementFailure(t *testing.T) { workingDirectory := t.TempDir() target := filepath.Join(workingDirectory, "comparison-daily") initial := testBundle() initialPlan, err := PlanDestination(workingDirectory, target, false) if err != nil { t.Fatal(err) } if _, err := Publish(context.Background(), initialPlan, initial); err != nil { t.Fatal(err) } before := directorySnapshot(t, target) next := testBundle() next.Reports[0].Markdown = []byte("# New\n") plan, err := PlanDestination(workingDirectory, target, true) if err != nil { t.Fatal(err) } calls := 0 publication, err := publish(context.Background(), plan, next, publishOperations{rename: func(oldPath, newPath string) error { calls++ if calls == 2 { return errors.New("replace failed") } return os.Rename(oldPath, newPath) }}) if publication.Committed || err == nil { t.Fatal("publish() succeeded despite replacement failure") } after := directorySnapshot(t, target) if !equalSnapshots(before, after) { t.Fatal("failed publication changed the prior bundle") } entries, err := os.ReadDir(workingDirectory) if err != nil { t.Fatal(err) } if len(entries) != 1 || entries[0].Name() != filepath.Base(target) { t.Fatalf("failed publication left sibling artifacts: %#v", entries) } } func TestPublishRetainsBackupWhenRestorationFails(t *testing.T) { workingDirectory := t.TempDir() target := filepath.Join(workingDirectory, "comparison-daily") initialPlan, err := PlanDestination(workingDirectory, target, false) if err != nil { t.Fatal(err) } if _, err := Publish(context.Background(), initialPlan, testBundle()); err != nil { t.Fatal(err) } plan, err := PlanDestination(workingDirectory, target, true) if err != nil { t.Fatal(err) } var backupPath string calls := 0 _, err = publish(context.Background(), plan, testBundle(), publishOperations{rename: func(oldPath, newPath string) error { calls++ if calls == 1 { backupPath = newPath } if calls == 2 || calls == 3 { return errors.New("rename failed") } return os.Rename(oldPath, newPath) }}) if err == nil || !strings.Contains(err.Error(), backupPath) { t.Fatalf("publish() error = %v, want retained backup path %q", err, backupPath) } if info, statErr := os.Stat(backupPath); statErr != nil || !info.IsDir() { t.Fatalf("backup stat = %v, %v, want retained directory", info, statErr) } } func TestPublishCancellationBeforeCommitLeavesNoDestination(t *testing.T) { workingDirectory := t.TempDir() target := filepath.Join(workingDirectory, "comparison-daily") plan, err := PlanDestination(workingDirectory, target, false) if err != nil { t.Fatal(err) } ctx, cancel := context.WithCancel(context.Background()) if publication, err := publish(ctx, plan, testBundle(), publishOperations{rename: os.Rename, beforeCommit: cancel}); publication.Committed || !errors.Is(err, context.Canceled) { t.Fatalf("Publish() error = %v, want context cancellation", err) } if _, err := os.Lstat(target); !errors.Is(err, os.ErrNotExist) { t.Fatalf("destination stat error = %v, want not exist", err) } } func TestPublishRestoresPriorDestinationWhenCanceledAfterBackup(t *testing.T) { for _, test := range []struct { name string prepare func(t *testing.T, workingDirectory, target string) }{ { name: "empty directory", prepare: func(t *testing.T, _, target string) { t.Helper() if err := os.Mkdir(target, 0o700); err != nil { t.Fatal(err) } }, }, { name: "recognized bundle", prepare: func(t *testing.T, workingDirectory, target string) { t.Helper() plan, err := PlanDestination(workingDirectory, target, false) if err != nil { t.Fatal(err) } if _, err := Publish(context.Background(), plan, testBundle()); err != nil { t.Fatal(err) } }, }, } { t.Run(test.name, func(t *testing.T) { workingDirectory := t.TempDir() target := filepath.Join(workingDirectory, "comparison-daily") test.prepare(t, workingDirectory, target) before := directorySnapshot(t, target) plan, err := PlanDestination(workingDirectory, target, true) if err != nil { t.Fatal(err) } ctx, cancel := context.WithCancel(context.Background()) next := testBundle() next.Reports[0].Markdown = []byte("# Replacement\n") publication, err := publish(ctx, plan, next, publishOperations{rename: func(oldPath, newPath string) error { if err := os.Rename(oldPath, newPath); err != nil { return err } if oldPath == target { cancel() } return nil }}) if publication.Committed || !errors.Is(err, context.Canceled) { t.Fatalf("publish() result/error = %#v/%v, want canceled uncommitted publication", publication, err) } if after := directorySnapshot(t, target); !equalSnapshots(before, after) { t.Fatalf("restored destination = %#v, want %#v", after, before) } assertOnlyDestinationEntry(t, workingDirectory, filepath.Base(target)) }) } } func TestPublishRetainsBackupWhenCancellationRestorationFails(t *testing.T) { workingDirectory := t.TempDir() target := filepath.Join(workingDirectory, "comparison-daily") initialPlan, err := PlanDestination(workingDirectory, target, false) if err != nil { t.Fatal(err) } if _, err := Publish(context.Background(), initialPlan, testBundle()); err != nil { t.Fatal(err) } plan, err := PlanDestination(workingDirectory, target, true) if err != nil { t.Fatal(err) } ctx, cancel := context.WithCancel(context.Background()) restoreCause := errors.New("restore failed") var backupPath string calls := 0 publication, err := publish(ctx, plan, testBundle(), publishOperations{rename: func(oldPath, newPath string) error { calls++ if calls == 2 { return restoreCause } if err := os.Rename(oldPath, newPath); err != nil { return err } if calls == 1 { backupPath = newPath cancel() } return nil }}) if publication.Committed || !errors.Is(err, context.Canceled) || !errors.Is(err, restoreCause) { t.Fatalf("publish() result/error = %#v/%v, want cancellation and restoration failure", publication, err) } if info, statErr := os.Stat(backupPath); statErr != nil || !info.IsDir() { t.Fatalf("backup stat = %v, %v, want retained directory", info, statErr) } if _, statErr := os.Lstat(target); !errors.Is(statErr, os.ErrNotExist) { t.Fatalf("destination stat error = %v, want not exist", statErr) } assertOnlyDestinationEntry(t, workingDirectory, filepath.Base(backupPath)) } func TestPublishReturnsCommittedBundleWhenCanceledAfterInstall(t *testing.T) { workingDirectory := t.TempDir() target := filepath.Join(workingDirectory, "comparison-daily") initialPlan, err := PlanDestination(workingDirectory, target, false) if err != nil { t.Fatal(err) } if _, err := Publish(context.Background(), initialPlan, testBundle()); err != nil { t.Fatal(err) } next := testBundle() next.Reports[0].Markdown = []byte("# Next\n") plan, err := PlanDestination(workingDirectory, target, true) if err != nil { t.Fatal(err) } ctx, cancel := context.WithCancel(context.Background()) publication, err := publish(ctx, plan, next, publishOperations{rename: func(oldPath, newPath string) error { if err := os.Rename(oldPath, newPath); err != nil { return err } if newPath == target && oldPath != target { cancel() } return nil }}) if !publication.Committed || err != nil { t.Fatalf("publish() result/error = %#v/%v, want committed bundle", publication, err) } if err := ctx.Err(); !errors.Is(err, context.Canceled) { t.Fatalf("context error = %v, want cancellation", err) } if got := string(readFile(t, filepath.Join(target, next.Reports[0].Path))); got != string(next.Reports[0].Markdown) { t.Fatalf("published report = %q, want %q", got, next.Reports[0].Markdown) } if _, err := RecognizeBundle(target); err != nil { t.Fatalf("committed bundle recognition error = %v", err) } assertOnlyDestinationEntry(t, workingDirectory, filepath.Base(target)) } func assertDestinationErrorKind(t *testing.T, workingDirectory, target string, replace bool, want DestinationErrorKind) { t.Helper() _, err := PlanDestination(workingDirectory, target, replace) var destinationError *DestinationError if !errors.As(err, &destinationError) || destinationError.Kind != want { t.Fatalf("PlanDestination(%q) error = %v, want destination error %q", target, err, want) } } func publishTestBundle(t *testing.T, bundle LogicalBundle) string { t.Helper() workingDirectory := t.TempDir() target := filepath.Join(workingDirectory, "comparison-daily") plan, err := PlanDestination(workingDirectory, target, false) if err != nil { t.Fatal(err) } if _, err := Publish(context.Background(), plan, bundle); err != nil { t.Fatal(err) } return target } func writeUnrecognizedDirectory(t *testing.T, target string) { t.Helper() if err := os.Mkdir(target, 0o700); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(target, "unrelated"), []byte("unrelated"), 0o600); err != nil { t.Fatal(err) } } func readFile(t *testing.T, path string) []byte { t.Helper() data, err := os.ReadFile(path) if err != nil { t.Fatal(err) } return data } func assertOnlyDestinationEntry(t *testing.T, directory, want string) { t.Helper() assertDestinationEntries(t, directory, want) } func assertDestinationEntries(t *testing.T, directory string, wants ...string) { t.Helper() entries, err := os.ReadDir(directory) if err != nil { t.Fatal(err) } if len(entries) != len(wants) { t.Fatalf("directory entries = %#v, want %#v", entries, wants) } for _, want := range wants { found := false for _, entry := range entries { if entry.Name() == want { found = true break } } if !found { t.Fatalf("directory entries = %#v, missing %q", entries, want) } } } func testBundle() LogicalBundle { dataPackage := []byte("report: daily\n") manifest := validManifest() manifest.DataPackage.SHA256 = SHA256(dataPackage) return LogicalBundle{ Manifest: manifest, DataPackage: dataPackage, Reports: []BundleReport{{ Position: 1, Path: "01-weather-light.md", Markdown: []byte("# Daily\n"), }}, } } func testBundleWithReports(t *testing.T, count int) LogicalBundle { t.Helper() dataPackage := []byte("report: daily\n") manifest := validManifest() manifest.Total, manifest.Succeeded, manifest.Failed = count, count, 0 manifest.Results = make([]Result, count) reports := make([]BundleReport, count) for i := range manifest.Results { position := i + 1 profileID := fmt.Sprintf("weather-%d", position) path, err := ReportFilename(position, count, profileID) if err != nil { t.Fatal(err) } manifest.Results[i] = Result{ Position: position, ProfileID: profileID, ModelName: "gpt-5-mini", Status: StatusSucceeded, ValidationStatus: "passed", ReportPath: path, } reports[i] = BundleReport{Position: position, Path: path, Markdown: []byte("# Daily\n")} } manifest.DataPackage.SHA256 = SHA256(dataPackage) return LogicalBundle{Manifest: manifest, DataPackage: dataPackage, Reports: reports} } type countingReadCloser struct { io.ReadCloser count func(int) } func (reader *countingReadCloser) Read(buffer []byte) (int, error) { n, err := reader.ReadCloser.Read(buffer) reader.count(n) return n, err } func assertMode(t *testing.T, path string, want os.FileMode) { t.Helper() info, err := os.Stat(path) if err != nil { t.Fatal(err) } if got := info.Mode().Perm(); got != want { t.Fatalf("mode for %q = %#o, want %#o", path, got, want) } } func directorySnapshot(t *testing.T, directory string) map[string]string { t.Helper() entries, err := os.ReadDir(directory) if err != nil { t.Fatal(err) } snapshot := make(map[string]string, len(entries)) for _, entry := range entries { data, err := os.ReadFile(filepath.Join(directory, entry.Name())) if err != nil { t.Fatal(err) } snapshot[entry.Name()] = string(data) } return snapshot } func equalSnapshots(left, right map[string]string) bool { if len(left) != len(right) { return false } for name, value := range left { if right[name] != value { return false } } return true } func TestRecognizeBundleRejectsTrailingJSONValue(t *testing.T) { directory := publishTestBundle(t, testBundle()) path := filepath.Join(directory, ManifestFilename) data, err := os.ReadFile(path) if err != nil { t.Fatal(err) } if err := os.WriteFile(path, append(data, []byte("{}")...), 0o600); err != nil { t.Fatal(err) } if _, err := RecognizeBundle(directory); !errors.Is(err, ErrUnrecognizedBundle) { t.Fatalf("RecognizeBundle() error = %v, want ErrUnrecognizedBundle", err) } } func TestPlanDestinationRejectsWhitespaceTarget(t *testing.T) { workingDirectory := t.TempDir() assertDestinationErrorKind(t, workingDirectory, " \t", false, DestinationInvalidPath) } func TestRecognizeBundleDoesNotAcceptNonRegularManifest(t *testing.T) { directory := t.TempDir() if err := os.Mkdir(filepath.Join(directory, ManifestFilename), 0o700); err != nil { t.Fatal(err) } if _, err := RecognizeBundle(directory); !strings.Contains(err.Error(), "regular file") { t.Fatalf("RecognizeBundle() error = %v, want regular file rejection", err) } }