はじめに

以下の記事でサーバー証明書の情報を一括で取得するツールを紹介しました。今回はこのツールのテスト方法について説明します。

経緯

Go で外部接続のあるアプリをテストする場合、以下の選択肢があると思います。

  • 外部接続のコードをインターフェース化し、テストではモックを差し込む
  • net/http/httptest でテストサーバーを立てる
  • 自前でローカルにテストサーバーを立てる

これに対して私は以下のような初心者ムーブをしました。

  1. 額面どおり外部接続をインターフェース化してモックでなんとかしようとする
  2. コードをテストのために書き換えまくり、本末転倒になった割に満足なテストができないことに気づく
  3. httptest で TLS サーバーが立てられるのを知らなかったため、自前で TLS サーバーを立ててテストする
  4. httptest が使えることを知り、コード量を減らすためにリファクタリングしようとする
  5. 痒いところに手が届かず、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() 関数では以下を実行しています。

  1. setupEnv() で環境変数を設定 (タイムゾーンの指定)
  2. setupPath() で証明書ファイル、鍵ファイルを置くための一時ディレクトリを準備
  3. setupCert() で証明書を作る
  4. setupServer() で TLS サーバーを作る
  5. goroutine で TLS サーバーを非同期に起動する
  6. 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 サーバーをセットアップする方法を紹介しました。面倒なのでなるべくやりたくないですが、一度作ってしまえば流用できます。