flipper 0.28.2 → 1.0.0.pre
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/Changelog.md +27 -0
- data/Gemfile +5 -3
- data/docs/images/flipper_cloud.png +0 -0
- data/examples/cloud/app.ru +12 -0
- data/examples/cloud/basic.rb +22 -0
- data/examples/cloud/cloud_setup.rb +4 -0
- data/examples/cloud/forked.rb +31 -0
- data/examples/cloud/import.rb +17 -0
- data/examples/cloud/threaded.rb +36 -0
- data/examples/dsl.rb +0 -14
- data/flipper-cloud.gemspec +19 -0
- data/flipper.gemspec +3 -2
- data/lib/flipper/cloud/configuration.rb +189 -0
- data/lib/flipper/cloud/dsl.rb +27 -0
- data/lib/flipper/cloud/instrumenter.rb +48 -0
- data/lib/flipper/cloud/message_verifier.rb +95 -0
- data/lib/flipper/cloud/middleware.rb +63 -0
- data/lib/flipper/cloud/routes.rb +14 -0
- data/lib/flipper/cloud.rb +53 -0
- data/lib/flipper/dsl.rb +0 -46
- data/lib/flipper/{railtie.rb → engine.rb} +19 -3
- data/lib/flipper/metadata.rb +5 -1
- data/lib/flipper/spec/shared_adapter_specs.rb +43 -43
- data/lib/flipper/test/shared_adapter_test.rb +43 -43
- data/lib/flipper/version.rb +1 -1
- data/lib/flipper.rb +3 -5
- data/spec/flipper/adapters/dual_write_spec.rb +2 -2
- data/spec/flipper/adapters/instrumented_spec.rb +1 -1
- data/spec/flipper/adapters/memoizable_spec.rb +6 -6
- data/spec/flipper/adapters/operation_logger_spec.rb +2 -2
- data/spec/flipper/adapters/read_only_spec.rb +6 -6
- data/spec/flipper/cloud/configuration_spec.rb +269 -0
- data/spec/flipper/cloud/dsl_spec.rb +82 -0
- data/spec/flipper/cloud/message_verifier_spec.rb +104 -0
- data/spec/flipper/cloud/middleware_spec.rb +289 -0
- data/spec/flipper/cloud_spec.rb +180 -0
- data/spec/flipper/dsl_spec.rb +0 -75
- data/spec/flipper/engine_spec.rb +190 -0
- data/spec/flipper_integration_spec.rb +12 -12
- data/spec/flipper_spec.rb +0 -30
- data/spec/spec_helper.rb +0 -12
- data/spec/support/climate_control.rb +7 -0
- metadata +54 -10
- data/spec/flipper/railtie_spec.rb +0 -109
@@ -0,0 +1,82 @@
|
|
1
|
+
require 'flipper/cloud/configuration'
|
2
|
+
require 'flipper/cloud/dsl'
|
3
|
+
require 'flipper/adapters/operation_logger'
|
4
|
+
require 'flipper/adapters/instrumented'
|
5
|
+
|
6
|
+
RSpec.describe Flipper::Cloud::DSL do
|
7
|
+
it 'delegates everything to flipper instance' do
|
8
|
+
cloud_configuration = Flipper::Cloud::Configuration.new({
|
9
|
+
token: "asdf",
|
10
|
+
sync_secret: "tasty",
|
11
|
+
})
|
12
|
+
dsl = described_class.new(cloud_configuration)
|
13
|
+
expect(dsl.features).to eq(Set.new)
|
14
|
+
expect(dsl.enabled?(:foo)).to be(false)
|
15
|
+
end
|
16
|
+
|
17
|
+
it 'delegates sync to cloud configuration' do
|
18
|
+
stub = stub_request(:get, "https://www.flippercloud.io/adapter/features?exclude_gate_names=true").
|
19
|
+
with({
|
20
|
+
headers: {
|
21
|
+
'Flipper-Cloud-Token'=>'asdf',
|
22
|
+
},
|
23
|
+
}).to_return(status: 200, body: '{"features": {}}', headers: {})
|
24
|
+
cloud_configuration = Flipper::Cloud::Configuration.new({
|
25
|
+
token: "asdf",
|
26
|
+
sync_secret: "tasty",
|
27
|
+
})
|
28
|
+
dsl = described_class.new(cloud_configuration)
|
29
|
+
dsl.sync
|
30
|
+
expect(stub).to have_been_requested
|
31
|
+
end
|
32
|
+
|
33
|
+
it 'delegates sync_secret to cloud configuration' do
|
34
|
+
cloud_configuration = Flipper::Cloud::Configuration.new({
|
35
|
+
token: "asdf",
|
36
|
+
sync_secret: "tasty",
|
37
|
+
})
|
38
|
+
dsl = described_class.new(cloud_configuration)
|
39
|
+
expect(dsl.sync_secret).to eq("tasty")
|
40
|
+
end
|
41
|
+
|
42
|
+
context "when sync_method is webhook" do
|
43
|
+
let(:local_adapter) do
|
44
|
+
Flipper::Adapters::OperationLogger.new Flipper::Adapters::Memory.new
|
45
|
+
end
|
46
|
+
|
47
|
+
let(:cloud_configuration) do
|
48
|
+
cloud_configuration = Flipper::Cloud::Configuration.new({
|
49
|
+
token: "asdf",
|
50
|
+
sync_secret: "tasty",
|
51
|
+
local_adapter: local_adapter
|
52
|
+
})
|
53
|
+
end
|
54
|
+
|
55
|
+
subject do
|
56
|
+
described_class.new(cloud_configuration)
|
57
|
+
end
|
58
|
+
|
59
|
+
it "sends reads to local adapter" do
|
60
|
+
subject.features
|
61
|
+
subject.enabled?(:foo)
|
62
|
+
expect(local_adapter.count(:features)).to be(1)
|
63
|
+
expect(local_adapter.count(:get)).to be(1)
|
64
|
+
end
|
65
|
+
|
66
|
+
it "sends writes to cloud and local" do
|
67
|
+
add_stub = stub_request(:post, "https://www.flippercloud.io/adapter/features").
|
68
|
+
with({headers: {'Flipper-Cloud-Token'=>'asdf'}}).
|
69
|
+
to_return(status: 200, body: '{}', headers: {})
|
70
|
+
enable_stub = stub_request(:post, "https://www.flippercloud.io/adapter/features/foo/boolean").
|
71
|
+
with(headers: {'Flipper-Cloud-Token'=>'asdf'}).
|
72
|
+
to_return(status: 200, body: '{}', headers: {})
|
73
|
+
|
74
|
+
subject.enable(:foo)
|
75
|
+
|
76
|
+
expect(local_adapter.count(:add)).to be(1)
|
77
|
+
expect(local_adapter.count(:enable)).to be(1)
|
78
|
+
expect(add_stub).to have_been_requested
|
79
|
+
expect(enable_stub).to have_been_requested
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,104 @@
|
|
1
|
+
require 'flipper/cloud/message_verifier'
|
2
|
+
|
3
|
+
RSpec.describe Flipper::Cloud::MessageVerifier do
|
4
|
+
let(:payload) { "some payload" }
|
5
|
+
let(:secret) { "secret" }
|
6
|
+
let(:timestamp) { Time.now }
|
7
|
+
|
8
|
+
describe "#generate" do
|
9
|
+
it "generates signature that can be verified" do
|
10
|
+
message_verifier = Flipper::Cloud::MessageVerifier.new(secret: secret)
|
11
|
+
signature = message_verifier.generate(payload, timestamp)
|
12
|
+
header = generate_header(timestamp: timestamp, signature: signature)
|
13
|
+
expect(message_verifier.verify(payload, header)).to be(true)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
describe "#header" do
|
18
|
+
it "generates a header in valid format" do
|
19
|
+
version = "v1"
|
20
|
+
message_verifier = Flipper::Cloud::MessageVerifier.new(secret: secret, version: version)
|
21
|
+
signature = message_verifier.generate(payload, timestamp)
|
22
|
+
header = message_verifier.header(signature, timestamp)
|
23
|
+
expect(header).to eq("t=#{timestamp.to_i},#{version}=#{signature}")
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
describe ".header" do
|
28
|
+
it "generates a header in valid format" do
|
29
|
+
version = "v1"
|
30
|
+
message_verifier = Flipper::Cloud::MessageVerifier.new(secret: secret, version: version)
|
31
|
+
signature = message_verifier.generate(payload, timestamp)
|
32
|
+
|
33
|
+
header = Flipper::Cloud::MessageVerifier.header(signature, timestamp, version)
|
34
|
+
expect(header).to eq("t=#{timestamp.to_i},#{version}=#{signature}")
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
describe "#verify" do
|
39
|
+
it "raises a InvalidSignature when the header does not have the expected format" do
|
40
|
+
header = "i'm not even a real signature header"
|
41
|
+
expect {
|
42
|
+
message_verifier = Flipper::Cloud::MessageVerifier.new(secret: "secret")
|
43
|
+
message_verifier.verify(payload, header)
|
44
|
+
}.to raise_error(Flipper::Cloud::MessageVerifier::InvalidSignature, "Unable to extract timestamp and signatures from header")
|
45
|
+
end
|
46
|
+
|
47
|
+
it "raises a InvalidSignature when there are no signatures with the expected version" do
|
48
|
+
header = generate_header(version: "v0")
|
49
|
+
expect {
|
50
|
+
message_verifier = Flipper::Cloud::MessageVerifier.new(secret: "secret")
|
51
|
+
message_verifier.verify(payload, header)
|
52
|
+
}.to raise_error(Flipper::Cloud::MessageVerifier::InvalidSignature, /No signatures found with expected version/)
|
53
|
+
end
|
54
|
+
|
55
|
+
it "raises a InvalidSignature when there are no valid signatures for the payload" do
|
56
|
+
header = generate_header(signature: "bad_signature")
|
57
|
+
expect {
|
58
|
+
message_verifier = Flipper::Cloud::MessageVerifier.new(secret: "secret")
|
59
|
+
message_verifier.verify(payload, header)
|
60
|
+
}.to raise_error(Flipper::Cloud::MessageVerifier::InvalidSignature, "No signatures found matching the expected signature for payload")
|
61
|
+
end
|
62
|
+
|
63
|
+
it "raises a InvalidSignature when the timestamp is not within the tolerance" do
|
64
|
+
header = generate_header(timestamp: Time.now - 15)
|
65
|
+
expect {
|
66
|
+
message_verifier = Flipper::Cloud::MessageVerifier.new(secret: secret)
|
67
|
+
message_verifier.verify(payload, header, tolerance: 10)
|
68
|
+
}.to raise_error(Flipper::Cloud::MessageVerifier::InvalidSignature, /Timestamp outside the tolerance zone/)
|
69
|
+
end
|
70
|
+
|
71
|
+
it "returns true when the header contains a valid signature and the timestamp is within the tolerance" do
|
72
|
+
header = generate_header
|
73
|
+
message_verifier = Flipper::Cloud::MessageVerifier.new(secret: "secret")
|
74
|
+
expect(message_verifier.verify(payload, header, tolerance: 10)).to be(true)
|
75
|
+
end
|
76
|
+
|
77
|
+
it "returns true when the header contains at least one valid signature" do
|
78
|
+
header = generate_header + ",v1=bad_signature"
|
79
|
+
message_verifier = Flipper::Cloud::MessageVerifier.new(secret: secret)
|
80
|
+
expect(message_verifier.verify(payload, header, tolerance: 10)).to be(true)
|
81
|
+
end
|
82
|
+
|
83
|
+
it "returns true when the header contains a valid signature and the timestamp is off but no tolerance is provided" do
|
84
|
+
header = generate_header(timestamp: Time.at(12_345))
|
85
|
+
message_verifier = Flipper::Cloud::MessageVerifier.new(secret: secret)
|
86
|
+
expect(message_verifier.verify(payload, header)).to be(true)
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
private
|
91
|
+
|
92
|
+
def generate_header(options = {})
|
93
|
+
options[:secret] ||= secret
|
94
|
+
options[:version] ||= "v1"
|
95
|
+
|
96
|
+
message_verifier = Flipper::Cloud::MessageVerifier.new(secret: options[:secret], version: options[:version])
|
97
|
+
|
98
|
+
options[:timestamp] ||= timestamp
|
99
|
+
options[:payload] ||= payload
|
100
|
+
options[:signature] ||= message_verifier.generate(options[:payload], options[:timestamp])
|
101
|
+
|
102
|
+
Flipper::Cloud::MessageVerifier.header(options[:signature], options[:timestamp], options[:version])
|
103
|
+
end
|
104
|
+
end
|
@@ -0,0 +1,289 @@
|
|
1
|
+
require 'securerandom'
|
2
|
+
require 'flipper/cloud'
|
3
|
+
require 'flipper/cloud/middleware'
|
4
|
+
require 'flipper/adapters/operation_logger'
|
5
|
+
|
6
|
+
RSpec.describe Flipper::Cloud::Middleware do
|
7
|
+
let(:flipper) {
|
8
|
+
Flipper::Cloud.new(token: "regular") do |config|
|
9
|
+
config.local_adapter = Flipper::Adapters::OperationLogger.new(Flipper::Adapters::Memory.new)
|
10
|
+
config.sync_secret = "regular_tasty"
|
11
|
+
end
|
12
|
+
}
|
13
|
+
|
14
|
+
let(:env_flipper) {
|
15
|
+
Flipper::Cloud.new(token: "env") do |config|
|
16
|
+
config.local_adapter = Flipper::Adapters::OperationLogger.new(Flipper::Adapters::Memory.new)
|
17
|
+
config.sync_secret = "env_tasty"
|
18
|
+
end
|
19
|
+
}
|
20
|
+
|
21
|
+
let(:app) { Flipper::Cloud.app(flipper) }
|
22
|
+
let(:response_body) { JSON.generate({features: {}}) }
|
23
|
+
let(:request_body) {
|
24
|
+
JSON.generate({
|
25
|
+
"environment_id" => 1,
|
26
|
+
"webhook_id" => 1,
|
27
|
+
"delivery_id" => SecureRandom.uuid,
|
28
|
+
"action" => "sync",
|
29
|
+
})
|
30
|
+
}
|
31
|
+
let(:timestamp) { Time.now }
|
32
|
+
let(:signature) {
|
33
|
+
Flipper::Cloud::MessageVerifier.new(secret: flipper.sync_secret).generate(request_body, timestamp)
|
34
|
+
}
|
35
|
+
let(:signature_header_value) {
|
36
|
+
Flipper::Cloud::MessageVerifier.new(secret: "").header(signature, timestamp)
|
37
|
+
}
|
38
|
+
|
39
|
+
context 'when initializing middleware with flipper instance' do
|
40
|
+
let(:app) { Flipper::Cloud.app(flipper) }
|
41
|
+
|
42
|
+
it 'uses instance to sync' do
|
43
|
+
Flipper.register(:admins) { |*args| false }
|
44
|
+
Flipper.register(:staff) { |*args| false }
|
45
|
+
Flipper.register(:basic) { |*args| false }
|
46
|
+
Flipper.register(:plus) { |*args| false }
|
47
|
+
Flipper.register(:premium) { |*args| false }
|
48
|
+
|
49
|
+
stub = stub_request_for_token('regular')
|
50
|
+
env = {
|
51
|
+
"HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
|
52
|
+
}
|
53
|
+
post '/', request_body, env
|
54
|
+
|
55
|
+
expect(last_response.status).to eq(200)
|
56
|
+
expect(JSON.parse(last_response.body)).to eq({
|
57
|
+
"groups" => [
|
58
|
+
{"name" => "admins"},
|
59
|
+
{"name" => "staff"},
|
60
|
+
{"name" => "basic"},
|
61
|
+
{"name" => "plus"},
|
62
|
+
{"name" => "premium"},
|
63
|
+
],
|
64
|
+
})
|
65
|
+
expect(stub).to have_been_requested
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
context 'when signature is invalid' do
|
70
|
+
let(:app) { Flipper::Cloud.app(flipper) }
|
71
|
+
let(:signature) {
|
72
|
+
Flipper::Cloud::MessageVerifier.new(secret: "nope").generate(request_body, timestamp)
|
73
|
+
}
|
74
|
+
|
75
|
+
it 'uses instance to sync' do
|
76
|
+
stub = stub_request_for_token('regular')
|
77
|
+
env = {
|
78
|
+
"HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
|
79
|
+
}
|
80
|
+
post '/', request_body, env
|
81
|
+
|
82
|
+
expect(last_response.status).to eq(400)
|
83
|
+
expect(stub).not_to have_been_requested
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
context "when flipper cloud responds with 402" do
|
88
|
+
let(:app) { Flipper::Cloud.app(flipper) }
|
89
|
+
|
90
|
+
it "results in 402" do
|
91
|
+
Flipper.register(:admins) { |*args| false }
|
92
|
+
Flipper.register(:staff) { |*args| false }
|
93
|
+
Flipper.register(:basic) { |*args| false }
|
94
|
+
Flipper.register(:plus) { |*args| false }
|
95
|
+
Flipper.register(:premium) { |*args| false }
|
96
|
+
|
97
|
+
stub = stub_request_for_token('regular', status: 402)
|
98
|
+
env = {
|
99
|
+
"HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
|
100
|
+
}
|
101
|
+
post '/', request_body, env
|
102
|
+
|
103
|
+
expect(last_response.status).to eq(402)
|
104
|
+
expect(last_response.headers["Flipper-Cloud-Response-Error-Class"]).to eq("Flipper::Adapters::Http::Error")
|
105
|
+
expect(last_response.headers["Flipper-Cloud-Response-Error-Message"]).to include("Failed with status: 402")
|
106
|
+
expect(stub).to have_been_requested
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
context "when flipper cloud responds with non-402 and non-2xx code" do
|
111
|
+
let(:app) { Flipper::Cloud.app(flipper) }
|
112
|
+
|
113
|
+
it "results in 500" do
|
114
|
+
Flipper.register(:admins) { |*args| false }
|
115
|
+
Flipper.register(:staff) { |*args| false }
|
116
|
+
Flipper.register(:basic) { |*args| false }
|
117
|
+
Flipper.register(:plus) { |*args| false }
|
118
|
+
Flipper.register(:premium) { |*args| false }
|
119
|
+
|
120
|
+
stub = stub_request_for_token('regular', status: 503)
|
121
|
+
env = {
|
122
|
+
"HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
|
123
|
+
}
|
124
|
+
post '/', request_body, env
|
125
|
+
|
126
|
+
expect(last_response.status).to eq(500)
|
127
|
+
expect(last_response.headers["Flipper-Cloud-Response-Error-Class"]).to eq("Flipper::Adapters::Http::Error")
|
128
|
+
expect(last_response.headers["Flipper-Cloud-Response-Error-Message"]).to include("Failed with status: 503")
|
129
|
+
expect(stub).to have_been_requested
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
context "when flipper cloud responds with timeout" do
|
134
|
+
let(:app) { Flipper::Cloud.app(flipper) }
|
135
|
+
|
136
|
+
it "results in 500" do
|
137
|
+
Flipper.register(:admins) { |*args| false }
|
138
|
+
Flipper.register(:staff) { |*args| false }
|
139
|
+
Flipper.register(:basic) { |*args| false }
|
140
|
+
Flipper.register(:plus) { |*args| false }
|
141
|
+
Flipper.register(:premium) { |*args| false }
|
142
|
+
|
143
|
+
stub = stub_request_for_token('regular', status: :timeout)
|
144
|
+
env = {
|
145
|
+
"HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
|
146
|
+
}
|
147
|
+
post '/', request_body, env
|
148
|
+
|
149
|
+
expect(last_response.status).to eq(500)
|
150
|
+
expect(last_response.headers["Flipper-Cloud-Response-Error-Class"]).to eq("Net::OpenTimeout")
|
151
|
+
expect(last_response.headers["Flipper-Cloud-Response-Error-Message"]).to eq("execution expired")
|
152
|
+
expect(stub).to have_been_requested
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
context 'when initialized with flipper instance and flipper instance in env' do
|
157
|
+
let(:app) { Flipper::Cloud.app(flipper) }
|
158
|
+
let(:signature) {
|
159
|
+
Flipper::Cloud::MessageVerifier.new(secret: env_flipper.sync_secret).generate(request_body, timestamp)
|
160
|
+
}
|
161
|
+
|
162
|
+
it 'uses env instance to sync' do
|
163
|
+
stub = stub_request_for_token('env')
|
164
|
+
env = {
|
165
|
+
"HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
|
166
|
+
'flipper' => env_flipper,
|
167
|
+
}
|
168
|
+
post '/', request_body, env
|
169
|
+
|
170
|
+
expect(last_response.status).to eq(200)
|
171
|
+
expect(stub).to have_been_requested
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
context 'when initialized without flipper instance but flipper instance in env' do
|
176
|
+
let(:app) { Flipper::Cloud.app }
|
177
|
+
let(:signature) {
|
178
|
+
Flipper::Cloud::MessageVerifier.new(secret: env_flipper.sync_secret).generate(request_body, timestamp)
|
179
|
+
}
|
180
|
+
|
181
|
+
it 'uses env instance to sync' do
|
182
|
+
stub = stub_request_for_token('env')
|
183
|
+
env = {
|
184
|
+
"HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
|
185
|
+
'flipper' => env_flipper,
|
186
|
+
}
|
187
|
+
post '/', request_body, env
|
188
|
+
|
189
|
+
expect(last_response.status).to eq(200)
|
190
|
+
expect(stub).to have_been_requested
|
191
|
+
end
|
192
|
+
end
|
193
|
+
|
194
|
+
context 'when initialized with env_key' do
|
195
|
+
let(:app) { Flipper::Cloud.app(flipper, env_key: 'flipper_cloud') }
|
196
|
+
let(:signature) {
|
197
|
+
Flipper::Cloud::MessageVerifier.new(secret: env_flipper.sync_secret).generate(request_body, timestamp)
|
198
|
+
}
|
199
|
+
|
200
|
+
it 'uses provided env key instead of default' do
|
201
|
+
stub = stub_request_for_token('env')
|
202
|
+
env = {
|
203
|
+
"HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
|
204
|
+
'flipper' => flipper,
|
205
|
+
'flipper_cloud' => env_flipper,
|
206
|
+
}
|
207
|
+
post '/', request_body, env
|
208
|
+
|
209
|
+
expect(last_response.status).to eq(200)
|
210
|
+
expect(stub).to have_been_requested
|
211
|
+
end
|
212
|
+
end
|
213
|
+
|
214
|
+
context 'when initializing lazily with a block' do
|
215
|
+
let(:app) { Flipper::Cloud.app(-> { flipper }) }
|
216
|
+
|
217
|
+
it 'works' do
|
218
|
+
stub = stub_request_for_token('regular')
|
219
|
+
env = {
|
220
|
+
"HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
|
221
|
+
}
|
222
|
+
post '/', request_body, env
|
223
|
+
|
224
|
+
expect(last_response.status).to eq(200)
|
225
|
+
expect(stub).to have_been_requested
|
226
|
+
end
|
227
|
+
end
|
228
|
+
|
229
|
+
context 'when using older /webhooks path' do
|
230
|
+
let(:app) { Flipper::Cloud.app(flipper) }
|
231
|
+
|
232
|
+
it 'uses instance to sync' do
|
233
|
+
Flipper.register(:admins) { |*args| false }
|
234
|
+
Flipper.register(:staff) { |*args| false }
|
235
|
+
Flipper.register(:basic) { |*args| false }
|
236
|
+
Flipper.register(:plus) { |*args| false }
|
237
|
+
Flipper.register(:premium) { |*args| false }
|
238
|
+
|
239
|
+
stub = stub_request_for_token('regular')
|
240
|
+
env = {
|
241
|
+
"HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
|
242
|
+
}
|
243
|
+
post '/webhooks', request_body, env
|
244
|
+
|
245
|
+
expect(last_response.status).to eq(200)
|
246
|
+
expect(JSON.parse(last_response.body)).to eq({
|
247
|
+
"groups" => [
|
248
|
+
{"name" => "admins"},
|
249
|
+
{"name" => "staff"},
|
250
|
+
{"name" => "basic"},
|
251
|
+
{"name" => "plus"},
|
252
|
+
{"name" => "premium"},
|
253
|
+
],
|
254
|
+
})
|
255
|
+
expect(stub).to have_been_requested
|
256
|
+
end
|
257
|
+
end
|
258
|
+
|
259
|
+
describe 'Request method unsupported' do
|
260
|
+
it 'skips middleware' do
|
261
|
+
get '/'
|
262
|
+
expect(last_response.status).to eq(404)
|
263
|
+
expect(last_response.content_type).to eq("application/json")
|
264
|
+
expect(last_response.body).to eq("{}")
|
265
|
+
end
|
266
|
+
end
|
267
|
+
|
268
|
+
describe 'Inspecting the built Rack app' do
|
269
|
+
it 'returns a String' do
|
270
|
+
expect(Flipper::Cloud.app(flipper).inspect).to eq("Flipper::Cloud")
|
271
|
+
end
|
272
|
+
end
|
273
|
+
|
274
|
+
private
|
275
|
+
|
276
|
+
def stub_request_for_token(token, status: 200)
|
277
|
+
stub = stub_request(:get, "https://www.flippercloud.io/adapter/features?exclude_gate_names=true").
|
278
|
+
with({
|
279
|
+
headers: {
|
280
|
+
'Flipper-Cloud-Token' => token,
|
281
|
+
},
|
282
|
+
})
|
283
|
+
if status == :timeout
|
284
|
+
stub.to_timeout
|
285
|
+
else
|
286
|
+
stub.to_return(status: status, body: response_body, headers: {})
|
287
|
+
end
|
288
|
+
end
|
289
|
+
end
|
@@ -0,0 +1,180 @@
|
|
1
|
+
require 'flipper/cloud'
|
2
|
+
require 'flipper/adapters/instrumented'
|
3
|
+
require 'flipper/instrumenters/memory'
|
4
|
+
|
5
|
+
RSpec.describe Flipper::Cloud do
|
6
|
+
before do
|
7
|
+
stub_request(:get, /flippercloud\.io/).to_return(status: 200, body: "{}")
|
8
|
+
end
|
9
|
+
|
10
|
+
context "initialize with token" do
|
11
|
+
let(:token) { 'asdf' }
|
12
|
+
|
13
|
+
before do
|
14
|
+
@instance = described_class.new(token: token)
|
15
|
+
end
|
16
|
+
|
17
|
+
it 'returns Flipper::DSL instance' do
|
18
|
+
expect(@instance).to be_instance_of(Flipper::Cloud::DSL)
|
19
|
+
end
|
20
|
+
|
21
|
+
it 'can read the cloud configuration' do
|
22
|
+
expect(@instance.cloud_configuration).to be_instance_of(Flipper::Cloud::Configuration)
|
23
|
+
end
|
24
|
+
|
25
|
+
it 'configures the correct adapter' do
|
26
|
+
# pardon the nesting...
|
27
|
+
memoized_adapter = @instance.adapter
|
28
|
+
dual_write_adapter = memoized_adapter.adapter
|
29
|
+
expect(dual_write_adapter).to be_instance_of(Flipper::Adapters::DualWrite)
|
30
|
+
poll_adapter = dual_write_adapter.local
|
31
|
+
expect(poll_adapter).to be_instance_of(Flipper::Adapters::Poll)
|
32
|
+
|
33
|
+
http_adapter = dual_write_adapter.remote
|
34
|
+
client = http_adapter.client
|
35
|
+
expect(client.uri.scheme).to eq('https')
|
36
|
+
expect(client.uri.host).to eq('www.flippercloud.io')
|
37
|
+
expect(client.uri.path).to eq('/adapter')
|
38
|
+
expect(client.headers['Flipper-Cloud-Token']).to eq(token)
|
39
|
+
expect(@instance.instrumenter).to be(Flipper::Instrumenters::Noop)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
context 'initialize with token and options' do
|
44
|
+
it 'sets correct url' do
|
45
|
+
instance = described_class.new(token: 'asdf', url: 'https://www.fakeflipper.com/sadpanda')
|
46
|
+
# pardon the nesting...
|
47
|
+
memoized = instance.adapter
|
48
|
+
dual_write = memoized.adapter
|
49
|
+
remote = dual_write.remote
|
50
|
+
uri = remote.client.uri
|
51
|
+
expect(uri.scheme).to eq('https')
|
52
|
+
expect(uri.host).to eq('www.fakeflipper.com')
|
53
|
+
expect(uri.path).to eq('/sadpanda')
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
it 'can initialize with no token explicitly provided' do
|
58
|
+
with_env 'FLIPPER_CLOUD_TOKEN' => 'asdf' do
|
59
|
+
expect(described_class.new).to be_instance_of(Flipper::Cloud::DSL)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
it 'can set instrumenter' do
|
64
|
+
instrumenter = Flipper::Instrumenters::Memory.new
|
65
|
+
instance = described_class.new(token: 'asdf', instrumenter: instrumenter)
|
66
|
+
expect(instance.instrumenter).to be(instrumenter)
|
67
|
+
end
|
68
|
+
|
69
|
+
it 'allows wrapping adapter with another adapter like the instrumenter' do
|
70
|
+
instance = described_class.new(token: 'asdf') do |config|
|
71
|
+
config.adapter do |adapter|
|
72
|
+
Flipper::Adapters::Instrumented.new(adapter)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
# instance.adapter is memoizable adapter instance
|
76
|
+
expect(instance.adapter.adapter).to be_instance_of(Flipper::Adapters::Instrumented)
|
77
|
+
end
|
78
|
+
|
79
|
+
it 'can set debug_output' do
|
80
|
+
expect(Flipper::Adapters::Http::Client).to receive(:new)
|
81
|
+
.with(hash_including(debug_output: STDOUT)).at_least(:once)
|
82
|
+
described_class.new(token: 'asdf', debug_output: STDOUT)
|
83
|
+
end
|
84
|
+
|
85
|
+
it 'can set read_timeout' do
|
86
|
+
expect(Flipper::Adapters::Http::Client).to receive(:new)
|
87
|
+
.with(hash_including(read_timeout: 1)).at_least(:once)
|
88
|
+
described_class.new(token: 'asdf', read_timeout: 1)
|
89
|
+
end
|
90
|
+
|
91
|
+
it 'can set open_timeout' do
|
92
|
+
expect(Flipper::Adapters::Http::Client).to receive(:new)
|
93
|
+
.with(hash_including(open_timeout: 1)).at_least(:once)
|
94
|
+
described_class.new(token: 'asdf', open_timeout: 1)
|
95
|
+
end
|
96
|
+
|
97
|
+
if RUBY_VERSION >= '2.6.0'
|
98
|
+
it 'can set write_timeout' do
|
99
|
+
expect(Flipper::Adapters::Http::Client).to receive(:new)
|
100
|
+
.with(hash_including(open_timeout: 1)).at_least(:once)
|
101
|
+
described_class.new(token: 'asdf', open_timeout: 1)
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
it 'can import' do
|
106
|
+
stub_request(:post, /www\.flippercloud\.io\/adapter\/features.*/).
|
107
|
+
with(headers: {'Flipper-Cloud-Token'=>'asdf'}).to_return(status: 200, body: "{}", headers: {})
|
108
|
+
|
109
|
+
flipper = Flipper.new(Flipper::Adapters::Memory.new)
|
110
|
+
|
111
|
+
flipper.enable(:test)
|
112
|
+
flipper.enable(:search)
|
113
|
+
flipper.enable_actor(:stats, Flipper::Actor.new("jnunemaker"))
|
114
|
+
flipper.enable_percentage_of_time(:logging, 5)
|
115
|
+
|
116
|
+
cloud_flipper = Flipper::Cloud.new(token: "asdf")
|
117
|
+
|
118
|
+
get_all = {
|
119
|
+
"logging" => {actors: Set.new, boolean: nil, groups: Set.new, percentage_of_actors: nil, percentage_of_time: "5"},
|
120
|
+
"search" => {actors: Set.new, boolean: "true", groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil},
|
121
|
+
"stats" => {actors: Set["jnunemaker"], boolean: nil, groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil},
|
122
|
+
"test" => {actors: Set.new, boolean: "true", groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil},
|
123
|
+
}
|
124
|
+
|
125
|
+
expect(flipper.adapter.get_all).to eq(get_all)
|
126
|
+
cloud_flipper.import(flipper)
|
127
|
+
expect(flipper.adapter.get_all).to eq(get_all)
|
128
|
+
expect(cloud_flipper.adapter.get_all).to eq(get_all)
|
129
|
+
end
|
130
|
+
|
131
|
+
it 'raises error for failure while importing' do
|
132
|
+
stub_request(:post, /www\.flippercloud\.io\/adapter\/features.*/).
|
133
|
+
with(headers: {'Flipper-Cloud-Token'=>'asdf'}).to_return(status: 500, body: "{}")
|
134
|
+
|
135
|
+
flipper = Flipper.new(Flipper::Adapters::Memory.new)
|
136
|
+
|
137
|
+
flipper.enable(:test)
|
138
|
+
flipper.enable(:search)
|
139
|
+
flipper.enable_actor(:stats, Flipper::Actor.new("jnunemaker"))
|
140
|
+
flipper.enable_percentage_of_time(:logging, 5)
|
141
|
+
|
142
|
+
cloud_flipper = Flipper::Cloud.new(token: "asdf")
|
143
|
+
|
144
|
+
get_all = {
|
145
|
+
"logging" => {actors: Set.new, boolean: nil, groups: Set.new, percentage_of_actors: nil, percentage_of_time: "5"},
|
146
|
+
"search" => {actors: Set.new, boolean: "true", groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil},
|
147
|
+
"stats" => {actors: Set["jnunemaker"], boolean: nil, groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil},
|
148
|
+
"test" => {actors: Set.new, boolean: "true", groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil},
|
149
|
+
}
|
150
|
+
|
151
|
+
expect(flipper.adapter.get_all).to eq(get_all)
|
152
|
+
expect { cloud_flipper.import(flipper) }.to raise_error(Flipper::Adapters::Http::Error)
|
153
|
+
expect(flipper.adapter.get_all).to eq(get_all)
|
154
|
+
end
|
155
|
+
|
156
|
+
it 'raises error for timeout while importing' do
|
157
|
+
stub_request(:post, /www\.flippercloud\.io\/adapter\/features.*/).
|
158
|
+
with(headers: {'Flipper-Cloud-Token'=>'asdf'}).to_timeout
|
159
|
+
|
160
|
+
flipper = Flipper.new(Flipper::Adapters::Memory.new)
|
161
|
+
|
162
|
+
flipper.enable(:test)
|
163
|
+
flipper.enable(:search)
|
164
|
+
flipper.enable_actor(:stats, Flipper::Actor.new("jnunemaker"))
|
165
|
+
flipper.enable_percentage_of_time(:logging, 5)
|
166
|
+
|
167
|
+
cloud_flipper = Flipper::Cloud.new(token: "asdf")
|
168
|
+
|
169
|
+
get_all = {
|
170
|
+
"logging" => {actors: Set.new, boolean: nil, groups: Set.new, percentage_of_actors: nil, percentage_of_time: "5"},
|
171
|
+
"search" => {actors: Set.new, boolean: "true", groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil},
|
172
|
+
"stats" => {actors: Set["jnunemaker"], boolean: nil, groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil},
|
173
|
+
"test" => {actors: Set.new, boolean: "true", groups: Set.new, percentage_of_actors: nil, percentage_of_time: nil},
|
174
|
+
}
|
175
|
+
|
176
|
+
expect(flipper.adapter.get_all).to eq(get_all)
|
177
|
+
expect { cloud_flipper.import(flipper) }.to raise_error(Net::OpenTimeout)
|
178
|
+
expect(flipper.adapter.get_all).to eq(get_all)
|
179
|
+
end
|
180
|
+
end
|