check / check (push) Failing after 0s
script/test now runs `go test -timeout 30s -race -cover ./...`, quiet on success and rerunning verbose with `exit 1` on failure, per policy. -race surfaced a real data race on the process-global apex/log logger: Init reconfigures it (SetHandler/SetLevel) each CLI run while other goroutines read it to log. internal/log now mutates the global under the write lock and reads it under the read lock (new emit helper, DebugReal), and drops WithError, whose returned Entry logged outside that lock. That Entry was also the only thing printing a failed command's error to stderr, so run() now reports it via log.Errorf (shown under -q too). The corruption fuzz test shrinks (20000->1500 files, 500->100 iters) to keep the suite under 20s with -race. Model: opus-4-8
767 lines
21 KiB
Go
767 lines
21 KiB
Go
//nolint:testpackage // white-box tests exercise unexported internals
|
|
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"math/rand"
|
|
"os"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/spf13/afero"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
urfcli "github.com/urfave/cli/v2"
|
|
"sneak.berlin/go/mfer/mfer"
|
|
)
|
|
|
|
const (
|
|
testApp = "mfer"
|
|
testDir = "/testdir"
|
|
testFile1 = "/testdir/file1.txt"
|
|
testMF = "/testdir/test.mf"
|
|
testOutput = "/output.mf"
|
|
testOutputTmp = "/output.mf.tmp"
|
|
testManifest = "/manifest.mf"
|
|
testFlagBase = "--base"
|
|
testFlagNoExtra = "--no-extra-files"
|
|
)
|
|
|
|
var errSimulatedWrite = errors.New("simulated write failure")
|
|
|
|
// runMu serializes CLI runs: RunWithOptions wires the process-global
|
|
// logger to the run's I/O streams, so parallel runs would cross-wire
|
|
// captured output between tests.
|
|
//
|
|
//nolint:gochecknoglobals // guards process-global logger state in tests
|
|
var runMu sync.Mutex
|
|
|
|
// runCLI invokes RunWithOptions while holding runMu so parallel tests
|
|
// capture their own output.
|
|
func runCLI(opts *RunOptions) int {
|
|
runMu.Lock()
|
|
defer runMu.Unlock()
|
|
|
|
return RunWithOptions(opts)
|
|
}
|
|
|
|
func TestMain(m *testing.M) {
|
|
// Prevent urfave/cli from calling os.Exit during tests
|
|
urfcli.OsExiter = func(_ int) {}
|
|
|
|
os.Exit(m.Run())
|
|
}
|
|
|
|
func TestBuild(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
m := &CLIApp{}
|
|
assert.NotNil(t, m)
|
|
}
|
|
|
|
func testOpts(args []string, fs afero.Fs) *RunOptions {
|
|
return &RunOptions{
|
|
Appname: testApp,
|
|
Version: "1.0.0",
|
|
Gitrev: "abc123",
|
|
Args: args,
|
|
Stdin: &bytes.Buffer{},
|
|
Stdout: &bytes.Buffer{},
|
|
Stderr: &bytes.Buffer{},
|
|
Fs: fs,
|
|
}
|
|
}
|
|
|
|
func testStdout(t *testing.T, opts *RunOptions) string {
|
|
t.Helper()
|
|
|
|
buf, ok := opts.Stdout.(*bytes.Buffer)
|
|
require.True(t, ok)
|
|
|
|
return buf.String()
|
|
}
|
|
|
|
func testStderr(t *testing.T, opts *RunOptions) string {
|
|
t.Helper()
|
|
|
|
buf, ok := opts.Stderr.(*bytes.Buffer)
|
|
require.True(t, ok)
|
|
|
|
return buf.String()
|
|
}
|
|
|
|
func writeTestFile(t *testing.T, fs afero.Fs, path, content string) {
|
|
t.Helper()
|
|
|
|
require.NoError(t, afero.WriteFile(fs, path, []byte(content), 0o644))
|
|
}
|
|
|
|
func TestVersionCommand(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
opts := testOpts([]string{testApp, "version"}, fs)
|
|
|
|
exitCode := runCLI(opts)
|
|
|
|
assert.Equal(t, 0, exitCode)
|
|
|
|
stdout := testStdout(t, opts)
|
|
assert.Contains(t, stdout, mfer.Version)
|
|
assert.Contains(t, stdout, "abc123")
|
|
}
|
|
|
|
func TestHelpCommand(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
opts := testOpts([]string{testApp, "--help"}, fs)
|
|
|
|
exitCode := runCLI(opts)
|
|
|
|
assert.Equal(t, 0, exitCode)
|
|
|
|
stdout := testStdout(t, opts)
|
|
assert.Contains(t, stdout, cmdGenerate)
|
|
assert.Contains(t, stdout, cmdCheck)
|
|
assert.Contains(t, stdout, "fetch")
|
|
}
|
|
|
|
func TestGenerateCommand(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
|
|
// Create test files in memory filesystem
|
|
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
|
writeTestFile(t, fs, testFile1, "hello world")
|
|
writeTestFile(t, fs, "/testdir/file2.txt", "test content")
|
|
|
|
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testMF, testDir}, fs)
|
|
|
|
exitCode := runCLI(opts)
|
|
|
|
assert.Equal(t, 0, exitCode, "stderr: %s", testStderr(t, opts))
|
|
|
|
// Verify manifest was created
|
|
exists, err := afero.Exists(fs, testMF)
|
|
require.NoError(t, err)
|
|
assert.True(t, exists)
|
|
}
|
|
|
|
func TestGenerateAndCheckCommand(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
|
|
// Create test files with subdirectory
|
|
require.NoError(t, fs.MkdirAll("/testdir/subdir", 0o755))
|
|
writeTestFile(t, fs, testFile1, "hello world")
|
|
writeTestFile(t, fs, "/testdir/subdir/file2.txt", "test content")
|
|
|
|
// Generate manifest
|
|
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testMF, testDir}, fs)
|
|
exitCode := runCLI(opts)
|
|
require.Equal(t, 0, exitCode, "generate failed: %s", testStderr(t, opts))
|
|
|
|
// Check manifest
|
|
opts = testOpts([]string{testApp, cmdCheck, "-q", testFlagBase, testDir, testMF}, fs)
|
|
exitCode = runCLI(opts)
|
|
assert.Equal(t, 0, exitCode, "check failed: %s", testStderr(t, opts))
|
|
}
|
|
|
|
func TestCheckCommandWithMissingFile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
|
|
// Create test file
|
|
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
|
writeTestFile(t, fs, testFile1, "hello world")
|
|
|
|
// Generate manifest
|
|
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testMF, testDir}, fs)
|
|
exitCode := runCLI(opts)
|
|
require.Equal(t, 0, exitCode, "generate failed: %s", testStderr(t, opts))
|
|
|
|
// Delete the file
|
|
require.NoError(t, fs.Remove(testFile1))
|
|
|
|
// Check manifest - should fail
|
|
opts = testOpts([]string{testApp, cmdCheck, "-q", testFlagBase, testDir, testMF}, fs)
|
|
exitCode = runCLI(opts)
|
|
assert.Equal(t, 1, exitCode, "check should have failed for missing file")
|
|
}
|
|
|
|
func runCheckAfterRewrite(t *testing.T, rewritten, msg string) {
|
|
t.Helper()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
|
|
// Create test file
|
|
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
|
writeTestFile(t, fs, testFile1, "hello world")
|
|
|
|
// Generate manifest
|
|
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testMF, testDir}, fs)
|
|
exitCode := runCLI(opts)
|
|
require.Equal(t, 0, exitCode, "generate failed: %s", testStderr(t, opts))
|
|
|
|
// Rewrite the file, then check the manifest - it must fail
|
|
writeTestFile(t, fs, testFile1, rewritten)
|
|
|
|
opts = testOpts([]string{testApp, cmdCheck, "-q", testFlagBase, testDir, testMF}, fs)
|
|
exitCode = runCLI(opts)
|
|
assert.Equal(t, 1, exitCode, msg)
|
|
}
|
|
|
|
func TestCheckCommandWithCorruptedFile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Corrupt the file (change content but keep same size)
|
|
runCheckAfterRewrite(t, "HELLO WORLD",
|
|
"check should have failed for corrupted file")
|
|
}
|
|
|
|
func TestCheckCommandWithSizeMismatch(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Change file size
|
|
runCheckAfterRewrite(t, "different size content here",
|
|
"check should have failed for size mismatch")
|
|
}
|
|
|
|
func TestBannerOutput(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
|
|
// Create test file
|
|
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
|
writeTestFile(t, fs, testFile1, "hello")
|
|
|
|
// Run without -q to see banner
|
|
opts := testOpts([]string{testApp, cmdGenerate, "-o", testMF, testDir}, fs)
|
|
exitCode := runCLI(opts)
|
|
assert.Equal(t, 0, exitCode)
|
|
|
|
// Banner ASCII art should be in stdout
|
|
stdout := testStdout(t, opts)
|
|
assert.Contains(t, stdout, "___")
|
|
assert.Contains(t, stdout, "\\")
|
|
}
|
|
|
|
func TestUnknownCommand(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
opts := testOpts([]string{testApp, "unknown"}, fs)
|
|
|
|
exitCode := runCLI(opts)
|
|
assert.Equal(t, 1, exitCode)
|
|
}
|
|
|
|
func TestGenerateExcludesDotfilesByDefault(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
|
|
// Create test files including dotfiles
|
|
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
|
writeTestFile(t, fs, testFile1, "hello")
|
|
writeTestFile(t, fs, "/testdir/.hidden", "secret")
|
|
|
|
// Generate manifest without --include-dotfiles (default excludes dotfiles)
|
|
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testMF, testDir}, fs)
|
|
exitCode := runCLI(opts)
|
|
require.Equal(t, 0, exitCode)
|
|
|
|
// Check that manifest exists
|
|
exists, _ := afero.Exists(fs, testMF)
|
|
assert.True(t, exists)
|
|
|
|
// Verify manifest only has 1 file (the non-dotfile)
|
|
manifest, err := mfer.NewManifestFromFile(fs, testMF)
|
|
require.NoError(t, err)
|
|
assert.Len(t, manifest.Files(), 1)
|
|
assert.Equal(t, "file1.txt", manifest.Files()[0].GetPath())
|
|
}
|
|
|
|
func TestGenerateWithIncludeDotfiles(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
|
|
// Create test files including dotfiles
|
|
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
|
writeTestFile(t, fs, testFile1, "hello")
|
|
writeTestFile(t, fs, "/testdir/.hidden", "secret")
|
|
|
|
// Generate manifest with --include-dotfiles
|
|
opts := testOpts([]string{
|
|
testApp, cmdGenerate, "-q", "--include-dotfiles", "-o", testMF, testDir,
|
|
}, fs)
|
|
exitCode := runCLI(opts)
|
|
require.Equal(t, 0, exitCode)
|
|
|
|
// Verify manifest has 2 files (including dotfile)
|
|
manifest, err := mfer.NewManifestFromFile(fs, testMF)
|
|
require.NoError(t, err)
|
|
assert.Len(t, manifest.Files(), 2)
|
|
}
|
|
|
|
func TestMultipleInputPaths(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
|
|
// Create test files in multiple directories
|
|
require.NoError(t, fs.MkdirAll("/dir1", 0o755))
|
|
require.NoError(t, fs.MkdirAll("/dir2", 0o755))
|
|
writeTestFile(t, fs, "/dir1/file1.txt", "content1")
|
|
writeTestFile(t, fs, "/dir2/file2.txt", "content2")
|
|
|
|
// Generate manifest from multiple paths
|
|
opts := testOpts([]string{
|
|
testApp, cmdGenerate, "-q", "-o", testOutput, "/dir1", "/dir2",
|
|
}, fs)
|
|
exitCode := runCLI(opts)
|
|
assert.Equal(t, 0, exitCode, "stderr: %s", testStderr(t, opts))
|
|
|
|
exists, _ := afero.Exists(fs, testOutput)
|
|
assert.True(t, exists)
|
|
}
|
|
|
|
func TestNoExtraFilesPass(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
|
|
// Create test files
|
|
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
|
writeTestFile(t, fs, testFile1, "hello")
|
|
writeTestFile(t, fs, "/testdir/file2.txt", "world")
|
|
|
|
// Generate manifest
|
|
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testManifest, testDir}, fs)
|
|
exitCode := runCLI(opts)
|
|
require.Equal(t, 0, exitCode)
|
|
|
|
// Check with --no-extra-files (should pass - no extra files)
|
|
opts = testOpts([]string{
|
|
testApp, cmdCheck, "-q", testFlagNoExtra, testFlagBase, testDir, testManifest,
|
|
}, fs)
|
|
exitCode = runCLI(opts)
|
|
assert.Equal(t, 0, exitCode)
|
|
}
|
|
|
|
func TestNoExtraFilesFail(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
|
|
// Create test files
|
|
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
|
writeTestFile(t, fs, testFile1, "hello")
|
|
|
|
// Generate manifest
|
|
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testManifest, testDir}, fs)
|
|
exitCode := runCLI(opts)
|
|
require.Equal(t, 0, exitCode)
|
|
|
|
// Add an extra file after manifest generation
|
|
writeTestFile(t, fs, "/testdir/extra.txt", "extra")
|
|
|
|
// Check with --no-extra-files (should fail - extra file exists)
|
|
opts = testOpts([]string{
|
|
testApp, cmdCheck, "-q", testFlagNoExtra, testFlagBase, testDir, testManifest,
|
|
}, fs)
|
|
exitCode = runCLI(opts)
|
|
assert.Equal(t, 1, exitCode, "check should fail when extra files exist")
|
|
}
|
|
|
|
func TestNoExtraFilesWithSubdirectory(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
|
|
// Create test files with subdirectory
|
|
require.NoError(t, fs.MkdirAll("/testdir/subdir", 0o755))
|
|
writeTestFile(t, fs, testFile1, "hello")
|
|
writeTestFile(t, fs, "/testdir/subdir/file2.txt", "world")
|
|
|
|
// Generate manifest
|
|
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testManifest, testDir}, fs)
|
|
exitCode := runCLI(opts)
|
|
require.Equal(t, 0, exitCode)
|
|
|
|
// Add extra file in subdirectory
|
|
writeTestFile(t, fs, "/testdir/subdir/extra.txt", "extra")
|
|
|
|
// Check with --no-extra-files (should fail)
|
|
opts = testOpts([]string{
|
|
testApp, cmdCheck, "-q", testFlagNoExtra, testFlagBase, testDir, testManifest,
|
|
}, fs)
|
|
exitCode = runCLI(opts)
|
|
assert.Equal(t, 1, exitCode,
|
|
"check should fail when extra files exist in subdirectory")
|
|
}
|
|
|
|
func TestCheckWithoutNoExtraFilesIgnoresExtra(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
|
|
// Create test file
|
|
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
|
writeTestFile(t, fs, testFile1, "hello")
|
|
|
|
// Generate manifest
|
|
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testManifest, testDir}, fs)
|
|
exitCode := runCLI(opts)
|
|
require.Equal(t, 0, exitCode)
|
|
|
|
// Add extra file
|
|
writeTestFile(t, fs, "/testdir/extra.txt", "extra")
|
|
|
|
// Check WITHOUT --no-extra-files (should pass - extra files ignored)
|
|
opts = testOpts([]string{
|
|
testApp, cmdCheck, "-q", testFlagBase, testDir, testManifest,
|
|
}, fs)
|
|
exitCode = runCLI(opts)
|
|
assert.Equal(t, 0, exitCode,
|
|
"check without --no-extra-files should ignore extra files")
|
|
}
|
|
|
|
func TestGenerateAtomicWriteNoTempFileOnSuccess(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
|
|
// Create test file
|
|
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
|
writeTestFile(t, fs, testFile1, "hello")
|
|
|
|
// Generate manifest
|
|
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testOutput, testDir}, fs)
|
|
exitCode := runCLI(opts)
|
|
require.Equal(t, 0, exitCode)
|
|
|
|
// Verify output file exists
|
|
exists, err := afero.Exists(fs, testOutput)
|
|
require.NoError(t, err)
|
|
assert.True(t, exists, "output file should exist")
|
|
|
|
// Verify temp file does NOT exist
|
|
tmpExists, err := afero.Exists(fs, testOutputTmp)
|
|
require.NoError(t, err)
|
|
assert.False(t, tmpExists,
|
|
"temp file should not exist after successful generation")
|
|
}
|
|
|
|
func TestGenerateAtomicWriteOverwriteWithForce(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
|
|
// Create test file
|
|
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
|
writeTestFile(t, fs, testFile1, "hello")
|
|
|
|
// Create existing manifest with different content
|
|
writeTestFile(t, fs, testOutput, "old content")
|
|
|
|
// Generate manifest with --force
|
|
opts := testOpts([]string{
|
|
testApp, cmdGenerate, "-q", "-f", "-o", testOutput, testDir,
|
|
}, fs)
|
|
exitCode := runCLI(opts)
|
|
require.Equal(t, 0, exitCode)
|
|
|
|
// Verify output file exists and was overwritten
|
|
content, err := afero.ReadFile(fs, testOutput)
|
|
require.NoError(t, err)
|
|
assert.NotEqual(t, "old content", string(content),
|
|
"manifest should be overwritten")
|
|
|
|
// Verify temp file does NOT exist
|
|
tmpExists, err := afero.Exists(fs, testOutputTmp)
|
|
require.NoError(t, err)
|
|
assert.False(t, tmpExists,
|
|
"temp file should not exist after successful generation")
|
|
}
|
|
|
|
func TestGenerateFailsWithoutForceWhenOutputExists(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
|
|
// Create test file
|
|
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
|
writeTestFile(t, fs, testFile1, "hello")
|
|
|
|
// Create existing manifest
|
|
writeTestFile(t, fs, testOutput, "existing")
|
|
|
|
// Generate manifest WITHOUT --force (should fail)
|
|
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testOutput, testDir}, fs)
|
|
exitCode := runCLI(opts)
|
|
assert.Equal(t, 1, exitCode, "should fail when output exists without --force")
|
|
|
|
// Verify original content is preserved
|
|
content, err := afero.ReadFile(fs, testOutput)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "existing", string(content), "original file should be preserved")
|
|
}
|
|
|
|
func TestGenerateAtomicWriteUsesTemp(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// This test verifies that generate uses a temp file by checking
|
|
// that the output file doesn't exist until generation completes.
|
|
// We do this by generating to a path and verifying the temp file
|
|
// pattern is used (output.mf.tmp -> output.mf)
|
|
fs := afero.NewMemMapFs()
|
|
|
|
// Create test file
|
|
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
|
writeTestFile(t, fs, testFile1, "hello")
|
|
|
|
// Generate manifest
|
|
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testOutput, testDir}, fs)
|
|
exitCode := runCLI(opts)
|
|
require.Equal(t, 0, exitCode)
|
|
|
|
// Both output file should exist and temp should not
|
|
exists, _ := afero.Exists(fs, testOutput)
|
|
assert.True(t, exists, "output file should exist")
|
|
|
|
tmpExists, _ := afero.Exists(fs, testOutputTmp)
|
|
assert.False(t, tmpExists, "temp file should be cleaned up")
|
|
|
|
// Verify manifest is valid (not empty)
|
|
content, err := afero.ReadFile(fs, testOutput)
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, content, "manifest should not be empty")
|
|
}
|
|
|
|
// failingWriterFs wraps a filesystem and makes writes fail after N bytes
|
|
type failingWriterFs struct {
|
|
afero.Fs
|
|
|
|
failAfter int64
|
|
written int64
|
|
}
|
|
|
|
type failingFile struct {
|
|
afero.File
|
|
|
|
fs *failingWriterFs
|
|
}
|
|
|
|
func (f *failingFile) Write(p []byte) (int, error) {
|
|
f.fs.written += int64(len(p))
|
|
if f.fs.written > f.fs.failAfter {
|
|
return 0, errSimulatedWrite
|
|
}
|
|
|
|
return f.File.Write(p)
|
|
}
|
|
|
|
//nolint:ireturn // Create must return afero.File to satisfy afero.Fs.
|
|
func (fs *failingWriterFs) Create(name string) (afero.File, error) {
|
|
f, err := fs.Fs.Create(name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &failingFile{File: f, fs: fs}, nil
|
|
}
|
|
|
|
func TestGenerateAtomicWriteCleansUpOnError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
baseFs := afero.NewMemMapFs()
|
|
|
|
// Create test files - need enough content to trigger the write failure
|
|
require.NoError(t, baseFs.MkdirAll(testDir, 0o755))
|
|
writeTestFile(t, baseFs, testFile1, "hello world this is a test file")
|
|
|
|
// Wrap with failing writer that fails after writing some bytes
|
|
fs := &failingWriterFs{Fs: baseFs, failAfter: 10}
|
|
|
|
// Generate manifest - should fail during write
|
|
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testOutput, testDir}, fs)
|
|
exitCode := runCLI(opts)
|
|
assert.Equal(t, 1, exitCode, "should fail due to write error")
|
|
|
|
// With atomic writes: output.mf should NOT exist (temp was cleaned up)
|
|
// With non-atomic writes: output.mf WOULD exist (partial/empty)
|
|
exists, _ := afero.Exists(baseFs, testOutput)
|
|
assert.False(t, exists,
|
|
"output file should not exist after failed generation (atomic write)")
|
|
|
|
// Temp file should also not exist
|
|
tmpExists, _ := afero.Exists(baseFs, testOutputTmp)
|
|
assert.False(t, tmpExists,
|
|
"temp file should be cleaned up after failed generation")
|
|
}
|
|
|
|
func TestGenerateValidatesInputPaths(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
seedValidDir := func(t *testing.T, fs afero.Fs) {
|
|
t.Helper()
|
|
|
|
require.NoError(t, fs.MkdirAll("/validdir", 0o755))
|
|
writeTestFile(t, fs, "/validdir/file.txt", "content")
|
|
}
|
|
|
|
t.Run("nonexistent path fails fast", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
seedValidDir(t, fs)
|
|
|
|
opts := testOpts([]string{
|
|
testApp, cmdGenerate, "-q", "-o", testOutput, "/nonexistent",
|
|
}, fs)
|
|
exitCode := runCLI(opts)
|
|
assert.Equal(t, 1, exitCode)
|
|
|
|
stderr := testStderr(t, opts)
|
|
assert.Contains(t, stderr, "path does not exist")
|
|
assert.Contains(t, stderr, "/nonexistent")
|
|
})
|
|
|
|
t.Run("mix of valid and invalid paths fails fast", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
seedValidDir(t, fs)
|
|
|
|
opts := testOpts([]string{
|
|
testApp, cmdGenerate, "-q", "-o", testOutput,
|
|
"/validdir", "/alsononexistent",
|
|
}, fs)
|
|
exitCode := runCLI(opts)
|
|
assert.Equal(t, 1, exitCode)
|
|
|
|
stderr := testStderr(t, opts)
|
|
assert.Contains(t, stderr, "path does not exist")
|
|
assert.Contains(t, stderr, "/alsononexistent")
|
|
|
|
// Output file should not have been created
|
|
exists, _ := afero.Exists(fs, testOutput)
|
|
assert.False(t, exists,
|
|
"output file should not exist when path validation fails")
|
|
})
|
|
|
|
t.Run("valid paths succeed", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
seedValidDir(t, fs)
|
|
|
|
opts := testOpts([]string{
|
|
testApp, cmdGenerate, "-q", "-o", testOutput, "/validdir",
|
|
}, fs)
|
|
exitCode := runCLI(opts)
|
|
assert.Equal(t, 0, exitCode)
|
|
})
|
|
}
|
|
|
|
func TestCheckDetectsManifestCorruption(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
rng := rand.New(rand.NewSource(42)) //nolint:gosec // deterministic test data
|
|
|
|
// Create many small files with random names so the manifest has many
|
|
// entries and random single-byte flips land at varied offsets. Each
|
|
// manifest entry is roughly 50-60 bytes. Kept modest so the suite stays
|
|
// within its wall-clock budget under -race.
|
|
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
|
|
|
numFiles := 1500
|
|
for range numFiles {
|
|
// Generate random filename
|
|
filename := fmt.Sprintf("/testdir/%08x%08x%08x.dat",
|
|
rng.Uint32(), rng.Uint32(), rng.Uint32())
|
|
// Small random content
|
|
content := make([]byte, 16+rng.Intn(48))
|
|
_, _ = rng.Read(content)
|
|
require.NoError(t, afero.WriteFile(fs, filename, content, 0o644))
|
|
}
|
|
|
|
// Generate manifest outside of testdir
|
|
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testManifest, testDir}, fs)
|
|
exitCode := runCLI(opts)
|
|
require.Equal(t, 0, exitCode, "generate should succeed")
|
|
|
|
// Read the valid manifest and verify it has real size.
|
|
validManifest, err := afero.ReadFile(fs, testManifest)
|
|
require.NoError(t, err)
|
|
require.GreaterOrEqual(t, len(validManifest), 64*1024,
|
|
"manifest should be at least 64KB, got %d bytes", len(validManifest))
|
|
t.Logf("manifest size: %d bytes (%d files)", len(validManifest), numFiles)
|
|
|
|
// First corruption: truncate the manifest
|
|
require.NoError(t, afero.WriteFile(fs, testManifest,
|
|
validManifest[:len(validManifest)/2], 0o644))
|
|
|
|
// Check should fail with truncated manifest
|
|
opts = testOpts([]string{
|
|
testApp, cmdCheck, "-q", testFlagBase, testDir, testManifest,
|
|
}, fs)
|
|
exitCode = runCLI(opts)
|
|
assert.Equal(t, 1, exitCode, "check should fail with truncated manifest")
|
|
|
|
// Verify check passes with valid manifest
|
|
require.NoError(t, afero.WriteFile(fs, testManifest, validManifest, 0o644))
|
|
|
|
opts = testOpts([]string{
|
|
testApp, cmdCheck, "-q", testFlagBase, testDir, testManifest,
|
|
}, fs)
|
|
exitCode = runCLI(opts)
|
|
require.Equal(t, 0, exitCode, "check should pass with valid manifest")
|
|
|
|
// Now do 100 random corruption iterations
|
|
for i := range 100 {
|
|
// Corrupt: write a random byte at a random offset
|
|
corrupted := make([]byte, len(validManifest))
|
|
copy(corrupted, validManifest)
|
|
|
|
offset := rng.Intn(len(corrupted))
|
|
originalByte := corrupted[offset]
|
|
|
|
// Make sure we actually change the byte
|
|
buf := make([]byte, 1)
|
|
|
|
newByte := originalByte
|
|
for newByte == originalByte {
|
|
_, _ = rng.Read(buf)
|
|
newByte = buf[0]
|
|
}
|
|
|
|
corrupted[offset] = newByte
|
|
|
|
require.NoError(t, afero.WriteFile(fs, testManifest, corrupted, 0o644))
|
|
|
|
// Check should fail with corrupted manifest
|
|
opts = testOpts([]string{
|
|
testApp, cmdCheck, "-q", testFlagBase, testDir, testManifest,
|
|
}, fs)
|
|
exitCode = runCLI(opts)
|
|
assert.Equal(t, 1, exitCode,
|
|
"iteration %d: check should fail with corrupted manifest "+
|
|
"(offset %d, 0x%02x -> 0x%02x)",
|
|
i, offset, originalByte, newByte)
|
|
|
|
// Restore valid manifest for next iteration
|
|
require.NoError(t, afero.WriteFile(fs, testManifest, validManifest, 0o644))
|
|
}
|
|
}
|