test: cover encryption wiring on feat/encryption-at-rest

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.
This commit is contained in:
brynmailer-mdc
2026-06-02 10:56:15 +10:00
committed by Bryn Mailer
parent 557d3e4d59
commit 0c577da6e2
3 changed files with 277 additions and 0 deletions
@@ -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")
}
@@ -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())
}
+95
View File
@@ -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)
}