はじめに
以下の記事でサーバー証明書の情報を一括で取得するツールを紹介しました。今回はこのツールのテスト方法について説明します。
経緯
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 サーバーをセットアップする方法を紹介しました。面倒なのでなるべくやりたくないですが、一度作ってしまえば流用できます。