From 0c577da6e213693b89fb0522965fb082ed7e5555 Mon Sep 17 00:00:00 2001 From: brynmailer-mdc Date: Tue, 2 Jun 2026 10:56:15 +1000 Subject: [PATCH] test: cover encryption wiring on feat/encryption-at-rest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lifts diff-coverage on PR #276 from 37.5% to 63.5% (threshold 60%) so CI's Check Diff Coverage step passes. - utils: LoadViperblockMasterKey — empty path, happy path + cache reuse, load error, and bad-perm failure-not-cached. - handlers/ec2/image: clusterEncryptionEnabled — nil config, empty key file, masterkey load error (warn + false), and key-loaded (true). - handlers/ec2/volume: - GetVolumeConfig surfaces "failed to unmarshal config" when the body is neither a valid VBState nor a wrapper. - mergeVolumeConfig refuses to re-marshal an encrypted VBState (would strip the AES-GCM tag and brick the volume). - CreateVolume returns ServerInternal when EncryptionKeyFile is set but unreadable. --- .../ec2/image/service_impl_encryption_test.go | 69 +++++++++++ .../volume/service_impl_encryption_test.go | 113 ++++++++++++++++++ spinifex/utils/encryption_test.go | 95 +++++++++++++++ 3 files changed, 277 insertions(+) create mode 100644 spinifex/handlers/ec2/image/service_impl_encryption_test.go create mode 100644 spinifex/handlers/ec2/volume/service_impl_encryption_test.go create mode 100644 spinifex/utils/encryption_test.go diff --git a/spinifex/handlers/ec2/image/service_impl_encryption_test.go b/spinifex/handlers/ec2/image/service_impl_encryption_test.go new file mode 100644 index 000000000..1362085ad --- /dev/null +++ b/spinifex/handlers/ec2/image/service_impl_encryption_test.go @@ -0,0 +1,69 @@ +// Copyright 2026 Mulga Defense Corporation (MDC). All rights reserved. +// Use of this source code is governed by an Apache 2.0 license +// that can be found in the LICENSE file. + +package handlers_ec2_image + +import ( + "crypto/rand" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/mulgadc/spinifex/spinifex/config" + "github.com/mulgadc/spinifex/spinifex/objectstore" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// writeMasterKey writes a 32-byte AES-256 master key at mode and returns the path. +func writeMasterKey(t *testing.T, mode os.FileMode) string { + t.Helper() + raw := make([]byte, 32) + _, err := rand.Read(raw) + require.NoError(t, err) + path := filepath.Join(t.TempDir(), "master.key") + require.NoError(t, os.WriteFile(path, raw, mode)) + require.NoError(t, os.Chmod(path, mode)) + return path +} + +// imageServiceWithConfig returns an ImageServiceImpl wired to the given +// ViperblockConfig — used to exercise the cluster-encryption posture path +// which reads s.config.Viperblock.EncryptionKeyFile. +func imageServiceWithConfig(vb config.ViperblockConfig) *ImageServiceImpl { + return &ImageServiceImpl{ + config: &config.Config{Viperblock: vb}, + store: objectstore.NewMemoryObjectStore(), + } +} + +func TestClusterEncryptionEnabled_NilConfig(t *testing.T) { + svc := &ImageServiceImpl{store: objectstore.NewMemoryObjectStore()} + assert.False(t, svc.clusterEncryptionEnabled(), + "nil config must report encryption disabled") +} + +func TestClusterEncryptionEnabled_EmptyKeyFile(t *testing.T) { + svc := imageServiceWithConfig(config.ViperblockConfig{}) + assert.False(t, svc.clusterEncryptionEnabled(), + "empty EncryptionKeyFile must report encryption disabled") +} + +func TestClusterEncryptionEnabled_LoadError(t *testing.T) { + missing := filepath.Join(t.TempDir(), "nonexistent.key") + svc := imageServiceWithConfig(config.ViperblockConfig{EncryptionKeyFile: missing}) + assert.False(t, svc.clusterEncryptionEnabled(), + "failed master key load must report encryption disabled (warn + fall back)") +} + +func TestClusterEncryptionEnabled_KeyLoaded(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX permission semantics not enforced on Windows") + } + path := writeMasterKey(t, 0o640) + svc := imageServiceWithConfig(config.ViperblockConfig{EncryptionKeyFile: path}) + assert.True(t, svc.clusterEncryptionEnabled(), + "valid master key must report cluster encryption enabled") +} diff --git a/spinifex/handlers/ec2/volume/service_impl_encryption_test.go b/spinifex/handlers/ec2/volume/service_impl_encryption_test.go new file mode 100644 index 000000000..b832af4b6 --- /dev/null +++ b/spinifex/handlers/ec2/volume/service_impl_encryption_test.go @@ -0,0 +1,113 @@ +// Copyright 2026 Mulga Defense Corporation (MDC). All rights reserved. +// Use of this source code is governed by an Apache 2.0 license +// that can be found in the LICENSE file. + +package handlers_ec2_volume + +import ( + "encoding/json" + "path/filepath" + "strings" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/aws/aws-sdk-go/service/s3" + "github.com/mulgadc/spinifex/spinifex/awserrors" + "github.com/mulgadc/spinifex/spinifex/config" + "github.com/mulgadc/spinifex/spinifex/objectstore" + "github.com/mulgadc/viperblock/viperblock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetVolumeConfig_CorruptJSON(t *testing.T) { + store := objectstore.NewMemoryObjectStore() + svc := newTestVolumeServiceWithStore("ap-southeast-2a", store) + + const volumeID = "vol-bad-json" + // Body is not a valid VBState (BlockSize stays 0 → falls through) and is + // not a valid volumeConfigWrapper either (unmarshal fails) — must surface + // "failed to unmarshal config" from getVolumeConfigAndEncryption. + _, err := store.PutObject(&s3.PutObjectInput{ + Bucket: aws.String("test-bucket"), + Key: aws.String(volumeID + "/config.json"), + Body: strings.NewReader("not valid json {{{"), + }) + require.NoError(t, err) + + _, err = svc.GetVolumeConfig(volumeID) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to unmarshal config") +} + +func TestMergeVolumeConfig_RefusesEncryptedVBState(t *testing.T) { + store := objectstore.NewMemoryObjectStore() + svc := newTestVolumeServiceWithStore("ap-southeast-2a", store) + + const volumeID = "vol-encrypted" + const configKey = volumeID + "/config.json" + + // Seed an encrypted-at-rest VBState. mergeVolumeConfig must refuse to + // re-marshal it because spinifex does not currently hold the master key + // and a tag-less rewrite would brick the volume. + state := viperblock.VBState{ + VolumeName: volumeID, + VolumeSize: 1024 * 1024 * 1024, + BlockSize: 4096, + EncryptionEnabled: true, + VolumeConfig: viperblock.VolumeConfig{ + VolumeMetadata: viperblock.VolumeMetadata{ + VolumeID: volumeID, + SizeGiB: 1, + }, + }, + } + data, err := json.Marshal(state) + require.NoError(t, err) + _, err = store.PutObject(&s3.PutObjectInput{ + Bucket: aws.String("test-bucket"), + Key: aws.String(configKey), + Body: strings.NewReader(string(data)), + }) + require.NoError(t, err) + + _, err = svc.mergeVolumeConfig(configKey, &viperblock.VolumeConfig{ + VolumeMetadata: viperblock.VolumeMetadata{VolumeID: volumeID, SizeGiB: 2}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "refusing to merge encrypted VBState") +} + +// newTestVolumeServiceWithEncryptionKey wires CreateVolume to a configured +// (but unreadable) EncryptionKeyFile so the master-key load error path is hit. +func newTestVolumeServiceWithEncryptionKey(az, keyFile string) *VolumeServiceImpl { + cfg := &config.Config{ + AZ: az, + Predastore: config.PredastoreConfig{ + Bucket: "test-bucket", + Region: "ap-southeast-2", + Host: "localhost:9000", + AccessKey: "testkey", + SecretKey: "testsecret", + }, + Viperblock: config.ViperblockConfig{EncryptionKeyFile: keyFile}, + WalDir: "/tmp/test-wal", + } + return NewVolumeServiceImplWithStore(cfg, objectstore.NewMemoryObjectStore(), nil) +} + +func TestCreateVolume_EncryptionKeyLoadError(t *testing.T) { + // Point EncryptionKeyFile at a non-existent path so LoadViperblockMasterKey + // fails — CreateVolume must abort with ServerInternal, before any backend + // init / SaveState call. + missing := filepath.Join(t.TempDir(), "absent.key") + svc := newTestVolumeServiceWithEncryptionKey("ap-southeast-2a", missing) + + _, err := svc.CreateVolume(&ec2.CreateVolumeInput{ + Size: aws.Int64(1), + AvailabilityZone: aws.String("ap-southeast-2a"), + }, "") + require.Error(t, err) + assert.Equal(t, awserrors.ErrorServerInternal, err.Error()) +} diff --git a/spinifex/utils/encryption_test.go b/spinifex/utils/encryption_test.go new file mode 100644 index 000000000..f009d07a4 --- /dev/null +++ b/spinifex/utils/encryption_test.go @@ -0,0 +1,95 @@ +// Copyright 2026 Mulga Defense Corporation (MDC). All rights reserved. +// Use of this source code is governed by an Apache 2.0 license +// that can be found in the LICENSE file. + +package utils + +import ( + "crypto/rand" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/mulgadc/predastore/pkg/masterkey" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// writeMasterKeyFile writes a 32-byte master key at name under t.TempDir() +// with the given mode (chmod'd explicitly to bypass umask). +func writeMasterKeyFile(t *testing.T, name string, mode os.FileMode) string { + t.Helper() + raw := make([]byte, 32) + _, err := rand.Read(raw) + require.NoError(t, err) + path := filepath.Join(t.TempDir(), name) + require.NoError(t, os.WriteFile(path, raw, mode)) + require.NoError(t, os.Chmod(path, mode)) + return path +} + +// resetKeyCache clears the process-wide cache so cache-sensitive tests +// can run independently regardless of order. +func resetKeyCache() { + viperblockKeyCacheMu.Lock() + defer viperblockKeyCacheMu.Unlock() + viperblockKeyCache = map[string]*masterkey.Key{} +} + +func TestLoadViperblockMasterKey_EmptyPath(t *testing.T) { + resetKeyCache() + k, err := LoadViperblockMasterKey("") + require.NoError(t, err) + assert.Nil(t, k, "empty path must return (nil, nil) — encryption disabled") +} + +func TestLoadViperblockMasterKey_LoadsAndCaches(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX permission semantics not enforced on Windows") + } + resetKeyCache() + + path := writeMasterKeyFile(t, "key", 0o640) + + k1, err := LoadViperblockMasterKey(path) + require.NoError(t, err) + require.NotNil(t, k1) + assert.NotEmpty(t, k1.Fingerprint) + + // Second call must return the cached *Key (pointer-equal), proving + // the path is memoised rather than re-stat'd + re-parsed. + k2, err := LoadViperblockMasterKey(path) + require.NoError(t, err) + assert.Same(t, k1, k2, "second LoadViperblockMasterKey must return cached pointer") +} + +func TestLoadViperblockMasterKey_LoadError(t *testing.T) { + resetKeyCache() + + missing := filepath.Join(t.TempDir(), "nonexistent.key") + k, err := LoadViperblockMasterKey(missing) + require.Error(t, err) + assert.Nil(t, k) + assert.Contains(t, err.Error(), "load viperblock encryption key") + assert.Contains(t, err.Error(), missing) +} + +func TestLoadViperblockMasterKey_FailedLoadNotCached(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX permission semantics not enforced on Windows") + } + resetKeyCache() + + // 0o644 has the world-read bit set, which masterkey.LoadShared rejects. + path := writeMasterKeyFile(t, "loose.key", 0o644) + _, err := LoadViperblockMasterKey(path) + require.Error(t, err) + + // Fix the permissions; a subsequent call must succeed (proving the + // previous failure was not memoised). + require.NoError(t, os.Chmod(path, 0o640)) + k, err := LoadViperblockMasterKey(path) + require.NoError(t, err) + require.NotNil(t, k) +}