package cli import ( "context" "errors" "fmt" "net" "net/http" "net/url" "sync/atomic" "syscall" "testing" "time" "github.com/Control-D-Inc/ctrld" "github.com/Control-D-Inc/ctrld/internal/controld" ) func TestContextFromStopCh(t *testing.T) { t.Run("cancels when stopCh closes", func(t *testing.T) { stopCh := make(chan struct{}) ctx, cancel := contextFromStopCh(stopCh) defer cancel() if ctx.Err() != nil { t.Fatalf("context cancelled before the stop request: %v", ctx.Err()) } close(stopCh) select { case <-ctx.Done(): case <-time.After(5 * time.Second): t.Fatal("context was not cancelled after stopCh closed") } if !errors.Is(ctx.Err(), context.Canceled) { t.Errorf("ctx.Err() = %v, want %v", ctx.Err(), context.Canceled) } }) t.Run("cancel releases the watcher", func(t *testing.T) { // stopCh is never closed: cancel() must still end the goroutine watching it. ctx, cancel := contextFromStopCh(make(chan struct{})) cancel() select { case <-ctx.Done(): case <-time.After(5 * time.Second): t.Fatal("context was not cancelled by cancel()") } }) t.Run("nil stopCh is usable", func(t *testing.T) { // Mobile callers have no stop channel; preflight must still run. ctx, cancel := contextFromStopCh(nil) defer cancel() if ctx.Err() != nil { t.Fatalf("context cancelled immediately: %v", ctx.Err()) } }) } // retryableNetworkErr is the shape processCDFlags treats as "retry with bootstrap // DNS": a url.Error wrapping a network failure. func retryableNetworkErr() error { return &url.Error{ Op: "Post", URL: "https://api.controld.com/utility", Err: &net.OpError{Op: "dial", Net: "tcp", Err: syscall.ECONNREFUSED}, } } func TestProcessCDFlagsStopsWhenCancelled(t *testing.T) { oldFetch := fetchResolverConfig oldUID := cdUID t.Cleanup(func() { fetchResolverConfig = oldFetch cdUID = oldUID }) cdUID = "testuid" var calls atomic.Int64 fetchResolverConfig = func(ctx context.Context, req *controld.ResolverConfigRequest, dev bool) (*controld.ResolverConfig, error) { calls.Add(1) return nil, retryableNetworkErr() } // A stop request arriving while the API is unreachable. Before this was // cancellable, the retry loop kept running after the service reported itself // stopped, which is what kept the incident's process alive and enforcing. stopCh := make(chan struct{}) ctx, cancel := contextFromStopCh(stopCh) defer cancel() done := make(chan error, 1) go func() { cfg := ctrld.Config{} _, err := processCDFlags(ctx, &cfg) done <- err }() // Let it fail at least once and settle into backoff before stopping. deadline := time.After(10 * time.Second) for calls.Load() == 0 { select { case <-deadline: t.Fatal("resolver config was never fetched") case err := <-done: t.Fatalf("processCDFlags returned before any fetch: %v", err) default: time.Sleep(5 * time.Millisecond) } } close(stopCh) select { case err := <-done: if !errors.Is(err, context.Canceled) { t.Errorf("processCDFlags err = %v, want it to report %v", err, context.Canceled) } case <-time.After(30 * time.Second): t.Fatal("processCDFlags did not return after the stop request") } } func TestProcessCDFlagsReturnsImmediatelyWhenAlreadyCancelled(t *testing.T) { oldFetch := fetchResolverConfig oldUID := cdUID t.Cleanup(func() { fetchResolverConfig = oldFetch cdUID = oldUID }) cdUID = "testuid" var calls atomic.Int64 fetchResolverConfig = func(ctx context.Context, req *controld.ResolverConfigRequest, dev bool) (*controld.ResolverConfig, error) { calls.Add(1) return nil, retryableNetworkErr() } ctx, cancel := context.WithCancel(context.Background()) cancel() cfg := ctrld.Config{} _, err := processCDFlags(ctx, &cfg) if !errors.Is(err, context.Canceled) { t.Errorf("processCDFlags err = %v, want %v", err, context.Canceled) } // One attempt is made before the loop notices; it must not retry past that. if got := calls.Load(); got > 1 { t.Errorf("fetched %d times with a cancelled context, want at most 1", got) } } // TestRunAPIPreflightClassification is the regression guard for classifying a preflight // failure as an operator stop. // // runAPIPreflight cancels the context it derived from stopCh. Sampling the stop state // from that context afterwards reports "stopped" unconditionally, because // context.CancelFunc sets ctx.Err() whether or not anyone asked to stop. run() then // takes the stop branch for every failure, which skips self-uninstalling a deleted // device, skips the mobile exit callback, and tells the service manager a failed start // was a clean exit. func TestRunAPIPreflightClassification(t *testing.T) { oldFetch := fetchResolverConfig oldUID := cdUID t.Cleanup(func() { fetchResolverConfig = oldFetch cdUID = oldUID }) cdUID = "testuid" // A deleted ControlD device: non-retryable, so preflight returns promptly. deletedDevice := func() error { e := &controld.ErrorResponse{} e.ErrorField.Code = controld.InvalidConfigCode e.ErrorField.Message = "device does not exist" return e } openCh := make(chan struct{}) closedCh := make(chan struct{}) close(closedCh) tests := []struct { name string stopCh <-chan struct{} fetchErr func() error wantStop bool }{ { // The P1: no stop was requested, so this must reach the failure branch. name: "api error with no stop request", stopCh: openCh, fetchErr: deletedDevice, }, { // Mobile passes no stop channel at all, so it could never have stopped. name: "api error with a nil stop channel", stopCh: nil, fetchErr: deletedDevice, }, { name: "stop requested during preflight", stopCh: closedCh, fetchErr: func() error { return retryableNetworkErr() }, wantStop: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { fetchResolverConfig = func(context.Context, *controld.ResolverConfigRequest, bool) (*controld.ResolverConfig, error) { return nil, tc.fetchErr() } cfg := ctrld.Config{} pf := runAPIPreflight(tc.stopCh, &cfg) if pf.err == nil { t.Fatal("expected preflight to fail") } if pf.stopRequested != tc.wantStop { t.Errorf("stopRequested = %v, want %v", pf.stopRequested, tc.wantStop) } }) } } // TestRunAPIPreflightPreservesAPIError verifies the error reaches the caller in a form // the failure branch can still act on: self-uninstall keys off an *ErrorResponse with // InvalidConfigCode, and it only runs if that error is both classified as a failure and // still unwrappable. func TestRunAPIPreflightPreservesAPIError(t *testing.T) { oldFetch := fetchResolverConfig oldUID := cdUID t.Cleanup(func() { fetchResolverConfig = oldFetch cdUID = oldUID }) cdUID = "testuid" want := &controld.ErrorResponse{} want.ErrorField.Code = controld.InvalidConfigCode fetchResolverConfig = func(context.Context, *controld.ResolverConfigRequest, bool) (*controld.ResolverConfig, error) { return nil, want } cfg := ctrld.Config{} pf := runAPIPreflight(make(chan struct{}), &cfg) if pf.stopRequested { t.Error("a device-deleted failure must not be reported as an operator stop") } var got *controld.ErrorResponse if !errors.As(pf.err, &got) { t.Fatalf("error no longer unwraps to *controld.ErrorResponse: %v", pf.err) } if got.ErrorField.Code != controld.InvalidConfigCode { t.Errorf("code = %d, want %d (self-uninstall would not trigger)", got.ErrorField.Code, controld.InvalidConfigCode) } } // TestPermanentAPIRejectionNarrowsToClientErrors is the regression guard for the clean // exit added above. // // controld builds an *ErrorResponse for any non-200 whose body decodes, so the Go type // says nothing about whether the API's answer will change on a retry. Keying the clean // exit off the type alone meant a 502 from a load balancer, or an API having a bad ten // minutes, stopped ctrld on every affected host with no service-manager retry behind it - // worse than the abnormal exit it replaced, because a Fatal at least gets restarted. // // Only a client-error status may take that path. func TestPermanentAPIRejectionNarrowsToClientErrors(t *testing.T) { rejection := func(status, code int) error { e := &controld.ErrorResponse{StatusCode: status} e.ErrorField.Code = code e.ErrorField.Message = "api said no" return e } tests := []struct { name string err error wantPermanent bool }{ { // The case the clean exit exists for: the device is gone, and every restart // will be told the same thing. name: "deleted device", err: rejection(http.StatusNotFound, controld.InvalidConfigCode), wantPermanent: true, }, {"revoked credentials", rejection(http.StatusUnauthorized, 0), true}, {"forbidden", rejection(http.StatusForbidden, 0), true}, {"malformed request", rejection(http.StatusBadRequest, 0), true}, // Server-side trouble. These must keep the abnormal exit so the service // manager's recovery policy retries. {"bad gateway", rejection(http.StatusBadGateway, 0), false}, {"internal error", rejection(http.StatusInternalServerError, 0), false}, {"service unavailable", rejection(http.StatusServiceUnavailable, 0), false}, // 4xx, but both are the API asking for a later attempt rather than refusing // this configuration. {"request timeout", rejection(http.StatusRequestTimeout, 0), false}, {"rate limited", rejection(http.StatusTooManyRequests, 0), false}, // An *ErrorResponse built without a recorded status carries no verdict. A // hand-constructed one, or a decode path that forgets to record the status, // must not silently gain the clean exit. {"no recorded status", rejection(0, controld.InvalidConfigCode), false}, // Not an API answer at all: the incident's denied socket reaches Fatal. {"network failure", retryableNetworkErr(), false}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got, ok := permanentAPIRejection(tc.err) if ok != tc.wantPermanent { t.Errorf("permanentAPIRejection() = %v, want %v", ok, tc.wantPermanent) } if ok && got == nil { t.Error("a permanent rejection must return the rejection for reporting") } }) } // The wrapped form matters too: preflight composes the fetch error, and errors.As has // to reach through that for either branch to be chosen correctly. wrapped := fmt.Errorf("processCDFlags: %w", rejection(http.StatusNotFound, controld.InvalidConfigCode)) if _, ok := permanentAPIRejection(wrapped); !ok { t.Error("a wrapped API rejection must still be recognised") } wrappedTransient := fmt.Errorf("processCDFlags: %w", rejection(http.StatusBadGateway, 0)) if _, ok := permanentAPIRejection(wrappedTransient); ok { t.Error("a wrapped 502 must not be treated as a permanent rejection") } } func TestStopRequested(t *testing.T) { closedCh := make(chan struct{}) close(closedCh) if stopRequested(nil) { t.Error("a nil stop channel must read as no stop (mobile passes none)") } if stopRequested(make(chan struct{})) { t.Error("an open stop channel must read as no stop") } if !stopRequested(closedCh) { t.Error("a closed stop channel must read as a stop") } } // TestReloadFetchIsBoundedByServiceLifetime covers the reload path's stop wiring. // // Reload fetches the ControlD config too, and it used to build the bounded context // itself. Nothing tested that: the wrong channel, or a dropped cancel, would have left a // reload retrying against an unreachable API after "Service stopped" was logged, and no // test would have failed. Both paths now go through one bounded fetch, so this pins it. func TestReloadFetchIsBoundedByServiceLifetime(t *testing.T) { original := processCDFlagsFn t.Cleanup(func() { processCDFlagsFn = original }) t.Run("a stop request cancels the reload fetch", func(t *testing.T) { stopCh := make(chan struct{}) close(stopCh) var sawCancelled bool processCDFlagsFn = func(ctx context.Context, _ *ctrld.Config) (*controld.ResolverConfig, error) { select { case <-ctx.Done(): sawCancelled = true case <-time.After(2 * time.Second): } return nil, ctx.Err() } p := &prog{stopCh: stopCh} if _, err := p.fetchCDConfigBoundedByLifetime(&ctrld.Config{}); !errors.Is(err, context.Canceled) { t.Errorf("reload fetch err = %v, want %v", err, context.Canceled) } if !sawCancelled { t.Error("the reload fetch did not observe the stop request: it is not bound to the service lifetime") } }) t.Run("the derived context is always released", func(t *testing.T) { // stopCh stays open: the fetch's own cancel is what must end the watcher, or // every reload leaks a goroutine. var captured context.Context processCDFlagsFn = func(ctx context.Context, _ *ctrld.Config) (*controld.ResolverConfig, error) { captured = ctx return nil, nil } p := &prog{stopCh: make(chan struct{})} if _, err := p.fetchCDConfigBoundedByLifetime(&ctrld.Config{}); err != nil { t.Fatalf("unexpected error: %v", err) } select { case <-captured.Done(): case <-time.After(time.Second): t.Error("the reload fetch left its context uncancelled") } }) }