1package app
2
3import (
4 "context"
5 "io"
6 "net"
7 "net/http"
8 "net/http/httptest"
9 "os"
10 "path/filepath"
11 "testing"
12 "time"
13
14 "github.com/bnema/zerowrap"
15 "github.com/stretchr/testify/assert"
16 "github.com/stretchr/testify/mock"
17 "github.com/stretchr/testify/require"
18
19 adminhttp "github.com/bnema/gordon/internal/adapters/in/http/admin"
20 "github.com/bnema/gordon/internal/adapters/localadmin"
21 in "github.com/bnema/gordon/internal/boundaries/in"
22 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
23)
24
25// unixHTTPClient returns a client that dials the given Unix socket and never
26// consults environment proxies.
27func unixHTTPClient(socketPath string) *http.Client {
28 dialer := &net.Dialer{Timeout: 5 * time.Second}
29 return &http.Client{
30 Timeout: 10 * time.Second,
31 Transport: &http.Transport{
32 Proxy: nil,
33 DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
34 return dialer.DialContext(ctx, "unix", socketPath)
35 },
36 },
37 CheckRedirect: func(*http.Request, []*http.Request) error {
38 return http.ErrUseLastResponse
39 },
40 }
41}
42
43func localAdminTestServices(t *testing.T, appSvc in.AppService) *services {
44 t.Helper()
45 return &services{adminHandler: adminhttp.NewHandler(adminhttp.HandlerDeps{
46 AppSvc: appSvc,
47 Log: zerowrap.Default(),
48 })}
49}
50
51func TestLocalAdminSocketServesAppSurfaceOverUnix(t *testing.T) {
52 xdg := t.TempDir()
53 t.Setenv("XDG_RUNTIME_DIR", xdg)
54
55 appSvc := inmocks.NewMockAppService(t)
56 appSvc.EXPECT().List(mock.Anything).Return([]in.AppSummary(nil), nil).Once()
57
58 server, err := startLocalAdminServer(localAdminTestServices(t, appSvc), make(chan error, 4), zerowrap.Default())
59 require.NoError(t, err)
60 require.NotNil(t, server)
61
62 path := localadmin.SocketPath(filepath.Join(xdg, "gordon"))
63 require.NoError(t, localadmin.ValidateSocket(path))
64
65 client := unixHTTPClient(path)
66
67 resp, err := client.Get("http://gordon.local/admin/apps")
68 require.NoError(t, err)
69 body, err := io.ReadAll(resp.Body)
70 require.NoError(t, err)
71 resp.Body.Close()
72 require.Equal(t, http.StatusOK, resp.StatusCode, "body: %s", body)
73
74 resp, err = client.Get("http://gordon.local/admin/auth/tokens")
75 require.NoError(t, err)
76 resp.Body.Close()
77 assert.Equal(t, http.StatusForbidden, resp.StatusCode)
78
79 server.Close()
80 assert.NoFileExists(t, path)
81}
82
83func TestLocalAdminSocketTCPAdminStaysDisabled(t *testing.T) {
84 xdg := t.TempDir()
85 t.Setenv("XDG_RUNTIME_DIR", xdg)
86
87 cfg := Config{}
88 cfg.Auth.Enabled = false
89
90 svc := localAdminTestServices(t, nil)
91 registryHandler, _, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
92
93 // The TCP registry mux must not expose /admin/* in local-only mode.
94 for _, path := range []string{"/admin/status", "/admin/apps", "/admin/apps/apply"} {
95 req := httptest.NewRequest(http.MethodGet, path, nil)
96 req.RemoteAddr = "192.0.2.10:12345"
97 rec := httptest.NewRecorder()
98 registryHandler.ServeHTTP(rec, req)
99 require.Equal(t, http.StatusNotFound, rec.Code, "path %s", path)
100 }
101
102 // The owner-only socket does serve the app surface.
103 server, err := startLocalAdminServer(svc, make(chan error, 4), zerowrap.Default())
104 require.NoError(t, err)
105 t.Cleanup(server.Close)
106
107 path := localadmin.SocketPath(filepath.Join(xdg, "gordon"))
108 client := unixHTTPClient(path)
109 resp, err := client.Get("http://gordon.local/admin/apps")
110 require.NoError(t, err)
111 resp.Body.Close()
112 assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
113}
114
115func TestLocalAdminSocketCloseLeavesReplacementFile(t *testing.T) {
116 xdg := t.TempDir()
117 t.Setenv("XDG_RUNTIME_DIR", xdg)
118
119 server, err := startLocalAdminServer(localAdminTestServices(t, inmocks.NewMockAppService(t)), make(chan error, 4), zerowrap.Default())
120 require.NoError(t, err)
121
122 path := localadmin.SocketPath(filepath.Join(xdg, "gordon"))
123 require.NoError(t, os.Remove(path))
124 require.NoError(t, os.WriteFile(path, []byte("replacement"), 0o600))
125
126 server.Close()
127
128 content, err := os.ReadFile(path)
129 require.NoError(t, err)
130 assert.Equal(t, "replacement", string(content))
131
132 // Close is idempotent and must not remove the replacement on a second call.
133 server.Close()
134 assert.FileExists(t, path)
135}
136
137func TestStartLocalAdminServerSkipsWithoutAdminHandler(t *testing.T) {
138 server, err := startLocalAdminServer(&services{}, make(chan error, 4), zerowrap.Default())
139 require.NoError(t, err)
140 assert.Nil(t, server)
141 server.Close()
142}