diff --git a/CHANGES b/CHANGES index 420d9fc..85170e1 100644 --- a/CHANGES +++ b/CHANGES @@ -1,5 +1,8 @@ This file describes changes in the curlInterface package. +Next + - Make HTTP tests hermetic with a local IO-backed server + 2.4.4 (2026-07-13) - Remove dependency on GAPDoc diff --git a/PackageInfo.g b/PackageInfo.g index adf792d..7556e7c 100644 --- a/PackageInfo.g +++ b/PackageInfo.g @@ -108,6 +108,7 @@ Dependencies := rec( GAP := ">= 4.12", NeededOtherPackages := [ ], SuggestedOtherPackages := [ ], + TestPackages := [ [ "io", ">= 4.7.0" ] ], NeededSystemPackages := rec( Ubuntu := [["libcurl4-gnutls-dev"]], Homebrew := [["curl"]], diff --git a/tst/basic.tst b/tst/basic.tst index 2192237..32f2830 100644 --- a/tst/basic.tst +++ b/tst/basic.tst @@ -1,198 +1,113 @@ -gap> LoadPackage("curlInterface", false); +#@local r, url, postString, requestType, server, baseurl +gap> LoadPackage( "curlInterface", false ); true - -# Check HTTP -gap> r := DownloadURL("http://www.google.com");; -gap> SortedList(RecNames(r)); -[ "result", "success" ] -gap> r.success; +gap> LoadPackage( "io", false ); true -gap> PositionSublist(r.result, "google") <> fail; -true -gap> PositionSublist("google", r.result) <> fail; -false +gap> ReadPackage( "curlInterface", "tst/http-server.g" );; +gap> server := CURLINTERFACE_StartHTTPTestServer();; +gap> baseurl := Concatenation( "http://127.0.0.1:", +> String( server.port ) );; -# Sanity check the result without actually printing it -gap> PositionSublist(r.result, "google") <> fail; -true -gap> r.result[1]; -'<' - -# Check HTTPS -gap> r := DownloadURL("https://www.google.com");; -gap> SortedList(RecNames(r)); +# Successful GET +gap> r := DownloadURL( Concatenation( baseurl, "/success" ) );; +gap> SortedList( RecNames( r ) ); [ "result", "success" ] gap> r.success; true -gap> PositionSublist(r.result, "google") <> fail; -true -gap> PositionSublist("google", r.result) <> fail; -false +gap> r.result; +"download test response\n" -# Sanity check the result without actually printing it -gap> PositionSublist(r.result, "google") <> fail; -true -gap> r.result[1]; -'<' - -# Check FTP -# Removed since we do not officially support FTP and it may fail in Docker -#gap> r := DownloadURL("ftp://fra36-speedtest-1.tele2.net/1KB.zip");; -#gap> SortedList(RecNames(r)); -#[ "result", "success" ] -#gap> r.success; -#true -#gap> r.result = ListWithIdenticalEntries(1024, '\000'); -#true - -# Check bad URL -gap> r := DownloadURL("https://www.google.cheesebadger");; -gap> SortedList(RecNames(r)); -[ "error", "success" ] -gap> r.success; +# A URL that is a string but not in IsStringRep +gap> url := List( Concatenation( baseurl, "/success" ), x -> x );; +gap> IsStringRep( url ); false -gap> PositionSublist(r.error, "Could not resolve host") <> fail; -true +gap> DownloadURL( url ).result; +"download test response\n" -# Check another bad URL -gap> r := DownloadURL("https://www.gap-system.org/Packages/curlInterface.x");; +# Successful POST and exact body echo +gap> r := PostToURL( Concatenation( baseurl, "/post" ), +> "field1=true&field2=17" );; gap> r.success; true -gap> PositionSublist(r.result, "URL was not found") <> fail; -true -gap> r := DownloadURL("https://www.gap-system.org/Packages/curlInterface.x", -> rec(failOnError:= true));; -gap> r.success; -false -gap> PositionSublist(r.error, "404") <> fail; -true - -# Check successful POST requests -gap> r := PostToURL("httpbun.com/post", "field1=true&field2=17");; -gap> SortedList(RecNames(r)); -[ "result", "success" ] -gap> r.success; -true -gap> PositionSublist(r.result, "\"field1\": \"true\"") <> fail; -true -gap> PositionSublist(r.result, "\"field2\": \"17\"") <> fail; -true -gap> PositionSublist(r.result, "field3") <> fail; -false +gap> r.result; +"field1=true&field2=17" -# Check POST on a string with null characters -gap> r := PostToURL("httpbun.com/post", "field1=my\000first\000field");; -gap> SortedList(RecNames(r)); -[ "result", "success" ] +# POST preserves embedded NUL bytes +gap> postString := "field1=my\000first\000field";; +gap> r := PostToURL( Concatenation( baseurl, "/post" ), postString );; gap> r.success; true -gap> PositionSublist(r.result, "my\\u0000first\\u0000field") <> fail; +gap> r.result = postString; true -gap> PositionSublist(r.result, "field3") <> fail; -false -# Check POST method not allowed (405) -gap> r := PostToURL("www.google.com", "myfield=42");; -gap> SortedList(RecNames(r)); -[ "result", "success" ] -gap> r.success; -true -gap> PositionSublist(r.result, "myfield") <> fail; +# A POST body that is a string but not in IsStringRep +gap> postString := List( "animal=tiger&material=cotton", x -> x );; +gap> IsStringRep( postString ); false -gap> PositionSublist(r.result, "405") <> fail; +gap> r := PostToURL( Concatenation( baseurl, "/post" ), postString, +> rec( verifyCert := true ) );; +gap> r.result = postString; true -# Check bad URL with POST -gap> r := PostToURL("https://www.google.cheesebadger", "hello");; -gap> SortedList(RecNames(r)); -[ "error", "success" ] -gap> r.success; +# A request type that is a string but not in IsStringRep +gap> requestType := List( "GET", x -> x );; +gap> IsStringRep( requestType ); false -gap> PositionSublist(r.error, "Could not resolve host") <> fail; -true +gap> r := CurlRequest( Concatenation( baseurl, "/success" ), +> requestType, "" );; +gap> r.result; +"download test response\n" -# Check not IsStringRep (url) -gap> url := List("https://www.google.com", letter -> letter);; -gap> IsStringRep(url); -false -gap> r := DownloadURL(url);; -gap> SortedList(RecNames(r)); -[ "result", "success" ] -gap> r.success; -true -gap> PositionSublist(r.result, "google") <> fail; -true +# HEAD returns no body +gap> CurlRequest( Concatenation( baseurl, "/success" ), "HEAD", "" ); +rec( result := "", success := true ) -# Check not IsStringRep (post_string) -gap> post_string := List("animal=tiger&material=cotton", letter -> letter);; -gap> IsStringRep(post_string); -false -gap> r := PostToURL("httpbun.com/post", post_string, rec(verifyCert := true));; +# DELETE reaches the deterministic route +gap> r := DeleteURL( Concatenation( baseurl, "/delete" ) );; gap> r.success; true -gap> PositionSublist(r.result, "\"animal\": \"tiger\"") <> fail; -true -gap> PositionSublist(r.result, "\"material\": \"cotton\"") <> fail; -true -gap> PositionSublist(r.result, "lion") <> fail; -false +gap> r.result; +"delete test response\n" -# Check not IsStringRep (request type) -gap> r := CurlRequest("www.google.com", ['G', 'E', 'T'] , "");; -gap> r.success; +# Verbose output does not change the result +gap> r := DownloadURL( Concatenation( baseurl, "/success" ), +> rec( verbose := true ) );; +gap> r.success and r.result = "download test response\n"; true -gap> SortedList(RecNames(r)); -[ "result", "success" ] -gap> PositionSublist(r.result, "google") <> fail; -true -gap> PositionSublist(r.result, "tiger") <> fail; -false -# HEAD requests -gap> CurlRequest("www.google.com", "HEAD" , ""); -rec( result := "", success := true ) -gap> r := CurlRequest("www.google.cheesebadger", "HEAD" , "");; -gap> r.success; -false +# Redirects are followed by default and when explicitly enabled +gap> url := Concatenation( baseurl, "/redirect" );; +gap> DownloadURL( url ).result; +"download test response\n" +gap> DownloadURL( url, rec( followRedirect := true ) ).result; +"download test response\n" +gap> DownloadURL( url, rec( followRedirect := false ) ).result; +"redirect response\n" -# DELETE requests -gap> r := DeleteURL("https://www.google.com");; +# 404 responses and failOnError +gap> url := Concatenation( baseurl, "/missing" );; +gap> r := DownloadURL( url );; gap> r.success; true -gap> PositionSublist(r.result, "405 ") <> fail; -true -gap> PositionSublist(r.result, "tiger") <> fail; -false -gap> r := DeleteURL("httpbun.com/delete");; +gap> r.result; +"not found\n" +gap> r := DownloadURL( url, rec( failOnError := true ) );; gap> r.success; -true -gap> PositionSublist(r.result, "405 ") <> fail; false - -# Check verbose requests don't break anything (we can't catch the output here) -gap> r := DownloadURL("httpbun.com/get", rec(verbose := true));; -gap> r.success; -true -gap> PositionSublist(r.result, "httpbun") <> fail; +gap> PositionSublist( r.error, "404" ) <> fail; true -#gap> PositionSublist(r.result, "404 ") <> fail; -#false - -# Follow redirects -gap> url := "http://www.icm.tu-bs.de/ag_algebra/software/polycyclic";; -gap> r := DownloadURL(url);; -gap> PositionSublist(r.result, "GitHub Pages") <> fail; -true -gap> r := DownloadURL(url, rec(followRedirect := true));; -gap> PositionSublist(r.result, "GitHub Pages") <> fail; -true -gap> r := DownloadURL(url, rec(followRedirect := false));; -gap> PositionSublist(r.result, "GitHub Pages") <> fail; +# A local disconnect exercises the transport-error result shape +gap> r := DownloadURL( Concatenation( baseurl, "/disconnect" ) );; +gap> SortedList( RecNames( r ) ); +[ "error", "success" ] +gap> r.success; false -gap> PositionSublist(r.result, "301 ") <> fail; -true -# Check timeout works -gap> CurlRequest("www.google.com", "HEAD" , "", rec(maxTime := 1000000)); -rec( result := "", success := true ) +# Timeout failure and success +gap> url := Concatenation( baseurl, "/delay" );; +gap> DownloadURL( url, rec( maxTime := 1 ) ).success; +false +gap> DownloadURL( url, rec( maxTime := 5 ) ).result; +"download test response\n" +gap> CURLINTERFACE_StopHTTPTestServer( server );; diff --git a/tst/errors.tst b/tst/errors.tst index 2fd047a..54ec418 100644 --- a/tst/errors.tst +++ b/tst/errors.tst @@ -21,43 +21,44 @@ gap> PostToURL(42, "hello"); Error, CurlRequest: must be a string # request type not a string -gap> CurlRequest("www.google.com", 637, "hello", rec(verifyCert := true)); +gap> CurlRequest("http://127.0.0.1/", 637, "hello", rec(verifyCert := true)); Error, CurlRequest: must be a string # post_string not a string -gap> PostToURL("httpbun.com/post", 17); +gap> PostToURL("http://127.0.0.1/", 17); Error, CurlRequest: must be a string # invalid verifyCert -gap> DownloadURL("https://www.google.com", rec(verifyCert := "maybe")); +gap> DownloadURL("http://127.0.0.1/", rec(verifyCert := "maybe")); Error, CurlRequest: .verifyCert must be true or false # invalid verbose -gap> DownloadURL("https://www.google.com", rec(verbose := "yes")); +gap> DownloadURL("http://127.0.0.1/", rec(verbose := "yes")); Error, CurlRequest: .verbose must be true or false # invalid followRedirect -gap> DownloadURL("https://www.google.com", rec(followRedirect := "always")); +gap> DownloadURL("http://127.0.0.1/", rec(followRedirect := "always")); Error, CurlRequest: .followRedirect must be true or false # invalid failOnError -gap> DownloadURL("https://www.google.com", rec(failOnError := "yes")); +gap> DownloadURL("http://127.0.0.1/", rec(failOnError := "yes")); Error, CurlRequest: .failOnError must be true or false # invalid opts -gap> DownloadURL("https://www.google.com", "please verify the cert"); +gap> DownloadURL("http://127.0.0.1/", "please verify the cert"); Error, CurlRequest: must be a record # too many arguments -gap> CurlRequest("www.google.com", "GET", "", rec(verifyCert := true), 3, true); +gap> CurlRequest("http://127.0.0.1/", "GET", "", +> rec(verifyCert := true), 3, true); Error, CurlRequest: usage: requires 3 or 4 arguments, but 6 were given # invalid time -gap> CurlRequest("www.google.com", "GET", "", rec(maxTime := -1)); +gap> CurlRequest("http://127.0.0.1/", "GET", "", rec(maxTime := -1)); Error, CurlRequest: .maxTime must be a non-negative integer # invalid time -gap> CurlRequest("www.google.com", "GET", "", rec(maxTime := "abc")); +gap> CurlRequest("http://127.0.0.1/", "GET", "", rec(maxTime := "abc")); Error, CurlRequest: .maxTime must be a non-negative integer # number of arguments diff --git a/tst/http-server.g b/tst/http-server.g new file mode 100644 index 0000000..e47ec59 --- /dev/null +++ b/tst/http-server.g @@ -0,0 +1,136 @@ +############################################################################# +## +## A small HTTP server for the curlInterface tests. +## + +BindGlobal( "CURLINTERFACE_HandleHTTPTestRequest", +function( listener, socket ) + local connection, line, parts, method, uri, contentLength, body, + status, response, location, headers; + + IO_close( listener ); + connection := IO_WrapFD( socket, IO.DefaultBufSize, IO.DefaultBufSize ); + line := IO_ReadLine( connection ); + if line = fail then + IO_Close( connection ); + IO_exit( 1 ); + fi; + parts := SplitString( line, " \r\n" ); + if Length( parts ) < 2 then + IO_Close( connection ); + IO_exit( 1 ); + fi; + method := parts[1]; + uri := parts[2]; + + contentLength := 0; + repeat + line := IO_ReadLine( connection ); + if line <> fail and not line in [ "", "\n", "\r\n" ] then + parts := SplitString( line, ": \r\n" ); + parts := Filtered( parts, part -> part <> "" ); + if Length( parts ) >= 2 and + LowercaseString( parts[1] ) = "content-length" then + contentLength := Int( parts[2] ); + fi; + fi; + until line = fail or line in [ "", "\n", "\r\n" ]; + if line = fail then + IO_Close( connection ); + IO_exit( 1 ); + fi; + + body := IO_ReadBlock( connection, contentLength ); + if body = fail or Length( body ) <> contentLength then + IO_Close( connection ); + IO_exit( 1 ); + fi; + + if uri = "/disconnect" then + IO_Close( connection ); + IO_exit( 0 ); + fi; + + status := "200 OK"; + response := "download test response\n"; + location := ""; + if uri = "/post" and method = "POST" then + response := body; + elif uri = "/delete" and method = "DELETE" then + response := "delete test response\n"; + elif uri = "/missing" then + status := "404 Not Found"; + response := "not found\n"; + elif uri = "/redirect" then + status := "302 Found"; + response := "redirect response\n"; + location := "Location: /success\r\n"; + elif uri = "/delay" then + Sleep( 2 ); + elif uri <> "/success" then + status := "404 Not Found"; + response := "not found\n"; + fi; + + headers := Concatenation( + "HTTP/1.1 ", status, "\r\n", + location, + "Content-Type: text/plain\r\n", + "Content-Length: ", String( Length( response ) ), "\r\n", + "Connection: close\r\n\r\n" ); + IO_Write( connection, headers ); + if method <> "HEAD" then + IO_Write( connection, response ); + fi; + IO_Flush( connection ); + IO_Close( connection ); + IO_exit( 0 ); +end ); + +BindGlobal( "CURLINTERFACE_StartHTTPTestServer", function() + local listener, address, port, pid, socket, handler; + + listener := IO_socket( IO.PF_INET, IO.SOCK_STREAM, "tcp" ); + if listener = fail then + Error( "cannot start the HTTP test server" ); + fi; + if IO_bind( listener, IO_MakeIPAddressPort( "127.0.0.1", 0 ) ) = fail or + IO_listen( listener, 8 ) <> true then + IO_close( listener ); + Error( "cannot start the HTTP test server" ); + fi; + address := IO_getsockname( listener ); + port := 256 * INT_CHAR( address[3] ) + INT_CHAR( address[4] ); + + pid := IO_fork(); + if pid = 0 then + while true do + socket := IO_accept( listener, + IO_MakeIPAddressPort( "0.0.0.0", 0 ) ); + if socket = fail then + IO_exit( 0 ); + fi; + handler := IO_fork(); + if handler = 0 then + CURLINTERFACE_HandleHTTPTestRequest( listener, socket ); + elif handler < 0 then + IO_close( socket ); + IO_exit( 1 ); + else + IO_close( socket ); + IO_IgnorePid( handler ); + fi; + od; + elif pid < 0 then + IO_close( listener ); + Error( "cannot fork the HTTP test server" ); + fi; + + IO_close( listener ); + return rec( pid := pid, port := port ); +end ); + +BindGlobal( "CURLINTERFACE_StopHTTPTestServer", function( server ) + IO_kill( server.pid, IO.SIGTERM ); + IO_WaitPid( server.pid, true ); +end );