All checks were successful
check / check (push) Successful in 5s
Updates golangci-lint to v2.12.2 everywhere it is pinned and installs the canonical `.golangci.yml`, then remediates every finding the new linter/config surfaces so `make check` is green. ## Version bump - `Dockerfile` lint stage: `golangci/golangci-lint:v2.11.3-alpine` -> `v2.12.2-alpine` (digest-pinned, date comment updated) - `Makefile` `deps` target: `go install` moved from the old v1 module path at `@latest` to the pinned `github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2` - `.golangci.yml` replaced with the canonical config (v2 schema; settings under `linters.settings` so the thresholds actually apply; `default: all` with the standard six disables) - `script/bootstrap` installs golangci-lint via the system package manager and carries no version pin, so it is unchanged - CI (`.gitea/workflows/check.yml`) only runs `script/cibuild`, so it needed no change ## Lint remediation The canonical config surfaced ~3,300 findings across 56k lines. All are fixed, behavior-preserving; incorporates and supersedes the per-package mechanical passes already merged to `main` (refs #61). Highlights: - `err113`: dynamic errors replaced with package sentinels + `%w` wrapping; comparisons via `errors.Is` - `goprintffuncname`: printf-style helpers renamed with an `f` suffix (`ui.Writer` message methods, `cli.ReportErrorf`, `database.Fatalf`) and all call sites updated - `revive` stutter renames: `blob.Handler`, `blob.WithReader`, `blob.ChunkPosition`, `storage.URL`, `storage.Info`; missing doc comments added - `contextcheck`/`noctx`: `context.Context` threaded through `blob.Packer` and the scanner call sites; context-aware `exec`/`sql` variants - `funlen`/`cyclop`/`gocognit`/`dupl`: oversized and duplicated functions split into focused helpers (production and test code) - tests: `t.Parallel()` added where safe (global logger init kept in the serial phase for `-race`), `t.TempDir()`/`t.Helper()` adopted, several suites converted to external test packages - `gosec`: bounded integer conversions, `ReadHeaderTimeout` on the test HTTP server; remaining warnings suppressed per-site with justifications - remaining `nolint` directives are rare, targeted, and each carries a reason (e.g. `nilnil` not-found contract in the repository layer, fx module globals, on-disk snake_case struct tags) - removed the deprecated `log.LogOptions` alias (callers migrated to `log.Options`) `make check` (tests with `-race`, lint, fmt-check) passes. Co-authored-by: sneak <sneak@sneak.berlin> Reviewed-on: #62 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
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)
|
|
}
|
|
}
|
|
}
|