はじめに
以下の記事でサーバー証明書の情報を一括で取得するツールを紹介しました。今回はこのツールのテスト方法について説明します。
経緯
Go で外部接続のあるアプリをテストする場合、以下の選択肢があると思います。
- 外部接続のコードをインターフェース化し、テストではモックを差し込む
net/http/httptest
でテストサーバーを立てる- 自前でローカルにテストサーバーを立てる
これに対して私は以下のような初心者ムーブをしました。
- 額面どおり外部接続をインターフェース化してモックでなんとかしようとする
- コードをテストのために書き換えまくり、本末転倒になった割に満足なテストができないことに気づく
httptest
で TLS サーバーが立てられるのを知らなかったため、自前で TLS サーバーを立ててテストするhttptest
が使えることを知り、コード量を減らすためにリファクタリングしようとする- 痒いところに手が届かず、TLS サーバーをせっかく書いたのでそのまま突き進むことにする
httptest によるテスト
net/http/httptest
を使えば、TestMain
で以下のようにセットアップできます。
package main import ( "fmt" "log" "net" "net/http" "net/http/httptest" "net/url" "os" "testing" ) var ( addr string host string port string ) func TestMain(m *testing.M) { server, err := setup() if err != nil { log.Fatal(err) } code := m.Run() server.Close() os.Exit(code) } func setup() (*httptest.Server, error) { if err := setupEnv(); err != nil { return nil, err } server, err := setupServer() if err != nil { return nil, err } return server, nil } func setupEnv() error { err := os.Setenv("TZ", "Asia/Tokyo") if err != nil { return err } return nil } func setupServer() (*httptest.Server, error) { handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) fmt.Fprintf(w, "test server") }) server := httptest.NewTLSServer(handler) fmt.Println("Test server running at:", server.URL) parsedURL, err := url.Parse(server.URL) if err != nil { return nil, err } addr = parsedURL.Host host = parsedURL.Hostname() port = parsedURL.Port() return server, nil }
以下のような流れで、非常に便利です。
- テスト前に TLS サーバーがセットアップされる
- テストが走る
- テストが終わったら TLS サーバーが閉じる
TLS サーバーのセットアップは以下を含みます。
- 証明書のセットアップが裏で行われる
- テストのため、証明書チェーンとホスト名の検証をスキップする設定が入る
server.URL
にはhttps://127.0.0.1:<random_port>
が設定される
補足として、以下のような処理も入れています。
- 時刻を扱うので、
setupEnv()
でタイムゾーンを指定する addr
,host
,port
をそれぞれ使うのでurl.Parse()
で分解してグローバル変数にセットする
最初からこのようにセットアップを書いていれば、その制限の範囲内でテストを実施し、自前で TLS サーバーをセットアップすることはなかったと思います。しかし、一方で以下のようなデメリットもあります。
- ポート番号がランダム
- 証明書のフィールドが固定値
HTTPS 越しにコンテンツの内容や Web アプリの動作をテストするような通常のケースでは、上記はデメリットになりません。ただ今回は TLS 証明書の情報を正しく取得できるかのテストなので、これらがデメリットになってしまいます。
自前サーバーによるテスト
httptest.NewTLSServer()
を知らずにテストを書いてしまったので、結果的にこんな感じになりました。
setup()
関数では以下を実行しています。
setupEnv()
で環境変数を設定 (タイムゾーンの指定)setupPath()
で証明書ファイル、鍵ファイルを置くための一時ディレクトリを準備setupCert()
で証明書を作るsetupServer()
で TLS サーバーを作る- goroutine で TLS サーバーを非同期に起動する
waitServer()
でサーバーの起動完了を待ち受ける
func setup(addr string) (*http.Server, string, error) { if err := setupEnv(); err != nil { return nil, "", fmt.Errorf("failed to set environment valiable: %w", err) } tempDir, certFile, keyFile, err := setupPath() if err != nil { return nil, "", fmt.Errorf("failed to create temp dir: %w", err) } if err := setupCert(certFile, keyFile); err != nil { return nil, "", fmt.Errorf("failed to create certificate: %w", err) } server := setupServer(addr) ch := make(chan error, 1) go func() { if err := server.ListenAndServeTLS(certFile, keyFile); err != http.ErrServerClosed { ch <- err } close(ch) }() if err := waitServer(addr, 5*time.Second); err != nil { return nil, "", fmt.Errorf("failed to start server: %w", err) } select { case err := <-ch: if err != nil { return nil, "", fmt.Errorf("failed to run server: %w", err) } default: } return server, tempDir, nil }
サーバー起動の待ち受けや一時ファイルの取り回しが必要で、非常に面倒です。ただ証明書を OpenSSL ではなく Go で作るのは新鮮で、その点は学びになりました (ちなみに Go の SSL/TLS は自前実装)
func setupCert(certFile, keyFile string) error { // create private key privKey, err := rsa.GenerateKey(rand.Reader, 4096) if err != nil { return err } // configure certificate serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) if err != nil { return err } notBefore, err := time.Parse(time.RFC3339, getNotBefore()) if err != nil { return err } notAfter, err := time.Parse(time.RFC3339, getNotAfter()) if err != nil { return err } tmpl := x509.Certificate{ SerialNumber: serialNumber, Subject: pkix.Name{CommonName: "local test CA"}, NotBefore: notBefore, NotAfter: notAfter, KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, BasicConstraintsValid: true, } // create certificate derBytes, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &privKey.PublicKey, privKey) if err != nil { return err } // save certificate in pem certOut, err := os.Create(certFile) if err != nil { return err } if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil { return err } certOut.Close() // convert private key privBytes, err := x509.MarshalPKCS8PrivateKey(privKey) if err != nil { return err } // save private key in pem keyOut, err := os.Create(keyFile) if err != nil { return err } if err := pem.Encode(keyOut, &pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}); err != nil { return err } keyOut.Close() return nil }
テスト終了後の teardown()
では一時ディレクトリの削除とサーバーのシャットダウンを行います。
func teardown(server *http.Server, tempDir string) error { defer os.RemoveAll(tempDir) if err := server.Shutdown(context.Background()); err != nil { return fmt.Errorf("cannot shutdown server: %w", err) } return nil }
ここまでやれば、証明書のフィールドも自前で設定できるので、たいていのテストケースを満たすことができます。
おわりに
ローカルにテスト用の TLS サーバーをセットアップする方法を紹介しました。面倒なのでなるべくやりたくないですが、一度作ってしまえば流用できます。