- Add afero.Fs field to Vaultik struct for filesystem operations - Vaultik now owns and manages the filesystem instance - SnapshotManager receives filesystem via SetFilesystem() setter - Update blob packer to use afero for temporary files - Convert all filesystem operations to use afero abstraction - Remove filesystem module - Vaultik manages filesystem directly - Update tests: remove symlink test (unsupported by afero memfs) - Fix TestMultipleFileChanges to handle scanner examining directories This enables full end-to-end testing without touching disk by using memory-backed filesystems. Database operations continue using real filesystem as SQLite requires actual files.
239 lines
7.4 KiB
Go
239 lines
7.4 KiB
Go
package snapshot_test
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.eeqj.de/sneak/vaultik/internal/database"
|
|
"git.eeqj.de/sneak/vaultik/internal/log"
|
|
"git.eeqj.de/sneak/vaultik/internal/snapshot"
|
|
"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 := snapshot.NewScanner(snapshot.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 := snapshot.NewScanner(snapshot.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)
|
|
|
|
// The scanner might examine more items than just our files (includes directories, etc)
|
|
// We should verify that at least our expected files were scanned
|
|
assert.GreaterOrEqual(t, result2.FilesScanned, 4, "Should scan at least 4 files (3 files + root dir)")
|
|
|
|
// 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)
|
|
}
|
|
}
|