Flaskとdynamodb_localでDB認証とセッション管理を試してみる(テストくどめ、Selenium入り)

# はじめに
PythonのWebアプリのフレームワークのFlaskとなります。
dynamodb_localを利用し、DB認証とセッション管理のサンプル実装となります。

各種バージョンは以下のとおりです。
Python 3.9.6
Flask 2.0.2
Flask-Login 0.5.0
Flask-Sessionstore 0.4.5
pynamodb 5.2.0
pytest             6.2.5
pytest-flask       1.2.0
selenium           4.1.0


ソースは[github](https://github.com/small-java-world/flask_login_sample)に登録しております。

# サンプル概要

## ファイル構成(github)
ファイル構成(github)は以下のとおりです。

```
プロジェクトルート
├─login_app
│  │─models
│  │  │─sessions.py
│  │  │─users.py
│  │
│  │─templates
│  │  │─login.html
│  │  │─top.html
│  │
│  │─views
│  │  │─__init__.py
│  │  │─common.py
│  │  │─user_auth.py
│  │
│  │─__init__.py
│  │─config.py
│  │─init_db.py
│  │─server.py
│  │─user_loader.py
│ 
├─tests
│  │─__init__.py
│  │─login_app_selenium_test.py
│  │─login_app_test.py
│  │─login_app_unit_test.py
│  │─test_common.py
```

## ファイル構成(github未登録)

プロジェクトルート
├─login_app
│  │─dynamodb_local_latest
├─ chromedriver.exe


dynamodb_local_latestフォルダには
[dynamodb_local_latest](https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/DynamoDBLocal.DownloadingAndRunning.html)からダウンロードし解凍したファイルを格納してください。

dynamodb_local_latestの中身は以下のようなイメージとなります。
![dynamodb_local_latest.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/102138/05a6c79c-68f9-8015-2391-ad6c83f3ffcb.png)


プロジェクトルート直下に
[chromedriver](https://chromedriver.chromium.org/downloads)
からダウンロードしたchromedriver.exeを配置してください。

chromedriver.exeのバージョンは、ご利用されているchromeのバージョンと同じ物を選択してください。


## 実際の動作イメージ
ルートにアクセスすると「Log In」リンクがあり、クリックするとログイン画面に遷移し、ログインに成功するとルートにリダイレクトされ、「Log In」リンクは表示されず、「Log Out」リンクが表示され、「Log Out」リンクをクリックするとログアウトし、ログイン画面にリダイレクトます。

あとは、ログインしているときにuser_idを指定し、ユーザ詳細(全然詳細じゃないですが)が見える画面を実装しております。
``http://localhost:5000/users/detail/user_id_1``のようにアクセスします。
ログインしていない場合はログイン画面にリダイレクトます。


![loginapp2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/102138/26a61cd7-28b7-3f87-b4b4-1535a4bce188.gif)

# dynamodb_localの起動方法
Java ランタイム環境 (JRE) 6.x 以降のバージョンが必要です。

dynamodb_local_latestに移動後し、カレントディレクトリがdynamodb_local_latestを前提としてたコマンド例とまります。

## アプリ動作用のDB起動
``java "-Djava.library.path=./DynamoDBLocal_lib" -jar DynamoDBLocal.jar -sharedDb``
を実行します。

以下のように出力されてDBが起動します。

```
Initializing DynamoDB Local with the following configuration:
Port:   8000
InMemory:       false
DbPath: null
SharedDb:       true
shouldDelayTransientStatuses:   false
CorsParams:     *
```

## テスト用のDB起動

テスト実行時に利用するDBをポート:9000で起動します。2つDBを起動しなくても問題ないのですが、config.pyで定義した設定が切り替わる事を確認できるように、との意図となります。


``java "-Djava.library.path=./DynamoDBLocal_lib" -jar DynamoDBLocal.jar -sharedDb -port 9000``
を実行します。

# サンプルの説明

## config.py

各種設定を含んだモジュールファイル
dummyやDUMMYを含む設定は、dynamodb_localでは利用しないので適当な値をセットしてください。
必要な設定は``DYNAMODB_ENDPOINT_URL ``、``SESSION_DYNAMODB_ENDPOINT_URL ``、``SESSION_TYPE ``、``SESSION_DYNAMODB_TABLE``となります。

```python:config.py
import os


class Config(object):
    DEBUG = True

    # AWS_ACCESS_KEY_ID ローカルなのでなんでもOK
    AWS_ACCESS_KEY_ID = "AWS_ACCESS_KEY_ID_DUMMY"
    # AWS_SECRET_ACCESS_KEY ローカルなのでなんでもOK
    AWS_SECRET_ACCESS_KEY = "AWS_SECRET_ACCESS_KEY_DUMMY"

    SECRET_KEY = "secret key dummy"
    USERNAME = "dummy_user"
    PASSWORD = "dummy_pass"

    # session情報をdynamodbに保存
    SESSION_TYPE = "dynamodb"
    SESSION_DYNAMODB_TABLE = "sessions"


class DevConfig(Config):
    # dynamodb_localのURL
    DYNAMODB_ENDPOINT_URL = "http://localhost:8000"

    # この設定ないとAWSに接続しにいく
    SESSION_DYNAMODB_ENDPOINT_URL = DYNAMODB_ENDPOINT_URL


class TestConfig(Config):
    # dynamodb_localのURL
    DYNAMODB_ENDPOINT_URL = "http://localhost:9000"

    # この設定ないとAWSに接続しにいく
    SESSION_DYNAMODB_ENDPOINT_URL = DYNAMODB_ENDPOINT_URL


class DevDbInitConfig(DevConfig):
    # sessionを利用しない
    SESSION_TYPE = ""


class TestDbInitConfig(TestConfig):
    # sessionを利用しない
    SESSION_TYPE = ""

```


必要な設定に指定する情報は以下のとおりです。

| 設定対象 |  説明  |
| ---- | ---- |
|  DYNAMODB_ENDPOINT_URL  |  dynamodb_localにアクセスするためのURL  |
|  SESSION_DYNAMODB_ENDPOINT_URL  |  dynamodb_local(session情報格納DB)にアクセスするためのURL  |
|  SESSION_TYPE  |  session情報格納の保存タイプで、null:利用しない、redis、 memcached、filesystem、mongodb、sqlalchemyが選択可能|
|  SESSION_DYNAMODB_TABLE  |  session情報を格納するテーブル名|



## login_appの__init__.py

```__init__.py

import os
from flask import session
from flask import Flask
from flask_login import LoginManager
from flask_sessionstore import Session
from logging import getLogger, StreamHandler, DEBUG

logger = getLogger(__name__)
handler = StreamHandler()
handler.setLevel(DEBUG)
logger.setLevel(DEBUG)
logger.addHandler(handler)
logger.propagate = False


app = Flask(__name__, instance_relative_config=True)

# 各種設定の読み込み
try:
    run_flag = os.environ["RUN_FLAG"]
except KeyError:
    run_flag = "1"

config_name = "login_app.config.DevConfig"

"""
RuntimeError: The table sessions does not exist in DynamoDB for the requested region・・・
と怒られるのでmanage_db.pyの呼び出しの時はDevDbInitConfig or TestDbInitConfigにして
SESSION_TYPE = ""としセッション機能を無効にする。
"""
if run_flag == "1":
    config_name = "login_app.config.DevConfig"
elif run_flag == "2":
    config_name = "login_app.config.TestConfig"
elif run_flag == "3":
    config_name = "login_app.config.DevDbInitConfig"
elif run_flag == "4":
    config_name = "login_app.config.TestDbInitConfig"

logger.debug(f"run_flag={run_flag} config_name={config_name}")

app.config.from_object(config_name)

# user_auth, commonをimportしないとルーティングされない
from login_app.views import user_auth, common

# セッション管理を有効化
Session(app)

# flask_loginの初期化
login_manager = LoginManager()
login_manager.init_app(app)

from login_app.user_loader import setup_auth

# userをロードするためのcallbackを定義
setup_auth(login_manager)

# @login_requiredが付与されたurlにアクセスし、未ログインの場合はここにリダイレクトされる。
login_manager.login_view = "login"
login_manager.login_message = "ログインしてください"

```

### 環境変数のRUN_FLAGとconfig.py
コメントに記載しているとおりなのですが、``SESSION_DYNAMODB_TABLE``で指定しているsessionsテーブルが存在しない場合、appに対するsession管理が有効化できません。既にsessionsテーブルが作成されているDBであれば問題ないのですが、DBの初期化時もappを利用するので、DBの初期化時はsession管理を無効にする必要があります。

環境変数のRUN_FLAGでconfig.pyで定義した設定の読み込みを分岐しています。

#### RUN_FLAGの値で利用DBを分岐
- RUN_FLAG=1 or RUN_FLAG = 3の時
8000で動作しているdynamodb_localを利用します。

- RUN_FLAG=2 or RUN_FLAG = 4の時
9000で動作しているdynamodb_localを利用します。

#### RUN_FLAGの値でsession管理の有効・無効を分岐
- RUN_FLAG=1 or RUN_FLAG = 2の時
SESSION_TYPE = "dynamodb"となるのでsession管理は有効

- RUN_FLAG=3 or RUN_FLAG = 4の時
SESSION_TYPE = ""となるのでsession管理は無効

## login_app.viewsのcommon.py

``/``に対する処理
404になるパスの処理
``/favicon.ico``の処理
を実装しています。

### common.py
```python:common.py
import os
from flask import redirect, url_for, render_template, send_from_directory
from login_app import app

from logging import getLogger, StreamHandler, DEBUG

logger = getLogger(__name__)
handler = StreamHandler()
handler.setLevel(DEBUG)
logger.setLevel(DEBUG)
logger.addHandler(handler)
logger.propagate = False

# parameter defaults to ``["GET"]``. ``HEAD`` and
#        ``OPTIONS`` are added automatically.
# だそうです。
@app.route("/")
def top():
    logger.debug("start top")
    return render_template("top.html")


@app.errorhandler(404)
def non_existant_route(error):
    logger.debug("start non_existant_route")
    # 404のときはloginにリダイレクト
    return redirect(url_for("login"))


@app.route("/favicon.ico")
def favicon():
    return send_from_directory(
        os.path.join(app.root_path, "static/img"),
        "favicon.ico",
    )

```

``/favicon.ico``は必要な処理ではないのですが、ブラウザからアクセスすると
``127.0.0.1 - - [29/Jan/2022 11:55:46] "GET /favicon.ico HTTP/1.1" 302 -``]
のログが出力され気持ち悪いので追加しております。

### top.html

current_userはログインしているユーザの情報となります。
詳細は[login_appのuser_loader.py](#login_appのuser_loaderpy)   
で説明させていたきます。

```html:top.html
<ul>
    {% if current_user.is_authenticated %}
    <li><a href="{{ url_for('logout') }}">Log Out</a></li>
    {% else %}
    <li>You are not logined. <a href="{{ url_for('login') }}">Log In</a></li>
    {% endif %}
</ul>
```

### login.html

シンプルなログイン画面です。

```html:login.html
<form action="{{ url_for('login') }}" method=post>
    <div>
        <label for="InputUserId">ユーザーID</label>
        <input type="text" id="InputUserId" name=user_id>
    </div>

    <div>
        <label for="InputPassword">パスワード</label>
        <input type="password" id="InputPassword" name=password>
    </div>

    <button type="submit" id="LoginButton">Login Button</button>

    {% for message in get_flashed_messages() %}
        <div role="alert">
            {{ message }}
        </div>
    {% endfor %}
</form>
```

## login_app.viewsのuser_auth.py

``/login``に対する``GET``, ``POST``の処理を
``/logout``に対する処理
``/users/detail/<user_id>"``に対する処理
を実装しています。

```python:user_auth.py
from flask import request, redirect, url_for, render_template, flash, session
from flask_login import login_user, logout_user, login_required

from logging import getLogger, StreamHandler, DEBUG
from pynamodb.exceptions import DoesNotExist
from login_app import app


logger = getLogger(__name__)
handler = StreamHandler()
handler.setLevel(DEBUG)
logger.setLevel(DEBUG)
logger.addHandler(handler)
logger.propagate = False


@app.route("/login", methods=["GET", "POST"])
def login():
    logger.debug("login start")

    # 以前にflashした値をクリア
    session.pop("_flashes", None)

    if request.method == "POST":
        user_id = request.form["user_id"]
        password = request.form["password"]

        if user_id:
            try:
                from login_app.models.users import User

                user = User.get(user_id)

                if user.password != password:
                    logger.error(f"パスワードが一致しません。")

                    flash("ユーザーIDもしくはパスワードが不正です。")
                else:
                    logger.debug(f"login成功 id={user.id}")

                    login_user(user)

                    logger.debug(f"call login_user end")

                    flash("ログインしました。")

                    return redirect(url_for("top"))
            except DoesNotExist:
                flash("ユーザーIDもしくはパスワードが不正です。")
                logger.error(f"ユーザが存在しません。user_id={user_id}")
        else:
            flash("ユーザーIDが指定されていません。")

    return render_template("login.html")


@app.route("/logout")
def logout():
    logger.debug("logout start")

    logout_user()
    flash("ログアウトしました。")
    return redirect(url_for("login"))


@app.route("/users/detail/<user_id>", methods=["GET"])
@login_required
def user_detail(user_id):
    logger.debug(f"user_detail user_id={user_id} start")

    try:
        from login_app.models.users import User

        user = User.get(user_id)
        return render_template("user_detail.html", user=user)

    except DoesNotExist:
        flash("ユーザが存在しません。")
        logger.error(f"user_detail ユーザが存在しません。user_id={user_id}")

        return redirect(url_for("top"))
```

### loginメソッド
#### httpのメソッドがPOSTの場合
    
``user_id = request.form["user_id"]``
``password = request.form["password"]``
でリクエストからパラメタを取り出して

``user = User.get(user_id)``
でDBからuser_idに対応するユーザを検索し

```python
                if user.password != password:
                    logger.error(f"パスワードが一致しません。")

                    flash("ユーザーIDもしくはパスワードが不正です。")
                else:
                    logger.debug(f"login成功 id={user.id}")

                    login_user(user)
                    flash("ログインしました。")

                    return redirect(url_for("top"))
```
でパスワードが一致しない場合はtop(アプリケーションルート)にリダイレクトします。

user_idに対応するユーザがDBに存在しない場合は、DoesNotExistがスローされ、
``return render_template("login.html")``に処理が流れるので、login画面が「ユーザーIDもしくはパスワードが不正です。」のメッセージ付きで表示されます。

#### httpのメソッドがGETの場合

``return render_template("login.html")``
でlogin画面が表示されます

## user_detail

GETのパスで指定されたuser_idでUserを検索し、
user_detail.htmlを表示するときのパラメタでuserを指定しているので、user_detail.htmlから``{{ user.id }}``のように参照可能となります。
また、``@login_required``が付与されているので、ログインしていない場合は、``login_manager.login_view = "login"``で定義したloginにリダイレクトされます。

```python
@app.route("/users/detail/<user_id>", methods=["GET"])
@login_required
def user_detail(user_id):
    logger.debug(f"user_detail user_id={user_id} start")

    try:
        from login_app.models.users import User

        user = User.get(user_id)
        return render_template("user_detail.html", user=user)

    except DoesNotExist:
        flash("ユーザが存在しません。")
        logger.error(f"user_detail ユーザが存在しません。user_id={user_id}")

        return redirect(url_for("top"))
```

```html:user_detail.html
<h2>id : {{ user.id }} mame : {{ user.name }}</h2>
```

## login_appのuser_loader.py

認証されいるユーザーの情報をロードする処理を実施します。

テンプレートのhtmlでcurrent_userを利用すると``@login_manager.user_loader``が付与されている関数が呼び出され、ログインしているユーザ情報返却されるのでテンプレートから参照可能となります。

```
from logging import getLogger, StreamHandler, DEBUG

logger = getLogger(__name__)
handler = StreamHandler()
handler.setLevel(DEBUG)
logger.setLevel(DEBUG)
logger.addHandler(handler)
logger.propagate = False


def setup_auth(login_manager):
    @login_manager.user_loader
    def load_user(user_id):
        logger.debug(f"load_user user_id={user_id}")
        from login_app.models.users import User

        return User(user_id)
```


## modelsのsessions.py

テーブル名やリージョンなどをconfigから読み込んでセットします。
テーブルの属性としてSessionIdとSessionをそれぞれ文字列として定義しています。
SessionIdがプライマリキーとなります。

```python:sessions.py
from datetime import datetime
from pynamodb.models import Model
from pynamodb.attributes import UnicodeAttribute, NumberAttribute, UTCDateTimeAttribute
from login_app import app

class Session(Model):
    class Meta:
        table_name = app.config.get("SESSION_DYNAMODB_TABLE")
        region = app.config.get("DYNAMODB_REGION")
        aws_access_key_id = app.config.get("AWS_ACCESS_KEY_ID")
        aws_secret_access_key = app.config.get("AWS_SECRET_ACCESS_KEY")
        host = app.config.get("DYNAMODB_ENDPOINT_URL")

    SessionId = UnicodeAttribute(hash_key=True, null=False)
    Session = UnicodeAttribute(null=True)

```


## modelsのusers.py

``class Meta:``ですが、table_nameは直で指定している以外はsessions.pyと同様となります。

テーブルの属性としてid、password、hostをそれぞれ文字列として定義しています。
idがプライマリキーとなります。

```python:users.py
from pynamodb.models import Model
from pynamodb.attributes import UnicodeAttribute
from login_app import app


class User(Model):
    class Meta:
        table_name = "users"
        region = app.config.get("DYNAMODB_REGION")
        aws_access_key_id = app.config.get("AWS_ACCESS_KEY_ID")
        aws_secret_access_key = app.config.get("AWS_SECRET_ACCESS_KEY")
        host = app.config.get("DYNAMODB_ENDPOINT_URL")

    id = UnicodeAttribute(hash_key=True, null=False)
    password = UnicodeAttribute(null=False)
    name = UnicodeAttribute(null=False)

    def get_id(self):
        return self.id

    def is_authenticated(self):
        return True

    def is_active(self):
        return True

    def is_anonymous(self):
        return False
```

| 設定対象 |  説明  |
| ---- | ---- |
|  get_id()|  ユーザを一意に識別できる文字列を返却するように実装します。idをintで管理している場合では文字列に変換して返却します。user_loaderに渡す値はこのメソッドから取得されます。|
|  is_authenticated()| ユーザが認証済み(ログインしているか)の場合はTrueを返却します。@login_requiredが付与されたURLにアクセスする時に呼び出されます。|
|  is_active() | このサンプルでは意味ないですが、どこかのサイトにアカウントを新規登録して、メール認証やSMS認証が完了していないようなユーザはFalseを返却するように実装します。|
|  is_anonymous()|  anonymousユーザの場合はTrueを返却します。 |


## init_db.py
DBの初期化を行うモジュールとなります。
sessionsテーブルとusersテーブルの作成とusersの初期データの登録を行います。

プロジェクトルートに移動後に、Windowsの場合は
8000ポートのDB利用時は``set RUN_FLAG=3``
9000ポートのDB利用時は``set RUN_FLAG=4``
を実行後にinit_db.pyを実行してください。
``python login_app/init_db.py``

RUN_FLAGの値は
1:8000ポートのDB利用でアプリ起動モード
2:9000ポートのDB利用でアプリ起動モード
3:8000ポートのDB利用でDBセットアップモード
4:9000ポートのDB利用でDBセットアップモード
を意味しています。

```python:init_db.py
from logging import getLogger, StreamHandler, DEBUG
from login_app.models.sessions import Session
from login_app.models.users import User


logger = getLogger(__name__)
handler = StreamHandler()
handler.setLevel(DEBUG)
logger.setLevel(DEBUG)
logger.addHandler(handler)
logger.propagate = False


def init():
    logger.debug("init start")

    if not Session.exists():
        Session.create_table(read_capacity_units=5, write_capacity_units=2)
    if not User.exists():
        User.create_table(read_capacity_units=5, write_capacity_units=2)

    user_1 = User(id="user_id_1", password="user_id_1_pass", name="user_id_1_name")
    user_1.save()
    logger.debug("user_id_1.save() end")

    user_2 = User(id="user_id_2", password="user_id_2_pass", name="user_id_2_name")
    user_2.save()
    logger.debug("user_id_2.save() end")


init()

```


## server.py

アプリの起動を行うモジュールとなります。

```python:serer.py
from login_app import app

if __name__ == "__main__":
    app.run()
```

プロジェクトルートに移動後に
__init__.pyでRUN_FLAGの環境変数で読み込むconfigを分岐していますので
Windowsの場合は
8000ポートのDB利用時は``set RUN_FLAG=1``
9000ポートのDB利用時は``set RUN_FLAG=2``
を実行後に``python ./login_app/server.py``を実行するとアプリが起動します。

```
python ./login_app/server.py
run_flag=2 config_name=login_app.config.TestConfig
 * Serving Flask app 'login_app' (lazy loading)
 * Environment: production
   WARNING: This is a development server. Do not use it in a production deployment.
   Use a production WSGI server instead.
 * Debug mode: on
 * Restarting with stat
run_flag=2 config_name=login_app.config.TestConfig
 * Debugger is active!
 * Debugger PIN: 219-915-120
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
```

# サンプルのテスト

## test_common.py

テストで利用する共通処理を実装しているモジュールです。

```python:test_common.py

from logging import getLogger, StreamHandler, DEBUG

logger = getLogger(__name__)
handler = StreamHandler()
handler.setLevel(DEBUG)
logger.setLevel(DEBUG)
logger.addHandler(handler)
logger.propagate = False

BASE_URL = "http:\\localhost:5000"
LOGIN_FORM_URL = f"{BASE_URL}/login"


def request_login(client, user_id, password):
    logger.debug(f"request_login user_id={user_id} password={password} ")

    return client.post(
        "/login", data=dict(user_id=user_id, password=password), follow_redirects=True
    )


def request_user_detail(client, user_id):
    logger.debug(f"request_user_detail user_id={user_id}")

    return client.get(
        f"/users/detail/{user_id}",
        follow_redirects=True,
    )

```

## login_app_test.py

flaskのapp.test_client()を利用し、DBにアクセスするテストは以下のとおりです。

```python:login_app_test.py
import pytest

from login_app import app
from logging import getLogger, StreamHandler, DEBUG

from tests.test_common import request_login, request_user_detail


logger = getLogger(__name__)
handler = StreamHandler()
handler.setLevel(DEBUG)
logger.setLevel(DEBUG)
logger.addHandler(handler)
logger.propagate = False


@pytest.fixture
def client():
    logger.debug("client start")

    with app.test_client() as client:
        yield client


def test_top_not_logined(client):
    rv = client.get("/")
    assert b"Log In" in rv.data


@pytest.fixture(
    params=[
        ("user_id_1", "wrong_password"),
        ("wrong_user_id", "user_id_1_pass"),
        ("", ""),
    ]
)
def login_fail_fixture(request):
    return (request.getfixturevalue("client"), request.param[0], request.param[1])


def test_login_fail(login_fail_fixture):
    client, user_id, password = login_fail_fixture

    # ログインが失敗すると"Login Button"を含む
    rv = request_login(client, user_id, password)
    assert b"Login Button" in rv.data


def test_top_logined(client):
    # loginが成功するとログイン状態でtopにリダイレクトされるので"Log Out"を含む
    rv = request_login(client, "user_id_1", "user_id_1_pass")
    assert b"Log Out" in rv.data

    # ログイン状態でtopにアクセスすると"Log Out"を含む
    rv = client.get("/")
    assert b"Log Out" in rv.data


def test_detail_logined(client):
    # まずはログイン
    rv = request_login(client, "user_id_1", "user_id_1_pass")
    assert b"Log Out" in rv.data

    # ログイン状態でuser_detailにアクセス
    rv = request_user_detail(client, "user_id_1")
    assert b"id : user_id_1 mame : user_id_1_name" in rv.data


def test_detail_not_logined(client):
    # 未ログイン状態でuser_detailにアクセスするとloginにリダイレクトされる
    rv = request_user_detail(client, "user_id_1")
    assert b"Login Button" in rv.data


def test_detail_logined_not_exist_user(client):
    # まずはログイン
    rv = request_login(client, "user_id_1", "user_id_1_pass")
    assert b"Log Out" in rv.data

    # userが存在しない場合はtopにリダイレクトされる
    rv = request_user_detail(client, "dummy_user_id")
    # のでtop画面でログインしているのでLog Outリンクが存在
    assert b"Log Out" in rv.data
```

### test_top_not_logined

未ログインでトップ(/)にアクセスする時のテストとなります。

```
@pytest.fixture
def client():
    logger.debug("client start")

    with app.test_client() as client:
        yield client


def test_top_not_logined(client):
    rv = client.get("/")
    assert b"Log In" in rv.data

```

``app.test_client() ``でテスト用のオブジェクトが取得できますので、client.getやclient.postなどを呼び出し、得られた結果を検証します。

''sert b"Log In" in rv.data''のように結果に"Log In"が含まれる事を検証しています。``rv.data``はバイナリですので、``b"Log In"``と指定する必要があります。

### test_login_fail

ログインが失敗するテストとなります。

```python
@pytest.fixture(
    params=[
        ("user_id_1", "wrong_password"),
        ("wrong_user_id", "user_id_1_pass"),
        ("", ""),
    ]
)
def login_fail_fixture(request):
    return (request.getfixturevalue("client"), request.param[0], request.param[1])


def test_login_fail(login_fail_fixture):
    client, user_id, password = login_fail_fixture

    # ログインが失敗すると"Login Button"を含む
    rv = request_login(client, user_id, password)
    assert b"Login Button" in rv.data
```

ログインが失敗するuser_idとpasswordの繰り合わせでloignをpostで呼び出すので、結果に"Login Button"が含まれます。


### test_top_logined

ログインに成功したらtopにリダイレクトされ、"Log Out"が含まれる。
ログイン状態でtop(/)にアクセスすると"Log Out"が含まれる。
とのテスト内容となります。

```python
def test_top_logined(client):
    # loginが成功するとログイン状態でtopにリダイレクトされるので"Log Out"を含む
    rv = request_login(client, "user_id_1", "user_id_1_pass")
    assert b"Log Out" in rv.data

    # ログイン状態でtopにアクセスすると"Log Out"を含む
    rv = client.get("/")
    assert b"Log Out" in rv.data
```

### test_detail_logined
ログイン状態でuser_detail("/users/detail/<user_id>")にアクセスするテストとなります。

```python
def test_detail_logined(client):
    # まずはログイン
    rv = request_login(client, "user_id_1", "user_id_1_pass")
    assert b"Log Out" in rv.data

    # ログイン状態でuser_detailにアクセス
    rv = request_user_detail(client, "user_id_1")
    assert b"id : user_id_1 mame : user_id_1_name" in rv.data
```

### test_detail_logined
未ログイン状態でuser_detail("/users/detail/<user_id>")にアクセスするテストとなります。

```python
def test_detail_not_logined(client):
    # 未ログイン状態でuser_detailにアクセスするとloginにリダイレクトされる
    rv = request_user_detail(client, "user_id_1")
    assert b"Login Button" in rv.data
```

未ログイン状態なのでlogin(性格にはlogin_manager.login_viewの値)にリダイレクトされるので、結果に"Login Button"が含まれる事を検証しています。


### test_detail_logined_not_exist_user

ログイン状態で、存在しないuser_idを指定してuser_detail("/users/detail/<user_id>")にアクセスするテストとなります。

```python
def test_detail_logined_not_exist_user(client):
    # まずはログイン
    rv = request_login(client, "user_id_1", "user_id_1_pass")
    assert b"Log Out" in rv.data

    # userが存在しない場合はtopにリダイレクトされる
    rv = request_user_detail(client, "dummy_user_id")
    # のでtop画面でログインしているのでLog Outリンクが存在
    assert b"Log Out" in rv.data

```

## login_app_unit_test.py

flaskのapp.test_client()を利用し、DBアクセス処理をモック化したテストは以下のとおりです。
DBにアクセス処理をモック化の部分にフォーカスするために、login_app_test.pyに対応する全てのテストは実装しておりません。


``mocker.patch.object(User, "get", return_value=user)``でUser.getが呼ばれた時の結果を固定化している。
``mocker.patch.object(User, "get", side_effect=DoesNotExist)``でUser.getが呼ばれた時にDoesNotExistがスローされる。
ぐらいの違いしかありまんが・・・

```python:login_app_unit_test.py
import pytest
from pynamodb.exceptions import DoesNotExist

from login_app import app
from login_app.models.users import User
from tests.test_common import request_login


@pytest.fixture
def client():
    with app.test_client() as client:
        yield client


def test_login_fail(mocker, client):
    user = User(id="id", password="password")
    mocker.patch.object(User, "get", return_value=user)

    # ログインが失敗すると"Login Button"が存在する
    rv = request_login(client, "id", "wrong_password")
    assert b"Login Button" in rv.data


def test_login_does_not_exist(mocker, client):
    mocker.patch.object(User, "get", side_effect=DoesNotExist)

    # ログインが失敗すると"Login Button"が存在する
    rv = request_login(client, "id", "wrong_password")
    assert b"Login Button" in rv.data


def test_top_logined(mocker, client):
    user = User(id="id", password="password")
    mocker.patch.object(User, "get", return_value=user)

    # loginが成功するとログイン状態でtopにリダイレクトされるので"Log Out"を含む
    rv = request_login(client, user.id, user.password)
    assert b"Log Out" in rv.data

    # ログイン状態でtopにアクセスすると"Log Out"を含む
    rv = client.get("/")
    assert b"Log Out" in rv.data

```

## login_app_selenium_test.py

seleniumでChromeを動かしてテストを実施するモジュールとなります。

```python:login_app_selenium_test.py
import pytest
from selenium import webdriver
from selenium.webdriver.common.by import By
from logging import getLogger, StreamHandler, DEBUG
from tests.test_common import BASE_URL, LOGIN_FORM_URL


logger = getLogger(__name__)
handler = StreamHandler()
handler.setLevel(DEBUG)
logger.setLevel(DEBUG)
logger.addHandler(handler)
logger.propagate = False


@pytest.fixture
def webdriver_chrome() -> webdriver:
    webdriver_chrome = webdriver.Chrome()
    yield webdriver_chrome
    webdriver_chrome.quit()


def test_top_not_logined(webdriver_chrome: webdriver):
    webdriver_chrome.get(BASE_URL)

    login_text = webdriver_chrome.find_element(
        by=By.XPATH, value='//*[contains(., "Log In")]'
    )

    assert not login_text is None


@pytest.fixture(
    params=[
        ("user_id_1", "wrong_password"),
        ("wrong_user_id", "user_id_1_pass"),
        ("", ""),
    ]
)
def login_fail_fixture(request):
    return (
        request.getfixturevalue("webdriver_chrome"),
        request.param[0],
        request.param[1],
    )


def test_login_fail(login_fail_fixture):
    webdriver_chrome, user_id, password = login_fail_fixture

    login(webdriver_chrome, user_id, password)

    # ログインが失敗すると"Login Button"が存在する
    login_button = webdriver_chrome.find_element(by=By.ID, value="LoginButton")
    assert not login_button == None


def login(webdriver_chrome: webdriver, user_id: str, password: str):
    logger.debug(f"login user_id={user_id} password={password} ")

    webdriver_chrome.get(LOGIN_FORM_URL)

    input_user_id = webdriver_chrome.find_element(by=By.ID, value="InputUserId")
    input_user_id.send_keys(user_id)

    input_password = webdriver_chrome.find_element(by=By.ID, value="InputPassword")
    input_password.send_keys(password)

    login_button = webdriver_chrome.find_element(by=By.ID, value="LoginButton")
    login_button.click()


def test_top_logined(webdriver_chrome: webdriver):

    # loginが成功するとログイン状態でtopにリダイレクトされるので"Log Out"を含む
    login(webdriver_chrome, "user_id_1", "user_id_1_pass")

    logout_text = webdriver_chrome.find_element(
        by=By.XPATH, value='//*[contains(., "Log Out")]'
    )
    assert not logout_text == None

    # ログイン状態でtopにアクセスすると"Log Out"を含む
    webdriver_chrome.get(BASE_URL)

    logout_text = webdriver_chrome.find_element(
        by=By.XPATH, value='//*[contains(., "Log Out")]'
    )
    assert not logout_text == None

```

# サンプルを動作させた時のログやDBデータ

``@login_manager.user_loader``や''User.get_id()''などは動作イメージが掴みにいと思いますので、手でブラウザを操作した時のログを記載いたします。

## ログイン時のログ

```
login start
127.0.0.1 - - [29/Jan/2022 17:23:27] "GET /login HTTP/1.1" 200 -
127.0.0.1 - - [29/Jan/2022 17:23:27] "GET /favicon.ico HTTP/1.1" 304 -
login start
login成功 id=user_id_1
get_id start
call login_user end
127.0.0.1 - - [29/Jan/2022 17:23:32] "POST /login HTTP/1.1" 302 -
start top
load_user user_id=user_id_1
127.0.0.1 - - [29/Jan/2022 17:23:32] "GET / HTTP/1.1" 200 -
```

```python:user_auth.py
                    logger.debug(f"login成功 id={user.id}")

                    login_user(user)

                    logger.debug(f"call login_user end")

                    flash("ログインしました。")

                    return redirect(url_for("top"))
```
ですので、``login_user(user)``からUser.get_id()が呼び出されている事が分かります。


``127.0.0.1 - - [29/Jan/2022 17:23:32] "POST /login HTTP/1.1" 302 -``
でtop(/)にリダイレクトされ

```html:top.html
{% if current_user.is_authenticated %}
```
のようにcurrent_userのis_authenticatedが参照されており、load_userがuser_id="user_id_1"で呼びされている事が分かります。

```python:user_loader.py
    @login_manager.user_loader
    def load_user(user_id):
        logger.debug(f"load_user user_id={user_id}")
        from login_app.models.users import User

        return User(user_id)
```
load_user user_id=user_id_1

## sessionsテーブルのデータ
ログイン後に
``aws dynamodb scan --table-name sessions --endpoint-url http://localhost:9000``
でsessionsのデータを取得してみます。

```
{
    "Items": [
        {
            "SessionId": {
                "S": "session:06d96df5-f0b9-446d-a773-c6d6268afd2f"
            },
            "Session": {
                "S": "{\"_flashes\":[{\" t\":[\"message\",\"\\u30ed\\u30b0\\u30a4\\u30f3\\u3057\\u307e\\u3057\\u305f\\u3002\"]}],\"_fresh\":true,\"_id\":\"afbe5f0825480344b9fb04f9eaf777bd554fc35119445b76f64c2ec1b5018c14bd5e16d176b95c2ba8133608772c600ce3474490e9e1c245088e736bb3ee2e1a\",\"_permanent\":true,\"_user_id\":\"user_id_1\"}"
            }
        }
    ],
    "Count": 1,
    "ScannedCount": 1,
    "ConsumedCapacity": null
}
```

それらしいデータが登録されている事が分かります。