1package webhook
2
3import (
4 "net"
5 "testing"
6)
7
8func TestValidateWebhookURL(t *testing.T) {
9 tests := []struct {
10 name string
11 url string
12 wantErr bool
13 errType error
14 skip string
15 }{
16 // Valid URLs (these will perform DNS lookups, so may fail in some environments)
17 {
18 name: "valid https URL",
19 url: "https://1.1.1.1/webhook",
20 wantErr: false,
21 },
22 {
23 name: "valid http URL",
24 url: "http://8.8.8.8/webhook",
25 wantErr: false,
26 },
27 {
28 name: "valid URL with port",
29 url: "https://1.1.1.1:8080/webhook",
30 wantErr: false,
31 },
32 {
33 name: "valid URL with path and query",
34 url: "https://8.8.8.8/webhook?token=abc123",
35 wantErr: false,
36 },
37
38 // Invalid schemes
39 {
40 name: "ftp scheme",
41 url: "ftp://example.com/webhook",
42 wantErr: true,
43 errType: ErrInvalidScheme,
44 },
45 {
46 name: "file scheme",
47 url: "file:///etc/passwd",
48 wantErr: true,
49 errType: ErrInvalidScheme,
50 },
51 {
52 name: "gopher scheme",
53 url: "gopher://example.com",
54 wantErr: true,
55 errType: ErrInvalidScheme,
56 },
57 {
58 name: "no scheme",
59 url: "example.com/webhook",
60 wantErr: true,
61 errType: ErrInvalidScheme,
62 },
63
64 // Localhost variations
65 {
66 name: "localhost",
67 url: "http://localhost/webhook",
68 wantErr: true,
69 errType: ErrPrivateIP,
70 },
71 {
72 name: "localhost with port",
73 url: "http://localhost:8080/webhook",
74 wantErr: true,
75 errType: ErrPrivateIP,
76 },
77 {
78 name: "localhost.localdomain",
79 url: "http://localhost.localdomain/webhook",
80 wantErr: true,
81 errType: ErrPrivateIP,
82 },
83
84 // Loopback IPs
85 {
86 name: "127.0.0.1",
87 url: "http://127.0.0.1/webhook",
88 wantErr: true,
89 errType: ErrPrivateIP,
90 },
91 {
92 name: "127.0.0.1 with port",
93 url: "http://127.0.0.1:8080/webhook",
94 wantErr: true,
95 errType: ErrPrivateIP,
96 },
97 {
98 name: "127.1.2.3",
99 url: "http://127.1.2.3/webhook",
100 wantErr: true,
101 errType: ErrPrivateIP,
102 },
103 {
104 name: "IPv6 loopback",
105 url: "http://[::1]/webhook",
106 wantErr: true,
107 errType: ErrPrivateIP,
108 },
109
110 // Private IPv4 ranges
111 {
112 name: "10.0.0.0",
113 url: "http://10.0.0.1/webhook",
114 wantErr: true,
115 errType: ErrPrivateIP,
116 },
117 {
118 name: "192.168.0.0",
119 url: "http://192.168.1.1/webhook",
120 wantErr: true,
121 errType: ErrPrivateIP,
122 },
123 {
124 name: "172.16.0.0",
125 url: "http://172.16.0.1/webhook",
126 wantErr: true,
127 errType: ErrPrivateIP,
128 },
129 {
130 name: "172.31.255.255",
131 url: "http://172.31.255.255/webhook",
132 wantErr: true,
133 errType: ErrPrivateIP,
134 },
135
136 // Link-local (AWS/GCP/Azure metadata)
137 {
138 name: "AWS metadata service",
139 url: "http://169.254.169.254/latest/meta-data/",
140 wantErr: true,
141 errType: ErrPrivateIP,
142 },
143 {
144 name: "link-local",
145 url: "http://169.254.1.1/webhook",
146 wantErr: true,
147 errType: ErrPrivateIP,
148 },
149
150 // Other reserved ranges
151 {
152 name: "0.0.0.0",
153 url: "http://0.0.0.0/webhook",
154 wantErr: true,
155 errType: ErrPrivateIP,
156 },
157 {
158 name: "broadcast",
159 url: "http://255.255.255.255/webhook",
160 wantErr: true,
161 errType: ErrPrivateIP,
162 },
163
164 // Invalid URLs
165 {
166 name: "empty URL",
167 url: "",
168 wantErr: true,
169 errType: ErrInvalidURL,
170 },
171 {
172 name: "missing hostname",
173 url: "http:///webhook",
174 wantErr: true,
175 errType: ErrInvalidURL,
176 },
177 }
178
179 for _, tt := range tests {
180 t.Run(tt.name, func(t *testing.T) {
181 if tt.skip != "" {
182 t.Skip(tt.skip)
183 }
184 err := ValidateWebhookURL(tt.url)
185 if (err != nil) != tt.wantErr {
186 t.Errorf("ValidateWebhookURL() error = %v, wantErr %v", err, tt.wantErr)
187 return
188 }
189 if tt.wantErr && tt.errType != nil {
190 if !isErrorType(err, tt.errType) {
191 t.Errorf("ValidateWebhookURL() error = %v, want error type %v", err, tt.errType)
192 }
193 }
194 })
195 }
196}
197
198func TestIsPrivateOrInternalIP(t *testing.T) {
199 tests := []struct {
200 name string
201 ip string
202 isPriv bool
203 }{
204 // Public IPs
205 {"Google DNS", "8.8.8.8", false},
206 {"Cloudflare DNS", "1.1.1.1", false},
207 {"Public IPv6", "2001:4860:4860::8888", false},
208
209 // Loopback
210 {"127.0.0.1", "127.0.0.1", true},
211 {"127.1.2.3", "127.1.2.3", true},
212 {"::1", "::1", true},
213
214 // Private ranges
215 {"10.0.0.1", "10.0.0.1", true},
216 {"192.168.1.1", "192.168.1.1", true},
217 {"172.16.0.1", "172.16.0.1", true},
218 {"172.31.255.255", "172.31.255.255", true},
219
220 // Link-local
221 {"169.254.169.254", "169.254.169.254", true},
222 {"169.254.1.1", "169.254.1.1", true},
223 {"fe80::1", "fe80::1", true},
224
225 // Other reserved
226 {"0.0.0.0", "0.0.0.0", true},
227 {"255.255.255.255", "255.255.255.255", true},
228 {"240.0.0.1", "240.0.0.1", true},
229
230 // Shared address space
231 {"100.64.0.1", "100.64.0.1", true},
232 {"100.127.255.255", "100.127.255.255", true},
233 }
234
235 for _, tt := range tests {
236 t.Run(tt.name, func(t *testing.T) {
237 ip := net.ParseIP(tt.ip)
238 if ip == nil {
239 t.Fatalf("Failed to parse IP: %s", tt.ip)
240 }
241 if got := isPrivateOrInternalIP(ip); got != tt.isPriv {
242 t.Errorf("isPrivateOrInternalIP(%s) = %v, want %v", tt.ip, got, tt.isPriv)
243 }
244 })
245 }
246}
247
248func TestIsLocalhost(t *testing.T) {
249 tests := []struct {
250 name string
251 hostname string
252 want bool
253 }{
254 {"localhost", "localhost", true},
255 {"LOCALHOST", "LOCALHOST", true},
256 {"localhost.localdomain", "localhost.localdomain", true},
257 {"test.localhost", "test.localhost", true},
258 {"example.com", "example.com", false},
259 {"localhos", "localhos", false},
260 {"localhost.com", "localhost.com", false},
261 }
262
263 for _, tt := range tests {
264 t.Run(tt.name, func(t *testing.T) {
265 if got := isLocalhost(tt.hostname); got != tt.want {
266 t.Errorf("isLocalhost(%s) = %v, want %v", tt.hostname, got, tt.want)
267 }
268 })
269 }
270}
271
272func TestValidateIPBeforeDial(t *testing.T) {
273 tests := []struct {
274 name string
275 ip string
276 wantErr bool
277 }{
278 {"public IP", "8.8.8.8", false},
279 {"private IP", "192.168.1.1", true},
280 {"loopback", "127.0.0.1", true},
281 {"link-local", "169.254.169.254", true},
282 }
283
284 for _, tt := range tests {
285 t.Run(tt.name, func(t *testing.T) {
286 ip := net.ParseIP(tt.ip)
287 if ip == nil {
288 t.Fatalf("Failed to parse IP: %s", tt.ip)
289 }
290 err := ValidateIPBeforeDial(ip)
291 if (err != nil) != tt.wantErr {
292 t.Errorf("ValidateIPBeforeDial(%s) error = %v, wantErr %v", tt.ip, err, tt.wantErr)
293 }
294 })
295 }
296}
297
298// isErrorType checks if err is or wraps errType.
299func isErrorType(err, errType error) bool {
300 if err == errType {
301 return true
302 }
303 // Check if err wraps errType
304 for err != nil {
305 if err == errType {
306 return true
307 }
308 unwrapped, ok := err.(interface{ Unwrap() error })
309 if !ok {
310 break
311 }
312 err = unwrapped.Unwrap()
313 }
314 return false
315}