Files
ITWorx-Pulse-Public/internal/probe/executor_test.go
T
ITWorx Pulse release export bd774932d5
Public source validation / validate (push) Failing after 3m8s
Publish ITWorx Pulse source
2026-09-03 02:09:19 +02:00

247 lines
10 KiB
Go

package probe
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"errors"
"io"
"math/big"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"net/url"
"strings"
"testing"
"time"
)
type fakeHTTPDoer struct {
response *http.Response
err error
request *http.Request
}
func (f *fakeHTTPDoer) Do(request *http.Request) (*http.Response, error) {
f.request = request
return f.response, f.err
}
func probeDefinition(probeType string) Definition {
return Definition{
ID: "probe-1",
ServiceID: "service-1",
Name: "probe",
Type: probeType,
Target: Target{Scheme: "http", Host: "service.internal", Port: 8080, Path: "/health"},
Interval: 30 * time.Second,
Timeout: 2 * time.Second,
Enabled: true,
Revision: 1,
VerifyTLS: false,
FollowRedirects: false,
}
}
func executorPolicy() NetworkPolicy {
return NetworkPolicy{Revision: 1, AllowedNetworks: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8"), netip.MustParsePrefix("127.0.0.0/8")}}
}
func executorResolver(_ context.Context, _ string) ([]netip.Addr, error) {
return []netip.Addr{netip.MustParseAddr("10.10.0.8")}, nil
}
func TestProbeExecutorHTTPAssertionsAndStatus(t *testing.T) {
body := `{"ok":true,"message":"ready"}`
doer := &fakeHTTPDoer{response: &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body))}}
definition := probeDefinition(TypeHTTP)
definition.ContentAssertion = map[string]any{"keyword": "ready", "json": map[string]any{"ok": true}}
executor := ProbeExecutor{Policy: executorPolicy(), Resolver: resolverFunc(executorResolver), HTTP: doer}
result, err := executor.Execute(context.Background(), definition)
if err != nil {
t.Fatal(err)
}
if result.State != "up" || result.StatusCode == nil || *result.StatusCode != http.StatusOK {
t.Fatalf("unexpected HTTP result: %+v", result)
}
if doer.request == nil || doer.request.Method != http.MethodGet || doer.request.URL.String() != "http://service.internal:8080/health" {
t.Fatalf("unexpected request: %+v", doer.request)
}
}
func TestProbeExecutorHTTPFailureBodyLimitAndRedaction(t *testing.T) {
definition := probeDefinition(TypeHTTP)
definition.ExpectedStatusCodes = []int{http.StatusOK}
definition.SecretReference = "secret-ref-should-never-leak"
failureDoer := &fakeHTTPDoer{response: &http.Response{StatusCode: http.StatusServiceUnavailable, Body: io.NopCloser(strings.NewReader("down"))}}
failure, err := (ProbeExecutor{Policy: executorPolicy(), Resolver: resolverFunc(executorResolver), HTTP: failureDoer}).Execute(context.Background(), definition)
if err != nil {
t.Fatal(err)
}
if failure.State != "down" || failure.ErrorClass != "status_not_expected" || strings.Contains(failure.ErrorMessage, definition.SecretReference) {
t.Fatalf("unexpected failure result: %+v", failure)
}
if failureDoer.request == nil || failureDoer.request.Header.Get("Authorization") != "" {
t.Fatal("probe credentials were placed in the request")
}
limitedPolicy := executorPolicy()
limitedPolicy.MaxResponseBytes = 4
largeDoer := &fakeHTTPDoer{response: &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("12345"))}}
large, err := (ProbeExecutor{Policy: limitedPolicy, Resolver: resolverFunc(executorResolver), HTTP: largeDoer}).Execute(context.Background(), definition)
if err != nil {
t.Fatal(err)
}
if large.State != "unknown" || large.ErrorClass != "response_too_large" {
t.Fatalf("unexpected bounded response result: %+v", large)
}
}
func TestProbeExecutorHTTPRedirectPolicyAndTimeout(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.URL.Path == "/start" {
http.Redirect(response, request, "/final", http.StatusFound)
return
}
_, _ = response.Write([]byte("final"))
}))
defer server.Close()
serverURL, err := url.Parse(server.URL)
if err != nil {
t.Fatal(err)
}
port := mustPort(serverURL.Port())
resolver := resolverFunc(func(_ context.Context, host string) ([]netip.Addr, error) {
if host == "service.internal" {
return []netip.Addr{netip.MustParseAddr("127.0.0.1")}, nil
}
return nil, errors.New("unexpected host")
})
definition := probeDefinition(TypeHTTP)
definition.Target = Target{Scheme: "http", Host: "service.internal", Port: port, Path: "/start"}
definition.FollowRedirects = false
withoutRedirect, err := (ProbeExecutor{Policy: executorPolicy(), Resolver: resolver}).Execute(context.Background(), definition)
if err != nil {
t.Fatal(err)
}
if withoutRedirect.State != "up" || withoutRedirect.StatusCode == nil || *withoutRedirect.StatusCode != http.StatusFound {
t.Fatalf("redirect was not safely stopped: %+v", withoutRedirect)
}
definition.FollowRedirects = true
withRedirect, err := (ProbeExecutor{Policy: executorPolicy(), Resolver: resolver}).Execute(context.Background(), definition)
if err != nil {
t.Fatal(err)
}
if withRedirect.State != "up" || withRedirect.StatusCode == nil || *withRedirect.StatusCode != http.StatusOK {
t.Fatalf("redirect was not followed within policy: %+v", withRedirect)
}
}
func TestProbeExecutorTimeoutAndDNS(t *testing.T) {
definition := probeDefinition(TypeHTTP)
definition.Timeout = time.Second
// Use a doer that releases only when the executor's per-probe context expires.
blockingDoer := httpDoerFunc(func(request *http.Request) (*http.Response, error) {
<-request.Context().Done()
return nil, request.Context().Err()
})
result, err := (ProbeExecutor{Policy: executorPolicy(), Resolver: resolverFunc(executorResolver), HTTP: blockingDoer}).Execute(context.Background(), definition)
if !errors.Is(err, context.DeadlineExceeded) || result.ErrorClass != "transport_error" {
t.Fatalf("unexpected timeout result=%+v err=%v", result, err)
}
dnsDefinition := probeDefinition(TypeDNS)
dnsDefinition.Target = Target{Host: "dns.internal"}
dnsResolver := resolverFunc(func(_ context.Context, host string) ([]netip.Addr, error) {
if host != "dns.internal" {
return nil, errors.New("unexpected host")
}
return []netip.Addr{netip.MustParseAddr("10.0.0.1"), netip.MustParseAddr("10.0.0.2")}, nil
})
dnsResult, err := (ProbeExecutor{Policy: executorPolicy(), Resolver: dnsResolver}).Execute(context.Background(), dnsDefinition)
if err != nil || dnsResult.State != "up" || dnsResult.Attributes["addressCount"] != 2 {
t.Fatalf("unexpected DNS result=%+v err=%v", dnsResult, err)
}
}
func TestProbeExecutorTCPAndUnsupportedICMP(t *testing.T) {
definition := probeDefinition(TypeTCP)
definition.Target = Target{Host: "tcp.internal", Port: 443}
calledNetwork := ""
tcpResult, err := (ProbeExecutor{Policy: executorPolicy(), Resolver: resolverFunc(executorResolver), DialContext: func(_ context.Context, network, _ string) (net.Conn, error) {
calledNetwork = network
client, peer := net.Pipe()
_ = peer.Close()
return client, nil
}}).Execute(context.Background(), definition)
if err != nil || tcpResult.State != "up" || calledNetwork != "tcp" {
t.Fatalf("unexpected TCP result=%+v err=%v network=%s", tcpResult, err, calledNetwork)
}
icmpDefinition := probeDefinition(TypeICMP)
icmpDefinition.Target = Target{Host: "gateway.internal"}
icmpResult, err := (ProbeExecutor{Policy: executorPolicy()}).Execute(context.Background(), icmpDefinition)
if err != nil || icmpResult.State != "unknown" || icmpResult.ErrorClass != "unsupported" {
t.Fatalf("unexpected ICMP fallback result=%+v err=%v", icmpResult, err)
}
}
func TestProbeExecutorTLSCertificateFacts(t *testing.T) {
observed := time.Date(2026, time.August, 2, 12, 0, 0, 0, time.UTC)
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
certificateTemplate := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "tls.internal"}, DNSNames: []string{"tls.internal"}, NotBefore: observed.Add(-time.Hour), NotAfter: observed.Add(90 * 24 * time.Hour), KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}
certificateDER, err := x509.CreateCertificate(rand.Reader, certificateTemplate, certificateTemplate, &privateKey.PublicKey, privateKey)
if err != nil {
t.Fatal(err)
}
serverCertificate := tls.Certificate{Certificate: [][]byte{certificateDER}, PrivateKey: privateKey}
definition := probeDefinition(TypeTLS)
definition.Target = Target{Scheme: "https", Host: "tls.internal", Port: 443}
definition.VerifyTLS = false
tlsResult, err := (ProbeExecutor{
Policy: executorPolicy(),
Resolver: resolverFunc(func(context.Context, string) ([]netip.Addr, error) {
return []netip.Addr{netip.MustParseAddr("10.0.0.3")}, nil
}),
Now: func() time.Time { return observed },
DialContext: func(_ context.Context, network, _ string) (net.Conn, error) {
if network != "tcp" {
return nil, errors.New("unexpected TLS network")
}
client, server := net.Pipe()
go func() {
defer server.Close()
_ = tls.Server(server, &tls.Config{Certificates: []tls.Certificate{serverCertificate}}).Handshake()
}()
return client, nil
},
}).Execute(context.Background(), definition)
if err != nil || tlsResult.State != "up" || tlsResult.Certificate == nil || tlsResult.Certificate.VerificationState != "valid" || tlsResult.Certificate.HostnameValid == nil || !*tlsResult.Certificate.HostnameValid {
t.Fatalf("unexpected TLS result=%+v err=%v", tlsResult, err)
}
attention := *certificateTemplate
attention.NotAfter = observed.Add(7 * 24 * time.Hour)
attentionResult := certificateFromX509(&attention, definition, func() time.Time { return observed })
if attentionResult.VerificationState != "attention" {
t.Fatalf("expected certificate attention, got %+v", attentionResult)
}
invalid := *certificateTemplate
invalid.DNSNames = []string{"other.internal"}
invalidResult := certificateFromX509(&invalid, definition, func() time.Time { return observed })
if invalidResult.VerificationState != "invalid" || invalidResult.HostnameValid == nil || *invalidResult.HostnameValid {
t.Fatalf("expected invalid hostname, got %+v", invalidResult)
}
}
type httpDoerFunc func(*http.Request) (*http.Response, error)
func (f httpDoerFunc) Do(request *http.Request) (*http.Response, error) { return f(request) }