Rails + rack-corsの設定をRSpecだけである程度お手軽にテストする

やりたいこと

  • CORSの設定ミスやエラーをできるだけ早期に発見する
  • CORSの設定のテストをできるだけブラウザを使った手動テスト以外の方法で実現する
  • 以上をできるだけ手軽に実現する

CORSのテストはとてもやっかい

CORS は非常にやっかいで、そもそも用語が request 側と response 側で分かりにくいというのもあるんだけど、何よりテストが面倒くさい。JavaScript からの CORS リクエストの動作を完全にテストしようとすると、例えば Rack アプリの場合、

  • cross-origin リクエストを生成するために JavaScript 向けに HTTP サーバを立てる
  • rack サーバを立てる
  • JavaScript のテストコードを書く
  • 以上を連動させる自動テストを作る

ということになってしまう。これはかなり面倒くさい。もちろん rack サーバ側は Rails アプリをそのまま使うとなると E2E になる。

rspec-railsだけでどこまでいけるのか?

ということで、どこまでだったら RSpec だけで頑張れるかをちょっと試してみた。環境は以下の通り。

  • rails 5.2.3
  • rspec-rails 3.8.2
  • rack-cors 1.0.3

rack-corsのdebugを有効に

config.middleware.insert_before 0, Rack::Cors do

で書いていく部分に debug 設定を追加。こんな感じ。

config.middleware.insert_before 0, Rack::Cors, debug: !Rails.env.production?

こうしておくと production の時以外は X-Rack-CORS* なヘッダが追加されるので、test 時にも利用できる。

ここで大事なのは

X-Rack-CORS: miss

というヘッダが現れるかどうか。これが現れたら少なくともサーバ側の動作としては CORS の要件を満たさないので目的の response を返してくれることはない。

request specを書く

これには以下のような理由がある。

  • Rails アプリが E2E で動作するように
    • rack-cors は rack レイヤーであって Rails の Controller, Routing などのテストでは不十分
  • ある程度自由に HTTP を喋れるように
    • Capybara のボキャブラリに縛られると自由が効かない

注意点。OPTIONS を直接呼べない。

RSpec::Rails::RequestExampleGroup の利用している ActionDispatch::Itegration::Runner で define_method で決まっているっぽい。

     %w(get post patch put head delete cookies assigns follow_redirect!).each do |method|
       define_method(method) do |*args|

こゆことです。つまり Preflight リクエストを直接テストできないことを意味している。ということで書けるのは Origin ヘッダ付きの GET とか POST とかそういうものになる。

get '/api/v1/posts.json', headers: { 'Origin' => 'http://example.net' }

みたいな感じ。例えば production で動く Origin をここで指定してあげると deploy する前にテストすることができる。

※ Rails 5.1 でオプションの与え方が変わっているらしいので注意が必要。ここは RSpec ではなく ActionDispatch の方に引っぱられているので、Rails のバージョンによって変わってしまう。

cf. Request tests fail after upgrading to Rails 5

この時、

response.headers

の中身は以下のようになる。

{ "Content-Type" => "application/json; charset=utf-8",
  "Vary"         => "Origin",
  "X-Rack-CORS"  => "miss; no-origin",
..

この "X-Rack-CORS" => "miss" がポイント。これがあったら Rack のレベルで弾かれている。つまり Controller に処理が渡ることはない。

理由については rack-cors の中で以下のように定義されている。

module Rack
  class Cors

    protected
     class Result
       HEADER_KEY = 'X-Rack-CORS'.freeze

       MISS_NO_ORIGIN = 'no-origin'.freeze
       MISS_NO_PATH   = 'no-path'.freeze

       MISS_NO_METHOD   = 'no-method'.freeze
       MISS_DENY_METHOD = 'deny-method'.freeze
       MISS_DENY_HEADER = 'deny-header'.freeze

stub outなどは適宜ご自由に

E2E なので Model 側の準備も必要になってしまうが、CORS の部分だけをチェックしたいのであれば適当に Stub Out してあげればなんとかなる。これはいつもの通り。

RR.stub(Post).recents { [] }

みたいなのでたぶん十分。

まとめ - できること、できないこと

  • 少なくとも Origin をまたいだ HTTP リクエストのフリはできる
    • XHR のフリもできる
  • Origin が食い違っていることで動かない様子や Origin をオウム返しして動く様子など Rack サーバ側のテストはできる
  • JavaScript が動いているわけではないので、XHR の mode 設定はできない
    • つまり withCredentials 周りで引っかかるか否かはテストできない

欲を言えば XHR のモード設定とかしたいけれども、少なくとも今回の方法で 単純な HTTP のレベルと XHR や Fetch などの API レベルの切り分けはできる ので、そういう使い方になるのかなと思っている。

おまけ

今回 RSpec でどこまでやれるのかを確かめるきっかけになったのは、実は rack-cors が 1.0 で default の動作が変わったこと、および XHR に利用していたライブラリがなぜか withCredentials = true で動いていたことのコンボで CORS で取得していたコンテンツが表示できなくなっていたという問題だった。こうした問題に対してあれこれ試して分かったのは

  • CORS で意図通りにコンテンツを扱えないという問題を取得できる堅牢な API はない
    • XHR および Fetch API では通信の失敗とコンテンツが利用できないエラーは区別できない
  • ブラウザのconsole上には何か出ている
  • つまり production のモニタリングで失敗を検知するのは難しい

ということだった。1 少なくとも本気でやるなら最初に書いたようなテストの自動化を用意してあげる必要はありそうなんだけど、ちょっと今の環境でそこまでやる手間を掛けるのはアレだなと思って別な方法を探していた次第。

はー、思ったより疲れたよ。CORS ってほんとに狭間にあるので、意外と知見がまとまってないんだよなー。

参考

  1. もしかしたらイマドキの賢い Fetch クライアント的なものではうまく扱える可能性もないではないけど。 

More