flipper 0.16.0 → 1.4.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.
Files changed (285) hide show
  1. checksums.yaml +5 -5
  2. data/.codeclimate.yml +1 -0
  3. data/.github/FUNDING.yml +1 -0
  4. data/.github/dependabot.yml +6 -0
  5. data/.github/workflows/ci.yml +110 -0
  6. data/.github/workflows/examples.yml +105 -0
  7. data/.github/workflows/release.yml +54 -0
  8. data/.rspec +1 -0
  9. data/CLAUDE.md +87 -0
  10. data/Changelog.md +2 -215
  11. data/Dockerfile +1 -1
  12. data/Gemfile +28 -20
  13. data/README.md +72 -62
  14. data/Rakefile +13 -3
  15. data/benchmark/enabled_ips.rb +10 -0
  16. data/benchmark/enabled_multiple_actors_ips.rb +20 -0
  17. data/benchmark/enabled_profile.rb +20 -0
  18. data/benchmark/instrumentation_ips.rb +21 -0
  19. data/benchmark/typecast_ips.rb +27 -0
  20. data/docker-compose.yml +37 -34
  21. data/docs/DockerCompose.md +0 -1
  22. data/docs/README.md +1 -0
  23. data/docs/images/banner.jpg +0 -0
  24. data/docs/images/flipper_cloud.png +0 -0
  25. data/examples/api/basic.ru +18 -0
  26. data/examples/api/custom_memoized.ru +36 -0
  27. data/examples/api/memoized.ru +42 -0
  28. data/examples/basic.rb +1 -12
  29. data/examples/cloud/app.ru +12 -0
  30. data/examples/cloud/backoff_policy.rb +13 -0
  31. data/examples/cloud/basic.rb +22 -0
  32. data/examples/cloud/cloud_setup.rb +20 -0
  33. data/examples/cloud/forked.rb +36 -0
  34. data/examples/cloud/import.rb +17 -0
  35. data/examples/cloud/poll_interval/README.md +111 -0
  36. data/examples/cloud/poll_interval/client.rb +108 -0
  37. data/examples/cloud/poll_interval/server.rb +98 -0
  38. data/examples/cloud/threaded.rb +33 -0
  39. data/examples/configuring_default.rb +2 -5
  40. data/examples/dsl.rb +10 -35
  41. data/examples/enabled_for_actor.rb +10 -15
  42. data/examples/expressions.rb +237 -0
  43. data/examples/group.rb +3 -6
  44. data/examples/group_dynamic_lookup.rb +5 -19
  45. data/examples/group_with_members.rb +4 -14
  46. data/examples/importing.rb +1 -1
  47. data/examples/individual_actor.rb +2 -5
  48. data/examples/instrumentation.rb +2 -2
  49. data/examples/instrumentation_last_accessed_at.rb +38 -0
  50. data/examples/memoizing.rb +35 -0
  51. data/examples/mirroring.rb +59 -0
  52. data/examples/percentage_of_actors.rb +6 -16
  53. data/examples/percentage_of_actors_enabled_check.rb +7 -10
  54. data/examples/percentage_of_actors_group.rb +5 -18
  55. data/examples/percentage_of_time.rb +3 -6
  56. data/examples/strict.rb +18 -0
  57. data/exe/flipper +5 -0
  58. data/flipper-cloud.gemspec +19 -0
  59. data/flipper.gemspec +10 -7
  60. data/lib/flipper/actor.rb +10 -3
  61. data/lib/flipper/adapter.rb +50 -8
  62. data/lib/flipper/adapter_builder.rb +44 -0
  63. data/lib/flipper/adapters/actor_limit.rb +54 -0
  64. data/lib/flipper/adapters/cache_base.rb +161 -0
  65. data/lib/flipper/adapters/dual_write.rb +63 -0
  66. data/lib/flipper/adapters/failover.rb +85 -0
  67. data/lib/flipper/adapters/failsafe.rb +72 -0
  68. data/lib/flipper/adapters/http/client.rb +64 -7
  69. data/lib/flipper/adapters/http/error.rb +19 -1
  70. data/lib/flipper/adapters/http.rb +97 -43
  71. data/lib/flipper/adapters/instrumented.rb +47 -26
  72. data/lib/flipper/adapters/memoizable.rb +44 -40
  73. data/lib/flipper/adapters/memory.rb +75 -111
  74. data/lib/flipper/adapters/operation_logger.rb +22 -78
  75. data/lib/flipper/adapters/poll/poller.rb +2 -0
  76. data/lib/flipper/adapters/poll.rb +52 -0
  77. data/lib/flipper/adapters/pstore.rb +27 -17
  78. data/lib/flipper/adapters/read_only.rb +8 -41
  79. data/lib/flipper/adapters/strict.rb +45 -0
  80. data/lib/flipper/adapters/sync/feature_synchronizer.rb +14 -1
  81. data/lib/flipper/adapters/sync/interval_synchronizer.rb +2 -7
  82. data/lib/flipper/adapters/sync/synchronizer.rb +13 -6
  83. data/lib/flipper/adapters/sync.rb +23 -29
  84. data/lib/flipper/adapters/wrapper.rb +54 -0
  85. data/lib/flipper/cli.rb +314 -0
  86. data/lib/flipper/cloud/configuration.rb +271 -0
  87. data/lib/flipper/cloud/dsl.rb +27 -0
  88. data/lib/flipper/cloud/message_verifier.rb +95 -0
  89. data/lib/flipper/cloud/middleware.rb +63 -0
  90. data/lib/flipper/cloud/migrate.rb +71 -0
  91. data/lib/flipper/cloud/routes.rb +14 -0
  92. data/lib/flipper/cloud/telemetry/backoff_policy.rb +96 -0
  93. data/lib/flipper/cloud/telemetry/instrumenter.rb +22 -0
  94. data/lib/flipper/cloud/telemetry/metric.rb +39 -0
  95. data/lib/flipper/cloud/telemetry/metric_storage.rb +30 -0
  96. data/lib/flipper/cloud/telemetry/submitter.rb +100 -0
  97. data/lib/flipper/cloud/telemetry.rb +191 -0
  98. data/lib/flipper/cloud.rb +54 -0
  99. data/lib/flipper/configuration.rb +54 -7
  100. data/lib/flipper/dsl.rb +58 -47
  101. data/lib/flipper/engine.rb +102 -0
  102. data/lib/flipper/errors.rb +3 -21
  103. data/lib/flipper/export.rb +24 -0
  104. data/lib/flipper/exporter.rb +17 -0
  105. data/lib/flipper/exporters/json/export.rb +32 -0
  106. data/lib/flipper/exporters/json/v1.rb +33 -0
  107. data/lib/flipper/expression/builder.rb +73 -0
  108. data/lib/flipper/expression/constant.rb +25 -0
  109. data/lib/flipper/expression.rb +71 -0
  110. data/lib/flipper/expressions/all.rb +9 -0
  111. data/lib/flipper/expressions/any.rb +9 -0
  112. data/lib/flipper/expressions/boolean.rb +9 -0
  113. data/lib/flipper/expressions/comparable.rb +13 -0
  114. data/lib/flipper/expressions/equal.rb +9 -0
  115. data/lib/flipper/expressions/feature_enabled.rb +34 -0
  116. data/lib/flipper/expressions/greater_than.rb +9 -0
  117. data/lib/flipper/expressions/greater_than_or_equal_to.rb +9 -0
  118. data/lib/flipper/expressions/less_than.rb +9 -0
  119. data/lib/flipper/expressions/less_than_or_equal_to.rb +9 -0
  120. data/lib/flipper/expressions/not_equal.rb +9 -0
  121. data/lib/flipper/expressions/now.rb +9 -0
  122. data/lib/flipper/expressions/number.rb +9 -0
  123. data/lib/flipper/expressions/percentage.rb +9 -0
  124. data/lib/flipper/expressions/percentage_of_actors.rb +12 -0
  125. data/lib/flipper/expressions/property.rb +9 -0
  126. data/lib/flipper/expressions/random.rb +9 -0
  127. data/lib/flipper/expressions/string.rb +9 -0
  128. data/lib/flipper/expressions/time.rb +16 -0
  129. data/lib/flipper/feature.rb +95 -28
  130. data/lib/flipper/feature_check_context.rb +10 -6
  131. data/lib/flipper/gate.rb +13 -11
  132. data/lib/flipper/gate_values.rb +5 -18
  133. data/lib/flipper/gates/actor.rb +10 -17
  134. data/lib/flipper/gates/boolean.rb +1 -1
  135. data/lib/flipper/gates/expression.rb +75 -0
  136. data/lib/flipper/gates/group.rb +5 -7
  137. data/lib/flipper/gates/percentage_of_actors.rb +10 -13
  138. data/lib/flipper/gates/percentage_of_time.rb +1 -2
  139. data/lib/flipper/identifier.rb +17 -0
  140. data/lib/flipper/instrumentation/log_subscriber.rb +35 -8
  141. data/lib/flipper/instrumentation/statsd.rb +4 -2
  142. data/lib/flipper/instrumentation/statsd_subscriber.rb +2 -4
  143. data/lib/flipper/instrumentation/subscriber.rb +8 -5
  144. data/lib/flipper/instrumenters/memory.rb +6 -2
  145. data/lib/flipper/metadata.rb +8 -1
  146. data/lib/flipper/middleware/memoizer.rb +46 -27
  147. data/lib/flipper/middleware/setup_env.rb +13 -3
  148. data/lib/flipper/model/active_record.rb +23 -0
  149. data/lib/flipper/poller.rb +157 -0
  150. data/lib/flipper/serializers/gzip.rb +22 -0
  151. data/lib/flipper/serializers/json.rb +17 -0
  152. data/lib/flipper/spec/shared_adapter_specs.rb +122 -56
  153. data/lib/flipper/test/shared_adapter_test.rb +120 -52
  154. data/lib/flipper/test_help.rb +43 -0
  155. data/lib/flipper/typecast.rb +59 -18
  156. data/lib/flipper/types/actor.rb +19 -13
  157. data/lib/flipper/types/group.rb +12 -5
  158. data/lib/flipper/types/percentage.rb +1 -1
  159. data/lib/flipper/version.rb +11 -1
  160. data/lib/flipper.rb +71 -12
  161. data/lib/generators/flipper/setup_generator.rb +68 -0
  162. data/lib/generators/flipper/templates/initializer.rb +45 -0
  163. data/lib/generators/flipper/templates/update/migrations/01_create_flipper_tables.rb.erb +22 -0
  164. data/lib/generators/flipper/templates/update/migrations/02_change_flipper_gates_value_to_text.rb.erb +18 -0
  165. data/lib/generators/flipper/update_generator.rb +35 -0
  166. data/package-lock.json +41 -0
  167. data/package.json +10 -0
  168. data/spec/fixtures/environment.rb +1 -0
  169. data/spec/fixtures/flipper_pstore_1679087600.json +46 -0
  170. data/spec/flipper/actor_spec.rb +10 -2
  171. data/spec/flipper/adapter_builder_spec.rb +72 -0
  172. data/spec/flipper/adapter_spec.rb +52 -6
  173. data/spec/flipper/adapters/actor_limit_spec.rb +75 -0
  174. data/spec/flipper/adapters/dual_write_spec.rb +82 -0
  175. data/spec/flipper/adapters/failover_spec.rb +141 -0
  176. data/spec/flipper/adapters/failsafe_spec.rb +58 -0
  177. data/spec/flipper/adapters/http/client_spec.rb +61 -0
  178. data/spec/flipper/adapters/http_spec.rb +402 -65
  179. data/spec/flipper/adapters/instrumented_spec.rb +31 -13
  180. data/spec/flipper/adapters/memoizable_spec.rb +51 -33
  181. data/spec/flipper/adapters/memory_spec.rb +33 -5
  182. data/spec/flipper/adapters/operation_logger_spec.rb +38 -12
  183. data/spec/flipper/adapters/poll_spec.rb +41 -0
  184. data/spec/flipper/adapters/pstore_spec.rb +0 -2
  185. data/spec/flipper/adapters/read_only_spec.rb +32 -18
  186. data/spec/flipper/adapters/strict_spec.rb +64 -0
  187. data/spec/flipper/adapters/sync/feature_synchronizer_spec.rb +39 -1
  188. data/spec/flipper/adapters/sync/interval_synchronizer_spec.rb +4 -5
  189. data/spec/flipper/adapters/sync/synchronizer_spec.rb +87 -1
  190. data/spec/flipper/adapters/sync_spec.rb +17 -6
  191. data/spec/flipper/cli_spec.rb +217 -0
  192. data/spec/flipper/cloud/configuration_spec.rb +257 -0
  193. data/spec/flipper/cloud/dsl_spec.rb +90 -0
  194. data/spec/flipper/cloud/message_verifier_spec.rb +104 -0
  195. data/spec/flipper/cloud/middleware_spec.rb +307 -0
  196. data/spec/flipper/cloud/migrate_spec.rb +160 -0
  197. data/spec/flipper/cloud/telemetry/backoff_policy_spec.rb +107 -0
  198. data/spec/flipper/cloud/telemetry/metric_spec.rb +87 -0
  199. data/spec/flipper/cloud/telemetry/metric_storage_spec.rb +58 -0
  200. data/spec/flipper/cloud/telemetry/submitter_spec.rb +145 -0
  201. data/spec/flipper/cloud/telemetry_spec.rb +208 -0
  202. data/spec/flipper/cloud_spec.rb +186 -0
  203. data/spec/flipper/configuration_spec.rb +37 -3
  204. data/spec/flipper/dsl_spec.rb +67 -80
  205. data/spec/flipper/engine_spec.rb +374 -0
  206. data/spec/flipper/export_spec.rb +13 -0
  207. data/spec/flipper/exporter_spec.rb +16 -0
  208. data/spec/flipper/exporters/json/export_spec.rb +60 -0
  209. data/spec/flipper/exporters/json/v1_spec.rb +33 -0
  210. data/spec/flipper/expression/builder_spec.rb +248 -0
  211. data/spec/flipper/expression_spec.rb +188 -0
  212. data/spec/flipper/expressions/all_spec.rb +15 -0
  213. data/spec/flipper/expressions/any_spec.rb +15 -0
  214. data/spec/flipper/expressions/boolean_spec.rb +15 -0
  215. data/spec/flipper/expressions/equal_spec.rb +24 -0
  216. data/spec/flipper/expressions/greater_than_or_equal_to_spec.rb +28 -0
  217. data/spec/flipper/expressions/greater_than_spec.rb +28 -0
  218. data/spec/flipper/expressions/less_than_or_equal_to_spec.rb +28 -0
  219. data/spec/flipper/expressions/less_than_spec.rb +32 -0
  220. data/spec/flipper/expressions/not_equal_spec.rb +15 -0
  221. data/spec/flipper/expressions/now_spec.rb +11 -0
  222. data/spec/flipper/expressions/number_spec.rb +21 -0
  223. data/spec/flipper/expressions/percentage_of_actors_spec.rb +20 -0
  224. data/spec/flipper/expressions/percentage_spec.rb +15 -0
  225. data/spec/flipper/expressions/property_spec.rb +13 -0
  226. data/spec/flipper/expressions/random_spec.rb +9 -0
  227. data/spec/flipper/expressions/string_spec.rb +11 -0
  228. data/spec/flipper/expressions/time_spec.rb +29 -0
  229. data/spec/flipper/feature_check_context_spec.rb +18 -20
  230. data/spec/flipper/feature_spec.rb +461 -48
  231. data/spec/flipper/gate_spec.rb +0 -2
  232. data/spec/flipper/gate_values_spec.rb +2 -34
  233. data/spec/flipper/gates/actor_spec.rb +0 -2
  234. data/spec/flipper/gates/boolean_spec.rb +1 -3
  235. data/spec/flipper/gates/expression_spec.rb +190 -0
  236. data/spec/flipper/gates/group_spec.rb +2 -5
  237. data/spec/flipper/gates/percentage_of_actors_spec.rb +61 -7
  238. data/spec/flipper/gates/percentage_of_time_spec.rb +2 -4
  239. data/spec/flipper/identifier_spec.rb +12 -0
  240. data/spec/flipper/instrumentation/log_subscriber_spec.rb +24 -7
  241. data/spec/flipper/instrumentation/statsd_subscriber_spec.rb +26 -3
  242. data/spec/flipper/instrumenters/memory_spec.rb +18 -1
  243. data/spec/flipper/instrumenters/noop_spec.rb +14 -8
  244. data/spec/flipper/middleware/memoizer_spec.rb +199 -62
  245. data/spec/flipper/middleware/setup_env_spec.rb +23 -5
  246. data/spec/flipper/model/active_record_spec.rb +72 -0
  247. data/spec/flipper/poller_spec.rb +390 -0
  248. data/spec/flipper/registry_spec.rb +0 -1
  249. data/spec/flipper/serializers/gzip_spec.rb +13 -0
  250. data/spec/flipper/serializers/json_spec.rb +13 -0
  251. data/spec/flipper/typecast_spec.rb +121 -7
  252. data/spec/flipper/types/actor_spec.rb +63 -47
  253. data/spec/flipper/types/boolean_spec.rb +0 -1
  254. data/spec/flipper/types/group_spec.rb +24 -3
  255. data/spec/flipper/types/percentage_of_actors_spec.rb +0 -1
  256. data/spec/flipper/types/percentage_of_time_spec.rb +0 -1
  257. data/spec/flipper/types/percentage_spec.rb +0 -1
  258. data/spec/{integration_spec.rb → flipper_integration_spec.rb} +301 -59
  259. data/spec/flipper_spec.rb +123 -29
  260. data/spec/{helper.rb → spec_helper.rb} +23 -21
  261. data/spec/support/actor_names.yml +1 -0
  262. data/spec/support/descriptions.yml +1 -0
  263. data/spec/support/fail_on_output.rb +8 -0
  264. data/spec/support/fake_backoff_policy.rb +15 -0
  265. data/spec/support/skippable.rb +18 -0
  266. data/spec/support/spec_helpers.rb +53 -6
  267. data/test/adapters/actor_limit_test.rb +20 -0
  268. data/test/test_helper.rb +2 -1
  269. data/test_rails/generators/flipper/setup_generator_test.rb +69 -0
  270. data/test_rails/generators/flipper/update_generator_test.rb +96 -0
  271. data/test_rails/helper.rb +31 -0
  272. data/test_rails/system/test_help_test.rb +52 -0
  273. metadata +200 -82
  274. data/.rubocop.yml +0 -54
  275. data/.rubocop_todo.yml +0 -199
  276. data/docs/Adapters.md +0 -124
  277. data/docs/Caveats.md +0 -4
  278. data/docs/Gates.md +0 -167
  279. data/docs/Instrumentation.md +0 -27
  280. data/docs/Optimization.md +0 -114
  281. data/docs/api/README.md +0 -849
  282. data/docs/http/README.md +0 -35
  283. data/docs/read-only/README.md +0 -21
  284. data/examples/example_setup.rb +0 -8
  285. data/test/helper.rb +0 -11
@@ -0,0 +1,90 @@
1
+ require 'flipper/cloud/configuration'
2
+ require 'flipper/cloud/dsl'
3
+ require 'flipper/adapters/operation_logger'
4
+ require 'flipper/adapters/instrumented'
5
+
6
+ RSpec.describe Flipper::Cloud::DSL do
7
+ it 'delegates everything to flipper instance' do
8
+ # stub the initial sync of http to local
9
+ stub_request(:get, /flippercloud\.io/).to_return(status: 200, body: "{}")
10
+
11
+ cloud_configuration = Flipper::Cloud::Configuration.new({
12
+ token: "asdf",
13
+ sync_secret: "tasty",
14
+ })
15
+ dsl = described_class.new(cloud_configuration)
16
+ expect(dsl.features).to eq(Set.new)
17
+ expect(dsl.enabled?(:foo)).to be(false)
18
+ end
19
+
20
+ it 'delegates sync to cloud configuration' do
21
+ stub = stub_request(:get, "https://www.flippercloud.io/adapter/features?exclude_gate_names=true").
22
+ with({
23
+ headers: {
24
+ 'flipper-cloud-token'=>'asdf',
25
+ },
26
+ }).to_return(status: 200, body: '{"features": {}}', headers: {})
27
+ cloud_configuration = Flipper::Cloud::Configuration.new({
28
+ token: "asdf",
29
+ sync_secret: "tasty",
30
+ })
31
+ dsl = described_class.new(cloud_configuration)
32
+ dsl.sync
33
+ expect(stub).to have_been_requested.at_least_once
34
+ end
35
+
36
+ it 'delegates sync_secret to cloud configuration' do
37
+ # stub the initial sync of http to local
38
+ stub_request(:get, /flippercloud\.io/).to_return(status: 200, body: "{}")
39
+
40
+ cloud_configuration = Flipper::Cloud::Configuration.new({
41
+ token: "asdf",
42
+ sync_secret: "tasty",
43
+ })
44
+ dsl = described_class.new(cloud_configuration)
45
+ expect(dsl.sync_secret).to eq("tasty")
46
+ end
47
+
48
+ context "when sync_method is webhook" do
49
+ let(:local_adapter) do
50
+ Flipper::Adapters::OperationLogger.new Flipper::Adapters::Memory.new
51
+ end
52
+
53
+ let(:cloud_configuration) do
54
+ Flipper::Cloud::Configuration.new({
55
+ token: "asdf",
56
+ sync_secret: "tasty",
57
+ local_adapter: local_adapter
58
+ })
59
+ end
60
+
61
+ subject do
62
+ # stub the initial sync of http to local
63
+ stub_request(:get, /flippercloud\.io/).to_return(status: 200, body: "{}")
64
+ described_class.new(cloud_configuration)
65
+ end
66
+
67
+ it "sends reads to local adapter" do
68
+ subject.features
69
+ subject.enabled?(:foo)
70
+ expect(local_adapter.count(:features)).to be(2)
71
+ expect(local_adapter.count(:get)).to be(1)
72
+ end
73
+
74
+ it "sends writes to cloud and local" do
75
+ add_stub = stub_request(:post, "https://www.flippercloud.io/adapter/features").
76
+ with({headers: {'flipper-cloud-token'=>'asdf'}}).
77
+ to_return(status: 200, body: '{}')
78
+ enable_stub = stub_request(:post, "https://www.flippercloud.io/adapter/features/foo/boolean").
79
+ with(headers: {'flipper-cloud-token'=>'asdf'}).
80
+ to_return(status: 200, body: '{}')
81
+
82
+ subject.enable(:foo)
83
+
84
+ expect(local_adapter.count(:add)).to be(1)
85
+ expect(local_adapter.count(:enable)).to be(1)
86
+ expect(add_stub).to have_been_requested
87
+ expect(enable_stub).to have_been_requested
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,104 @@
1
+ require 'flipper/cloud/message_verifier'
2
+
3
+ RSpec.describe Flipper::Cloud::MessageVerifier do
4
+ let(:payload) { "some payload" }
5
+ let(:secret) { "secret" }
6
+ let(:timestamp) { Time.now }
7
+
8
+ describe "#generate" do
9
+ it "generates signature that can be verified" do
10
+ message_verifier = Flipper::Cloud::MessageVerifier.new(secret: secret)
11
+ signature = message_verifier.generate(payload, timestamp)
12
+ header = generate_header(timestamp: timestamp, signature: signature)
13
+ expect(message_verifier.verify(payload, header)).to be(true)
14
+ end
15
+ end
16
+
17
+ describe "#header" do
18
+ it "generates a header in valid format" do
19
+ version = "v1"
20
+ message_verifier = Flipper::Cloud::MessageVerifier.new(secret: secret, version: version)
21
+ signature = message_verifier.generate(payload, timestamp)
22
+ header = message_verifier.header(signature, timestamp)
23
+ expect(header).to eq("t=#{timestamp.to_i},#{version}=#{signature}")
24
+ end
25
+ end
26
+
27
+ describe ".header" do
28
+ it "generates a header in valid format" do
29
+ version = "v1"
30
+ message_verifier = Flipper::Cloud::MessageVerifier.new(secret: secret, version: version)
31
+ signature = message_verifier.generate(payload, timestamp)
32
+
33
+ header = Flipper::Cloud::MessageVerifier.header(signature, timestamp, version)
34
+ expect(header).to eq("t=#{timestamp.to_i},#{version}=#{signature}")
35
+ end
36
+ end
37
+
38
+ describe "#verify" do
39
+ it "raises a InvalidSignature when the header does not have the expected format" do
40
+ header = "i'm not even a real signature header"
41
+ expect {
42
+ message_verifier = Flipper::Cloud::MessageVerifier.new(secret: "secret")
43
+ message_verifier.verify(payload, header)
44
+ }.to raise_error(Flipper::Cloud::MessageVerifier::InvalidSignature, "Unable to extract timestamp and signatures from header")
45
+ end
46
+
47
+ it "raises a InvalidSignature when there are no signatures with the expected version" do
48
+ header = generate_header(version: "v0")
49
+ expect {
50
+ message_verifier = Flipper::Cloud::MessageVerifier.new(secret: "secret")
51
+ message_verifier.verify(payload, header)
52
+ }.to raise_error(Flipper::Cloud::MessageVerifier::InvalidSignature, /No signatures found with expected version/)
53
+ end
54
+
55
+ it "raises a InvalidSignature when there are no valid signatures for the payload" do
56
+ header = generate_header(signature: "bad_signature")
57
+ expect {
58
+ message_verifier = Flipper::Cloud::MessageVerifier.new(secret: "secret")
59
+ message_verifier.verify(payload, header)
60
+ }.to raise_error(Flipper::Cloud::MessageVerifier::InvalidSignature, "No signatures found matching the expected signature for payload")
61
+ end
62
+
63
+ it "raises a InvalidSignature when the timestamp is not within the tolerance" do
64
+ header = generate_header(timestamp: Time.now - 15)
65
+ expect {
66
+ message_verifier = Flipper::Cloud::MessageVerifier.new(secret: secret)
67
+ message_verifier.verify(payload, header, tolerance: 10)
68
+ }.to raise_error(Flipper::Cloud::MessageVerifier::InvalidSignature, /Timestamp outside the tolerance zone/)
69
+ end
70
+
71
+ it "returns true when the header contains a valid signature and the timestamp is within the tolerance" do
72
+ header = generate_header
73
+ message_verifier = Flipper::Cloud::MessageVerifier.new(secret: "secret")
74
+ expect(message_verifier.verify(payload, header, tolerance: 10)).to be(true)
75
+ end
76
+
77
+ it "returns true when the header contains at least one valid signature" do
78
+ header = generate_header + ",v1=bad_signature"
79
+ message_verifier = Flipper::Cloud::MessageVerifier.new(secret: secret)
80
+ expect(message_verifier.verify(payload, header, tolerance: 10)).to be(true)
81
+ end
82
+
83
+ it "returns true when the header contains a valid signature and the timestamp is off but no tolerance is provided" do
84
+ header = generate_header(timestamp: Time.at(12_345))
85
+ message_verifier = Flipper::Cloud::MessageVerifier.new(secret: secret)
86
+ expect(message_verifier.verify(payload, header)).to be(true)
87
+ end
88
+ end
89
+
90
+ private
91
+
92
+ def generate_header(options = {})
93
+ options[:secret] ||= secret
94
+ options[:version] ||= "v1"
95
+
96
+ message_verifier = Flipper::Cloud::MessageVerifier.new(secret: options[:secret], version: options[:version])
97
+
98
+ options[:timestamp] ||= timestamp
99
+ options[:payload] ||= payload
100
+ options[:signature] ||= message_verifier.generate(options[:payload], options[:timestamp])
101
+
102
+ Flipper::Cloud::MessageVerifier.header(options[:signature], options[:timestamp], options[:version])
103
+ end
104
+ end
@@ -0,0 +1,307 @@
1
+ require 'securerandom'
2
+ require 'flipper/cloud'
3
+ require 'flipper/cloud/middleware'
4
+ require 'flipper/adapters/operation_logger'
5
+
6
+ RSpec.describe Flipper::Cloud::Middleware do
7
+ let(:flipper) {
8
+ Flipper::Cloud.new(token: "regular") do |config|
9
+ config.local_adapter = Flipper::Adapters::OperationLogger.new(Flipper::Adapters::Memory.new)
10
+ config.sync_secret = "regular_tasty"
11
+ end
12
+ }
13
+
14
+ let(:env_flipper) {
15
+ Flipper::Cloud.new(token: "env") do |config|
16
+ config.local_adapter = Flipper::Adapters::OperationLogger.new(Flipper::Adapters::Memory.new)
17
+ config.sync_secret = "env_tasty"
18
+ end
19
+ }
20
+
21
+ let(:app) { Flipper::Cloud.app(flipper) }
22
+ let(:response_body) { JSON.generate({features: {}}) }
23
+ let(:request_body) {
24
+ JSON.generate({
25
+ "environment_id" => 1,
26
+ "webhook_id" => 1,
27
+ "delivery_id" => SecureRandom.uuid,
28
+ "action" => "sync",
29
+ })
30
+ }
31
+ let(:timestamp) { Time.now }
32
+ let(:signature) {
33
+ Flipper::Cloud::MessageVerifier.new(secret: flipper.sync_secret).generate(request_body, timestamp)
34
+ }
35
+ let(:signature_header_value) {
36
+ Flipper::Cloud::MessageVerifier.new(secret: "").header(signature, timestamp)
37
+ }
38
+
39
+ context 'when initializing middleware with flipper instance' do
40
+ let(:app) { Flipper::Cloud.app(flipper) }
41
+
42
+ it 'uses instance to sync' do
43
+ Flipper.register(:admins) { |*args| false }
44
+ Flipper.register(:staff) { |*args| false }
45
+ Flipper.register(:basic) { |*args| false }
46
+ Flipper.register(:plus) { |*args| false }
47
+ Flipper.register(:premium) { |*args| false }
48
+
49
+ stub = stub_request_for_token('regular')
50
+ env = {
51
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
52
+ }
53
+ post '/', request_body, env
54
+
55
+ expect(last_response.status).to eq(200)
56
+ expect(JSON.parse(last_response.body)).to eq({
57
+ "groups" => [
58
+ {"name" => "admins"},
59
+ {"name" => "staff"},
60
+ {"name" => "basic"},
61
+ {"name" => "plus"},
62
+ {"name" => "premium"},
63
+ ],
64
+ })
65
+ expect(stub).to have_been_made.at_least_once
66
+ end
67
+ end
68
+
69
+ context 'when signature is invalid' do
70
+ let(:app) { Flipper::Cloud.app(flipper) }
71
+ let(:signature) {
72
+ Flipper::Cloud::MessageVerifier.new(secret: "nope").generate(request_body, timestamp)
73
+ }
74
+
75
+ it 'does not perform webhook sync' do
76
+ webhook_regular_stub = stub_request_for_token('regular', from_webhook: true)
77
+ poll_regular_stub = stub_request_for_token('regular', from_webhook: false)
78
+ env = {
79
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
80
+ }
81
+ post '/', request_body, env
82
+
83
+ expect(last_response.status).to eq(400)
84
+ expect(poll_regular_stub).to have_been_requested.at_least_once
85
+ expect(webhook_regular_stub).not_to have_been_requested
86
+ end
87
+ end
88
+
89
+ context "when flipper cloud responds with 402" do
90
+ let(:app) { Flipper::Cloud.app(flipper) }
91
+
92
+ it "results in 402" do
93
+ Flipper.register(:admins) { |*args| false }
94
+ Flipper.register(:staff) { |*args| false }
95
+ Flipper.register(:basic) { |*args| false }
96
+ Flipper.register(:plus) { |*args| false }
97
+ Flipper.register(:premium) { |*args| false }
98
+
99
+ stub = stub_request_for_token('regular', status: 402)
100
+ env = {
101
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
102
+ }
103
+ post '/', request_body, env
104
+
105
+ expect(last_response.status).to eq(402)
106
+ expect(last_response.headers["flipper-cloud-response-error-class"]).to eq("Flipper::Adapters::Http::Error")
107
+ expect(last_response.headers["flipper-cloud-response-error-message"]).to include("Failed with status: 402")
108
+ expect(stub).to have_been_made.at_least_once
109
+ end
110
+ end
111
+
112
+ context "when flipper cloud responds with non-402 and non-2xx code" do
113
+ let(:app) { Flipper::Cloud.app(flipper) }
114
+
115
+ it "results in 500" do
116
+ Flipper.register(:admins) { |*args| false }
117
+ Flipper.register(:staff) { |*args| false }
118
+ Flipper.register(:basic) { |*args| false }
119
+ Flipper.register(:plus) { |*args| false }
120
+ Flipper.register(:premium) { |*args| false }
121
+
122
+ stub = stub_request_for_token('regular', status: 503)
123
+ env = {
124
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
125
+ }
126
+ post '/', request_body, env
127
+
128
+ expect(last_response.status).to eq(500)
129
+ expect(last_response.headers["flipper-cloud-response-error-class"]).to eq("Flipper::Adapters::Http::Error")
130
+ expect(last_response.headers["flipper-cloud-response-error-message"]).to include("Failed with status: 503")
131
+ expect(stub).to have_been_made.at_least_once
132
+ end
133
+ end
134
+
135
+ context "when flipper cloud responds with timeout" do
136
+ let(:app) { Flipper::Cloud.app(flipper) }
137
+
138
+ it "results in 500" do
139
+ Flipper.register(:admins) { |*args| false }
140
+ Flipper.register(:staff) { |*args| false }
141
+ Flipper.register(:basic) { |*args| false }
142
+ Flipper.register(:plus) { |*args| false }
143
+ Flipper.register(:premium) { |*args| false }
144
+
145
+ stub = stub_request_for_token('regular', status: :timeout)
146
+ env = {
147
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
148
+ }
149
+ post '/', request_body, env
150
+
151
+ expect(last_response.status).to eq(500)
152
+ expect(last_response.headers["flipper-cloud-response-error-class"]).to eq("Net::OpenTimeout")
153
+ expect(last_response.headers["flipper-cloud-response-error-message"]).to eq("execution expired")
154
+ expect(stub).to have_been_made.at_least_once
155
+ end
156
+ end
157
+
158
+ context 'when initialized with flipper instance and flipper instance in env' do
159
+ let(:app) { Flipper::Cloud.app(flipper) }
160
+ let(:signature) {
161
+ Flipper::Cloud::MessageVerifier.new(secret: env_flipper.sync_secret).generate(request_body, timestamp)
162
+ }
163
+
164
+ it 'uses env instance to sync' do
165
+ regular_stub = stub_request_for_token('regular')
166
+ env_stub = stub_request_for_token('env')
167
+ env = {
168
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
169
+ 'flipper' => env_flipper,
170
+ }
171
+ post '/', request_body, env
172
+
173
+ expect(last_response.status).to eq(200)
174
+ expect(regular_stub).to have_been_made.at_least_once
175
+ expect(env_stub).to have_been_made.at_least_once
176
+ end
177
+ end
178
+
179
+ context 'when initialized without flipper instance but flipper instance in env' do
180
+ let(:app) { Flipper::Cloud.app }
181
+ let(:signature) {
182
+ Flipper::Cloud::MessageVerifier.new(secret: env_flipper.sync_secret).generate(request_body, timestamp)
183
+ }
184
+
185
+ it 'uses env instance to sync' do
186
+ stub = stub_request_for_token('env')
187
+ env = {
188
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
189
+ 'flipper' => env_flipper,
190
+ }
191
+ post '/', request_body, env
192
+
193
+ expect(last_response.status).to eq(200)
194
+ expect(stub).to have_been_made.at_least_once
195
+ end
196
+ end
197
+
198
+ context 'when initialized with env_key' do
199
+ let(:app) { Flipper::Cloud.app(flipper, env_key: 'flipper_cloud') }
200
+ let(:signature) {
201
+ Flipper::Cloud::MessageVerifier.new(secret: env_flipper.sync_secret).generate(request_body, timestamp)
202
+ }
203
+
204
+ it 'uses provided env key instead of default' do
205
+ regular_poll_stub = stub_request_for_token('regular')
206
+ env_poll_stub = stub_request_for_token('env')
207
+ env_webhook_stub = stub_request_for_token('env', from_webhook: true)
208
+ env = {
209
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
210
+ 'flipper' => flipper,
211
+ 'flipper_cloud' => env_flipper,
212
+ }
213
+ post '/', request_body, env
214
+
215
+ expect(last_response.status).to eq(200)
216
+ expect(regular_poll_stub).to have_been_made.at_least_once
217
+ expect(env_poll_stub).to have_been_made.at_least_once
218
+ expect(env_webhook_stub).not_to have_been_requested
219
+ end
220
+ end
221
+
222
+ context 'when initializing lazily with a block' do
223
+ let(:app) { Flipper::Cloud.app(-> { flipper }) }
224
+
225
+ it 'works' do
226
+ stub = stub_request_for_token('regular')
227
+ env = {
228
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
229
+ }
230
+ post '/', request_body, env
231
+
232
+ expect(last_response.status).to eq(200)
233
+ expect(stub).to have_been_made.at_least_once
234
+ end
235
+ end
236
+
237
+ context 'when using older /webhooks path' do
238
+ let(:app) { Flipper::Cloud.app(flipper) }
239
+
240
+ it 'uses instance to sync' do
241
+ Flipper.register(:admins) { |*args| false }
242
+ Flipper.register(:staff) { |*args| false }
243
+ Flipper.register(:basic) { |*args| false }
244
+ Flipper.register(:plus) { |*args| false }
245
+ Flipper.register(:premium) { |*args| false }
246
+
247
+ stub = stub_request_for_token('regular')
248
+ env = {
249
+ "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
250
+ }
251
+ post '/webhooks', request_body, env
252
+
253
+ expect(last_response.status).to eq(200)
254
+ expect(JSON.parse(last_response.body)).to eq({
255
+ "groups" => [
256
+ {"name" => "admins"},
257
+ {"name" => "staff"},
258
+ {"name" => "basic"},
259
+ {"name" => "plus"},
260
+ {"name" => "premium"},
261
+ ],
262
+ })
263
+ expect(stub).to have_been_made.at_least_once
264
+ end
265
+ end
266
+
267
+ describe 'Request method unsupported' do
268
+ it 'skips middleware' do
269
+ stub_request(:get, /flippercloud\.io/).to_return(status: 200, body: "{}")
270
+ get '/'
271
+ expect(last_response.status).to eq(404)
272
+ expect(last_response.content_type).to eq("application/json")
273
+ expect(last_response.body).to eq("{}")
274
+ end
275
+ end
276
+
277
+ describe 'Inspecting the built Rack app' do
278
+ it 'returns a String' do
279
+ stub_request(:get, /flippercloud\.io/).to_return(status: 200, body: "{}")
280
+ expect(Flipper::Cloud.app(flipper).inspect).to eq("Flipper::Cloud")
281
+ end
282
+ end
283
+
284
+ private
285
+
286
+ def stub_request_for_token(token, status: 200, from_webhook: false)
287
+ if from_webhook
288
+ # Match URL with both exclude_gate_names=true and _cb=integer
289
+ url_pattern = %r{https://www\.flippercloud\.io/adapter/features\?.*exclude_gate_names=true.*&_cb=\d+}
290
+ else
291
+ # Match URL with just exclude_gate_names=true
292
+ url_pattern = %r{https://www\.flippercloud\.io/adapter/features\?.*exclude_gate_names=true}
293
+ end
294
+
295
+ stub = stub_request(:get, url_pattern).
296
+ with({
297
+ headers: {
298
+ 'flipper-cloud-token' => token,
299
+ },
300
+ })
301
+ if status == :timeout
302
+ stub.to_timeout
303
+ else
304
+ stub.to_return(status: status, body: response_body)
305
+ end
306
+ end
307
+ end
@@ -0,0 +1,160 @@
1
+ require "flipper/cloud/migrate"
2
+ require "flipper/typecast"
3
+ require "webmock/rspec"
4
+
5
+ RSpec.describe Flipper::Cloud, ".migrate" do
6
+ let(:flipper) { Flipper.new(Flipper::Adapters::Memory.new) }
7
+
8
+ before do
9
+ flipper.enable :search
10
+ flipper.disable :analytics
11
+ flipper.enable_percentage_of_actors :checkout, 50
12
+ end
13
+
14
+ around do |example|
15
+ original = ENV["FLIPPER_CLOUD_URL"]
16
+ ENV["FLIPPER_CLOUD_URL"] = "http://localhost:5555"
17
+ example.run
18
+ ensure
19
+ ENV["FLIPPER_CLOUD_URL"] = original
20
+ end
21
+
22
+ def decompress_request_body
23
+ raw = WebMock::RequestRegistry.instance.requested_signatures.hash.keys.last.body
24
+ JSON.parse(Flipper::Typecast.from_gzip(raw))
25
+ end
26
+
27
+ describe ".migrate" do
28
+ it "returns a MigrateResult with code and url on success" do
29
+ stub_request(:post, "http://localhost:5555/api/migrate")
30
+ .to_return(status: 200, body: '{"url":"http://localhost:5555/cloud/setup/abc123"}', headers: {"Content-Type" => "application/json"})
31
+
32
+ result = Flipper::Cloud.migrate(flipper)
33
+
34
+ expect(result).to be_a(Flipper::Cloud::MigrateResult)
35
+ expect(result.code).to eq(200)
36
+ expect(result.url).to eq("http://localhost:5555/cloud/setup/abc123")
37
+ end
38
+
39
+ it "sends export data and metadata in the request body" do
40
+ stub = stub_request(:post, "http://localhost:5555/api/migrate")
41
+ .to_return(status: 200, body: '{"url":"http://localhost:5555/cloud/setup/abc123"}')
42
+
43
+ Flipper::Cloud.migrate(flipper, app_name: "MyApp")
44
+
45
+ expect(stub).to have_been_requested
46
+ body = decompress_request_body
47
+ expect(body["metadata"]["app_name"]).to eq("MyApp")
48
+ expect(body["export"]["version"]).to eq(1)
49
+ expect(body["export"]["features"]).to have_key("search")
50
+ end
51
+
52
+ it "sends gzip-compressed request body" do
53
+ stub = stub_request(:post, "http://localhost:5555/api/migrate")
54
+ .with(headers: {"content-encoding" => "gzip"})
55
+ .to_return(status: 200, body: '{"url":"http://localhost:5555/cloud/setup/abc"}')
56
+
57
+ Flipper::Cloud.migrate(flipper)
58
+
59
+ expect(stub).to have_been_requested
60
+ body = decompress_request_body
61
+ expect(body["export"]["features"]).to have_key("search")
62
+ end
63
+
64
+ it "handles error responses" do
65
+ stub_request(:post, "http://localhost:5555/api/migrate")
66
+ .to_return(status: 500, body: '{"error":"Internal Server Error"}')
67
+
68
+ result = Flipper::Cloud.migrate(flipper)
69
+
70
+ expect(result.code).to eq(500)
71
+ expect(result.url).to be_nil
72
+ end
73
+
74
+ it "includes error message from response body" do
75
+ stub_request(:post, "http://localhost:5555/api/migrate")
76
+ .to_return(status: 422, body: '{"error":"Invalid export format"}')
77
+
78
+ result = Flipper::Cloud.migrate(flipper)
79
+
80
+ expect(result.code).to eq(422)
81
+ expect(result.message).to eq("Invalid export format")
82
+ end
83
+
84
+ it "uses FLIPPER_CLOUD_URL environment variable" do
85
+ stub = stub_request(:post, "http://localhost:5555/api/migrate")
86
+ .to_return(status: 200, body: '{"url":"http://localhost:5555/cloud/setup/abc"}')
87
+
88
+ Flipper::Cloud.migrate(flipper)
89
+
90
+ expect(stub).to have_been_requested
91
+ end
92
+
93
+ it "sends content-type and accept headers" do
94
+ stub = stub_request(:post, "http://localhost:5555/api/migrate")
95
+ .with(headers: {
96
+ "content-type" => "application/json",
97
+ "accept" => "application/json",
98
+ })
99
+ .to_return(status: 200, body: '{"url":"http://localhost:5555/cloud/setup/abc"}')
100
+
101
+ Flipper::Cloud.migrate(flipper)
102
+
103
+ expect(stub).to have_been_requested
104
+ end
105
+ end
106
+
107
+ describe ".push" do
108
+ it "returns a MigrateResult with code on success" do
109
+ stub_request(:post, "http://localhost:5555/adapter/import")
110
+ .to_return(status: 204, body: "")
111
+
112
+ result = Flipper::Cloud.push("test-token", flipper)
113
+
114
+ expect(result).to be_a(Flipper::Cloud::MigrateResult)
115
+ expect(result.code).to eq(204)
116
+ end
117
+
118
+ it "sends the token as a header" do
119
+ stub = stub_request(:post, "http://localhost:5555/adapter/import")
120
+ .with(headers: {"flipper-cloud-token" => "test-token"})
121
+ .to_return(status: 204, body: "")
122
+
123
+ Flipper::Cloud.push("test-token", flipper)
124
+
125
+ expect(stub).to have_been_requested
126
+ end
127
+
128
+ it "sends gzip-compressed export contents as the body" do
129
+ stub = stub_request(:post, "http://localhost:5555/adapter/import")
130
+ .with(headers: {"content-encoding" => "gzip"})
131
+ .to_return(status: 204, body: "")
132
+
133
+ Flipper::Cloud.push("test-token", flipper)
134
+
135
+ expect(stub).to have_been_requested
136
+ body = decompress_request_body
137
+ expect(body["version"]).to eq(1)
138
+ expect(body["features"]).to have_key("search")
139
+ end
140
+
141
+ it "handles error responses" do
142
+ stub_request(:post, "http://localhost:5555/adapter/import")
143
+ .to_return(status: 401, body: '{"error":"Unauthorized"}')
144
+
145
+ result = Flipper::Cloud.push("bad-token", flipper)
146
+
147
+ expect(result.code).to eq(401)
148
+ end
149
+
150
+ it "includes error message from response body" do
151
+ stub_request(:post, "http://localhost:5555/adapter/import")
152
+ .to_return(status: 401, body: '{"error":"Invalid token"}')
153
+
154
+ result = Flipper::Cloud.push("bad-token", flipper)
155
+
156
+ expect(result.code).to eq(401)
157
+ expect(result.message).to eq("Invalid token")
158
+ end
159
+ end
160
+ end