diff --git a/spinifex/service/service_test.go b/spinifex/service/service_test.go index 9708d5b6e..fb496e2f3 100644 --- a/spinifex/service/service_test.go +++ b/spinifex/service/service_test.go @@ -6,13 +6,15 @@ import ( "github.com/mulgadc/spinifex/spinifex/config" "github.com/mulgadc/spinifex/spinifex/services/nats" "github.com/mulgadc/spinifex/spinifex/services/predastore" + "github.com/mulgadc/spinifex/spinifex/services/spinifexui" "github.com/mulgadc/spinifex/spinifex/services/viperblockd" + "github.com/mulgadc/spinifex/spinifex/services/vpcd" "github.com/stretchr/testify/assert" ) func TestNew(t *testing.T) { // Test known service types - services := []string{"nats", "predastore", "viperblock", "spinifex", "awsgw"} + services := []string{"nats", "predastore", "viperblock", "spinifex", "awsgw", "spinifex-ui", "vpcd"} for _, s := range services { var svc Service @@ -32,6 +34,10 @@ func TestNew(t *testing.T) { case "awsgw": svc, err = New(s, &config.ClusterConfig{}) // No special setup needed + case "spinifex-ui": + svc, err = New(s, &spinifexui.Config{}) + case "vpcd": + svc, err = New(s, &vpcd.Config{}) } assert.NoError(t, err) diff --git a/spinifex/services/nats/nats_test.go b/spinifex/services/nats/nats_test.go index f173fb322..d1fbe44ee 100644 --- a/spinifex/services/nats/nats_test.go +++ b/spinifex/services/nats/nats_test.go @@ -119,3 +119,18 @@ func TestRenderedConfig_HasMigrationVersionMarker(t *testing.T) { assert.Equal(t, "# spinifex-config-version: 3", firstLine, "nats.conf template must start with the current migration version marker") } + +func TestNew_ValidConfig(t *testing.T) { + cfg := &Config{Port: 4222, Host: "127.0.0.1"} + svc, err := New(cfg) + require.NoError(t, err) + require.NotNil(t, svc) + assert.Same(t, cfg, svc.Config) +} + +func TestNew_InvalidConfigType(t *testing.T) { + svc, err := New("not a config") + assert.Nil(t, svc) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid config type") +} diff --git a/spinifex/services/spinifexui/tlssplit_test.go b/spinifex/services/spinifexui/tlssplit_test.go new file mode 100644 index 000000000..d841e3aab --- /dev/null +++ b/spinifex/services/spinifexui/tlssplit_test.go @@ -0,0 +1,223 @@ +package spinifexui + +import ( + "bufio" + "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "io" + "math/big" + "net" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPrefixConn_Read_ReplaysPrefixThenStream(t *testing.T) { + server, client := net.Pipe() + defer server.Close() + defer client.Close() + + go func() { + _, _ = client.Write([]byte("est")) + }() + + pc := &prefixConn{ + Conn: server, + r: io.MultiReader(bytes.NewReader([]byte{'t'}), server), + } + + buf := make([]byte, 4) + n, err := io.ReadFull(pc, buf) + require.NoError(t, err) + assert.Equal(t, 4, n) + assert.Equal(t, "test", string(buf)) +} + +// startSplitListener returns a tlsSplitListener on a random local port. Caller +// should close the returned net.Listener when done. The Accept loop runs in a +// goroutine and accepted TLS connections are pushed onto the returned channel. +func startSplitListener(t *testing.T, tlsCfg *tls.Config, redirectPort int) (*tlsSplitListener, net.Listener, <-chan net.Conn) { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + splitLn := &tlsSplitListener{ + Listener: ln, + port: redirectPort, + tlsCfg: tlsCfg, + } + + accepted := make(chan net.Conn, 1) + go func() { + defer close(accepted) + for { + c, err := splitLn.Accept() + if err != nil { + return + } + accepted <- c + } + }() + + return splitLn, ln, accepted +} + +func TestTLSSplitListener_Accept_TLSHandshakeByteWrapsTLS(t *testing.T) { + cert := loadTLSCert(t) + tlsCfg := &tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS12, + } + + _, ln, accepted := startSplitListener(t, tlsCfg, 8443) + defer ln.Close() + + conn, err := net.Dial("tcp", ln.Addr().String()) + require.NoError(t, err) + defer conn.Close() + + // First byte 0x16 = TLS ClientHello record type — listener should wrap with TLS + // without yet performing the handshake. + _, err = conn.Write([]byte{0x16}) + require.NoError(t, err) + + select { + case c := <-accepted: + require.NotNil(t, c) + defer c.Close() + _, ok := c.(*tls.Conn) + assert.True(t, ok, "expected *tls.Conn, got %T", c) + case <-time.After(2 * time.Second): + t.Fatal("Accept did not return a TLS connection in time") + } +} + +func TestTLSSplitListener_Accept_PlainHTTPRedirectsToHTTPS(t *testing.T) { + _, ln, _ := startSplitListener(t, &tls.Config{}, 8443) + defer ln.Close() + + conn, err := net.Dial("tcp", ln.Addr().String()) + require.NoError(t, err) + defer conn.Close() + + _, err = conn.Write([]byte("GET /foo?x=1 HTTP/1.1\r\nHost: example.com\r\n\r\n")) + require.NoError(t, err) + + require.NoError(t, conn.SetReadDeadline(time.Now().Add(3*time.Second))) + resp, err := http.ReadResponse(bufio.NewReader(conn), nil) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusMovedPermanently, resp.StatusCode) + assert.Equal(t, "https://example.com:8443/foo?x=1", resp.Header.Get("Location")) +} + +func TestTLSSplitListener_Accept_PlainHTTPStripsHostPort(t *testing.T) { + _, ln, _ := startSplitListener(t, &tls.Config{}, 9443) + defer ln.Close() + + conn, err := net.Dial("tcp", ln.Addr().String()) + require.NoError(t, err) + defer conn.Close() + + // Host with explicit port — redirectHTTP should strip it before re-emitting. + _, err = conn.Write([]byte("GET /a HTTP/1.1\r\nHost: example.com:80\r\n\r\n")) + require.NoError(t, err) + + require.NoError(t, conn.SetReadDeadline(time.Now().Add(3*time.Second))) + resp, err := http.ReadResponse(bufio.NewReader(conn), nil) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, "https://example.com:9443/a", resp.Header.Get("Location")) +} + +func TestTLSSplitListener_Accept_MalformedHTTPClosesSilently(t *testing.T) { + _, ln, _ := startSplitListener(t, &tls.Config{}, 8443) + defer ln.Close() + + conn, err := net.Dial("tcp", ln.Addr().String()) + require.NoError(t, err) + defer conn.Close() + + // Non-TLS first byte but garbage afterwards — http.ReadRequest fails and + // redirectHTTP returns without writing a response. + _, err = conn.Write([]byte("NOT-AN-HTTP-REQUEST\r\n\r\n")) + require.NoError(t, err) + + require.NoError(t, conn.SetReadDeadline(time.Now().Add(2*time.Second))) + buf := make([]byte, 256) + n, _ := conn.Read(buf) + assert.Equal(t, 0, n, "no response expected for malformed request") +} + +func TestTLSSplitListener_Accept_ClientClosesBeforeFirstByte(t *testing.T) { + cert := loadTLSCert(t) + tlsCfg := &tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS12, + } + + _, ln, accepted := startSplitListener(t, tlsCfg, 8443) + defer ln.Close() + + // Dial and immediately close — listener should drop the conn and keep looping. + c, err := net.Dial("tcp", ln.Addr().String()) + require.NoError(t, err) + require.NoError(t, c.Close()) + + // Now send a valid TLS first byte from a fresh connection to verify the + // loop still serves subsequent clients. + c2, err := net.Dial("tcp", ln.Addr().String()) + require.NoError(t, err) + defer c2.Close() + _, err = c2.Write([]byte{0x16}) + require.NoError(t, err) + + select { + case got := <-accepted: + require.NotNil(t, got) + defer got.Close() + _, ok := got.(*tls.Conn) + assert.True(t, ok) + case <-time.After(2 * time.Second): + t.Fatal("Accept loop did not recover after client disconnect") + } +} + +// loadTLSCert builds a self-signed certificate for local listener tests. +func loadTLSCert(t *testing.T) tls.Certificate { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "tlssplit-test"}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + } + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + require.NoError(t, err) + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + + keyDER, err := x509.MarshalECPrivateKey(key) + require.NoError(t, err) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + + cert, err := tls.X509KeyPair(certPEM, keyPEM) + require.NoError(t, err) + return cert +} diff --git a/spinifex/utils/tags_test.go b/spinifex/utils/tags_test.go index f25ff57ce..690febdd2 100644 --- a/spinifex/utils/tags_test.go +++ b/spinifex/utils/tags_test.go @@ -34,3 +34,21 @@ func TestExtractTags(t *testing.T) { assert.NotNil(t, empty) assert.Empty(t, empty) } + +func TestMapToEC2Tags_Populated(t *testing.T) { + got := MapToEC2Tags(map[string]string{ + "Name": "web-1", + "Env": "prod", + }) + assert.Len(t, got, 2) + asMap := make(map[string]string, len(got)) + for _, tag := range got { + asMap[aws.StringValue(tag.Key)] = aws.StringValue(tag.Value) + } + assert.Equal(t, map[string]string{"Name": "web-1", "Env": "prod"}, asMap) +} + +func TestMapToEC2Tags_Empty(t *testing.T) { + assert.Nil(t, MapToEC2Tags(nil)) + assert.Nil(t, MapToEC2Tags(map[string]string{})) +} diff --git a/spinifex/utils/utils_test.go b/spinifex/utils/utils_test.go index d9ac0c37d..79d4968a7 100644 --- a/spinifex/utils/utils_test.go +++ b/spinifex/utils/utils_test.go @@ -1004,6 +1004,76 @@ func TestReadPidFileFrom_EmptyDir(t *testing.T) { assert.Error(t, err) } +func TestServiceStatus_Stopped(t *testing.T) { + dir := t.TempDir() + status, err := ServiceStatus(dir, fmt.Sprintf("no-such-svc-%d", time.Now().UnixNano())) + require.NoError(t, err) + assert.Equal(t, "stopped", status) +} + +func TestServiceStatus_Running(t *testing.T) { + dir := t.TempDir() + + cmd := exec.Command("sleep", "60") + require.NoError(t, cmd.Start()) + defer func() { + _ = cmd.Process.Kill() + _ = cmd.Wait() + }() + + require.NoError(t, WritePidFileTo(dir, "running-svc", cmd.Process.Pid)) + + status, err := ServiceStatus(dir, "running-svc") + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf("running (pid: %d)", cmd.Process.Pid), status) +} + +func TestServiceStatus_CorruptPidFile(t *testing.T) { + dir := t.TempDir() + // Write a non-numeric pid file — ReadPidFileFrom should fail to parse it. + require.NoError(t, os.WriteFile(filepath.Join(dir, "bad-svc.pid"), []byte("not-a-pid"), 0o644)) + + _, err := ServiceStatus(dir, "bad-svc") + assert.Error(t, err) +} + +func TestWaitForProcessExit_ProcessAlreadyDead(t *testing.T) { + cmd := exec.Command("true") + require.NoError(t, cmd.Start()) + pid := cmd.Process.Pid + require.NoError(t, cmd.Wait()) + + // `true` has exited and been reaped — WaitForProcessExit should return + // nil immediately on the first tick. + err := WaitForProcessExit(pid, 2*time.Second) + assert.NoError(t, err) +} + +func TestWaitForProcessExit_ProcessExitsBeforeTimeout(t *testing.T) { + cmd := exec.Command("sleep", "0.1") + require.NoError(t, cmd.Start()) + pid := cmd.Process.Pid + + // Reap in the background so the kernel releases the PID once sleep exits. + go func() { _ = cmd.Wait() }() + + err := WaitForProcessExit(pid, 5*time.Second) + assert.NoError(t, err) +} + +func TestWaitForProcessExit_Timeout(t *testing.T) { + cmd := exec.Command("sleep", "30") + require.NoError(t, cmd.Start()) + defer func() { + _ = cmd.Process.Kill() + _ = cmd.Wait() + }() + + err := WaitForProcessExit(cmd.Process.Pid, 200*time.Millisecond) + require.Error(t, err) + assert.Contains(t, err.Error(), "timeout") +} + func TestHashMAC_FirstOctetPinnedTo02(t *testing.T) { // First octet must be literal 0x02 across all inputs. Anything else // (e.g. 0x4a, 0xfe — technically valid LAA) visually encroaches on