1package webhook23import (4 "net"5 "testing"6)78func TestValidateWebhookURL(t *testing.T) {9 tests := []struct {10 name string11 url string12 wantErr bool13 errType error14 skip string15 }{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 },3738 // Invalid schemes39 {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 },6364 // Localhost variations65 {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 },8384 // Loopback IPs85 {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 },109110 // Private IPv4 ranges111 {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 },135136 // 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 },149150 // Other reserved ranges151 {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 },163164 // Invalid URLs165 {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 }178179 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 return188 }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}197198func TestIsPrivateOrInternalIP(t *testing.T) {199 tests := []struct {200 name string201 ip string202 isPriv bool203 }{204 // Public IPs205 {"Google DNS", "8.8.8.8", false},206 {"Cloudflare DNS", "1.1.1.1", false},207 {"Public IPv6", "2001:4860:4860::8888", false},208209 // Loopback210 {"127.0.0.1", "127.0.0.1", true},211 {"127.1.2.3", "127.1.2.3", true},212 {"::1", "::1", true},213214 // Private ranges215 {"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},219220 // Link-local221 {"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},224225 // Other reserved226 {"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},229230 // Shared address space231 {"100.64.0.1", "100.64.0.1", true},232 {"100.127.255.255", "100.127.255.255", true},233 }234235 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}247248func TestIsLocalhost(t *testing.T) {249 tests := []struct {250 name string251 hostname string252 want bool253 }{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 }262263 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}271272func TestValidateIPBeforeDial(t *testing.T) {273 tests := []struct {274 name string275 ip string276 wantErr bool277 }{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 }283284 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}297298// isErrorType checks if err is or wraps errType.299func isErrorType(err, errType error) bool {300 if err == errType {301 return true302 }303 // Check if err wraps errType304 for err != nil {305 if err == errType {306 return true307 }308 unwrapped, ok := err.(interface{ Unwrap() error })309 if !ok {310 break311 }312 err = unwrapped.Unwrap()313 }314 return false315}