// SPDX-License-Identifier: GPL-2.0-or-later // Copyright (C) 2026 Bryan Joshua Pedini package main import ( "context" "encoding/json" "net" "net/http" "os" "path/filepath" "strings" "sync" "testing" "time" ) // fakeEngine is a minimal Docker Engine API served on a temporary unix socket type fakeEngine struct { mu sync.Mutex status int body string hang bool queries []string host string } func startFakeEngine(t *testing.T) *fakeEngine { t.Helper() // Keep the socket path short (unix socket paths are limited to ~108 bytes) dir, err := os.MkdirTemp("", "dps") if err != nil { t.Fatal(err) } t.Cleanup(func() { os.RemoveAll(dir) }) sock := filepath.Join(dir, "docker.sock") ln, err := net.Listen("unix", sock) if err != nil { t.Fatal(err) } f := &fakeEngine{status: http.StatusOK, body: "[]", host: "unix://" + sock} stop := make(chan struct{}) srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasSuffix(r.URL.Path, "/_ping") { w.Header().Set("Api-Version", "1.47") w.Header().Set("Ostype", "linux") w.WriteHeader(http.StatusOK) w.Write([]byte("OK")) return } if !strings.HasSuffix(r.URL.Path, "/containers/json") { http.NotFound(w, r) return } f.mu.Lock() f.queries = append(f.queries, r.URL.RawQuery) status, body, hang := f.status, f.body, f.hang f.mu.Unlock() if hang { select { case <-r.Context().Done(): case <-stop: } return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) w.Write([]byte(body)) })} go srv.Serve(ln) t.Cleanup(func() { close(stop) srv.Close() }) return f } func (f *fakeEngine) set(status int, body string, hang bool) { f.mu.Lock() defer f.mu.Unlock() f.status, f.body, f.hang = status, body, hang } func newTestDockerClient(t *testing.T, host string) *DockerClient { t.Helper() d, err := NewDockerClient(host, traceDiscard()) if err != nil { t.Fatal(err) } t.Cleanup(func() { d.Close() }) return d } func TestDockerClientSuccess(t *testing.T) { t.Parallel() engine := startFakeEngine(t) want := makeContainers(3) body, _ := json.Marshal(want) engine.set(http.StatusOK, string(body), false) log, buf := newTestLogger(LevelTrace) d, err := NewDockerClient(engine.host, log) if err != nil { t.Fatal(err) } defer d.Close() got, err := d.ListRunningContainers(context.Background()) if err != nil { t.Fatal(err) } if len(got) != 3 || got[0].ID != want[0].ID || got[2].Names[0] != "/c2" || got[1].Labels["index"] != "1" { t.Fatalf("got %+v", got) } // docker ps semantics: no all=1 engine.mu.Lock() queries := engine.queries engine.mu.Unlock() if len(queries) != 1 || strings.Contains(queries[0], "all") { t.Fatalf("queries = %q", queries) } if !strings.Contains(buf.String(), `msg="container listed" id=`+want[0].ID) || !strings.Contains(buf.String(), `func=DockerClient.ListRunningContainers`) { t.Fatalf("missing trace:\n%s", buf.String()) } } func TestDockerClientEmpty(t *testing.T) { t.Parallel() engine := startFakeEngine(t) d := newTestDockerClient(t, engine.host) for _, body := range []string{"[]", "null"} { engine.set(http.StatusOK, body, false) got, err := d.ListRunningContainers(context.Background()) if err != nil { t.Fatalf("%s: %v", body, err) } if got == nil || len(got) != 0 { t.Fatalf("%s: expected empty non-nil slice, got %#v", body, got) } b, _ := json.Marshal(got) if string(b) != "[]" { t.Fatalf("%s: serialized as %s", body, b) } } } func TestDockerClientDaemonError(t *testing.T) { t.Parallel() engine := startFakeEngine(t) d := newTestDockerClient(t, engine.host) engine.set(http.StatusInternalServerError, `{"message":"daemon exploded"}`, false) if _, err := d.ListRunningContainers(context.Background()); err == nil || !strings.Contains(err.Error(), "daemon exploded") { t.Fatalf("got %v", err) } engine.set(http.StatusOK, `{not json`, false) if _, err := d.ListRunningContainers(context.Background()); err == nil { t.Fatal("expected decode error") } } func TestDockerClientTimeout(t *testing.T) { t.Parallel() engine := startFakeEngine(t) d := newTestDockerClient(t, engine.host) d.timeout = 100 * time.Millisecond engine.set(0, "", true) start := time.Now() _, err := d.ListRunningContainers(context.Background()) if err == nil { t.Fatal("expected timeout error") } if elapsed := time.Since(start); elapsed > 5*time.Second { t.Fatalf("timeout not applied: %v", elapsed) } if d := dockerListTimeout; d != 10*time.Second { t.Fatalf("production timeout = %v", d) } } func TestDockerClientMissingSocket(t *testing.T) { t.Parallel() // Creation is lazy: a missing socket only fails the request d := newTestDockerClient(t, "unix://"+filepath.Join(t.TempDir(), "absent.sock")) if _, err := d.ListRunningContainers(context.Background()); err == nil { t.Fatal("expected connection error") } } func TestNewDockerClientInvalidHost(t *testing.T) { t.Parallel() if _, err := NewDockerClient("not a host", traceDiscard()); err == nil || !strings.Contains(err.Error(), "create docker client") { t.Fatalf("got %v", err) } if dockerHost != "unix:///var/run/docker.sock" { t.Fatalf("dockerHost = %q", dockerHost) } }