local_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 106

↑↓ moveenter openesc close