1package remote
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "net/http"
8 "os"
9 "path/filepath"
10 "testing"
11 "time"
12
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/require"
15
16 "github.com/bnema/gordon/internal/adapters/localadmin"
17)
18
19func mustListenUnix(t *testing.T, dir string, handler http.Handler) string {
20 t.Helper()
21 ln, _, err := localadmin.Listen(dir)
22 require.NoError(t, err)
23
24 srv := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second}
25 go func() { _ = srv.Serve(ln) }()
26 t.Cleanup(func() { _ = srv.Close() })
27
28 return localadmin.SocketPath(dir)
29}
30
31func TestLocalAdminDiscoveryRejectsUnsafeSelectedCandidate(t *testing.T) {
32 unsafeDir := t.TempDir()
33 goodDir := t.TempDir()
34
35 unsafeLn, _, err := localadmin.Listen(unsafeDir)
36 require.NoError(t, err)
37 t.Cleanup(func() { _ = unsafeLn.Close() })
38 require.NoError(t, os.Chmod(localadmin.SocketPath(unsafeDir), 0o666))
39
40 goodLn, _, err := localadmin.Listen(goodDir)
41 require.NoError(t, err)
42 t.Cleanup(func() { _ = goodLn.Close() })
43
44 missingDir := t.TempDir()
45
46 _, err = DiscoverLocalSocketIn([]string{missingDir, unsafeDir, goodDir})
47 require.Error(t, err)
48 assert.ErrorIs(t, err, ErrDaemonUnavailable)
49 assert.ErrorIs(t, err, localadmin.ErrUnsafePath)
50}
51
52func TestLocalAdminDiscoveryReportsUnavailable(t *testing.T) {
53 dir := t.TempDir()
54 _, err := DiscoverLocalSocketIn([]string{dir})
55 require.Error(t, err)
56 assert.ErrorIs(t, err, ErrDaemonUnavailable)
57}
58
59func TestLocalAdminDiscoveryUsesXDGCandidates(t *testing.T) {
60 xdg := t.TempDir()
61 t.Setenv("XDG_RUNTIME_DIR", xdg)
62
63 ln, _, err := localadmin.Listen(filepath.Join(xdg, "gordon"))
64 require.NoError(t, err)
65 t.Cleanup(func() { _ = ln.Close() })
66
67 got, err := DiscoverLocalSocket()
68 require.NoError(t, err)
69 assert.Equal(t, localadmin.SocketPath(filepath.Join(xdg, "gordon")), got)
70}
71
72func TestLocalAdminClientFailsWithoutSocket(t *testing.T) {
73 dirs := []string{t.TempDir(), t.TempDir()}
74 restore := localSocketDirs
75 localSocketDirs = func() []string { return dirs }
76 t.Cleanup(func() { localSocketDirs = restore })
77
78 _, err := NewLocalClient()
79 require.Error(t, err)
80 assert.ErrorIs(t, err, ErrDaemonUnavailable)
81}
82
83// TestLocalAdminClientSendsNoTokenAndNoExchange proves tokenless local
84// requests: no Authorization header and no /auth/token call.
85func TestLocalAdminClientSendsNoTokenAndNoExchange(t *testing.T) {
86 dir := t.TempDir()
87 type captured struct {
88 path string
89 auth string
90 method string
91 }
92 requests := make(chan captured, 4)
93
94 socketPath := mustListenUnix(t, dir, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
95 requests <- captured{path: r.URL.Path, auth: r.Header.Get("Authorization"), method: r.Method}
96 w.Header().Set("Content-Type", "application/json")
97 _ = json.NewEncoder(w).Encode(map[string]any{"apps": []any{}})
98 }))
99
100 client := NewLocalClientForSocket(socketPath)
101 apps, err := client.ListApps(context.Background())
102 require.NoError(t, err)
103 assert.Empty(t, apps)
104
105 select {
106 case got := <-requests:
107 assert.Equal(t, "/admin/apps", got.path)
108 assert.Empty(t, got.auth)
109 case <-time.After(2 * time.Second):
110 t.Fatal("no request captured")
111 }
112
113 select {
114 case extra := <-requests:
115 t.Fatalf("unexpected extra request: %+v", extra)
116 case <-time.After(100 * time.Millisecond):
117 }
118}
119
120func TestLocalAdminClientIgnoresEnvironmentProxy(t *testing.T) {
121 dir := t.TempDir()
122 requests := make(chan string, 1)
123
124 socketPath := mustListenUnix(t, dir, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
125 requests <- r.URL.Path
126 w.Header().Set("Content-Type", "application/json")
127 _ = json.NewEncoder(w).Encode(map[string]any{"apps": []any{}})
128 }))
129
130 t.Setenv("HTTP_PROXY", "http://127.0.0.1:1")
131 t.Setenv("HTTPS_PROXY", "http://127.0.0.1:1")
132
133 client := NewLocalClientForSocket(socketPath)
134 _, err := client.ListApps(context.Background())
135 require.NoError(t, err)
136
137 select {
138 case got := <-requests:
139 assert.Equal(t, "/admin/apps", got)
140 case <-time.After(2 * time.Second):
141 t.Fatal("no request captured")
142 }
143}
144
145func TestLocalAdminClientRejectsRedirects(t *testing.T) {
146 dir := t.TempDir()
147 socketPath := mustListenUnix(t, dir, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
148 if r.URL.Path == "/admin/apps" {
149 http.Redirect(w, r, "http://evil.example/admin/apps", http.StatusFound)
150 return
151 }
152 w.WriteHeader(http.StatusTeapot)
153 }))
154
155 client := NewLocalClientForSocket(socketPath)
156 _, err := client.ListApps(context.Background())
157 require.Error(t, err)
158 assert.Contains(t, err.Error(), "redirect")
159}
160
161func TestLocalAdminClientRespectsCancellation(t *testing.T) {
162 dir := t.TempDir()
163 release := make(chan struct{})
164 socketPath := mustListenUnix(t, dir, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
165 <-release
166 }))
167
168 ctx, cancel := context.WithCancel(context.Background())
169 done := make(chan error, 1)
170 go func() {
171 client := NewLocalClientForSocket(socketPath)
172 _, err := client.ListApps(ctx)
173 done <- err
174 }()
175
176 cancel()
177 select {
178 case err := <-done:
179 require.Error(t, err)
180 assert.True(t, errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded), "got %v", err)
181 case <-time.After(3 * time.Second):
182 t.Fatal("request did not honor cancellation")
183 }
184 close(release)
185}
186
187func TestLocalAdminClientFailsOnMissingSocket(t *testing.T) {
188 client := NewLocalClientForSocket(filepath.Join(t.TempDir(), "absent.sock"))
189 _, err := client.ListApps(context.Background())
190 require.Error(t, err)
191
192 var transportErr *requestTransportError
193 assert.True(t, errors.As(err, &transportErr), "expected transport error, got %T: %v", err, err)
194}
195
196func TestLocalAdminTransportDialsUnixOnly(t *testing.T) {
197 dir := t.TempDir()
198 socketPath := mustListenUnix(t, dir, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
199 w.WriteHeader(http.StatusNoContent)
200 }))
201
202 // The synthetic base URL host must never be resolved: only the socket is dialed.
203 client := NewLocalClientForSocket(socketPath)
204 req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, LocalBaseURL+"/admin/apps", nil)
205 require.NoError(t, err)
206 resp, err := client.httpClient.Do(req)
207 require.NoError(t, err)
208 defer resp.Body.Close()
209 assert.Equal(t, http.StatusNoContent, resp.StatusCode)
210}