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) } }