master
go 422 lines 10.6 KB
Raw
1 // SPDX-License-Identifier: GPL-3.0-or-later
2
3 package web
4
5 import (
6 "encoding/base64"
7 "io"
8 "net/http"
9 "os"
10 "path/filepath"
11 "strings"
12 "testing"
13
14 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/require"
16 )
17
18 func TestRequest_Copy(t *testing.T) {
19 tests := map[string]struct {
20 orig RequestConfig
21 change func(req *RequestConfig)
22 verify func(t *testing.T, orig, copy RequestConfig)
23 }{
24 "change headers": {
25 orig: RequestConfig{
26 URL: "http://127.0.0.1:19999/api/v1/info",
27 Method: "POST",
28 Headers: map[string]string{
29 "X-Api-Key": "secret",
30 },
31 Username: "username",
32 Password: "password",
33 ProxyUsername: "proxy_username",
34 ProxyPassword: "proxy_password",
35 },
36 change: func(req *RequestConfig) {
37 req.Headers["header_key"] = "header_value"
38 },
39 verify: func(t *testing.T, orig, copy RequestConfig) {
40 assert.Equal(t, 1, len(orig.Headers))
41 assert.Equal(t, 2, len(copy.Headers))
42 },
43 },
44 "nil headers": {
45 orig: RequestConfig{
46 URL: "http://127.0.0.1:19999/api/v1/info",
47 },
48 change: func(req *RequestConfig) {
49 req.Headers = map[string]string{"new": "header"}
50 },
51 verify: func(t *testing.T, orig, copy RequestConfig) {
52 assert.Nil(t, orig.Headers)
53 assert.NotNil(t, copy.Headers)
54 },
55 },
56 "change URL": {
57 orig: RequestConfig{
58 URL: "http://example.com",
59 },
60 change: func(req *RequestConfig) {
61 req.URL = "http://changed.com"
62 },
63 verify: func(t *testing.T, orig, copy RequestConfig) {
64 assert.Equal(t, "http://example.com", orig.URL)
65 assert.Equal(t, "http://changed.com", copy.URL)
66 },
67 },
68 }
69
70 for name, test := range tests {
71 t.Run(name, func(t *testing.T) {
72 reqCopy := test.orig.Copy()
73
74 // Initial state should be equal
75 assert.Equal(t, test.orig, reqCopy)
76
77 // Apply changes
78 test.change(&reqCopy)
79
80 // Verify changes don't affect original
81 assert.NotEqual(t, test.orig, reqCopy)
82
83 // Run custom verification if provided
84 if test.verify != nil {
85 test.verify(t, test.orig, reqCopy)
86 }
87 })
88 }
89 }
90
91 func TestNewHTTPRequest(t *testing.T) {
92 // Create a temporary file for bearer token test
93 tmpDir := t.TempDir()
94 bearerTokenFile := filepath.Join(tmpDir, "token")
95 err := os.WriteFile(bearerTokenFile, []byte("test-bearer-token"), 0644)
96 require.NoError(t, err)
97
98 tests := map[string]struct {
99 req RequestConfig
100 validate func(t *testing.T, req *http.Request, cfg RequestConfig)
101 wantErr bool
102 errMsg string
103 }{
104 "empty config": {
105 req: RequestConfig{},
106 validate: func(t *testing.T, req *http.Request, cfg RequestConfig) {
107 assert.Equal(t, "GET", req.Method)
108 assert.Equal(t, "", req.URL.String())
109 assert.NotEmpty(t, req.Header.Get("User-Agent"))
110 },
111 },
112 "full config": {
113 req: RequestConfig{
114 URL: "http://127.0.0.1:19999/api/v1/info",
115 Method: "POST",
116 Body: "test body content",
117 Username: "user",
118 Password: "pass",
119 ProxyUsername: "proxy_user",
120 ProxyPassword: "proxy_pass",
121 Headers: map[string]string{
122 "X-Custom-Header": "custom-value",
123 "Content-Type": "application/json",
124 },
125 },
126 validate: func(t *testing.T, req *http.Request, cfg RequestConfig) {
127 assert.Equal(t, cfg.URL, req.URL.String())
128 assert.Equal(t, cfg.Method, req.Method)
129
130 // Check body
131 body, err := io.ReadAll(req.Body)
132 require.NoError(t, err)
133 assert.Equal(t, cfg.Body, string(body))
134
135 // Check basic auth
136 user, pass, ok := req.BasicAuth()
137 assert.True(t, ok)
138 assert.Equal(t, cfg.Username, user)
139 assert.Equal(t, cfg.Password, pass)
140
141 // Check proxy auth
142 proxyAuth := req.Header.Get("Proxy-Authorization")
143 assert.NotEmpty(t, proxyAuth)
144 proxyUser, proxyPass, ok := parseBasicAuth(proxyAuth)
145 assert.True(t, ok)
146 assert.Equal(t, cfg.ProxyUsername, proxyUser)
147 assert.Equal(t, cfg.ProxyPassword, proxyPass)
148
149 // Check headers
150 for k, v := range cfg.Headers {
151 assert.Equal(t, v, req.Header.Get(k))
152 }
153 },
154 },
155 "bearer token authentication": {
156 req: RequestConfig{
157 URL: "http://example.com",
158 BearerTokenFile: bearerTokenFile,
159 },
160 validate: func(t *testing.T, req *http.Request, cfg RequestConfig) {
161 auth := req.Header.Get("Authorization")
162 assert.Equal(t, "Bearer test-bearer-token", auth)
163 },
164 },
165 "bearer token file not found": {
166 req: RequestConfig{
167 URL: "http://example.com",
168 BearerTokenFile: "/non/existent/file",
169 },
170 wantErr: true,
171 errMsg: "bearer token file",
172 },
173 "bearer token takes precedence over basic auth": {
174 req: RequestConfig{
175 URL: "http://example.com",
176 Username: "user",
177 Password: "pass",
178 BearerTokenFile: bearerTokenFile,
179 },
180 validate: func(t *testing.T, req *http.Request, cfg RequestConfig) {
181 // Should have bearer token, not basic auth
182 auth := req.Header.Get("Authorization")
183 assert.Equal(t, "Bearer test-bearer-token", auth)
184
185 // Basic auth should not be set
186 _, _, ok := req.BasicAuth()
187 assert.False(t, ok)
188 },
189 },
190 "special headers - host lowercase": {
191 req: RequestConfig{
192 URL: "http://example.com",
193 Headers: map[string]string{
194 "host": "custom-host.com",
195 },
196 },
197 validate: func(t *testing.T, req *http.Request, cfg RequestConfig) {
198 assert.Equal(t, "custom-host.com", req.Host)
199 // host header should not be in req.Header
200 assert.Empty(t, req.Header.Get("host"))
201 },
202 },
203 "special headers - Host uppercase": {
204 req: RequestConfig{
205 URL: "http://example.com",
206 Headers: map[string]string{
207 "Host": "custom-host.com",
208 },
209 },
210 validate: func(t *testing.T, req *http.Request, cfg RequestConfig) {
211 assert.Equal(t, "custom-host.com", req.Host)
212 assert.Empty(t, req.Header.Get("Host"))
213 },
214 },
215 "proxy auth without username": {
216 req: RequestConfig{
217 URL: "http://example.com",
218 ProxyPassword: "proxy_pass",
219 },
220 validate: func(t *testing.T, req *http.Request, cfg RequestConfig) {
221 // Proxy auth should not be set if username is missing
222 assert.Empty(t, req.Header.Get("Proxy-Authorization"))
223 },
224 },
225 "invalid URL": {
226 req: RequestConfig{
227 URL: "://invalid-url",
228 },
229 wantErr: true,
230 },
231 "empty body": {
232 req: RequestConfig{
233 URL: "http://example.com",
234 Body: "",
235 },
236 validate: func(t *testing.T, req *http.Request, cfg RequestConfig) {
237 assert.Nil(t, req.Body)
238 },
239 },
240 "default GET method": {
241 req: RequestConfig{
242 URL: "http://example.com",
243 },
244 validate: func(t *testing.T, req *http.Request, cfg RequestConfig) {
245 assert.Equal(t, "GET", req.Method)
246 },
247 },
248 "custom method": {
249 req: RequestConfig{
250 URL: "http://example.com",
251 Method: "DELETE",
252 },
253 validate: func(t *testing.T, req *http.Request, cfg RequestConfig) {
254 assert.Equal(t, "DELETE", req.Method)
255 },
256 },
257 }
258
259 for name, test := range tests {
260 t.Run(name, func(t *testing.T) {
261 httpReq, err := NewHTTPRequest(test.req)
262
263 if test.wantErr {
264 assert.Error(t, err)
265 if test.errMsg != "" {
266 assert.Contains(t, err.Error(), test.errMsg)
267 }
268 assert.Nil(t, httpReq)
269 return
270 }
271
272 require.NoError(t, err)
273 require.NotNil(t, httpReq)
274
275 if test.validate != nil {
276 test.validate(t, httpReq, test.req)
277 }
278 })
279 }
280 }
281
282 func TestNewHTTPRequestWithPath(t *testing.T) {
283 tests := map[string]struct {
284 config RequestConfig
285 path string
286 wantURL string
287 wantErr bool
288 errMsg string
289 }{
290 "base url with path": {
291 config: RequestConfig{URL: "http://127.0.0.1:65535"},
292 path: "/bar",
293 wantURL: "http://127.0.0.1:65535/bar",
294 },
295 "url with trailing slash": {
296 config: RequestConfig{URL: "http://127.0.0.1:65535/"},
297 path: "bar",
298 wantURL: "http://127.0.0.1:65535/bar",
299 },
300 "url with path and trailing slash": {
301 config: RequestConfig{URL: "http://127.0.0.1:65535/foo/"},
302 path: "/bar",
303 wantURL: "http://127.0.0.1:65535/foo/bar",
304 },
305 "url with path no trailing slash": {
306 config: RequestConfig{URL: "http://127.0.0.1:65535/foo"},
307 path: "bar",
308 wantURL: "http://127.0.0.1:65535/foo/bar",
309 },
310 "empty path": {
311 config: RequestConfig{URL: "http://example.com"},
312 path: "",
313 wantURL: "http://example.com",
314 },
315 "path with query params": {
316 config: RequestConfig{URL: "http://example.com"},
317 path: "/path?key=value",
318 wantURL: "http://example.com/path%3Fkey=value", // url.JoinPath correctly escapes special chars
319 },
320 "complex path": {
321 config: RequestConfig{URL: "http://example.com/api/v1"},
322 path: "../v2/endpoint",
323 wantURL: "http://example.com/api/v2/endpoint",
324 },
325 "preserve headers": {
326 config: RequestConfig{
327 URL: "http://example.com",
328 Headers: map[string]string{
329 "X-Custom": "value",
330 },
331 },
332 path: "/test",
333 wantURL: "http://example.com/test",
334 },
335 "invalid base URL": {
336 config: RequestConfig{URL: "://invalid"},
337 path: "/path",
338 wantErr: true,
339 },
340 }
341
342 for name, test := range tests {
343 t.Run(name, func(t *testing.T) {
344 // Store original headers count
345 originalHeadersCount := len(test.config.Headers)
346
347 req, err := NewHTTPRequestWithPath(test.config, test.path)
348
349 if test.wantErr {
350 assert.Error(t, err)
351 if test.errMsg != "" {
352 assert.Contains(t, err.Error(), test.errMsg)
353 }
354 assert.Nil(t, req)
355 return
356 }
357
358 require.NoError(t, err)
359 require.NotNil(t, req)
360 assert.Equal(t, test.wantURL, req.URL.String())
361
362 // Verify original config wasn't modified
363 assert.Equal(t, originalHeadersCount, len(test.config.Headers))
364 })
365 }
366 }
367
368 func TestURLQuery(t *testing.T) {
369 tests := map[string]struct {
370 key string
371 value string
372 want string
373 }{
374 "simple query": {
375 key: "foo",
376 value: "bar",
377 want: "foo=bar",
378 },
379 "empty value": {
380 key: "key",
381 value: "",
382 want: "key=",
383 },
384 "special characters": {
385 key: "key",
386 value: "value with spaces & special=chars",
387 want: "key=value+with+spaces+%26+special%3Dchars",
388 },
389 "unicode": {
390 key: "name",
391 value: "测试",
392 want: "name=%E6%B5%8B%E8%AF%95",
393 },
394 }
395
396 for name, test := range tests {
397 t.Run(name, func(t *testing.T) {
398 got := URLQuery(test.key, test.value)
399 assert.Equal(t, test.want, got)
400 })
401 }
402 }
403
404 func parseBasicAuth(auth string) (username, password string, ok bool) {
405 const prefix = "Basic "
406 if len(auth) < len(prefix) || !strings.EqualFold(auth[:len(prefix)], prefix) {
407 return "", "", false
408 }
409
410 decoded, err := base64.StdEncoding.DecodeString(auth[len(prefix):])
411 if err != nil {
412 return "", "", false
413 }
414
415 decodedStr := string(decoded)
416 before, after, ok0 := strings.Cut(decodedStr, ":")
417 if !ok0 {
418 return "", "", false
419 }
420
421 return before, after, true
422 }