diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index ed465cc0..60245b6e 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -37,5 +37,13 @@ jobs: with: ruby-version: ${{ matrix.ruby-version }} bundler-cache: true # runs 'bundle install' and caches installed gems automatically + - name: Set up Go + uses: actions/setup-go@v3 + with: + go-version: '1.21' + - name: Start test server + run: | + cd spec/server && go run main.go & + sleep 2 && curl -f http://localhost:3001/__identify__ || sleep 3 - name: Run tests run: bundle exec rake diff --git a/spec/server/main.go b/spec/server/main.go new file mode 100644 index 00000000..15485fe4 --- /dev/null +++ b/spec/server/main.go @@ -0,0 +1,414 @@ + +package main + +import ( + "bytes" + "compress/gzip" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/textproto" + "net/url" + "os" + "os/signal" + "strconv" + "strings" + "sync" + "syscall" + "time" +) + +const readyMsg = "Server ready" + +var ( + failCountMu sync.Mutex + failCount int +) + +func main() { + port := getenv("PORT", "3001") + verbose := os.Getenv("VERBOSE_SERVER") != "" + + mux := http.NewServeMux() + + // health + mux.HandleFunc("/__identify__", func(w http.ResponseWriter, r *http.Request) { + writeText(w, http.StatusOK, readyMsg) + }) + + // multipart file echo + mux.HandleFunc("/file", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + if err := r.ParseMultipartForm(64 << 20); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + f, fh, err := r.FormFile("file") + if err != nil { + http.Error(w, "missing multipart field 'file'", http.StatusBadRequest) + return + } + defer f.Close() + content, _ := io.ReadAll(f) + ct := contentTypeFromMultipartHeader(fh.Header) + resp := map[string]any{ + "content-type": ct, + "filename": fh.Filename, + "content": string(content), + "request-content-type": r.Header.Get("Content-Type"), + } + writeJSON(w, http.StatusOK, resp) + }) + + // multiple headers + mux.HandleFunc("/multiple-headers", func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Set-Cookie", "foo") + w.Header().Add("Set-Cookie", "bar") + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + }) + + // fail/:number + mux.HandleFunc("/fail/", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.NotFound(w, r) + return + } + numberStr := strings.TrimPrefix(r.URL.Path, "/fail/") + n, _ := strconv.Atoi(numberStr) + + failCountMu.Lock() + defer failCountMu.Unlock() + if failCount >= n { + writeText(w, http.StatusOK, "ok") + return + } + failCount++ + http.Error(w, "oh noes!", http.StatusInternalServerError) + }) + + // fail_forever + mux.HandleFunc("/fail_forever", func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "oh noes!", http.StatusInternalServerError) + }) + + // redirects + mux.HandleFunc("/redirect", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/", http.StatusFound) // 302 + }) + mux.HandleFunc("/bad_redirect", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/bad_redirect", http.StatusFound) + }) + + // basic auth path: /auth_basic/:username/:password + mux.HandleFunc("/auth_basic/", func(w http.ResponseWriter, r *http.Request) { + pathPart := strings.TrimPrefix(r.URL.Path, "/auth_basic/") + // Split only on the first slash to get username and password parts + parts := strings.SplitN(pathPart, "/", 2) + if len(parts) != 2 { + http.NotFound(w, r) + return + } + // URL decode the username and password from the path + wantU, err1 := url.QueryUnescape(parts[0]) + wantP, err2 := url.QueryUnescape(parts[1]) + if err1 != nil || err2 != nil { + http.NotFound(w, r) + return + } + u, p, ok := r.BasicAuth() + if ok && u == wantU && p == wantP { + writeText(w, http.StatusOK, "ok") + return + } + w.Header().Set("WWW-Authenticate", `Basic realm="Testing HTTP Auth"`) + http.Error(w, "Not authorized", http.StatusUnauthorized) + }) + + // ntlm “presence” check + mux.HandleFunc("/auth_ntlm", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("WWW-Authenticate", "NTLM") + if strings.HasPrefix(r.Header.Get("Authorization"), "NTLM") { + writeText(w, http.StatusOK, "ok") + return + } + http.Error(w, "Not authorized", http.StatusUnauthorized) + }) + + // gzipped + mux.HandleFunc("/gzipped", func(w http.ResponseWriter, r *http.Request) { + body := echoEnvJSON(r, true) + // Compress + var b strings.Builder + gz := gzip.NewWriter(&b) + _, _ = gz.Write([]byte(body)) + _ = gz.Close() + out := b.String() + + w.Header().Set("Content-Encoding", "gzip") + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Length", strconv.Itoa(len(out))) + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, out) + }) + + // catch-alls for /** (GET/HEAD/PUT/POST/DELETE/PATCH/OPTIONS/PURGE) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + applyDelay(r) + + switch r.Method { + case http.MethodHead: + // Return headers only (empty body) + js := echoEnvJSON(r, false) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Length", strconv.Itoa(len(js))) + w.WriteHeader(http.StatusOK) + return + + case http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch, http.MethodOptions: + respMap := envMap(r) + body, _ := io.ReadAll(r.Body) + _ = r.Body.Close() + respMap["body"] = string(body) + + // Handle multipart forms to extract filename + if isMultipartForm(r.Header.Get("Content-Type")) { + if filename := extractFilenameFromMultipart(string(body), r.Header.Get("Content-Type")); filename != "" { + respMap["filename"] = filename + } + } else if isFormURLEncoded(r.Header.Get("Content-Type")) { + // For urlencoded, include rack-like parsed form hash + formHash := parseFormLikeRack(string(body)) + if formHash != nil { + respMap["rack.request.form_hash"] = formHash + } + } + writeJSON(w, http.StatusOK, respMap) + + default: + // Support custom PURGE verb + if r.Method == "PURGE" { + respMap := envMap(r) + // rack does rewind; replicate by reading whole body + b, _ := io.ReadAll(r.Body) + _ = r.Body.Close() + respMap["body"] = string(b) + + // Handle multipart forms to extract filename + if isMultipartForm(r.Header.Get("Content-Type")) { + if filename := extractFilenameFromMultipart(string(b), r.Header.Get("Content-Type")); filename != "" { + respMap["filename"] = filename + } + } else if isFormURLEncoded(r.Header.Get("Content-Type")) { + formHash := parseFormLikeRack(string(b)) + if formHash != nil { + respMap["rack.request.form_hash"] = formHash + } + } + writeJSON(w, http.StatusOK, respMap) + return + } + http.NotFound(w, r) + } + }) + + srv := &http.Server{ + Addr: ":" + port, + Handler: logMiddleware(mux, verbose), + ReadHeaderTimeout: 10 * time.Second, + } + + // graceful shutdown + idle := make(chan struct{}) + go func() { + c := make(chan os.Signal, 1) + signal.Notify(c, syscall.SIGINT, syscall.SIGTERM) + <-c + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = srv.Shutdown(ctx) + close(idle) + }() + + log.Printf("ethon-test-server listening on :%s\n", port) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatal(err) + } + <-idle +} + +func getenv(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} + +func logMiddleware(next http.Handler, verbose bool) http.Handler { + if !verbose { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log.Printf("%s %s %s", r.RemoteAddr, r.Method, r.URL.String()) + next.ServeHTTP(w, r) + }) +} + +func writeText(w http.ResponseWriter, code int, s string) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("Content-Length", strconv.Itoa(len(s))) + w.WriteHeader(code) + _, _ = io.WriteString(w, s) +} + +func writeJSON(w http.ResponseWriter, code int, v any) { + // Use custom encoder to avoid escaping &, <, > like Ruby does + var buf bytes.Buffer + encoder := json.NewEncoder(&buf) + encoder.SetEscapeHTML(false) + _ = encoder.Encode(v) + b := bytes.TrimRight(buf.Bytes(), "\n") // Remove trailing newline from encoder + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Length", strconv.Itoa(len(b))) + w.WriteHeader(code) + _, _ = w.Write(b) +} + +func scheme(r *http.Request) string { + if r.TLS != nil { + return "https" + } + // libcurl hits us over http in tests + return "http" +} + +func envMap(r *http.Request) map[string]any { + m := map[string]any{ + "REQUEST_METHOD": r.Method, + "REQUEST_URI": fmt.Sprintf("%s://%s%s", scheme(r), r.Host, r.URL.RequestURI()), + } + if ct := r.Header.Get("Content-Type"); ct != "" { + m["CONTENT_TYPE"] = ct + } + // Rack-style HTTP_* headers + for k, vv := range r.Header { + hk := "HTTP_" + strings.ToUpper(strings.ReplaceAll(k, "-", "_")) + m[hk] = strings.Join(vv, ", ") + } + return m +} + +func echoEnvJSON(r *http.Request, includeBody bool) string { + m := envMap(r) + if includeBody { + b, _ := io.ReadAll(r.Body) + _ = r.Body.Close() + m["body"] = string(b) + if isFormURLEncoded(r.Header.Get("Content-Type")) { + formHash := parseFormLikeRack(string(b)) + if formHash != nil { + m["rack.request.form_hash"] = formHash + } + } + } + b, _ := json.Marshal(m) + return string(b) +} + +func isFormURLEncoded(ct string) bool { + ct = strings.ToLower(ct) + return strings.HasPrefix(ct, "application/x-www-form-urlencoded") +} + +func isMultipartForm(ct string) bool { + ct = strings.ToLower(ct) + return strings.HasPrefix(ct, "multipart/form-data") +} + +func extractFilenameFromMultipart(body, contentType string) string { + // Simple parsing to find filename + bodyLines := strings.Split(body, "\r\n") + for _, line := range bodyLines { + if strings.Contains(line, "Content-Disposition:") && strings.Contains(line, "filename=") { + // Extract filename from: Content-Disposition: form-data; name="file"; filename="custom_spec.rb" + parts := strings.Split(line, "filename=") + if len(parts) >= 2 { + filename := strings.Trim(parts[1], `"`) + // Remove any trailing parameters + if idx := strings.Index(filename, `"`); idx > 0 { + filename = filename[:idx] + } + return filename + } + } + } + return "" +} + +func parseFormLikeRack(body string) map[string]any { + // We can’t rely on r.ParseForm() after reading; emulate minimally: + // split on &, decode keys/values. + if body == "" { + return nil + } + out := map[string]any{} + pairs := strings.Split(body, "&") + for _, p := range pairs { + if p == "" { + continue + } + kv := strings.SplitN(p, "=", 2) + k := urlDecode(kv[0]) + v := "" + if len(kv) == 2 { + v = urlDecode(kv[1]) + } + // For tests we map single key to string (not array) + // If the key repeats we’ll overwrite; that’s fine for this suite + out[k] = v + } + return out +} + +func applyDelay(r *http.Request) { + if delayStr := r.URL.Query().Get("delay"); delayStr != "" { + if delay, err := strconv.Atoi(delayStr); err == nil && delay > 0 { + time.Sleep(time.Duration(delay) * time.Second) + } + } +} + +func urlDecode(s string) string { + // net/url is overkill; quick decode of + and %.. (enough for tests) + s = strings.ReplaceAll(s, "+", " ") + // Percent decoding + for { + i := strings.IndexByte(s, '%') + if i < 0 || i+2 >= len(s) { + break + } + h := s[i+1 : i+3] + b, err := strconv.ParseUint(h, 16, 8) + if err != nil { + break + } + s = s[:i] + string(byte(b)) + s[i+3:] + } + return s +} + +func contentTypeFromMultipartHeader(h textproto.MIMEHeader) string { + // Prefer explicit content-type of the part + if ct := h.Get("Content-Type"); ct != "" { + return ct + } + // Best-effort + return "application/octet-stream" +} diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 6bf08cc0..a4e4fd0c 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -7,17 +7,18 @@ require "ethon" require 'rspec' -require_relative 'support/localhost_server' -require_relative 'support/server' - -# Ethon.logger = Logger.new($stdout).tap do |log| -# log.level = Logger::DEBUG +puts `pwd` +# require_relative 'support/localhost_server' +# require_relative 'support/server' +# +# # Ethon.logger = Logger.new($stdout).tap do |log| +# # log.level = Logger::DEBUG +# # end +# +# RSpec.configure do |config| +# # config.order = :rand +# +# config.before(:suite) do +# LocalhostServer.new(TESTSERVER.new, 3001) +# end # end - -RSpec.configure do |config| - # config.order = :rand - - config.before(:suite) do - LocalhostServer.new(TESTSERVER.new, 3001) - end -end diff --git a/spec/support/localhost_server.rb b/spec/support/localhost_server.rb deleted file mode 100644 index 520a3b14..00000000 --- a/spec/support/localhost_server.rb +++ /dev/null @@ -1,109 +0,0 @@ -# frozen_string_literal: true -require 'rack' -require 'webrick' -require 'net/http' -require 'rackup' -require 'timeout' - -# The code for this is inspired by Capybara's server: -# http://github.com/jnicklas/capybara/blob/0.3.9/lib/capybara/server.rb -class LocalhostServer - READY_MESSAGE = "Server ready" - - # Fallback to older Rack handler if rackup is not available - RACKUP_HANDLER = - if defined?(Rackup::Handler) - Rackup::Handler::WEBrick - else - Rack::Handler::WEBrick - end - - class Identify - def initialize(app) - @app = app - end - - def call(env) - if env["PATH_INFO"] == "/__identify__" - [200, {}, [LocalhostServer::READY_MESSAGE]] - else - @app.call(env) - end - end - end - - attr_reader :port - - def initialize(rack_app, port = nil) - @port = port || find_available_port - @rack_app = rack_app - concurrently { boot } - wait_until(10, "Boot failed.") { booted? } - end - - private - - def find_available_port - server = TCPServer.new('127.0.0.1', 0) - server.addr[1] - ensure - server.close if server - end - - def boot - options = { :Port => port, :Host => 'localhost' } - # Use WEBrick for tests since it supports all HTTP methods including custom ones like PURGE - # Puma is stricter about HTTP methods and rejects custom methods with 501 "method not supported" - app = Identify.new(@rack_app) - - options.merge!(:AccessLog => [], :Logger => WEBrick::BasicLog.new(StringIO.new)) unless ENV['VERBOSE_SERVER'] - - RACKUP_HANDLER.run(app, **options) - end - - def booted? - res = ::Net::HTTP.get_response("127.0.0.1", '/__identify__', port) - if res.is_a?(::Net::HTTPSuccess) or res.is_a?(::Net::HTTPRedirection) - return res.body == READY_MESSAGE - end - rescue Errno::ECONNREFUSED, Errno::EBADF - return false - end - - def concurrently - if should_use_subprocess? - pid = Process.fork do - trap(:INT) { RACKUP_HANDLER.shutdown } - yield - exit # manually exit; otherwise this sub-process will re-run the specs that haven't run yet. - end - - at_exit do - Process.kill('INT', pid) - begin - Process.wait(pid) - rescue Errno::ECHILD - # ignore this error...I think it means the child process has already exited. - end - end - else - Thread.new { yield } - end - end - - def should_use_subprocess? - # !ENV['THREADED'] - false - end - - def wait_until(timeout, error_message, &block) - start_time = Time.now - - while true - return if yield - raise Timeout::Error.new(error_message) if (Time.now - start_time) > timeout - sleep(0.05) - end - end -end - diff --git a/spec/support/server.rb b/spec/support/server.rb deleted file mode 100644 index 94874067..00000000 --- a/spec/support/server.rb +++ /dev/null @@ -1,117 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true -require 'json' -require 'zlib' -require 'sinatra/base' - -TESTSERVER = Sinatra.new do - use Rack::RewindableInput::Middleware if defined?(Rack::RewindableInput::Middleware) - - set :logging, nil - - fail_count = 0 - - post '/file' do - { - 'content-type' => params[:file][:type], - 'filename' => params[:file][:filename], - 'content' => params[:file][:tempfile].read, - 'request-content-type' => request.env['CONTENT_TYPE'] - }.to_json - end - - get '/multiple-headers' do - [200, { 'Set-Cookie' => %w[ foo bar ], 'Content-Type' => 'text/plain' }, ['']] - end - - get '/fail/:number' do - if fail_count >= params[:number].to_i - "ok" - else - fail_count += 1 - error 500, "oh noes!" - end - end - - get '/fail_forever' do - error 500, "oh noes!" - end - - get '/redirect' do - redirect '/' - end - - post '/redirect' do - redirect '/' - end - - get '/bad_redirect' do - redirect '/bad_redirect' - end - - get '/auth_basic/:username/:password' do - @auth ||= Rack::Auth::Basic::Request.new(request.env) - # Check that we've got a basic auth, and that it's credentials match the ones - # provided in the request - if @auth.provided? && @auth.basic? && @auth.credentials == [ params[:username], params[:password] ] - # auth is valid - confirm it - true - else - # invalid auth - request the authentication - response['WWW-Authenticate'] = %(Basic realm="Testing HTTP Auth") - throw(:halt, [401, "Not authorized\n"]) - end - end - - get '/auth_ntlm' do - # we're just checking for the existence if NTLM auth header here. It's validation - # is too troublesome and really doesn't bother is much, it's up to libcurl to make - # it valid - response['WWW-Authenticate'] = 'NTLM' - is_ntlm_auth = /^NTLM/ =~ request.env['HTTP_AUTHORIZATION'] - true if is_ntlm_auth - throw(:halt, [401, "Not authorized\n"]) if !is_ntlm_auth - end - - get '/gzipped' do - req_env = request.env.to_json - z = Zlib::Deflate.new - gzipped_env = z.deflate(req_env, Zlib::FINISH) - z.close - response['Content-Encoding'] = 'gzip' - gzipped_env - end - - get '/**' do - sleep params["delay"].to_i if params.has_key?("delay") - request.env.merge!(:body => request.body.read).to_json - end - - head '/**' do - sleep params["delay"].to_i if params.has_key?("delay") - end - - put '/**' do - request.env.merge!(:body => request.body.read).to_json - end - - post '/**' do - request.env.merge!(:body => request.body.tap {|b| b.rewind }.read).to_json - end - - delete '/**' do - request.env.merge!(:body => request.body.read).to_json - end - - patch '/**' do - request.env.merge!(:body => request.body.read).to_json - end - - options '/**' do - request.env.merge!(:body => request.body.read).to_json - end - - route 'PURGE', '/**' do - request.env.merge!(:body => request.body.tap {|b| b.rewind }.read).to_json - end -end