diff --git a/README.md b/README.md index 09fe1fd2a..ead6f5ab1 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,10 @@ Flipper gives you control over who has access to features in your app. -* Enable or disable features for everyone, specific actors, groups of actors, a percentage of actors, or a percentage of time. -* Configure your feature flags from the console or a web UI. -* Regardless of what data store you are using, Flipper can performantly store your feature flags. -* Use [Flipper Cloud](#flipper-cloud) to cascade features from multiple environments, share settings with your team, control permissions, keep an audit history, and rollback. +- Enable or disable features for everyone, specific actors, groups of actors, a percentage of actors, or a percentage of time. +- Configure your feature flags from the console or a web UI. +- Regardless of what data store you are using, Flipper can performantly store your feature flags. +- Use [Flipper Cloud](#flipper-cloud) to cascade features from multiple environments, share settings with your team, control permissions, keep an audit history, and rollback. Control your software — don't let it control you. @@ -66,19 +66,29 @@ Flipper.enable_group :search, :admin Flipper.enable_percentage_of_actors :search, 2 ``` +You also have the ability to explicitly deny a user for a feature. This takes precedence over enabling them. + +```ruby +# Deny access to a feature for a specific actor +Flipper.deny_actor :search, current_user + +# Reinstate access to that feature +Flipper.reinstate_actor :search, current_user +``` + Read more about [getting started with Flipper](https://flippercloud.io/docs) and [enabling features](https://flippercloud.io/docs/features). ## Flipper Cloud Like Flipper and want more? Check out [Flipper Cloud](https://www.flippercloud.io), which comes with: -* **everything in one place** — no need to bounce around from different application UIs or IRB consoles. -* **permissions** — grant access to everyone in your organization or lockdown each project to particular people. -* **multiple environments** — production, staging, enterprise, by continent, whatever you need. -* **personal environments** — no more rake scripts or manual enable/disable to get your laptop to look like production. Every developer gets a personal environment that inherits from production that they can override as they please ([read more](https://www.johnnunemaker.com/flipper-cloud-environments/)). -* **no maintenance** — we'll keep the lights on for you. We also have handy webhooks for keeping your app in sync with Cloud, so **our availability won't affect yours**. All your feature flag reads are local to your app. -* **audit history** — every feature change and who made it. -* **rollbacks** — enable or disable a feature accidentally? No problem. You can roll back to any point in the audit history with a single click. +- **everything in one place** — no need to bounce around from different application UIs or IRB consoles. +- **permissions** — grant access to everyone in your organization or lockdown each project to particular people. +- **multiple environments** — production, staging, enterprise, by continent, whatever you need. +- **personal environments** — no more rake scripts or manual enable/disable to get your laptop to look like production. Every developer gets a personal environment that inherits from production that they can override as they please ([read more](https://www.johnnunemaker.com/flipper-cloud-environments/)). +- **no maintenance** — we'll keep the lights on for you. We also have handy webhooks for keeping your app in sync with Cloud, so **our availability won't affect yours**. All your feature flag reads are local to your app. +- **audit history** — every feature change and who made it. +- **rollbacks** — enable or disable a feature accidentally? No problem. You can roll back to any point in the audit history with a single click. [![Flipper Cloud Screenshot](docs/images/flipper_cloud.png)](https://www.flippercloud.io) @@ -101,11 +111,11 @@ Cloud is super simple to integrate with Rails ([demo app](https://github.com/few ## Brought To You By -| pic | @mention | area | -|---|---|---| -| ![@jnunemaker](https://avatars3.githubusercontent.com/u/235?s=64) | [@jnunemaker](https://github.com/jnunemaker) | most things | -| ![@bkeepers](https://avatars3.githubusercontent.com/u/173?s=64) | [@bkeepers](https://github.com/bkeepers) | most things | -| ![@dpep](https://avatars3.githubusercontent.com/u/918804?s=64) | [@dpep](https://github.com/dpep) | tbd | -| ![@alexwheeler](https://avatars3.githubusercontent.com/u/3260042?s=64) | [@alexwheeler](https://github.com/alexwheeler) | api | -| ![@thetimbanks](https://avatars1.githubusercontent.com/u/471801?s=64) | [@thetimbanks](https://github.com/thetimbanks) | ui | -| ![@lazebny](https://avatars1.githubusercontent.com/u/6276766?s=64) | [@lazebny](https://github.com/lazebny) | docker | +| pic | @mention | area | +| ---------------------------------------------------------------------- | ---------------------------------------------- | ----------- | +| ![@jnunemaker](https://avatars3.githubusercontent.com/u/235?s=64) | [@jnunemaker](https://github.com/jnunemaker) | most things | +| ![@bkeepers](https://avatars3.githubusercontent.com/u/173?s=64) | [@bkeepers](https://github.com/bkeepers) | most things | +| ![@dpep](https://avatars3.githubusercontent.com/u/918804?s=64) | [@dpep](https://github.com/dpep) | tbd | +| ![@alexwheeler](https://avatars3.githubusercontent.com/u/3260042?s=64) | [@alexwheeler](https://github.com/alexwheeler) | api | +| ![@thetimbanks](https://avatars1.githubusercontent.com/u/471801?s=64) | [@thetimbanks](https://github.com/thetimbanks) | ui | +| ![@lazebny](https://avatars1.githubusercontent.com/u/6276766?s=64) | [@lazebny](https://github.com/lazebny) | docker | diff --git a/lib/flipper.rb b/lib/flipper.rb index f7b256424..10b0f4659 100644 --- a/lib/flipper.rb +++ b/lib/flipper.rb @@ -158,6 +158,7 @@ def groups_registry=(registry) require 'flipper/registry' require 'flipper/type' require 'flipper/types/actor' +require 'flipper/types/denied_actor' require 'flipper/types/boolean' require 'flipper/types/group' require 'flipper/types/percentage' diff --git a/lib/flipper/adapter.rb b/lib/flipper/adapter.rb index 3d4737b63..70f3368ee 100644 --- a/lib/flipper/adapter.rb +++ b/lib/flipper/adapter.rb @@ -15,6 +15,7 @@ def default_config { boolean: nil, groups: Set.new, + denied_actors: Set.new, actors: Set.new, percentage_of_actors: nil, percentage_of_time: nil, diff --git a/lib/flipper/adapters/rollout.rb b/lib/flipper/adapters/rollout.rb index 4e268f4b6..a8c54cb44 100644 --- a/lib/flipper/adapters/rollout.rb +++ b/lib/flipper/adapters/rollout.rb @@ -51,8 +51,9 @@ def get(feature) boolean: boolean, groups: groups, actors: actors, + denied_actors: Set.new, # Rollout doesn't support this percentage_of_actors: percentage_of_actors, - percentage_of_time: nil, + percentage_of_time: nil, # Rollout doesn't support this } end diff --git a/lib/flipper/api/middleware.rb b/lib/flipper/api/middleware.rb index 01e660672..2cb70f462 100644 --- a/lib/flipper/api/middleware.rb +++ b/lib/flipper/api/middleware.rb @@ -17,6 +17,7 @@ def initialize(app, options = {}) @action_collection.add Api::V1::Actions::PercentageOfTimeGate @action_collection.add Api::V1::Actions::PercentageOfActorsGate @action_collection.add Api::V1::Actions::ActorsGate + @action_collection.add Api::V1::Actions::DeniedActorsGate @action_collection.add Api::V1::Actions::GroupsGate @action_collection.add Api::V1::Actions::BooleanGate @action_collection.add Api::V1::Actions::ClearFeature diff --git a/lib/flipper/api/v1/actions/denied_actors_gate.rb b/lib/flipper/api/v1/actions/denied_actors_gate.rb new file mode 100644 index 000000000..3a2f33a8b --- /dev/null +++ b/lib/flipper/api/v1/actions/denied_actors_gate.rb @@ -0,0 +1,44 @@ +require 'flipper/api/action' +require 'flipper/api/v1/decorators/feature' + +module Flipper + module Api + module V1 + module Actions + class DeniedActorsGate < Api::Action + include FeatureNameFromRoute + + route %r{\A/features/(?.*)/denied_actors/?\Z} + + def post + ensure_valid_params + feature = flipper[feature_name] + actor = Actor.new(flipper_id) + feature.deny_actor(actor) + decorated_feature = Decorators::Feature.new(feature) + json_response(decorated_feature.as_json, 200) + end + + def delete + ensure_valid_params + feature = flipper[feature_name] + actor = Actor.new(flipper_id) + feature.reinstate_actor(actor) + decorated_feature = Decorators::Feature.new(feature) + json_response(decorated_feature.as_json, 200) + end + + private + + def ensure_valid_params + json_error_response(:flipper_id_invalid) if flipper_id.nil? + end + + def flipper_id + @flipper_id ||= params['flipper_id'] + end + end + end + end + end +end diff --git a/lib/flipper/dsl.rb b/lib/flipper/dsl.rb index 88b82b045..b20c9fd9e 100644 --- a/lib/flipper/dsl.rb +++ b/lib/flipper/dsl.rb @@ -144,6 +144,28 @@ def disable_percentage_of_actors(name) feature(name).disable_percentage_of_actors end + # Public: Deny a feature for an actor. + # + # name - The String or Symbol name of the feature. + # actor - a Flipper::Types::DeniedActor instance or an object that responds + # to flipper_id. + # + # Returns result of Feature#deny_actor. + def deny_actor(name, actor) + feature(name).deny_actor(actor) + end + + # Public: Reinstate a feature for an actor. + # + # name - The String or Symbol name of the feature. + # actor - a Flipper::Types::DeniedActor instance or an object that responds + # to flipper_id. + # + # Returns result of Feature#reinstate_actor. + def reinstate_actor(name, actor) + feature(name).reinstate_actor(actor) + end + # Public: Add a feature. # # name - The String or Symbol name of the feature. @@ -245,6 +267,16 @@ def actor(thing) Types::Actor.new(thing) end + # Public: Wraps an object as a flipper denied actor. + # + # thing - The object that you would like to wrap. + # + # Returns an instance of Flipper::Types::DeniedActor. + # Raises ArgumentError if thing does not respond to `flipper_id`. + def denied_actor(thing) + Types::DeniedActor.new(thing) + end + # Public: Shortcut for getting a percentage of time instance. # # number - The percentage of time that should be enabled. diff --git a/lib/flipper/feature.rb b/lib/flipper/feature.rb index a43e212d1..c2ad2a9f2 100644 --- a/lib/flipper/feature.rb +++ b/lib/flipper/feature.rb @@ -68,6 +68,40 @@ def disable(thing = false) end end + # Public: Deny this feature for something. + # Works like an enable under the hood. + # + # Returns the result of Adapter#enable. + def deny(thing) + instrument(:deny) do |payload| + adapter.add self + + gate = deniable_gate_for(thing) + wrapped_thing = gate.wrap(thing) + payload[:gate_name] = gate.name + payload[:thing] = wrapped_thing + + adapter.enable self, gate, wrapped_thing + end + end + + # Public: Reinstate this disabled feature for something. + # Works like a disable under the hood. + # + # Returns the result of Adapter#disable. + def reinstate(thing) + instrument(:reinstate) do |payload| + adapter.add self + + gate = deniable_gate_for(thing) + wrapped_thing = gate.wrap(thing) + payload[:gate_name] = gate.name + payload[:thing] = wrapped_thing + + adapter.disable self, gate, wrapped_thing + end + end + # Public: Adds this feature. # # Returns the result of Adapter#add. @@ -110,7 +144,13 @@ def enabled?(thing = nil) thing: thing ) - if open_gate = gates.detect { |gate| gate.open?(context) } + # We first check if we have any deniable gate activated here, + # because they have preference over the other gates + if closed_deny_gate = deniable_gates.detect { |gate| !gate.open?(context) } + return false + end + + if open_gate = forward_gates.detect { |gate| gate.open?(context) } payload[:gate_name] = open_gate.name true else @@ -199,6 +239,26 @@ def disable_percentage_of_actors disable Types::PercentageOfActors.new(0) end + # Public: Denies a feature for an actor. + # + # actor - a Flipper::Types::Actor instance or an object that responds + # to flipper_id. + # + # Returns result of deny. + def deny_actor(actor) + deny Types::DeniedActor.wrap(actor) + end + + # Public: Reinstates a denied feature for an actor. + # + # actor - a Flipper::Types::Actor instance or an object that responds + # to flipper_id. + # + # Returns result of reinstate. + def reinstate_actor(actor) + reinstate Types::DeniedActor.wrap(actor) + end + # Public: Returns state for feature (:on, :off, or :conditional). def state values = gate_values @@ -264,6 +324,13 @@ def actors_value gate_values.actors end + # Public: Get the adapter value for the denied actors gate. + # + # Returns Set of String flipper_id's. + def denied_actors_value + gate_values.denied_actors + end + # Public: Get the adapter value for the boolean gate. # # Returns true or false. @@ -342,12 +409,27 @@ def gates @gates ||= [ Gates::Boolean.new, Gates::Actor.new, + Gates::DeniedActor.new, Gates::PercentageOfActors.new, Gates::PercentageOfTime.new, Gates::Group.new, ] end + # Public: Get all the non deniable gates used to determine enabled/disabled for the feature. + # + # Returns an array of gates + def forward_gates + @forward_gates ||= gates.reject(&:deniable?) + end + + # Public: Get all the deniable gates used to determine enabled/disabled for the feature. + # + # Returns an array of gates + def deniable_gates + @deniable_gates ||= gates.select(&:deniable?) + end + # Public: Find a gate by name. # # Returns a Flipper::Gate if found, nil if not. @@ -355,14 +437,24 @@ def gate(name) gates.detect { |gate| gate.name == name.to_sym } end - # Public: Find the gate that protects a thing. + # Public: Find the forward gate that protects a thing. # # thing - The object for which you would like to find a gate # # Returns a Flipper::Gate. # Raises Flipper::GateNotFound if no gate found for thing def gate_for(thing) - gates.detect { |gate| gate.protects?(thing) } || raise(GateNotFound, thing) + forward_gates.detect { |gate| gate.protects?(thing) } || raise(GateNotFound, thing) + end + + # Public: Find the deniable gate that protects a thing. + # + # thing - The object for which you would like to find a gate + # + # Returns a Flipper::Gate. + # Raises Flipper::GateNotFound if no gate found for thing + def deniable_gate_for(thing) + deniable_gates.detect { |gate| gate.protects?(thing) } || raise(GateNotFound, thing) end private diff --git a/lib/flipper/feature_check_context.rb b/lib/flipper/feature_check_context.rb index ed2f660a1..37a93f51a 100644 --- a/lib/flipper/feature_check_context.rb +++ b/lib/flipper/feature_check_context.rb @@ -26,6 +26,11 @@ def actors_value values.actors end + # Public: Convenience method for denied actors value value like Feature has. + def denied_actors_value + values.denied_actors + end + # Public: Convenience method for boolean value value like Feature has. def boolean_value values.boolean diff --git a/lib/flipper/gate.rb b/lib/flipper/gate.rb index da7af2e55..e7c2e0ceb 100644 --- a/lib/flipper/gate.rb +++ b/lib/flipper/gate.rb @@ -36,6 +36,13 @@ def protects?(_thing) false end + # Internal: Check if a gate is responsible for denying access for a thing. Implemented in subclass. + # + # Returns true if gate denies, false if not. + def deniable? + false + end + # Internal: Allows gate to wrap thing using one of the supported flipper # types so adapters always get something that responds to value. def wrap(thing) @@ -55,6 +62,7 @@ def inspect end require 'flipper/gates/actor' +require 'flipper/gates/denied_actor' require 'flipper/gates/boolean' require 'flipper/gates/group' require 'flipper/gates/percentage_of_actors' diff --git a/lib/flipper/gate_values.rb b/lib/flipper/gate_values.rb index 71967244c..31eb21589 100644 --- a/lib/flipper/gate_values.rb +++ b/lib/flipper/gate_values.rb @@ -8,6 +8,7 @@ class GateValues LegitIvars = { 'boolean' => '@boolean', 'actors' => '@actors', + 'denied_actors' => '@denied_actors', 'groups' => '@groups', 'percentage_of_time' => '@percentage_of_time', 'percentage_of_actors' => '@percentage_of_actors', @@ -15,6 +16,7 @@ class GateValues attr_reader :boolean attr_reader :actors + attr_reader :denied_actors attr_reader :groups attr_reader :percentage_of_actors attr_reader :percentage_of_time @@ -22,6 +24,7 @@ class GateValues def initialize(adapter_values) @boolean = Typecast.to_boolean(adapter_values[:boolean]) @actors = Typecast.to_set(adapter_values[:actors]) + @denied_actors = Typecast.to_set(adapter_values[:denied_actors]) @groups = Typecast.to_set(adapter_values[:groups]) @percentage_of_actors = Typecast.to_percentage(adapter_values[:percentage_of_actors]) @percentage_of_time = Typecast.to_percentage(adapter_values[:percentage_of_time]) @@ -37,6 +40,7 @@ def eql?(other) self.class.eql?(other.class) && boolean == other.boolean && actors == other.actors && + denied_actors == other.denied_actors && groups == other.groups && percentage_of_actors == other.percentage_of_actors && percentage_of_time == other.percentage_of_time diff --git a/lib/flipper/gates/denied_actor.rb b/lib/flipper/gates/denied_actor.rb new file mode 100644 index 000000000..96f312db5 --- /dev/null +++ b/lib/flipper/gates/denied_actor.rb @@ -0,0 +1,54 @@ +module Flipper + module Gates + class DeniedActor < Gate + # Internal: The name of the gate. Used for instrumentation, etc. + def name + :denied_actor + end + + # Internal: Name converted to value safe for adapter. + def key + :denied_actors + end + + def data_type + :set + end + + def enabled?(value) + !value.empty? + end + + def deniable? + true + end + + # Internal: Checks if the gate is open for a thing. + # + # Returns true if gate open for thing, false if not. + def open?(context) + value = context.values[key] + if context.thing.nil? + true + else + if protects?(context.thing) + actor = wrap(context.thing) + denied_actor_ids = value + !denied_actor_ids.include?(actor.value) + else + true + end + end + end + + def wrap(thing) + Types::DeniedActor.wrap(thing) + end + + def protects?(thing) + Types::DeniedActor.wrappable?(thing) + end + end + end + end + \ No newline at end of file diff --git a/lib/flipper/types/denied_actor.rb b/lib/flipper/types/denied_actor.rb new file mode 100644 index 000000000..57d3a0cbb --- /dev/null +++ b/lib/flipper/types/denied_actor.rb @@ -0,0 +1,31 @@ +module Flipper + module Types + class DeniedActor < Type + def self.wrappable?(thing) + return false if thing.nil? + thing.respond_to?(:flipper_id) + end + + attr_reader :thing + + def initialize(thing) + raise ArgumentError, 'thing cannot be nil' if thing.nil? + + unless thing.respond_to?(:flipper_id) + raise ArgumentError, "#{thing.inspect} must respond to flipper_id, but does not" + end + + @thing = thing + @value = thing.flipper_id.to_s + end + + def respond_to?(*args) + super || @thing.respond_to?(*args) + end + + def method_missing(name, *args, &block) + @thing.send name, *args, &block + end + end + end +end diff --git a/lib/flipper/ui/actions/denied_actors_gate.rb b/lib/flipper/ui/actions/denied_actors_gate.rb new file mode 100644 index 000000000..09bf184d8 --- /dev/null +++ b/lib/flipper/ui/actions/denied_actors_gate.rb @@ -0,0 +1,51 @@ +require 'flipper/ui/action' +require 'flipper/ui/decorators/feature' +require 'flipper/ui/util' + +module Flipper + module UI + module Actions + class DeniedActorsGate < UI::Action + include FeatureNameFromRoute + + route %r{\A/features/(?.*)/denied_actors/?\Z} + + def get + feature = flipper[feature_name] + @feature = Decorators::Feature.new(feature) + + breadcrumb 'Home', '/' + breadcrumb 'Features', '/features' + breadcrumb @feature.key, "/features/#{@feature.key}" + breadcrumb 'Deny Actor' + + view_response :deny_actor + end + + def post + feature = flipper[feature_name] + value = params['value'].to_s.strip + values = value.split(UI.configuration.actors_separator).map(&:strip).uniq + + if values.empty? + error = "#{value.inspect} is not a valid actor value." + redirect_to("/features/#{feature.key}/denied_actors?error=#{error}") + end + + values.each do |value| + actor = Flipper::Actor.new(value) + + case params['operation'] + when 'deny' + feature.deny_actor actor + when 'reinstate' + feature.reinstate_actor actor + end + end + + redirect_to("/features/#{feature.key}") + end + end + end + end +end diff --git a/lib/flipper/ui/configuration.rb b/lib/flipper/ui/configuration.rb index f3db15a19..ae5c5e8e2 100644 --- a/lib/flipper/ui/configuration.rb +++ b/lib/flipper/ui/configuration.rb @@ -35,6 +35,11 @@ class Configuration # application needs. Defaults to "a flipper id". attr_accessor :add_actor_placeholder + # Public: What should show up in the form to deny actors. This can be + # different per application since flipper_id's can be whatever an + # application needs. Defaults to "a flipper id". + attr_accessor :deny_actor_placeholder + # Public: If you set this, Flipper::UI will fetch descriptions # from your external source. Descriptions for `features` will be shown on `feature` # page, and optionally the `features` pages. Defaults to empty block. @@ -71,6 +76,7 @@ def initialize @fun = true @cloud_recommendation = true @add_actor_placeholder = "a flipper id" + @deny_actor_placeholder = "a flipper id" @descriptions_source = DEFAULT_DESCRIPTIONS_SOURCE @show_feature_description_in_list = false @actors_separator = ',' diff --git a/lib/flipper/ui/middleware.rb b/lib/flipper/ui/middleware.rb index 80b391436..0856ae256 100644 --- a/lib/flipper/ui/middleware.rb +++ b/lib/flipper/ui/middleware.rb @@ -19,6 +19,7 @@ def initialize(app, options = {}) # UI @action_collection.add UI::Actions::AddFeature @action_collection.add UI::Actions::ActorsGate + @action_collection.add UI::Actions::DeniedActorsGate @action_collection.add UI::Actions::GroupsGate @action_collection.add UI::Actions::BooleanGate @action_collection.add UI::Actions::PercentageOfTimeGate diff --git a/lib/flipper/ui/views/deny_actor.erb b/lib/flipper/ui/views/deny_actor.erb new file mode 100644 index 000000000..2839c01f9 --- /dev/null +++ b/lib/flipper/ui/views/deny_actor.erb @@ -0,0 +1,20 @@ +<% if params.key?("error") %> +
+ <%= params["error"] %> +
+<% end %> + +
+

Deny Actor for <%= @feature.key %>

+
+

+ Turn off this feature for actors. +

+
+ <%== csrf_input_tag %> + + + +
+
+
diff --git a/lib/flipper/ui/views/feature.erb b/lib/flipper/ui/views/feature.erb index ec87c034c..38e2862e8 100644 --- a/lib/flipper/ui/views/feature.erb +++ b/lib/flipper/ui/views/feature.erb @@ -78,6 +78,58 @@ <% end %> + <%# Denied Actors Info and Form %> +
+
+
+
+ "> + <% if @feature.denied_actors_value.count > 0 %> + Denied for <%= Util.pluralize @feature.actors_value.count, 'actor', 'actors' %> + <% else %> + No actors denied + <% end %> + +
+
+
+ +
+
+
+ <%== csrf_input_tag %> + + + +
+
+
+ +
+
+
+ + <%# Denied Actors list %> + <% @feature.denied_actors_value.each do |item| %> +
+
+
+
<%= item %>
+
+
+
+ <%== csrf_input_tag %> + + + +
+
+
+
+ <% end %> + <%# Groups Info and Form %>
diff --git a/spec/flipper/adapter_spec.rb b/spec/flipper/adapter_spec.rb index 5af2828cc..0da264cf7 100644 --- a/spec/flipper/adapter_spec.rb +++ b/spec/flipper/adapter_spec.rb @@ -6,6 +6,7 @@ boolean: nil, groups: Set.new, actors: Set.new, + denied_actors: Set.new, percentage_of_actors: nil, percentage_of_time: nil, } diff --git a/spec/flipper/adapters/read_only_spec.rb b/spec/flipper/adapters/read_only_spec.rb index 7df0d44ec..8113fd217 100644 --- a/spec/flipper/adapters/read_only_spec.rb +++ b/spec/flipper/adapters/read_only_spec.rb @@ -8,6 +8,7 @@ let(:boolean_gate) { feature.gate(:boolean) } let(:group_gate) { feature.gate(:group) } let(:actor_gate) { feature.gate(:actor) } + let(:denied_actor_gate) { feature.gate(:denied_actor) } let(:actors_gate) { feature.gate(:percentage_of_actors) } let(:time_gate) { feature.gate(:percentage_of_time) } @@ -42,15 +43,18 @@ it 'can get feature' do actor22 = Flipper::Actor.new('22') + actor23 = Flipper::Actor.new('23') adapter.enable(feature, boolean_gate, flipper.boolean) adapter.enable(feature, group_gate, flipper.group(:admins)) adapter.enable(feature, actor_gate, flipper.actor(actor22)) + adapter.enable(feature, denied_actor_gate, flipper.denied_actor(actor23)) adapter.enable(feature, actors_gate, flipper.actors(25)) adapter.enable(feature, time_gate, flipper.time(45)) expect(subject.get(feature)).to eq(boolean: 'true', groups: Set['admins'], actors: Set['22'], + denied_actors: Set['23'], percentage_of_actors: '25', percentage_of_time: '45') end diff --git a/spec/flipper/adapters/rollout_spec.rb b/spec/flipper/adapters/rollout_spec.rb index 95cb65bf4..f1596b9e4 100644 --- a/spec/flipper/adapters/rollout_spec.rb +++ b/spec/flipper/adapters/rollout_spec.rb @@ -35,6 +35,7 @@ boolean: nil, groups: Set.new([:admins]), actors: Set.new(["1"]), + denied_actors: Set.new, percentage_of_actors: 20.0, percentage_of_time: nil, } @@ -48,6 +49,7 @@ boolean: true, groups: Set.new, actors: Set.new, + denied_actors: Set.new, percentage_of_actors: nil, percentage_of_time: nil, } @@ -63,6 +65,7 @@ boolean: true, groups: Set.new, actors: Set.new, + denied_actors: Set.new, percentage_of_actors: nil, percentage_of_time: nil, } diff --git a/spec/flipper/api/v1/actions/denied_actors_gate_spec.rb b/spec/flipper/api/v1/actions/denied_actors_gate_spec.rb new file mode 100644 index 000000000..681b9cab1 --- /dev/null +++ b/spec/flipper/api/v1/actions/denied_actors_gate_spec.rb @@ -0,0 +1,104 @@ +RSpec.describe Flipper::Api::V1::Actions::DeniedActorsGate do + let(:app) { build_api(flipper) } + let(:actor) { Flipper::Actor.new('1') } + + describe 'deny' do + before do + flipper[:my_feature].enable_actor(actor) + flipper[:my_feature].reinstate_actor(actor) + post '/features/my_feature/denied_actors', flipper_id: actor.flipper_id + end + + it 'denies feature for actor' do + expect(last_response.status).to eq(200) + expect(flipper[:my_feature].enabled?(actor)).to be_falsy + end + end + + describe 'reinstate' do + let(:actor) { Flipper::Actor.new('1') } + + before do + flipper[:my_feature].enable_actor(actor) + flipper[:my_feature].deny_actor(actor) + delete '/features/my_feature/denied_actors', flipper_id: actor.flipper_id + end + + it 'reinstates feature' do + expect(last_response.status).to eq(200) + expect(flipper[:my_feature].enabled?(actor)).to be_truthy + expect(flipper[:my_feature].enabled_gate_names).not_to be_empty + end + end + + describe 'deny feature with slash in name' do + before do + flipper["my/feature"].enable_actor(actor) + flipper["my/feature"].reinstate_actor(actor) + post '/features/my/feature/denied_actors', flipper_id: actor.flipper_id + end + + it 'denies feature for actor' do + expect(last_response.status).to eq(200) + expect(flipper["my/feature"].enabled?(actor)).to be_falsy + end + end + + describe 'deny feature with space in name' do + before do + flipper["sp ace"].enable_actor(actor) + flipper["sp ace"].reinstate_actor(actor) + post '/features/sp%20ace/denied_actors', flipper_id: actor.flipper_id + end + + it 'denies feature for actor' do + expect(last_response.status).to eq(200) + expect(flipper["sp ace"].enabled?(actor)).to be_falsy + end + end + + + describe 'deny missing flipper_id parameter' do + before do + post '/features/my_feature/denied_actors' + end + + it 'returns correct error response' do + expect(last_response.status).to eq(422) + expect(json_response).to eq(api_flipper_id_is_missing_response) + end + end + + describe 'reinstate missing flipper_id parameter' do + before do + delete '/features/my_feature/denied_actors' + end + + it 'returns correct error response' do + expect(last_response.status).to eq(422) + expect(json_response).to eq(api_flipper_id_is_missing_response) + end + end + + describe 'deny nil flipper_id parameter' do + before do + post '/features/my_feature/denied_actors', flipper_id: nil + end + + it 'returns correct error response' do + expect(last_response.status).to eq(422) + expect(json_response).to eq(api_flipper_id_is_missing_response) + end + end + + describe 'reinstate nil flipper_id parameter' do + before do + delete '/features/my_feature/denied_actors', flipper_id: nil + end + + it 'returns correct error response' do + expect(last_response.status).to eq(422) + expect(json_response).to eq(api_flipper_id_is_missing_response) + end + end +end diff --git a/spec/flipper/api/v1/actions/feature_spec.rb b/spec/flipper/api/v1/actions/feature_spec.rb index d8e0b8776..5af38e2db 100644 --- a/spec/flipper/api/v1/actions/feature_spec.rb +++ b/spec/flipper/api/v1/actions/feature_spec.rb @@ -25,6 +25,11 @@ 'name' => 'actor', 'value' => [], }, + { + 'key' => 'denied_actors', + 'name' => 'denied_actor', + 'value' => [], + }, { 'key' => 'percentage_of_actors', 'name' => 'percentage_of_actors', @@ -69,6 +74,11 @@ 'name' => 'actor', 'value' => [], }, + { + 'key' => 'denied_actors', + 'name' => 'denied_actor', + 'value' => [], + }, { 'key' => 'percentage_of_actors', 'name' => 'percentage_of_actors', @@ -129,6 +139,11 @@ 'name' => 'actor', 'value' => [], }, + { + 'key' => 'denied_actors', + 'name' => 'denied_actor', + 'value' => [], + }, { 'key' => 'percentage_of_actors', 'name' => 'percentage_of_actors', @@ -173,6 +188,11 @@ 'name' => 'actor', 'value' => [], }, + { + 'key' => 'denied_actors', + 'name' => 'denied_actor', + 'value' => [], + }, { 'key' => 'percentage_of_actors', 'name' => 'percentage_of_actors', diff --git a/spec/flipper/api/v1/actions/features_spec.rb b/spec/flipper/api/v1/actions/features_spec.rb index 5631974b4..e479e991e 100644 --- a/spec/flipper/api/v1/actions/features_spec.rb +++ b/spec/flipper/api/v1/actions/features_spec.rb @@ -28,6 +28,11 @@ 'name' => 'actor', 'value' => ['10'], }, + { + 'key' => 'denied_actors', + 'name' => 'denied_actor', + 'value' => [], + }, { 'key' => 'percentage_of_actors', 'name' => 'percentage_of_actors', @@ -123,6 +128,11 @@ 'name' => 'actor', 'value' => [], }, + { + 'key' => 'denied_actors', + 'name' => 'denied_actor', + 'value' => [], + }, { 'key' => 'percentage_of_actors', 'name' => 'percentage_of_actors', diff --git a/spec/flipper/cloud_spec.rb b/spec/flipper/cloud_spec.rb index c0f2c203e..d561081cf 100644 --- a/spec/flipper/cloud_spec.rb +++ b/spec/flipper/cloud_spec.rb @@ -127,10 +127,10 @@ cloud_flipper = Flipper::Cloud.new(token: "asdf") get_all = { - "logging" => {actors: Set.new, boolean: nil, groups: Set.new, percentage_of_actors: nil, percentage_of_time: "5"}, - "search" => {actors: Set.new, boolean: "true", groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil}, - "stats" => {actors: Set["jnunemaker"], boolean: nil, groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil}, - "test" => {actors: Set.new, boolean: "true", groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil}, + "logging" => {actors: Set.new, denied_actors: Set.new, boolean: nil, groups: Set.new, percentage_of_actors: nil, percentage_of_time: "5"}, + "search" => {actors: Set.new, denied_actors: Set.new, boolean: "true", groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil}, + "stats" => {actors: Set["jnunemaker"], denied_actors: Set.new, boolean: nil, groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil}, + "test" => {actors: Set.new, denied_actors: Set.new, boolean: "true", groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil}, } expect(flipper.adapter.get_all).to eq(get_all) @@ -153,10 +153,10 @@ cloud_flipper = Flipper::Cloud.new(token: "asdf") get_all = { - "logging" => {actors: Set.new, boolean: nil, groups: Set.new, percentage_of_actors: nil, percentage_of_time: "5"}, - "search" => {actors: Set.new, boolean: "true", groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil}, - "stats" => {actors: Set["jnunemaker"], boolean: nil, groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil}, - "test" => {actors: Set.new, boolean: "true", groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil}, + "logging" => {actors: Set.new, denied_actors: Set.new, boolean: nil, groups: Set.new, percentage_of_actors: nil, percentage_of_time: "5"}, + "search" => {actors: Set.new, denied_actors: Set.new, boolean: "true", groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil}, + "stats" => {actors: Set["jnunemaker"], denied_actors: Set.new, boolean: nil, groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil}, + "test" => {actors: Set.new, denied_actors: Set.new, boolean: "true", groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil}, } expect(flipper.adapter.get_all).to eq(get_all) @@ -178,10 +178,10 @@ cloud_flipper = Flipper::Cloud.new(token: "asdf") get_all = { - "logging" => {actors: Set.new, boolean: nil, groups: Set.new, percentage_of_actors: nil, percentage_of_time: "5"}, - "search" => {actors: Set.new, boolean: "true", groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil}, - "stats" => {actors: Set["jnunemaker"], boolean: nil, groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil}, - "test" => {actors: Set.new, boolean: "true", groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil}, + "logging" => {actors: Set.new, denied_actors: Set.new, boolean: nil, groups: Set.new, percentage_of_actors: nil, percentage_of_time: "5"}, + "search" => {actors: Set.new, denied_actors: Set.new, boolean: "true", groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil}, + "stats" => {actors: Set["jnunemaker"], denied_actors: Set.new, boolean: nil, groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil}, + "test" => {actors: Set.new, denied_actors: Set.new, boolean: "true", groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil}, } expect(flipper.adapter.get_all).to eq(get_all) diff --git a/spec/flipper/feature_spec.rb b/spec/flipper/feature_spec.rb index c3b6ad451..25accd041 100644 --- a/spec/flipper/feature_spec.rb +++ b/spec/flipper/feature_spec.rb @@ -63,7 +63,32 @@ instance.gates.each do |gate| expect(gate).to be_a(Flipper::Gate) end - expect(instance.gates.size).to be(5) + + expect(instance.gates.size).to be(6) + end + end + + describe '#forward_gates' do + it 'returns array of gates' do + instance = described_class.new(:search, adapter) + expect(instance.gates).to be_instance_of(Array) + instance.forward_gates.each do |gate| + expect(gate).to be_a(Flipper::Gate) + end + + expect(instance.forward_gates.size).to be(5) + end + end + + describe '#deniable_gates' do + it 'returns array of gates' do + instance = described_class.new(:search, adapter) + expect(instance.gates).to be_instance_of(Array) + instance.deniable_gates.each do |gate| + expect(gate).to be_a(Flipper::Gate) + end + + expect(instance.deniable_gates.size).to be(1) end end @@ -291,10 +316,12 @@ user = Flipper::Actor.new('1') actor = Flipper::Types::Actor.new(user) + denied_actor = Flipper::Types::DeniedActor.new(user) { nil => nil, user => actor, actor => actor, + denied_actor => Flipper::Types::Actor.new(denied_actor), }.each do |thing, wrapped_thing| it "always instruments #{thing.inspect} as #{wrapped_thing.class} for enabled?" do subject.enabled?(thing) @@ -539,6 +566,25 @@ end end + describe '#denied_actors_value' do + context 'when no actors are denied' do + it 'returns empty set' do + expect(subject.denied_actors_value).to eq(Set.new) + end + end + + context 'when one or more actors are denied' do + before do + subject.deny Flipper::Types::DeniedActor.new(Flipper::Actor.new('User;5')) + subject.deny Flipper::Types::DeniedActor.new(Flipper::Actor.new('User;22')) + end + + it 'returns set of actor ids' do + expect(subject.denied_actors_value).to eq(Set.new(['User;5', 'User;22'])) + end + end + end + describe '#boolean_value' do context 'when not enabled or disabled' do it 'returns false' do @@ -634,6 +680,7 @@ before do subject.enable Flipper::Types::Boolean.new(true) subject.enable Flipper::Types::Actor.new(Flipper::Actor.new(5)) + subject.deny Flipper::Types::DeniedActor.new(Flipper::Actor.new(6)) subject.enable Flipper::Types::Group.new(:admins) subject.enable Flipper::Types::PercentageOfTime.new(50) subject.enable Flipper::Types::PercentageOfActors.new(25) @@ -641,6 +688,7 @@ it 'returns gate values' do expect(subject.gate_values).to eq(Flipper::GateValues.new(actors: Set.new(['5']), + denied_actors: Set.new(['6']), groups: Set.new(['admins']), boolean: 'true', percentage_of_time: '50', @@ -649,6 +697,31 @@ end end + describe '#deny_actor/reinstate_actor' do + context 'with object that responds to flipper_id' do + it 'updates the gate values to include the actor' do + actor = Flipper::Actor.new(5) + expect(subject.gate_values.denied_actors).to be_empty + subject.deny_actor(actor) + expect(subject.gate_values.denied_actors).to eq(Set['5']) + subject.reinstate_actor(actor) + expect(subject.gate_values.denied_actors).to be_empty + end + end + + context 'with actor instance' do + it 'updates the gate values to include the actor' do + actor = Flipper::Actor.new(5) + instance = Flipper::Types::DeniedActor.new(actor) + expect(subject.gate_values.denied_actors).to be_empty + subject.deny_actor(instance) + expect(subject.gate_values.denied_actors).to eq(Set['5']) + subject.reinstate_actor(instance) + expect(subject.gate_values.denied_actors).to be_empty + end + end + end + describe '#enable_actor/disable_actor' do context 'with object that responds to flipper_id' do it 'updates the gate values to include the actor' do @@ -799,12 +872,14 @@ it 'can return disabled gates' do expect(subject.disabled_gates.map(&:name).to_set).to eq(Set[ :actor, + :denied_actor, :boolean, :group, ]) expect(subject.disabled_gate_names.to_set).to eq(Set[ :actor, + :denied_actor, :boolean, :group, ]) diff --git a/spec/flipper/gate_values_spec.rb b/spec/flipper/gate_values_spec.rb index e66e56c0a..c904cece3 100644 --- a/spec/flipper/gate_values_spec.rb +++ b/spec/flipper/gate_values_spec.rb @@ -112,6 +112,11 @@ expect(described_class.new(actors: Set[1, 2])['actors']).to eq(Set[1, 2]) end + it 'can read the denied_actors value' do + expect(described_class.new(denied_actors: Set[1, 2])[:denied_actors]).to eq(Set[1, 2]) + expect(described_class.new(denied_actors: Set[1, 2])['denied_actors']).to eq(Set[1, 2]) + end + it 'can read the groups value' do expect(described_class.new(groups: Set[:admins])[:groups]).to eq(Set[:admins]) expect(described_class.new(groups: Set[:admins])['groups']).to eq(Set[:admins]) diff --git a/spec/flipper/gates/denied_actor_spec.rb b/spec/flipper/gates/denied_actor_spec.rb new file mode 100644 index 000000000..c1bffaca8 --- /dev/null +++ b/spec/flipper/gates/denied_actor_spec.rb @@ -0,0 +1,7 @@ +RSpec.describe Flipper::Gates::DeniedActor do + let(:feature_name) { :search } + + subject do + described_class.new + end +end diff --git a/spec/flipper/types/denied_actor_spec.rb b/spec/flipper/types/denied_actor_spec.rb new file mode 100644 index 000000000..d38d5a19a --- /dev/null +++ b/spec/flipper/types/denied_actor_spec.rb @@ -0,0 +1,115 @@ +require 'flipper/types/denied_actor' + +RSpec.describe Flipper::Types::DeniedActor do + subject do + thing = thing_class.new('2') + described_class.new(thing) + end + + let(:thing_class) do + Class.new do + attr_reader :flipper_id + + def initialize(flipper_id) + @flipper_id = flipper_id + end + + def admin? + true + end + end + end + + describe '.wrappable?' do + it 'returns true if denied actor' do + thing = thing_class.new('1') + denied_actor = described_class.new(thing) + expect(described_class.wrappable?(denied_actor)).to eq(true) + end + + it 'returns true if responds to flipper_id' do + thing = thing_class.new(10) + expect(described_class.wrappable?(thing)).to eq(true) + end + + it 'returns false if nil' do + expect(described_class.wrappable?(nil)).to be(false) + end + end + + describe '.wrap' do + context 'for denied actor' do + it 'returns denied actor' do + denied_actor = described_class.wrap(subject) + expect(denied_actor).to be_instance_of(described_class) + expect(denied_actor).to be(subject) + end + end + + context 'for other thing' do + it 'returns denied actor' do + thing = thing_class.new('1') + denied_actor = described_class.wrap(thing) + expect(denied_actor).to be_instance_of(described_class) + end + end + end + + it 'initializes with thing that responds to id' do + thing = thing_class.new('1') + denied_actor = described_class.new(thing) + expect(denied_actor.value).to eq('1') + end + + it 'raises error when initialized with nil' do + expect do + described_class.new(nil) + end.to raise_error(ArgumentError) + end + + it 'raises error when initalized with non-wrappable object' do + unwrappable_thing = Struct.new(:id).new(1) + expect do + described_class.new(unwrappable_thing) + end.to raise_error(ArgumentError, + "#{unwrappable_thing.inspect} must respond to flipper_id, but does not") + end + + it 'converts id to string' do + thing = thing_class.new(2) + denied_actor = described_class.new(thing) + expect(denied_actor.value).to eq('2') + end + + it 'proxies everything to thing' do + thing = thing_class.new(10) + denied_actor = described_class.new(thing) + expect(denied_actor.admin?).to eq(true) + end + + it 'exposes thing' do + thing = thing_class.new(10) + denied_actor = described_class.new(thing) + expect(denied_actor.thing).to be(thing) + end + + describe '#respond_to?' do + it 'returns true if responds to method' do + thing = thing_class.new('1') + denied_actor = described_class.new(thing) + expect(denied_actor.respond_to?(:value)).to eq(true) + end + + it 'returns true if thing responds to method' do + thing = thing_class.new(10) + denied_actor = described_class.new(thing) + expect(denied_actor.respond_to?(:admin?)).to eq(true) + end + + it 'returns false if does not respond to method and thing does not respond to method' do + thing = thing_class.new(10) + denied_actor = described_class.new(thing) + expect(denied_actor.respond_to?(:frankenstein)).to eq(false) + end + end +end diff --git a/spec/flipper/ui/actions/denied_actors_gate_spec.rb b/spec/flipper/ui/actions/denied_actors_gate_spec.rb new file mode 100644 index 000000000..5f010306f --- /dev/null +++ b/spec/flipper/ui/actions/denied_actors_gate_spec.rb @@ -0,0 +1,184 @@ +RSpec.describe Flipper::UI::Actions::ActorsGate do + let(:token) do + if Rack::Protection::AuthenticityToken.respond_to?(:random_token) + Rack::Protection::AuthenticityToken.random_token + else + 'a' + end + end + let(:session) do + { :csrf => token, 'csrf' => token, '_csrf_token' => token } + end + + describe 'GET /features/:feature/denied_actors' do + before do + get 'features/search/denied_actors' + end + + it 'responds with success' do + expect(last_response.status).to be(200) + end + + it 'renders add new denied actor form' do + form = '
' + expect(last_response.body).to include(form) + end + end + + describe 'GET /features/:feature/denied_actors with slash in feature name' do + before do + get 'features/a/b/denied_actors' + end + + it 'responds with success' do + expect(last_response.status).to be(200) + end + + it 'renders add new actor form' do + form = '' + expect(last_response.body).to include(form) + end + end + + describe 'POST /features/:feature/denied_actors' do + context 'denying an actor' do + let(:value) { 'User;6' } + let(:multi_value) { 'User;5, User;7, User;9, User;12' } + + before do + post 'features/search/denied_actors', + { 'value' => value, 'operation' => 'deny', 'authenticity_token' => token }, + 'rack.session' => session + end + + it 'adds item to members' do + expect(flipper[:search].denied_actors_value).to include(value) + end + + it 'adds item to multiple members' do + post 'features/search/denied_actors', + { 'value' => multi_value, 'operation' => 'deny', 'authenticity_token' => token }, + 'rack.session' => session + + expect(flipper[:search].denied_actors_value).to include('User;5') + expect(flipper[:search].denied_actors_value).to include('User;7') + expect(flipper[:search].denied_actors_value).to include('User;9') + expect(flipper[:search].denied_actors_value).to include('User;12') + end + + it 'redirects back to feature' do + expect(last_response.status).to be(302) + expect(last_response.headers['Location']).to eq('/features/search') + end + + context "when feature name contains space" do + before do + post 'features/sp%20ace/denied_actors', + { 'value' => value, 'operation' => 'deny', 'authenticity_token' => token }, + 'rack.session' => session + end + + it 'adds item to members' do + expect(flipper["sp ace"].denied_actors_value).to include('User;6') + end + + it "redirects back to feature" do + expect(last_response.status).to be(302) + expect(last_response.headers['Location']).to eq('/features/sp%20ace') + end + end + + context 'value contains whitespace' do + let(:value) { ' User;6 ' } + let(:multi_value) { ' User;5 , User;7 , User;9 , User;12 ' } + + it 'adds item without whitespace' do + expect(flipper[:search].denied_actors_value).to include('User;6') + end + + it 'adds item to multi members without whitespace' do + post 'features/search/denied_actors', + { 'value' => multi_value, 'operation' => 'deny', 'authenticity_token' => token }, + 'rack.session' => session + + expect(flipper[:search].denied_actors_value).to include('User;5') + expect(flipper[:search].denied_actors_value).to include('User;7') + expect(flipper[:search].denied_actors_value).to include('User;9') + expect(flipper[:search].denied_actors_value).to include('User;12') + end + end + + context 'for an invalid actor value' do + context 'empty value' do + let(:value) { '' } + + it 'redirects to denied actors page' do + expect(last_response.status).to be(302) + expect(last_response.headers['Location']).to eq('/features/search/denied_actors?error=%22%22%20is%20not%20a%20valid%20actor%20value.') + end + end + + context 'nil value' do + let(:value) { nil } + + it 'redirects to denied actors page' do + expect(last_response.status).to be(302) + expect(last_response.headers['Location']).to eq('/features/search/denied_actors?error=%22%22%20is%20not%20a%20valid%20actor%20value.') + end + end + end + end + + context 'reinstating an actor' do + let(:value) { 'User;6' } + let(:multi_value) { 'User;5, User;7, User;9, User;12' } + + before do + flipper[:search].deny_actor Flipper::Actor.new(value) + post 'features/search/denied_actors', + { 'value' => value, 'operation' => 'reinstate', 'authenticity_token' => token }, + 'rack.session' => session + end + + it 'removes item from members' do + expect(flipper[:search].denied_actors_value).not_to include(value) + end + + it 'removes item from multi members' do + multi_value.split(',').map(&:strip).each do |value| + flipper[:search].deny_actor Flipper::Actor.new(value) + end + + post 'features/search/denied_actors', + { 'value' => multi_value, 'operation' => 'reinstate', 'authenticity_token' => token }, + 'rack.session' => session + + expect(flipper[:search].denied_actors_value).not_to eq(Set.new(multi_value.split(',').map(&:strip))) + end + + it 'redirects back to feature' do + expect(last_response.status).to be(302) + expect(last_response.headers['Location']).to eq('/features/search') + end + + context 'value contains whitespace' do + let(:value) { ' User;6 ' } + let(:multi_value) { ' User;5 , User;7 , User;9 , User;12 ' } + + it 'removes item without whitespace' do + expect(flipper[:search].denied_actors_value).not_to include('User;6') + end + + it 'removes item without whitespace' do + multi_value.split(',').map(&:strip).each do |value| + flipper[:search].deny_actor Flipper::Actor.new(value) + end + post 'features/search/denied_actors', + { 'value' => multi_value, 'operation' => 'reinstate', 'authenticity_token' => token }, + 'rack.session' => session + expect(flipper[:search].denied_actors_value).not_to eq(Set.new(multi_value.split(',').map(&:strip))) + end + end + end + end +end