flipper-cloud 0.19.1 → 0.20.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/examples/cloud/app.ru +17 -0
- data/lib/flipper/cloud.rb +24 -3
- data/lib/flipper/cloud/configuration.rb +64 -19
- data/lib/flipper/cloud/dsl.rb +27 -0
- data/lib/flipper/cloud/message_verifier.rb +95 -0
- data/lib/flipper/cloud/middleware.rb +51 -0
- data/lib/flipper/version.rb +1 -1
- data/spec/flipper/cloud/configuration_spec.rb +194 -1
- data/spec/flipper/cloud/dsl_spec.rb +87 -0
- data/spec/flipper/cloud/message_verifier_spec.rb +105 -0
- data/spec/flipper/cloud/middleware_spec.rb +188 -0
- data/spec/flipper/cloud_spec.rb +12 -2
- metadata +14 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: d17e92045e80beb6c0c83aa2591b75585393d0e6e922b834379fcfa0f5dc9049
|
4
|
+
data.tar.gz: 6943dde37bab5cadf5e8e8a2db847aa6ddb0e310fd1b7bc9c5638835ae81cfe1
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: cf7073018558fa53b8f7ada5d984b42066d0a5ae5cd81e343cd249cc2d1032ecd4c95a094c2f49027f1bf87de7ddc6c7f1a5804c5658d4700c09f0c0c7d74b8c
|
7
|
+
data.tar.gz: faa00d96aae37eff0559f88e8a913ff407526825b55eff2404c794b2a6aa7f1f9882d04022746468fc157c52f8cc4eafa2ffc500c11aaa213876b38d963d6cab
|
@@ -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
|
data/lib/flipper/cloud.rb
CHANGED
@@ -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
|
-
|
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
|
-
|
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
|
@@ -1,19 +1,22 @@
|
|
1
1
|
require "flipper/adapters/http"
|
2
2
|
require "flipper/adapters/memory"
|
3
|
+
require "flipper/adapters/dual_write"
|
3
4
|
require "flipper/adapters/sync"
|
4
5
|
|
5
6
|
module Flipper
|
6
7
|
module Cloud
|
7
8
|
class Configuration
|
8
|
-
# The
|
9
|
-
|
9
|
+
# The set of valid ways that syncing can happpen.
|
10
|
+
VALID_SYNC_METHODS = Set[
|
11
|
+
:poll,
|
12
|
+
:webhook,
|
13
|
+
].freeze
|
10
14
|
|
11
15
|
# Public: The token corresponding to an environment on flippercloud.io.
|
12
16
|
attr_accessor :token
|
13
17
|
|
14
|
-
# Public: The url for http adapter
|
15
|
-
|
16
|
-
# to forget you ever saw this.
|
18
|
+
# Public: The url for http adapter. Really should only be customized for
|
19
|
+
# development work. Feel free to forget you ever saw this.
|
17
20
|
attr_reader :url
|
18
21
|
|
19
22
|
# Public: net/http read timeout for all http requests (default: 5).
|
@@ -53,18 +56,32 @@ module Flipper
|
|
53
56
|
# the local in sync with cloud (default: 10).
|
54
57
|
attr_accessor :sync_interval
|
55
58
|
|
59
|
+
# Public: The method to be used for synchronizing your local flipper
|
60
|
+
# adapter with cloud. (default: :poll, can also be :webhook).
|
61
|
+
attr_reader :sync_method
|
62
|
+
|
63
|
+
# Public: The secret used to verify if syncs in the middleware should
|
64
|
+
# occur or not.
|
65
|
+
attr_accessor :sync_secret
|
66
|
+
|
56
67
|
def initialize(options = {})
|
57
|
-
@token = options.fetch(:token)
|
68
|
+
@token = options.fetch(:token) { ENV["FLIPPER_CLOUD_TOKEN"] }
|
69
|
+
|
70
|
+
if @token.nil?
|
71
|
+
raise ArgumentError, "Flipper::Cloud token is missing. Please set FLIPPER_CLOUD_TOKEN or provide the token (e.g. Flipper::Cloud.new('token'))."
|
72
|
+
end
|
73
|
+
|
58
74
|
@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)
|
75
|
+
@read_timeout = options.fetch(:read_timeout) { ENV.fetch("FLIPPER_CLOUD_READ_TIMEOUT", 5).to_f }
|
76
|
+
@open_timeout = options.fetch(:open_timeout) { ENV.fetch("FLIPPER_CLOUD_OPEN_TIMEOUT", 5).to_f }
|
77
|
+
@write_timeout = options.fetch(:write_timeout) { ENV.fetch("FLIPPER_CLOUD_WRITE_TIMEOUT", 5).to_f }
|
78
|
+
@sync_interval = options.fetch(:sync_interval) { ENV.fetch("FLIPPER_CLOUD_SYNC_INTERVAL", 10).to_f }
|
79
|
+
@sync_secret = options.fetch(:sync_secret) { ENV["FLIPPER_CLOUD_SYNC_SECRET"] }
|
63
80
|
@local_adapter = options.fetch(:local_adapter) { Adapters::Memory.new }
|
64
81
|
@debug_output = options[:debug_output]
|
65
82
|
@adapter_block = ->(adapter) { adapter }
|
66
|
-
|
67
|
-
self.url = options.fetch(:url,
|
83
|
+
self.sync_method = options.fetch(:sync_method) { ENV.fetch("FLIPPER_CLOUD_SYNC_METHOD", :poll).to_sym }
|
84
|
+
self.url = options.fetch(:url) { ENV.fetch("FLIPPER_CLOUD_URL", "https://www.flippercloud.io/adapter".freeze) }
|
68
85
|
end
|
69
86
|
|
70
87
|
# Public: Read or customize the http adapter. Calling without a block will
|
@@ -82,34 +99,62 @@ module Flipper
|
|
82
99
|
if block_given?
|
83
100
|
@adapter_block = block
|
84
101
|
else
|
85
|
-
@adapter_block.call
|
102
|
+
@adapter_block.call app_adapter
|
86
103
|
end
|
87
104
|
end
|
88
105
|
|
89
106
|
# Public: Set url for the http adapter.
|
90
107
|
attr_writer :url
|
91
108
|
|
109
|
+
def sync
|
110
|
+
Flipper::Adapters::Sync::Synchronizer.new(local_adapter, http_adapter, {
|
111
|
+
instrumenter: instrumenter,
|
112
|
+
interval: sync_interval,
|
113
|
+
}).call
|
114
|
+
end
|
115
|
+
|
116
|
+
def sync_method=(new_sync_method)
|
117
|
+
new_sync_method = new_sync_method.to_sym
|
118
|
+
|
119
|
+
unless VALID_SYNC_METHODS.include?(new_sync_method)
|
120
|
+
raise ArgumentError, "Unsupported sync_method. Valid options are (#{VALID_SYNC_METHODS.to_a.join(', ')})"
|
121
|
+
end
|
122
|
+
|
123
|
+
if new_sync_method == :webhook && sync_secret.nil?
|
124
|
+
raise ArgumentError, "Flipper::Cloud sync_secret is missing. Please set FLIPPER_CLOUD_SYNC_SECRET or provide the sync_secret used to validate webhooks."
|
125
|
+
end
|
126
|
+
|
127
|
+
@sync_method = new_sync_method
|
128
|
+
end
|
129
|
+
|
92
130
|
private
|
93
131
|
|
132
|
+
def app_adapter
|
133
|
+
sync_method == :webhook ? dual_write_adapter : sync_adapter
|
134
|
+
end
|
135
|
+
|
136
|
+
def dual_write_adapter
|
137
|
+
Flipper::Adapters::DualWrite.new(local_adapter, http_adapter)
|
138
|
+
end
|
139
|
+
|
94
140
|
def sync_adapter
|
95
|
-
|
141
|
+
Flipper::Adapters::Sync.new(local_adapter, http_adapter, {
|
96
142
|
instrumenter: instrumenter,
|
97
143
|
interval: sync_interval,
|
98
|
-
}
|
99
|
-
Flipper::Adapters::Sync.new(local_adapter, http_adapter, sync_options)
|
144
|
+
})
|
100
145
|
end
|
101
146
|
|
102
147
|
def http_adapter
|
103
|
-
|
148
|
+
Flipper::Adapters::Http.new({
|
104
149
|
url: @url,
|
105
150
|
read_timeout: @read_timeout,
|
106
151
|
open_timeout: @open_timeout,
|
107
152
|
debug_output: @debug_output,
|
108
153
|
headers: {
|
154
|
+
"Flipper-Cloud-Token" => @token,
|
109
155
|
"Feature-Flipper-Token" => @token,
|
110
156
|
},
|
111
|
-
}
|
112
|
-
Flipper::Adapters::Http.new(http_options)
|
157
|
+
})
|
113
158
|
end
|
114
159
|
end
|
115
160
|
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,51 @@
|
|
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
|
+
body = JSON.generate({
|
37
|
+
groups: Flipper.group_names.map { |name| {name: name}}
|
38
|
+
})
|
39
|
+
end
|
40
|
+
rescue MessageVerifier::InvalidSignature
|
41
|
+
status = 400
|
42
|
+
end
|
43
|
+
|
44
|
+
[status, headers, [body]]
|
45
|
+
else
|
46
|
+
@app.call(env)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
data/lib/flipper/version.rb
CHANGED
@@ -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 "
|
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::DualWrite)
|
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::DualWrite)
|
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,87 @@
|
|
1
|
+
require 'helper'
|
2
|
+
require 'flipper/cloud/configuration'
|
3
|
+
require 'flipper/cloud/dsl'
|
4
|
+
require 'flipper/adapters/operation_logger'
|
5
|
+
require 'flipper/adapters/instrumented'
|
6
|
+
|
7
|
+
RSpec.describe Flipper::Cloud::DSL do
|
8
|
+
it 'delegates everything to flipper instance' do
|
9
|
+
cloud_configuration = Flipper::Cloud::Configuration.new({
|
10
|
+
token: "asdf",
|
11
|
+
sync_secret: "tasty",
|
12
|
+
sync_method: :webhook,
|
13
|
+
})
|
14
|
+
dsl = described_class.new(cloud_configuration)
|
15
|
+
expect(dsl.features).to eq(Set.new)
|
16
|
+
expect(dsl.enabled?(:foo)).to be(false)
|
17
|
+
end
|
18
|
+
|
19
|
+
it 'delegates sync to cloud configuration' do
|
20
|
+
stub = stub_request(:get, "https://www.flippercloud.io/adapter/features").
|
21
|
+
with({
|
22
|
+
headers: {
|
23
|
+
'Flipper-Cloud-Token'=>'asdf',
|
24
|
+
},
|
25
|
+
}).to_return(status: 200, body: '{"features": {}}', headers: {})
|
26
|
+
cloud_configuration = Flipper::Cloud::Configuration.new({
|
27
|
+
token: "asdf",
|
28
|
+
sync_secret: "tasty",
|
29
|
+
sync_method: :webhook,
|
30
|
+
})
|
31
|
+
dsl = described_class.new(cloud_configuration)
|
32
|
+
dsl.sync
|
33
|
+
expect(stub).to have_been_requested
|
34
|
+
end
|
35
|
+
|
36
|
+
it 'delegates sync_secret to cloud configuration' do
|
37
|
+
cloud_configuration = Flipper::Cloud::Configuration.new({
|
38
|
+
token: "asdf",
|
39
|
+
sync_secret: "tasty",
|
40
|
+
sync_method: :webhook,
|
41
|
+
})
|
42
|
+
dsl = described_class.new(cloud_configuration)
|
43
|
+
expect(dsl.sync_secret).to eq("tasty")
|
44
|
+
end
|
45
|
+
|
46
|
+
context "when sync_method is webhook" do
|
47
|
+
let(:local_adapter) do
|
48
|
+
Flipper::Adapters::OperationLogger.new Flipper::Adapters::Memory.new
|
49
|
+
end
|
50
|
+
|
51
|
+
let(:cloud_configuration) do
|
52
|
+
cloud_configuration = Flipper::Cloud::Configuration.new({
|
53
|
+
token: "asdf",
|
54
|
+
sync_secret: "tasty",
|
55
|
+
sync_method: :webhook,
|
56
|
+
local_adapter: local_adapter
|
57
|
+
})
|
58
|
+
end
|
59
|
+
|
60
|
+
subject do
|
61
|
+
described_class.new(cloud_configuration)
|
62
|
+
end
|
63
|
+
|
64
|
+
it "sends reads to local adapter" do
|
65
|
+
subject.features
|
66
|
+
subject.enabled?(:foo)
|
67
|
+
expect(local_adapter.count(:features)).to be(1)
|
68
|
+
expect(local_adapter.count(:get)).to be(1)
|
69
|
+
end
|
70
|
+
|
71
|
+
it "sends writes to cloud and local" do
|
72
|
+
add_stub = stub_request(:post, "https://www.flippercloud.io/adapter/features").
|
73
|
+
with({headers: {'Flipper-Cloud-Token'=>'asdf'}}).
|
74
|
+
to_return(status: 200, body: '{}', headers: {})
|
75
|
+
enable_stub = stub_request(:post, "https://www.flippercloud.io/adapter/features/foo/boolean").
|
76
|
+
with(headers: {'Flipper-Cloud-Token'=>'asdf'}).
|
77
|
+
to_return(status: 200, body: '{}', headers: {})
|
78
|
+
|
79
|
+
subject.enable(:foo)
|
80
|
+
|
81
|
+
expect(local_adapter.count(:add)).to be(1)
|
82
|
+
expect(local_adapter.count(:enable)).to be(1)
|
83
|
+
expect(add_stub).to have_been_requested
|
84
|
+
expect(enable_stub).to have_been_requested
|
85
|
+
end
|
86
|
+
end
|
87
|
+
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,188 @@
|
|
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
|
+
Flipper.register(:admins) { |*args| false }
|
47
|
+
Flipper.register(:staff) { |*args| false }
|
48
|
+
Flipper.register(:basic) { |*args| false }
|
49
|
+
Flipper.register(:plus) { |*args| false }
|
50
|
+
Flipper.register(:premium) { |*args| false }
|
51
|
+
|
52
|
+
stub = stub_request_for_token('regular')
|
53
|
+
env = {
|
54
|
+
"HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
|
55
|
+
}
|
56
|
+
post '/webhooks', request_body, env
|
57
|
+
|
58
|
+
expect(last_response.status).to eq(200)
|
59
|
+
expect(JSON.parse(last_response.body)).to eq({
|
60
|
+
"groups" => [
|
61
|
+
{"name" => "admins"},
|
62
|
+
{"name" => "staff"},
|
63
|
+
{"name" => "basic"},
|
64
|
+
{"name" => "plus"},
|
65
|
+
{"name" => "premium"},
|
66
|
+
],
|
67
|
+
})
|
68
|
+
expect(stub).to have_been_requested
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
context 'when signature is invalid' do
|
73
|
+
let(:app) { Flipper::Cloud.app(flipper) }
|
74
|
+
let(:signature) {
|
75
|
+
Flipper::Cloud::MessageVerifier.new(secret: "nope").generate(request_body, timestamp)
|
76
|
+
}
|
77
|
+
|
78
|
+
it 'uses instance to sync' do
|
79
|
+
stub = stub_request_for_token('regular')
|
80
|
+
env = {
|
81
|
+
"HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
|
82
|
+
}
|
83
|
+
post '/webhooks', request_body, env
|
84
|
+
|
85
|
+
expect(last_response.status).to eq(400)
|
86
|
+
expect(stub).not_to have_been_requested
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
context 'when initialized with flipper instance and flipper instance in env' do
|
91
|
+
let(:app) { Flipper::Cloud.app(flipper) }
|
92
|
+
let(:signature) {
|
93
|
+
Flipper::Cloud::MessageVerifier.new(secret: env_flipper.sync_secret).generate(request_body, timestamp)
|
94
|
+
}
|
95
|
+
|
96
|
+
it 'uses env instance to sync' do
|
97
|
+
stub = stub_request_for_token('env')
|
98
|
+
env = {
|
99
|
+
"HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
|
100
|
+
'flipper' => env_flipper,
|
101
|
+
}
|
102
|
+
post '/webhooks', request_body, env
|
103
|
+
|
104
|
+
expect(last_response.status).to eq(200)
|
105
|
+
expect(stub).to have_been_requested
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
context 'when initialized without flipper instance but flipper instance in env' do
|
110
|
+
let(:app) { Flipper::Cloud.app }
|
111
|
+
let(:signature) {
|
112
|
+
Flipper::Cloud::MessageVerifier.new(secret: env_flipper.sync_secret).generate(request_body, timestamp)
|
113
|
+
}
|
114
|
+
|
115
|
+
it 'uses env instance to sync' do
|
116
|
+
stub = stub_request_for_token('env')
|
117
|
+
env = {
|
118
|
+
"HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
|
119
|
+
'flipper' => env_flipper,
|
120
|
+
}
|
121
|
+
post '/webhooks', request_body, env
|
122
|
+
|
123
|
+
expect(last_response.status).to eq(200)
|
124
|
+
expect(stub).to have_been_requested
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
context 'when initialized with env_key' do
|
129
|
+
let(:app) { Flipper::Cloud.app(flipper, env_key: 'flipper_cloud') }
|
130
|
+
let(:signature) {
|
131
|
+
Flipper::Cloud::MessageVerifier.new(secret: env_flipper.sync_secret).generate(request_body, timestamp)
|
132
|
+
}
|
133
|
+
|
134
|
+
it 'uses provided env key instead of default' do
|
135
|
+
stub = stub_request_for_token('env')
|
136
|
+
env = {
|
137
|
+
"HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
|
138
|
+
'flipper' => flipper,
|
139
|
+
'flipper_cloud' => env_flipper,
|
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
|
+
context 'when initializing lazily with a block' do
|
149
|
+
let(:app) { Flipper::Cloud.app(-> { flipper }) }
|
150
|
+
|
151
|
+
it 'works' do
|
152
|
+
stub = stub_request_for_token('regular')
|
153
|
+
env = {
|
154
|
+
"HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
|
155
|
+
}
|
156
|
+
post '/webhooks', request_body, env
|
157
|
+
|
158
|
+
expect(last_response.status).to eq(200)
|
159
|
+
expect(stub).to have_been_requested
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
describe 'Request method unsupported' do
|
164
|
+
it 'skips middleware' do
|
165
|
+
get '/webhooks'
|
166
|
+
expect(last_response.status).to eq(404)
|
167
|
+
expect(last_response.content_type).to eq("application/json")
|
168
|
+
expect(last_response.body).to eq("{}")
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
describe 'Inspecting the built Rack app' do
|
173
|
+
it 'returns a String' do
|
174
|
+
expect(Flipper::Cloud.app(flipper).inspect).to be_a(String)
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
private
|
179
|
+
|
180
|
+
def stub_request_for_token(token)
|
181
|
+
stub_request(:get, "https://www.flippercloud.io/adapter/features").
|
182
|
+
with({
|
183
|
+
headers: {
|
184
|
+
'Flipper-Cloud-Token' => token,
|
185
|
+
},
|
186
|
+
}).to_return(status: 200, body: response_body, headers: {})
|
187
|
+
end
|
188
|
+
end
|
data/spec/flipper/cloud_spec.rb
CHANGED
@@ -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['
|
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.
|
4
|
+
version: 0.20.0
|
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-
|
11
|
+
date: 2020-12-20 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
|
+
version: 0.20.0
|
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.
|
26
|
+
version: 0.20.0
|
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:
|
@@ -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
|