From 4271194af7721142ca9a13ac00804f2d39476e9c Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sun, 14 Jun 2026 03:20:10 +1200 Subject: [PATCH 1/2] 100% test coverage. --- bin/falcon | 9 +- bin/falcon-host | 5 +- config/covered.rb | 5 + gems.rb | 1 + lib/falcon/command.rb | 7 + lib/falcon/command/host.rb | 12 ++ lib/falcon/command/serve.rb | 1 + lib/falcon/service/virtual.rb | 1 + test/falcon/command.rb | 39 +++++ test/falcon/command/host.rb | 66 +++++++++ test/falcon/command/proxy.rb | 68 +++++++++ test/falcon/command/redirect.rb | 71 +++++++++ test/falcon/command/serve.rb | 58 ++++++++ test/falcon/command/top.rb | 90 ++++++++++++ test/falcon/command/virtual.rb | 34 ++++- test/falcon/endpoint.rb | 21 +++ test/falcon/environment/application.rb | 32 ++++ test/falcon/environment/configured.rb | 28 ++++ test/falcon/environment/proxy.rb | 35 +++++ test/falcon/environment/redirect.rb | 21 +++ test/falcon/environment/self_signed_tls.rb | 19 +++ test/falcon/environment/server.rb | 24 +++ test/falcon/environment/tls.rb | 63 ++++++++ test/falcon/environment/virtual.rb | 23 +++ test/falcon/middleware/proxy.rb | 31 ++++ test/falcon/middleware/redirect.rb | 27 ++++ test/falcon/proxy_endpoint.rb | 30 ++++ test/falcon/server.rb | 15 ++ test/falcon/service/virtual.rb | 161 +++++++++++++++++++++ 29 files changed, 984 insertions(+), 13 deletions(-) create mode 100644 config/covered.rb create mode 100644 test/falcon/command.rb create mode 100644 test/falcon/command/host.rb create mode 100644 test/falcon/command/proxy.rb create mode 100644 test/falcon/command/redirect.rb create mode 100644 test/falcon/endpoint.rb create mode 100644 test/falcon/environment/application.rb create mode 100644 test/falcon/environment/configured.rb create mode 100644 test/falcon/environment/proxy.rb create mode 100644 test/falcon/environment/redirect.rb create mode 100644 test/falcon/environment/self_signed_tls.rb create mode 100644 test/falcon/environment/server.rb create mode 100644 test/falcon/environment/tls.rb create mode 100644 test/falcon/environment/virtual.rb create mode 100644 test/falcon/middleware/redirect.rb create mode 100644 test/falcon/proxy_endpoint.rb create mode 100644 test/falcon/service/virtual.rb diff --git a/bin/falcon b/bin/falcon index 58e9e6d8..0c72897a 100755 --- a/bin/falcon +++ b/bin/falcon @@ -23,11 +23,4 @@ require_relative "../lib/falcon/command" -begin - Falcon::Command.call -rescue Interrupt - # Ignore. -rescue => error - Console.error(Falcon::Command, error) - exit! 1 -end +Falcon::Command.call diff --git a/bin/falcon-host b/bin/falcon-host index 8c1cad27..38690bfb 100755 --- a/bin/falcon-host +++ b/bin/falcon-host @@ -23,7 +23,4 @@ require_relative "../lib/falcon/command/host" -begin - Falcon::Command::Host.call -rescue Interrupt -end +Falcon::Command::Host.call diff --git a/config/covered.rb b/config/covered.rb new file mode 100644 index 00000000..61639e3f --- /dev/null +++ b/config/covered.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +def ignore_paths + super + ["examples/"] +end diff --git a/gems.rb b/gems.rb index 0f9f9035..f5faafbe 100644 --- a/gems.rb +++ b/gems.rb @@ -53,6 +53,7 @@ gem "sus-fixtures-async" gem "sus-fixtures-async-http" + gem "sus-fixtures-console" gem "sus-fixtures-openssl" gem "bake" diff --git a/lib/falcon/command.rb b/lib/falcon/command.rb index efe670c7..3e23250f 100644 --- a/lib/falcon/command.rb +++ b/lib/falcon/command.rb @@ -6,6 +6,8 @@ require_relative "command/top" +require "console" + module Falcon # @namespace module Command @@ -13,6 +15,11 @@ module Command # @parameter arguments [Array(String)] The command line arguments. def self.call(*arguments) Top.call(*arguments) + rescue Interrupt + # Ignore. + rescue => error + Console.error(self, error) + exit! 1 end end end diff --git a/lib/falcon/command/host.rb b/lib/falcon/command/host.rb index 7ef4c3e9..9f797829 100644 --- a/lib/falcon/command/host.rb +++ b/lib/falcon/command/host.rb @@ -8,6 +8,7 @@ require "samovar" require "async/service/controller" +require "console" module Falcon module Command @@ -17,6 +18,17 @@ module Command class Host < Samovar::Command self.description = "Host the specified applications." + # The main entry point for the `falcon-host` executable. + # @parameter arguments [Array(String)] The command line arguments. + def self.call(...) + super + rescue Interrupt + # Ignore. + rescue => error + Console.error(self, error) + exit! 1 + end + # One or more paths to the configuration files. # @name paths # @attribute [Array(String)] diff --git a/lib/falcon/command/serve.rb b/lib/falcon/command/serve.rb index f368706d..06f320ca 100644 --- a/lib/falcon/command/serve.rb +++ b/lib/falcon/command/serve.rb @@ -7,6 +7,7 @@ require_relative "../server" require_relative "../endpoint" require_relative "../service/server" +require_relative "../environment/server" require_relative "../environment/rackup" require "async/service/configuration" diff --git a/lib/falcon/service/virtual.rb b/lib/falcon/service/virtual.rb index f562b498..64301d5d 100644 --- a/lib/falcon/service/virtual.rb +++ b/lib/falcon/service/virtual.rb @@ -4,6 +4,7 @@ # Copyright, 2020-2024, by Samuel Williams. require "async/service/generic" +require "console" module Falcon module Service diff --git a/test/falcon/command.rb b/test/falcon/command.rb new file mode 100644 index 00000000..b1edf9f3 --- /dev/null +++ b/test/falcon/command.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "falcon/command" +require "sus/fixtures/console/captured_logger" + +describe Falcon::Command do + include Sus::Fixtures::Console::CapturedLogger + + it "reports errors from the command entry point" do + error = RuntimeError.new("boom") + captured_status = nil + + mock(Falcon::Command::Top) do |top| + top.replace(:call) do + raise error + end + end + + mock(subject) do |command| + command.replace(:exit!) do |status| + captured_status = status + end + end + + subject.call + + expect(captured_status).to be == 1 + + expect_console.to have_logged( + severity: be == :error, + subject: be == subject, + arguments: be == [error], + message: be == "boom", + ) + end +end diff --git a/test/falcon/command/host.rb b/test/falcon/command/host.rb new file mode 100644 index 00000000..abb9265f --- /dev/null +++ b/test/falcon/command/host.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "falcon/command/host" +require "sus/fixtures/console/captured_logger" + +describe Falcon::Command::Host do + include Sus::Fixtures::Console::CapturedLogger + + let(:command) do + subject[] + end + + it "runs the controller" do + captured_configuration = nil + captured_container_class = nil + + mock(Async::Service::Controller) do |controller| + controller.replace(:run) do |configuration, container_class:| + captured_configuration = configuration + captured_container_class = container_class + end + end + + command.call + + expect(captured_configuration).to be_a(Async::Service::Configuration) + expect(captured_container_class).to be == command.container_class + + expect_console.to have_logged( + severity: be == :info, + subject: be == command, + message: be(:include?, "Falcon Host v"), + ) + end + + it "reports errors from the host entry point" do + error = RuntimeError.new("boom") + captured_status = nil + + mock(Async::Service::Controller) do |controller| + controller.replace(:run) do |configuration, container_class:| + raise error + end + end + + mock(subject) do |host| + host.replace(:exit!) do |status| + captured_status = status + end + end + + subject.call(["missing.rb"]) + + expect(captured_status).to be == 1 + + expect_console.to have_logged( + severity: be == :error, + subject: be == subject, + arguments: be == [error], + message: be == "boom", + ) + end +end diff --git a/test/falcon/command/proxy.rb b/test/falcon/command/proxy.rb new file mode 100644 index 00000000..07461218 --- /dev/null +++ b/test/falcon/command/proxy.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "falcon/command/proxy" +require "sus/fixtures/console/captured_logger" + +describe Falcon::Command::Proxy do + include Sus::Fixtures::Console::CapturedLogger + + let(:command) do + subject[ + "--bind", "https://localhost:8496", + "--timeout", "1", + ] + end + + it "builds an endpoint helper" do + expect(command.endpoint).to be_a(Async::HTTP::Endpoint) + end + + it "logs resolved paths when running" do + command = subject[ + "--bind", "https://localhost:8496", + "test/falcon/command/config.ru", + ] + + expect(command).to receive(:configuration).and_return(Async::Service::Configuration.new) + captured_configuration = nil + + mock(Async::Service::Controller) do |controller| + controller.replace(:run) do |configuration| + captured_configuration = configuration + end + end + + command.call + + expect(captured_configuration).to be_a(Async::Service::Configuration) + + expect_console.to have_logged( + severity: be == :info, + subject: be == command, + message: be(:include?, "Loading configuration from test/falcon/command/config.ru"), + ) + end + + it "runs the controller" do + captured_configuration = nil + + mock(Async::Service::Controller) do |controller| + controller.replace(:run) do |configuration| + captured_configuration = configuration + end + end + + command.call + + expect(captured_configuration).to be_a(Async::Service::Configuration) + + expect_console.to have_logged( + severity: be == :info, + subject: be == command, + message: be(:include?, "Falcon Proxy v"), + ) + end +end diff --git a/test/falcon/command/redirect.rb b/test/falcon/command/redirect.rb new file mode 100644 index 00000000..85e80a67 --- /dev/null +++ b/test/falcon/command/redirect.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "falcon/command/redirect" +require "sus/fixtures/console/captured_logger" + +describe Falcon::Command::Redirect do + include Sus::Fixtures::Console::CapturedLogger + + let(:command) do + subject[ + "--bind", "http://localhost:8095", + "--redirect", "https://localhost:8495", + "--timeout", "1", + ] + end + + it "builds endpoint helpers" do + expect(command.endpoint).to be_a(Async::HTTP::Endpoint) + expect(command.redirect_endpoint).to be_a(Async::HTTP::Endpoint) + end + + it "logs resolved paths when running" do + command = subject[ + "--bind", "http://localhost:8095", + "--redirect", "https://localhost:8495", + "test/falcon/command/config.ru", + ] + + expect(command).to receive(:configuration).and_return(Async::Service::Configuration.new) + captured_configuration = nil + + mock(Async::Service::Controller) do |controller| + controller.replace(:run) do |configuration| + captured_configuration = configuration + end + end + + command.call + + expect(captured_configuration).to be_a(Async::Service::Configuration) + + expect_console.to have_logged( + severity: be == :info, + subject: be == command, + message: be(:include?, "Loading configuration from test/falcon/command/config.ru"), + ) + end + + it "runs the controller" do + captured_configuration = nil + + mock(Async::Service::Controller) do |controller| + controller.replace(:run) do |configuration| + captured_configuration = configuration + end + end + + command.call + + expect(captured_configuration).to be_a(Async::Service::Configuration) + + expect_console.to have_logged( + severity: be == :info, + subject: be == command, + message: be(:include?, "Falcon Redirect v"), + ) + end +end diff --git a/test/falcon/command/serve.rb b/test/falcon/command/serve.rb index fe593b59..43f1e535 100644 --- a/test/falcon/command/serve.rb +++ b/test/falcon/command/serve.rb @@ -5,6 +5,7 @@ # Copyright, 2019, by Sho Ito. require "falcon/command/serve" +require "sus/fixtures/console/captured_logger" ServeCommand = Sus::Shared("falcon serve") do let(:command) do @@ -37,6 +38,8 @@ end describe Falcon::Command::Serve do + include Sus::Fixtures::Console::CapturedLogger + let(:options) {[]} with "custom port" do @@ -61,4 +64,59 @@ let(:options) {["--count", 4, "--forked"]} it_behaves_like ServeCommand end + + with "hybrid container" do + let(:port) {8094} + let(:options) {["--count", 4, "--hybrid", "--forks", 2, "--threads", 2]} + + it "uses the hybrid container class and exposes endpoint helpers" do + command = subject[ + "--port", port, + "--config", File.expand_path("config.ru", __dir__), + *options + ] + + expect(command.container_class).to be == Async::Container::Hybrid + expect(command.endpoint).to be_a(Falcon::Endpoint) + expect(command.client_endpoint).to be_a(Async::HTTP::Endpoint) + end + end + + it "selects container classes" do + expect(subject["--threaded"].container_class).to be == Async::Container::Threaded + expect(subject["--forked"].container_class).to be == Async::Container::Forked + expect(subject["--hybrid"].container_class).to be == Async::Container::Hybrid + end + + it "runs the controller" do + command = subject[ + "--port", 8095, + "--config", File.expand_path("config.ru", __dir__), + "--threaded", + ] + + captured_configuration = nil + captured_container_class = nil + captured_graceful_stop = nil + + mock(Async::Service::Controller) do |controller| + controller.replace(:run) do |configuration, container_class:, graceful_stop:| + captured_configuration = configuration + captured_container_class = container_class + captured_graceful_stop = graceful_stop + end + end + + command.call + + expect(captured_configuration).to be_a(Async::Service::Configuration) + expect(captured_container_class).to be == Async::Container::Threaded + expect(captured_graceful_stop).to be == 1.0 + + expect_console.to have_logged( + severity: be == :info, + subject: be == command, + message: be(:include?, "Falcon v"), + ) + end end diff --git a/test/falcon/command/top.rb b/test/falcon/command/top.rb index fee6d7b5..8cc3ee00 100644 --- a/test/falcon/command/top.rb +++ b/test/falcon/command/top.rb @@ -4,8 +4,11 @@ # Copyright, 2018-2026, by Samuel Williams. require "falcon/command" +require "sus/fixtures/console/captured_logger" describe Falcon::Command::Top do + include Sus::Fixtures::Console::CapturedLogger + with "basic server configuration" do it "can listen on specified port" do top = subject[ @@ -32,4 +35,91 @@ controller.stop end end + + it "identifies quiet logging" do + expect(subject["--quiet"]).to be(:quiet?) + end + + it "prints the version" do + top = subject["--version"] + captured_message = nil + + mock(top) do |mock| + mock.replace(:puts) do |message| + captured_message = message + end + end + + top.call + + expect(captured_message).to be == "#{top.name} v#{Falcon::VERSION}" + end + + it "prints usage" do + top = subject["--help"] + + printed_usage = false + + mock(top) do |mock| + mock.replace(:print_usage) do + printed_usage = true + end + end + + top.call + + expect(printed_usage).to be == true + end + + it "updates default encoding when no encoding is configured" do + top = subject[] + called = false + captured_encoding = nil + + mock(top) do |mock| + mock.replace(:encoding) do + nil + end + + mock.replace(:update_external_encoding!) do |encoding = Encoding::UTF_8| + captured_encoding = encoding + end + end + + mock(top.command) do |command| + command.replace(:call) do + called = true + end + end + + top.call + + expect(called).to be == true + expect(captured_encoding).to be == Encoding::UTF_8 + end + + it "updates the default external encoding" do + top = subject[] + captured_encoding = nil + + mock(Encoding) do |mock| + mock.replace(:default_external) do + Encoding::UTF_8 + end + + mock.replace(:default_external=) do |encoding| + captured_encoding = encoding + end + end + + top.update_external_encoding!(Encoding::US_ASCII) + + expect_console.to have_logged( + severity: be == :warn, + subject: be == top, + message: be(:include?, "Updating Encoding.default_external"), + ) + + expect(captured_encoding).to be == Encoding::US_ASCII + end end diff --git a/test/falcon/command/virtual.rb b/test/falcon/command/virtual.rb index 185d6ec1..7e940ef5 100644 --- a/test/falcon/command/virtual.rb +++ b/test/falcon/command/virtual.rb @@ -4,6 +4,7 @@ # Copyright, 2019-2026, by Samuel Williams. require "falcon/command/virtual" +require "sus/fixtures/console/captured_logger" require "async/http" require "protocol/http/request" @@ -23,7 +24,7 @@ let(:command) do subject[ - "--bind-insecure", "http://localhost:8080", + "--bind-insecure", "http://localhost:8090", "--bind-secure", "https://localhost:8443", *options, *paths, @@ -165,6 +166,37 @@ def around end describe Falcon::Command::Virtual do + include Sus::Fixtures::Console::CapturedLogger + + with "#call" do + let(:command) do + subject[ + "--bind-insecure", "http://localhost:8090", + "--bind-secure", "https://localhost:8443", + ] + end + + it "runs the controller" do + captured_configuration = nil + + mock(Async::Service::Controller) do |controller| + controller.replace(:run) do |configuration| + captured_configuration = configuration + end + end + + command.call + + expect(captured_configuration).to be_a(Async::Service::Configuration) + + expect_console.to have_logged( + severity: be == :info, + subject: be == command, + message: be(:include?, "Falcon Virtual v"), + ) + end + end + with "HTTP/1.0" do let(:protocol) {Async::HTTP::Protocol::HTTP10} it_behaves_like VirtualCommand, unique: "HTTP10" diff --git a/test/falcon/endpoint.rb b/test/falcon/endpoint.rb new file mode 100644 index 00000000..112af45b --- /dev/null +++ b/test/falcon/endpoint.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "falcon/endpoint" + +describe Falcon::Endpoint do + let(:endpoint) do + subject.parse("https://localhost:9292") + end + + it "selects an application protocol" do + callback = endpoint.build_ssl_context.alpn_select_cb + + expect(callback.call(["h2", "http/1.1"])).to be == "h2" + expect(callback.call(["http/1.1"])).to be == "http/1.1" + expect(callback.call(["http/1.0"])).to be == "http/1.0" + expect(callback.call(["spdy/3"])).to be == nil + end +end diff --git a/test/falcon/environment/application.rb b/test/falcon/environment/application.rb new file mode 100644 index 00000000..44bf7ccc --- /dev/null +++ b/test/falcon/environment/application.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "falcon/environment/application" +require "falcon/proxy_endpoint" +require "async/service/environment" +require "temporary_directory_context" + +describe Falcon::Environment::Application do + include TemporaryDirectoryContext + + let(:evaluator) do + Async::Service::Environment.build(subject, root: root, name: "localhost").evaluator + end + + it "provides default middleware and application endpoint" do + expect(evaluator).to have_attributes( + middleware: be == Protocol::HTTP::Middleware::HelloWorld, + ipc_path: be == File.join(root, "application.ipc"), + ) + + endpoint = evaluator.endpoint + + expect(endpoint).to be_a(Falcon::ProxyEndpoint) + expect(endpoint).to have_attributes( + scheme: be == "https", + authority: be == "localhost", + ) + end +end diff --git a/test/falcon/environment/configured.rb b/test/falcon/environment/configured.rb new file mode 100644 index 00000000..5db8b611 --- /dev/null +++ b/test/falcon/environment/configured.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "falcon/environment/configured" +require "async/service/configuration" +require "async/service/environment" + +describe Falcon::Environment::Configured do + let(:evaluator) do + Async::Service::Environment.build(subject).evaluator + end + + it "provides default configuration paths" do + expect(evaluator.configuration_paths).to be == ["/srv/http/*/falcon.rb"] + end + + it "expands configured paths" do + expect(evaluator.resolved_configuration_paths).to be == [] + end + + it "loads resolved configuration paths" do + expect(Async::Service::Configuration).to receive(:load).with([]).and_return(:configuration) + + expect(evaluator.configuration).to be == :configuration + end +end diff --git a/test/falcon/environment/proxy.rb b/test/falcon/environment/proxy.rb new file mode 100644 index 00000000..86b4f6ed --- /dev/null +++ b/test/falcon/environment/proxy.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "falcon/environment/proxy" +require "async/service/environment" +require "sus/fixtures/console/captured_logger" + +describe Falcon::Environment::Proxy do + include Sus::Fixtures::Console::CapturedLogger + + let(:evaluator) do + Async::Service::Environment.build(subject).evaluator + end + + it "provides default proxy settings" do + expect(evaluator).to have_attributes( + url: be == "https://[::]:443", + environments: be == [], + ) + end + + it "returns nil for unknown host contexts" do + socket = Object.new + + expect(evaluator.host_context(socket, "missing.localhost")).to be == nil + + expect_console.to have_logged( + severity: be == :warn, + subject: be == evaluator, + message: be(:include?, "Unable to resolve missing.localhost"), + ) + end +end diff --git a/test/falcon/environment/redirect.rb b/test/falcon/environment/redirect.rb new file mode 100644 index 00000000..d296a674 --- /dev/null +++ b/test/falcon/environment/redirect.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "falcon/environment/redirect" +require "async/service/environment" + +describe Falcon::Environment::Redirect do + let(:evaluator) do + Async::Service::Environment.build(subject).evaluator + end + + it "provides default redirect settings" do + expect(evaluator).to have_attributes( + redirect_url: be == "https://[::]:443", + environments: be == [], + ) + expect(evaluator.redirect_endpoint).to be_a(Async::HTTP::Endpoint) + end +end diff --git a/test/falcon/environment/self_signed_tls.rb b/test/falcon/environment/self_signed_tls.rb new file mode 100644 index 00000000..d3a06b34 --- /dev/null +++ b/test/falcon/environment/self_signed_tls.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "falcon/environment/self_signed_tls" +require "async/service/environment" + +describe Falcon::Environment::SelfSignedTLS do + let(:evaluator) do + Async::Service::Environment.build(subject, authority: "localhost").evaluator + end + + it "returns nil for unsupported application protocols" do + callback = evaluator.ssl_context.alpn_select_cb + + expect(callback.call(["spdy/3"])).to be == nil + end +end diff --git a/test/falcon/environment/server.rb b/test/falcon/environment/server.rb new file mode 100644 index 00000000..07d7b9b1 --- /dev/null +++ b/test/falcon/environment/server.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "falcon/environment/server" +require "async/service/environment" + +describe Falcon::Environment::Server do + let(:evaluator) do + Async::Service::Environment.build(subject, name: "localhost").evaluator + end + + it "provides default server settings" do + expect(evaluator).to have_attributes( + url: be == "http://[::]:9292", + authority: be == "localhost", + timeout: be == nil, + verbose: be == false, + cache: be == false, + ) + expect(evaluator.client_endpoint).to be_a(Async::HTTP::Endpoint) + end +end diff --git a/test/falcon/environment/tls.rb b/test/falcon/environment/tls.rb new file mode 100644 index 00000000..91ffe706 --- /dev/null +++ b/test/falcon/environment/tls.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "falcon/environment/tls" +require "async/service/environment" +require "sus/fixtures/openssl/valid_certificate_context" +require "temporary_directory_context" +require "fileutils" + +describe Falcon::Environment::TLS do + include TemporaryDirectoryContext + include Sus::Fixtures::OpenSSL::ValidCertificateContext + + let(:evaluator) do + Async::Service::Environment.build(subject, root: root).evaluator + end + + def write_certificate_files + FileUtils.mkdir_p(File.join(root, "ssl")) + + File.write(evaluator.ssl_certificate_path, certificate.to_pem) + File.write(evaluator.ssl_private_key_path, key.to_pem) + end + + it "provides default TLS paths and settings" do + expect(evaluator).to have_attributes( + ssl_session_id: be == "falcon", + ssl_ciphers: be == Falcon::TLS::SERVER_CIPHERS, + ssl_certificate_path: be == File.join(root, "ssl/certificate.pem"), + ssl_private_key_path: be == File.join(root, "ssl/private.key"), + ) + end + + it "loads certificate material from the configured paths" do + write_certificate_files + + expect(evaluator.ssl_certificate.subject.to_s).to be == certificate.subject.to_s + expect(evaluator.ssl_certificate_chain).to be(:empty?) + expect(evaluator.ssl_private_key.to_pem).to be == key.to_pem + end + + it "builds an SSL context" do + write_certificate_files + + context = evaluator.ssl_context + + expect(context).to be_a(OpenSSL::SSL::SSLContext) + expect(context.session_id_context).to be == "falcon" + end + + it "selects an application protocol" do + write_certificate_files + + callback = evaluator.ssl_context.alpn_select_cb + + expect(callback.call(["h2", "http/1.1"])).to be == "h2" + expect(callback.call(["http/1.1"])).to be == "http/1.1" + expect(callback.call(["http/1.0"])).to be == "http/1.0" + expect(callback.call(["spdy/3"])).to be == nil + end +end diff --git a/test/falcon/environment/virtual.rb b/test/falcon/environment/virtual.rb new file mode 100644 index 00000000..13669b0d --- /dev/null +++ b/test/falcon/environment/virtual.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "falcon/environment/virtual" +require "async/service/environment" + +describe Falcon::Environment::Virtual do + let(:evaluator) do + Async::Service::Environment.build(subject).evaluator + end + + it "provides default virtual host settings" do + expect(evaluator).to have_attributes( + name: be == "Falcon::Service::Virtual", + bind_secure: be == "https://[::]:443", + bind_insecure: be == "http://[::]:80", + timeout: be == 10.0, + falcon_path: be == File.expand_path("../../../bin/falcon", __dir__), + ) + end +end diff --git a/test/falcon/middleware/proxy.rb b/test/falcon/middleware/proxy.rb index 8ee21312..2444ffc3 100644 --- a/test/falcon/middleware/proxy.rb +++ b/test/falcon/middleware/proxy.rb @@ -7,12 +7,14 @@ require "falcon/middleware/proxy" require "sus/fixtures/async" +require "sus/fixtures/console/captured_logger" require "async/http/client" require "async/http/endpoint" require "async/service/environment" describe Falcon::Middleware::Proxy do include Sus::Fixtures::Async::ReactorContext + include Sus::Fixtures::Console::CapturedLogger def proxy_for(**options) Async::Service::Environment.build(**options).evaluator @@ -62,4 +64,33 @@ def proxy_for(**options) proxy.close end + + it "returns a bad gateway response if the upstream request fails" do + request = Protocol::HTTP::Request.new("https", "www.google.com", "GET", "/", nil, headers, nil) + client = Object.new + + expect(request).to receive(:remote_address).and_return(nil) + expect(client).to receive(:call).and_raise(RuntimeError, "upstream failed") + expect(proxy).to receive(:connect).and_return(client) + + response = proxy.call(request) + + expect(response.status).to be == 502 + expect(response.read).to be == "RuntimeError" + end + + it "logs proxy request details when preparing requests" do + request = Protocol::HTTP::Request.new("https", "www.google.com", "GET", "/", "HTTP/1.1", headers, nil) + host = proxy_for(authority: "www.google.com", endpoint: Async::HTTP::Endpoint.parse("https://www.google.com")) + + expect(request).to receive(:remote_address).and_return(nil) + + proxy.prepare_request(request, host) + + expect_console.to have_logged( + severity: be == :debug, + subject: be == proxy, + message: be(:include?, "Request authority: www.google.com"), + ) + end end diff --git a/test/falcon/middleware/redirect.rb b/test/falcon/middleware/redirect.rb new file mode 100644 index 00000000..14a8e9f6 --- /dev/null +++ b/test/falcon/middleware/redirect.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Fletcher Dares. + +require "falcon/middleware/redirect" + +require "async/http/endpoint" +require "async/service/environment" + +describe Falcon::Middleware::Redirect do + let(:host) do + Async::Service::Environment.build(authority: "www.example.com").evaluator + end + + it "redirects to a default port without an explicit port" do + redirect = subject.new(Falcon::Middleware::NotFound, { + "www.example.com" => host, + }, Async::HTTP::Endpoint.parse("https://localhost")) + + request = Protocol::HTTP::Request.new("http", "www.example.com", "GET", "/index", "HTTP/1.1", Protocol::HTTP::Headers["accept" => "*/*"], nil) + response = redirect.call(request) + + expect(response.status).to be == 301 + expect(response.headers["location"]).to be == "https://www.example.com/index" + end +end diff --git a/test/falcon/proxy_endpoint.rb b/test/falcon/proxy_endpoint.rb new file mode 100644 index 00000000..c67f8c8c --- /dev/null +++ b/test/falcon/proxy_endpoint.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "falcon/proxy_endpoint" + +describe Falcon::ProxyEndpoint do + let(:endpoint) do + IO::Endpoint.unix("test.ipc") + end + + let(:proxy_endpoint) do + subject.new(endpoint, protocol: Async::HTTP::Protocol::HTTP2, scheme: "https", authority: "localhost") + end + + it "can enumerate proxied endpoints" do + expect(proxy_endpoint.each).to be_a(Enumerator) + + proxied = proxy_endpoint.each.to_a + + expect(proxied).to have_attributes(size: be == 1) + expect(proxied.first).to be_a(subject) + expect(proxied.first).to have_attributes( + protocol: be == Async::HTTP::Protocol::HTTP2, + scheme: be == "https", + authority: be == "localhost", + ) + end +end diff --git a/test/falcon/server.rb b/test/falcon/server.rb index 171cc6d5..35f87a0a 100644 --- a/test/falcon/server.rb +++ b/test/falcon/server.rb @@ -8,6 +8,21 @@ require "sus/fixtures/openssl/verified_certificate_context" describe Falcon::Server do + it "can build middleware with a cache" do + app = lambda do |env| + [200, {}, ["OK"]] + end + + expect(subject.middleware(app, cache: true)).to be_a(Async::HTTP::Cache::General) + end + + it "formats large statistics counts" do + server = subject.allocate + + expect(server.send(:format_count, 1_001)).to be == "1.0K" + expect(server.send(:format_count, 1_000_001)).to be == "1.0M" + end + include ServerContext with "http client" do diff --git a/test/falcon/service/virtual.rb b/test/falcon/service/virtual.rb new file mode 100644 index 00000000..dcba60ab --- /dev/null +++ b/test/falcon/service/virtual.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "falcon/service/virtual" +require "falcon/environment/virtual" +require "async/service/environment" +require "temporary_directory_context" + +describe Falcon::Service::Virtual do + include TemporaryDirectoryContext + + let(:environment) do + Async::Service::Environment.new(Falcon::Environment::Virtual) + end + + let(:service) do + subject.new(environment) + end + + let(:path) do + File.join(root, "app", "falcon.rb") + end + + before do + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, "# test application\n") + end + + it "builds a child environment with path privileges" do + stat = File.stat(path) + user = Etc.getpwuid(stat.uid) + + expect(Process::GID).to receive(:change_privilege).with(stat.gid) + expect(Process::UID).to receive(:change_privilege).with(stat.uid) + + ENV["BUNDLE_GEMFILE"] = "ignored" + env = service.assume_privileges(path) + + expect(env["BUNDLE_GEMFILE"]).to be == nil + expect(env["PWD"]).to be == File.dirname(path) + expect(env["HOME"]).to be == user.dir + ensure + ENV.delete("BUNDLE_GEMFILE") + end + + it "spawns an application instance" do + instance = Class.new do + def exec(...) + end + end.new + container = Class.new do + def spawn(...) + end + end.new + + expect(service).to receive(:assume_privileges).with(path).and_return({"HOME" => root}) + expect(instance).to receive(:exec).with({"HOME" => root}, "bundle", "exec", path, ready: false, chdir: File.dirname(path)) + + spawn_arguments = nil + + mock(container) do |mock| + mock.replace(:spawn) do |name:, restart:, key:, &block| + spawn_arguments = {name: name, restart: restart, key: key} + + block.call(instance) + end + end + + service.spawn(path, container, chdir: File.dirname(path)) + + expect(spawn_arguments).to be == {name: "Falcon Application", restart: true, key: path} + end + + it "sets up application, redirect and proxy instances" do + environment = Async::Service::Environment.new(Falcon::Environment::Virtual).with( + configuration_paths: [path], + resolved_configuration_paths: [path], + bind_insecure: "http://localhost:8090", + bind_secure: "https://localhost:8490", + timeout: 2.0, + falcon_path: "/usr/bin/falcon", + ) + service = subject.new(environment) + + proxy = Class.new do + attr :signals + + def initialize + @signals = [] + end + + def kill(signal) + @signals << signal + end + end.new + + redirect = proxy.class.new + + instances = [] + spawns = [] + container = Class.new do + def initialize(proxy, redirect, instances, spawns) + @proxy = proxy + @redirect = redirect + @instances = instances + @spawns = spawns + end + + def [](key) + case key + when :proxy + @proxy + when :redirect + @redirect + end + end + + def reload(&block) + block.call + end + + def spawn(name:, restart:, key:, &block) + instance = Class.new do + attr :exec_arguments + attr :exec_options + + def exec(*arguments, **options) + @exec_arguments = arguments + @exec_options = options + end + end.new + + @spawns << {name: name, restart: restart, key: key} + @instances << instance + + block.call(instance) + end + end.new(proxy, redirect, instances, spawns) + + expect(service).to receive(:assume_privileges).with(path).and_return({"HOME" => root}) + + service.setup(container) + + expect(proxy.signals).to be == [:HUP] + expect(redirect.signals).to be == [:HUP] + expect(spawns).to be == [ + {name: "Falcon Application", restart: true, key: path}, + {name: "Falcon Redirector", restart: true, key: :redirect}, + {name: "Falcon Proxy", restart: true, key: :proxy}, + ] + + expect(instances[0].exec_arguments).to be == [{"HOME" => root}, "bundle", "exec", path] + expect(instances[0].exec_options).to be == {ready: false, chdir: File.dirname(path)} + expect(instances[1].exec_arguments).to be == ["/usr/bin/falcon", "redirect", "--bind", "http://localhost:8090", "--timeout", "2.0", "--redirect", "https://localhost:8490", path] + expect(instances[1].exec_options).to be == {ready: false} + expect(instances[2].exec_arguments).to be == ["/usr/bin/falcon", "proxy", "--bind", "https://localhost:8490", "--timeout", "2.0", path] + expect(instances[2].exec_options).to be == {ready: false} + end +end From 34095a60c98f28e16580d97cbd36f471d16345a8 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sun, 14 Jun 2026 18:42:38 +1200 Subject: [PATCH 2/2] Improve virtual service integration test. --- test/falcon/environment/application.rb | 4 +- test/falcon/environment/tls.rb | 4 +- test/falcon/service/virtual.rb | 246 +++++++++++++------------ 3 files changed, 134 insertions(+), 120 deletions(-) diff --git a/test/falcon/environment/application.rb b/test/falcon/environment/application.rb index 44bf7ccc..c107513d 100644 --- a/test/falcon/environment/application.rb +++ b/test/falcon/environment/application.rb @@ -6,10 +6,10 @@ require "falcon/environment/application" require "falcon/proxy_endpoint" require "async/service/environment" -require "temporary_directory_context" +require "sus/fixtures/temporary_directory_context" describe Falcon::Environment::Application do - include TemporaryDirectoryContext + include Sus::Fixtures::TemporaryDirectoryContext let(:evaluator) do Async::Service::Environment.build(subject, root: root, name: "localhost").evaluator diff --git a/test/falcon/environment/tls.rb b/test/falcon/environment/tls.rb index 91ffe706..91d57fb7 100644 --- a/test/falcon/environment/tls.rb +++ b/test/falcon/environment/tls.rb @@ -6,11 +6,11 @@ require "falcon/environment/tls" require "async/service/environment" require "sus/fixtures/openssl/valid_certificate_context" -require "temporary_directory_context" +require "sus/fixtures/temporary_directory_context" require "fileutils" describe Falcon::Environment::TLS do - include TemporaryDirectoryContext + include Sus::Fixtures::TemporaryDirectoryContext include Sus::Fixtures::OpenSSL::ValidCertificateContext let(:evaluator) do diff --git a/test/falcon/service/virtual.rb b/test/falcon/service/virtual.rb index dcba60ab..8d14ec24 100644 --- a/test/falcon/service/virtual.rb +++ b/test/falcon/service/virtual.rb @@ -6,10 +6,10 @@ require "falcon/service/virtual" require "falcon/environment/virtual" require "async/service/environment" -require "temporary_directory_context" +require "sus/fixtures/temporary_directory_context" describe Falcon::Service::Virtual do - include TemporaryDirectoryContext + include Sus::Fixtures::TemporaryDirectoryContext let(:environment) do Async::Service::Environment.new(Falcon::Environment::Virtual) @@ -28,134 +28,148 @@ File.write(path, "# test application\n") end - it "builds a child environment with path privileges" do - stat = File.stat(path) - user = Etc.getpwuid(stat.uid) - - expect(Process::GID).to receive(:change_privilege).with(stat.gid) - expect(Process::UID).to receive(:change_privilege).with(stat.uid) - - ENV["BUNDLE_GEMFILE"] = "ignored" - env = service.assume_privileges(path) - - expect(env["BUNDLE_GEMFILE"]).to be == nil - expect(env["PWD"]).to be == File.dirname(path) - expect(env["HOME"]).to be == user.dir - ensure - ENV.delete("BUNDLE_GEMFILE") - end - - it "spawns an application instance" do - instance = Class.new do - def exec(...) - end - end.new - container = Class.new do - def spawn(...) - end - end.new - - expect(service).to receive(:assume_privileges).with(path).and_return({"HOME" => root}) - expect(instance).to receive(:exec).with({"HOME" => root}, "bundle", "exec", path, ready: false, chdir: File.dirname(path)) - - spawn_arguments = nil - - mock(container) do |mock| - mock.replace(:spawn) do |name:, restart:, key:, &block| - spawn_arguments = {name: name, restart: restart, key: key} - - block.call(instance) + with "#assume_privileges" do + it "builds a child environment with path privileges" do + stat = File.stat(path) + user = Etc.getpwuid(stat.uid) + + expect(Process::GID).to receive(:change_privilege).with(stat.gid) + expect(Process::UID).to receive(:change_privilege).with(stat.uid) + + mock(ENV) do |mock| + mock.replace(:to_h) do |&block| + {"BUNDLE_GEMFILE" => "ignored", "PATH" => "/bin"}.to_h(&block) + end end + + env = service.assume_privileges(path) + + expect(env["BUNDLE_GEMFILE"]).to be == nil + expect(env["PATH"]).to be == "/bin" + expect(env["PWD"]).to be == File.dirname(path) + expect(env["HOME"]).to be == user.dir end - - service.spawn(path, container, chdir: File.dirname(path)) - - expect(spawn_arguments).to be == {name: "Falcon Application", restart: true, key: path} end - it "sets up application, redirect and proxy instances" do - environment = Async::Service::Environment.new(Falcon::Environment::Virtual).with( - configuration_paths: [path], - resolved_configuration_paths: [path], - bind_insecure: "http://localhost:8090", - bind_secure: "https://localhost:8490", - timeout: 2.0, - falcon_path: "/usr/bin/falcon", - ) - service = subject.new(environment) - - proxy = Class.new do - attr :signals + with "#spawn" do + it "spawns an application instance" do + instance = Class.new do + def exec(...) + end + end.new + container = Class.new do + def spawn(...) + end + end.new - def initialize - @signals = [] - end + expect(service).to receive(:assume_privileges).with(path).and_return({"HOME" => root}) + expect(instance).to receive(:exec).with({"HOME" => root}, "bundle", "exec", path, ready: false, chdir: File.dirname(path)) - def kill(signal) - @signals << signal - end - end.new - - redirect = proxy.class.new - - instances = [] - spawns = [] - container = Class.new do - def initialize(proxy, redirect, instances, spawns) - @proxy = proxy - @redirect = redirect - @instances = instances - @spawns = spawns - end + spawn_arguments = nil - def [](key) - case key - when :proxy - @proxy - when :redirect - @redirect + mock(container) do |mock| + mock.replace(:spawn) do |name:, restart:, key:, &block| + spawn_arguments = {name: name, restart: restart, key: key} + + block.call(instance) end end - def reload(&block) - block.call - end + service.spawn(path, container, chdir: File.dirname(path)) - def spawn(name:, restart:, key:, &block) - instance = Class.new do - attr :exec_arguments - attr :exec_options - - def exec(*arguments, **options) - @exec_arguments = arguments - @exec_options = options - end - end.new + expect(spawn_arguments).to be == {name: "Falcon Application", restart: true, key: path} + end + end + + with "#setup" do + def inline_child + Class.new do + attr :signals + attr :exec_arguments + attr :exec_options - @spawns << {name: name, restart: restart, key: key} - @instances << instance + def initialize + @signals = [] + end - block.call(instance) - end - end.new(proxy, redirect, instances, spawns) - - expect(service).to receive(:assume_privileges).with(path).and_return({"HOME" => root}) - - service.setup(container) + def kill(signal) + @signals << signal + end + + def exec(*arguments, **options) + @exec_arguments = arguments + @exec_options = options + end + end.new + end - expect(proxy.signals).to be == [:HUP] - expect(redirect.signals).to be == [:HUP] - expect(spawns).to be == [ - {name: "Falcon Application", restart: true, key: path}, - {name: "Falcon Redirector", restart: true, key: :redirect}, - {name: "Falcon Proxy", restart: true, key: :proxy}, - ] + def inline_container(existing, children) + make_child = method(:inline_child) + + Object.new.tap do |container| + container.define_singleton_method(:[]) do |key| + existing[key] + end + + container.define_singleton_method(:reload) do |&block| + block.call + end + + container.define_singleton_method(:spawn) do |name:, restart:, key:, &block| + child = existing[key] = make_child.call + + children << {name: name, restart: restart, key: key, child: child} + + block.call(child) + end + end + end - expect(instances[0].exec_arguments).to be == [{"HOME" => root}, "bundle", "exec", path] - expect(instances[0].exec_options).to be == {ready: false, chdir: File.dirname(path)} - expect(instances[1].exec_arguments).to be == ["/usr/bin/falcon", "redirect", "--bind", "http://localhost:8090", "--timeout", "2.0", "--redirect", "https://localhost:8490", path] - expect(instances[1].exec_options).to be == {ready: false} - expect(instances[2].exec_arguments).to be == ["/usr/bin/falcon", "proxy", "--bind", "https://localhost:8490", "--timeout", "2.0", path] - expect(instances[2].exec_options).to be == {ready: false} + it "reloads redirect and proxy instances" do + falcon_path = File.join(root, "bin", "falcon") + environment = Async::Service::Environment.new(Falcon::Environment::Virtual).with( + configuration_paths: [path], + resolved_configuration_paths: [path], + bind_insecure: "http://localhost:8090", + bind_secure: "https://localhost:8490", + timeout: 2.0, + falcon_path: falcon_path, + ) + service = subject.new(environment) + proxy = inline_child + redirect = inline_child + existing = {proxy: proxy, redirect: redirect} + children = [] + container = inline_container(existing, children) + + expect(service).to receive(:assume_privileges).with(path).and_return({"HOME" => root}) + + service.setup(container) + + expect(proxy).to have_attributes(signals: be == [:HUP]) + expect(redirect).to have_attributes(signals: be == [:HUP]) + expect(children).to have_value( + have_keys( + name: be == "Falcon Redirector", + restart: be == true, + key: be == :redirect, + child: have_attributes( + exec_arguments: be == [falcon_path, "redirect", "--bind", "http://localhost:8090", "--timeout", "2.0", "--redirect", "https://localhost:8490", path], + exec_options: be == {ready: false}, + ), + ) + ) + expect(children).to have_value( + have_keys( + name: be == "Falcon Proxy", + restart: be == true, + key: be == :proxy, + child: have_attributes( + exec_arguments: be == [falcon_path, "proxy", "--bind", "https://localhost:8490", "--timeout", "2.0", path], + exec_options: be == {ready: false}, + ), + ) + ) + end end end