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.
641 lines
14 KiB
Go
641 lines
14 KiB
Go
//nolint:testpackage // inspects the unexported database connection
|
|
package database
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"sneak.berlin/go/vaultik/internal/types"
|
|
)
|
|
|
|
// fileEdgeCase describes one Create edge-case scenario.
|
|
type fileEdgeCase struct {
|
|
name string
|
|
file *File
|
|
wantErr bool
|
|
errMsg string
|
|
}
|
|
|
|
// fileEdgeCases returns the Create edge-case table.
|
|
func fileEdgeCases() []fileEdgeCase {
|
|
return []fileEdgeCase{
|
|
{
|
|
name: "empty path",
|
|
file: &File{
|
|
Path: "",
|
|
MTime: time.Now(),
|
|
Size: 1024,
|
|
Mode: 0644,
|
|
UID: 1000,
|
|
GID: 1000,
|
|
},
|
|
wantErr: false, // Empty strings are allowed, only NULL is not allowed
|
|
},
|
|
{
|
|
name: "very long path",
|
|
file: &File{
|
|
Path: types.FilePath("/" + strings.Repeat("a", 4096)),
|
|
MTime: time.Now(),
|
|
Size: 1024,
|
|
Mode: 0644,
|
|
UID: 1000,
|
|
GID: 1000,
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "path with special characters",
|
|
file: &File{
|
|
//nolint:gosmopolitan // non-ASCII path is deliberate test data
|
|
Path: "/test/file with spaces and 特殊文字.txt",
|
|
MTime: time.Now(),
|
|
Size: 1024,
|
|
Mode: 0644,
|
|
UID: 1000,
|
|
GID: 1000,
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "zero size file",
|
|
file: &File{
|
|
Path: "/empty.txt",
|
|
MTime: time.Now(),
|
|
Size: 0,
|
|
Mode: 0644,
|
|
UID: 1000,
|
|
GID: 1000,
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "symlink with target",
|
|
file: &File{
|
|
Path: "/link",
|
|
MTime: time.Now(),
|
|
Size: 0,
|
|
Mode: 0777 | 0120000, // symlink mode
|
|
UID: 1000,
|
|
GID: 1000,
|
|
LinkTarget: "/target",
|
|
},
|
|
wantErr: false,
|
|
},
|
|
}
|
|
}
|
|
|
|
// TestFileRepositoryEdgeCases tests edge cases for file repository
|
|
func TestFileRepositoryEdgeCases(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db, cleanup := setupTestDB(t)
|
|
t.Cleanup(cleanup)
|
|
|
|
ctx := context.Background()
|
|
repo := NewFileRepository(db)
|
|
|
|
for i, tt := range fileEdgeCases() {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Add a unique suffix to paths to avoid UNIQUE constraint violations
|
|
if tt.file.Path != "" {
|
|
tt.file.Path = types.FilePath(fmt.Sprintf("%s_%d_%d",
|
|
tt.file.Path, i, time.Now().UnixNano()))
|
|
}
|
|
|
|
err := repo.Create(ctx, nil, tt.file)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("Create() error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
|
|
if err != nil && tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
|
|
t.Errorf("Create() error = %v, want error containing %q", err, tt.errMsg)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// testDuplicateFilePaths exercises the UPSERT behavior for duplicate paths.
|
|
func testDuplicateFilePaths(t *testing.T, repos *Repositories) {
|
|
t.Helper()
|
|
|
|
ctx := context.Background()
|
|
|
|
file1 := &File{
|
|
Path: "/duplicate.txt",
|
|
MTime: time.Now(),
|
|
Size: 1024,
|
|
Mode: 0644,
|
|
UID: 1000,
|
|
GID: 1000,
|
|
}
|
|
file2 := &File{
|
|
Path: "/duplicate.txt", // Same path
|
|
MTime: time.Now().Add(time.Hour),
|
|
Size: 2048,
|
|
Mode: 0644,
|
|
UID: 1000,
|
|
GID: 1000,
|
|
}
|
|
|
|
err := repos.Files.Create(ctx, nil, file1)
|
|
if err != nil {
|
|
t.Fatalf("failed to create file1: %v", err)
|
|
}
|
|
|
|
originalID := file1.ID
|
|
|
|
// Create with same path should update the existing record (UPSERT behavior)
|
|
err = repos.Files.Create(ctx, nil, file2)
|
|
if err != nil {
|
|
t.Fatalf("failed to create file2: %v", err)
|
|
}
|
|
|
|
// Verify the file was updated, not duplicated
|
|
retrievedFile, err := repos.Files.GetByPath(ctx, "/duplicate.txt")
|
|
if err != nil {
|
|
t.Fatalf("failed to retrieve file: %v", err)
|
|
}
|
|
|
|
// The file should have been updated with file2's data
|
|
if retrievedFile.Size != 2048 {
|
|
t.Errorf("expected size 2048, got %d", retrievedFile.Size)
|
|
}
|
|
|
|
// ID might be different due to the UPSERT
|
|
if retrievedFile.ID != file2.ID {
|
|
t.Logf("File ID changed from %s to %s during upsert",
|
|
originalID, retrievedFile.ID)
|
|
}
|
|
}
|
|
|
|
// testDuplicateFileChunks exercises idempotent file-chunk mapping creation.
|
|
func testDuplicateFileChunks(t *testing.T, repos *Repositories) {
|
|
t.Helper()
|
|
|
|
ctx := context.Background()
|
|
|
|
file := &File{
|
|
Path: "/test-dup-fc.txt",
|
|
MTime: time.Now(),
|
|
Size: 1024,
|
|
Mode: 0644,
|
|
UID: 1000,
|
|
GID: 1000,
|
|
}
|
|
|
|
err := repos.Files.Create(ctx, nil, file)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
chunk := &Chunk{
|
|
ChunkHash: types.ChunkHash("test-chunk-dup"),
|
|
Size: 1024,
|
|
}
|
|
|
|
err = repos.Chunks.Create(ctx, nil, chunk)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
fc := &FileChunk{
|
|
FileID: file.ID,
|
|
Idx: 0,
|
|
ChunkHash: chunk.ChunkHash,
|
|
}
|
|
|
|
err = repos.FileChunks.Create(ctx, nil, fc)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Creating the same mapping again should be idempotent
|
|
err = repos.FileChunks.Create(ctx, nil, fc)
|
|
if err != nil {
|
|
t.Error("file-chunk creation should be idempotent")
|
|
}
|
|
}
|
|
|
|
// TestDuplicateHandling tests handling of duplicate entries
|
|
func TestDuplicateHandling(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db, cleanup := setupTestDB(t)
|
|
t.Cleanup(cleanup)
|
|
|
|
ctx := context.Background()
|
|
repos := NewRepositories(db)
|
|
|
|
// Test duplicate file paths - Create uses UPSERT logic
|
|
t.Run("duplicate file paths", func(t *testing.T) {
|
|
t.Parallel()
|
|
testDuplicateFilePaths(t, repos)
|
|
})
|
|
|
|
// Test duplicate chunk hashes
|
|
t.Run("duplicate chunk hashes", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
chunk := &Chunk{
|
|
ChunkHash: types.ChunkHash("duplicate-chunk"),
|
|
Size: 1024,
|
|
}
|
|
|
|
err := repos.Chunks.Create(ctx, nil, chunk)
|
|
if err != nil {
|
|
t.Fatalf("failed to create chunk: %v", err)
|
|
}
|
|
|
|
// Creating the same chunk again should be idempotent (ON CONFLICT DO NOTHING)
|
|
err = repos.Chunks.Create(ctx, nil, chunk)
|
|
if err != nil {
|
|
t.Errorf("duplicate chunk creation should be idempotent, got error: %v", err)
|
|
}
|
|
})
|
|
|
|
// Test duplicate file-chunk mappings
|
|
t.Run("duplicate file-chunk mappings", func(t *testing.T) {
|
|
t.Parallel()
|
|
testDuplicateFileChunks(t, repos)
|
|
})
|
|
}
|
|
|
|
// TestNullHandling tests handling of NULL values
|
|
func TestNullHandling(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db, cleanup := setupTestDB(t)
|
|
t.Cleanup(cleanup)
|
|
|
|
ctx := context.Background()
|
|
repos := NewRepositories(db)
|
|
|
|
// Test file with no link target
|
|
t.Run("file without link target", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
file := &File{
|
|
Path: "/regular.txt",
|
|
MTime: time.Now(),
|
|
Size: 1024,
|
|
Mode: 0644,
|
|
UID: 1000,
|
|
GID: 1000,
|
|
LinkTarget: "", // Should be stored as NULL
|
|
}
|
|
|
|
err := repos.Files.Create(ctx, nil, file)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
retrieved, err := repos.Files.GetByID(ctx, file.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if retrieved.LinkTarget != "" {
|
|
t.Errorf("expected empty link target, got %q", retrieved.LinkTarget)
|
|
}
|
|
})
|
|
|
|
// Test snapshot with NULL completed_at
|
|
t.Run("incomplete snapshot", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
snapshot := &Snapshot{
|
|
ID: "incomplete-test",
|
|
Hostname: internalTestHost,
|
|
StartedAt: time.Now(),
|
|
CompletedAt: nil, // Should remain NULL until completed
|
|
}
|
|
|
|
err := repos.Snapshots.Create(ctx, nil, snapshot)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
retrieved, err := repos.Snapshots.GetByID(ctx, snapshot.ID.String())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if retrieved.CompletedAt != nil {
|
|
t.Error("expected nil CompletedAt for incomplete snapshot")
|
|
}
|
|
})
|
|
|
|
// Test blob with NULL uploaded_ts
|
|
t.Run("blob not uploaded", func(t *testing.T) {
|
|
t.Parallel()
|
|
verifyBlobNullUploadTS(ctx, t, repos)
|
|
})
|
|
}
|
|
|
|
// verifyBlobNullUploadTS checks that a blob created without an upload
|
|
// timestamp round-trips with UploadedTS nil.
|
|
func verifyBlobNullUploadTS(
|
|
ctx context.Context, t *testing.T, repos *Repositories,
|
|
) {
|
|
t.Helper()
|
|
|
|
blob := &Blob{
|
|
ID: types.NewBlobID(),
|
|
Hash: types.BlobHash("test-hash"),
|
|
CreatedTS: time.Now(),
|
|
UploadedTS: nil, // Not uploaded yet
|
|
}
|
|
|
|
err := repos.Blobs.Create(ctx, nil, blob)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
retrieved, err := repos.Blobs.GetByID(ctx, blob.ID.String())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if retrieved.UploadedTS != nil {
|
|
t.Error("expected nil UploadedTS for non-uploaded blob")
|
|
}
|
|
}
|
|
|
|
// createLargeDatasetFiles creates fileCount files and adds every other
|
|
// one to the snapshot.
|
|
func createLargeDatasetFiles(
|
|
t *testing.T,
|
|
repos *Repositories,
|
|
snapshotID string,
|
|
fileCount int,
|
|
) {
|
|
t.Helper()
|
|
|
|
ctx := context.Background()
|
|
start := time.Now()
|
|
|
|
for i := range fileCount {
|
|
file := &File{
|
|
Path: types.FilePath(fmt.Sprintf("/large/file%05d.txt", i)),
|
|
MTime: time.Now(),
|
|
Size: int64(i * 1024),
|
|
Mode: 0644,
|
|
UID: uint32(1000 + (i % 10)),
|
|
GID: uint32(1000 + (i % 10)),
|
|
}
|
|
|
|
err := repos.Files.Create(ctx, nil, file)
|
|
if err != nil {
|
|
t.Fatalf("failed to create file %d: %v", i, err)
|
|
}
|
|
|
|
// Add half to snapshot
|
|
if i%2 == 0 {
|
|
err = repos.Snapshots.AddFileByID(ctx, nil, snapshotID, file.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
t.Logf("Created %d files in %v", fileCount, time.Since(start))
|
|
}
|
|
|
|
// TestLargeDatasets tests operations with large amounts of data
|
|
//
|
|
//nolint:tparallel // subtests share one database and are order-dependent
|
|
func TestLargeDatasets(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if testing.Short() {
|
|
t.Skip("skipping large dataset test in short mode")
|
|
}
|
|
|
|
db, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
repos := NewRepositories(db)
|
|
|
|
// Create a snapshot
|
|
snapshot := &Snapshot{
|
|
ID: "large-dataset-test",
|
|
Hostname: internalTestHost,
|
|
StartedAt: time.Now(),
|
|
}
|
|
|
|
err := repos.Snapshots.Create(ctx, nil, snapshot)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Create many files
|
|
const fileCount = 1000
|
|
|
|
//nolint:paralleltest // phases share one database and are order-dependent
|
|
t.Run("create many files", func(t *testing.T) {
|
|
createLargeDatasetFiles(t, repos, snapshot.ID.String(), fileCount)
|
|
})
|
|
|
|
// Test ListByPrefix performance
|
|
//nolint:paralleltest // phases share one database and are order-dependent
|
|
t.Run("list by prefix performance", func(t *testing.T) {
|
|
start := time.Now()
|
|
|
|
files, err := repos.Files.ListByPrefix(ctx, "/large/")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if len(files) != fileCount {
|
|
t.Errorf("expected %d files, got %d", fileCount, len(files))
|
|
}
|
|
|
|
t.Logf("Listed %d files in %v", len(files), time.Since(start))
|
|
})
|
|
|
|
// Test orphaned cleanup performance
|
|
//nolint:paralleltest // phases share one database and are order-dependent
|
|
t.Run("orphaned cleanup performance", func(t *testing.T) {
|
|
start := time.Now()
|
|
|
|
err := repos.Files.DeleteOrphaned(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
t.Logf("Cleaned up orphaned files in %v", time.Since(start))
|
|
|
|
// Verify correct number remain
|
|
files, err := repos.Files.ListByPrefix(ctx, "/large/")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if len(files) != fileCount/2 {
|
|
t.Errorf("expected %d files after cleanup, got %d",
|
|
fileCount/2, len(files))
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestErrorPropagation tests that errors are properly propagated
|
|
func TestErrorPropagation(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db, cleanup := setupTestDB(t)
|
|
t.Cleanup(cleanup)
|
|
|
|
ctx := context.Background()
|
|
repos := NewRepositories(db)
|
|
|
|
// Test GetByID with non-existent ID
|
|
t.Run("GetByID non-existent", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
file, err := repos.Files.GetByID(ctx, types.NewFileID())
|
|
if err != nil {
|
|
t.Errorf("GetByID should not return error for non-existent ID, got: %v", err)
|
|
}
|
|
|
|
if file != nil {
|
|
t.Error("expected nil file for non-existent ID")
|
|
}
|
|
})
|
|
|
|
// Test GetByPath with non-existent path
|
|
t.Run("GetByPath non-existent", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
file, err := repos.Files.GetByPath(ctx, "/non/existent/path.txt")
|
|
if err != nil {
|
|
t.Errorf("GetByPath should not return error for non-existent path, got: %v",
|
|
err)
|
|
}
|
|
|
|
if file != nil {
|
|
t.Error("expected nil file for non-existent path")
|
|
}
|
|
})
|
|
|
|
// Test invalid foreign key reference
|
|
t.Run("invalid foreign key", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fc := &FileChunk{
|
|
FileID: types.NewFileID(),
|
|
Idx: 0,
|
|
ChunkHash: types.ChunkHash("some-chunk"),
|
|
}
|
|
|
|
err := repos.FileChunks.Create(ctx, nil, fc)
|
|
if err == nil {
|
|
t.Error("expected error for invalid foreign key")
|
|
}
|
|
|
|
if !strings.Contains(err.Error(), "FOREIGN KEY") {
|
|
t.Errorf("expected foreign key error, got: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestQueryInjection tests that the system is safe from SQL injection
|
|
func TestQueryInjection(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db, cleanup := setupTestDB(t)
|
|
t.Cleanup(cleanup)
|
|
|
|
ctx := context.Background()
|
|
repos := NewRepositories(db)
|
|
|
|
// Test various injection attempts
|
|
injectionTests := []string{
|
|
"'; DROP TABLE files; --",
|
|
"' OR '1'='1",
|
|
"'; DELETE FROM files WHERE '1'='1'; --",
|
|
`test'); DROP TABLE files; --`,
|
|
}
|
|
|
|
for _, injection := range injectionTests {
|
|
t.Run("injection attempt", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Try injection in file path
|
|
file := &File{
|
|
Path: types.FilePath(injection),
|
|
MTime: time.Now(),
|
|
Size: 1024,
|
|
Mode: 0644,
|
|
UID: 1000,
|
|
GID: 1000,
|
|
}
|
|
_ = repos.Files.Create(ctx, nil, file)
|
|
// Should either succeed (treating as normal string) or fail with constraint
|
|
// but should NOT execute the injected SQL
|
|
|
|
// Verify tables still exist
|
|
var count int
|
|
|
|
err := db.conn.QueryRowContext(ctx, countFilesQuery).Scan(&count)
|
|
if err != nil {
|
|
t.Fatal("files table was damaged by injection")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestTimezoneHandling tests that times are properly handled in UTC
|
|
func TestTimezoneHandling(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
ctx := context.Background()
|
|
repos := NewRepositories(db)
|
|
|
|
// Create file with specific timezone
|
|
loc, err := time.LoadLocation("America/New_York")
|
|
if err != nil {
|
|
t.Skip("timezone not available")
|
|
}
|
|
|
|
// Use Truncate to remove sub-second precision since we store as Unix timestamps
|
|
nyTime := time.Now().In(loc).Truncate(time.Second)
|
|
file := &File{
|
|
Path: "/timezone-test.txt",
|
|
MTime: nyTime,
|
|
Size: 1024,
|
|
Mode: 0644,
|
|
UID: 1000,
|
|
GID: 1000,
|
|
}
|
|
|
|
err = repos.Files.Create(ctx, nil, file)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Retrieve and verify times are in UTC
|
|
retrieved, err := repos.Files.GetByID(ctx, file.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Check that times are equivalent (same instant)
|
|
if !retrieved.MTime.Equal(nyTime) {
|
|
t.Error("time was not preserved correctly")
|
|
}
|
|
|
|
// Check that retrieved time is in UTC
|
|
if retrieved.MTime.Location() != time.UTC {
|
|
t.Error("retrieved time is not in UTC")
|
|
}
|
|
}
|