check / check (push) Waiting to run
The files table held mtime in whole seconds and the scanner compared whole seconds. A file rewritten with its size unchanged and a new mtime in the same second as the indexed one was treated as unchanged, and every later snapshot restored the old content. mtime is now stored as nanoseconds since the Unix epoch and compared at that precision. A local index written before this change holds seconds in mtime: its next backup re-chunks every file once, and a restore of a snapshot made before it sets mtimes near 1970. An mtime outside the years 1678 to 2262 does not fit in int64 nanoseconds; it is stored wrong and restored wrong. Model: opus-5-5
640 lines
14 KiB
Go
640 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 ListUnderPath performance
|
|
//nolint:paralleltest // phases share one database and are order-dependent
|
|
t.Run("list under path performance", func(t *testing.T) {
|
|
start := time.Now()
|
|
|
|
files, err := repos.Files.ListUnderPath(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.ListUnderPath(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")
|
|
}
|
|
|
|
nyTime := time.Now().In(loc)
|
|
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")
|
|
}
|
|
}
|