validator_test.go

  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}