package net.usebox.gemini.server.handlers import java.net.URI import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers import net.usebox.gemini.server._ class GeminiHandlerSpec extends AnyFlatSpec with Matchers { behavior of "handle" val handler = new GeminiHandler(TestData.conf) val handlerUserDir = new GeminiHandler(TestData.confUserDir) val handlerCgi = new GeminiHandler(TestData.cgiConf) def handleWith( geminiHandler: GeminiHandler, req: String, remoteAddr: String = "127.0.0.1" ): Response = geminiHandler.handle(req, URI.create(req), remoteAddr) it should "handle host case insensitive" in { handleWith(handler, "gemini://LOCALHOST/") should be(a[Success]) } it should "return proxy request refused when the vhost is not found" in { handleWith(handler, "gemini://otherhost/") should be( a[ProxyRequestRefused] ) } it should "return bad request when user info is present" in { handleWith(handler, "gemini://user@localhost/") should be( a[BadRequest] ) } it should "return bad request when the path is out of root dir" in { handleWith(handler, "gemini://localhost/../../") should be( a[BadRequest] ) } it should "redirect to normalize the URL" in { handleWith(handler, "gemini://localhost/./") should be( a[PermanentRedirect] ) } it should "return not found if the path doesn't exist" in { handleWith( handler, "gemini://localhost/doesnotexist" ) should be(a[NotFound]) } it should "return not found if a dot file" in { handleWith(handler, "gemini://localhost/.dotfile") should be( a[NotFound] ) } it should "return success on reading file" in { handleWith( handler, "gemini://localhost/index.gmi" ) should matchPattern { case Success(_, "text/gemini", Some(_), 25L) => } } it should "redirect and normalize request on a directory" in { handleWith(handler, "gemini://localhost/dir") should be( a[PermanentRedirect] ) } it should "return an existing index file when requesting a directory" in { handleWith(handler, "gemini://localhost/") should matchPattern { case Success(_, "text/gemini", Some(_), 25L) => } } it should "include gemini params for gemini MIME type" in { handleWith( new GeminiHandler( TestData.conf.copy(virtualHosts = List(TestData.conf.virtualHosts(0).copy(geminiParams = Some("test"))) ) ), "gemini://localhost/index.gmi" ) should matchPattern { case Success(_, "text/gemini; test", Some(_), 25L) => } } behavior of "handler, directory listings" it should "return a directory listing if is enabled and no index" in { handleWith(handler, "gemini://localhost/dir/") should be( a[DirListing] ) } it should "return a directory listing, directory listing flags: vhost flag false, directories flag true" in { handleWith( new GeminiHandler( ServiceConf.initConf( TestData.conf.copy(virtualHosts = List( TestData.conf .virtualHosts(0) .copy( directoryListing = false, directories = List( Directory( "dir/", directoryListing = Some(true), allowCgi = None ) ) ) ) ) ) ), "gemini://localhost/dir/" ) should be(a[DirListing]) } it should "return not found with no index, directory listing flags: vhost flag true, directories flag false" in { handleWith( new GeminiHandler( ServiceConf.initConf( TestData.conf.copy(virtualHosts = List( TestData.conf .virtualHosts(0) .copy( directoryListing = true, directories = List( Directory( "dir/", directoryListing = Some(false), allowCgi = None ) ) ) ) ) ) ), "gemini://localhost/dir/" ) should be(a[NotFound]) } it should "not apply directory listing override to subdirectories" in { handleWith( new GeminiHandler( ServiceConf.initConf( TestData.conf.copy(virtualHosts = List( TestData.conf .virtualHosts(0) .copy( directoryListing = false, directories = List( Directory( "dir/", directoryListing = Some(true), allowCgi = None ) ) ) ) ) ) ), "gemini://localhost/dir/sub/" ) should be( a[NotFound] ) } it should "return not found if directory listing is not enabled and no index" in { handleWith( new GeminiHandler( TestData.conf.copy(virtualHosts = List(TestData.conf.virtualHosts(0).copy(directoryListing = false)) ) ), "gemini://localhost/dir/" ) should be(a[NotFound]) } behavior of "handler, user directories" it should "return success on reading file" in { handleWith( handlerUserDir, "gemini://localhost/~username/index.gmi" ) should matchPattern { case Success(_, "text/gemini", Some(_), 38L) => } } it should "return redirect accessing the user directory without ending slash" in { handleWith(handlerUserDir, "gemini://localhost/~username") should be( a[PermanentRedirect] ) } it should "return success accessing the user directory index" in { handleWith( handlerUserDir, "gemini://localhost/~username/" ) should matchPattern { case Success(_, "text/gemini", Some(_), 38L) => } } it should "return bad request trying to exit the root directory" in { handleWith(handlerUserDir, "gemini://localhost/~username/../../") should be( a[BadRequest] ) } it should "return redirect to the virtual host root when leaving the user dir" in { handleWith(handlerUserDir, "gemini://localhost/~username/../") should be( a[PermanentRedirect] ) } it should "not translate root if used an invalid user pattern" in { handleWith(handlerUserDir, "gemini://localhost/~username../") should be( a[NotFound] ) handleWith(handlerUserDir, "gemini://localhost/~0invalid/") should be( a[NotFound] ) } it should "not translate root if no user directory path was provided" in { handleWith( new GeminiHandler( TestData.conf.copy(virtualHosts = List( TestData.conf .virtualHosts(0) .copy(userDirectories = true) ) ) ), "gemini://localhost/~username/", "127.0.0.1" ) should be(a[NotFound]) } it should "not execute a CGI if the target resource is not executable" in { handleWith( handlerCgi, "gemini://localhost/dir/file.txt" ) should matchPattern { case Success(_, "text/plain", Some(_), 5L) => } } it should "not execute a CGI if the target resource is a directory" in { handleWith(handlerCgi, "gemini://localhost/dir/sub/") should be( a[DirListing] ) } it should "not apply allow CGI to subdirectories" in { handleWith( handlerCgi, "gemini://localhost/dir/sub/cgi" ) should matchPattern { case Success(_, "text/plain", Some(_), 72) => } } it should "execute a CGI" in { val cgi = handleWith(handlerCgi, "gemini://localhost/dir/cgi") .asInstanceOf[Cgi] cgi.status should be(20) cgi.meta should be("text/gemini") cgi.body should include("GATEWAY_INTERFACE=CGI/1.1") } it should "execute a CGI: empty parameters, host and port" in { handleWith( handlerCgi, "gemini://localhost/dir/cgi" ) should matchPattern { case Cgi( _, _, "", "", "cgi", TestData.host, TestData.portStr, _, _ ) => } } it should "execute a CGI: host is case-insensitive and the value in the conf is used" in { handleWith( handlerCgi, "gemini://LOCALHOST/dir/cgi" ) should matchPattern { case Cgi( _, _, "", "", "cgi", TestData.host, TestData.portStr, _, _ ) => } } it should "execute a CGI: query string" in { handleWith( handlerCgi, "gemini://localhost/dir/cgi?query&string" ) should matchPattern { case Cgi( _, _, "query&string", "", "cgi", TestData.host, TestData.portStr, _, _ ) => } } it should "execute a CGI: path info" in { handleWith( handlerCgi, "gemini://localhost/dir/cgi/path/info" ) should matchPattern { case Cgi( _, _, "", "/path/info", "cgi", TestData.host, TestData.portStr, _, _ ) => } } it should "execute a CGI: query string and path info" in { handleWith( handlerCgi, "gemini://localhost/dir/cgi/path/info?query=string" ) should matchPattern { case Cgi( _, _, "query=string", "/path/info", "cgi", TestData.host, TestData.portStr, _, _ ) => } } it should "not execute an executable if allow CGI is off" in { handleWith( handler, "gemini://localhost/dir/cgi" ) should matchPattern { case Success(_, "text/plain", Some(_), _) => } } it should "response with an error if the CGI exits with non 0" in { val bad = handleWith(handlerCgi, "gemini://localhost/dir/bad-cgi") .asInstanceOf[Cgi] val meta = "Error executing CGI" bad.status should be(50) bad.meta should be(meta) bad.body should include(meta) } it should "return a response with an error if the CGI exits with non 0" in { val bad = handleWith(handlerCgi, "gemini://localhost/dir/bad-cgi") .asInstanceOf[Cgi] val meta = "Error executing CGI" bad.status should be(50) bad.meta should be(meta) bad.body should include(meta) } it should "return a response with an error if the CGI response is invalid" in { val bad = handleWith(handlerCgi, "gemini://localhost/dir/bad-response") .asInstanceOf[Cgi] val meta = "Invalid response from CGI" bad.status should be(40) bad.meta should be(meta) bad.body should include(meta) } it should "environment variables are optional" in { handleWith( handlerCgi, "gemini://localhost/dir/cgi/" ) should matchPattern { case Cgi( _, _, _, _, "cgi", TestData.host, TestData.portStr, _, m ) if m == Map() => } } it should "pass environment variables to the CGI" in { handleWith( new GeminiHandler(TestData.cgiEnvConf), "gemini://localhost/dir/cgi/" ) should matchPattern { case Cgi( _, _, _, _, "cgi", TestData.host, TestData.portStr, _, m ) if m == Map("env1" -> "value") => } } it should "execute a CGI with the environment variables" in { val cgi = handleWith( new GeminiHandler(TestData.cgiEnvConf), "gemini://localhost/dir/cgi" ).asInstanceOf[Cgi] cgi.status should be(20) cgi.meta should be("text/gemini") cgi.body should include("env1=value") } it should "execute a CGI when it is the index document" in { val cgi = handleWith( new GeminiHandler(TestData.cgiIndexConf), "gemini://localhost/dir/" ).asInstanceOf[Cgi] cgi.status should be(20) cgi.meta should be("text/gemini") cgi.body should include("GATEWAY_INTERFACE=CGI/1.1") } it should "execute a CGI when it is the index document (full name)" in { val cgi = handleWith( new GeminiHandler(TestData.cgiIndexConf), "gemini://localhost/dir/cgi" ).asInstanceOf[Cgi] cgi.status should be(20) cgi.meta should be("text/gemini") cgi.body should include("GATEWAY_INTERFACE=CGI/1.1") } it should "execute a CGI when it is the index document (full name, path info)" in { val cgi = handleWith( new GeminiHandler(TestData.cgiIndexConf), "gemini://localhost/dir/cgi/path/info" ).asInstanceOf[Cgi] cgi.status should be(20) cgi.meta should be("text/gemini") cgi.body should include("GATEWAY_INTERFACE=CGI/1.1") cgi.body should include("PATH_INFO=/path/info") } it should "resolve CGI directories from more to less specific" in { // issue: https://gitlab.com/reidrac/spacebeans/-/issues/2 val cgi = handleWith( new GeminiHandler(TestData.cgiPrefConf), "gemini://localhost/dir/sub/cgiOk/path/info" ).asInstanceOf[Cgi] cgi.status should be(20) cgi.meta should be("text/gemini") cgi.body should include("GATEWAY_INTERFACE=CGI/1.1") cgi.body should include("PATH_INFO=/path/info") } }