flipper 1.3.6 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +9 -6
- data/.github/workflows/examples.yml +5 -4
- data/.github/workflows/release.yml +54 -0
- data/.superset/config.json +4 -0
- data/CLAUDE.md +22 -3
- data/README.md +4 -3
- data/examples/cloud/poll_interval/README.md +111 -0
- data/examples/cloud/poll_interval/client.rb +108 -0
- data/examples/cloud/poll_interval/server.rb +98 -0
- data/examples/expressions.rb +35 -11
- data/lib/flipper/adapter.rb +17 -1
- data/lib/flipper/adapters/actor_limit.rb +27 -1
- data/lib/flipper/adapters/cache_base.rb +21 -3
- data/lib/flipper/adapters/dual_write.rb +6 -2
- data/lib/flipper/adapters/failover.rb +9 -3
- data/lib/flipper/adapters/failsafe.rb +2 -2
- data/lib/flipper/adapters/http/client.rb +15 -4
- data/lib/flipper/adapters/http.rb +37 -2
- data/lib/flipper/adapters/instrumented.rb +2 -2
- data/lib/flipper/adapters/memoizable.rb +3 -3
- data/lib/flipper/adapters/memory.rb +1 -1
- data/lib/flipper/adapters/pstore.rb +1 -1
- data/lib/flipper/adapters/strict.rb +30 -0
- data/lib/flipper/adapters/sync/feature_synchronizer.rb +5 -1
- data/lib/flipper/adapters/sync/synchronizer.rb +13 -5
- data/lib/flipper/adapters/sync.rb +7 -3
- data/lib/flipper/cli.rb +51 -0
- data/lib/flipper/cloud/configuration.rb +9 -4
- data/lib/flipper/cloud/dsl.rb +2 -2
- data/lib/flipper/cloud/middleware.rb +1 -1
- data/lib/flipper/cloud/migrate.rb +71 -0
- data/lib/flipper/cloud/telemetry.rb +1 -1
- data/lib/flipper/cloud.rb +1 -0
- data/lib/flipper/dsl.rb +1 -1
- data/lib/flipper/expressions/feature_enabled.rb +34 -0
- data/lib/flipper/expressions/time.rb +8 -1
- data/lib/flipper/gates/expression.rb +2 -2
- data/lib/flipper/poller.rb +52 -9
- data/lib/flipper/version.rb +1 -1
- data/lib/flipper.rb +17 -1
- data/spec/flipper/adapter_spec.rb +20 -0
- data/spec/flipper/adapters/actor_limit_spec.rb +55 -0
- data/spec/flipper/adapters/dual_write_spec.rb +13 -0
- data/spec/flipper/adapters/failover_spec.rb +12 -0
- data/spec/flipper/adapters/http_spec.rb +240 -0
- data/spec/flipper/adapters/strict_spec.rb +62 -4
- data/spec/flipper/adapters/sync/feature_synchronizer_spec.rb +12 -0
- data/spec/flipper/adapters/sync/synchronizer_spec.rb +87 -0
- data/spec/flipper/adapters/sync_spec.rb +13 -0
- data/spec/flipper/cli_spec.rb +51 -0
- data/spec/flipper/cloud/configuration_spec.rb +6 -0
- data/spec/flipper/cloud/dsl_spec.rb +10 -2
- data/spec/flipper/cloud/middleware_spec.rb +34 -16
- data/spec/flipper/cloud/migrate_spec.rb +160 -0
- data/spec/flipper/cloud/telemetry_spec.rb +1 -1
- data/spec/flipper/engine_spec.rb +2 -2
- data/spec/flipper/expressions/time_spec.rb +16 -0
- data/spec/flipper/gates/expression_spec.rb +82 -0
- data/spec/flipper/middleware/memoizer_spec.rb +37 -6
- data/spec/flipper/poller_spec.rb +347 -4
- data/spec/flipper_integration_spec.rb +133 -0
- data/spec/flipper_spec.rb +6 -1
- data/spec/spec_helper.rb +7 -0
- metadata +17 -112
- data/lib/flipper/expressions/duration.rb +0 -28
- data/spec/flipper/expressions/duration_spec.rb +0 -43
|
@@ -21,6 +21,12 @@ RSpec.describe Flipper::Adapters::Strict do
|
|
|
21
21
|
expect { subject.get_multi([feature]) }.to raise_error(Flipper::Adapters::Strict::NotFound)
|
|
22
22
|
end
|
|
23
23
|
end
|
|
24
|
+
|
|
25
|
+
context "#add" do
|
|
26
|
+
it "raises an error for unknown feature" do
|
|
27
|
+
expect { subject.add(feature) }.to raise_error(Flipper::Adapters::Strict::NotFound)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
24
30
|
end
|
|
25
31
|
end
|
|
26
32
|
|
|
@@ -28,16 +34,22 @@ RSpec.describe Flipper::Adapters::Strict do
|
|
|
28
34
|
subject { described_class.new(Flipper::Adapters::Memory.new, :warn) }
|
|
29
35
|
|
|
30
36
|
context "#get" do
|
|
31
|
-
it "
|
|
37
|
+
it "warns for unknown feature" do
|
|
32
38
|
expect(capture_output { subject.get(feature) }).to match(/Could not find feature "unknown"/)
|
|
33
39
|
end
|
|
34
40
|
end
|
|
35
41
|
|
|
36
42
|
context "#get_multi" do
|
|
37
|
-
it "
|
|
43
|
+
it "warns for unknown feature" do
|
|
38
44
|
expect(capture_output { subject.get_multi([feature]) }).to match(/Could not find feature "unknown"/)
|
|
39
45
|
end
|
|
40
46
|
end
|
|
47
|
+
|
|
48
|
+
context "#add" do
|
|
49
|
+
it "warns for unknown feature" do
|
|
50
|
+
expect(capture_output { subject.add(feature) }).to match(/Could not find feature "unknown"/)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
41
53
|
end
|
|
42
54
|
|
|
43
55
|
context "handler = Block" do
|
|
@@ -48,17 +60,63 @@ RSpec.describe Flipper::Adapters::Strict do
|
|
|
48
60
|
|
|
49
61
|
|
|
50
62
|
context "#get" do
|
|
51
|
-
it "
|
|
63
|
+
it "calls block for unknown feature" do
|
|
52
64
|
subject.get(feature)
|
|
53
65
|
expect(unknown_features).to eq(["unknown"])
|
|
54
66
|
end
|
|
55
67
|
end
|
|
56
68
|
|
|
57
69
|
context "#get_multi" do
|
|
58
|
-
it "
|
|
70
|
+
it "calls block for unknown feature" do
|
|
59
71
|
subject.get_multi([flipper[:foo], flipper[:bar]])
|
|
60
72
|
expect(unknown_features).to eq(["foo", "bar"])
|
|
61
73
|
end
|
|
62
74
|
end
|
|
75
|
+
|
|
76
|
+
context "#add" do
|
|
77
|
+
it "calls block for unknown feature" do
|
|
78
|
+
subject.add(feature)
|
|
79
|
+
expect(unknown_features).to eq(["unknown"])
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
describe ".with_sync_mode" do
|
|
85
|
+
subject { described_class.new(Flipper::Adapters::Memory.new, :raise) }
|
|
86
|
+
|
|
87
|
+
it "bypasses strict checks for add" do
|
|
88
|
+
described_class.with_sync_mode do
|
|
89
|
+
expect { subject.add(feature) }.not_to raise_error
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it "bypasses strict checks for get" do
|
|
94
|
+
described_class.with_sync_mode do
|
|
95
|
+
expect { subject.get(feature) }.not_to raise_error
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it "bypasses strict checks for get_multi" do
|
|
100
|
+
described_class.with_sync_mode do
|
|
101
|
+
expect { subject.get_multi([feature]) }.not_to raise_error
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it "restores previous sync mode after block" do
|
|
106
|
+
described_class.with_sync_mode do
|
|
107
|
+
# inside sync mode
|
|
108
|
+
end
|
|
109
|
+
expect { subject.add(feature) }.to raise_error(Flipper::Adapters::Strict::NotFound)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
it "restores previous sync mode even on error" do
|
|
113
|
+
begin
|
|
114
|
+
described_class.with_sync_mode do
|
|
115
|
+
raise "boom"
|
|
116
|
+
end
|
|
117
|
+
rescue RuntimeError
|
|
118
|
+
end
|
|
119
|
+
expect { subject.add(feature) }.to raise_error(Flipper::Adapters::Strict::NotFound)
|
|
120
|
+
end
|
|
63
121
|
end
|
|
64
122
|
end
|
|
@@ -105,6 +105,18 @@ RSpec.describe Flipper::Adapters::Sync::FeatureSynchronizer do
|
|
|
105
105
|
expect_no_enable_or_disable
|
|
106
106
|
end
|
|
107
107
|
|
|
108
|
+
it "updates expression when remote conditionally enabled but expression is nil" do
|
|
109
|
+
remote = Flipper::GateValues.new(expression: nil, actors: Set["1"])
|
|
110
|
+
feature.enable_expression(plan_expression)
|
|
111
|
+
feature.enable_actor(Flipper::Actor.new("1"))
|
|
112
|
+
adapter.reset
|
|
113
|
+
|
|
114
|
+
described_class.new(feature, feature.gate_values, remote).call
|
|
115
|
+
|
|
116
|
+
expect(feature.expression_value).to eq(nil)
|
|
117
|
+
expect_only_disable
|
|
118
|
+
end
|
|
119
|
+
|
|
108
120
|
it "adds remotely added actors" do
|
|
109
121
|
remote = Flipper::GateValues.new(actors: Set["1", "2"])
|
|
110
122
|
feature.enable_actor(Flipper::Actor.new("1"))
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "flipper/adapters/memory"
|
|
2
|
+
require "flipper/adapters/actor_limit"
|
|
2
3
|
require "flipper/instrumenters/memory"
|
|
3
4
|
require "flipper/adapters/sync/synchronizer"
|
|
4
5
|
|
|
@@ -84,5 +85,91 @@ RSpec.describe Flipper::Adapters::Sync::Synchronizer do
|
|
|
84
85
|
|
|
85
86
|
expect(local_flipper.features.map(&:key)).to eq([])
|
|
86
87
|
end
|
|
88
|
+
|
|
89
|
+
it 'emits feature_operation.flipper events when syncing' do
|
|
90
|
+
remote_flipper.enable(:search)
|
|
91
|
+
|
|
92
|
+
subject.call
|
|
93
|
+
|
|
94
|
+
events = instrumenter.events_by_name("feature_operation.flipper")
|
|
95
|
+
enable_events = events.select { |e| e.payload[:operation] == :enable }
|
|
96
|
+
expect(enable_events).not_to be_empty
|
|
97
|
+
|
|
98
|
+
feature_names = enable_events.map { |e| e.payload[:feature_name].to_s }
|
|
99
|
+
expect(feature_names).to include("search")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
it 'emits feature_operation.flipper events when adding features' do
|
|
103
|
+
remote_flipper.add(:new_feature)
|
|
104
|
+
|
|
105
|
+
subject.call
|
|
106
|
+
|
|
107
|
+
events = instrumenter.events_by_name("feature_operation.flipper")
|
|
108
|
+
add_events = events.select { |e| e.payload[:operation] == :add }
|
|
109
|
+
expect(add_events).not_to be_empty
|
|
110
|
+
|
|
111
|
+
feature_names = add_events.map { |e| e.payload[:feature_name].to_s }
|
|
112
|
+
expect(feature_names).to include("new_feature")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it 'emits feature_operation.flipper events when removing features' do
|
|
116
|
+
local_flipper.add(:old_feature)
|
|
117
|
+
|
|
118
|
+
subject.call
|
|
119
|
+
|
|
120
|
+
events = instrumenter.events_by_name("feature_operation.flipper")
|
|
121
|
+
remove_events = events.select { |e| e.payload[:operation] == :remove }
|
|
122
|
+
expect(remove_events).not_to be_empty
|
|
123
|
+
|
|
124
|
+
feature_names = remove_events.map { |e| e.payload[:feature_name].to_s }
|
|
125
|
+
expect(feature_names).to include("old_feature")
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
context 'with ActorLimit adapter wrapping local' do
|
|
130
|
+
let(:limit) { 10 }
|
|
131
|
+
let(:limited_local) { Flipper::Adapters::ActorLimit.new(local, limit) }
|
|
132
|
+
let(:limited_local_flipper) { Flipper.new(limited_local) }
|
|
133
|
+
|
|
134
|
+
subject { described_class.new(limited_local, remote, instrumenter: instrumenter) }
|
|
135
|
+
|
|
136
|
+
it 'syncs actors even when remote has more actors than local limit' do
|
|
137
|
+
# Remote has more actors than local limit allows
|
|
138
|
+
20.times { |i| remote_flipper[:search].enable_actor Flipper::Actor.new("User;#{i}") }
|
|
139
|
+
|
|
140
|
+
# This should NOT raise - sync should bypass actor limits
|
|
141
|
+
expect { subject.call }.not_to raise_error
|
|
142
|
+
|
|
143
|
+
# All actors should be synced
|
|
144
|
+
expect(limited_local_flipper[:search].actors_value.size).to eq(20)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
it 'syncs new actors added to remote after initial sync' do
|
|
148
|
+
# Initial state: remote has 20 actors, local limit is 10
|
|
149
|
+
20.times { |i| remote_flipper[:search].enable_actor Flipper::Actor.new("User;#{i}") }
|
|
150
|
+
|
|
151
|
+
# First sync - should work despite exceeding limit
|
|
152
|
+
subject.call
|
|
153
|
+
expect(limited_local_flipper[:search].actors_value.size).to eq(20)
|
|
154
|
+
|
|
155
|
+
# Add a 21st actor to remote (simulating Cloud adding a new actor)
|
|
156
|
+
remote_flipper[:search].enable_actor Flipper::Actor.new("User;20")
|
|
157
|
+
|
|
158
|
+
# Sync again - should pick up the new actor
|
|
159
|
+
expect { subject.call }.not_to raise_error
|
|
160
|
+
expect(limited_local_flipper[:search].actors_value.size).to eq(21)
|
|
161
|
+
expect(limited_local_flipper[:search].actors_value).to include("User;20")
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
it 'still enforces limit for direct enable operations' do
|
|
165
|
+
# First sync 20 actors from remote
|
|
166
|
+
20.times { |i| remote_flipper[:search].enable_actor Flipper::Actor.new("User;#{i}") }
|
|
167
|
+
subject.call
|
|
168
|
+
|
|
169
|
+
# Direct enable should still fail because we're over limit
|
|
170
|
+
expect {
|
|
171
|
+
limited_local_flipper[:search].enable_actor Flipper::Actor.new("User;new")
|
|
172
|
+
}.to raise_error(Flipper::Adapters::ActorLimit::LimitExceeded)
|
|
173
|
+
end
|
|
87
174
|
end
|
|
88
175
|
end
|
|
@@ -197,4 +197,17 @@ RSpec.describe Flipper::Adapters::Sync do
|
|
|
197
197
|
expect(remote_adapter).to receive(:get_all).and_raise(exception)
|
|
198
198
|
expect { subject.get_all }.not_to raise_error
|
|
199
199
|
end
|
|
200
|
+
|
|
201
|
+
describe '#adapter_stack' do
|
|
202
|
+
it 'returns the tree representation' do
|
|
203
|
+
expect(subject.adapter_stack).to eq("sync(local: operation_logger -> memory, remote: operation_logger -> memory)")
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
it 'shows nested adapters in the tree' do
|
|
207
|
+
memory = Flipper::Adapters::Memory.new
|
|
208
|
+
strict = Flipper::Adapters::Strict.new(Flipper::Adapters::Memory.new)
|
|
209
|
+
adapter = described_class.new(memory, strict, interval: 1)
|
|
210
|
+
expect(adapter.adapter_stack).to eq("sync(local: memory, remote: strict -> memory)")
|
|
211
|
+
end
|
|
212
|
+
end
|
|
200
213
|
end
|
data/spec/flipper/cli_spec.rb
CHANGED
|
@@ -147,6 +147,57 @@ RSpec.describe Flipper::CLI do
|
|
|
147
147
|
it { should have_attributes(status: 1, stderr: /invalid option: --nope/) }
|
|
148
148
|
end
|
|
149
149
|
|
|
150
|
+
describe "export" do
|
|
151
|
+
before do
|
|
152
|
+
Flipper.enable :search
|
|
153
|
+
Flipper.disable :analytics
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
it "outputs valid JSON export" do
|
|
157
|
+
expect(subject).to have_attributes(status: 0)
|
|
158
|
+
data = JSON.parse(subject.stdout)
|
|
159
|
+
expect(data["version"]).to eq(1)
|
|
160
|
+
expect(data["features"]).to have_key("search")
|
|
161
|
+
expect(data["features"]).to have_key("analytics")
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
describe "cloud" do
|
|
166
|
+
it "shows help when no subcommand given" do
|
|
167
|
+
expect(subject).to have_attributes(status: 0, stdout: /migrate/)
|
|
168
|
+
expect(subject.stdout).to match(/push/)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
describe "cloud migrate" do
|
|
173
|
+
before do
|
|
174
|
+
Flipper.enable :search
|
|
175
|
+
require 'flipper/cloud/migrate'
|
|
176
|
+
allow(Flipper::Cloud).to receive(:migrate).and_return(
|
|
177
|
+
Flipper::Cloud::MigrateResult.new(code: 200, url: "https://www.flippercloud.io/cloud/setup/abc123")
|
|
178
|
+
)
|
|
179
|
+
allow(cli).to receive(:system)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
it "prints the cloud URL" do
|
|
183
|
+
expect(subject).to have_attributes(status: 0, stdout: /flippercloud\.io/)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
describe "cloud push test-token" do
|
|
188
|
+
before do
|
|
189
|
+
Flipper.enable :search
|
|
190
|
+
require 'flipper/cloud/migrate'
|
|
191
|
+
allow(Flipper::Cloud).to receive(:push).and_return(
|
|
192
|
+
Flipper::Cloud::MigrateResult.new(code: 204, url: nil)
|
|
193
|
+
)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
it "prints success message" do
|
|
197
|
+
expect(subject).to have_attributes(status: 0, stdout: /Successfully pushed/)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
150
201
|
describe "show foo" do
|
|
151
202
|
context "boolean" do
|
|
152
203
|
before { Flipper.enable :foo }
|
|
@@ -135,6 +135,9 @@ RSpec.describe Flipper::Cloud::Configuration do
|
|
|
135
135
|
end
|
|
136
136
|
|
|
137
137
|
it "sets sync_method to :webhook if sync_secret provided" do
|
|
138
|
+
# The initial sync of http to local invokes this web request.
|
|
139
|
+
stub_request(:get, /flippercloud\.io/).to_return(status: 200, body: "{}")
|
|
140
|
+
|
|
138
141
|
instance = described_class.new(required_options.merge({
|
|
139
142
|
sync_secret: "secret",
|
|
140
143
|
}))
|
|
@@ -144,6 +147,9 @@ RSpec.describe Flipper::Cloud::Configuration do
|
|
|
144
147
|
end
|
|
145
148
|
|
|
146
149
|
it "sets sync_method to :webhook if FLIPPER_CLOUD_SYNC_SECRET set" do
|
|
150
|
+
# The initial sync of http to local invokes this web request.
|
|
151
|
+
stub_request(:get, /flippercloud\.io/).to_return(status: 200, body: "{}")
|
|
152
|
+
|
|
147
153
|
ENV["FLIPPER_CLOUD_SYNC_SECRET"] = "abc"
|
|
148
154
|
instance = described_class.new(required_options)
|
|
149
155
|
|
|
@@ -5,6 +5,9 @@ require 'flipper/adapters/instrumented'
|
|
|
5
5
|
|
|
6
6
|
RSpec.describe Flipper::Cloud::DSL do
|
|
7
7
|
it 'delegates everything to flipper instance' do
|
|
8
|
+
# stub the initial sync of http to local
|
|
9
|
+
stub_request(:get, /flippercloud\.io/).to_return(status: 200, body: "{}")
|
|
10
|
+
|
|
8
11
|
cloud_configuration = Flipper::Cloud::Configuration.new({
|
|
9
12
|
token: "asdf",
|
|
10
13
|
sync_secret: "tasty",
|
|
@@ -27,10 +30,13 @@ RSpec.describe Flipper::Cloud::DSL do
|
|
|
27
30
|
})
|
|
28
31
|
dsl = described_class.new(cloud_configuration)
|
|
29
32
|
dsl.sync
|
|
30
|
-
expect(stub).to have_been_requested
|
|
33
|
+
expect(stub).to have_been_requested.at_least_once
|
|
31
34
|
end
|
|
32
35
|
|
|
33
36
|
it 'delegates sync_secret to cloud configuration' do
|
|
37
|
+
# stub the initial sync of http to local
|
|
38
|
+
stub_request(:get, /flippercloud\.io/).to_return(status: 200, body: "{}")
|
|
39
|
+
|
|
34
40
|
cloud_configuration = Flipper::Cloud::Configuration.new({
|
|
35
41
|
token: "asdf",
|
|
36
42
|
sync_secret: "tasty",
|
|
@@ -53,13 +59,15 @@ RSpec.describe Flipper::Cloud::DSL do
|
|
|
53
59
|
end
|
|
54
60
|
|
|
55
61
|
subject do
|
|
62
|
+
# stub the initial sync of http to local
|
|
63
|
+
stub_request(:get, /flippercloud\.io/).to_return(status: 200, body: "{}")
|
|
56
64
|
described_class.new(cloud_configuration)
|
|
57
65
|
end
|
|
58
66
|
|
|
59
67
|
it "sends reads to local adapter" do
|
|
60
68
|
subject.features
|
|
61
69
|
subject.enabled?(:foo)
|
|
62
|
-
expect(local_adapter.count(:features)).to be(
|
|
70
|
+
expect(local_adapter.count(:features)).to be(2)
|
|
63
71
|
expect(local_adapter.count(:get)).to be(1)
|
|
64
72
|
end
|
|
65
73
|
|
|
@@ -62,7 +62,7 @@ RSpec.describe Flipper::Cloud::Middleware do
|
|
|
62
62
|
{"name" => "premium"},
|
|
63
63
|
],
|
|
64
64
|
})
|
|
65
|
-
expect(stub).to
|
|
65
|
+
expect(stub).to have_been_made.at_least_once
|
|
66
66
|
end
|
|
67
67
|
end
|
|
68
68
|
|
|
@@ -72,15 +72,17 @@ RSpec.describe Flipper::Cloud::Middleware do
|
|
|
72
72
|
Flipper::Cloud::MessageVerifier.new(secret: "nope").generate(request_body, timestamp)
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
it '
|
|
76
|
-
|
|
75
|
+
it 'does not perform webhook sync' do
|
|
76
|
+
webhook_regular_stub = stub_request_for_token('regular', from_webhook: true)
|
|
77
|
+
poll_regular_stub = stub_request_for_token('regular', from_webhook: false)
|
|
77
78
|
env = {
|
|
78
79
|
"HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
|
|
79
80
|
}
|
|
80
81
|
post '/', request_body, env
|
|
81
82
|
|
|
82
83
|
expect(last_response.status).to eq(400)
|
|
83
|
-
expect(
|
|
84
|
+
expect(poll_regular_stub).to have_been_requested.at_least_once
|
|
85
|
+
expect(webhook_regular_stub).not_to have_been_requested
|
|
84
86
|
end
|
|
85
87
|
end
|
|
86
88
|
|
|
@@ -103,7 +105,7 @@ RSpec.describe Flipper::Cloud::Middleware do
|
|
|
103
105
|
expect(last_response.status).to eq(402)
|
|
104
106
|
expect(last_response.headers["flipper-cloud-response-error-class"]).to eq("Flipper::Adapters::Http::Error")
|
|
105
107
|
expect(last_response.headers["flipper-cloud-response-error-message"]).to include("Failed with status: 402")
|
|
106
|
-
expect(stub).to
|
|
108
|
+
expect(stub).to have_been_made.at_least_once
|
|
107
109
|
end
|
|
108
110
|
end
|
|
109
111
|
|
|
@@ -126,7 +128,7 @@ RSpec.describe Flipper::Cloud::Middleware do
|
|
|
126
128
|
expect(last_response.status).to eq(500)
|
|
127
129
|
expect(last_response.headers["flipper-cloud-response-error-class"]).to eq("Flipper::Adapters::Http::Error")
|
|
128
130
|
expect(last_response.headers["flipper-cloud-response-error-message"]).to include("Failed with status: 503")
|
|
129
|
-
expect(stub).to
|
|
131
|
+
expect(stub).to have_been_made.at_least_once
|
|
130
132
|
end
|
|
131
133
|
end
|
|
132
134
|
|
|
@@ -149,7 +151,7 @@ RSpec.describe Flipper::Cloud::Middleware do
|
|
|
149
151
|
expect(last_response.status).to eq(500)
|
|
150
152
|
expect(last_response.headers["flipper-cloud-response-error-class"]).to eq("Net::OpenTimeout")
|
|
151
153
|
expect(last_response.headers["flipper-cloud-response-error-message"]).to eq("execution expired")
|
|
152
|
-
expect(stub).to
|
|
154
|
+
expect(stub).to have_been_made.at_least_once
|
|
153
155
|
end
|
|
154
156
|
end
|
|
155
157
|
|
|
@@ -160,7 +162,8 @@ RSpec.describe Flipper::Cloud::Middleware do
|
|
|
160
162
|
}
|
|
161
163
|
|
|
162
164
|
it 'uses env instance to sync' do
|
|
163
|
-
|
|
165
|
+
regular_stub = stub_request_for_token('regular')
|
|
166
|
+
env_stub = stub_request_for_token('env')
|
|
164
167
|
env = {
|
|
165
168
|
"HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
|
|
166
169
|
'flipper' => env_flipper,
|
|
@@ -168,7 +171,8 @@ RSpec.describe Flipper::Cloud::Middleware do
|
|
|
168
171
|
post '/', request_body, env
|
|
169
172
|
|
|
170
173
|
expect(last_response.status).to eq(200)
|
|
171
|
-
expect(
|
|
174
|
+
expect(regular_stub).to have_been_made.at_least_once
|
|
175
|
+
expect(env_stub).to have_been_made.at_least_once
|
|
172
176
|
end
|
|
173
177
|
end
|
|
174
178
|
|
|
@@ -187,7 +191,7 @@ RSpec.describe Flipper::Cloud::Middleware do
|
|
|
187
191
|
post '/', request_body, env
|
|
188
192
|
|
|
189
193
|
expect(last_response.status).to eq(200)
|
|
190
|
-
expect(stub).to
|
|
194
|
+
expect(stub).to have_been_made.at_least_once
|
|
191
195
|
end
|
|
192
196
|
end
|
|
193
197
|
|
|
@@ -198,7 +202,9 @@ RSpec.describe Flipper::Cloud::Middleware do
|
|
|
198
202
|
}
|
|
199
203
|
|
|
200
204
|
it 'uses provided env key instead of default' do
|
|
201
|
-
|
|
205
|
+
regular_poll_stub = stub_request_for_token('regular')
|
|
206
|
+
env_poll_stub = stub_request_for_token('env')
|
|
207
|
+
env_webhook_stub = stub_request_for_token('env', from_webhook: true)
|
|
202
208
|
env = {
|
|
203
209
|
"HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
|
|
204
210
|
'flipper' => flipper,
|
|
@@ -207,7 +213,9 @@ RSpec.describe Flipper::Cloud::Middleware do
|
|
|
207
213
|
post '/', request_body, env
|
|
208
214
|
|
|
209
215
|
expect(last_response.status).to eq(200)
|
|
210
|
-
expect(
|
|
216
|
+
expect(regular_poll_stub).to have_been_made.at_least_once
|
|
217
|
+
expect(env_poll_stub).to have_been_made.at_least_once
|
|
218
|
+
expect(env_webhook_stub).not_to have_been_requested
|
|
211
219
|
end
|
|
212
220
|
end
|
|
213
221
|
|
|
@@ -222,7 +230,7 @@ RSpec.describe Flipper::Cloud::Middleware do
|
|
|
222
230
|
post '/', request_body, env
|
|
223
231
|
|
|
224
232
|
expect(last_response.status).to eq(200)
|
|
225
|
-
expect(stub).to
|
|
233
|
+
expect(stub).to have_been_made.at_least_once
|
|
226
234
|
end
|
|
227
235
|
end
|
|
228
236
|
|
|
@@ -252,12 +260,13 @@ RSpec.describe Flipper::Cloud::Middleware do
|
|
|
252
260
|
{"name" => "premium"},
|
|
253
261
|
],
|
|
254
262
|
})
|
|
255
|
-
expect(stub).to
|
|
263
|
+
expect(stub).to have_been_made.at_least_once
|
|
256
264
|
end
|
|
257
265
|
end
|
|
258
266
|
|
|
259
267
|
describe 'Request method unsupported' do
|
|
260
268
|
it 'skips middleware' do
|
|
269
|
+
stub_request(:get, /flippercloud\.io/).to_return(status: 200, body: "{}")
|
|
261
270
|
get '/'
|
|
262
271
|
expect(last_response.status).to eq(404)
|
|
263
272
|
expect(last_response.content_type).to eq("application/json")
|
|
@@ -267,14 +276,23 @@ RSpec.describe Flipper::Cloud::Middleware do
|
|
|
267
276
|
|
|
268
277
|
describe 'Inspecting the built Rack app' do
|
|
269
278
|
it 'returns a String' do
|
|
279
|
+
stub_request(:get, /flippercloud\.io/).to_return(status: 200, body: "{}")
|
|
270
280
|
expect(Flipper::Cloud.app(flipper).inspect).to eq("Flipper::Cloud")
|
|
271
281
|
end
|
|
272
282
|
end
|
|
273
283
|
|
|
274
284
|
private
|
|
275
285
|
|
|
276
|
-
def stub_request_for_token(token, status: 200)
|
|
277
|
-
|
|
286
|
+
def stub_request_for_token(token, status: 200, from_webhook: false)
|
|
287
|
+
if from_webhook
|
|
288
|
+
# Match URL with both exclude_gate_names=true and _cb=integer
|
|
289
|
+
url_pattern = %r{https://www\.flippercloud\.io/adapter/features\?.*exclude_gate_names=true.*&_cb=\d+}
|
|
290
|
+
else
|
|
291
|
+
# Match URL with just exclude_gate_names=true
|
|
292
|
+
url_pattern = %r{https://www\.flippercloud\.io/adapter/features\?.*exclude_gate_names=true}
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
stub = stub_request(:get, url_pattern).
|
|
278
296
|
with({
|
|
279
297
|
headers: {
|
|
280
298
|
'flipper-cloud-token' => token,
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
require "flipper/cloud/migrate"
|
|
2
|
+
require "flipper/typecast"
|
|
3
|
+
require "webmock/rspec"
|
|
4
|
+
|
|
5
|
+
RSpec.describe Flipper::Cloud, ".migrate" do
|
|
6
|
+
let(:flipper) { Flipper.new(Flipper::Adapters::Memory.new) }
|
|
7
|
+
|
|
8
|
+
before do
|
|
9
|
+
flipper.enable :search
|
|
10
|
+
flipper.disable :analytics
|
|
11
|
+
flipper.enable_percentage_of_actors :checkout, 50
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
around do |example|
|
|
15
|
+
original = ENV["FLIPPER_CLOUD_URL"]
|
|
16
|
+
ENV["FLIPPER_CLOUD_URL"] = "http://localhost:5555"
|
|
17
|
+
example.run
|
|
18
|
+
ensure
|
|
19
|
+
ENV["FLIPPER_CLOUD_URL"] = original
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def decompress_request_body
|
|
23
|
+
raw = WebMock::RequestRegistry.instance.requested_signatures.hash.keys.last.body
|
|
24
|
+
JSON.parse(Flipper::Typecast.from_gzip(raw))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
describe ".migrate" do
|
|
28
|
+
it "returns a MigrateResult with code and url on success" do
|
|
29
|
+
stub_request(:post, "http://localhost:5555/api/migrate")
|
|
30
|
+
.to_return(status: 200, body: '{"url":"http://localhost:5555/cloud/setup/abc123"}', headers: {"Content-Type" => "application/json"})
|
|
31
|
+
|
|
32
|
+
result = Flipper::Cloud.migrate(flipper)
|
|
33
|
+
|
|
34
|
+
expect(result).to be_a(Flipper::Cloud::MigrateResult)
|
|
35
|
+
expect(result.code).to eq(200)
|
|
36
|
+
expect(result.url).to eq("http://localhost:5555/cloud/setup/abc123")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it "sends export data and metadata in the request body" do
|
|
40
|
+
stub = stub_request(:post, "http://localhost:5555/api/migrate")
|
|
41
|
+
.to_return(status: 200, body: '{"url":"http://localhost:5555/cloud/setup/abc123"}')
|
|
42
|
+
|
|
43
|
+
Flipper::Cloud.migrate(flipper, app_name: "MyApp")
|
|
44
|
+
|
|
45
|
+
expect(stub).to have_been_requested
|
|
46
|
+
body = decompress_request_body
|
|
47
|
+
expect(body["metadata"]["app_name"]).to eq("MyApp")
|
|
48
|
+
expect(body["export"]["version"]).to eq(1)
|
|
49
|
+
expect(body["export"]["features"]).to have_key("search")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it "sends gzip-compressed request body" do
|
|
53
|
+
stub = stub_request(:post, "http://localhost:5555/api/migrate")
|
|
54
|
+
.with(headers: {"content-encoding" => "gzip"})
|
|
55
|
+
.to_return(status: 200, body: '{"url":"http://localhost:5555/cloud/setup/abc"}')
|
|
56
|
+
|
|
57
|
+
Flipper::Cloud.migrate(flipper)
|
|
58
|
+
|
|
59
|
+
expect(stub).to have_been_requested
|
|
60
|
+
body = decompress_request_body
|
|
61
|
+
expect(body["export"]["features"]).to have_key("search")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it "handles error responses" do
|
|
65
|
+
stub_request(:post, "http://localhost:5555/api/migrate")
|
|
66
|
+
.to_return(status: 500, body: '{"error":"Internal Server Error"}')
|
|
67
|
+
|
|
68
|
+
result = Flipper::Cloud.migrate(flipper)
|
|
69
|
+
|
|
70
|
+
expect(result.code).to eq(500)
|
|
71
|
+
expect(result.url).to be_nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it "includes error message from response body" do
|
|
75
|
+
stub_request(:post, "http://localhost:5555/api/migrate")
|
|
76
|
+
.to_return(status: 422, body: '{"error":"Invalid export format"}')
|
|
77
|
+
|
|
78
|
+
result = Flipper::Cloud.migrate(flipper)
|
|
79
|
+
|
|
80
|
+
expect(result.code).to eq(422)
|
|
81
|
+
expect(result.message).to eq("Invalid export format")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it "uses FLIPPER_CLOUD_URL environment variable" do
|
|
85
|
+
stub = stub_request(:post, "http://localhost:5555/api/migrate")
|
|
86
|
+
.to_return(status: 200, body: '{"url":"http://localhost:5555/cloud/setup/abc"}')
|
|
87
|
+
|
|
88
|
+
Flipper::Cloud.migrate(flipper)
|
|
89
|
+
|
|
90
|
+
expect(stub).to have_been_requested
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it "sends content-type and accept headers" do
|
|
94
|
+
stub = stub_request(:post, "http://localhost:5555/api/migrate")
|
|
95
|
+
.with(headers: {
|
|
96
|
+
"content-type" => "application/json",
|
|
97
|
+
"accept" => "application/json",
|
|
98
|
+
})
|
|
99
|
+
.to_return(status: 200, body: '{"url":"http://localhost:5555/cloud/setup/abc"}')
|
|
100
|
+
|
|
101
|
+
Flipper::Cloud.migrate(flipper)
|
|
102
|
+
|
|
103
|
+
expect(stub).to have_been_requested
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
describe ".push" do
|
|
108
|
+
it "returns a MigrateResult with code on success" do
|
|
109
|
+
stub_request(:post, "http://localhost:5555/adapter/import")
|
|
110
|
+
.to_return(status: 204, body: "")
|
|
111
|
+
|
|
112
|
+
result = Flipper::Cloud.push("test-token", flipper)
|
|
113
|
+
|
|
114
|
+
expect(result).to be_a(Flipper::Cloud::MigrateResult)
|
|
115
|
+
expect(result.code).to eq(204)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
it "sends the token as a header" do
|
|
119
|
+
stub = stub_request(:post, "http://localhost:5555/adapter/import")
|
|
120
|
+
.with(headers: {"flipper-cloud-token" => "test-token"})
|
|
121
|
+
.to_return(status: 204, body: "")
|
|
122
|
+
|
|
123
|
+
Flipper::Cloud.push("test-token", flipper)
|
|
124
|
+
|
|
125
|
+
expect(stub).to have_been_requested
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
it "sends gzip-compressed export contents as the body" do
|
|
129
|
+
stub = stub_request(:post, "http://localhost:5555/adapter/import")
|
|
130
|
+
.with(headers: {"content-encoding" => "gzip"})
|
|
131
|
+
.to_return(status: 204, body: "")
|
|
132
|
+
|
|
133
|
+
Flipper::Cloud.push("test-token", flipper)
|
|
134
|
+
|
|
135
|
+
expect(stub).to have_been_requested
|
|
136
|
+
body = decompress_request_body
|
|
137
|
+
expect(body["version"]).to eq(1)
|
|
138
|
+
expect(body["features"]).to have_key("search")
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
it "handles error responses" do
|
|
142
|
+
stub_request(:post, "http://localhost:5555/adapter/import")
|
|
143
|
+
.to_return(status: 401, body: '{"error":"Unauthorized"}')
|
|
144
|
+
|
|
145
|
+
result = Flipper::Cloud.push("bad-token", flipper)
|
|
146
|
+
|
|
147
|
+
expect(result.code).to eq(401)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
it "includes error message from response body" do
|
|
151
|
+
stub_request(:post, "http://localhost:5555/adapter/import")
|
|
152
|
+
.to_return(status: 401, body: '{"error":"Invalid token"}')
|
|
153
|
+
|
|
154
|
+
result = Flipper::Cloud.push("bad-token", flipper)
|
|
155
|
+
|
|
156
|
+
expect(result.code).to eq(401)
|
|
157
|
+
expect(result.message).to eq("Invalid token")
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -122,7 +122,7 @@ RSpec.describe Flipper::Cloud::Telemetry do
|
|
|
122
122
|
# Check the conig interval and the timer interval.
|
|
123
123
|
expect(telemetry.interval).to eq(120)
|
|
124
124
|
expect(telemetry.timer.execution_interval).to eq(120)
|
|
125
|
-
expect(stub).to have_been_requested.
|
|
125
|
+
expect(stub).to have_been_requested.at_least_times(5)
|
|
126
126
|
end
|
|
127
127
|
|
|
128
128
|
it "doesn't try to update telemetry interval from error if not response error" do
|