バックエンドのテスト

https://www.udemy.com/course/introduction-to-testing-in-go-golang/

を参考に実装しつつまとめていく

ハンドラのテスト

流れ

  1. requestにデータを詰める
  2. ハンドラを実行する
  3. responseからデータを取り出してチェックする

テンプレ

func Test_Example(t *testing.T) {
    //テストケースを定義する
    tests := []struct {
        name   string
        data   string
        status int
    }{
        {"test 1", "data1", http.StatusBadRequest},
        {"test 2", "data2", http.StatusOK},
    }
    for _, test := range tests {
        //テスト実行
        t.Run(test.name, func(t *testing.T) {
            //requestの作成
            r := httptest.NewRequest(http.MethodPost, "/test", nil)
            //responseの作成
            w := httptest.NewRecorder()
            //handlerの作成
            handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
            //実行
            handler.ServeHTTP(w, r)

            //responseがwに書き込まれるのでテストケースと比較
            want_status := test.status
            got_status := w.Result().StatusCode
            if got_status != want_status {
                t.Errorf("statusCode: got %d, want %d", got_status, want_status)
            }
            if want_status == http.StatusOK {
                //responseのチェック
            } else {
                //エラーのチェック
            }
        })
    }
}

requestにデータを詰める

//json
//Marshalなどで文字列化して、NewRequestの引数(body)に入れる
r := httptest.NewRequest(http.MethodPost, "/test", strings.NewReader(jsonString))
//context
//WithContextで渡す
ctx := context.WithValue(context.Background(),ContextKey, "value")
handler.ServeHTTP(w, r.WithContext(ctx))

ミドルウェアのテスト

func myMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        next.ServeHTTP(w, r)
    })
}

//適当なハンドラを作ってmiddlewareに渡す
    emptyHandler := http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {})
    handler := myMiddleware(emptyHandler)
        handler.ServeHTTP(w, r)
//middleware内でcontextにセットした値はハンドラ内から取り出してチェック
    emptyHandler := http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
             value := r.Context().Value(Key)
             if value ==nil{
                 t.Error("")
             }
    handler := myMiddleware(emptyHandler)
})

APIリクエストのテスト

書き途中

//サーバーを立てる
         ts = httptest.NewTLSServer(app.registerHandlers())
         defer ts.Close()
//request作成
    r := httptest.NewRequest(http.MethodGet, ts.URL+"/_chk", nil)
//エラー防止
    r.RequestURI = ""
//client作成, request実行
    res, err := ts.Client().Do(r)
         defer res.Body.Close()
        if res.StatusCode != http.StatusOK{
            t.Error("...")
        }

ハンドラのテストと同様にhttptest.NewRequestからrequestを作成できますが、エラーが発生するためRequestURIの上書きが必要でした。
http.Request RequestURI field when making request in go - Stack Overflow

DB絡みをどうするか

正解はわかりませんが、現在はテスト専用のDBコンテナに接続してテスト毎にDBをクリア(DROP TABLEしてマイグレーションし直す)しています。

既に感じているデメリットとして

  • テスト全体で一つのDBを共有しているため、-p 1で並列実行を止める必要がある
  • 複数パッケージでDBを使おうとすると、importの都合でDBをクリアする関数がアプリ側に漏れてしまう

リクエストからレスポンスまで一貫して挙動を確認できるのは良いのですが、将来的にはDBコンテナを使ったテストはモデル内で完結させて、その他のパッケージからモデルを利用する時はモックを返すようにすると良いのかもしれません。
そうすることで並列実行が可能になり、クリア関数をテストファイル内に留めておくことができます。(一長一短な感じしますが・・・)

書いた後に知りましたがDBのクリアに関してはテスト終了後にロールバックするという裏技があるようなので、まず地獄みたいな関数を無くそうと思います。

テスト毎にロールバックする方式にしたところスピードも上がり大体解決しました。
トランザクション | GORM - The fantastic ORM library for Golang, aims to be developer friendly.

auto_incrementの値がロールバックされないようなので、IDの返り値を決め打ちでチェックしてる箇所は修正が必要になります。

ログイン、リフレッシュ処理の流れ

地獄みたいなコードになってたので整理したものをまとめておきます。

正式なフローとかではないのでご了承ください。
SPAを想定しています。

データ

アクセストーク

  • JWT
  • このトークンをparseすることでuser_idを取り出し、そのユーザーからのアクセスとみなす
  • アクセスの度に取り出したuser_idからUsersテーブルを引くようなことは基本しない(はず)
  • 有効期間は短くする(1時間程度)
  • バックエンドには保存しない
  • フロントエンドにはメモリ(javascriptの変数)上に保存する

リフレッシュトーク

  • JWT
  • アクセストークンを更新するためのトーク
  • 有効期間を長くする(〜数ヶ月)
  • バックエンドではセッションIDとともにDBに保存する
  • JWT単体で無効化できない、payloadの中身が読める、サイズが大きいなどの理由でフロントには返さない
  • DBのレコードを削除することで無効化する

セッションID

  • UUID
  • DBに保存したリフレッシュトークンにアクセスするための文字列
  • バックエンドではリフレッシュトークンと一緒にDBに保存する
  • フロント側ではリフレッシュトークンの代わりにクッキー内に保存する
  • クッキーをhttpOnly属性にすることで、javascriptから読み取られない状態でフロントに保存することができる

まとめ

アクセストーク リフレッシュトーク セッションID
役割 そのユーザーになれる アクセストークンを再発行できる リフレッシュトークンの取得と削除
有効期間 短時間(~1時間) 長時間(~数ヶ月) 永続
形式 JWT JWT UUID
中身 user_idなど user_idなど -
保存場所(バックエンド) 保存しない DB DB
保存場所(フロントエンド) メモリ内 保存しない cookie(httpOnly)

流れ

サインイン

サインアップ(ID、パスワード登録)済みとします。

  1. (フロント)ID、パスワードを送信
  2. (バックエンド)ID、パスワードのチェック
  3. (バックエンド)OKならリフレッシュトークンとセッションIDを生成し、保存
  4. (バックエンド)セッションIDを乗せたクッキーをSet-Cookieでフロントに返す
  5. (アクセストークンも同時に返してセットさせるか、またはブラウザ更新して次のアクセストークン取得処理を実行させる)

アクセストークンの取得、リフレッシュ

アクセストークンはメモリ上に保存しているため、ページを離れると削除されてしまう。
ブラウザ更新時や再訪問時にはセッションIDを用いてアクセストークンを取得し直す必要がある。

  1. (フロント)リクエストを送信
  2. (バックエンド)クッキーからセッションIDを取り出し、リフレッシュトークンを検索
  3. (バックエンド)リフレッシュトークンをチェック
  4. (バックエンド)有効ならセッションIDとリフレッシュトークンを更新して再保存、アクセストークン生成
  5. (バックエンド)セッションIDをクッキーにセットし、レスポンスでアクセストークンを返す
  6. (フロント)アクセストークンをメモリに保存

アクセストークンの自動更新

ユーザーが長時間操作を行うと、途中でアクセストークンが切れて通信できなくなってしまう。
アクセストークンが切れるより前のタイミングでアクセストークンの取得、リフレッシュを再実行し、アクセストークンを更新する必要がある。
setIntervalなどを使い、定期的に実行させる。

アクセストークンによるAPI通信

  1. (フロント)バックエンドへの各リクエストに対して、アクセストークンをAuthorizationヘッダにつけて実行
  2. (バックエンド)Authorizationヘッダからアクセストークンを取り出し、検証
  3. (バックエンド)parseして得られたuser_idからのアクセスとして取り扱う

サインアウト

フロントエンドからhttpOnlyクッキーが削除できないので、バックエンド経由で削除する必要がある

  1. (フロント)アクセストークンを削除、クッキー削除リクエスト送信
  2. (バックエンド)無効なクッキー(value: "",expires: now(), max-age 0など)をセットさせる

その他

reactの場合

アクセストークンがセットされる前に認証が必要なリクエストが飛んで失敗するので、成否に関わらずアクセストークン取得処理が終わってから各ページがrenderされるようにする。

            {authFinished ? <RouterProvider router={router} /> : null}

参考URL

セキュアなトークン管理方法 - Carpe Diem
より良い記事

cannot find package ~~ in GOROOT or GOPATH

golang関連の教材のコードをリファレンスとしてすぐに動かせる状態で一つのコンテナに押し込めようとしたところ、パッケージがインストールされているにもかかわらずvscodeのgoplsが上記のエラーを吐いてしまいました。

下記の通りそれぞれのgo.modのパスをworkspaceに追加することで解決しました。 stackoverflow.com github.com

Session ManagerでEC2に接続、そこからRDSに接続

RDSのデータを直接触る用のEC2を立てました。
ほぼ参考記事の紹介になります。

Session ManagerでEC2にアクセス

dev.classmethod.jp

Amazon Linuxmysqlをインストール

qiita.com

インストール時にproblem conflicting requestsのエラーが出たのでググった結果、追加するリポジトリmysql80-community-release-el9-3.noarch.rpmに変更したら解消しました。

RDSに接続

mysql -h [RDSエンドポイント] -u [ユーザー名] -p [DB名]

「Create React App Sampleをインストールします」を消す

create-react-appで作成したアプリをビルドしてブラウザで開くと出てくるこちらについて
index.htmlの以下をコメントアウトすると消せました

<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />

stackoverflow.com

manifest.jsonはPWA周りの設定ファイルだそうです。

(react)tailwindcss導入->vscodeで自動補完するまで

tailwindcssのインストール

tailwindcss.com

vscode拡張のインストール

補完や色の確認ができるようになり便利です tailwindcss.com

prettierとの連携

導入すると自動フォーマット時にtailwindcssのclass記述がいい感じの順番にソートされるようになりスッキリします

prettier本体, 拡張プラグインvscode拡張のインストール

npm install -D prettier prettier-plugin-tailwindcss
vscode拡張でprettier検索してインストール

prettier設定ファイル作成

prettier.io

prettierとtailwindcssの連携

prettier設定ファイルにpluginsを追記

plugins: ['prettier-plugin-tailwindcss'],

GitHub - tailwindlabs/prettier-plugin-tailwindcss: A Prettier plugin for Tailwind CSS that automatically sorts classes based on our recommended class order.

自動フォーマットの有効化

vscode->preferences->settings
Workspaceに切り替え
formatterで検索->Default Formatterでprettier選択、Format On Saveにチェック

動作確認

以下のコンポーネントをコピペ->保存していい感じにフォーマットされればOK

const TEST=()=> {
  const value=1
  return (
    <h1 className="font-bold underline text-3xl">
      Hello world!
    </h1>
  )
}

追加作業

特定の変数名に対して補完を適用させたい

通化などの都合でclassNameに指定する文字列を外部で宣言したくなると思います。
現状のままだと素のstringに対してtailwindcssの拡張が機能しないので、使いにくくなってしまいます。

const style="text-3xl font-bold underline" // <- ここに補完やフォーマットを適用させたい
<div className={style}>...

解決策を調べたところ、変数名のパターンを指定して補完を適用させる方法があるようです。 stackoverflow.com 自分はTAILWINDという名前のオブジェクトとtailwind_から始まるstringに補完を適用させたかったので以下を指定してみました。

.vscode/settings.json
===
    "tailwindCSS.classAttributes": [
        "class",
        "className",
        "ngClass",
        "TAILWIND",
        "tailwind_.*"
    ]
const TAILWIND = {
    BG_COLOR: 'bg-slate-100',
    TEXT_COLOR: 'text-zinc-400',
    TEXT_COLOR_STRONG:'text-orange-600'
}
const tailwind_style = 'text-3xl font-bold underline'

またprettierによる自動ソートも同じような設定ができるようですが、変数名に対しては適用できませんでした。
GitHub - tailwindlabs/prettier-plugin-tailwindcss: A Prettier plugin for Tailwind CSS that automatically sorts classes based on our recommended class order.
とりあえず補完が効くようになったので満足してます。