From 8c85c76f60b6929843131c4bb2ce508668d9a3ab Mon Sep 17 00:00:00 2001 From: Mark Taylor <138604938+mtaylorgds@users.noreply.github.com> Date: Wed, 24 Jan 2024 10:18:24 +0000 Subject: [PATCH] Add Flipflop gem for feature-toggling Add the Flipflop gem to enable feature-toggling within the publisher app. The dashboard (available at `/flipflop`) provided is not secured in any way (but since we will be using the "Cookie" strategy, users will only be able to toggle features on/off for themselves). This could be added later, if desired. Also provides a `FeatureConstraint` class to enable dynamic routing to be set up based on the value of the feature toggles. --- Gemfile | 1 + Gemfile.lock | 6 ++ app/constraints/feature_constraint.rb | 13 ++++ config/application.rb | 8 +++ config/environments/development.rb | 8 +++ config/environments/test.rb | 8 +++ config/features.rb | 11 ++++ config/routes.rb | 1 + .../constraints/feature_constraint_test.rb | 65 +++++++++++++++++++ 9 files changed, 121 insertions(+) create mode 100644 app/constraints/feature_constraint.rb create mode 100644 config/features.rb create mode 100644 test/unit/constraints/feature_constraint_test.rb diff --git a/Gemfile b/Gemfile index 122c916ec..469fec184 100644 --- a/Gemfile +++ b/Gemfile @@ -7,6 +7,7 @@ gem "bootsnap", require: false gem "bootstrap-kaminari-views" gem "diffy" gem "erubis" +gem "flipflop" gem "gds-api-adapters" gem "gds-sso" gem "govspeak" diff --git a/Gemfile.lock b/Gemfile.lock index 4599988da..1888821ef 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -153,6 +153,9 @@ GEM ruby2_keywords (>= 0.0.4) faraday-net_http (3.0.2) ffi (1.15.5) + flipflop (2.7.1) + activesupport (>= 4.0) + terminal-table (>= 1.8) gds-api-adapters (91.1.0) addressable link_header @@ -740,6 +743,8 @@ GEM statsd-ruby (1.5.0) strip_attributes (1.13.0) activemodel (>= 3.0, < 8.0) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) terser (1.1.20) execjs (>= 0.3.0, < 3) thor (1.3.0) @@ -789,6 +794,7 @@ DEPENDENCIES diffy erubis factory_bot_rails + flipflop gds-api-adapters gds-sso govspeak diff --git a/app/constraints/feature_constraint.rb b/app/constraints/feature_constraint.rb new file mode 100644 index 000000000..642eb6218 --- /dev/null +++ b/app/constraints/feature_constraint.rb @@ -0,0 +1,13 @@ +class FeatureConstraint + def initialize(feature_name) + @feature_name = feature_name + end + + def matches?(request) + if request.cookies.key?(@feature_name) + request.cookies[@feature_name] == "1" + else + Flipflop.enabled?(@feature_name.to_sym) + end + end +end diff --git a/config/application.rb b/config/application.rb index ae204b9fc..a7778dc1f 100644 --- a/config/application.rb +++ b/config/application.rb @@ -18,6 +18,14 @@ module Publisher class Application < Rails::Application + # Before filter for Flipflop dashboard. Replace with a lambda or method name + # defined in ApplicationController to implement access control. + config.flipflop.dashboard_access_filter = nil + + # By default, when set to `nil`, strategy loading errors are suppressed in test + # mode. Set to `true` to always raise errors, or `false` to always warn. + config.flipflop.raise_strategy_errors = nil + # Initialize configuration defaults for originally generated Rails version. config.load_defaults 7.0 diff --git a/config/environments/development.rb b/config/environments/development.rb index 0eb32b44b..ddcafbb3e 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -1,6 +1,14 @@ require "active_support/core_ext/integer/time" Rails.application.configure do + # Before filter for Flipflop dashboard. Replace with a lambda or method name + # defined in ApplicationController to implement access control. + config.flipflop.dashboard_access_filter = nil + + # By default, when set to `nil`, strategy loading errors are suppressed in test + # mode. Set to `true` to always raise errors, or `false` to always warn. + config.flipflop.raise_strategy_errors = nil + # Settings specified here will take precedence over those in config/application.rb. # In the development environment your application's code is reloaded any time diff --git a/config/environments/test.rb b/config/environments/test.rb index 0e9bfbd57..48266efd9 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -6,6 +6,14 @@ # and recreated between test runs. Don't rely on the data there! Rails.application.configure do + # Before filter for Flipflop dashboard. Replace with a lambda or method name + # defined in ApplicationController to implement access control. + config.flipflop.dashboard_access_filter = nil + + # By default, when set to `nil`, strategy loading errors are suppressed in test + # mode. Set to `true` to always raise errors, or `false` to always warn. + config.flipflop.raise_strategy_errors = nil + # Settings specified here will take precedence over those in config/application.rb. # Turn false under Spring and add config.action_view.cache_template_loading = true. diff --git a/config/features.rb b/config/features.rb new file mode 100644 index 000000000..e10ab9803 --- /dev/null +++ b/config/features.rb @@ -0,0 +1,11 @@ +Flipflop.configure do + # Strategies will be used in the order listed here. + strategy :cookie + strategy :default + + if Rails.env.test? + feature :feature_for_tests, + default: true, + description: "A feature only used by tests; not to be used for any actual features." + end +end diff --git a/config/routes.rb b/config/routes.rb index 42d90336c..2a9254844 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -72,4 +72,5 @@ get "/govuk-sitemap.xml" => "sitemap#index" mount GovukAdminTemplate::Engine, at: "/style-guide" + mount Flipflop::Engine => "/flipflop" end diff --git a/test/unit/constraints/feature_constraint_test.rb b/test/unit/constraints/feature_constraint_test.rb new file mode 100644 index 000000000..ac2df92e1 --- /dev/null +++ b/test/unit/constraints/feature_constraint_test.rb @@ -0,0 +1,65 @@ +require "test_helper" + +class FeatureConstraintTest < ActiveSupport::TestCase + context "Feature 'feature_for_tests' is enabled by default" do + setup do + test_strategy = Flipflop::FeatureSet.current.test! + test_strategy.switch!(:feature_for_tests, true) + end + + should "match when a request cookie explicitly enables feature" do + request = stub(cookies: { "feature_for_tests" => "1" }) + + feature_constraint = FeatureConstraint.new("feature_for_tests") + + assert_equal true, feature_constraint.matches?(request) + end + + should "not match when a request cookie explicitly disables feature" do + request = stub(cookies: { "feature_for_tests" => "0" }) + + feature_constraint = FeatureConstraint.new("feature_for_tests") + + assert_equal false, feature_constraint.matches?(request) + end + + should "match when a request cookie does not override default feature status" do + request = stub(cookies: {}) + + feature_constraint = FeatureConstraint.new("feature_for_tests") + + assert_equal true, feature_constraint.matches?(request) + end + end + + context "Feature 'feature_for_tests' is disabled by default" do + setup do + test_strategy = Flipflop::FeatureSet.current.test! + test_strategy.switch!(:feature_for_tests, false) + end + + should "match when a request cookie explicitly enables feature" do + request = stub(cookies: { "feature_for_tests" => "1" }) + + feature_constraint = FeatureConstraint.new("feature_for_tests") + + assert_equal true, feature_constraint.matches?(request) + end + + should "not match when a request cookie explicitly disables feature" do + request = stub(cookies: { "feature_for_tests" => "0" }) + + feature_constraint = FeatureConstraint.new("feature_for_tests") + + assert_equal false, feature_constraint.matches?(request) + end + + should "not match when a request cookie does not override default feature status" do + request = stub(cookies: {}) + + feature_constraint = FeatureConstraint.new("feature_for_tests") + + assert_equal false, feature_constraint.matches?(request) + end + end +end