package backup_test import ( "context" "database/sql" "testing" "time" "git.eeqj.de/sneak/vaultik/internal/backup" "git.eeqj.de/sneak/vaultik/internal/database" "git.eeqj.de/sneak/vaultik/internal/log" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // TestFileContentChange verifies that when a file's content changes, // the old chunks are properly disassociated func TestFileContentChange(t *testing.T) { // Initialize logger for tests log.Initialize(log.Config{}) // Create in-memory filesystem fs := afero.NewMemMapFs() // Create initial file err := afero.WriteFile(fs, "/test.txt", []byte("Initial content"), 0644) require.NoError(t, err) // Create test database db, err := database.NewTestDB() require.NoError(t, err) defer func() { if err := db.Close(); err != nil { t.Errorf("failed to close database: %v", err) } }() repos := database.NewRepositories(db) // Create scanner scanner := backup.NewScanner(backup.ScannerConfig{ FS: fs, ChunkSize: int64(1024 * 16), // 16KB chunks for testing Repositories: repos, MaxBlobSize: int64(1024 * 1024), // 1MB blobs CompressionLevel: 3, AgeRecipients: []string{"age1ezrjmfpwsc95svdg0y54mums3zevgzu0x0ecq2f7tp8a05gl0sjq9q9wjg"}, // Test public key }) // Create first snapshot ctx := context.Background() snapshotID1 := "snapshot1" err = repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error { snapshot := &database.Snapshot{ ID: snapshotID1, Hostname: "test-host", VaultikVersion: "test", StartedAt: time.Now(), } return repos.Snapshots.Create(ctx, tx, snapshot) }) require.NoError(t, err) // First scan - should create chunks for initial content result1, err := scanner.Scan(ctx, "/", snapshotID1) require.NoError(t, err) t.Logf("First scan: %d files scanned", result1.FilesScanned) // Get file chunks from first scan fileChunks1, err := repos.FileChunks.GetByPath(ctx, "/test.txt") require.NoError(t, err) assert.Len(t, fileChunks1, 1) // Small file = 1 chunk oldChunkHash := fileChunks1[0].ChunkHash // Get chunk files from first scan chunkFiles1, err := repos.ChunkFiles.GetByFilePath(ctx, "/test.txt") require.NoError(t, err) assert.Len(t, chunkFiles1, 1) // Modify the file time.Sleep(10 * time.Millisecond) // Ensure mtime changes err = afero.WriteFile(fs, "/test.txt", []byte("Modified content with different data"), 0644) require.NoError(t, err) // Create second snapshot snapshotID2 := "snapshot2" err = repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error { snapshot := &database.Snapshot{ ID: snapshotID2, Hostname: "test-host", VaultikVersion: "test", StartedAt: time.Now(), } return repos.Snapshots.Create(ctx, tx, snapshot) }) require.NoError(t, err) // Second scan - should create new chunks and remove old associations result2, err := scanner.Scan(ctx, "/", snapshotID2) require.NoError(t, err) t.Logf("Second scan: %d files scanned", result2.FilesScanned) // Get file chunks from second scan fileChunks2, err := repos.FileChunks.GetByPath(ctx, "/test.txt") require.NoError(t, err) assert.Len(t, fileChunks2, 1) // Still 1 chunk but different hash newChunkHash := fileChunks2[0].ChunkHash // Verify the chunk hashes are different assert.NotEqual(t, oldChunkHash, newChunkHash, "Chunk hash should change when content changes") // Get chunk files from second scan chunkFiles2, err := repos.ChunkFiles.GetByFilePath(ctx, "/test.txt") require.NoError(t, err) assert.Len(t, chunkFiles2, 1) assert.Equal(t, newChunkHash, chunkFiles2[0].ChunkHash) // Verify old chunk still exists (it's still valid data) oldChunk, err := repos.Chunks.GetByHash(ctx, oldChunkHash) require.NoError(t, err) assert.NotNil(t, oldChunk) // Verify new chunk exists newChunk, err := repos.Chunks.GetByHash(ctx, newChunkHash) require.NoError(t, err) assert.NotNil(t, newChunk) // Verify that chunk_files for old chunk no longer references this file oldChunkFiles, err := repos.ChunkFiles.GetByChunkHash(ctx, oldChunkHash) require.NoError(t, err) for _, cf := range oldChunkFiles { file, err := repos.Files.GetByID(ctx, cf.FileID) require.NoError(t, err) assert.NotEqual(t, "/data/test.txt", file.Path, "Old chunk should not be associated with the modified file") } } // TestMultipleFileChanges verifies handling of multiple file changes in one scan func TestMultipleFileChanges(t *testing.T) { // Initialize logger for tests log.Initialize(log.Config{}) // Create in-memory filesystem fs := afero.NewMemMapFs() // Create initial files files := map[string]string{ "/file1.txt": "Content 1", "/file2.txt": "Content 2", "/file3.txt": "Content 3", } for path, content := range files { err := afero.WriteFile(fs, path, []byte(content), 0644) require.NoError(t, err) } // Create test database db, err := database.NewTestDB() require.NoError(t, err) defer func() { if err := db.Close(); err != nil { t.Errorf("failed to close database: %v", err) } }() repos := database.NewRepositories(db) // Create scanner scanner := backup.NewScanner(backup.ScannerConfig{ FS: fs, ChunkSize: int64(1024 * 16), // 16KB chunks for testing Repositories: repos, MaxBlobSize: int64(1024 * 1024), // 1MB blobs CompressionLevel: 3, AgeRecipients: []string{"age1ezrjmfpwsc95svdg0y54mums3zevgzu0x0ecq2f7tp8a05gl0sjq9q9wjg"}, // Test public key }) // Create first snapshot ctx := context.Background() snapshotID1 := "snapshot1" err = repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error { snapshot := &database.Snapshot{ ID: snapshotID1, Hostname: "test-host", VaultikVersion: "test", StartedAt: time.Now(), } return repos.Snapshots.Create(ctx, tx, snapshot) }) require.NoError(t, err) // First scan result1, err := scanner.Scan(ctx, "/", snapshotID1) require.NoError(t, err) // 4 files because root directory is also counted assert.Equal(t, 4, result1.FilesScanned) // Modify two files time.Sleep(10 * time.Millisecond) // Ensure mtime changes err = afero.WriteFile(fs, "/file1.txt", []byte("Modified content 1"), 0644) require.NoError(t, err) err = afero.WriteFile(fs, "/file3.txt", []byte("Modified content 3"), 0644) require.NoError(t, err) // Create second snapshot snapshotID2 := "snapshot2" err = repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error { snapshot := &database.Snapshot{ ID: snapshotID2, Hostname: "test-host", VaultikVersion: "test", StartedAt: time.Now(), } return repos.Snapshots.Create(ctx, tx, snapshot) }) require.NoError(t, err) // Second scan result2, err := scanner.Scan(ctx, "/", snapshotID2) require.NoError(t, err) // 4 files because root directory is also counted assert.Equal(t, 4, result2.FilesScanned) // Verify each file has exactly one set of chunks for path := range files { fileChunks, err := repos.FileChunks.GetByPath(ctx, path) require.NoError(t, err) assert.Len(t, fileChunks, 1, "File %s should have exactly 1 chunk association", path) chunkFiles, err := repos.ChunkFiles.GetByFilePath(ctx, path) require.NoError(t, err) assert.Len(t, chunkFiles, 1, "File %s should have exactly 1 chunk-file association", path) } }