mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-19 19:01:42 +00:00
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:
committed by
Bryn Mailer
parent
557d3e4d59
commit
0c577da6e2
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user