emahiro/b.log

日々の勉強の記録とか育児の記録とか。

Cookie に格納された情報を使っている Handler のテストを実装する

Overview

タイトルの通りです。Go で API を書くときなど HTTP の Handler を実装していくと思いますが、ここでログインなどの Cookie にセッションを持たせるような認証機構がセットになってる実装をする場合、この認証機構(特に管理画面でいうところの持ってる権限の判定など) を突破しないと Handler のテストが動かない、と言うケースがあると思います。もしかしたら認証機構を Middleware として切り出して Handler の処理本体では認証を行わない、と言う実装方針も考えることができますが、今回は Handler のなかで認証を実装してるケースにおける Cookie をセットした状態でテストを書くことを念頭におきます。最終的には Handler でチェックしてる認証機構を Middleware に切り出したり、と言ったことも考えられるかなと。

前提: そもそも Go で Cookie を操作するには?

Go の HTTP リクエストの処理において Cookie を操作するにはリクエストとレスポンスで扱うそれぞれのケースがあり、これらは全て net/http package の内部にあります。

Cookie に何を入れるかにもよりますが、おおよそ社内機能のようにアクセスが制限されてる環境であれば、セッション情報を埋め込んでログインだったりに利用することが多いと思います。

Cookie をログインセッションのために使うのはお手軽ですが、万能ではないのでちゃんと使われるサービスの特性を元に検討してください。ここでは Cookie に埋め込んだセッション情報をどう安全に取り扱うかについては言及しません。

Cookie を使った認証機構がある Handler のテスト

Cookie を取り出してその情報を何かしら検証してるテストにおいてはテストする時に Cookie をセットします。

サンプル

以下のような Handler の実装を考えます。

func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodGet {
        w.WriteHeader(http.StatusMethodNotAllowed)
        w.Write([]byte(http.StatusText(http.StatusMethodNotAllowed)))
        return
    }
    ck, err := r.Cookie("test")
    if err != nil {
        w.WriteHeader(http.StatusUnauthorized)
        w.Write([]byte(http.StatusText(http.StatusUnauthorized)))
        return
    }

    w.WriteHeader(http.StatusOK)
    w.Write([]byte(fmt.Sprintf("OK. cookie value is %#v", ck)))
}

これの Handler のテストにおいてテストを追加します。

Cookie を追加していない時

go test -v .
=== RUN   TestRouter
    main_test.go:37: Unauthorized
--- PASS: TestRouter (0.00s)
PASS
ok      github.com/emahiro/il/cookiesessiontest 0.239s

以下のように Cookie を追加した時

req, _ := http.NewRequest(http.MethodGet, ts.URL, nil)
req.AddCookie(&http.Cookie{
    Name:  "test",
    Value: "test",
})
resp, err := http.DefaultClient.Do(req)
if err != nil {
    t.Fatal(err)
}

この時のテストの結果は以下

go test -v .
=== RUN   TestRouter
    main_test.go:41: OK. cookie value is &http.Cookie{Name:"test", Value:"test", Path:"", Domain:"", Expires:time.Time{wall:0x0, ext:0, loc:(*time.Location)(nil)}, RawExpires:"", MaxAge:0, Secure:false, HttpOnly:false, SameSite:0, Raw:"", Unparsed:[]string(nil)}
--- PASS: TestRouter (0.00s)
PASS
ok    github.com/emahiro/il/cookiesessiontest 0.933s

雑な実装としては上記のとおりです。

SecureCookie を使って Cookie で Session を扱う

単純な Cookie ではなく、Session とセットで扱うために gorilla が提供してる https://pkg.go.dev/github.com/gorilla/securecookie を紹介します。

個人的には go-chi も好きですが、gorilla はさらにいろんなツールセットが整っていて好みなフレームワークです。Routing の仕方が特徴的ですが、ne/http の ServerHTTP を満たすインターフェースをしてる点も go-chi と同じで標準に乗っ取っていて好みです。

net/http 互換なので gorilla のパッケージを go-chi から利用することも可能です。

securecookie を使って Session をやりとりする実装をします。

まず最初に Cookie をセットする箇所は以下のようになります。

req, _ := http.NewRequest(http.MethodGet, ts.URL, nil)
codec := securecookie.CodecsFromPairs(keyPairs)
ckstr, err := codec[0].Encode("test", map [interface{}]interface{}{
    "test": "test",
})
if err != nil {
    t.Fatal(err)
}
req.AddCookie(&http.Cookie{
    Name:  "test",
    Value: ckstr,
})

取り出す側は以下です

store := sessions.NewCookieStore(keyPairs)
store.Options = &sessions.Options{}
session, err := store.Get(r, ckName)
if err != nil {
    // refresh session
    session = sessions.NewSession(store, ckName)
}

テストする側では以下のように securecookie を設定します。

store := sessions.NewCookieStore(keyPairs)
store.Options = &sessions.Options{}
session, err := store.Get(r, ckName)
if err != nil {
    // refresh session
    session = sessions.NewSession(store, ckName)
}

securecookie では codec 後の encode 処理をかける際に上記 interface の map を渡します。key も interface なら value も interface を渡すのでこれは本当の意味でなんでも渡せる君になってます笑。

map を渡せるようになってるので受け取る側では key を指定して SessionStore に格納された値を指定して以下のように取り出すことが可能です。

store := sessions.NewCookieStore(keyPairs)
store.Options = &sessions.Options{}
session, err := store.Get(r, ckName)
if err != nil {
    // refresh session
    session = sessions.NewSession(store, ckName)
}

w.WriteHeader(http.StatusOK)
w.Write([]byte(fmt.Sprintf("OK. cookie name is %s value is %#v", session.Name(), session.Values["test"]))) // map[interface{}]interface{} で指定した key で値を取り出せる

少しややこしいな、と思ったのは net/http の Cookie の実装と違い、ここは Cookie をセットする時は SecureCookie として扱うものの、Cookie からセッション情報を取り出した場合には Session として以下扱っているところです。

ちょっとだけリファクタリングする

最後に handler で Cookie の情報を取り出していた箇所を Middleware にしました -> https://github.com/emahiro/il/pull/31/commits/3ee20b9ead7fc24b8238360dee4e40f06ba87cbb

まとめ

このエントリーでざっと流した内容は https://github.com/emahiro/il/pull/31 にまとめてあります。

Memo

http: Request.RequestURI can't be set in client requests が出る

httptest.NewRequest で httptest.Newserver でインスタンス化したサーバーにリクエストを送ると http: Request.RequestURI can't be set in client requests というエラーが出ます。

=== RUN   TestRouter
    main_test.go:23: http://127.0.0.1:49592
    main_test.go:29: Get "http://127.0.0.1:49592": http: Request.RequestURI can't be set in client requests

httptest で立ち上げたサーバーの URL を指定するとURI が指定されていない、というかクライアントではセットできないと言われてリクエストが不通です。

req, err := http.ReadRequest(bufio.NewReader(strings.NewReader(method + " " + target + " HTTP/1.0\r\n\r\n")))

パッと調べた感じ、httptest の中でより LowLevel な関数を Call しており、この辺が怪しそうでした。

// ReadRequest reads and parses an incoming request from b.
//
// ReadRequest is a low-level function and should only be used for
// specialized applications; most code should use the Server to read
// requests and handle them via the Handler interface. ReadRequest
// only supports HTTP/1.x requests. For HTTP/2, use golang.org/x/net/http2.
func ReadRequest(b *bufio.Reader) (*Request, error) {
    return readRequest(b, deleteHostHeader)
}

この readRequest 関数の内部で parseRequestLine という処理を読んでおり、この中で Request.RequestURI が空になって返ってそうだったので、Request.RequestURI がないことによるリクエストの普通が発生してると考えてます。時間がある時に深掘りしてみようと思います。