CakePHP 1.3 で testAction メソッドを便利に使う

CakePHP 1.3 でコントローラをテストするには testAction メソッドが便利。

けれど、コントローラーの中で redirect メソッドが使われていると、テストケースが途中で終了してしまうため、テストができない(redirect メソッドの中で exit() が呼ばれている)。

様々な解決策が模索されてきたのだけれど、一番お手軽だったのが Extended Test Case Plugin を使う方法。Mock Object を使っている。どう動作しているかは Testing controllers the (slightly less) hard way » 42Pixels で紹介されている。

使い方はREADMEを読めばわかるが、注意点をいくつか。

  • cake bake controller してできる class Test<コントローラ名>には var redirectUrl; を追加する。これで、Testing CakePHP Controllers the hard way | Mark Story に書いてある方法でリダイレクトが正しく実施されたかのテストができるようになる。
  • class CakeTestCase を class ExtendedTestCaseに変更する。
  • class ExtendedTestCase の startTest() メソッドの中で、$this->testController にテスト対象のコントローラーを渡す。
  • 別のコントローラーを使ったテストの際は、testActionのURLを変更するだけでは足りない。testActionの前にtestControllerにテストするコントローラを代入しておくこと。

Firefox Add-on Builder で Cookie を扱うには

見ているページの URL を WordPress.com に簡単に投稿するには、WordPress.com Extension :: Add-ons for Firefox が使えるのだけれど、もっとシンプルなものが欲しかったので、Add-on Builder を使って作ってみた (wp it :: Add-ons for Firefox)。

WordPress.com の記事投稿画面を表示するには、

http://<ユーザー名>.wordpress.com/wp-admin/post-new.php?post_type=post&post_title=<記事のタイトル>&content=<記事の内容>

をブラウザーで表示すればよい。
ユーザー名は http://wordpress.com から受信したクッキーから取得できる。そこで、Add-on Builder の中からクッキーを参照する方法を調べてみた。

firefox – read cookies with addon – Stack Overflow によれば、

var {Cc, Ci} = require("chrome");
var cookieSvc = Cc["@mozilla.org/cookieService;1"].getService(Ci.nsICookieService);

とすれば、cookieService を利用できるようになる。

Cookies – MDN によれば、

var ios = Components.classes["@mozilla.org/network/io-service;1"]  
            .getService(Components.interfaces.nsIIOService);  
var uri = ios.newURI("http://www.google.com/", null, null);  
var cookieSvc = Components.classes["@mozilla.org/cookieService;1"]  
                  .getService(Components.interfaces.nsICookieService);  
var cookie = cookieSvc.getCookieString(uri, null); 

とすることで、指定したホストから受信したクッキー文字列を参照できる。

指定した url から受信した、名前が name のクッキーの値を取得する関数 getCookie は以下のようになる。Add-on Builder 上で確認するにはこちらをどうぞ。

const {Cc, Ci} = require("chrome");
const ios = Cc["@mozilla.org/network/io-service;1"].getService(Components.interfaces.nsIIOService);  
const cookieSvc = Cc["@mozilla.org/cookieService;1"].getService(Ci.nsICookieService);

function getCookie(url, name) {
    var cookie_uri = ios.newURI(url, null, null);
    var cookie_string = cookieSvc.getCookieString(cookie_uri, null);
    var cookies = cookie_string.split('; ');
    var key = name + '=',
        i;
    for (i = 0; i < cookies.length; i++) {
        if (cookies[i].lastIndexOf(key, 0) == 0) {
            return cookies[i].replace(key, '');
        }
    }
    return null;
}

Addon Builder を使って Firefox アドオンを作ってみる

Mozilla が提供する Add-on Builder を使って Firefox アドオンを作ってみた。目標とするのは、タブで開いているページのタイトルとURLを手軽に Twitter へツイートできるアドオン。完成したアドオンのソースコードはBuilder上で見ることができる。作ったアドオンは simpletweet という名前をつけて公開した

Add-on Builder は Web ブラウザ単独で Firefox アドオンを作れてしまう Web アプリケーション。オンラインのテキストエディター、ファイルのバージョン管理、作成中のAdd-onの自動再読み込みといった機能を提供してくれる。利用するためには addons.mozilla.org の無料アカウントが必要。ここからアカウントが作成できる。

まずはチュートリアルでBuilderの使い方をを確認。Tutorial – Add-on Builder: にアクセスして「Create Add-on」ボタンを押し、さらに「BrowserID」ボタンを押して、addons.mozilla.org で登録したメールアドレスを使ってログインする。

すると、コメントが付加されてはいるものの、上記チュートリアルと同じコードが main.js として記述された状態となる。このコードは、ブラウザー最下部のアドオンバーに表示された Firefox のアイコンをクリックすると、http://www.mozilla.org を新しいタブで開いてくれるアドオン。目のアイコンを押すだけで、main.js に記述されたアドオンを試すことができる。(先に、開発用のアドオンがインストールされる)。

main.js

require("widget").Widget({
    id: "widgetID1",
    label: "My Mozilla Widget",
    contentURL: "http://www.mozilla.org/favicon.ico", // アイコンのURL。Firefox のアイコンになる。
    onClick: function(event) {  // クリックすると、
        require("tabs").open("http://www.mozilla.org"); // http://mozilla.org を開く。
    }
});

次に、目標とするツイート用アドオンを作成する。参考にするのは、Display a Popup – Add-on SDK Documentation。アドオンをクリックしたときにポップアップを表示して、その中にアクティブなタブのタイトルとURLを表示させる。

今度は、main.js に加えて、get-text.js と text-entry.html の二つのファイルが必要。Builder の左側メニューの Data アイコンにマウスオーバーすると現れるプラスボタンを押して、二つのファイルを作成する。

まずは「Display a Popup」のコードをコピー&ペーストして、上部のフロッピーアイコンを押して保存。すると、自動的に目のアイコンが押された状態となり、アドオンバーに置かれた Firefox アイコンをクリックするとポップアップ画面が表示されるようになる。

次は、アクティブなタブのタイトルとURLを取得して、ポップアップ画面に表示する。
APIリファレンスを眺めてみると activeTab を使えばよさそうだ。しかし、
Content Scripts と呼ばれる get-text.js からは、require(“tabs”); をしても、Tabオブジェクトが取得できない。Add-on Code と呼ばれる main.js でないと取得できないことが Two types of Scriptsに書いてある。

そこで、main.js で取得したタイトルとURLを、Communicating using “port” に書いてある方法を使って get-text.js へ渡すことにする。

main.js

text_entry.on("show", function() {
  var tabs = require("tabs");
  var title = tabs.activeTab.title;
  var url = tabs.activeTab.url;
  var tweet = title + ' ' + url;
  text_entry.port.emit("show", tweet); // ポップアップに表示するデータ tweet を渡す。
});

get-text.js では渡されたデータを、テキストエリアに設定する。

get-text.js

self.port.on("show", function onShow(data) { // データ data を受け取る
  textArea.value = data;
  textArea.focus();
});

あとは、ポップアップ画面での入力が終わったときに、get-text.js から main.js へ入力内容を渡し、main.js 側でサーバへデータを渡す。ここで注意が必要なのは、XMLHttpRequest は使えず、Request を使わなければいけない点。

最後にできあがったアドオンはBuilder上の下向き矢印アイコンを押すことでダウンロードできる。ダウンロードした .xpi ファイルを Firefox へドラッグ&ドロップしてインストールする。

また、Buidler のダッシュボードにアクセスすると、作成したアドオン一覧が表示され、AMO (Add-ons Mozilla Org https://addons.mozilla.org/ の略) への公開も可能。upload to AMO をクリック後、ダッシュボードを再読み込みする。さらに edit on AMO をクリックして、アドオンの説明、アイコンやスクリーンショット、公開する際のライセンスを入力する。

もしアイコンの表示位置を最下部のアドオンバーから変更したい場合は、オプション>ツールバーのカスタマイズ… を選択し、アイコンを表示したい位置へドラッグ&ドロップすればよい。

ちなみに、ダウンロードした .xpi ファイルは拡張子を .zip にすると解凍できる。なんと、100個近くのファイルから構成されている。

simpleauth で Google App Engine を使った oauth 認証

Simple auth on App Engine without passwords で紹介されている simpleauth (Google code) (github) を使って、Twitter、Google、Facebook の oauth 認証を試してみた。simpleauth は Alex Vagin さんが作った Google App Engine / Python2.7用のoauth 認証デモ。GAE の新しい Web フレームワーク webapp2 を活かした設計になっている。

作者が公開しているデモはこちら。Google, Facebook, Yahoo! (OpenID), Twitter, LinkedIn, Windows Liveでの認証ができるようになっている。

下準備として、Twitter でアプリケーションを作成する。
https://dev.twitter.com/apps にアクセスして Create a new application する。
Callback URL に https://simplemash.appspot.com/auth/twitter/callback と入力しておく。URLの最後にスラッシュ (/) を入れないこと。Callback URL を入力しておかないと(コールバックするモードではなく)PINコードを入力するモードになってしまう。
後で使うので、Consumer Key と Consumer Secret を参照できるようにしておく。これらの値が外部に漏れると大変なので厳重に管理すること。

次に Git Bash を起動する。gae-simpleauth を clone した後、example を動かす準備をする。GAE に配備したとき simpleauth ライブラリの参照がうまくいかないので、ちょっと工夫が必要。

$git clone https://code.google.com/p/gae-simpleauth/
$cd gae-simpleauth
$rm example/lib/simpleauth
$cp -r simpleauth/ example/lib/
$cd example
$mv secrets.py.template secrets.py
$vi secrets.py
  SESSION_KEY = 'セッション用のランダムで推測できない文字列を入れる'
  TWITTER_CONSUMER_KEY = 'アプリケーションのConsumer Key'
  TWITTER_CONSUMER_SECRET = 'アプリケーションのConsumer Secret'
$vi app.yaml
  application を Google App Engine のアプリケーションIDに変更。
  version を変えた場合は、Google App Engine のダッシュボードで version を変更するのを忘れずに。

次に Google App Engine Launcher を起動。File>Add exsiting application で \gae-simpleauth\example を追加。
Twitter からのコールバックがあるため、テストサーバでは動かせず、Deployする必要がある。

https://<アプリケーションID>.appspot.com/ にアクセスして「Twitter」ボタンを押すと、Twitterで認証され、/profile ページが表示される。

ちなみに動かしたのは WIndows 7。Windows の vi はコピーが Ctrl + Insert, ペーストが Shift + Insert なことを学んだ。

同様の手順で、Google、Facebook についてもアプリケーションを作成して認証を行う。

Google でアプリケーションを作るには APIs Console にアクセスしてClient ID for web applicationsを取得する。設定でRedirect URIsを https://<アプリケーションID>.appspot.com/auth/google/callback にしておく。secrets.py の GOOGLE_APP_ID, GOOGLE_APP_SECRET に取得した値を入れる。これらの値は厳重に管理すること。

Facebook でアプリケーションを作るには https://developers.facebook.com/apps にアクセスしてFacebookアプリ「Developer」をインストール後、Create New App から作成する。作成の際、携帯電話かクレジットカードで本人認証される。Softbank の iPhone を持っていたので、@softbank.ne.jpのSMSアドレスを入力して本人認証した。secrets.py の FACEBOOK_APP_ID, FACEBOOK_APP_SECRET に取得した値を入れる。これらの値は厳重に管理すること。

Google App Engine の webapp2 で認証してみた

Google App Engine で Python2.7 が使えるようになった。
前のポストで紹介したとおり、チュートリアルで利用される Web アプリケーションフレームワークが webapp2 に変更された。
webapp2 では webapp2_extras を呼び出すことで、セッション管理や国際化といったオプション機能を使うことができる。
Issue 20 – webapp-improved – Sample auth app – Google App Engine’s webapp, take two – Google Project Hosting で紹介されているコードを参考に、認証機能を提供する Auth モジュールの使い方を勉強した。ソースは github で公開している

app.yaml

application: webapp2-auth
version: 1
runtime: python27
api_version: 1
threadsafe: true

handlers:
- url: /.*
  script: handlers.app

handlers.py

import webapp2
from webapp2_extras import auth
from webapp2_extras import sessions
from webapp2_extras.auth import InvalidAuthIdError
from webapp2_extras.auth import InvalidPasswordError

authsessions をインポートして利用する。

def user_required(handler):
    """
         Decorator for checking if there's a user associated with the current session.
         Will also fail if there's no session present.
     """
    def check_login(self, *args, **kwargs):
        auth = self.auth
        if not auth.get_user_by_session():
            # If handler has no login_url specified invoke a 403 error
            try:
                self.redirect(self.auth_config['login_url'], abort=True)
            except (AttributeError, KeyError), e:
                self.abort(403)
        else:
            return handler(self, *args, **kwargs)
    return check_login

user_required はauth.get_user_by_session()を使って、現在のsessionに関連付けられたuserがいるかどうかをチェックするデコレーター。
userがいなければwebapp2.Route(name=’login’)で定義されたログイン画面へ遷移する。もしログイン画面が設定されていなければ、403エラーを表示する。
デコレーターについては Life is beautiful: Python入門:デコレータとは を参照のこと。認証を必要とする処理の前に@user_requiredと記述するだけで、認証を要求できる。

class BaseHandler(webapp2.RequestHandler):
    """
         BaseHandler for all requests
         Holds the auth and session properties so they are reachable for all requests
     """
    def dispatch(self):
        """
              Save the sessions for preservation across requests
          """
        try:
            response = super(BaseHandler, self).dispatch()
            self.response.write(response)
        finally:
            self.session_store.save_sessions(self.response)

    @webapp2.cached_property
    def auth(self):
        return auth.get_auth()

    @webapp2.cached_property
    def session_store(self):
        return sessions.get_store(request=self.request)

    @webapp2.cached_property
    def auth_config(self):
        """
              Dict to hold urls for login/logout
          """
        return {
            'login_url': self.uri_for('login'),
            'logout_url': self.uri_for('logout')
        }

BaseHandlerは全てレスポンスをsessionに保持する。それ以外にも、auth と session へ簡便にアクセスするためのメソッドを定義する。

class LoginHandler(BaseHandler):
    def get(self):
        """
              Returns a simple HTML form for login
          """
        return """
            <!DOCTYPE hml>
            <html>
                <head>
                    <title>webapp2 auth example</title>
                </head>
                <body>
                <form action="%s" method="post">
                    <fieldset>
                        <legend>Login form</legend>
                        <label>Username <input type="text" name="username" placeholder="Your username" /></label>
                        <label>Password <input type="password" name="password" placeholder="Your password" /></label>
                        </fieldset>
                        <button>Login</button>
                 </form>
            </html>
        """ % self.request.url

    def post(self):
        """
              username: Get the username from POST dict
              password: Get the password from POST dict
          """
        username = self.request.POST.get('username')
        password = self.request.POST.get('password')
        # Try to login user with password
        # Raises InvalidAuthIdError if user is not found
        # Raises InvalidPasswordError if provided password doesn't match with specified user
        try:
            self.auth.get_user_by_password(username, password)
            self.redirect('/secure/')
        except (InvalidAuthIdError, InvalidPasswordError), e:
            # Returns error message to self.response.write in the BaseHandler.dispatcher
            # return e
            return """
                Could not login. <a href="/login/">retry</a>
            """

/login/にアクセスしたとき、ログイン画面を表示する。
ログイン画面で入力されたユーザー名とパスワードを auth.get_user_by_password() を使って認証し、エラーがでたらログインできなかったことを表示する。

class CreateUserHandler(BaseHandler):
    def get(self):
        """
              Returns a simple HTML form for create a new user
          """
        return """
            <!DOCTYPE hml>
            <html>
                <head>
                    <title>webapp2 auth example</title>
                </head>
                <body>
                <form action="%s" method="post">
                    <fieldset>
                        <legend>Create user form</legend>
                        <label>Username <input type="text" name="username" placeholder="Your username" /></label>
                        <label>Password <input type="password" name="password" placeholder="Your password" /></label>
                    </fieldset>
                    <button>Create user</button>
                </form>
            </html>
        """ % self.request.url

    def post(self):
        """
              username: Get the username from POST dict
              password: Get the password from POST dict
          """
        username = self.request.POST.get('username')
        password = self.request.POST.get('password')
        # Passing password_raw=password so password will be hashed
        # Returns a tuple, where first value is BOOL. If True ok, If False no new user is created
        user = self.auth.store.user_model.create_user(username, password_raw=password)
        if not user[0]: #user is a tuple
            return user[1] # Error message
        else:
            # User is created, let's try redirecting to login page
            try:
                self.redirect(self.auth_config['login_url'], abort=True)
            except (AttributeError, KeyError), e:
                self.abort(403)

ユーザー追加画面を表示する。
ユーザー追加画面で auth.store.user_model.create_user() を呼び出し、ユーザーを生成できれば、ログイン画面へ遷移する。生成できなければ403エラーを表示する。

class LogoutHandler(BaseHandler):
    """
         Destroy user session and redirect to login
     """

    def get(self):
        self.auth.unset_session()
        # User is logged out, let's try redirecting to login page
        try:
            self.redirect(self.auth_config['login_url'])
        except (AttributeError, KeyError), e:
            return "User is logged out"

/logout/にアクセスしたとき、auth.unset_session() を呼び出し、現在のsessionとユーザーの関連付けを外す。

class SecureRequestHandler(BaseHandler):
    """
         Only accessible to users that are logged in
     """

    @user_required
    def get(self, **kwargs):
        user = self.auth.get_user_by_session()
        try:
            return "Secure zone for %s Logout" % (str(user), self.auth_config['logout_url'])
        except (AttributeError, KeyError), e:
            return "Secure zone"

/secure/にアクセスしたとき、デコレーター user_required() でログイン済みかチェックした後、ログインしているユーザーの情報を表示する。

webapp2_config = {}
webapp2_config['webapp2_extras.sessions'] = {
		'secret_key': 'Set_this_to_something_random_and_unguessable',
	}

app = webapp2.WSGIApplication([
		webapp2.Route(r'/login/', handler=LoginHandler, name='login'),
		webapp2.Route(r'/logout/', handler=LogoutHandler, name='logout'),
		webapp2.Route(r'/secure/', handler=SecureRequestHandler, name='secure'),
		webapp2.Route(r'/create/', handler=CreateUserHandler, name='create-user')
	], debug=True, config=webapp2_config)

sessions の secret_key は推測されないものを指定しなければならない。
/login/ にアクセスしたとき、ログイン画面を表示する。
/logout/ にアクセスしたとき、ユーザーをログアウトさせ、ログイン画面に遷移する。
/secure/ にアクセスしたとき、認証を要求する。
/create/ にアクセスしたとき、ユーザー追加画面を表示する。

Google App Engine Launcher を起動して、localhost:8080/create/ にアクセスしてユーザーを追加。ログイン画面に遷移するのでログイン。その後 localhost:8080/secure/ にアクセスすると、ユーザー情報が表示される。
http://localhost:8083/_ah/admin/ にアクセスして Datastore Viewer を使うと、User が保存されていることを確認できる。