Fix every finding surfaced by the canonical .golangci.yml with golangci-lint v2.12.2 (refs #61), behavior-preserving throughout: - err113: dynamic errors replaced with package-level sentinels and %w wrapping; direct comparisons converted to errors.Is - goprintffuncname: printf-style helpers renamed with an f suffix (ui.Writer message methods, cli.ReportErrorf, database.Fatalf, vaultik stdoutf) and all call sites updated - revive: stuttering type names renamed (blob.Handler, blob.WithReader, blob.ChunkPosition, storage.URL, storage.Info), doc comments added, unused parameters blanked, package comments added - contextcheck/noctx: ctx threaded through blob.Packer (AddChunk/Flush/FinalizeBlob/PackChunks) and scanner call sites; context-aware exec and sql variants used - funlen/cyclop/gocognit/nestif/dupl: oversized or duplicated functions split into focused helpers across production and test code - paralleltest/tparallel/thelper/usetesting/testpackage: tests parallelized where safe (global log.Initialize kept in the serial phase), helpers marked, t.TempDir adopted, external test packages where only exported API is used - gosec: integer conversions clamped or justified, header timeouts added, remaining findings suppressed with per-site justifications - mnd/goconst/lll/wsl_v5/nlreturn/noinlineerr/errcheck and other mechanical findings fixed directly Remove the deprecated log.LogOptions alias (callers migrated to log.Options). make check is green.
308 lines
8.5 KiB
Go
308 lines
8.5 KiB
Go
package snapshot_test
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/spf13/afero"
|
|
"sneak.berlin/go/vaultik/internal/database"
|
|
"sneak.berlin/go/vaultik/internal/log"
|
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
|
"sneak.berlin/go/vaultik/internal/types"
|
|
)
|
|
|
|
// Shared test fixture values for the snapshot_test package.
|
|
const (
|
|
// testHost is the hostname recorded on test snapshot rows.
|
|
testHost = "test-host"
|
|
|
|
// testVersion is the vaultik version recorded on test snapshot rows.
|
|
testVersion = "test"
|
|
|
|
// testAgePublicKey is the fixed age public key used for test encryption.
|
|
testAgePublicKey = "age1ezrjmfpwsc95svdg0y54mums3zevgzu0x0ecq2f7tp8a05gl0sjq9q9wjg"
|
|
)
|
|
|
|
// TestMain initializes the shared logger once, before any tests run, so
|
|
// parallel tests never race on the logger's global state.
|
|
func TestMain(m *testing.M) {
|
|
log.Initialize(log.Config{})
|
|
os.Exit(m.Run())
|
|
}
|
|
|
|
// createTestSnapshotRecord inserts an empty snapshot row used as the
|
|
// association target for scan tests.
|
|
func createTestSnapshotRecord(
|
|
ctx context.Context, t *testing.T, repos *database.Repositories, snapshotID string,
|
|
) {
|
|
t.Helper()
|
|
|
|
err := repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
|
snapshot := &database.Snapshot{
|
|
ID: types.SnapshotID(snapshotID),
|
|
Hostname: testHost,
|
|
VaultikVersion: testVersion,
|
|
StartedAt: time.Now(),
|
|
CompletedAt: nil,
|
|
FileCount: 0,
|
|
ChunkCount: 0,
|
|
BlobCount: 0,
|
|
TotalSize: 0,
|
|
BlobSize: 0,
|
|
CompressionRatio: 1.0,
|
|
}
|
|
|
|
return repos.Snapshots.Create(ctx, tx, snapshot)
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("failed to create snapshot: %v", err)
|
|
}
|
|
}
|
|
|
|
// verifySimpleScanDatabase checks the database contents produced by
|
|
// TestScannerSimpleDirectory's scan.
|
|
func verifySimpleScanDatabase(
|
|
ctx context.Context, t *testing.T, repos *database.Repositories,
|
|
) {
|
|
t.Helper()
|
|
|
|
// Verify files in database - includes regular files and directories
|
|
files, err := repos.Files.ListByPrefix(ctx, "/source")
|
|
if err != nil {
|
|
t.Fatalf("failed to list files: %v", err)
|
|
}
|
|
|
|
// 6 regular files + 3 directories (/source, /source/subdir, /source/subdir2)
|
|
if len(files) != 9 {
|
|
t.Errorf("expected 9 entries in database (6 files + 3 dirs), got %d", len(files))
|
|
}
|
|
|
|
// Verify specific file
|
|
file1, err := repos.Files.GetByPath(ctx, "/source/file1.txt")
|
|
if err != nil {
|
|
t.Fatalf("failed to get file1.txt: %v", err)
|
|
}
|
|
|
|
if file1.Size != 13 {
|
|
t.Errorf("expected file1.txt size 13, got %d", file1.Size)
|
|
}
|
|
|
|
if file1.Mode != 0644 {
|
|
t.Errorf("expected file1.txt mode 0644, got %o", file1.Mode)
|
|
}
|
|
|
|
// Verify chunks were created
|
|
chunks, err := repos.FileChunks.GetByFile(ctx, "/source/file1.txt")
|
|
if err != nil {
|
|
t.Fatalf("failed to get chunks for file1.txt: %v", err)
|
|
}
|
|
|
|
if len(chunks) != 1 { // Small file should be one chunk
|
|
t.Errorf("expected 1 chunk for file1.txt, got %d", len(chunks))
|
|
}
|
|
|
|
// Verify deduplication - file3.txt and file4.txt have different content
|
|
// but we should still have the correct number of unique chunks
|
|
allChunks, err := repos.Chunks.List(ctx)
|
|
if err != nil {
|
|
t.Fatalf("failed to list all chunks: %v", err)
|
|
}
|
|
|
|
// We should have at most 6 chunks (one per unique file content)
|
|
// Empty file might not create a chunk
|
|
if len(allChunks) > 6 {
|
|
t.Errorf("expected at most 6 chunks, got %d", len(allChunks))
|
|
}
|
|
}
|
|
|
|
func TestScannerSimpleDirectory(t *testing.T) {
|
|
// Initialize logger for tests
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
// Create in-memory filesystem
|
|
fs := afero.NewMemMapFs()
|
|
|
|
// Create test directory structure
|
|
testFiles := map[string]string{
|
|
"/source/file1.txt": "Hello, world!", // 13 bytes
|
|
"/source/file2.txt": "This is another file", // 20 bytes
|
|
"/source/subdir/file3.txt": "File in subdirectory", // 20 bytes
|
|
"/source/subdir/file4.txt": "Another file in subdirectory", // 28 bytes
|
|
"/source/empty.txt": "", // 0 bytes
|
|
"/source/subdir2/file5.txt": "Yet another file", // 16 bytes
|
|
}
|
|
|
|
// Create files with specific times
|
|
testTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
|
|
|
for path, content := range testFiles {
|
|
dir := filepath.Dir(path)
|
|
|
|
err := fs.MkdirAll(dir, 0755)
|
|
if err != nil {
|
|
t.Fatalf("failed to create directory %s: %v", dir, err)
|
|
}
|
|
|
|
err = afero.WriteFile(fs, path, []byte(content), 0644)
|
|
if err != nil {
|
|
t.Fatalf("failed to write file %s: %v", path, err)
|
|
}
|
|
// Set times
|
|
err = fs.Chtimes(path, testTime, testTime)
|
|
if err != nil {
|
|
t.Fatalf("failed to set times for %s: %v", path, err)
|
|
}
|
|
}
|
|
|
|
// Create test database
|
|
db, err := database.NewTestDB()
|
|
if err != nil {
|
|
t.Fatalf("failed to create test database: %v", err)
|
|
}
|
|
defer func() {
|
|
err := db.Close()
|
|
if 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{testAgePublicKey},
|
|
})
|
|
|
|
// Create a snapshot record for testing
|
|
ctx := context.Background()
|
|
snapshotID := "test-snapshot-001"
|
|
|
|
createTestSnapshotRecord(ctx, t, repos, snapshotID)
|
|
|
|
// Scan the directory
|
|
result, err := scanner.Scan(ctx, "/source", snapshotID)
|
|
if err != nil {
|
|
t.Fatalf("scan failed: %v", err)
|
|
}
|
|
|
|
// Verify results - we only scan regular files, not directories
|
|
if result.FilesScanned != 6 {
|
|
t.Errorf("expected 6 files scanned, got %d", result.FilesScanned)
|
|
}
|
|
|
|
// Total bytes should be the sum of all file contents
|
|
if result.BytesScanned < 97 { // At minimum we have 97 bytes of file content
|
|
t.Errorf("expected at least 97 bytes scanned, got %d", result.BytesScanned)
|
|
}
|
|
|
|
verifySimpleScanDatabase(ctx, t, repos)
|
|
}
|
|
|
|
func TestScannerLargeFile(t *testing.T) {
|
|
// Initialize logger for tests
|
|
log.Initialize(log.Config{})
|
|
t.Parallel()
|
|
|
|
// Create in-memory filesystem
|
|
fs := afero.NewMemMapFs()
|
|
|
|
// Create a large file that will require multiple chunks
|
|
// Use random content to ensure good chunk boundaries
|
|
largeContent := make([]byte, 1024*1024) // 1MB
|
|
// Fill with pseudo-random data to ensure chunk boundaries
|
|
for i := range largeContent {
|
|
// Simple pseudo-random generator for deterministic tests
|
|
//nolint:gosec // G115: intentional byte truncation of test data
|
|
largeContent[i] = byte((i * 7919) ^ (i >> 3))
|
|
}
|
|
|
|
err := fs.MkdirAll("/source", 0755)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err = afero.WriteFile(fs, "/source/large.bin", largeContent, 0644)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Create test database
|
|
db, err := database.NewTestDB()
|
|
if err != nil {
|
|
t.Fatalf("failed to create test database: %v", err)
|
|
}
|
|
defer func() {
|
|
err := db.Close()
|
|
if err != nil {
|
|
t.Errorf("failed to close database: %v", err)
|
|
}
|
|
}()
|
|
|
|
repos := database.NewRepositories(db)
|
|
|
|
// Create scanner with 64KB average chunk size
|
|
scanner := snapshot.NewScanner(snapshot.ScannerConfig{
|
|
FS: fs,
|
|
ChunkSize: int64(1024 * 64), // 64KB average chunks
|
|
Repositories: repos,
|
|
MaxBlobSize: int64(1024 * 1024),
|
|
CompressionLevel: 3,
|
|
AgeRecipients: []string{testAgePublicKey},
|
|
})
|
|
|
|
// Create a snapshot record for testing
|
|
ctx := context.Background()
|
|
snapshotID := "test-snapshot-001"
|
|
|
|
createTestSnapshotRecord(ctx, t, repos, snapshotID)
|
|
|
|
// Scan the directory
|
|
result, err := scanner.Scan(ctx, "/source", snapshotID)
|
|
if err != nil {
|
|
t.Fatalf("scan failed: %v", err)
|
|
}
|
|
|
|
// We scan only regular files, not directories
|
|
if result.FilesScanned != 1 {
|
|
t.Errorf("expected 1 file scanned, got %d", result.FilesScanned)
|
|
}
|
|
|
|
// The file size should be at least 1MB
|
|
if result.BytesScanned < 1024*1024 {
|
|
t.Errorf("expected at least %d bytes scanned, got %d",
|
|
1024*1024, result.BytesScanned)
|
|
}
|
|
|
|
// Verify chunks
|
|
chunks, err := repos.FileChunks.GetByFile(ctx, "/source/large.bin")
|
|
if err != nil {
|
|
t.Fatalf("failed to get chunks: %v", err)
|
|
}
|
|
|
|
// With content-defined chunking, the number of chunks depends on content
|
|
// For a 1MB file, we should get at least 1 chunk
|
|
if len(chunks) < 1 {
|
|
t.Errorf("expected at least 1 chunk, got %d", len(chunks))
|
|
}
|
|
|
|
// Log the actual number of chunks for debugging
|
|
t.Logf("1MB file produced %d chunks with 64KB average chunk size", len(chunks))
|
|
|
|
// Verify chunk sequence
|
|
for i, fc := range chunks {
|
|
if fc.Idx != i {
|
|
t.Errorf("chunk %d has incorrect sequence %d", i, fc.Idx)
|
|
}
|
|
}
|
|
}
|