flipper-cloud 0.19.0 → 0.20.0
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/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-
|
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
|