stitches 4.1.0RC2 → 4.2.0.RC1

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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/.circleci/config.yml +0 -38
  3. data/.gitignore +1 -0
  4. data/README.md +36 -4
  5. data/lib/stitches/add_disabled_at_to_api_clients_generator.rb +18 -0
  6. data/lib/stitches/allowlist_middleware.rb +20 -6
  7. data/lib/stitches/api_client_access_wrapper.rb +42 -11
  8. data/lib/stitches/api_key.rb +5 -5
  9. data/lib/stitches/configuration.rb +4 -0
  10. data/lib/stitches/generator_files/db/migrate/add_disabled_at_to_api_clients.rb +9 -0
  11. data/lib/stitches/generator_files/db/migrate/create_api_clients.rb +1 -0
  12. data/lib/stitches/render_timestamps_in_iso8601_in_json.rb +2 -6
  13. data/lib/stitches/valid_mime_type.rb +1 -1
  14. data/lib/stitches/version.rb +1 -1
  15. data/lib/stitches_norailtie.rb +1 -0
  16. data/spec/api_key_middleware_spec.rb +368 -0
  17. data/spec/api_version_constraint_middleware_spec.rb +58 -0
  18. data/spec/configuration_spec.rb +1 -1
  19. data/spec/deprecation_spec.rb +1 -1
  20. data/spec/error_spec.rb +1 -1
  21. data/spec/errors_spec.rb +3 -3
  22. data/spec/fake_app/.rspec +1 -0
  23. data/spec/fake_app/.ruby-version +1 -0
  24. data/spec/fake_app/Gemfile +53 -0
  25. data/spec/fake_app/README.md +24 -0
  26. data/spec/fake_app/Rakefile +6 -0
  27. data/spec/fake_app/app/assets/config/manifest.js +2 -0
  28. data/spec/fake_app/app/assets/stylesheets/application.css +15 -0
  29. data/spec/fake_app/app/controllers/api.rb +2 -0
  30. data/spec/fake_app/app/controllers/api/api_controller.rb +31 -0
  31. data/spec/fake_app/app/controllers/api/v1.rb +2 -0
  32. data/spec/fake_app/app/controllers/api/v1/hellos_controller.rb +7 -0
  33. data/spec/fake_app/app/controllers/api/v1/pings_controller.rb +16 -0
  34. data/spec/fake_app/app/controllers/api/v2.rb +2 -0
  35. data/spec/fake_app/app/controllers/api/v2/hellos_controller.rb +7 -0
  36. data/spec/fake_app/app/controllers/api/v2/pings_controller.rb +16 -0
  37. data/spec/fake_app/app/controllers/application_controller.rb +2 -0
  38. data/spec/fake_app/app/helpers/application_helper.rb +2 -0
  39. data/spec/fake_app/app/models/api_client.rb +2 -0
  40. data/spec/fake_app/app/models/application_record.rb +3 -0
  41. data/spec/fake_app/bin/rails +4 -0
  42. data/spec/fake_app/bin/rake +4 -0
  43. data/spec/fake_app/bin/setup +33 -0
  44. data/spec/fake_app/config.ru +6 -0
  45. data/spec/fake_app/config/application.rb +35 -0
  46. data/spec/fake_app/config/boot.rb +3 -0
  47. data/spec/fake_app/config/credentials.yml.enc +1 -0
  48. data/spec/fake_app/config/database.yml +25 -0
  49. data/spec/fake_app/config/environment.rb +5 -0
  50. data/spec/fake_app/config/environments/development.rb +71 -0
  51. data/spec/fake_app/config/environments/production.rb +109 -0
  52. data/spec/fake_app/config/environments/test.rb +52 -0
  53. data/spec/fake_app/config/initializers/assets.rb +12 -0
  54. data/spec/fake_app/config/initializers/cookies_serializer.rb +5 -0
  55. data/spec/fake_app/config/initializers/filter_parameter_logging.rb +6 -0
  56. data/spec/fake_app/config/initializers/stitches.rb +24 -0
  57. data/spec/fake_app/config/locales/en.yml +33 -0
  58. data/spec/fake_app/config/master.key +1 -0
  59. data/spec/fake_app/config/puma.rb +43 -0
  60. data/spec/fake_app/config/routes.rb +17 -0
  61. data/spec/fake_app/config/storage.yml +34 -0
  62. data/spec/fake_app/db/development.sqlite3 +0 -0
  63. data/spec/fake_app/db/migrate/20210802153118_enable_uuid_ossp_extension.rb +7 -0
  64. data/spec/fake_app/db/migrate/20210802153119_create_api_clients.rb +14 -0
  65. data/spec/fake_app/db/schema_missing_disabled_at.rb +12 -0
  66. data/spec/fake_app/db/schema_missing_enabled.rb +11 -0
  67. data/spec/fake_app/db/schema_modern.rb +13 -0
  68. data/spec/fake_app/db/seeds.rb +7 -0
  69. data/spec/fake_app/db/test.sqlite3 +0 -0
  70. data/spec/fake_app/doc/api.md +4 -0
  71. data/spec/fake_app/lib/tasks/generate_api_key.rake +10 -0
  72. data/spec/fake_app/public/404.html +67 -0
  73. data/spec/fake_app/public/422.html +67 -0
  74. data/spec/fake_app/public/500.html +66 -0
  75. data/spec/fake_app/public/apple-touch-icon-precomposed.png +0 -0
  76. data/spec/fake_app/public/apple-touch-icon.png +0 -0
  77. data/spec/fake_app/public/favicon.ico +0 -0
  78. data/spec/fake_app/public/javascripts/apitome/application.js +31 -0
  79. data/spec/fake_app/public/robots.txt +1 -0
  80. data/spec/fake_app/public/stylesheets/apitome/application.css +269 -0
  81. data/spec/fake_app/test/application_system_test_case.rb +5 -0
  82. data/spec/fake_app/test/test_helper.rb +13 -0
  83. data/spec/fake_app/tmp/development_secret.txt +1 -0
  84. data/spec/integration/add_to_rails_app_spec.rb +9 -1
  85. data/spec/rails_helper.rb +64 -0
  86. data/spec/valid_mime_type_middleware_spec.rb +59 -0
  87. data/spec/valid_mime_type_spec.rb +6 -4
  88. data/stitches.gemspec +2 -0
  89. metadata +165 -9
  90. data/spec/api_client_access_wrapper_spec.rb +0 -52
  91. data/spec/api_key_spec.rb +0 -208
  92. data/spec/api_version_constraint_spec.rb +0 -33
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 181e2199d9e7f15ef933d3c3b87262f83894e02c25821d53aa23191db16c6a7a
4
- data.tar.gz: 8f90ed0f8e39e94715f77f2708f1fe87da31318f97708dfd50692f8408ec9d71
3
+ metadata.gz: ff331a78189008795f3789617197c5bf4ac77fb9b2db8fe036256903668656ba
4
+ data.tar.gz: 2757ff4ef4739e826b60406726547b225693f44846d811c0c8fd0afa093eb55a
5
5
  SHA512:
6
- metadata.gz: f4043849fc0c7da16cd1a87216897a6bf696b0cf82adf18d5a2d0e0277fd8ba7881e6af75e1c6451ee9fece50902bb1088166ac46c40eddc74cc4b3c01f5ecc5
7
- data.tar.gz: 2e2310a95b713fe859ce309cac4d03552fd094ac03d2526045095980f488204a27a04da5c459b90719af9f947af13f1d70301b9eddb21f90a742470198760bd2
6
+ metadata.gz: c6a9e99b2f0410ba3e1bff9c851fde7ec923154388667ea86c7a080354eb3943a2188e33336cc23e5ea530a98851e1fdf00ceb750cd246ebc95a3f43f2ecb95e
7
+ data.tar.gz: ed1f7f688b16629940ccd0f7b201ad6b47e9fcfdc005969dd3fdff7264fb45bd7c31d572a1dda5a962386bf567e07bb1823dbab66ccea40e8bed9650d67b59c9
data/.circleci/config.yml CHANGED
@@ -161,36 +161,6 @@ jobs:
161
161
  when: on_fail
162
162
  - store_test_results:
163
163
  path: "/tmp/test-results"
164
- ruby-2.7.2-rails-5.2:
165
- docker:
166
- - image: circleci/ruby:2.7.2
167
- auth:
168
- username: "$DOCKERHUB_USERNAME"
169
- password: "$DOCKERHUB_PASSWORD"
170
- environment:
171
- BUNDLE_GEMFILE: Gemfile.rails-5.2
172
- working_directory: "~/stitches"
173
- steps:
174
- - checkout
175
- - run:
176
- name: Check for Gemfile.lock presence
177
- command: ' if (test -f Gemfile.lock) then echo "Dont commit Gemfile.lock (see
178
- https://github.com/stitchfix/eng-wiki/blob/master/architecture-decisions/0009-rubygem-dependencies-will-be-managed-more-explicitly.md)"
179
- 1>&2 ; exit 1 ; else exit 0 ; fi '
180
- - run: bundle config stitchfix01.jfrog.io $ARTIFACTORY_USER:$ARTIFACTORY_TOKEN
181
- - run: bundle install --full-index
182
- - run: bundle exec rspec --format RspecJunitFormatter --out /tmp/test-results/rspec.xml
183
- --format=doc
184
- - run:
185
- name: Run Additional CI Steps
186
- command: if [ -e bin/additional-ci-steps ]; then bin/additional-ci-steps;
187
- fi
188
- - run:
189
- name: Notify Pager Duty
190
- command: bundle exec y-notify "#eng-runtime-alerts"
191
- when: on_fail
192
- - store_test_results:
193
- path: "/tmp/test-results"
194
164
  workflows:
195
165
  version: 2
196
166
  on-commit:
@@ -202,7 +172,6 @@ workflows:
202
172
  - ruby-2.7.2-rails-6.1
203
173
  - ruby-3.0.0-rails-6.0
204
174
  - ruby-2.7.2-rails-6.0
205
- - ruby-2.7.2-rails-5.2
206
175
  filters:
207
176
  tags:
208
177
  only: /^[0-9]+\.[0-9]+\.[0-9]+(\.?(RC|rc)[-\.]?\w*)?$/
@@ -237,11 +206,6 @@ workflows:
237
206
  filters:
238
207
  tags:
239
208
  only: *1
240
- - ruby-2.7.2-rails-5.2:
241
- context: org-global
242
- filters:
243
- tags:
244
- only: *1
245
209
  scheduled:
246
210
  triggers:
247
211
  - schedule:
@@ -259,5 +223,3 @@ workflows:
259
223
  context: org-global
260
224
  - ruby-2.7.2-rails-6.0:
261
225
  context: org-global
262
- - ruby-2.7.2-rails-5.2:
263
- context: org-global
data/.gitignore CHANGED
@@ -1,5 +1,6 @@
1
1
  pkg
2
2
  spec/reports
3
+ spec/fake_app/log/
3
4
  .vimrc
4
5
  *.sw?
5
6
  .idea/
data/README.md CHANGED
@@ -35,7 +35,7 @@ Then, set it up:
35
35
 
36
36
  ### Upgrading from an older version
37
37
 
38
- - When upgrading to version 4.0.0 you may now take advantage of an in-memory cache
38
+ - When upgrading to version 4.0.0 and above you may now take advantage of an in-memory cache
39
39
 
40
40
  You can enabled it like so
41
41
 
@@ -46,18 +46,46 @@ Stitches.configure do |config|
46
46
  end
47
47
  ```
48
48
 
49
- - If you have a version lower than 3.3.0, you need to run two generators, one of which creates a new database migration on your
50
- `api_clients` table:
49
+ You can also set a leniency for disabled API keys, which will allow old API keys to continue to be used if they have a
50
+ `disabled_at` field set as long as the leniency is not exceeded. Note that if the `disabled_at` field is not populated
51
+ the behavior will remain the same as it always was, and the request will be denied when the `enabled` field is set to
52
+ `true`. If Stitches allows a call due to leniency settings, a log message will be generated with a severity depending on
53
+ how long ago the API key was disabled.
54
+
55
+ ```ruby
56
+ Stitches.configure do |config|
57
+ config.disabled_key_leniency_in_seconds = 3 * 24 * 60 * 60 # Time in seconds, defaults to three days
58
+ config.disabled_key_leniency_error_log_threshold_in_seconds = 2 * 24 * 60 * 60 # Time in seconds, defaults to two days
59
+ end
60
+ ```
61
+
62
+ If a disabled key is used within the `disabled_key_leniency_in_seconds`, it will be allowed.
63
+
64
+ Anytime a disabled key is used a log will be generated. If it is before the
65
+ `disabled_key_leniency_error_log_threshold_in_seconds` it will be a warning log message, if it is after that, it will be
66
+ an error message. `disabled_key_leniency_error_log_threshold_in_seconds` should never be a greater number than
67
+ `disabled_key_leniency_in_seconds`, as this provides an escallating series of warnings before finally disabling access.
68
+
69
+ - If you are upgrading from a version older than 3.3.0 you need to run three generators, two of which create database
70
+ migrations on your `api_clients` table:
51
71
 
52
72
  ```
53
73
  > bin/rails generate stitches:add_enabled_to_api_clients
54
74
  > bin/rails generate stitches:add_deprecation
75
+ > bin/rails generate stitches:add_disabled_at_to_api_clients
55
76
  ```
56
77
 
57
- - If you have a version lower than 3.6.0, you need to run one generator:
78
+ - If you are upgrading from a version between 3.3.0 and 3.5.0 you need to run two generators:
58
79
 
59
80
  ```
60
81
  > bin/rails generate stitches:add_deprecation
82
+ > bin/rails generate stitches:add_disabled_at_to_api_clients
83
+ ```
84
+
85
+ - If you are upgrading from a version between 3.6.0 and 4.0.2 you need to run one generator:
86
+
87
+ ```
88
+ > bin/rails generate stitches:add_disabled_at_to_api_clients
61
89
  ```
62
90
 
63
91
  ## Example Microservice Endpoint
@@ -143,6 +171,10 @@ Also, the integration test does a lot of "testing the implementation", but since
143
171
  failing with a successful result, we have to make sure that the various `inject_into_file` calls are actually working. Do not do
144
172
  any fancy refactors here, just keep it up to date.
145
173
 
174
+ ## Releases
175
+
176
+ See the release process for open source gems in the Stitch Fix engineering wiki under technical topics.
177
+
146
178
  ---
147
179
 
148
180
  Provided with love by your friends at [Stitch Fix Engineering](http://technology.stitchfix.com)
@@ -0,0 +1,18 @@
1
+ require 'rails/generators'
2
+
3
+ module Stitches
4
+ class AddDisabledAtToApiClientsGenerator < Rails::Generators::Base
5
+ include Rails::Generators::Migration
6
+
7
+ source_root(File.expand_path(File.join(File.dirname(__FILE__),"generator_files")))
8
+
9
+ def self.next_migration_number(path)
10
+ Time.now.utc.strftime("%Y%m%d%H%M%S")
11
+ end
12
+
13
+ desc "Upgrade your api_clients table so it includes the `disabled_at` field"
14
+ def update_api_clients_table
15
+ migration_template "db/migrate/add_disabled_at_to_api_clients.rb", "db/migrate/add_disabled_at_to_api_clients.rb"
16
+ end
17
+ end
18
+ end
@@ -3,15 +3,14 @@ module Stitches
3
3
  class AllowlistMiddleware
4
4
  def initialize(app, options={})
5
5
  @app = app
6
- @configuration = options[:configuration] || Stitches.configuration
7
- @except = options[:except] || @configuration.allowlist_regexp
6
+ @configuration = options[:configuration]
7
+ @except = options[:except]
8
8
 
9
- unless @except.nil? || @except.is_a?(Regexp)
10
- raise ":except must be a Regexp"
11
- end
9
+ allowlist_regex
12
10
  end
11
+
13
12
  def call(env)
14
- if @except && @except.match(env["PATH_INFO"])
13
+ if allowlist_regex && allowlist_regex.match(env["PATH_INFO"])
15
14
  @app.call(env)
16
15
  else
17
16
  do_call(env)
@@ -24,5 +23,20 @@ module Stitches
24
23
  raise 'subclass must implement'
25
24
  end
26
25
 
26
+ def configuration
27
+ @configuration || Stitches.configuration
28
+ end
29
+
30
+ private
31
+
32
+ def allowlist_regex
33
+ regex = @except || configuration.allowlist_regexp
34
+
35
+ if !regex.nil? && !regex.is_a?(Regexp)
36
+ raise ":except must be a Regexp"
37
+ end
38
+
39
+ regex
40
+ end
27
41
  end
28
42
  end
@@ -2,26 +2,57 @@ require 'lru_redux'
2
2
 
3
3
  module Stitches::ApiClientAccessWrapper
4
4
 
5
- def self.fetch_for_key(key)
5
+ def self.fetch_for_key(key, configuration)
6
6
  if cache_enabled
7
- fetch_for_key_from_cache(key)
7
+ fetch_for_key_from_cache(key, configuration)
8
8
  else
9
- fetch_for_key_from_db(key)
9
+ fetch_for_key_from_db(key, configuration)
10
10
  end
11
11
  end
12
12
 
13
- def self.fetch_for_key_from_cache(key)
13
+ def self.fetch_for_key_from_cache(key, configuration)
14
14
  api_key_cache.getset(key) do
15
- fetch_for_key_from_db(key)
15
+ fetch_for_key_from_db(key, configuration)
16
16
  end
17
17
  end
18
18
 
19
- def self.fetch_for_key_from_db(key)
20
- if ::ApiClient.column_names.include?("enabled")
21
- ::ApiClient.find_by(key: key, enabled: true)
19
+ def self.fetch_for_key_from_db(key, configuration)
20
+ api_client = ::ApiClient.find_by(key: key)
21
+ return unless api_client
22
+
23
+ unless api_client.respond_to?(:enabled?)
24
+ logger.warn('api_keys is missing "enabled" column. Run "rails g stitches:add_enabled_to_api_clients"')
25
+ return api_client
26
+ end
27
+
28
+ unless api_client.respond_to?(:disabled_at)
29
+ logger.warn('api_keys is missing "disabled_at" column. Run "rails g stitches:add_disabled_at_to_api_clients"')
30
+ end
31
+
32
+ return api_client if api_client.enabled?
33
+
34
+ disabled_at = api_client.respond_to?(:disabled_at) ? api_client.disabled_at : nil
35
+ if disabled_at && disabled_at > configuration.disabled_key_leniency_in_seconds.seconds.ago
36
+ message = "Allowing disabled ApiClient: #{api_client.name} with key #{api_client.key} disabled at #{disabled_at}"
37
+ if disabled_at > configuration.disabled_key_leniency_error_log_threshold_in_seconds.seconds.ago
38
+ logger.warn(message)
39
+ else
40
+ logger.error(message)
41
+ end
42
+ return api_client
43
+ else
44
+ logger.error("Rejecting disabled ApiClient: #{api_client.name} with key #{api_client.key}")
45
+ end
46
+ nil
47
+ end
48
+
49
+ def self.logger
50
+ if defined?(StitchFix::Logger::LogWriter)
51
+ StitchFix::Logger::LogWriter
52
+ elsif defined?(Rails.logger)
53
+ Rails.logger
22
54
  else
23
- ActiveSupport::Deprecation.warn('api_keys is missing "enabled" column. Run "rails g stitches:add_enabled_to_api_clients"')
24
- ::ApiClient.find_by(key: key)
55
+ ::Logger.new('/dev/null')
25
56
  end
26
57
  end
27
58
 
@@ -39,4 +70,4 @@ module Stitches::ApiClientAccessWrapper
39
70
  def self.cache_enabled
40
71
  Stitches.configuration.max_cache_ttl.positive?
41
72
  end
42
- end
73
+ end
@@ -25,12 +25,12 @@ module Stitches
25
25
  def do_call(env)
26
26
  authorization = env["HTTP_AUTHORIZATION"]
27
27
  if authorization
28
- if authorization =~ /#{@configuration.custom_http_auth_scheme}\s+key=(.*)\s*$/
28
+ if authorization =~ /#{configuration.custom_http_auth_scheme}\s+key=(.*)\s*$/
29
29
  key = $1
30
- client = Stitches::ApiClientAccessWrapper.fetch_for_key(key)
30
+ client = Stitches::ApiClientAccessWrapper.fetch_for_key(key, configuration)
31
31
  if client.present?
32
- env[@configuration.env_var_to_hold_api_client_primary_key] = client.id
33
- env[@configuration.env_var_to_hold_api_client] = client
32
+ env[configuration.env_var_to_hold_api_client_primary_key] = client.id
33
+ env[configuration.env_var_to_hold_api_client] = client
34
34
  @app.call(env)
35
35
  else
36
36
  unauthorized_response("key invalid")
@@ -59,7 +59,7 @@ module Stitches
59
59
  def unauthorized_response(reason)
60
60
  status = 401
61
61
  body = "Unauthorized - #{reason}"
62
- header = { "WWW-Authenticate" => "#{@configuration.custom_http_auth_scheme} realm=#{rails_app_module}" }
62
+ header = { "WWW-Authenticate" => "#{configuration.custom_http_auth_scheme} realm=#{rails_app_module}" }
63
63
  Rack::Response.new(body, status, header).finish
64
64
  end
65
65
 
@@ -15,8 +15,12 @@ class Stitches::Configuration
15
15
  @env_var_to_hold_api_client= NonNullString.new("env_var_to_hold_api_client","STITCHES_API_CLIENT")
16
16
  @max_cache_ttl = NonNullInteger.new("max_cache_ttl", 0)
17
17
  @max_cache_size = NonNullInteger.new("max_cache_size", 0)
18
+ @disabled_key_leniency_in_seconds = ActiveSupport::Duration.days(3)
19
+ @disabled_key_leniency_error_log_threshold_in_seconds = ActiveSupport::Duration.days(2)
18
20
  end
19
21
 
22
+ attr_accessor :disabled_key_leniency_in_seconds, :disabled_key_leniency_error_log_threshold_in_seconds
23
+
20
24
  # A RegExp that allows URLS around the mime type and api key requirements.
21
25
  # nil means that ever request must have a proper mime type and api key.
22
26
  attr_reader :allowlist_regexp
@@ -0,0 +1,9 @@
1
+ <% if Rails::VERSION::MAJOR >= 5 %>
2
+ class AddEnabledToApiClients < ActiveRecord::Migration[<%= Rails::VERSION::MAJOR %>.<%= Rails::VERSION::MINOR %>]
3
+ <% else %>
4
+ class AddEnabledToApiClients < ActiveRecord::Migration
5
+ <% end %>
6
+ def change
7
+ add_column :api_clients, :disabled_at, "timestamp with time zone", null: true
8
+ end
9
+ end
@@ -9,6 +9,7 @@ class CreateApiClients < ActiveRecord::Migration
9
9
  t.column :key, "uuid default uuid_generate_v4()", null: false
10
10
  t.column :enabled, :bool, null: false, default: true
11
11
  t.column :created_at, "timestamp with time zone default now()", null: false
12
+ t.column :disabled_at, "timestamp with time zone", null: true
12
13
  end
13
14
  add_index :api_clients, [:name]
14
15
  add_index :api_clients, [:key], unique: true
@@ -1,13 +1,9 @@
1
1
  require 'active_support/time_with_zone'
2
2
 
3
3
  class ActiveSupport::TimeWithZone
4
- # We want dates to always be in UTC
4
+ # We want dates to be a) in UTC and b) in ISO8601 always
5
5
  def as_json(options = {})
6
- if utc?
7
- super
8
- else
9
- utc.as_json(options)
10
- end
6
+ utc.iso8601
11
7
  end
12
8
  end
13
9
 
@@ -20,7 +20,7 @@ module Stitches
20
20
 
21
21
  def do_call(env)
22
22
  accept = String(env["HTTP_ACCEPT"])
23
- if (accept =~ %r{application/json} && accept =~ %r{version=\d+}) || accept =~ %r{application/protobuf}
23
+ if (%r{application/json}.match?(accept) && %r{version=\d+}.match?(accept)) || %r{application/protobuf}.match?(accept)
24
24
  @app.call(env)
25
25
  else
26
26
  not_acceptable_response(accept)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Stitches
4
- VERSION = '4.1.0RC2'
4
+ VERSION = '4.2.0.RC1'
5
5
  end
@@ -14,6 +14,7 @@ require 'stitches/errors'
14
14
  require 'stitches/api_generator'
15
15
  require 'stitches/add_deprecation_generator'
16
16
  require 'stitches/add_enabled_to_api_clients_generator'
17
+ require 'stitches/add_disabled_at_to_api_clients_generator'
17
18
  require 'stitches/api_version_constraint'
18
19
  require 'stitches/api_key'
19
20
  require 'stitches/deprecation'
@@ -0,0 +1,368 @@
1
+ require 'rails_helper'
2
+ require 'securerandom'
3
+
4
+ class FakeLogger
5
+ # This shouldn't be needed but there's a weird mocking conflict with kernal warn method otherwise
6
+ def warn(message)
7
+ end
8
+ end
9
+
10
+ RSpec.describe "/api/hellos", type: :request do
11
+ let(:uuid) { api_client.key }
12
+ let(:auth_header) { "MyAwesomeInternalScheme key=#{uuid}" }
13
+ let(:allowlist) { nil }
14
+
15
+ before do
16
+ Stitches.configuration.reset_to_defaults!
17
+ Stitches.configuration.custom_http_auth_scheme = 'MyAwesomeInternalScheme'
18
+ Stitches.configuration.allowlist_regexp = allowlist if allowlist
19
+ Stitches::ApiClientAccessWrapper.clear_api_cache
20
+ end
21
+
22
+ def execute_call(auth: auth_header)
23
+ headers = {
24
+ "Accept" => "application/json; version=1"
25
+ }
26
+ headers["Authorization"] = auth if auth
27
+
28
+ get "/api/hellos", headers: headers
29
+ end
30
+
31
+ def expect_unauthorized
32
+ expect(response.body).to include "Unauthorized"
33
+ expect(response.status).to eq 401
34
+ expect(response.headers["WWW-Authenticate"]).to eq("MyAwesomeInternalScheme realm=FakeApp")
35
+ end
36
+
37
+ context "with modern schema" do
38
+ let(:api_client_enabled) { true }
39
+ let(:disabled_at) { nil }
40
+ let!(:api_client) {
41
+ uuid = SecureRandom.uuid
42
+ ApiClient.create(name: "MyApiClient", key: SecureRandom.uuid, enabled: false, created_at: 20.days.ago, disabled_at: 15.days.ago)
43
+ ApiClient.create(name: "MyApiClient", key: uuid, enabled: api_client_enabled, created_at: 10.days.ago, disabled_at: disabled_at)
44
+ }
45
+
46
+ context "when path is not on allowlist" do
47
+ context "when api_client is valid" do
48
+ it "executes the correct controller" do
49
+ execute_call
50
+
51
+ expect(response.body).to include "Hello"
52
+ end
53
+
54
+ it "saves the api_client information used" do
55
+ execute_call
56
+
57
+ expect(response.body).to include "MyApiClient"
58
+ expect(response.body).to include "#{api_client.id}"
59
+ end
60
+
61
+ context "caching is enabled" do
62
+ before do
63
+ allow(ApiClient).to receive(:find_by).and_call_original
64
+
65
+ Stitches.configure do |config|
66
+ config.max_cache_ttl = 5
67
+ config.max_cache_size = 10
68
+ end
69
+ end
70
+
71
+ it "only gets the the api_client information once" do
72
+ execute_call
73
+ execute_call
74
+
75
+ expect(response.body).to include "#{api_client.id}"
76
+ expect(ApiClient).to have_received(:find_by).once
77
+ end
78
+ end
79
+ end
80
+
81
+ context "when api client key does not match" do
82
+ let(:uuid) { SecureRandom.uuid } # random uuid
83
+
84
+ it "rejects request" do
85
+ execute_call
86
+
87
+ expect_unauthorized
88
+ end
89
+ end
90
+
91
+ context "when api client key not enabled" do
92
+ let(:api_client_enabled) { false }
93
+
94
+ context "when disabled_at is not set" do
95
+ it "rejects request" do
96
+ execute_call
97
+
98
+ expect_unauthorized
99
+ end
100
+ end
101
+
102
+ context "when disabled_at is set to a time older than three days ago" do
103
+ let(:disabled_at) { 4.day.ago }
104
+
105
+ it "allows the call" do
106
+ execute_call
107
+
108
+ expect_unauthorized
109
+ end
110
+ end
111
+
112
+ context "when disabled_at is set to a recent time" do
113
+ let(:disabled_at) { 1.day.ago }
114
+
115
+ it "allows the call" do
116
+ execute_call
117
+
118
+ expect(response.body).to include "Hello"
119
+ expect(response.body).to include "MyApiClient"
120
+ expect(response.body).to include "#{api_client.id}"
121
+ end
122
+
123
+ it "warns about the disabled key to log writer when available" do
124
+ stub_const("StitchFix::Logger::LogWriter", FakeLogger.new)
125
+ allow(StitchFix::Logger::LogWriter).to receive(:warn)
126
+
127
+ execute_call
128
+
129
+ expect(StitchFix::Logger::LogWriter).to have_received(:warn).once
130
+ end
131
+
132
+ it "warns about the disabled key to the Rails.logger" do
133
+ allow(Rails.logger).to receive(:warn)
134
+ allow(Rails.logger).to receive(:error)
135
+
136
+ execute_call
137
+
138
+ expect(Rails.logger).to have_received(:warn).once
139
+ expect(Rails.logger).not_to have_received(:error)
140
+ end
141
+ end
142
+
143
+ context "when disabled_at is set to a dangerously long time" do
144
+ let(:disabled_at) { 52.hours.ago }
145
+
146
+ it "allows the call" do
147
+ execute_call
148
+
149
+ expect(response.body).to include "Hello"
150
+ expect(response.body).to include "MyApiClient"
151
+ expect(response.body).to include "#{api_client.id}"
152
+ end
153
+
154
+ it "logs error about the disabled key to log writer when available" do
155
+ stub_const("StitchFix::Logger::LogWriter", FakeLogger.new)
156
+ allow(StitchFix::Logger::LogWriter).to receive(:error)
157
+
158
+ execute_call
159
+
160
+ expect(StitchFix::Logger::LogWriter).to have_received(:error).once
161
+ end
162
+
163
+ it "logs error about the disabled key to the Rails.logger" do
164
+ allow(Rails.logger).to receive(:warn)
165
+ allow(Rails.logger).to receive(:error)
166
+
167
+ execute_call
168
+
169
+ expect(Rails.logger).to have_received(:error).once
170
+ expect(Rails.logger).not_to have_received(:warn)
171
+ end
172
+ end
173
+
174
+ context "when disabled_at is set to an unacceptably long time" do
175
+ let(:disabled_at) { 5.days.ago }
176
+
177
+ it "forbids the call" do
178
+ execute_call
179
+
180
+ expect_unauthorized
181
+ end
182
+
183
+ it "logs error about the disabled key to log writer when available" do
184
+ stub_const("StitchFix::Logger::LogWriter", FakeLogger.new)
185
+ allow(StitchFix::Logger::LogWriter).to receive(:error)
186
+
187
+ execute_call
188
+
189
+ expect(StitchFix::Logger::LogWriter).to have_received(:error).once
190
+ end
191
+
192
+ it "logs error about the disabled key to the Rails.logger" do
193
+ allow(Rails.logger).to receive(:warn)
194
+ allow(Rails.logger).to receive(:error)
195
+
196
+ execute_call
197
+
198
+ expect(Rails.logger).to have_received(:error).once
199
+ expect(Rails.logger).not_to have_received(:warn)
200
+ end
201
+ end
202
+
203
+ context "custom leniency is set" do
204
+ before do
205
+ Stitches.configuration.disabled_key_leniency_in_seconds = 100
206
+ Stitches.configuration.disabled_key_leniency_error_log_threshold_in_seconds = 50
207
+ end
208
+
209
+ context "when disabled_at is set to an unacceptably long time" do
210
+ let(:disabled_at) { 101.seconds.ago }
211
+
212
+ it "forbids the call" do
213
+ allow(Rails.logger).to receive(:error)
214
+ execute_call
215
+
216
+ expect_unauthorized
217
+ expect(Rails.logger).to have_received(:error).once
218
+ end
219
+ end
220
+
221
+ context "when disabled_at is set to a dangerously long time" do
222
+ let(:disabled_at) { 75.seconds.ago }
223
+
224
+ it "allows the call" do
225
+ allow(Rails.logger).to receive(:error)
226
+
227
+ execute_call
228
+
229
+ expect(response.body).to include "Hello"
230
+ expect(Rails.logger).to have_received(:error).once
231
+ end
232
+ end
233
+
234
+ context "when disabled_at is set to a short time ago" do
235
+ let(:disabled_at) { 25.seconds.ago }
236
+
237
+ it "allows the call" do
238
+ allow(Rails.logger).to receive(:warn)
239
+
240
+ execute_call
241
+
242
+ expect(response.body).to include "Hello"
243
+ expect(Rails.logger).to have_received(:warn).once
244
+ end
245
+ end
246
+ end
247
+ end
248
+
249
+ context "when authorization header is missing" do
250
+ it "rejects request" do
251
+ execute_call(auth: nil)
252
+
253
+ expect_unauthorized
254
+ end
255
+ end
256
+
257
+ context "when scheme does not match" do
258
+ it "rejects request" do
259
+ execute_call(auth: "OtherScheme key=#{uuid}")
260
+
261
+ expect_unauthorized
262
+ end
263
+ end
264
+ end
265
+
266
+ context "when path is on allowlist" do
267
+ let(:allowlist) { /.*hello.*/ }
268
+
269
+ context "when api_client is valid" do
270
+ it "executes the correct controller" do
271
+ execute_call
272
+
273
+ expect(response.body).to include "Hello"
274
+ end
275
+
276
+ it "does not save the api_client information used" do
277
+ execute_call
278
+
279
+ expect(response.body).to include "NameNotFound"
280
+ expect(response.body).to include "IdNotFound"
281
+ end
282
+ end
283
+
284
+ context "when api client key does not match" do
285
+ let(:uuid) { SecureRandom.uuid } # random uuid
286
+
287
+ it "executes the correct controller" do
288
+ execute_call
289
+
290
+ expect(response.body).to include "Hello"
291
+ end
292
+ end
293
+ end
294
+ end
295
+
296
+ context "when schema is old and missing disabled_at field" do
297
+ around(:each) do |example|
298
+ load 'fake_app/db/schema_missing_disabled_at.rb'
299
+ ApiClient.reset_column_information
300
+ example.run
301
+ load 'fake_app/db/schema_modern.rb'
302
+ ApiClient.reset_column_information
303
+ end
304
+
305
+ context "when api_client is valid" do
306
+ let!(:api_client) {
307
+ uuid = SecureRandom.uuid
308
+ ApiClient.create(name: "MyApiClient", key: uuid, created_at: Time.now(), enabled: true)
309
+ }
310
+
311
+ it "executes the correct controller" do
312
+ execute_call
313
+
314
+ expect(response.body).to include "Hello"
315
+ end
316
+
317
+ it "saves the api_client information used" do
318
+ execute_call
319
+
320
+ expect(response.body).to include "MyApiClient"
321
+ expect(response.body).to include "#{api_client.id}"
322
+ end
323
+ end
324
+
325
+ context "when api_client is not enabled" do
326
+ let!(:api_client) {
327
+ uuid = SecureRandom.uuid
328
+ ApiClient.create(name: "MyApiClient", key: uuid, created_at: Time.now(), enabled: false)
329
+ }
330
+
331
+ it "rejects request" do
332
+ execute_call
333
+
334
+ expect_unauthorized
335
+ end
336
+ end
337
+ end
338
+
339
+ context "when schema is old and missing enabled field" do
340
+ around(:each) do |example|
341
+ load 'fake_app/db/schema_missing_enabled.rb'
342
+ ApiClient.reset_column_information
343
+ example.run
344
+ load 'fake_app/db/schema_modern.rb'
345
+ ApiClient.reset_column_information
346
+ end
347
+
348
+ let!(:api_client) {
349
+ uuid = SecureRandom.uuid
350
+ ApiClient.create(name: "MyApiClient", key: uuid, created_at: Time.now())
351
+ }
352
+
353
+ context "when api_client is valid" do
354
+ it "executes the correct controller" do
355
+ execute_call
356
+
357
+ expect(response.body).to include "Hello"
358
+ end
359
+
360
+ it "saves the api_client information used" do
361
+ execute_call
362
+
363
+ expect(response.body).to include "MyApiClient"
364
+ expect(response.body).to include "#{api_client.id}"
365
+ end
366
+ end
367
+ end
368
+ end