ITの窓辺から

三流IT技術者の日常

DjangoでLDAP認証 その7 機能のテスト

前回でとりあえずDjangoの認証にActive Directoryを使用したLDAP認証を実装したのでした。

realizeznsg.hatenablog.com

今回はこれらの認証におけるテストを行います。はじめはPythonのunittestで諸々やろうとしていたのですが、調べるとDjangoにunittestを拡張した独自実装があるようだったのでこちらを使ってみることにします。通常のunittestに加えてDjangoのテストを行うための便利機能が多数ついているようなので、思っていたよりは楽にテストが書けそうです。正直、標準のunittestの場合、テストのテストコードがいるなと思っていたレベルです。しかし、実際のDjangoのテスト機能はあくまでプロジェクト内のコードの動作正常性を担保するためのもののようなので、Webサーバ等の関連要素を含めたテストを行う場合は別のテスト機能を使用した方が良いように思われます。

ファイル名とテスト用クラス内のテストメソッドに「test」という文字列が入っていると自動的にテスト対象として収集されるようです。テストコードが増えてきたら、test用のディレクトリを作ってその中に複数のテストコードファイルを作成するのがとりあえず普通のやり方と思われます。

いつもの注意書きですが、この記事はWebアプリケーションとDjangoの初心者が調べたことをメモするものであり、正しい解説記事を目的としていないのでご注意ください。

環境は以下のとおりです。
OS : macOS(intel CPU) Big Sur
Python : バージョン3.8.5 (venv環境)
Django : バージョン3.2.5
Webサーバ : Djangoに組み込みのもの
ldap3 : バージョン2.9.1
LDAPサーバ : Active Directory (Windows Server2016)

テスト設計

ホワイトボックステストが難しい

少し調べたところ、Djangoで用意されているテストツールだけで認証バックエンド自体の挙動をテストすることは少し面倒そうでした。というのも、認証バックエンドのコードが正しく動作することを確認するためには、(少なくとも今回のコードにおいては)テスト実行時にテスト用のユーザをLDAPサーバ側に作成する必要があるからです。

もちろん究極的にはテスト実行は問題ないはずですが、そもそもこの汎用的なテストコードからLDAPサーバに接続してアカウントを作成したりアカウントの設定を変更するにはLDAP BINDに使用するユーザにかなりの権限を与える必要があり、セキュリティ的な観点からは全くやる気にならないです。Active Directoryならドメイン管理者かアカウント管理者相当の権限が必要です。また、Django内のユーザーモデルのデータベースがテストのたびに汚れることも個人的には少し気になります。要するにホワイトボックステストがやりづらいです。

もしこのあたりのテスト自動化も行うなら、アカウント管理者相当の権限を付与し、このアカウントがLDAPサーバにアクセスできる方法を限定した上で、このテストの実行を許可制かつ監査ログに操作内容を明確に記録し、所定以外の操作が行われた時はアラートを出すようにしておくべきかなと思いました。

今回のテスト概要

アカウント操作をPythonから行うのはLDAPとLDAP3モジュールの話が中心になり、Djangoとはあまり関係ない話になるので、この記事では基本的にブラックボックステストによる動作確認とします。また、Selenium等を使ったクライアントサイドからのテストは行いません。

具体的には、事前にDjango利用のためのセキュリティグループを振ったテストアカウントをLDAPサーバに事前に作成しておきます。Djangoパーミッションも使用しているのでこれに応じたアカウントも用意します。そのアカウントとパスワードをテストコードに静的に入力して認証を行い、想定したデータ/ページを返すかテストするイメージです。

事前の動作検証

Clientオブジェクトのloginメソッド

Djangoに用意されているdjang > testモジュールにClientというクラスがあります。このクラスのメソッドにloginというものがあるようで、このメソッドにユーザ情報を渡すことで認証動作を発生させることができるようです。このメソッドは認証に成功するとTrueを返すとのことです。

公式のドキュメントにはあまり詳細な説明がなくて動作の理解に苦慮したので、諸々動作確認した結果、私が勝手に想像していた動作とは異なることがわかりました。具体的には、loginメソッドによる認証動作を行うことで認証状態の維持に必要なクッキーを取得、その状態でClientオブジェクトのgetメソッドを使用することで要認証ページのHTTPレスポンスを取得、というのを想像していました。

とりあえず2個の誤解がありました。
1つ目は、要認証としているビューが返すHTTPレスポンスは、認証済み状態であってもHTTP302を返すことです。このためassertEqualを使用して要認証ページのアクセス結果がHTTPステータスコード200であることを確認できません。リダイレクトを使用しているページ用に、assertRedirectsもありますが、どうもこれが認証ページだと正しく動かず、assertRedirects内で使用するターゲットURLが期待したものになりません。

これらから予想するに、loginメソッドは使用したクレデンシャル及び認証バックエンドで認証が成功するかどうかを確認するためのもので、その後のテストを対象としたものではないのではないか、というものです。

つまりテスト方針としては、

  • 認証成功パターンのビューテストケース
    • 認可制御の各パターンのビューテストケース
  • 認証失敗パターンのビューテストケース

というものを考えることになると思われます。認証成功パターンではloginメソッドの返り値がTrue、認証失敗パターンでは戻り値がFalseになることが正解になるようなテストを書きます。このチェックにはassertTrueとassertFalseを使用するのが良さそうです。さらに、認証成功パターンでは認可制御によってテストが枝分かれします。

と書いてみたものの、イマイチ腑に落ちないんですよね・・・。loginメソッドでログインした後に諸々のビューの動作テストが行えるほうが個人的には直感的です。もし有識者の方が見ていたら実際のところを教えて頂けると幸いです。

Clientオブジェクトのforce_loginメソッド

loginメソッドが単純に認証可否の動作をすることに対して、指定したユーザクレデンシャルで認証成功したことにして動作を確認するforce_loginメソッドというものがあるようです。これを使って要認証ページのHTTPレスポンスを見たところ、無事に意図した内容になっていました。実際にWebブラウザでアクセスすると前述の通りHTTP302が返るのですが、force_loginを使用した場合はこの302はスキップされるようです。

loginメソッドが引数にクレデンシャルをキーワード引数として自由に指定可能になっていることに対して、force_loginでは引数がユーザーモデルになっています。つまり、force_login実行時にはDjango内にアカウントが作成済みである必要があるということか・・・?

loginとforce_loginの検証

force_loginメソッドをテストコードのほぼはじめに実行する内容で試しまして、既にDjango内に作成してあるユーザモデルのオブジェクトを使用すると「User matching query does not exitst.」エラーが返りました。

次にloginメソッドで認証を行った後、同じClientオブジェクトとloginメソッドで使用したユーザ名でユーザーモデルから標準マネージャで取得したユーザーモデルオブジェクトでforce_loginメソッドを使ったところ、エラーは表示されませんでした。

実はこれまでインターネットで読んでいたDjangoのテストコードの動作の説明では、実際のテーブルにデータを書き込みしてロールバックするのが基本動作だと書いてあったような気がしておりとても困惑しています。ユーザーモデルだけは例外なのか、そもそも認識が間違っているのか・・・・。

logoutメソッドを使用した後にforce_loginしてもエラーは表示されないのでClientオブジェクトの状態の問題ではなく、テスト動作中に使用されるユーザーモデルの状態に原因がありそうです。

loginメソッドで使用するアカウントがどこで作成済みであるかによってforce_loginメソッドの結果がどのように変わるか調べたところ、結果は以下の通りでした。尚、両方のメソッドで同じアカウント名を使用しています。

LDAP Django 結果
アカウント有 アカウント有 成功
アカウント有 アカウント無 成功
アカウント無 アカウント有 失敗
アカウント無 アカウント無 失敗

今度はloginメソッドとforce_loginメソッドで使用するアカウント名を変えてみました。LDAP側にアカウントがない場合は前述の通り絶対に失敗するので、loginメソッドで使用するアカウントはLDAPに作成済みのパターンのみ試します。

login force_login 結果
アカウントA LDAP上のみにあるアカウントB 失敗
アカウントA LDAPDjangoにあるアカウントB 失敗
アカウントA Django上のみにあるアカウントB 失敗
アカウントA LDAPDjangoどちらにもないアカウントB 失敗

これらの結果から、テストの時に仮のユーザーモデル用のテーブルが出来上がっており、loginメソッドによって実行される認証バックエンドにより仮テーブルにその時使用されたユーザーが登録され、force_loginではそのユーザーモデルのみ使用できる、という動作をする可能性が高そうです。

尚、今回は認証バックエンドをカスタマイズしており、認証バックエンド内にユーザーモデルにユーザ情報を登録する処理があります。標準認証アプリではユーザ登録処理はないので、テストの中でユーザ登録処理が必要になりそうです。調べてみると、標準マネージャのcreate_userというメソッドを使うことで実現できそうです。試していませんが。

とりあえず今回は調査はここまでにして、ユーザーモデルに関してはテスト実行時に特別なテーブルが作成される説を仮採用して先に進みたいと思います。いずれ全貌が明らかになることを期待して・・・。

実際のテストコード

かなり事前調査が長くなりましたが、それに反して実際のテストコードはシンプルです(単純にビューが少なく機能も少ないから当然なのですが)。大体以下のようなイメージです。認証後の実際の動作テストにはステータスコード、内容、テンプレート使用等必要なものを書きます。

UserModel = get_user_model()

class PageViewByLdapAuthTest(Testcase):
    def setUp(self):
        test_client_1 = Client()
        test_client_2 = Client()
        ....
        #認証成功パターン
        assertTrue(test_client_1.login(username='USER01', password='PASS1')
        assertTrue(test_client_2.login(username='USER02', password='PASS2')

        #認証失敗パターン
        assertFalse(test_client_3.login(username='USER03', password='PASS3')

    def test_auth_valid_page_view(self):
        #認証成功パターンのテスト
        test_client_1.force_login(UserModel.objects.get(username='USER01'))
        response1 = test_client_1.get('VIEW PATH')
        assertContains(〜〜〜〜〜〜〜)

    def test_auth_invalid_page_view(self):
        #認証失敗パターンのテスト (内容は省略)

アカウント情報の食わせ方は工夫の余地があると思いますのでセキュリティやその後の運用に配慮するのが良さそうです。コード内にLDAPサーバに存在するアカウントのパスワード直書きはやばいのでご注意下さい。

終わりに

今回のテストコードは調査に本当に時間がかかりました。特にテストコードにおけるテーブルの取り扱いが混乱のもとになっています。DjangoLDAP認証を行う構成では、多分私以外の初学者も大体同じところで引っかかるのではないだろうか・・・と自分を擁護してみます。もしそういう人がいてこの記事にたどり着けた場合は考え方の補強の一助になれば良いなと思うところです。

DjangoLDAP認証に関する一連の記事は今回で終了です。前回の記事でも書きましたが、認証以外のところでも相当理解が甘かった部分が判明したり、新たな疑問が出てきたところが多数あったりもするので、引き続き確認した内容をまとめていきたいと思います。