package cli import ( "bytes" "fmt" "math/rand" "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" ) func init() { // Prevent urfave/cli from calling os.Exit during tests urfcli.OsExiter = func(code int) {} } func TestBuild(t *testing.T) { m := &CLIApp{} assert.NotNil(t, m) } func testOpts(args []string, fs afero.Fs) *RunOptions { return &RunOptions{ Appname: "mfer", Version: "1.0.0", Gitrev: "abc123", Args: args, Stdin: &bytes.Buffer{}, Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}, Fs: fs, } } func TestVersionCommand(t *testing.T) { fs := afero.NewMemMapFs() opts := testOpts([]string{"mfer", "version"}, fs) exitCode := RunWithOptions(opts) assert.Equal(t, 0, exitCode) stdout := opts.Stdout.(*bytes.Buffer).String() assert.Contains(t, stdout, mfer.Version) assert.Contains(t, stdout, "abc123") } func TestHelpCommand(t *testing.T) { fs := afero.NewMemMapFs() opts := testOpts([]string{"mfer", "--help"}, fs) exitCode := RunWithOptions(opts) assert.Equal(t, 0, exitCode) stdout := opts.Stdout.(*bytes.Buffer).String() assert.Contains(t, stdout, "generate") assert.Contains(t, stdout, "check") assert.Contains(t, stdout, "fetch") } func TestGenerateCommand(t *testing.T) { fs := afero.NewMemMapFs() // Create test files in memory filesystem require.NoError(t, fs.MkdirAll("/testdir", 0o755)) require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello world"), 0o644)) require.NoError(t, afero.WriteFile(fs, "/testdir/file2.txt", []byte("test content"), 0o644)) opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/testdir/test.mf", "/testdir"}, fs) exitCode := RunWithOptions(opts) assert.Equal(t, 0, exitCode, "stderr: %s", opts.Stderr.(*bytes.Buffer).String()) // Verify manifest was created exists, err := afero.Exists(fs, "/testdir/test.mf") require.NoError(t, err) assert.True(t, exists) } func TestGenerateAndCheckCommand(t *testing.T) { fs := afero.NewMemMapFs() // Create test files with subdirectory require.NoError(t, fs.MkdirAll("/testdir/subdir", 0o755)) require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello world"), 0o644)) require.NoError(t, afero.WriteFile(fs, "/testdir/subdir/file2.txt", []byte("test content"), 0o644)) // Generate manifest opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/testdir/test.mf", "/testdir"}, fs) exitCode := RunWithOptions(opts) require.Equal(t, 0, exitCode, "generate failed: %s", opts.Stderr.(*bytes.Buffer).String()) // Check manifest opts = testOpts([]string{"mfer", "check", "-q", "--base", "/testdir", "/testdir/test.mf"}, fs) exitCode = RunWithOptions(opts) assert.Equal(t, 0, exitCode, "check failed: %s", opts.Stderr.(*bytes.Buffer).String()) } func TestCheckCommandWithMissingFile(t *testing.T) { fs := afero.NewMemMapFs() // Create test file require.NoError(t, fs.MkdirAll("/testdir", 0o755)) require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello world"), 0o644)) // Generate manifest opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/testdir/test.mf", "/testdir"}, fs) exitCode := RunWithOptions(opts) require.Equal(t, 0, exitCode, "generate failed: %s", opts.Stderr.(*bytes.Buffer).String()) // Delete the file require.NoError(t, fs.Remove("/testdir/file1.txt")) // Check manifest - should fail opts = testOpts([]string{"mfer", "check", "-q", "--base", "/testdir", "/testdir/test.mf"}, fs) exitCode = RunWithOptions(opts) assert.Equal(t, 1, exitCode, "check should have failed for missing file") } func TestCheckCommandWithCorruptedFile(t *testing.T) { fs := afero.NewMemMapFs() // Create test file require.NoError(t, fs.MkdirAll("/testdir", 0o755)) require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello world"), 0o644)) // Generate manifest opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/testdir/test.mf", "/testdir"}, fs) exitCode := RunWithOptions(opts) require.Equal(t, 0, exitCode, "generate failed: %s", opts.Stderr.(*bytes.Buffer).String()) // Corrupt the file (change content but keep same size) require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("HELLO WORLD"), 0o644)) // Check manifest - should fail with hash mismatch opts = testOpts([]string{"mfer", "check", "-q", "--base", "/testdir", "/testdir/test.mf"}, fs) exitCode = RunWithOptions(opts) assert.Equal(t, 1, exitCode, "check should have failed for corrupted file") } func TestCheckCommandWithSizeMismatch(t *testing.T) { fs := afero.NewMemMapFs() // Create test file require.NoError(t, fs.MkdirAll("/testdir", 0o755)) require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello world"), 0o644)) // Generate manifest opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/testdir/test.mf", "/testdir"}, fs) exitCode := RunWithOptions(opts) require.Equal(t, 0, exitCode, "generate failed: %s", opts.Stderr.(*bytes.Buffer).String()) // Change file size require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("different size content here"), 0o644)) // Check manifest - should fail with size mismatch opts = testOpts([]string{"mfer", "check", "-q", "--base", "/testdir", "/testdir/test.mf"}, fs) exitCode = RunWithOptions(opts) assert.Equal(t, 1, exitCode, "check should have failed for size mismatch") } func TestBannerOutput(t *testing.T) { fs := afero.NewMemMapFs() // Create test file require.NoError(t, fs.MkdirAll("/testdir", 0o755)) require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello"), 0o644)) // Run without -q to see banner opts := testOpts([]string{"mfer", "generate", "-o", "/testdir/test.mf", "/testdir"}, fs) exitCode := RunWithOptions(opts) assert.Equal(t, 0, exitCode) // Banner ASCII art should be in stdout stdout := opts.Stdout.(*bytes.Buffer).String() assert.Contains(t, stdout, "___") assert.Contains(t, stdout, "\\") } func TestUnknownCommand(t *testing.T) { fs := afero.NewMemMapFs() opts := testOpts([]string{"mfer", "unknown"}, fs) exitCode := RunWithOptions(opts) assert.Equal(t, 1, exitCode) } func TestGenerateExcludesDotfilesByDefault(t *testing.T) { fs := afero.NewMemMapFs() // Create test files including dotfiles require.NoError(t, fs.MkdirAll("/testdir", 0o755)) require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello"), 0o644)) require.NoError(t, afero.WriteFile(fs, "/testdir/.hidden", []byte("secret"), 0o644)) // Generate manifest without --include-dotfiles (default excludes dotfiles) opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/testdir/test.mf", "/testdir"}, fs) exitCode := RunWithOptions(opts) require.Equal(t, 0, exitCode) // Check that manifest exists exists, _ := afero.Exists(fs, "/testdir/test.mf") assert.True(t, exists) // Verify manifest only has 1 file (the non-dotfile) manifest, err := mfer.NewManifestFromFile(fs, "/testdir/test.mf") require.NoError(t, err) assert.Len(t, manifest.Files(), 1) assert.Equal(t, "file1.txt", manifest.Files()[0].Path) } func TestGenerateWithIncludeDotfiles(t *testing.T) { fs := afero.NewMemMapFs() // Create test files including dotfiles require.NoError(t, fs.MkdirAll("/testdir", 0o755)) require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello"), 0o644)) require.NoError(t, afero.WriteFile(fs, "/testdir/.hidden", []byte("secret"), 0o644)) // Generate manifest with --include-dotfiles opts := testOpts([]string{"mfer", "generate", "-q", "--include-dotfiles", "-o", "/testdir/test.mf", "/testdir"}, fs) exitCode := RunWithOptions(opts) require.Equal(t, 0, exitCode) // Verify manifest has 2 files (including dotfile) manifest, err := mfer.NewManifestFromFile(fs, "/testdir/test.mf") require.NoError(t, err) assert.Len(t, manifest.Files(), 2) } func TestMultipleInputPaths(t *testing.T) { fs := afero.NewMemMapFs() // Create test files in multiple directories require.NoError(t, fs.MkdirAll("/dir1", 0o755)) require.NoError(t, fs.MkdirAll("/dir2", 0o755)) require.NoError(t, afero.WriteFile(fs, "/dir1/file1.txt", []byte("content1"), 0o644)) require.NoError(t, afero.WriteFile(fs, "/dir2/file2.txt", []byte("content2"), 0o644)) // Generate manifest from multiple paths opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/output.mf", "/dir1", "/dir2"}, fs) exitCode := RunWithOptions(opts) assert.Equal(t, 0, exitCode, "stderr: %s", opts.Stderr.(*bytes.Buffer).String()) exists, _ := afero.Exists(fs, "/output.mf") assert.True(t, exists) } func TestNoExtraFilesPass(t *testing.T) { fs := afero.NewMemMapFs() // Create test files require.NoError(t, fs.MkdirAll("/testdir", 0o755)) require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello"), 0o644)) require.NoError(t, afero.WriteFile(fs, "/testdir/file2.txt", []byte("world"), 0o644)) // Generate manifest opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/manifest.mf", "/testdir"}, fs) exitCode := RunWithOptions(opts) require.Equal(t, 0, exitCode) // Check with --no-extra-files (should pass - no extra files) opts = testOpts([]string{"mfer", "check", "-q", "--no-extra-files", "--base", "/testdir", "/manifest.mf"}, fs) exitCode = RunWithOptions(opts) assert.Equal(t, 0, exitCode) } func TestNoExtraFilesFail(t *testing.T) { fs := afero.NewMemMapFs() // Create test files require.NoError(t, fs.MkdirAll("/testdir", 0o755)) require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello"), 0o644)) // Generate manifest opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/manifest.mf", "/testdir"}, fs) exitCode := RunWithOptions(opts) require.Equal(t, 0, exitCode) // Add an extra file after manifest generation require.NoError(t, afero.WriteFile(fs, "/testdir/extra.txt", []byte("extra"), 0o644)) // Check with --no-extra-files (should fail - extra file exists) opts = testOpts([]string{"mfer", "check", "-q", "--no-extra-files", "--base", "/testdir", "/manifest.mf"}, fs) exitCode = RunWithOptions(opts) assert.Equal(t, 1, exitCode, "check should fail when extra files exist") } func TestNoExtraFilesWithSubdirectory(t *testing.T) { fs := afero.NewMemMapFs() // Create test files with subdirectory require.NoError(t, fs.MkdirAll("/testdir/subdir", 0o755)) require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello"), 0o644)) require.NoError(t, afero.WriteFile(fs, "/testdir/subdir/file2.txt", []byte("world"), 0o644)) // Generate manifest opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/manifest.mf", "/testdir"}, fs) exitCode := RunWithOptions(opts) require.Equal(t, 0, exitCode) // Add extra file in subdirectory require.NoError(t, afero.WriteFile(fs, "/testdir/subdir/extra.txt", []byte("extra"), 0o644)) // Check with --no-extra-files (should fail) opts = testOpts([]string{"mfer", "check", "-q", "--no-extra-files", "--base", "/testdir", "/manifest.mf"}, fs) exitCode = RunWithOptions(opts) assert.Equal(t, 1, exitCode, "check should fail when extra files exist in subdirectory") } func TestCheckWithoutNoExtraFilesIgnoresExtra(t *testing.T) { fs := afero.NewMemMapFs() // Create test file require.NoError(t, fs.MkdirAll("/testdir", 0o755)) require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello"), 0o644)) // Generate manifest opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/manifest.mf", "/testdir"}, fs) exitCode := RunWithOptions(opts) require.Equal(t, 0, exitCode) // Add extra file require.NoError(t, afero.WriteFile(fs, "/testdir/extra.txt", []byte("extra"), 0o644)) // Check WITHOUT --no-extra-files (should pass - extra files ignored) opts = testOpts([]string{"mfer", "check", "-q", "--base", "/testdir", "/manifest.mf"}, fs) exitCode = RunWithOptions(opts) assert.Equal(t, 0, exitCode, "check without --no-extra-files should ignore extra files") } func TestGenerateAtomicWriteNoTempFileOnSuccess(t *testing.T) { fs := afero.NewMemMapFs() // Create test file require.NoError(t, fs.MkdirAll("/testdir", 0o755)) require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello"), 0o644)) // Generate manifest opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/output.mf", "/testdir"}, fs) exitCode := RunWithOptions(opts) require.Equal(t, 0, exitCode) // Verify output file exists exists, err := afero.Exists(fs, "/output.mf") require.NoError(t, err) assert.True(t, exists, "output file should exist") // Verify temp file does NOT exist tmpExists, err := afero.Exists(fs, "/output.mf.tmp") require.NoError(t, err) assert.False(t, tmpExists, "temp file should not exist after successful generation") } func TestGenerateAtomicWriteOverwriteWithForce(t *testing.T) { fs := afero.NewMemMapFs() // Create test file require.NoError(t, fs.MkdirAll("/testdir", 0o755)) require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello"), 0o644)) // Create existing manifest with different content require.NoError(t, afero.WriteFile(fs, "/output.mf", []byte("old content"), 0o644)) // Generate manifest with --force opts := testOpts([]string{"mfer", "generate", "-q", "-f", "-o", "/output.mf", "/testdir"}, fs) exitCode := RunWithOptions(opts) require.Equal(t, 0, exitCode) // Verify output file exists and was overwritten content, err := afero.ReadFile(fs, "/output.mf") 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, "/output.mf.tmp") require.NoError(t, err) assert.False(t, tmpExists, "temp file should not exist after successful generation") } func TestGenerateFailsWithoutForceWhenOutputExists(t *testing.T) { fs := afero.NewMemMapFs() // Create test file require.NoError(t, fs.MkdirAll("/testdir", 0o755)) require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello"), 0o644)) // Create existing manifest require.NoError(t, afero.WriteFile(fs, "/output.mf", []byte("existing"), 0o644)) // Generate manifest WITHOUT --force (should fail) opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/output.mf", "/testdir"}, fs) exitCode := RunWithOptions(opts) assert.Equal(t, 1, exitCode, "should fail when output exists without --force") // Verify original content is preserved content, err := afero.ReadFile(fs, "/output.mf") require.NoError(t, err) assert.Equal(t, "existing", string(content), "original file should be preserved") } func TestGenerateAtomicWriteUsesTemp(t *testing.T) { // 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)) require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello"), 0o644)) // Generate manifest opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/output.mf", "/testdir"}, fs) exitCode := RunWithOptions(opts) require.Equal(t, 0, exitCode) // Both output file should exist and temp should not exists, _ := afero.Exists(fs, "/output.mf") assert.True(t, exists, "output file should exist") tmpExists, _ := afero.Exists(fs, "/output.mf.tmp") assert.False(t, tmpExists, "temp file should be cleaned up") // Verify manifest is valid (not empty) content, err := afero.ReadFile(fs, "/output.mf") require.NoError(t, err) assert.True(t, len(content) > 0, "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, fmt.Errorf("simulated write failure") } return f.File.Write(p) } 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) { baseFs := afero.NewMemMapFs() // Create test files - need enough content to trigger the write failure require.NoError(t, baseFs.MkdirAll("/testdir", 0o755)) require.NoError(t, afero.WriteFile(baseFs, "/testdir/file1.txt", []byte("hello world this is a test file"), 0o644)) // 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{"mfer", "generate", "-q", "-o", "/output.mf", "/testdir"}, fs) exitCode := RunWithOptions(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, "/output.mf") assert.False(t, exists, "output file should not exist after failed generation (atomic write)") // Temp file should also not exist tmpExists, _ := afero.Exists(baseFs, "/output.mf.tmp") assert.False(t, tmpExists, "temp file should be cleaned up after failed generation") } func TestGenerateValidatesInputPaths(t *testing.T) { fs := afero.NewMemMapFs() // Create one valid directory require.NoError(t, fs.MkdirAll("/validdir", 0o755)) require.NoError(t, afero.WriteFile(fs, "/validdir/file.txt", []byte("content"), 0o644)) t.Run("nonexistent path fails fast", func(t *testing.T) { opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/output.mf", "/nonexistent"}, fs) exitCode := RunWithOptions(opts) assert.Equal(t, 1, exitCode) stderr := opts.Stderr.(*bytes.Buffer).String() 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) { opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/output.mf", "/validdir", "/alsononexistent"}, fs) exitCode := RunWithOptions(opts) assert.Equal(t, 1, exitCode) stderr := opts.Stderr.(*bytes.Buffer).String() assert.Contains(t, stderr, "path does not exist") assert.Contains(t, stderr, "/alsononexistent") // Output file should not have been created exists, _ := afero.Exists(fs, "/output.mf") assert.False(t, exists, "output file should not exist when path validation fails") }) t.Run("valid paths succeed", func(t *testing.T) { opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/output.mf", "/validdir"}, fs) exitCode := RunWithOptions(opts) assert.Equal(t, 0, exitCode) }) } func TestCheckDetectsManifestCorruption(t *testing.T) { fs := afero.NewMemMapFs() rng := rand.New(rand.NewSource(42)) // Create many small files with random names to generate a ~1MB manifest // Each manifest entry is roughly 50-60 bytes, so we need ~20000 files require.NoError(t, fs.MkdirAll("/testdir", 0o755)) numFiles := 20000 for i := 0; i < numFiles; i++ { // 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{"mfer", "generate", "-q", "-o", "/manifest.mf", "/testdir"}, fs) exitCode := RunWithOptions(opts) require.Equal(t, 0, exitCode, "generate should succeed") // Read the valid manifest and verify it's approximately 1MB validManifest, err := afero.ReadFile(fs, "/manifest.mf") require.NoError(t, err) require.True(t, len(validManifest) >= 1024*1024, "manifest should be at least 1MB, 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, "/manifest.mf", validManifest[:len(validManifest)/2], 0o644)) // Check should fail with truncated manifest opts = testOpts([]string{"mfer", "check", "-q", "--base", "/testdir", "/manifest.mf"}, fs) exitCode = RunWithOptions(opts) assert.Equal(t, 1, exitCode, "check should fail with truncated manifest") // Verify check passes with valid manifest require.NoError(t, afero.WriteFile(fs, "/manifest.mf", validManifest, 0o644)) opts = testOpts([]string{"mfer", "check", "-q", "--base", "/testdir", "/manifest.mf"}, fs) exitCode = RunWithOptions(opts) require.Equal(t, 0, exitCode, "check should pass with valid manifest") // Now do 500 random corruption iterations for i := 0; i < 500; i++ { // 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 newByte := byte(rng.Intn(256)) for newByte == originalByte { newByte = byte(rng.Intn(256)) } corrupted[offset] = newByte require.NoError(t, afero.WriteFile(fs, "/manifest.mf", corrupted, 0o644)) // Check should fail with corrupted manifest opts = testOpts([]string{"mfer", "check", "-q", "--base", "/testdir", "/manifest.mf"}, fs) exitCode = RunWithOptions(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, "/manifest.mf", validManifest, 0o644)) } }