1package cli
2
3import (
4 "context"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 "testing"
9 "time"
10
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/require"
13
14 "github.com/bnema/gordon/internal/adapters/dto"
15 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
16 "github.com/bnema/gordon/internal/adapters/localadmin"
17)
18
19// appPlaneFixtureHandler serves the same app DTO payloads a daemon would,
20// independent of transport, so local and remote seams can be compared.
21func appPlaneFixtureHandler(t *testing.T) http.Handler {
22 t.Helper()
23 mux := http.NewServeMux()
24 mux.HandleFunc("/admin/apps", func(w http.ResponseWriter, _ *http.Request) {
25 writeFixtureJSON(t, w, map[string]any{
26 "apps": []dto.AppSummaryDTO{{App: "blog", Desired: "rev-2", Active: "rev-1", Converged: false}},
27 })
28 })
29 mux.HandleFunc("/admin/apps/blog", func(w http.ResponseWriter, _ *http.Request) {
30 writeFixtureJSON(t, w, dto.AppShowResponse{
31 App: "blog",
32 Desired: dto.AppDesiredDTO{Revision: "rev-2", Status: "pending"},
33 Active: dto.AppActiveDTO{
34 Converged: true,
35 Services: map[string]dto.AppActiveServiceDTO{
36 "web": {EffectiveRevision: "rev-1", Container: "ctr-web"},
37 },
38 },
39 })
40 })
41 mux.HandleFunc("/admin/apps/blog/diff", func(w http.ResponseWriter, _ *http.Request) {
42 writeFixtureJSON(t, w, dto.AppDiffResponse{App: "blog"})
43 })
44 mux.HandleFunc("/admin/apps/apply", func(w http.ResponseWriter, r *http.Request) {
45 if r.Method != http.MethodPost {
46 w.WriteHeader(http.StatusMethodNotAllowed)
47 return
48 }
49 writeFixtureJSON(t, w, dto.AppApplyResponse{App: "blog", ResultingRevision: "rev-3"})
50 })
51 return mux
52}
53
54func writeFixtureJSON(t *testing.T, w http.ResponseWriter, payload any) {
55 t.Helper()
56 w.Header().Set("Content-Type", "application/json")
57 require.NoError(t, json.NewEncoder(w).Encode(payload))
58}
59
60// startUnixAppPlane serves the fixture over an owner-only Unix socket.
61func startUnixAppPlane(t *testing.T) string {
62 t.Helper()
63 dir := t.TempDir()
64 ln, _, err := localadmin.Listen(dir)
65 require.NoError(t, err)
66
67 srv := &http.Server{Handler: appPlaneFixtureHandler(t), ReadHeaderTimeout: 5 * time.Second}
68 go func() { _ = srv.Serve(ln) }()
69 t.Cleanup(func() { _ = srv.Close() })
70
71 return localadmin.SocketPath(dir)
72}
73
74// withRemoteTarget pins the CLI remote flags and clears the environment.
75func withRemoteTarget(t *testing.T, target string) {
76 t.Helper()
77 origRemote, origToken, origInsecure := remoteFlag, tokenFlag, insecureTLSFlag
78 t.Cleanup(func() { remoteFlag, tokenFlag, insecureTLSFlag = origRemote, origToken, origInsecure })
79 remoteFlag, tokenFlag, insecureTLSFlag = target, "", false
80
81 t.Setenv("GORDON_REMOTE", "")
82 t.Setenv("GORDON_TOKEN", "")
83 t.Setenv("XDG_CONFIG_HOME", t.TempDir())
84}
85
86func stubLocalAppClient(t *testing.T, fn func() (*remote.Client, error)) {
87 t.Helper()
88 restore := newLocalAppClient
89 newLocalAppClient = fn
90 t.Cleanup(func() { newLocalAppClient = restore })
91}
92
93func TestResolveAppPlane_UsesLocalAdminSocket(t *testing.T) {
94 socketPath := startUnixAppPlane(t)
95 withRemoteTarget(t, "")
96 stubLocalAppClient(t, func() (*remote.Client, error) {
97 return remote.NewLocalClientForSocket(socketPath), nil
98 })
99
100 handle, err := resolveAppPlane()
101 require.NoError(t, err)
102 defer handle.close()
103 plane := handle.plane
104
105 apps, err := plane.ListApps(context.Background())
106 require.NoError(t, err)
107 require.Len(t, apps, 1)
108 assert.Equal(t, "blog", apps[0].App)
109
110 show, err := plane.ShowApp(context.Background(), "blog")
111 require.NoError(t, err)
112 assert.Equal(t, "rev-2", show.Desired.Revision)
113
114 diff, err := plane.DiffApp(context.Background(), "blog")
115 require.NoError(t, err)
116 assert.Equal(t, "blog", diff.App)
117
118 apply, err := plane.ApplyApp(context.Background(), dto.AppApplyRequest{ManifestTOML: "name = \"blog\"\n"})
119 require.NoError(t, err)
120 assert.Equal(t, "rev-3", apply.ResultingRevision)
121}
122
123// TestResolveAppControlPlane_LocalAndRemoteDTOParity asserts the shared DTO
124// seam yields identical values over both transports.
125func TestControlPlane_LocalAndRemoteDTOParity(t *testing.T) {
126 fixture := appPlaneFixtureHandler(t)
127
128 tcp := httptest.NewServer(fixture)
129 t.Cleanup(tcp.Close)
130
131 dir := t.TempDir()
132 ln, _, err := localadmin.Listen(dir)
133 require.NoError(t, err)
134 unixSrv := &http.Server{Handler: fixture, ReadHeaderTimeout: 5 * time.Second}
135 go func() { _ = unixSrv.Serve(ln) }()
136 t.Cleanup(func() { _ = unixSrv.Close() })
137
138 localPlane := remote.NewLocalClientForSocket(localadmin.SocketPath(dir))
139 remotePlane := remote.NewClient(tcp.URL)
140
141 ctx := context.Background()
142
143 localApps, err := localPlane.ListApps(ctx)
144 require.NoError(t, err)
145 remoteApps, err := remotePlane.ListApps(ctx)
146 require.NoError(t, err)
147 assert.Equal(t, remoteApps, localApps)
148
149 localShow, err := localPlane.ShowApp(ctx, "blog")
150 require.NoError(t, err)
151 remoteShow, err := remotePlane.ShowApp(ctx, "blog")
152 require.NoError(t, err)
153 assert.Equal(t, remoteShow, localShow)
154
155 localDiff, err := localPlane.DiffApp(ctx, "blog")
156 require.NoError(t, err)
157 remoteDiff, err := remotePlane.DiffApp(ctx, "blog")
158 require.NoError(t, err)
159 assert.Equal(t, remoteDiff, localDiff)
160}
161
162// TestResolveAppControlPlane_ExplicitRemoteNeverProbesLocal pins that an
163// explicit remote is authoritative: the local socket is never consulted, and
164// a remote failure never falls back to it.
165func TestResolveAppPlane_ExplicitRemoteNeverProbesLocal(t *testing.T) {
166 t.Run("remote succeeds", func(t *testing.T) {
167 tcp := httptest.NewServer(appPlaneFixtureHandler(t))
168 t.Cleanup(tcp.Close)
169 withRemoteTarget(t, tcp.URL)
170
171 probed := false
172 stubLocalAppClient(t, func() (*remote.Client, error) {
173 probed = true
174 return nil, remote.ErrDaemonUnavailable
175 })
176
177 handle, err := resolveAppPlane()
178 require.NoError(t, err)
179 defer handle.close()
180 plane := handle.plane
181
182 apps, err := plane.ListApps(context.Background())
183 require.NoError(t, err)
184 require.Len(t, apps, 1)
185 assert.False(t, probed, "local socket must not be probed when a remote is selected")
186 })
187
188 t.Run("remote fails without local fallback", func(t *testing.T) {
189 tcp := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
190 url := tcp.URL
191 tcp.Close()
192
193 withRemoteTarget(t, url)
194
195 probed := false
196 stubLocalAppClient(t, func() (*remote.Client, error) {
197 probed = true
198 return remote.NewLocalClientForSocket("/nonexistent/admin.sock"), nil
199 })
200
201 handle, err := resolveAppPlane()
202 require.NoError(t, err, "client construction succeeds; the failure must surface on the request")
203 defer handle.close()
204 plane := handle.plane
205
206 _, err = plane.ListApps(context.Background())
207 require.Error(t, err)
208 assert.False(t, probed, "a failed remote must never fall back to the local socket")
209 })
210}
211
212func TestResolveAppPlane_ReportsDaemonUnavailable(t *testing.T) {
213 withRemoteTarget(t, "")
214 stubLocalAppClient(t, func() (*remote.Client, error) {
215 return nil, remote.ErrDaemonUnavailable
216 })
217
218 _, err := resolveAppPlane()
219 require.Error(t, err)
220 assert.ErrorContains(t, err, "daemon-unavailable")
221 assert.ErrorIs(t, err, remote.ErrDaemonUnavailable)
222}