mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-16 01:22:00 +00:00
test coverage improvements: instance, hiveui, health, utils
Total hive coverage: 54.3% → 55.8% (+1.5%) - instance/service_impl.go: new test file (generateHostname, RunInstance, cloud-init template, constructor) - hiveui.go: new test file (security headers, gzip middleware, New(), Status(), Reload()) - daemon/health.go: extend existing tests (handleInstanceCrash core flow, first crash time, unknown instance type, maybeRestart scheduling) - utils/utils.go: extend existing tests (pidPath all branches, RuntimeDir, extractDiskImagePath, WritePidFile error path) - fix: use smallestAllocType in health tests to avoid CI resource failures
This commit is contained in:
@@ -2,7 +2,9 @@ package daemon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -200,3 +202,264 @@ func TestRestartBackoff_Exponential(t *testing.T) {
|
||||
assert.Equal(t, want, delay, "Backoff mismatch at restart count %d", i)
|
||||
}
|
||||
}
|
||||
|
||||
// newTestDaemon creates a Daemon with shared NATS and a ResourceManager for health tests.
|
||||
// jsManager is nil — WriteState will error (logged, not fatal).
|
||||
func newTestDaemon(t *testing.T) (*Daemon, func()) {
|
||||
t.Helper()
|
||||
nc, err := nats.Connect(sharedNATSURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
d := &Daemon{
|
||||
node: "test-node",
|
||||
natsConn: nc,
|
||||
config: &config.Config{
|
||||
Services: []string{"daemon"},
|
||||
},
|
||||
resourceMgr: NewResourceManager(),
|
||||
Instances: vm.Instances{VMS: make(map[string]*vm.VM)},
|
||||
}
|
||||
return d, func() { nc.Close() }
|
||||
}
|
||||
|
||||
// smallestAllocType returns the name of the smallest (fewest vCPUs) instance type
|
||||
// in the resource manager. This ensures tests work on CI runners with limited resources.
|
||||
func smallestAllocType(t *testing.T, rm *ResourceManager) string {
|
||||
t.Helper()
|
||||
var smallest string
|
||||
var smallestVCPU int64 = 1<<63 - 1
|
||||
for name, it := range rm.instanceTypes {
|
||||
vcpu := instanceTypeVCPUs(it)
|
||||
if vcpu < smallestVCPU {
|
||||
smallestVCPU = vcpu
|
||||
smallest = name
|
||||
}
|
||||
}
|
||||
if smallest == "" {
|
||||
t.Fatal("resource manager has no instance types")
|
||||
}
|
||||
return smallest
|
||||
}
|
||||
|
||||
func TestHandleInstanceCrash_CoreFlow(t *testing.T) {
|
||||
d, cleanup := newTestDaemon(t)
|
||||
defer cleanup()
|
||||
|
||||
allocType := smallestAllocType(t, d.resourceMgr)
|
||||
instanceType := d.resourceMgr.instanceTypes[allocType]
|
||||
require.NoError(t, d.resourceMgr.allocate(instanceType))
|
||||
|
||||
// Record resource levels before crash
|
||||
d.resourceMgr.mu.RLock()
|
||||
allocVCPUBefore := d.resourceMgr.allocatedVCPU
|
||||
allocMemBefore := d.resourceMgr.allocatedMem
|
||||
d.resourceMgr.mu.RUnlock()
|
||||
require.Greater(t, allocVCPUBefore, 0)
|
||||
|
||||
// Create a temp QMP socket file to verify removal
|
||||
tmpDir := t.TempDir()
|
||||
qmpPath := filepath.Join(tmpDir, "qmp.sock")
|
||||
require.NoError(t, os.WriteFile(qmpPath, []byte("dummy"), 0o600))
|
||||
|
||||
instance := &vm.VM{
|
||||
ID: "i-test-core",
|
||||
Status: vm.StateRunning,
|
||||
Running: true,
|
||||
PID: 12345,
|
||||
InstanceType: allocType,
|
||||
Config: vm.Config{QMPSocket: qmpPath},
|
||||
}
|
||||
d.Instances.VMS[instance.ID] = instance
|
||||
|
||||
d.handleInstanceCrash(instance, fmt.Errorf("test crash"))
|
||||
|
||||
// Status transitioned to error
|
||||
assert.Equal(t, vm.StateError, instance.Status)
|
||||
|
||||
// Health tracking updated
|
||||
assert.Equal(t, 1, instance.Health.CrashCount)
|
||||
assert.False(t, instance.Health.LastCrashTime.IsZero())
|
||||
assert.Equal(t, "unknown", instance.Health.LastCrashReason) // fmt.Errorf is not *exec.ExitError
|
||||
assert.False(t, instance.Health.FirstCrashTime.IsZero())
|
||||
|
||||
// Running and PID cleared
|
||||
assert.False(t, instance.Running)
|
||||
assert.Equal(t, 0, instance.PID)
|
||||
|
||||
// Resources deallocated
|
||||
d.resourceMgr.mu.RLock()
|
||||
assert.Less(t, d.resourceMgr.allocatedVCPU, allocVCPUBefore)
|
||||
assert.Less(t, d.resourceMgr.allocatedMem, allocMemBefore)
|
||||
d.resourceMgr.mu.RUnlock()
|
||||
|
||||
// QMP socket file removed
|
||||
_, err := os.Stat(qmpPath)
|
||||
assert.True(t, os.IsNotExist(err), "QMP socket should be removed")
|
||||
}
|
||||
|
||||
func TestHandleInstanceCrash_FirstCrashSetsTime(t *testing.T) {
|
||||
d, cleanup := newTestDaemon(t)
|
||||
defer cleanup()
|
||||
|
||||
allocType := smallestAllocType(t, d.resourceMgr)
|
||||
|
||||
instance := &vm.VM{
|
||||
ID: "i-test-firstcrash",
|
||||
Status: vm.StateRunning,
|
||||
Running: true,
|
||||
PID: 111,
|
||||
InstanceType: allocType,
|
||||
}
|
||||
d.Instances.VMS[instance.ID] = instance
|
||||
|
||||
// First crash
|
||||
d.handleInstanceCrash(instance, fmt.Errorf("crash 1"))
|
||||
firstTime := instance.Health.FirstCrashTime
|
||||
assert.False(t, firstTime.IsZero())
|
||||
assert.Equal(t, 1, instance.Health.CrashCount)
|
||||
|
||||
// Reset to running for a second crash
|
||||
d.Instances.Mu.Lock()
|
||||
instance.Status = vm.StateRunning
|
||||
instance.Running = true
|
||||
instance.PID = 222
|
||||
d.Instances.Mu.Unlock()
|
||||
|
||||
time.Sleep(time.Millisecond) // ensure time advances
|
||||
d.handleInstanceCrash(instance, fmt.Errorf("crash 2"))
|
||||
|
||||
// FirstCrashTime should be unchanged from the first crash
|
||||
assert.Equal(t, firstTime, instance.Health.FirstCrashTime)
|
||||
assert.Equal(t, 2, instance.Health.CrashCount)
|
||||
}
|
||||
|
||||
func TestHandleInstanceCrash_UnknownInstanceType(t *testing.T) {
|
||||
d, cleanup := newTestDaemon(t)
|
||||
defer cleanup()
|
||||
|
||||
instance := &vm.VM{
|
||||
ID: "i-test-unknown-type",
|
||||
Status: vm.StateRunning,
|
||||
Running: true,
|
||||
PID: 333,
|
||||
InstanceType: "z99.nonexistent",
|
||||
}
|
||||
d.Instances.VMS[instance.ID] = instance
|
||||
|
||||
// Should not panic even though instance type is not in resourceMgr
|
||||
d.handleInstanceCrash(instance, fmt.Errorf("crash"))
|
||||
|
||||
assert.Equal(t, vm.StateError, instance.Status)
|
||||
assert.Equal(t, 1, instance.Health.CrashCount)
|
||||
assert.False(t, instance.Running)
|
||||
assert.Equal(t, 0, instance.PID)
|
||||
}
|
||||
|
||||
func TestMaybeRestart_SkipsShuttingDown(t *testing.T) {
|
||||
d, cleanup := newTestDaemon(t)
|
||||
defer cleanup()
|
||||
d.shuttingDown.Store(true)
|
||||
|
||||
allocType := smallestAllocType(t, d.resourceMgr)
|
||||
|
||||
instance := &vm.VM{
|
||||
ID: "i-test-restart-shutdown",
|
||||
Status: vm.StateError,
|
||||
InstanceType: allocType,
|
||||
Health: vm.InstanceHealthState{
|
||||
CrashCount: 1,
|
||||
FirstCrashTime: time.Now(),
|
||||
},
|
||||
}
|
||||
d.Instances.VMS[instance.ID] = instance
|
||||
|
||||
d.maybeRestartInstance(instance)
|
||||
|
||||
// RestartCount should not have changed — restart was skipped
|
||||
assert.Equal(t, 0, instance.Health.RestartCount)
|
||||
}
|
||||
|
||||
func TestMaybeRestart_UnknownInstanceType(t *testing.T) {
|
||||
d, cleanup := newTestDaemon(t)
|
||||
defer cleanup()
|
||||
|
||||
instance := &vm.VM{
|
||||
ID: "i-test-restart-unknown",
|
||||
Status: vm.StateError,
|
||||
InstanceType: "z99.nonexistent",
|
||||
Health: vm.InstanceHealthState{
|
||||
CrashCount: 1,
|
||||
FirstCrashTime: time.Now(),
|
||||
},
|
||||
}
|
||||
d.Instances.VMS[instance.ID] = instance
|
||||
|
||||
// Should not panic — just log and return
|
||||
d.maybeRestartInstance(instance)
|
||||
|
||||
// No restart scheduled
|
||||
assert.Equal(t, 0, instance.Health.RestartCount)
|
||||
}
|
||||
|
||||
func TestMaybeRestart_InsufficientResources(t *testing.T) {
|
||||
d, cleanup := newTestDaemon(t)
|
||||
defer cleanup()
|
||||
|
||||
allocType := smallestAllocType(t, d.resourceMgr)
|
||||
|
||||
// Exhaust all resources by setting allocated = available
|
||||
d.resourceMgr.mu.Lock()
|
||||
d.resourceMgr.allocatedVCPU = d.resourceMgr.availableVCPU
|
||||
d.resourceMgr.allocatedMem = d.resourceMgr.availableMem
|
||||
d.resourceMgr.mu.Unlock()
|
||||
|
||||
instance := &vm.VM{
|
||||
ID: "i-test-restart-nores",
|
||||
Status: vm.StateError,
|
||||
InstanceType: allocType,
|
||||
Health: vm.InstanceHealthState{
|
||||
CrashCount: 1,
|
||||
FirstCrashTime: time.Now(),
|
||||
},
|
||||
}
|
||||
d.Instances.VMS[instance.ID] = instance
|
||||
|
||||
d.maybeRestartInstance(instance)
|
||||
|
||||
// No restart scheduled
|
||||
assert.Equal(t, 0, instance.Health.RestartCount)
|
||||
}
|
||||
|
||||
func TestMaybeRestart_SchedulesRestart(t *testing.T) {
|
||||
d, cleanup := newTestDaemon(t)
|
||||
defer cleanup()
|
||||
|
||||
allocType := smallestAllocType(t, d.resourceMgr)
|
||||
|
||||
instance := &vm.VM{
|
||||
ID: "i-test-restart-schedule",
|
||||
Status: vm.StateError,
|
||||
InstanceType: allocType,
|
||||
Health: vm.InstanceHealthState{
|
||||
CrashCount: 1,
|
||||
FirstCrashTime: time.Now(),
|
||||
RestartCount: 0,
|
||||
},
|
||||
}
|
||||
d.Instances.VMS[instance.ID] = instance
|
||||
|
||||
// maybeRestartInstance should pass all guards and call time.AfterFunc.
|
||||
// The scheduled restartCrashedInstance will increment RestartCount and
|
||||
// attempt LaunchInstance (which will fail without real infra — that's fine).
|
||||
d.maybeRestartInstance(instance)
|
||||
|
||||
// The restart is scheduled via time.AfterFunc with 5s delay.
|
||||
// We verify that the scheduling code path was reached by checking that
|
||||
// the function didn't bail out early (all early returns leave RestartCount at 0
|
||||
// and don't call time.AfterFunc). The counter reset branch does not apply since
|
||||
// FirstCrashTime is recent. If we got here without panic, the scheduling path ran.
|
||||
// We can't easily assert the timer fired without waiting 5s, but we can
|
||||
// verify the counters were not reset (proving we didn't take the window-expired path).
|
||||
assert.Equal(t, 1, instance.Health.CrashCount)
|
||||
assert.Equal(t, 0, instance.Health.RestartCount) // incremented by restartCrashedInstance, not maybeRestart
|
||||
}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
package handlers_ec2_instance
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
"text/template"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/hive/hive/awserrors"
|
||||
"github.com/mulgadc/hive/hive/config"
|
||||
"github.com/mulgadc/hive/hive/objectstore"
|
||||
"github.com/mulgadc/hive/hive/vm"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewInstanceServiceImpl(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
instanceTypes := map[string]*ec2.InstanceTypeInfo{
|
||||
"t3.micro": {InstanceType: aws.String("t3.micro")},
|
||||
}
|
||||
store := objectstore.NewMemoryObjectStore()
|
||||
instances := &vm.Instances{VMS: make(map[string]*vm.VM)}
|
||||
|
||||
svc := NewInstanceServiceImpl(cfg, instanceTypes, nil, instances, store)
|
||||
|
||||
require.NotNil(t, svc)
|
||||
assert.Equal(t, cfg, svc.config)
|
||||
assert.Equal(t, instanceTypes, svc.instanceTypes)
|
||||
assert.Nil(t, svc.natsConn)
|
||||
assert.Equal(t, instances, svc.instances)
|
||||
assert.Equal(t, store, svc.objectStore)
|
||||
}
|
||||
|
||||
func TestGenerateHostname(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
instanceID string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "Normal instance ID",
|
||||
instanceID: "i-0123456789abcdef0",
|
||||
want: "hive-vm-01234567",
|
||||
},
|
||||
{
|
||||
name: "Too short (2 chars)",
|
||||
instanceID: "ab",
|
||||
want: "hive-vm-unknown",
|
||||
},
|
||||
{
|
||||
name: "Empty string",
|
||||
instanceID: "",
|
||||
want: "hive-vm-unknown",
|
||||
},
|
||||
{
|
||||
name: "Exactly 10 chars",
|
||||
instanceID: "i-abcdef01",
|
||||
want: "hive-vm-abcdef01",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := generateHostname(tt.instanceID)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunInstance_Success(t *testing.T) {
|
||||
instanceTypes := map[string]*ec2.InstanceTypeInfo{
|
||||
"t3.micro": {InstanceType: aws.String("t3.micro")},
|
||||
"t3.small": {InstanceType: aws.String("t3.small")},
|
||||
}
|
||||
|
||||
svc := &InstanceServiceImpl{
|
||||
instanceTypes: instanceTypes,
|
||||
}
|
||||
|
||||
input := &ec2.RunInstancesInput{
|
||||
ImageId: aws.String("ami-0abcdef1234567890"),
|
||||
InstanceType: aws.String("t3.micro"),
|
||||
KeyName: aws.String("my-key"),
|
||||
}
|
||||
|
||||
instance, ec2Instance, err := svc.RunInstance(input)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, instance)
|
||||
require.NotNil(t, ec2Instance)
|
||||
|
||||
// Verify VM struct
|
||||
assert.Contains(t, instance.ID, "i-")
|
||||
assert.Equal(t, vm.StateProvisioning, instance.Status)
|
||||
assert.Equal(t, "t3.micro", instance.InstanceType)
|
||||
assert.Equal(t, input, instance.RunInstancesInput)
|
||||
assert.Equal(t, ec2Instance, instance.Instance)
|
||||
|
||||
// Verify EC2 metadata
|
||||
assert.Equal(t, instance.ID, *ec2Instance.InstanceId)
|
||||
assert.Equal(t, "t3.micro", *ec2Instance.InstanceType)
|
||||
assert.Equal(t, "ami-0abcdef1234567890", *ec2Instance.ImageId)
|
||||
assert.Equal(t, "my-key", *ec2Instance.KeyName)
|
||||
assert.Equal(t, int64(0), *ec2Instance.State.Code)
|
||||
assert.Equal(t, "pending", *ec2Instance.State.Name)
|
||||
assert.NotNil(t, ec2Instance.LaunchTime)
|
||||
}
|
||||
|
||||
func TestRunInstance_NoKeyName(t *testing.T) {
|
||||
instanceTypes := map[string]*ec2.InstanceTypeInfo{
|
||||
"t3.micro": {InstanceType: aws.String("t3.micro")},
|
||||
}
|
||||
|
||||
svc := &InstanceServiceImpl{instanceTypes: instanceTypes}
|
||||
|
||||
input := &ec2.RunInstancesInput{
|
||||
ImageId: aws.String("ami-012345"),
|
||||
InstanceType: aws.String("t3.micro"),
|
||||
}
|
||||
|
||||
instance, ec2Instance, err := svc.RunInstance(input)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, instance)
|
||||
assert.Nil(t, ec2Instance.KeyName)
|
||||
}
|
||||
|
||||
func TestRunInstance_InvalidInstanceType(t *testing.T) {
|
||||
instanceTypes := map[string]*ec2.InstanceTypeInfo{
|
||||
"t3.micro": {InstanceType: aws.String("t3.micro")},
|
||||
}
|
||||
|
||||
svc := &InstanceServiceImpl{instanceTypes: instanceTypes}
|
||||
|
||||
input := &ec2.RunInstancesInput{
|
||||
ImageId: aws.String("ami-012345"),
|
||||
InstanceType: aws.String("nonexistent.type"),
|
||||
}
|
||||
|
||||
instance, ec2Instance, err := svc.RunInstance(input)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidInstanceType, err.Error())
|
||||
assert.Nil(t, instance)
|
||||
assert.Nil(t, ec2Instance)
|
||||
}
|
||||
|
||||
func TestRunInstance_UniqueIDs(t *testing.T) {
|
||||
instanceTypes := map[string]*ec2.InstanceTypeInfo{
|
||||
"t3.micro": {InstanceType: aws.String("t3.micro")},
|
||||
}
|
||||
|
||||
svc := &InstanceServiceImpl{instanceTypes: instanceTypes}
|
||||
|
||||
input := &ec2.RunInstancesInput{
|
||||
ImageId: aws.String("ami-012345"),
|
||||
InstanceType: aws.String("t3.micro"),
|
||||
}
|
||||
|
||||
instance1, _, err1 := svc.RunInstance(input)
|
||||
instance2, _, err2 := svc.RunInstance(input)
|
||||
|
||||
require.NoError(t, err1)
|
||||
require.NoError(t, err2)
|
||||
assert.NotEqual(t, instance1.ID, instance2.ID, "Each instance should have a unique ID")
|
||||
}
|
||||
|
||||
func TestCloudInitTemplateRendering(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data CloudInitData
|
||||
contains []string
|
||||
}{
|
||||
{
|
||||
name: "Basic SSH key and hostname",
|
||||
data: CloudInitData{
|
||||
Username: "ec2-user",
|
||||
SSHKey: "ssh-rsa AAAAB3... user@host",
|
||||
Hostname: "hive-vm-01234567",
|
||||
},
|
||||
contains: []string{
|
||||
"ec2-user",
|
||||
"ssh-rsa AAAAB3... user@host",
|
||||
"hive-vm-01234567",
|
||||
"#cloud-config",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "With cloud-config userdata",
|
||||
data: CloudInitData{
|
||||
Username: "ec2-user",
|
||||
SSHKey: "ssh-ed25519 AAAA...",
|
||||
Hostname: "hive-vm-abcdef01",
|
||||
UserDataCloudConfig: "packages:\n - nginx",
|
||||
},
|
||||
contains: []string{
|
||||
"packages:",
|
||||
"nginx",
|
||||
"custom userdata cloud-config",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "With script userdata",
|
||||
data: CloudInitData{
|
||||
Username: "ec2-user",
|
||||
SSHKey: "ssh-rsa AAAA...",
|
||||
Hostname: "hive-vm-test",
|
||||
UserDataScript: " #!/bin/bash\n echo hello",
|
||||
},
|
||||
contains: []string{
|
||||
"write_files:",
|
||||
"/tmp/cloud-init-startup.sh",
|
||||
"runcmd:",
|
||||
"echo hello",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tmpl := template.Must(template.New("cloud-init").Parse(cloudInitUserDataTemplate))
|
||||
var buf bytes.Buffer
|
||||
err := tmpl.Execute(&buf, tt.data)
|
||||
require.NoError(t, err)
|
||||
|
||||
rendered := buf.String()
|
||||
for _, s := range tt.contains {
|
||||
assert.Contains(t, rendered, s)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudInitMetaTemplateRendering(t *testing.T) {
|
||||
data := CloudInitMetaData{
|
||||
InstanceID: "i-0123456789abcdef0",
|
||||
Hostname: "hive-vm-01234567",
|
||||
}
|
||||
|
||||
tmpl := template.Must(template.New("meta-data").Parse(cloudInitMetaTemplate))
|
||||
var buf bytes.Buffer
|
||||
err := tmpl.Execute(&buf, data)
|
||||
require.NoError(t, err)
|
||||
|
||||
rendered := buf.String()
|
||||
assert.Contains(t, rendered, "i-0123456789abcdef0")
|
||||
assert.Contains(t, rendered, "hive-vm-01234567")
|
||||
assert.Contains(t, rendered, "instance-id:")
|
||||
assert.Contains(t, rendered, "local-hostname:")
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package hiveui
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNew_ValidConfig(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Port: 8080,
|
||||
Host: "127.0.0.1",
|
||||
TLSCert: "/custom/cert.pem",
|
||||
TLSKey: "/custom/key.pem",
|
||||
}
|
||||
|
||||
svc, err := New(cfg)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, svc)
|
||||
assert.Equal(t, 8080, svc.Config.Port)
|
||||
assert.Equal(t, "127.0.0.1", svc.Config.Host)
|
||||
assert.Equal(t, "/custom/cert.pem", svc.Config.TLSCert)
|
||||
assert.Equal(t, "/custom/key.pem", svc.Config.TLSKey)
|
||||
}
|
||||
|
||||
func TestNew_DefaultPortAndHost(t *testing.T) {
|
||||
cfg := &Config{
|
||||
TLSCert: "/custom/cert.pem",
|
||||
TLSKey: "/custom/key.pem",
|
||||
}
|
||||
|
||||
svc, err := New(cfg)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 3000, svc.Config.Port)
|
||||
assert.Equal(t, "0.0.0.0", svc.Config.Host)
|
||||
}
|
||||
|
||||
func TestNew_DefaultTLSPaths(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
|
||||
svc, err := New(cfg)
|
||||
|
||||
require.NoError(t, err)
|
||||
// TLS paths should be filled in from home directory
|
||||
assert.NotEmpty(t, svc.Config.TLSCert)
|
||||
assert.NotEmpty(t, svc.Config.TLSKey)
|
||||
assert.Contains(t, svc.Config.TLSCert, "server.pem")
|
||||
assert.Contains(t, svc.Config.TLSKey, "server.key")
|
||||
}
|
||||
|
||||
func TestNew_CustomTLSPreserved(t *testing.T) {
|
||||
cfg := &Config{
|
||||
TLSCert: "/my/cert.pem",
|
||||
TLSKey: "/my/key.pem",
|
||||
}
|
||||
|
||||
svc, err := New(cfg)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "/my/cert.pem", svc.Config.TLSCert)
|
||||
assert.Equal(t, "/my/key.pem", svc.Config.TLSKey)
|
||||
}
|
||||
|
||||
func TestNew_PartialTLSFillsOnlyEmpty(t *testing.T) {
|
||||
cfg := &Config{
|
||||
TLSCert: "/my/cert.pem",
|
||||
// TLSKey left empty — should get default
|
||||
}
|
||||
|
||||
svc, err := New(cfg)
|
||||
|
||||
require.NoError(t, err)
|
||||
// Both are empty-or-not checked together, so both get defaults
|
||||
// because the condition is cfg.TLSCert == "" || cfg.TLSKey == ""
|
||||
assert.Equal(t, "/my/cert.pem", svc.Config.TLSCert)
|
||||
assert.Contains(t, svc.Config.TLSKey, "server.key")
|
||||
}
|
||||
|
||||
func TestNew_InvalidConfigType(t *testing.T) {
|
||||
svc, err := New("not a config")
|
||||
|
||||
assert.Nil(t, svc)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid config type")
|
||||
}
|
||||
|
||||
func TestNew_InvalidConfigTypeInt(t *testing.T) {
|
||||
svc, err := New(42)
|
||||
|
||||
assert.Nil(t, svc)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestStatus_ReturnsValidState(t *testing.T) {
|
||||
svc := &Service{
|
||||
Config: &Config{},
|
||||
}
|
||||
|
||||
status, err := svc.Status()
|
||||
|
||||
assert.NoError(t, err)
|
||||
// On a dev machine the hive-ui PID file may exist, so accept either outcome
|
||||
assert.True(t, status == "stopped" || len(status) > 0, "status should be non-empty")
|
||||
}
|
||||
|
||||
func TestReload_ReturnsNil(t *testing.T) {
|
||||
svc := &Service{
|
||||
Config: &Config{},
|
||||
}
|
||||
|
||||
err := svc.Reload()
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestServiceName(t *testing.T) {
|
||||
assert.Equal(t, "hive-ui", serviceName)
|
||||
}
|
||||
|
||||
func TestSecurityHeadersMiddleware(t *testing.T) {
|
||||
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
handler := securityHeadersMiddleware(inner)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
resp := rec.Result()
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.NotEmpty(t, resp.Header.Get("Content-Security-Policy"))
|
||||
assert.Contains(t, resp.Header.Get("Content-Security-Policy"), "default-src 'self'")
|
||||
assert.Equal(t, "camera=(), microphone=(), geolocation=(), browsing-topics=()", resp.Header.Get("Permissions-Policy"))
|
||||
assert.Equal(t, "strict-origin-when-cross-origin", resp.Header.Get("Referrer-Policy"))
|
||||
assert.Equal(t, "max-age=31536000; includeSubDomains", resp.Header.Get("Strict-Transport-Security"))
|
||||
assert.Equal(t, "nosniff", resp.Header.Get("X-Content-Type-Options"))
|
||||
}
|
||||
|
||||
func TestSecurityHeadersMiddleware_PassesThrough(t *testing.T) {
|
||||
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Custom", "test-value")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
})
|
||||
|
||||
handler := securityHeadersMiddleware(inner)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/data", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
resp := rec.Result()
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
assert.Equal(t, "test-value", resp.Header.Get("X-Custom"))
|
||||
// Security headers still present
|
||||
assert.NotEmpty(t, resp.Header.Get("Content-Security-Policy"))
|
||||
}
|
||||
|
||||
func TestGzipMiddleware_CompressesEligibleContent(t *testing.T) {
|
||||
body := "Hello, this is some text content that should be compressed if long enough."
|
||||
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte(body))
|
||||
})
|
||||
|
||||
handler := gzipMiddleware(inner)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
resp := rec.Result()
|
||||
defer resp.Body.Close()
|
||||
|
||||
// The gzip handler may or may not compress depending on content size,
|
||||
// but the handler should still return a valid response.
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
if resp.Header.Get("Content-Encoding") == "gzip" {
|
||||
reader, err := gzip.NewReader(resp.Body)
|
||||
require.NoError(t, err)
|
||||
defer reader.Close()
|
||||
decompressed, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, body, string(decompressed))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGzipMiddleware_NoCompressionWithoutHeader(t *testing.T) {
|
||||
body := "uncompressed response body"
|
||||
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte(body))
|
||||
})
|
||||
|
||||
handler := gzipMiddleware(inner)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
// No Accept-Encoding header
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
resp := rec.Result()
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.NotEqual(t, "gzip", resp.Header.Get("Content-Encoding"))
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, body, string(respBody))
|
||||
}
|
||||
|
||||
func TestGzipMiddleware_IgnoresNonTextContent(t *testing.T) {
|
||||
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
w.Write([]byte("fake image data"))
|
||||
})
|
||||
|
||||
handler := gzipMiddleware(inner)
|
||||
req := httptest.NewRequest(http.MethodGet, "/image.png", nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
resp := rec.Result()
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
// image/png not in the allowed content types — should not be compressed
|
||||
assert.NotEqual(t, "gzip", resp.Header.Get("Content-Encoding"))
|
||||
}
|
||||
|
||||
func TestShutdown_WithServer(t *testing.T) {
|
||||
// Create a real server on a random port so Shutdown exercises the non-nil path
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
srv := &http.Server{Handler: mux}
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
|
||||
go srv.Serve(ln)
|
||||
|
||||
svc := &Service{
|
||||
Config: &Config{},
|
||||
server: srv,
|
||||
}
|
||||
|
||||
err = svc.Shutdown()
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
@@ -942,3 +942,68 @@ func TestSetOOMScore_InvalidPID(t *testing.T) {
|
||||
err := SetOOMScore(999999999, 100)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestRuntimeDir(t *testing.T) {
|
||||
dir := RuntimeDir()
|
||||
assert.NotEmpty(t, dir)
|
||||
}
|
||||
|
||||
func TestPidPath_XDG(t *testing.T) {
|
||||
t.Setenv("XDG_RUNTIME_DIR", "/tmp/test-xdg-runtime")
|
||||
assert.Equal(t, "/tmp/test-xdg-runtime", pidPath())
|
||||
}
|
||||
|
||||
func TestPidPath_HomeHiveFallback(t *testing.T) {
|
||||
tmpHome := t.TempDir()
|
||||
hiveDir := fmt.Sprintf("%s/hive", tmpHome)
|
||||
require.NoError(t, os.Mkdir(hiveDir, 0755))
|
||||
|
||||
t.Setenv("XDG_RUNTIME_DIR", "")
|
||||
t.Setenv("HOME", tmpHome)
|
||||
|
||||
assert.Equal(t, hiveDir, pidPath())
|
||||
}
|
||||
|
||||
func TestPidPath_TempDirFallback(t *testing.T) {
|
||||
t.Setenv("XDG_RUNTIME_DIR", "")
|
||||
t.Setenv("HOME", "/nonexistent-home-dir-utils-test")
|
||||
|
||||
assert.Equal(t, os.TempDir(), pidPath())
|
||||
}
|
||||
|
||||
func TestExtractDiskImagePath_NoMatch(t *testing.T) {
|
||||
output := []byte("somefile.txt\nanotherfile.conf\n")
|
||||
diskimage, err := extractDiskImagePath(t.TempDir(), output)
|
||||
assert.Empty(t, diskimage)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestExtractDiskImagePath_EmptyOutput(t *testing.T) {
|
||||
diskimage, err := extractDiskImagePath(t.TempDir(), []byte{})
|
||||
assert.Empty(t, diskimage)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestWritePidFile_CreateError(t *testing.T) {
|
||||
t.Setenv("XDG_RUNTIME_DIR", "/nonexistent-dir-utils-test")
|
||||
err := WritePidFile("testservice", 12345)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestRemovePidFileAt_EmptyDir(t *testing.T) {
|
||||
err := RemovePidFileAt("", fmt.Sprintf("nonexistent-service-%d", time.Now().UnixNano()))
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestGeneratePidFile_InvalidPath(t *testing.T) {
|
||||
t.Setenv("XDG_RUNTIME_DIR", "/nonexistent-dir-utils-test")
|
||||
path, err := GeneratePidFile("test")
|
||||
// GeneratePidFile doesn't check if path exists, just builds it
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, path, "test.pid")
|
||||
}
|
||||
|
||||
func TestReadPidFileFrom_EmptyDir(t *testing.T) {
|
||||
_, err := ReadPidFileFrom("", fmt.Sprintf("nonexistent-service-%d", time.Now().UnixNano()))
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user