Add the mock provider with a real CONNECT proxy per port (Step 3)
An in-memory provider.Provider whose state (Provisioning -> Running -> Terminated -> purged) is a pure function of an injectable clock, not background timers, so it's deterministic under tests and correct under real time with no goroutine lifecycle to leak. Once an instance is observed Running, it lazily acquires a real HTTP CONNECT proxy listener so the health engine's through-the-proxy probe (later steps) genuinely tunnels a request end to end, instead of the healthcheck being simulated or bypassed for local development. Redesigned the listener sharing model from what the plan assumed: the plan's "one loopback IP per instance" doesn't work on macOS (only 127.0.0.1 binds without a privileged ifconfig alias, unlike Linux where the whole 127.0.0.0/8 routes to loopback by default), and there's no channel for a provider to report a port back to the reconciler anyway (EffectivePort() is spec-only). Instances now share one real listener per port, reference-counted at the package level rather than per Provider instance, since a bound TCP port is a genuinely process-global OS resource -- two separately configured mock-typed provider entries must not both try to bind the same default port. Fault injection wired both ways: MockConfig.FailNextCreates/FailWith for demos, InjectCreateFailures(n, class) for tests. Create is idempotent by name. Caught and fixed a real test flake (not a logic bug): the freePort test helper asked the OS for a free port via bind-then-close, a TOCTOU race under t.Parallel() that let two tests collide on the same "free" port. Replaced it with a monotonic counter, since these tests only need uniqueness within the test run. internal/provider/mock at 91.1% coverage, including an end-to-end test that opens real sockets: Create -> Get past provisionDelay -> a real http.Client tunnelling a CONNECT through the mock to a real TLS origin. make test green across the whole repo. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
430
internal/provider/mock/mock_test.go
Normal file
430
internal/provider/mock/mock_test.go
Normal file
@@ -0,0 +1,430 @@
|
||||
package mock
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
|
||||
)
|
||||
|
||||
// fakeClock lets tests drive Provider's internal state machine
|
||||
// deterministically instead of racing real time.
|
||||
type fakeClock struct {
|
||||
mu sync.Mutex
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func newFakeClock() *fakeClock {
|
||||
return &fakeClock{now: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}
|
||||
}
|
||||
|
||||
func (c *fakeClock) Now() time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.now
|
||||
}
|
||||
|
||||
func (c *fakeClock) Advance(d time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.now = c.now.Add(d)
|
||||
}
|
||||
|
||||
func newTestProvider(clock *fakeClock) *Provider {
|
||||
return &Provider{
|
||||
name: "test",
|
||||
now: clock.Now,
|
||||
provisionDelay: 5 * time.Second,
|
||||
deleteDelay: 1 * time.Second,
|
||||
instances: make(map[string]*record),
|
||||
}
|
||||
}
|
||||
|
||||
// testPortCounter hands out distinct ports across this package's test run.
|
||||
// A "bind to :0, read back the port, close it" approach looks more
|
||||
// realistic but is a TOCTOU race under t.Parallel(): two tests can be
|
||||
// handed the same "free" port before either actually claims it, since
|
||||
// nothing holds it open in between. What these tests actually need is a
|
||||
// port no *other test in this run* will also try — a monotonic counter
|
||||
// guarantees that outright, at the acceptable cost of a (much rarer) clash
|
||||
// with an unrelated process already using something in this range.
|
||||
var testPortCounter atomic.Int32
|
||||
|
||||
func freePort(t *testing.T) int32 {
|
||||
t.Helper()
|
||||
return 20000 + testPortCounter.Add(1)
|
||||
}
|
||||
|
||||
func TestProvider_Create_idempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := newTestProvider(newFakeClock())
|
||||
ctx := context.Background()
|
||||
req := provider.CreateRequest{Name: "proxy-abc", UID: "uid-1", Port: freePort(t)}
|
||||
|
||||
id1, err := p.Create(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
id2, err := p.Create(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("Create() (repeat) error = %v", err)
|
||||
}
|
||||
if id1 != id2 {
|
||||
t.Errorf("Create() not idempotent: %q != %q", id1, id2)
|
||||
}
|
||||
|
||||
instances, err := p.ListByTag(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListByTag() error = %v", err)
|
||||
}
|
||||
if len(instances) != 1 {
|
||||
t.Errorf("len(instances) = %d, want 1 (repeat Create must not duplicate)", len(instances))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Get_notFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := newTestProvider(newFakeClock())
|
||||
_, err := p.Get(context.Background(), "does-not-exist")
|
||||
if !errors.Is(err, provider.ErrNotFound) {
|
||||
t.Errorf("Get() error = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_stateTransitions(t *testing.T) {
|
||||
t.Parallel()
|
||||
clock := newFakeClock()
|
||||
p := newTestProvider(clock)
|
||||
ctx := context.Background()
|
||||
port := freePort(t)
|
||||
|
||||
id, err := p.Create(ctx, provider.CreateRequest{Name: "proxy-xyz", UID: "uid-2", Port: port})
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
|
||||
inst, err := p.Get(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() error = %v", err)
|
||||
}
|
||||
if inst.State != provider.StateProvisioning {
|
||||
t.Errorf("State = %v, want Provisioning immediately after Create", inst.State)
|
||||
}
|
||||
if inst.IP != "" {
|
||||
t.Errorf("IP = %q, want empty while Provisioning", inst.IP)
|
||||
}
|
||||
|
||||
clock.Advance(5*time.Second + time.Millisecond)
|
||||
inst, err = p.Get(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() error = %v", err)
|
||||
}
|
||||
if inst.State != provider.StateRunning {
|
||||
t.Errorf("State = %v, want Running after provisionDelay", inst.State)
|
||||
}
|
||||
if inst.IP == "" {
|
||||
t.Error("IP is empty, want a real address once Running")
|
||||
}
|
||||
if inst.UID != "uid-2" {
|
||||
t.Errorf("UID = %q, want %q", inst.UID, "uid-2")
|
||||
}
|
||||
|
||||
if err := p.Delete(ctx, id); err != nil {
|
||||
t.Fatalf("Delete() error = %v", err)
|
||||
}
|
||||
inst, err = p.Get(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() error = %v", err)
|
||||
}
|
||||
if inst.State != provider.StateTerminated {
|
||||
t.Errorf("State = %v, want Terminated immediately after Delete", inst.State)
|
||||
}
|
||||
|
||||
clock.Advance(time.Second + time.Millisecond)
|
||||
_, err = p.Get(ctx, id)
|
||||
if !errors.Is(err, provider.ErrNotFound) {
|
||||
t.Errorf("Get() error = %v, want ErrNotFound after deleteDelay", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Delete_idempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := newTestProvider(newFakeClock())
|
||||
ctx := context.Background()
|
||||
if err := p.Delete(ctx, "never-existed"); err != nil {
|
||||
t.Errorf("Delete() on unknown ID error = %v, want nil", err)
|
||||
}
|
||||
|
||||
id, _ := p.Create(ctx, provider.CreateRequest{Name: "proxy-del", UID: "uid-3", Port: freePort(t)})
|
||||
if err := p.Delete(ctx, id); err != nil {
|
||||
t.Fatalf("Delete() error = %v", err)
|
||||
}
|
||||
if err := p.Delete(ctx, id); err != nil {
|
||||
t.Errorf("Delete() (repeat) error = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_FaultInjection_configDriven(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
prov, err := New(ctx, provider.ProviderConfig{
|
||||
Name: "flaky",
|
||||
Type: "mock",
|
||||
Mock: &provider.MockConfig{FailNextCreates: 1, FailWith: provider.FailWithQuota},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
|
||||
_, err = prov.Create(ctx, provider.CreateRequest{Name: "proxy-1", UID: "uid-4", Port: freePort(t)})
|
||||
if !errors.Is(err, provider.ErrQuotaExceeded) {
|
||||
t.Fatalf("first Create() error = %v, want ErrQuotaExceeded", err)
|
||||
}
|
||||
|
||||
_, err = prov.Create(ctx, provider.CreateRequest{Name: "proxy-1", UID: "uid-4", Port: freePort(t)})
|
||||
if err != nil {
|
||||
t.Fatalf("second Create() error = %v, want nil (failure budget exhausted)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_InjectCreateFailures(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := newTestProvider(newFakeClock())
|
||||
ctx := context.Background()
|
||||
p.InjectCreateFailures(2, provider.ErrPermanent)
|
||||
|
||||
for i := range 2 {
|
||||
_, err := p.Create(ctx, provider.CreateRequest{Name: "proxy-fail", UID: "uid-5", Port: freePort(t)})
|
||||
if !errors.Is(err, provider.ErrPermanent) {
|
||||
t.Fatalf("Create() #%d error = %v, want ErrPermanent", i, err)
|
||||
}
|
||||
}
|
||||
_, err := p.Create(ctx, provider.CreateRequest{Name: "proxy-fail", UID: "uid-5", Port: freePort(t)})
|
||||
if err != nil {
|
||||
t.Fatalf("Create() after budget exhausted, error = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_New_unknownFailWith(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := New(context.Background(), provider.ProviderConfig{
|
||||
Name: "bad",
|
||||
Type: "mock",
|
||||
Mock: &provider.MockConfig{FailNextCreates: 1, FailWith: "oops"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("New() error = nil, want error for unknown failWith")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_appliesConfigOverrides(t *testing.T) {
|
||||
t.Parallel()
|
||||
prov, err := New(context.Background(), provider.ProviderConfig{
|
||||
Name: "custom",
|
||||
Type: "mock",
|
||||
Mock: &provider.MockConfig{ProvisionDelaySeconds: 30, DeleteDelaySeconds: 10},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
p := prov.(*Provider)
|
||||
if p.provisionDelay != 30*time.Second {
|
||||
t.Errorf("provisionDelay = %v, want 30s", p.provisionDelay)
|
||||
}
|
||||
if p.deleteDelay != 10*time.Second {
|
||||
t.Errorf("deleteDelay = %v, want 10s", p.deleteDelay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailClassFromString(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
in string
|
||||
want error
|
||||
wantErr bool
|
||||
}{
|
||||
{in: "", want: provider.ErrTransient},
|
||||
{in: provider.FailWithTransient, want: provider.ErrTransient},
|
||||
{in: provider.FailWithNotFound, want: provider.ErrNotFound},
|
||||
{in: provider.FailWithQuota, want: provider.ErrQuotaExceeded},
|
||||
{in: provider.FailWithPermanent, want: provider.ErrPermanent},
|
||||
{in: "bogus", wantErr: true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := failClassFromString(tc.in)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("failClassFromString() error = nil, want error")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("failClassFromString() error = %v, want nil", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("failClassFromString(%q) = %v, want %v", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_ListByTag_includesRunningAndPurgesExpired(t *testing.T) {
|
||||
t.Parallel()
|
||||
clock := newFakeClock()
|
||||
p := newTestProvider(clock)
|
||||
ctx := context.Background()
|
||||
|
||||
running, err := p.Create(ctx, provider.CreateRequest{Name: "proxy-running", UID: "uid-running", Port: freePort(t)})
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
expiring, err := p.Create(ctx, provider.CreateRequest{Name: "proxy-expiring", UID: "uid-expiring", Port: freePort(t)})
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
|
||||
clock.Advance(5*time.Second + time.Millisecond)
|
||||
if _, err := p.Get(ctx, running); err != nil {
|
||||
t.Fatalf("Get(running) error = %v", err)
|
||||
}
|
||||
if err := p.Delete(ctx, expiring); err != nil {
|
||||
t.Fatalf("Delete(expiring) error = %v", err)
|
||||
}
|
||||
clock.Advance(time.Second + time.Millisecond) // past deleteDelay
|
||||
|
||||
instances, err := p.ListByTag(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListByTag() error = %v", err)
|
||||
}
|
||||
if len(instances) != 1 {
|
||||
t.Fatalf("len(instances) = %d, want 1 (expired instance should be purged)", len(instances))
|
||||
}
|
||||
if instances[0].ID != running {
|
||||
t.Errorf("instances[0].ID = %q, want %q", instances[0].ID, running)
|
||||
}
|
||||
if instances[0].State != provider.StateRunning {
|
||||
t.Errorf("instances[0].State = %v, want Running", instances[0].State)
|
||||
}
|
||||
if instances[0].IP == "" {
|
||||
t.Error("instances[0].IP is empty, want a real address for a Running instance")
|
||||
}
|
||||
|
||||
if _, err := p.Get(ctx, expiring); !errors.Is(err, provider.ErrNotFound) {
|
||||
t.Errorf("Get(expiring) error = %v, want ErrNotFound (ListByTag should have purged it)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProvider_realProxyForwardsPlainHTTP covers the non-CONNECT path: a
|
||||
// probeURL override using plain http:// instead of the default https://
|
||||
// should also work, going through handleForward rather than handleConnect.
|
||||
func TestProvider_realProxyForwardsPlainHTTP(t *testing.T) {
|
||||
t.Parallel()
|
||||
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer origin.Close()
|
||||
|
||||
clock := newFakeClock()
|
||||
p := newTestProvider(clock)
|
||||
ctx := context.Background()
|
||||
port := freePort(t)
|
||||
|
||||
id, err := p.Create(ctx, provider.CreateRequest{Name: "proxy-forward", UID: "uid-forward", Port: port})
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
clock.Advance(5*time.Second + time.Millisecond)
|
||||
inst, err := p.Get(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() error = %v", err)
|
||||
}
|
||||
|
||||
proxyURL, err := url.Parse("http://" + net.JoinHostPort(inst.IP, strconv.Itoa(int(port))))
|
||||
if err != nil {
|
||||
t.Fatalf("parsing proxy URL: %v", err)
|
||||
}
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)},
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.Get(origin.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("GET through mock proxy failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProvider_realProxyTunnelsConnect is the load-bearing test for this
|
||||
// package's whole reason to exist: once an instance is Running, a real
|
||||
// http.Client using it as an HTTP proxy must genuinely tunnel a CONNECT
|
||||
// request to a real origin — the exact mechanism internal/health depends
|
||||
// on. This is not simulated; it opens real sockets.
|
||||
func TestProvider_realProxyTunnelsConnect(t *testing.T) {
|
||||
t.Parallel()
|
||||
origin := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer origin.Close()
|
||||
|
||||
clock := newFakeClock()
|
||||
p := newTestProvider(clock)
|
||||
ctx := context.Background()
|
||||
port := freePort(t)
|
||||
|
||||
id, err := p.Create(ctx, provider.CreateRequest{Name: "proxy-e2e", UID: "uid-6", Port: port})
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
clock.Advance(5*time.Second + time.Millisecond)
|
||||
|
||||
inst, err := p.Get(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() error = %v", err)
|
||||
}
|
||||
if inst.State != provider.StateRunning {
|
||||
t.Fatalf("State = %v, want Running", inst.State)
|
||||
}
|
||||
|
||||
proxyURL, err := url.Parse("http://" + net.JoinHostPort(inst.IP, strconv.Itoa(int(port))))
|
||||
if err != nil {
|
||||
t.Fatalf("parsing proxy URL: %v", err)
|
||||
}
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyURL(proxyURL),
|
||||
// origin is an httptest TLS server with a self-signed cert;
|
||||
// this test is about the CONNECT tunnel, not certificate
|
||||
// trust, so skip verification the same way httptest's own
|
||||
// .Client() helper would.
|
||||
TLSClientConfig: origin.Client().Transport.(*http.Transport).TLSClientConfig,
|
||||
},
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.Get(origin.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("GET through mock proxy failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user