flipper-cloud 0.19.1 → 0.20.0.beta1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6216e75f04e5bef06c69e891f6ed5efe6e3fd76031fd696cc286282a41976049
4
- data.tar.gz: 508fda309071c22ffe75372ebbd6c419a18083c4e996c392029d631a9f6968b5
3
+ metadata.gz: e589ce31508179cafeb1cd8c008d157354c18e5868546d6e76ec543701268fd7
4
+ data.tar.gz: 6b3eb3c890ad5afde871ab6594a2b21f8adfd848a45b4a4bc87d325d79020b68
5
5
  SHA512:
6
- metadata.gz: 46f2f8c832d94884c7faa1093ddf2910d47ebb9d755ee677e9599760a28bae56f09cb091be0df7d2dc6830863c23536341f0ac30005a6505b1240eac44d2c930
7
- data.tar.gz: f4ad93d8c151754b60eda5952a156347e3864eb0d5be5e2ba2cea25940adb8680403b8ce5a99d362d73553f42f50acbd545a9d3342cb75918f3c516dc8b6e95d
6
+ metadata.gz: 66c7d9c80c1f6be4c8506e25d72f6d88eb2b1b6f0679dec9acdb6e722dcae2b2e2f94026ad25ad260a2e5a2c89cdb826b519b66702ddb338e18b2c7b294429fa
7
+ data.tar.gz: aa0de73a845c37c05d1f98cfa33cbc31b24b5d6fb5d8697e81a6079b172e6cecb2bb9c7e06f2cf030d72db1f8142b75c0826fc9ec5f60177a75c1937ab259630
@@ -0,0 +1,17 @@
1
+ # Usage (from the repo root):
2
+ # env FLIPPER_CLOUD_TOKEN=<token> FLIPPER_CLOUD_SYNC_SECRET=<secret> FLIPPER_CLOUD_SYNC_METHOD=webhook bundle exec rackup examples/ui/basic.ru -p 9999
3
+ # env FLIPPER_CLOUD_TOKEN=<token> FLIPPER_CLOUD_SYNC_SECRET=<secret> FLIPPER_CLOUD_SYNC_METHOD=webhook bundle exec shotgun examples/ui/basic.ru -p 9999
4
+ # http://localhost:9999/
5
+ # http://localhost:9999/webhooks
6
+
7
+ require 'pathname'
8
+ root_path = Pathname(__FILE__).dirname.join('..').expand_path
9
+ lib_path = root_path.join('lib')
10
+ $:.unshift(lib_path)
11
+
12
+ require 'flipper/cloud'
13
+ Flipper.configure do |config|
14
+ config.default { Flipper::Cloud.new }
15
+ end
16
+
17
+ run Flipper::Cloud.app
@@ -1,5 +1,9 @@
1
1
  require "flipper"
2
+ require "flipper/middleware/setup_env"
3
+ require "flipper/middleware/memoizer"
2
4
  require "flipper/cloud/configuration"
5
+ require "flipper/cloud/dsl"
6
+ require "flipper/cloud/middleware"
3
7
 
4
8
  module Flipper
5
9
  module Cloud
@@ -10,10 +14,27 @@ module Flipper
10
14
  # options - The Hash of options. See Flipper::Cloud::Configuration.
11
15
  # block - The block that configuration will be yielded to allowing you to
12
16
  # customize this cloud instance and its adapter.
13
- def self.new(token, options = {})
14
- configuration = Configuration.new(options.merge(token: token))
17
+ def self.new(token = nil, options = {})
18
+ options = options.merge(token: token) if token
19
+ configuration = Configuration.new(options)
15
20
  yield configuration if block_given?
16
- Flipper.new(configuration.adapter, instrumenter: configuration.instrumenter)
21
+ DSL.new(configuration)
22
+ end
23
+
24
+ def self.app(flipper = nil, options = {})
25
+ env_key = options.fetch(:env_key, 'flipper')
26
+ memoizer_options = options.fetch(:memoizer_options, {})
27
+
28
+ app = ->(_) { [404, { 'Content-Type'.freeze => 'application/json'.freeze }, ['{}'.freeze]] }
29
+ builder = Rack::Builder.new
30
+ yield builder if block_given?
31
+ builder.use Flipper::Middleware::SetupEnv, flipper, env_key: env_key
32
+ builder.use Flipper::Middleware::Memoizer, memoizer_options.merge(env_key: env_key)
33
+ builder.use Flipper::Cloud::Middleware, env_key: env_key
34
+ builder.run app
35
+ klass = self
36
+ builder.define_singleton_method(:inspect) { klass.inspect } # pretty rake routes output
37
+ builder
17
38
  end
18
39
  end
19
40
  end
@@ -5,15 +5,17 @@ require "flipper/adapters/sync"
5
5
  module Flipper
6
6
  module Cloud
7
7
  class Configuration
8
- # The default url should be the one, the only, the website.
9
- DEFAULT_URL = "https://www.flippercloud.io/adapter".freeze
8
+ # The set of valid ways that syncing can happpen.
9
+ VALID_SYNC_METHODS = Set[
10
+ :poll,
11
+ :webhook,
12
+ ].freeze
10
13
 
11
14
  # Public: The token corresponding to an environment on flippercloud.io.
12
15
  attr_accessor :token
13
16
 
14
- # Public: The url for http adapter (default: Flipper::Cloud::DEFAULT_URL).
15
- # Really should only be customized for development work. Feel free
16
- # to forget you ever saw this.
17
+ # Public: The url for http adapter. Really should only be customized for
18
+ # development work. Feel free to forget you ever saw this.
17
19
  attr_reader :url
18
20
 
19
21
  # Public: net/http read timeout for all http requests (default: 5).
@@ -53,18 +55,32 @@ module Flipper
53
55
  # the local in sync with cloud (default: 10).
54
56
  attr_accessor :sync_interval
55
57
 
58
+ # Public: The method to be used for synchronizing your local flipper
59
+ # adapter with cloud. (default: :poll, can also be :webhook).
60
+ attr_reader :sync_method
61
+
62
+ # Public: The secret used to verify if syncs in the middleware should
63
+ # occur or not.
64
+ attr_accessor :sync_secret
65
+
56
66
  def initialize(options = {})
57
- @token = options.fetch(:token)
67
+ @token = options.fetch(:token) { ENV["FLIPPER_CLOUD_TOKEN"] }
68
+
69
+ if @token.nil?
70
+ raise ArgumentError, "Flipper::Cloud token is missing. Please set FLIPPER_CLOUD_TOKEN or provide the token used to validate webhooks (e.g. Flipper::Cloud.new('token'))."
71
+ end
72
+
58
73
  @instrumenter = options.fetch(:instrumenter, Instrumenters::Noop)
59
- @read_timeout = options.fetch(:read_timeout, 5)
60
- @open_timeout = options.fetch(:open_timeout, 5)
61
- @write_timeout = options.fetch(:write_timeout, 5)
62
- @sync_interval = options.fetch(:sync_interval, 10)
74
+ @read_timeout = options.fetch(:read_timeout) { ENV.fetch("FLIPPER_CLOUD_READ_TIMEOUT", 5).to_f }
75
+ @open_timeout = options.fetch(:open_timeout) { ENV.fetch("FLIPPER_CLOUD_OPEN_TIMEOUT", 5).to_f }
76
+ @write_timeout = options.fetch(:write_timeout) { ENV.fetch("FLIPPER_CLOUD_WRITE_TIMEOUT", 5).to_f }
77
+ @sync_interval = options.fetch(:sync_interval) { ENV.fetch("FLIPPER_CLOUD_SYNC_INTERVAL", 10).to_f }
78
+ @sync_secret = options.fetch(:sync_secret) { ENV["FLIPPER_CLOUD_SYNC_SECRET"] }
63
79
  @local_adapter = options.fetch(:local_adapter) { Adapters::Memory.new }
64
80
  @debug_output = options[:debug_output]
65
81
  @adapter_block = ->(adapter) { adapter }
66
-
67
- self.url = options.fetch(:url, DEFAULT_URL)
82
+ self.sync_method = options.fetch(:sync_method) { ENV.fetch("FLIPPER_CLOUD_SYNC_METHOD", :poll).to_sym }
83
+ self.url = options.fetch(:url) { ENV.fetch("FLIPPER_CLOUD_URL", "https://www.flippercloud.io/adapter".freeze) }
68
84
  end
69
85
 
70
86
  # Public: Read or customize the http adapter. Calling without a block will
@@ -82,34 +98,57 @@ module Flipper
82
98
  if block_given?
83
99
  @adapter_block = block
84
100
  else
85
- @adapter_block.call sync_adapter
101
+ @adapter_block.call app_adapter
86
102
  end
87
103
  end
88
104
 
89
105
  # Public: Set url for the http adapter.
90
106
  attr_writer :url
91
107
 
108
+ def sync
109
+ Flipper::Adapters::Sync::Synchronizer.new(local_adapter, http_adapter, {
110
+ instrumenter: instrumenter,
111
+ interval: sync_interval,
112
+ }).call
113
+ end
114
+
115
+ def sync_method=(new_sync_method)
116
+ new_sync_method = new_sync_method.to_sym
117
+
118
+ unless VALID_SYNC_METHODS.include?(new_sync_method)
119
+ raise ArgumentError, "Unsupported sync_method. Valid options are (#{VALID_SYNC_METHODS.to_a.join(', ')})"
120
+ end
121
+
122
+ if new_sync_method == :webhook && sync_secret.nil?
123
+ raise ArgumentError, "Flipper::Cloud sync_secret is missing. Please set FLIPPER_CLOUD_SYNC_SECRET or provide the sync_secret used to validate webhooks."
124
+ end
125
+
126
+ @sync_method = new_sync_method
127
+ end
128
+
92
129
  private
93
130
 
131
+ def app_adapter
132
+ sync_method == :webhook ? local_adapter : sync_adapter
133
+ end
134
+
94
135
  def sync_adapter
95
- sync_options = {
136
+ Flipper::Adapters::Sync.new(local_adapter, http_adapter, {
96
137
  instrumenter: instrumenter,
97
138
  interval: sync_interval,
98
- }
99
- Flipper::Adapters::Sync.new(local_adapter, http_adapter, sync_options)
139
+ })
100
140
  end
101
141
 
102
142
  def http_adapter
103
- http_options = {
143
+ Flipper::Adapters::Http.new({
104
144
  url: @url,
105
145
  read_timeout: @read_timeout,
106
146
  open_timeout: @open_timeout,
107
147
  debug_output: @debug_output,
108
148
  headers: {
109
- "Feature-Flipper-Token" => @token,
149
+ "Flipper-Cloud-Token" => @token,
110
150
  },
111
- }
112
- Flipper::Adapters::Http.new(http_options)
151
+ })
113
152
  end
114
153
  end
115
154
  end
@@ -0,0 +1,27 @@
1
+ require 'forwardable'
2
+
3
+ module Flipper
4
+ module Cloud
5
+ class DSL < SimpleDelegator
6
+ attr_reader :cloud_configuration
7
+
8
+ def initialize(cloud_configuration)
9
+ @cloud_configuration = cloud_configuration
10
+ super Flipper.new(@cloud_configuration.adapter, instrumenter: @cloud_configuration.instrumenter)
11
+ end
12
+
13
+ def sync
14
+ @cloud_configuration.sync
15
+ end
16
+
17
+ def sync_secret
18
+ @cloud_configuration.sync_secret
19
+ end
20
+
21
+ def inspect
22
+ inspect_id = ::Kernel::format "%x", (object_id * 2)
23
+ %(#<#{self.class}:0x#{inspect_id} @cloud_configuration=#{cloud_configuration.inspect}, flipper=#{__getobj__.inspect}>)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,95 @@
1
+ require "openssl"
2
+ require "digest/sha2"
3
+
4
+ module Flipper
5
+ module Cloud
6
+ class MessageVerifier
7
+ class InvalidSignature < StandardError; end
8
+
9
+ DEFAULT_VERSION = "v1"
10
+
11
+ def self.header(signature, timestamp, version = DEFAULT_VERSION)
12
+ raise ArgumentError, "timestamp should be an instance of Time" unless timestamp.is_a?(Time)
13
+ raise ArgumentError, "signature should be a string" unless signature.is_a?(String)
14
+ "t=#{timestamp.to_i},#{version}=#{signature}"
15
+ end
16
+
17
+ def initialize(secret:, version: DEFAULT_VERSION)
18
+ @secret = secret
19
+ @version = version || DEFAULT_VERSION
20
+
21
+ raise ArgumentError, "secret should be a string" unless @secret.is_a?(String)
22
+ raise ArgumentError, "version should be a string" unless @version.is_a?(String)
23
+ end
24
+
25
+ def generate(payload, timestamp)
26
+ raise ArgumentError, "timestamp should be an instance of Time" unless timestamp.is_a?(Time)
27
+ raise ArgumentError, "payload should be a string" unless payload.is_a?(String)
28
+
29
+ OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), @secret, "#{timestamp.to_i}.#{payload}")
30
+ end
31
+
32
+ def header(signature, timestamp)
33
+ self.class.header(signature, timestamp, @version)
34
+ end
35
+
36
+ # Public: Verifies the signature header for a given payload.
37
+ #
38
+ # Raises a InvalidSignature in the following cases:
39
+ # - the header does not match the expected format
40
+ # - no signatures found with the expected scheme
41
+ # - no signatures matching the expected signature
42
+ # - a tolerance is provided and the timestamp is not within the
43
+ # tolerance
44
+ #
45
+ # Returns true otherwise.
46
+ def verify(payload, header, tolerance: nil)
47
+ begin
48
+ timestamp, signatures = get_timestamp_and_signatures(header)
49
+ rescue StandardError
50
+ raise InvalidSignature, "Unable to extract timestamp and signatures from header"
51
+ end
52
+
53
+ if signatures.empty?
54
+ raise InvalidSignature, "No signatures found with expected version #{@version}"
55
+ end
56
+
57
+ expected_sig = generate(payload, timestamp)
58
+ unless signatures.any? { |s| secure_compare(expected_sig, s) }
59
+ raise InvalidSignature, "No signatures found matching the expected signature for payload"
60
+ end
61
+
62
+ if tolerance && timestamp < Time.now - tolerance
63
+ raise InvalidSignature, "Timestamp outside the tolerance zone (#{Time.at(timestamp)})"
64
+ end
65
+
66
+ true
67
+ end
68
+
69
+ private
70
+
71
+ # Extracts the timestamp and the signature(s) with the desired version
72
+ # from the header
73
+ def get_timestamp_and_signatures(header)
74
+ list_items = header.split(/,\s*/).map { |i| i.split("=", 2) }
75
+ timestamp = Integer(list_items.select { |i| i[0] == "t" }[0][1])
76
+ signatures = list_items.select { |i| i[0] == @version }.map { |i| i[1] }
77
+ [Time.at(timestamp), signatures]
78
+ end
79
+
80
+ # Private
81
+ def fixed_length_secure_compare(a, b)
82
+ raise ArgumentError, "string length mismatch." unless a.bytesize == b.bytesize
83
+ l = a.unpack "C#{a.bytesize}"
84
+ res = 0
85
+ b.each_byte { |byte| res |= byte ^ l.shift }
86
+ res == 0
87
+ end
88
+
89
+ # Private
90
+ def secure_compare(a, b)
91
+ fixed_length_secure_compare(::Digest::SHA256.digest(a), ::Digest::SHA256.digest(b)) && a == b
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "flipper/cloud/message_verifier"
4
+
5
+ module Flipper
6
+ module Cloud
7
+ class Middleware
8
+ # Internal: The path to match for webhook requests.
9
+ WEBHOOK_PATH = %r{\A/webhooks\/?\Z}
10
+
11
+ def initialize(app, options = {})
12
+ @app = app
13
+ @env_key = options.fetch(:env_key, 'flipper')
14
+ end
15
+
16
+ def call(env)
17
+ dup.call!(env)
18
+ end
19
+
20
+ def call!(env)
21
+ request = Rack::Request.new(env)
22
+ if request.post? && request.path_info.match(WEBHOOK_PATH)
23
+ status = 200
24
+ headers = {
25
+ "Content-Type" => "application/json",
26
+ }
27
+ body = "{}"
28
+ payload = request.body.read
29
+ signature = request.env["HTTP_FLIPPER_CLOUD_SIGNATURE"]
30
+ flipper = env.fetch(@env_key)
31
+
32
+ begin
33
+ message_verifier = MessageVerifier.new(secret: flipper.sync_secret)
34
+ if message_verifier.verify(payload, signature)
35
+ flipper.sync
36
+ end
37
+ rescue MessageVerifier::InvalidSignature
38
+ status = 400
39
+ end
40
+
41
+ [status, headers, [body]]
42
+ else
43
+ @app.call(env)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -1,3 +1,3 @@
1
1
  module Flipper
2
- VERSION = '0.19.1'.freeze
2
+ VERSION = '0.20.0.beta1'.freeze
3
3
  end
@@ -12,6 +12,13 @@ RSpec.describe Flipper::Cloud::Configuration do
12
12
  expect(instance.token).to eq(required_options[:token])
13
13
  end
14
14
 
15
+ it "can set token from ENV var" do
16
+ with_modified_env "FLIPPER_CLOUD_TOKEN" => "from_env" do
17
+ instance = described_class.new(required_options.reject { |k, v| k == :token })
18
+ expect(instance.token).to eq("from_env")
19
+ end
20
+ end
21
+
15
22
  it "can set instrumenter" do
16
23
  instrumenter = Object.new
17
24
  instance = described_class.new(required_options.merge(instrumenter: instrumenter))
@@ -23,21 +30,49 @@ RSpec.describe Flipper::Cloud::Configuration do
23
30
  expect(instance.read_timeout).to eq(5)
24
31
  end
25
32
 
33
+ it "can set read_timeout from ENV var" do
34
+ with_modified_env "FLIPPER_CLOUD_READ_TIMEOUT" => "9" do
35
+ instance = described_class.new(required_options.reject { |k, v| k == :read_timeout })
36
+ expect(instance.read_timeout).to eq(9)
37
+ end
38
+ end
39
+
26
40
  it "can set open_timeout" do
27
41
  instance = described_class.new(required_options.merge(open_timeout: 5))
28
42
  expect(instance.open_timeout).to eq(5)
29
43
  end
30
44
 
45
+ it "can set open_timeout from ENV var" do
46
+ with_modified_env "FLIPPER_CLOUD_OPEN_TIMEOUT" => "9" do
47
+ instance = described_class.new(required_options.reject { |k, v| k == :open_timeout })
48
+ expect(instance.open_timeout).to eq(9)
49
+ end
50
+ end
51
+
31
52
  it "can set write_timeout" do
32
53
  instance = described_class.new(required_options.merge(write_timeout: 5))
33
54
  expect(instance.write_timeout).to eq(5)
34
55
  end
35
56
 
57
+ it "can set write_timeout from ENV var" do
58
+ with_modified_env "FLIPPER_CLOUD_WRITE_TIMEOUT" => "9" do
59
+ instance = described_class.new(required_options.reject { |k, v| k == :write_timeout })
60
+ expect(instance.write_timeout).to eq(9)
61
+ end
62
+ end
63
+
36
64
  it "can set sync_interval" do
37
65
  instance = described_class.new(required_options.merge(sync_interval: 1))
38
66
  expect(instance.sync_interval).to eq(1)
39
67
  end
40
68
 
69
+ it "can set sync_interval from ENV var" do
70
+ with_modified_env "FLIPPER_CLOUD_SYNC_INTERVAL" => "5" do
71
+ instance = described_class.new(required_options.reject { |k, v| k == :sync_interval })
72
+ expect(instance.sync_interval).to eq(5)
73
+ end
74
+ end
75
+
41
76
  it "passes sync_interval into sync adapter" do
42
77
  # The initial sync of http to local invokes this web request.
43
78
  stub_request(:get, /flippercloud\.io/).to_return(status: 200, body: "{}")
@@ -70,7 +105,12 @@ RSpec.describe Flipper::Cloud::Configuration do
70
105
  expect(instance.adapter).to be_instance_of(Flipper::Adapters::Instrumented)
71
106
  end
72
107
 
73
- it "can override url" do
108
+ it "defaults url" do
109
+ instance = described_class.new(required_options.reject { |k, v| k == :url })
110
+ expect(instance.url).to eq("https://www.flippercloud.io/adapter")
111
+ end
112
+
113
+ it "can override url using options" do
74
114
  options = required_options.merge(url: "http://localhost:5000/adapter")
75
115
  instance = described_class.new(options)
76
116
  expect(instance.url).to eq("http://localhost:5000/adapter")
@@ -79,4 +119,157 @@ RSpec.describe Flipper::Cloud::Configuration do
79
119
  instance.url = "http://localhost:5000/adapter"
80
120
  expect(instance.url).to eq("http://localhost:5000/adapter")
81
121
  end
122
+
123
+ it "can override URL using ENV var" do
124
+ with_modified_env "FLIPPER_CLOUD_URL" => "https://example.com" do
125
+ instance = described_class.new(required_options.reject { |k, v| k == :url })
126
+ expect(instance.url).to eq("https://example.com")
127
+ end
128
+ end
129
+
130
+ it "defaults to sync_method to poll" do
131
+ memory_adapter = Flipper::Adapters::Memory.new
132
+ instance = described_class.new(required_options)
133
+
134
+ expect(instance.sync_method).to eq(:poll)
135
+ end
136
+
137
+ it "can use webhook for sync_method" do
138
+ memory_adapter = Flipper::Adapters::Memory.new
139
+ instance = described_class.new(required_options.merge({
140
+ sync_secret: "secret",
141
+ sync_method: :webhook,
142
+ local_adapter: memory_adapter,
143
+ }))
144
+
145
+ expect(instance.sync_method).to eq(:webhook)
146
+ expect(instance.adapter).to be_instance_of(Flipper::Adapters::Memory)
147
+ end
148
+
149
+ it "raises ArgumentError for invalid sync_method" do
150
+ expect {
151
+ described_class.new(required_options.merge(sync_method: :foo))
152
+ }.to raise_error(ArgumentError, "Unsupported sync_method. Valid options are (poll, webhook)")
153
+ end
154
+
155
+ it "can use ENV var for sync_method" do
156
+ with_modified_env "FLIPPER_CLOUD_SYNC_METHOD" => "webhook" do
157
+ instance = described_class.new(required_options.merge({
158
+ sync_secret: "secret",
159
+ }))
160
+
161
+ expect(instance.sync_method).to eq(:webhook)
162
+ end
163
+ end
164
+
165
+ it "can use string sync_method instead of symbol" do
166
+ memory_adapter = Flipper::Adapters::Memory.new
167
+ instance = described_class.new(required_options.merge({
168
+ sync_secret: "secret",
169
+ sync_method: "webhook",
170
+ local_adapter: memory_adapter,
171
+ }))
172
+
173
+ expect(instance.sync_method).to eq(:webhook)
174
+ expect(instance.adapter).to be_instance_of(Flipper::Adapters::Memory)
175
+ end
176
+
177
+ it "can set sync_secret" do
178
+ instance = described_class.new(required_options.merge(sync_secret: "from_config"))
179
+ expect(instance.sync_secret).to eq("from_config")
180
+ end
181
+
182
+ it "can override sync_secret using ENV var" do
183
+ with_modified_env "FLIPPER_CLOUD_SYNC_SECRET" => "from_env" do
184
+ instance = described_class.new(required_options.reject { |k, v| k == :sync_secret })
185
+ expect(instance.sync_secret).to eq("from_env")
186
+ end
187
+ end
188
+
189
+ it "can sync with cloud" do
190
+ body = JSON.generate({
191
+ "features": [
192
+ {
193
+ "key": "search",
194
+ "state": "on",
195
+ "gates": [
196
+ {
197
+ "key": "boolean",
198
+ "name": "boolean",
199
+ "value": true
200
+ },
201
+ {
202
+ "key": "groups",
203
+ "name": "group",
204
+ "value": []
205
+ },
206
+ {
207
+ "key": "actors",
208
+ "name": "actor",
209
+ "value": []
210
+ },
211
+ {
212
+ "key": "percentage_of_actors",
213
+ "name": "percentage_of_actors",
214
+ "value": 0
215
+ },
216
+ {
217
+ "key": "percentage_of_time",
218
+ "name": "percentage_of_time",
219
+ "value": 0
220
+ }
221
+ ]
222
+ },
223
+ {
224
+ "key": "history",
225
+ "state": "off",
226
+ "gates": [
227
+ {
228
+ "key": "boolean",
229
+ "name": "boolean",
230
+ "value": false
231
+ },
232
+ {
233
+ "key": "groups",
234
+ "name": "group",
235
+ "value": []
236
+ },
237
+ {
238
+ "key": "actors",
239
+ "name": "actor",
240
+ "value": []
241
+ },
242
+ {
243
+ "key": "percentage_of_actors",
244
+ "name": "percentage_of_actors",
245
+ "value": 0
246
+ },
247
+ {
248
+ "key": "percentage_of_time",
249
+ "name": "percentage_of_time",
250
+ "value": 0
251
+ }
252
+ ]
253
+ }
254
+ ]
255
+ })
256
+ stub = stub_request(:get, "https://www.flippercloud.io/adapter/features").
257
+ with({
258
+ headers: {
259
+ 'Flipper-Cloud-Token'=>'asdf',
260
+ },
261
+ }).to_return(status: 200, body: body, headers: {})
262
+ instance = described_class.new(required_options)
263
+ instance.sync
264
+
265
+ # Check that remote was fetched.
266
+ expect(stub).to have_been_requested
267
+
268
+ # Check that local adapter really did sync.
269
+ local_adapter = instance.adapter.instance_variable_get("@local")
270
+ all = local_adapter.get_all
271
+ expect(all.keys).to eq(["search", "history"])
272
+ expect(all["search"][:boolean]).to eq("true")
273
+ expect(all["history"][:boolean]).to eq(nil)
274
+ end
82
275
  end
@@ -0,0 +1,44 @@
1
+ require 'helper'
2
+ require 'flipper/cloud/configuration'
3
+ require 'flipper/cloud/dsl'
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
+ sync_method: :webhook,
12
+ })
13
+ dsl = described_class.new(cloud_configuration)
14
+ expect(dsl.features).to eq(Set.new)
15
+ expect(dsl.enabled?(:foo)).to be(false)
16
+ end
17
+
18
+ it 'delegates sync to cloud configuration' do
19
+ stub = stub_request(:get, "https://www.flippercloud.io/adapter/features").
20
+ with({
21
+ headers: {
22
+ 'Flipper-Cloud-Token'=>'asdf',
23
+ },
24
+ }).to_return(status: 200, body: '{"features": {}}', headers: {})
25
+ cloud_configuration = Flipper::Cloud::Configuration.new({
26
+ token: "asdf",
27
+ sync_secret: "tasty",
28
+ sync_method: :webhook,
29
+ })
30
+ dsl = described_class.new(cloud_configuration)
31
+ dsl.sync
32
+ expect(stub).to have_been_requested
33
+ end
34
+
35
+ it 'delegates sync_secret to cloud configuration' do
36
+ cloud_configuration = Flipper::Cloud::Configuration.new({
37
+ token: "asdf",
38
+ sync_secret: "tasty",
39
+ sync_method: :webhook,
40
+ })
41
+ dsl = described_class.new(cloud_configuration)
42
+ expect(dsl.sync_secret).to eq("tasty")
43
+ end
44
+ end
@@ -0,0 +1,105 @@
1
+ require 'helper'
2
+ require 'flipper/cloud/message_verifier'
3
+
4
+ RSpec.describe Flipper::Cloud::MessageVerifier do
5
+ let(:payload) { "some payload" }
6
+ let(:secret) { "secret" }
7
+ let(:timestamp) { Time.now }
8
+
9
+ describe "#generate" do
10
+ it "generates signature that can be verified" do
11
+ message_verifier = Flipper::Cloud::MessageVerifier.new(secret: secret)
12
+ signature = message_verifier.generate(payload, timestamp)
13
+ header = generate_header(timestamp: timestamp, signature: signature)
14
+ expect(message_verifier.verify(payload, header)).to be(true)
15
+ end
16
+ end
17
+
18
+ describe "#header" do
19
+ it "generates a header in valid format" do
20
+ version = "v1"
21
+ message_verifier = Flipper::Cloud::MessageVerifier.new(secret: secret, version: version)
22
+ signature = message_verifier.generate(payload, timestamp)
23
+ header = message_verifier.header(signature, timestamp)
24
+ expect(header).to eq("t=#{timestamp.to_i},#{version}=#{signature}")
25
+ end
26
+ end
27
+
28
+ describe ".header" do
29
+ it "generates a header in valid format" do
30
+ version = "v1"
31
+ message_verifier = Flipper::Cloud::MessageVerifier.new(secret: secret, version: version)
32
+ signature = message_verifier.generate(payload, timestamp)
33
+
34
+ header = Flipper::Cloud::MessageVerifier.header(signature, timestamp, version)
35
+ expect(header).to eq("t=#{timestamp.to_i},#{version}=#{signature}")
36
+ end
37
+ end
38
+
39
+ describe "#verify" do
40
+ it "raises a InvalidSignature when the header does not have the expected format" do
41
+ header = "i'm not even a real signature header"
42
+ expect {
43
+ message_verifier = Flipper::Cloud::MessageVerifier.new(secret: "secret")
44
+ message_verifier.verify(payload, header)
45
+ }.to raise_error(Flipper::Cloud::MessageVerifier::InvalidSignature, "Unable to extract timestamp and signatures from header")
46
+ end
47
+
48
+ it "raises a InvalidSignature when there are no signatures with the expected version" do
49
+ header = generate_header(version: "v0")
50
+ expect {
51
+ message_verifier = Flipper::Cloud::MessageVerifier.new(secret: "secret")
52
+ message_verifier.verify(payload, header)
53
+ }.to raise_error(Flipper::Cloud::MessageVerifier::InvalidSignature, /No signatures found with expected version/)
54
+ end
55
+
56
+ it "raises a InvalidSignature when there are no valid signatures for the payload" do
57
+ header = generate_header(signature: "bad_signature")
58
+ expect {
59
+ message_verifier = Flipper::Cloud::MessageVerifier.new(secret: "secret")
60
+ message_verifier.verify(payload, header)
61
+ }.to raise_error(Flipper::Cloud::MessageVerifier::InvalidSignature, "No signatures found matching the expected signature for payload")
62
+ end
63
+
64
+ it "raises a InvalidSignature when the timestamp is not within the tolerance" do
65
+ header = generate_header(timestamp: Time.now - 15)
66
+ expect {
67
+ message_verifier = Flipper::Cloud::MessageVerifier.new(secret: secret)
68
+ message_verifier.verify(payload, header, tolerance: 10)
69
+ }.to raise_error(Flipper::Cloud::MessageVerifier::InvalidSignature, /Timestamp outside the tolerance zone/)
70
+ end
71
+
72
+ it "returns true when the header contains a valid signature and the timestamp is within the tolerance" do
73
+ header = generate_header
74
+ message_verifier = Flipper::Cloud::MessageVerifier.new(secret: "secret")
75
+ expect(message_verifier.verify(payload, header, tolerance: 10)).to be(true)
76
+ end
77
+
78
+ it "returns true when the header contains at least one valid signature" do
79
+ header = generate_header + ",v1=bad_signature"
80
+ message_verifier = Flipper::Cloud::MessageVerifier.new(secret: secret)
81
+ expect(message_verifier.verify(payload, header, tolerance: 10)).to be(true)
82
+ end
83
+
84
+ it "returns true when the header contains a valid signature and the timestamp is off but no tolerance is provided" do
85
+ header = generate_header(timestamp: Time.at(12_345))
86
+ message_verifier = Flipper::Cloud::MessageVerifier.new(secret: secret)
87
+ expect(message_verifier.verify(payload, header)).to be(true)
88
+ end
89
+ end
90
+
91
+ private
92
+
93
+ def generate_header(options = {})
94
+ options[:secret] ||= secret
95
+ options[:version] ||= "v1"
96
+
97
+ message_verifier = Flipper::Cloud::MessageVerifier.new(secret: options[:secret], version: options[:version])
98
+
99
+ options[:timestamp] ||= timestamp
100
+ options[:payload] ||= payload
101
+ options[:signature] ||= message_verifier.generate(options[:payload], options[:timestamp])
102
+
103
+ Flipper::Cloud::MessageVerifier.header(options[:signature], options[:timestamp], options[:version])
104
+ end
105
+ end
@@ -0,0 +1,173 @@
1
+ require 'securerandom'
2
+ require 'helper'
3
+ require 'flipper/cloud'
4
+ require 'flipper/cloud/middleware'
5
+ require 'flipper/adapters/operation_logger'
6
+
7
+ RSpec.describe Flipper::Cloud::Middleware do
8
+ let(:flipper) {
9
+ Flipper::Cloud.new("regular") do |config|
10
+ config.local_adapter = Flipper::Adapters::OperationLogger.new(Flipper::Adapters::Memory.new)
11
+ config.sync_secret = "regular_tasty"
12
+ config.sync_method = :webhook
13
+ end
14
+ }
15
+
16
+ let(:env_flipper) {
17
+ Flipper::Cloud.new("env") do |config|
18
+ config.local_adapter = Flipper::Adapters::OperationLogger.new(Flipper::Adapters::Memory.new)
19
+ config.sync_secret = "env_tasty"
20
+ config.sync_method = :webhook
21
+ end
22
+ }
23
+
24
+ let(:app) { Flipper::Cloud.app(flipper) }
25
+ let(:response_body) { JSON.generate({features: {}}) }
26
+ let(:request_body) {
27
+ JSON.generate({
28
+ "environment_id" => 1,
29
+ "webhook_id" => 1,
30
+ "delivery_id" => SecureRandom.uuid,
31
+ "action" => "sync",
32
+ })
33
+ }
34
+ let(:timestamp) { Time.now }
35
+ let(:signature) {
36
+ Flipper::Cloud::MessageVerifier.new(secret: flipper.sync_secret).generate(request_body, timestamp)
37
+ }
38
+ let(:signature_header_value) {
39
+ Flipper::Cloud::MessageVerifier.new(secret: "").header(signature, timestamp)
40
+ }
41
+
42
+ context 'when initializing middleware with flipper instance' do
43
+ let(:app) { Flipper::Cloud.app(flipper) }
44
+
45
+ it 'uses instance to sync' do
46
+ stub = stub_request_for_token('regular')
47
+ env = {
48
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
49
+ }
50
+ post '/webhooks', request_body, env
51
+
52
+ expect(last_response.status).to eq(200)
53
+ expect(stub).to have_been_requested
54
+ end
55
+ end
56
+
57
+ context 'when signature is invalid' do
58
+ let(:app) { Flipper::Cloud.app(flipper) }
59
+ let(:signature) {
60
+ Flipper::Cloud::MessageVerifier.new(secret: "nope").generate(request_body, timestamp)
61
+ }
62
+
63
+ it 'uses instance to sync' do
64
+ stub = stub_request_for_token('regular')
65
+ env = {
66
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
67
+ }
68
+ post '/webhooks', request_body, env
69
+
70
+ expect(last_response.status).to eq(400)
71
+ expect(stub).not_to have_been_requested
72
+ end
73
+ end
74
+
75
+ context 'when initialized with flipper instance and flipper instance in env' do
76
+ let(:app) { Flipper::Cloud.app(flipper) }
77
+ let(:signature) {
78
+ Flipper::Cloud::MessageVerifier.new(secret: env_flipper.sync_secret).generate(request_body, timestamp)
79
+ }
80
+
81
+ it 'uses env instance to sync' do
82
+ stub = stub_request_for_token('env')
83
+ env = {
84
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
85
+ 'flipper' => env_flipper,
86
+ }
87
+ post '/webhooks', request_body, env
88
+
89
+ expect(last_response.status).to eq(200)
90
+ expect(stub).to have_been_requested
91
+ end
92
+ end
93
+
94
+ context 'when initialized without flipper instance but flipper instance in env' do
95
+ let(:app) { Flipper::Cloud.app }
96
+ let(:signature) {
97
+ Flipper::Cloud::MessageVerifier.new(secret: env_flipper.sync_secret).generate(request_body, timestamp)
98
+ }
99
+
100
+ it 'uses env instance to sync' do
101
+ stub = stub_request_for_token('env')
102
+ env = {
103
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
104
+ 'flipper' => env_flipper,
105
+ }
106
+ post '/webhooks', request_body, env
107
+
108
+ expect(last_response.status).to eq(200)
109
+ expect(stub).to have_been_requested
110
+ end
111
+ end
112
+
113
+ context 'when initialized with env_key' do
114
+ let(:app) { Flipper::Cloud.app(flipper, env_key: 'flipper_cloud') }
115
+ let(:signature) {
116
+ Flipper::Cloud::MessageVerifier.new(secret: env_flipper.sync_secret).generate(request_body, timestamp)
117
+ }
118
+
119
+ it 'uses provided env key instead of default' do
120
+ stub = stub_request_for_token('env')
121
+ env = {
122
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
123
+ 'flipper' => flipper,
124
+ 'flipper_cloud' => env_flipper,
125
+ }
126
+ post '/webhooks', request_body, env
127
+
128
+ expect(last_response.status).to eq(200)
129
+ expect(stub).to have_been_requested
130
+ end
131
+ end
132
+
133
+ context 'when initializing lazily with a block' do
134
+ let(:app) { Flipper::Cloud.app(-> { flipper }) }
135
+
136
+ it 'works' do
137
+ stub = stub_request_for_token('regular')
138
+ env = {
139
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
140
+ }
141
+ post '/webhooks', request_body, env
142
+
143
+ expect(last_response.status).to eq(200)
144
+ expect(stub).to have_been_requested
145
+ end
146
+ end
147
+
148
+ describe 'Request method unsupported' do
149
+ it 'skips middleware' do
150
+ get '/webhooks'
151
+ expect(last_response.status).to eq(404)
152
+ expect(last_response.content_type).to eq("application/json")
153
+ expect(last_response.body).to eq("{}")
154
+ end
155
+ end
156
+
157
+ describe 'Inspecting the built Rack app' do
158
+ it 'returns a String' do
159
+ expect(Flipper::Cloud.app(flipper).inspect).to be_a(String)
160
+ end
161
+ end
162
+
163
+ private
164
+
165
+ def stub_request_for_token(token)
166
+ stub_request(:get, "https://www.flippercloud.io/adapter/features").
167
+ with({
168
+ headers: {
169
+ 'Flipper-Cloud-Token' => token,
170
+ },
171
+ }).to_return(status: 200, body: response_body, headers: {})
172
+ end
173
+ end
@@ -20,7 +20,11 @@ RSpec.describe Flipper::Cloud do
20
20
  end
21
21
 
22
22
  it 'returns Flipper::DSL instance' do
23
- expect(@instance).to be_instance_of(Flipper::DSL)
23
+ expect(@instance).to be_instance_of(Flipper::Cloud::DSL)
24
+ end
25
+
26
+ it 'can read the cloud configuration' do
27
+ expect(@instance.cloud_configuration).to be_instance_of(Flipper::Cloud::Configuration)
24
28
  end
25
29
 
26
30
  it 'configures instance to use http adapter' do
@@ -36,7 +40,7 @@ RSpec.describe Flipper::Cloud do
36
40
 
37
41
  it 'sets correct token header' do
38
42
  headers = @http_client.instance_variable_get('@headers')
39
- expect(headers['Feature-Flipper-Token']).to eq(token)
43
+ expect(headers['Flipper-Cloud-Token']).to eq(token)
40
44
  end
41
45
 
42
46
  it 'uses noop instrumenter' do
@@ -63,6 +67,12 @@ RSpec.describe Flipper::Cloud do
63
67
  end
64
68
  end
65
69
 
70
+ it 'can initialize with no token explicitly provided' do
71
+ with_modified_env "FLIPPER_CLOUD_TOKEN" => "asdf" do
72
+ expect(described_class.new).to be_instance_of(Flipper::Cloud::DSL)
73
+ end
74
+ end
75
+
66
76
  it 'can set instrumenter' do
67
77
  instrumenter = Flipper::Instrumenters::Memory.new
68
78
  instance = described_class.new('asdf', instrumenter: instrumenter)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: flipper-cloud
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.19.1
4
+ version: 0.20.0.beta1
5
5
  platform: ruby
6
6
  authors:
7
7
  - John Nunemaker
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-12-04 00:00:00.000000000 Z
11
+ date: 2020-12-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: flipper
@@ -16,14 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: 0.19.1
19
+ version: 0.20.0.beta1
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: 0.19.1
26
+ version: 0.20.0.beta1
27
27
  description:
28
28
  email:
29
29
  - nunemaker@gmail.com
@@ -31,6 +31,7 @@ executables: []
31
31
  extensions: []
32
32
  extra_rdoc_files: []
33
33
  files:
34
+ - examples/cloud/app.ru
34
35
  - examples/cloud/basic.rb
35
36
  - examples/cloud/cached_in_memory.rb
36
37
  - examples/cloud/import.rb
@@ -39,8 +40,14 @@ files:
39
40
  - lib/flipper-cloud.rb
40
41
  - lib/flipper/cloud.rb
41
42
  - lib/flipper/cloud/configuration.rb
43
+ - lib/flipper/cloud/dsl.rb
44
+ - lib/flipper/cloud/message_verifier.rb
45
+ - lib/flipper/cloud/middleware.rb
42
46
  - lib/flipper/version.rb
43
47
  - spec/flipper/cloud/configuration_spec.rb
48
+ - spec/flipper/cloud/dsl_spec.rb
49
+ - spec/flipper/cloud/message_verifier_spec.rb
50
+ - spec/flipper/cloud/middleware_spec.rb
44
51
  - spec/flipper/cloud_spec.rb
45
52
  homepage: https://github.com/jnunemaker/flipper
46
53
  licenses:
@@ -58,9 +65,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
58
65
  version: '0'
59
66
  required_rubygems_version: !ruby/object:Gem::Requirement
60
67
  requirements:
61
- - - ">="
68
+ - - ">"
62
69
  - !ruby/object:Gem::Version
63
- version: '0'
70
+ version: 1.3.1
64
71
  requirements: []
65
72
  rubygems_version: 3.0.3
66
73
  signing_key:
@@ -68,4 +75,7 @@ specification_version: 4
68
75
  summary: FeatureFlipper.com adapter for Flipper
69
76
  test_files:
70
77
  - spec/flipper/cloud/configuration_spec.rb
78
+ - spec/flipper/cloud/dsl_spec.rb
79
+ - spec/flipper/cloud/message_verifier_spec.rb
80
+ - spec/flipper/cloud/middleware_spec.rb
71
81
  - spec/flipper/cloud_spec.rb