ンンンパ

ふとしです

移転しました

Rails開発でリポジトリには入れたくないんだけどローカルではやっておきたいテストがある場合の取りあつかい

自明だったり、すごく重かったり、細かすぎるテストたちがいます。

重いテストは短期的にも長期的にもコストになり、細かすぎるテストは実装変更時のコストが必要以上に高く、よくありません。

しかし、たまにやっときたいんですよというテストがありますので、本体に影響がないようにやっていきます。

Railsのディレクトリの外にもう一層つくる

以下のような構成になりました。

- /my_test
  - /.git
  - /rails_application # 本体
  - /seed              # テスト用の
  - /spec              # 自分専用テスト
    - /models           
    - rails_helper.rb  # 自分専用のテストのrails_helper.rb
  - .gitignore         # 本体を含めないため
  - Gemfile            # 自分専用テスト用のGemfile

自分だけが使うGemを用意する

テストで使いたいが、該当アプリのGemfileに入っていないので使えないGemというのがありますので、どうにかします。(今回のはテストで使う!というGemではないですが)

むりくりrequireしても、コンテキストが変わってしまい、ダメなようですので、本体のGemfileの内容をinstance_evalします。

instance_eval(File.read(File.expand_path('../rails_application/Gemfile', __FILE__)))

# 好きなGemを入れる
gem 'seed-fu' 

seed-fu

ところで、ダミーデータもまた、大量だったり詳細だったりすると、コストになります。

しかしさまざまなレコード状態表示の調整をする場合など、ある局面では役に立つので、テスト以外でも使えるように用意しておきます。

#!/usr/bin/env ruby
require File.expand_path('../../rails_application/config/application', __FILE__)
abort("The Rails environment is running in production mode!") if Rails.env.production?
Rails.application.require_environment!

require 'seed-fu'

10000.times do |n|
  ModelA.seed(
    id: n,
    name: SecureRandom.hex(4)
  )
end
$ bundle exec ./seed/seed.rb 

これで外側から、自分のデベロップメントや、テストに、大量のダミーデータを投入できるようになりました。

テスト

rails_helper.rbをコピペして編集する

environmentspec_helperに対するパス部分を本体のファイルに当たるように変更します。

require File.expand_path('../../rails_application/config/environment', __FILE__)
require File.expand_path('../../rails_application/spec/spec_helper', __FILE__)

さきのseed-fuを利用する

seed-fuconfig.before :all内などに書いておけば安泰ではないでしょうか。DatabaseCleanerなどを使っている場合は、それが動いた後である必要があります。

require File.expand_path('../seed/seed', __FILE__)

実行

require 'rails_helper'

RSpec.describe ModelA, type: :model do
  it { expect(ModelA.create!).to be_a(ModelA) }
  it { expect(ModelA.count).to eq(10000) }
end
$ rspec -cfd

# 略

  should be a kind of ModelA(id: integer, name: string, created_at: datetime, updated_at: datetime)
  should eq 10000

Finished in 1 minute 41.39 seconds (files took 3.8 seconds to load)
2 examples, 0 failures

できました。

これはジョークのようなテストですが、たとえばCapybaraでスクリーンショットを撮りまくるといった、半分趣味のようなテストも、本体に影響なくどんどんやっていくことができます。

Authlogicに関するメモ

認証機能再発明するべからずは有名な鉄則ですが、まるで知らないままというのもマズイので実装を読むことでお茶を濁していきたい。

saltSCryptの運用、persistence_tokenの更新などで比較的安全にいけるのでは。

パスワード

salt

Authlogic::Random.friendly_token
def friendly_token
  # use base64url as defined by RFC4648
  SecureRandom.base64(15).tr('+/=', '').strip.delete("\n")
end

暗号化

authlogic/lib/authlogic/crypto_providers以下に、各種Cryptorをラップしたアダプターがある。

SCryptならAuthlogic::CryptoProviders::SCrypt#encrypt(*tokens)で暗号化パスワードを得られる。

*tokensには[password, salt]が入り、saltは保存されてログイン時のパスワード一致を見る時に使われる。

生パスワードは勿論破棄される。

パスワード確認

各種Cryptorの比較演算子を使ったりする。

# ユーザーのレコードから得る
salt = user.password_salt
encrypted = user.encryped_password

# 入力
password = 'aaaaaaaa'

# 比較
SCrypt::Password.new(encrypted) == Authlogic::CryptoProviders::SCrypt.encrypt(password, salt)

セッション

cookieなどの値を得るために、Controllerインスタンスを持つアダプターが必要になる。

Controller経由では、初期化時に自動的に付与されるAuthlogic::ControllerAdapters::RailsAdapterが設定する。

Authlogic::Session::Base.controller = RailsAdapter.new(self)

それ以外で使いたい場合は、必要な値を返すなにかを用意する。

class ControllerLike
  def initialize(request)
    @request = request
  end

  def cookies
    @request.cookies
  end

  def params
    @request.params
  end

  def session
    @request.session
  end

  def responds_to_last_request_update_allowed?
    true
  end

  def last_request_update_allowed?
    false
  end

  def method_missing(*args)
    false
  end
end

Authlogic::Session::Base.controller = ControllerLike.new(request)

開始

create時にユーザーレコードにpersistence_tokenを保存する。

クッキーにはそのpersistence_tokenとユーザーのidを、後にsplit可能な形で保存する。[persistence_token, id].join('::')など。

復元

保存しておいたクッキーの値のidからユーザーを検索、persistence_tokenと一致するか見る。

AWS Lambdaで使う関数をローカルでテストするサーバーを建てる。

AWS Lambda + AWS Api Gateway + AWS DynamoDBでなにかをつくることにはまっています。

AWS Lambdaで使う関数自体はmochaなどでテストできますが、実際にブラウザから叩くテストをローカルでしたいと思いました。

そこでNode.jsで簡単なサーバーを建てます。

まず、AWS Lambdaはこのような関数です

"use strict";

const Workbook = require('./src/workbook').Workbook;
const Marker = require('./src/marker').Marker;

exports.handler = (data, context, callback) => {
  let workbook = new Workbook(data);
  let marker = new Marker(workbook, data);

  marker.mark((result) => {
    if (result) {
      context.succeed({result: 'correct', data: marker.explain()});
    } else {
      context.fail(JSON.stringify({result: 'incorrect', data: marker.explain()}));
    }
  });
};

WorkbookMarkerがDynamoDBにアクセスします。環境変数を使って、内部で参照ファイルを切りかえ、ローカルのDynamoDBにアクセスするようにしているので、そちらは大丈夫です。

テストサーバーです

サーバー側の言語をまともに触ったのはNode.jsがはじめてで、はじめて書いたのもこのような簡易なサーバーでした。非常に懐かしいですね。

import * as http from 'http';
import * as qs from 'querystring';
import * as marker from '../marker/index';

http.createServer(function (req, res) {
  if (req.method !== 'POST' && req.method !== 'OPTIONS') {
    res.end('');
    return;
  }

  res.setHeader('Access-Control-Allow-Origin', '*');
  res.setHeader('Access-Control-Allow-Methods', 'POST');
  res.setHeader('Access-Control-Allow-Headers', 'X-Requested-With,content-type');
  res.setHeader('Content-Type', 'application/json');

  if(req.method === 'OPTIONS'){
    res.end(``);
    return;
  }

  let body = '';
  req
    .on('data', (data) => {
      console.log('data', data);
      body += data
    })
    .on('end', () => {
      qs.parse(body);

      marker.handler(JSON.parse(body), {
        succeed: (data)=> {
          res.end(JSON.stringify(data));
        },
        fail: (stringifiedData)=> {
          res.end(stringifiedData);
        }
      });
    });

}).listen(process.env.PORT || 3080, '127.0.0.1');

OPTIONSによるプリフライト対応処理を追加しました。(昨日は素のPOSTだけ飛んでたのにな……)

まずPOSTOPTIONS以外はハネます。

つぎに、データの整形ですね。こういうのは自動でやってくれないんでしょうか。

marker.handlerがAWS Lambdaでの関数本体なので、そこへデータを投げます。

context.succeescontext.failで関数を終了するつくりなので、そこに関数を妖異します。その関数呼び出しをもってJSONレスポンスを作成します。

実際のfailでは{errorMessage: 引数}というレスポンスになりますが、これはAWS Api Gatewayで整形します。ので、それを踏まえたJSONとします。

$ TEST=true babel-node test-server/index.js

テストできた

これでブラウザ側からSuperagentなどを使いPOSTすると、実際のレスポンスが得られるようになりました。

クリッククリックで行うテストがはかどりますね。

`Regexp`などActiveRecordでメソッドが用意されていない標準SQLの演算子を清く正しく使う。

清く正しくとは、生SQL文字列を書かない程度の意味です。

下の方の長いやつはRails 4までの話です

REGEXPRails 5もしくはArel 7からmatches_regexpとしてメソッドが用意されました。

Writer.where(Writer.arel_table[:email].matches_regexp('.*@gmail.com'))

# SELECT "writers".* FROM "writers" WHERE ("writers"."email" ~ '.*@gmail.com')

Rails 5になってなお下のような書き方をしていると殴られる可能性があります。

このようにどんどん便利になっていってるので、たまに知識更新するのも大切ですね。

たとえばRegexpwhereする

グーグル先生にたずねるとStack Overflowが出てきて以下のようなコードが出てきます。

Writer.where("email REGEXP ?", '.*@gmail.com')

# SELECT "writers".* FROM "writers" WHERE (email ~ '.*@gmail.com')

動きますが、せっかくのORMですから、ORMらしく書きましょう。

だいたいの演算子や句はArelに用意されている

ドキュメントやグーグル先生より、ソース内を検索したほうが早いのですが、大体の演算子のビルダーが用意されています。

REGEXPなら以下のように書けます。

Writer
  .where(
    Arel::Nodes::Regexp.new(
      Writer.arel_table[:email],
      Arel::Nodes::Casted.new('.*@gmail.com', Writer.arel_table[:email])
    )
  )

# SELECT "writers".* FROM "writers" WHERE "writers"."email" ~ '.*@gmail.com'

WHERE句のカラム指定にテーブル名が入りました。

より具体的な記述になっていますが、テーブル名が欲しくない場合もあります('AS'した場合とか)。

ActiveRecord#arel_tableで得られるArel::Tableはその名の通りテーブルを前提とした値しか返してくれませんので、自分で組みたてる必要があります。

Writer
  .where(
    Arel::Nodes::Regexp.new(
      Arel.sql(Writer.connection.quote_column_name(:email)),
      Arel::Nodes::Quoted.new('.*@gmail.com')
    )
  )

# SELECT "writers".* FROM "writers" WHERE "email" ~ '.*@gmail.com'

カラムのクォーティングのルールはデータベースによって違うので、quote_column_nameで万全を期すと良いでしょう。

値は値用にクォーティングします。

WHERE句の()が欲しい場合はさらにこうします。

Writer
 .where(
    Arel::Nodes::Grouping.new(
      Arel::Nodes::Regexp.new(
        Arel.sql(Writer.connection.quote_column_name(:email)),
        Arel::Nodes::Quoted.new('.*@gmail.com')
      )
    )
  )

# SELECT "writers".* FROM "writers" WHERE ("email" ~ '.*@gmail.com')

大変な騒ぎになってきましたね。

Arelのルール

Arelは安全性を維持するために、生のStringは受けつけてくれず、なんらかのArelクラスでラップする必要があります。

ところでArel.sqlArel::Nodes::SqlLiteral.newのショートカットで、これは何の評価も加工もされず、SQLに渡ります。

カジュアルに使うと生SQLをいじくりまわしているのと大して変わらないので気をつけましょう。

長い

記述が長くなってうれしみが少ないですか?

最初はウッとなりましたが、自分で文字列を組みたてるのと比べると、これが別に気にならなくなったりします。

フシギですね。

AWS Lambdaを使って、ブラウザ側とサーバー側で同じバリデーションをするということをやった。

日記です。今日の日記コードはこれ。

github.com

qiitaにはさすがにもうAWS Lambdaでフォームなんていう記事は山盛りあったのでこっちで。

日記

Node.jsはサーバーとして動かせるので、ブラウザ側とサーバー側で同じスクリプトを用いることが可能です。(アイソモーフィック?)

しかし動かすとなるとサーバーを用意して〜、など色々と難関がありました。その難関を軽く解消してくれたのがAWS Lambdaであることはみなさんご存知のとおりですね。

バリデーションの準備

まずバリデーターライブラリを用意します。

これにはvalidatorを使いました。

github.com

これをラップしたRecordというクラスをつくり、以下のようなコンフィグ(config.jsに入っています)をあたえ、validateメソッドで確認できるようにしました。

exports.config = {
  attributes: ['name', 'email', 'age', 'gender'],
  validation: {
    name: {
      isLength: {min: 1, max: 20, message: '1-20文字で入力してください'}
    },
    email: {
      isEmail: {message: 'メールアドレスを入力してください'}
    },
    age: {
      isNumeric: {message: '数字を入力してください'}
    },
    gender: {
      isIn: ['female', 'male', 'other']
    }
  }
};

ブラウザ側バリデーション

あとはこのバリデーターを、ブラウザ側で使えるようにします。

ライブラリやコンフィグファイルが別ファイルになっているので、毎度おなじみのbrowserifyで連結します。

const Record = require('../lib/src/record').Record;
const config = require('../lib/src/config').config;
const record = new Record(config);

const Validator = window.Validator = {};

Validator.validate = (data) => {
  if (record.assign(data).validate()) {
    return {result: 'success', data: record.parameters};
  } else {
    return {result: 'failure', errors: record.errors};
  }
};

あとはこのバリデーションをサーバーに送信する前に行い、validであれば送信します。

サーバー側バリデーション

送信されてくるデータは全く安全でないのは常識ですから、サーバー側でもバリデーションする必要があります。

ブラウザ側とほぼ同じコードですが、AWS LambdaではNode.jsが動いていますから、browserifyは必要ありません。

今回はバリデーションの成否で終了していますが、そこらへんにdynamoDBやメール送信の処理を書けばいいんじゃないでしょうか。

"use strict";

const Record = require('./src/record').Record;
const config = require('./src/config').config;

exports.handler = (event, context, callback) => {
  let record = new Record(config);

  if (record.assign(event).validate()) {
    context.succeed({result: 'success', data: record.parameters});
  } else {
    context.fail(JSON.stringify({result: 'failure', errors: record.errors}));
  }
};

エラー時の戻り値がブラウザ側と違ってしまうのが気になるところですが(failの引数は{"errorMessage": 引数}JSONとして飛んでいく)、これはAWS Api Gateway側でfailureをキャッチして、同じ形にします。

.*"result":"failure".*で失敗ステータスコードに振り、本文マッピングテンプレートでエラー部を取り出したものをレスポンスとします。

$input.path('$.errorMessage')

ブラウザ側送信時処理

送信せずとも同じバリデーションが行えるわけですから、送信前にバリデーションし、invalidであればdeployErrorsというメソッドが、各項目のinputの上や下にエラーを表示します。

もし何らかの都合でinvalidなデータが送信されても、サーバー側でinvalid判定されて、同じエラーが返ってきてdeployErrorsというメソッドが、各項目のinputの上や下にエラーを表示します。

    function send($form, $thanks) {
      var data = traceValue($form);

      var validated = Validator.validate(data);
      if(validated.result == 'failure'){
        deployErrors($form, validated.errors);
        return;
      }

      disablizeButton($form, true);
      superagent
              .post(endPoint)
              .set('Accept', 'application/json')
              .send(data)
              .end(function (err, res) {
                if (err) {
                  deployErrors($form, res.body.errors);
                  disablizeButton($form, false);
                } else {
                  $form.hide();
                  $thanks.show();
                }
              });
    }

わりと長年の夢であった、サーバーとクライアントで同じコードでバリデーションするというのが、比較的簡単にできてしまいました。しかも課金の心配が結構軽い。

フォーム作成というこまごまとした仕事は、前職前前職ともによくあって、cgiの、メンテされてるんだがされてないんだかわからないスクリプトを使用していました。

Rails大好きなわたしもさすがに一個の問い合わせフォームにRailsというわけにもいかなくて難儀していましたが、これならヤバイ脆弱性も自分の責任のうちで作成できるのでいいかもしれませんね。

HerokuにSymbolic Link入りを投げるとき気をつけること

リソースを再利用しようとしました。

そこでディレクトリに対してln -sで作成しますが、例えば同ディレクトリ内の何がしかへ張る場合、

$ ln -s development staging

とする必要があり

$ ln -s ./development staging

これだと辿れなくなります。

セキュリティかなにかの都合でスラッシュアクセスがブロックされているのでは。同じ理由で上には登れないんじゃないかな(試してない)。

レールズにプルリクエストがマージされてハッピーだった

ので、思いつく限りのSNSやブログに書いています。

ここにも書きます。

どれ

以下の記事のやつを作ってる時に「バグかしら?」と思ってモンキーパッチで回避してて、せっかくだからとpull requestを飛ばしてみたら忘れた頃にマージされたという話です。

qiita.com

Rails5.0.0がつい先日リリースされましたが、その時には自分でも忘れていて、ほん今日、なんか通知が出てるなと思って見てみたらマージされていました。

無職してた甲斐があった

仕事をしないで自分で思うがままにコードを書きつづけていたところで出会ったバグ的なものでしたから、無職でなかったら出会わなかった可能性があります。

これがここのコードが原因で起こってる、っていうのも結局pメソッドとクリッククリックで追いつめて見つけたみたいなもんですから、よっぽど時間に余裕がないと無理だったでしょう。

無職時代のいい思い出になったな〜〜と言いたいところですが、まだ就職先は決まっておりません。

大変ですね。

まぁとにかく嬉しい

好きなフレームワークに貢献できたということで、本当に嬉しいですね。