package http import ( "context" "crypto/tls" "crypto/x509" "errors" "io" "net" "net/http" "net/http/httptest" "net/url" "os" "strings" "syscall" "testing" "internetpressure.io/probe-agent/internal/protocol" ) type timeoutErr struct{} func (timeoutErr) Error() string { return "i/o timeout" } func (timeoutErr) Timeout() bool { return true } func (timeoutErr) Temporary() bool { return true } func TestClassify(t *testing.T) { wrap := func(e error) error { return &url.Error{Op: "Get", URL: "https://x/", Err: e} } cases := []struct { name string err error st stage want string }{ {"dns", wrap(&net.DNSError{Err: "no such host", IsNotFound: true}), stageDNS, protocol.ErrDNSFail}, {"dns timeout", wrap(&net.DNSError{Err: "timeout", IsTimeout: true}), stageDNS, protocol.ErrDNSFail}, {"refused", wrap(&net.OpError{Op: "dial", Err: &os.SyscallError{Syscall: "connect", Err: syscall.ECONNREFUSED}}), stageConnect, protocol.ErrTCPRefused}, {"connect timeout", wrap(timeoutErr{}), stageConnect, protocol.ErrTCPTimeout}, {"tls timeout", wrap(timeoutErr{}), stageTLS, protocol.ErrTLSFail}, {"http timeout", wrap(context.DeadlineExceeded), stageResponse, protocol.ErrHTTPTimeout}, {"client timeout string", wrap(errors.New("net/http: request canceled (Client.Timeout exceeded while awaiting headers)")), stageResponse, protocol.ErrHTTPTimeout}, {"reset early", wrap(syscall.ECONNRESET), stageConnect, protocol.ErrTCPReset}, {"reset late", wrap(syscall.ECONNRESET), stageResponse, protocol.ErrReset}, {"cert unknown authority", wrap(x509.UnknownAuthorityError{}), stageTLS, protocol.ErrTLSCert}, {"cert hostname", wrap(x509.HostnameError{Host: "x"}), stageTLS, protocol.ErrTLSCert}, {"cert verification", wrap(&tls.CertificateVerificationError{Err: errors.New("expired")}), stageTLS, protocol.ErrTLSCert}, {"tls record", wrap(tls.RecordHeaderError{Msg: "first record does not look like a TLS handshake"}), stageTLS, protocol.ErrTLSFail}, {"tls alert", wrap(tls.AlertError(40)), stageTLS, protocol.ErrTLSFail}, {"eof during tls", wrap(io.EOF), stageTLS, protocol.ErrTLSFail}, {"eof during response", wrap(io.ErrUnexpectedEOF), stageResponse, protocol.ErrReset}, {"unknown", wrap(errors.New("something odd")), stageResponse, protocol.ErrOther}, {"nil", nil, stageResponse, ""}, } for _, c := range cases { if got := classify(c.err, c.st); got != c.want { t.Errorf("%s: got %q want %q", c.name, got, c.want) } } } // End-to-end against a local TLS server: status codes, no-redirect, body cap and cert error mapping. func TestRunAgainstLocalServer(t *testing.T) { big := strings.Repeat("x", 1<<20) srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !strings.HasPrefix(r.UserAgent(), "InternetPressureProbe/") { t.Errorf("unexpected UA %q", r.UserAgent()) } switch r.URL.Path { case "/redirect": http.Redirect(w, r, "/elsewhere", http.StatusFound) case "/boom": w.WriteHeader(503) case "/missing": w.WriteHeader(404) default: w.WriteHeader(200) io.WriteString(w, big) } })) srv.EnableHTTP2 = true srv.StartTLS() defer srv.Close() u, _ := url.Parse(srv.URL) host, port, _ := net.SplitHostPort(u.Host) c := &Checker{UserAgent: "InternetPressureProbe/test (+https://www.internetpressure.io/probes)"} // 1. Untrusted certificate → tls_cert, not ok. target := protocol.Target{TargetID: "local", Hostname: host, URL: srv.URL + "/", Port: 443} m := c.Run(context.Background(), target) if m.OK || m.Error != protocol.ErrTLSCert { t.Fatalf("expected tls_cert, got ok=%v error=%q", m.OK, m.Error) } if m.TCPMs == nil || m.TotalMs == nil { t.Fatalf("tcp/total should be measured even when TLS fails: %+v", m) } // Trust the test CA for the remaining cases by swapping the run function's TLS root via a helper. pool := x509.NewCertPool() pool.AddCert(srv.Certificate()) run := func(path string) protocol.Measurement { return runWithRoots(c, protocol.Target{TargetID: "local", Hostname: host, URL: srv.URL + path, Port: atoi(port)}, pool) } m = run("/") if !m.OK || m.Error != "" || m.HTTPStatus == nil || *m.HTTPStatus != 200 { t.Fatalf("200: %+v", m) } if m.HTTPProto != "HTTP/2.0" { t.Errorf("expected HTTP/2.0, got %q", m.HTTPProto) } if m.TLSVersion != "TLS1.3" { t.Errorf("expected TLS1.3, got %q", m.TLSVersion) } if m.TCPMs == nil || m.TLSMs == nil || m.TTFBMs == nil || m.TotalMs == nil || m.ResolvedIP != host { t.Errorf("timings incomplete: %+v", m) } if *m.TotalMs > 5000 { t.Errorf("body cap not applied, total=%v ms", *m.TotalMs) } if m = run("/redirect"); !m.OK || *m.HTTPStatus != 302 { t.Errorf("redirect must not be followed: %+v", m) } if m = run("/boom"); m.OK || m.Error != protocol.ErrHTTP5xx || *m.HTTPStatus != 503 { t.Errorf("5xx: %+v", m) } if m = run("/missing"); !m.OK || m.Error != protocol.ErrHTTP4xx { t.Errorf("4xx should be ok with http_4xx code: %+v", m) } // Connection refused. srv2 := httptest.NewServer(http.NotFoundHandler()) refusedURL := srv2.URL srv2.Close() m = c.Run(context.Background(), protocol.Target{TargetID: "dead", Hostname: host, URL: refusedURL + "/"}) if m.OK || m.Error != protocol.ErrTCPRefused { t.Errorf("refused: %+v", m) } } func atoi(s string) int { n := 0 for _, r := range s { n = n*10 + int(r-'0') } return n }