diff --git a/app/controllers/api/v0/feature_flags_controller.rb b/app/controllers/api/v0/feature_flags_controller.rb new file mode 100644 index 000000000..7bbe04cf2 --- /dev/null +++ b/app/controllers/api/v0/feature_flags_controller.rb @@ -0,0 +1,27 @@ +module Api + module V0 + # This controller is used for toggling feature flags in the test + # environment, specifically for Cypress tests. + # + # @note: Despite the used methods this controller does not add or remove + # the flags themselves, I just wanted distinct methods for enabling and + # disabling so we don't need conditional or boolean casting and these were + # the most fitting actions. + class FeatureFlagsController < ApiController + def create + FeatureFlag.enable(params[:flag]) + head :ok + end + + def show + flag = params[:flag] + render json: { flag => FeatureFlag.enabled?(flag) } + end + + def destroy + FeatureFlag.disable(params[:flag]) + head :ok + end + end + end +end diff --git a/app/lib/rails_env_constraint.rb b/app/lib/rails_env_constraint.rb new file mode 100644 index 000000000..9fc434c6c --- /dev/null +++ b/app/lib/rails_env_constraint.rb @@ -0,0 +1,18 @@ +# Used as routing constraint to expose routes only in certain environments. +class RailsEnvConstraint + # @param [Array] allowed_envs the environments the route(s) will be + # available in. + def initialize(allowed_envs:) + @allowed_envs = allowed_envs + end + + # Returns true if we're in an allowed env + # + # @note We always ignore the request argument since it's not used. + # @return [Boolean] + def matches?(_req = nil) + # NOTE: ActiveSupport::StringInquirer works with all string methods, so + # e.g. "test" == "test".inquiry works as expected. + @allowed_envs.any?(Rails.env) + end +end diff --git a/config/routes.rb b/config/routes.rb index 1dffa2f81..1dad9de8e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -84,6 +84,10 @@ Rails.application.routes.draw do resources :articles, only: [:index], to: "organizations#articles" end resource :instance, only: %i[show] + + constraints(RailsEnvConstraint.new(allowed_envs: %w[test])) do + resource :feature_flags, only: %i[create show destroy], param: :flag + end end end diff --git a/cypress/integration/seededFlows/toggleFeatureFlags.spec.js b/cypress/integration/seededFlows/toggleFeatureFlags.spec.js new file mode 100644 index 000000000..c044bacb3 --- /dev/null +++ b/cypress/integration/seededFlows/toggleFeatureFlags.spec.js @@ -0,0 +1,18 @@ +describe('Toggling feature flags', () => { + function checkFeatureFlag(flag, expected) { + return cy + .request('GET', `/api/feature_flags?flag=${flag}`) + .should((response) => { + expect(response.body).to.deep.equal({ [flag]: expected }); + }); + } + + it('toggles and verifies feature flags', () => { + const flag = 'test_feature_flag'; + checkFeatureFlag(flag, false); + cy.enableFeatureFlag(flag); + checkFeatureFlag(flag, true); + cy.disableFeatureFlag(flag); + checkFeatureFlag(flag, false); + }); +}); diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 3d4483d11..c8a06cdab 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -342,3 +342,25 @@ Cypress.Commands.add('createResponseTemplate', ({ title, content }) => { `utf8=%E2%9C%93&response_template%5Btitle%5D=${encodedTitle}&response_template%5Bcontent%5D=${encodedContent}`, ); }); + +/** + * Enables a feature flag. + * + * @param {string} flag The name of the feature flag to enable. + * + * @returns {Cypress.Chainable} A cypress request for enabling a feature flag. + */ +Cypress.Commands.add('enableFeatureFlag', (flag) => { + return cy.request('POST', '/api/feature_flags', { flag }); +}); + +/** + * Disables a feature flag. + * + * @param {string} flag The name of the feature flag to disable. + * + * @returns {Cypress.Chainable} A cypress request for disabling a feature flag. + */ +Cypress.Commands.add('disableFeatureFlag', (flag) => { + return cy.request('DELETE', `/api/feature_flags?flag=${flag}`); +}); diff --git a/spec/lib/rails_env_constraint_spec.rb b/spec/lib/rails_env_constraint_spec.rb new file mode 100644 index 000000000..06d236d06 --- /dev/null +++ b/spec/lib/rails_env_constraint_spec.rb @@ -0,0 +1,13 @@ +require "rails_helper" + +RSpec.describe RailsEnvConstraint, type: :lib do + it "matches only in the correct environments", :aggregate_failures do + constraint = described_class.new(allowed_envs: %w[development]) + + allow(Rails).to receive(:env).and_return("development") + expect(constraint.matches?).to be true + + allow(Rails).to receive(:env).and_return("production") + expect(constraint.matches?).to be false + end +end diff --git a/spec/requests/api/v0/feature_flags_spec.rb b/spec/requests/api/v0/feature_flags_spec.rb new file mode 100644 index 000000000..a64344ba0 --- /dev/null +++ b/spec/requests/api/v0/feature_flags_spec.rb @@ -0,0 +1,64 @@ +require "rails_helper" + +RSpec.describe "Api::V0::FeatureFlagsController", type: :request do + let(:flag) { "test_flag" } + let(:params) { { flag: flag } } + + it "is not available in the production environment" do + # We really need an ActiveSupport::StringInquirer here + # rubocop:disable Rails/Inquiry + allow(Rails).to receive(:env).and_return("production".inquiry) + # rubocop:enable Rails/Inquiry + + expect do + post api_feature_flags_path, params: params + end.to raise_error(ActionController::RoutingError) + end + + context "when toggling feature flags" do + before { FeatureFlag.add(flag) } + + after { FeatureFlag.remove(flag) } + + it "can enable a disabled feature flag" do + FeatureFlag.disable(flag) + + expect do + post api_feature_flags_path, params: params + end.to change { FeatureFlag.enabled?(flag) }.from(false).to(true) + end + + it "keeps the flag enabled when it was already enabled" do + FeatureFlag.enable(flag) + + expect do + post api_feature_flags_path, params: params + end.not_to change { FeatureFlag.enabled?(flag) }.from(true) + end + + it "can disable an enabled feature flag" do + FeatureFlag.enable(flag) + + expect do + delete api_feature_flags_path, params: params + end.to change { FeatureFlag.enabled?(flag) }.from(true).to(false) + end + + it "keeps the flag disabled when it was already disabled" do + FeatureFlag.disable(flag) + + expect do + delete api_feature_flags_path, params: params + end.not_to change { FeatureFlag.enabled?(flag) }.from(false) + end + + it "shows the current value of a feature flag" do + FeatureFlag.enable(flag) + + get api_feature_flags_path(flag: flag) + + parsed_response = JSON.parse(response.body) + expect(parsed_response[flag]).to be true + end + end +end