//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 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 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's approximately 1MB validManifest, err := afero.ReadFile(fs, testManifest) require.NoError(t, err) require.GreaterOrEqual(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, 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 500 random corruption iterations for i := range 500 { // 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)) } }