Scert/backend/internal/app/management_integration_test.go

247 lines
7.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package app
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"math/big"
"net"
"net/http"
"os"
"path/filepath"
"strconv"
"sync"
"testing"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
"git.pcux.cn/xuans/Scert/backend/internal/config"
"git.pcux.cn/xuans/Scert/backend/internal/nginxactivation"
)
type applicationTLSFixture struct {
root string
config config.Config
path string
caPath string
oldLeaf []byte
newLeaf []byte
candidate certificate.SavedResource
upstream nginxactivation.Endpoint
proxy nginxactivation.Endpoint
}
func writeApplicationTLSFile(t *testing.T, path string, content []byte) {
t.Helper()
if err := os.WriteFile(path, content, 0o600); err != nil {
t.Fatal(err)
}
}
func applicationTestPort(t *testing.T) int {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
port := listener.Addr().(*net.TCPAddr).Port
if err := listener.Close(); err != nil {
t.Fatal(err)
}
return port
}
func newApplicationTLSFixture(t *testing.T) *applicationTLSFixture {
t.Helper()
cfg := readyApplicationConfig(t)
for _, path := range []string{cfg.Paths.Config, cfg.Paths.Certificate, filepath.Join(cfg.Paths.Install, "empty-roots")} {
if err := os.MkdirAll(path, 0o700); err != nil {
t.Fatal(err)
}
}
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
now := time.Now()
authority := &x509.Certificate{
SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "Scert integration CA"},
NotBefore: now.Add(-time.Hour), NotAfter: now.Add(7 * 24 * time.Hour),
IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign,
}
der, err := x509.CreateCertificate(rand.Reader, authority, authority, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
authority, err = x509.ParseCertificate(der)
if err != nil {
t.Fatal(err)
}
caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
writeApplicationTLSFile(t, filepath.Join(cfg.Paths.Install, "ca.pem"), caPEM)
for index, name := range []string{"previous", "candidate"} {
version := filepath.Join(cfg.Paths.Certificate, name)
if err := os.Mkdir(version, 0o700); err != nil {
t.Fatal(err)
}
leafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
leaf := &x509.Certificate{
SerialNumber: big.NewInt(int64(index + 2)), DNSNames: []string{cfg.Web.Hostname},
NotBefore: now.Add(-time.Hour), NotAfter: now.Add(time.Duration(index+1) * 24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
leafDER, err := x509.CreateCertificate(rand.Reader, leaf, authority, &leafKey.PublicKey, key)
if err != nil {
t.Fatal(err)
}
keyDER, err := x509.MarshalPKCS8PrivateKey(leafKey)
if err != nil {
t.Fatal(err)
}
chain := append(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafDER}), caPEM...)
writeApplicationTLSFile(t, filepath.Join(version, "fullchain.pem"), chain)
writeApplicationTLSFile(t, filepath.Join(version, "key.pem"), pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}))
}
cfg.Server.Host, cfg.Server.HTTPSMode = "127.0.0.1", "proxy"
cfg.Server.Port, cfg.Server.ProxyHTTPSPort = applicationTestPort(t), applicationTestPort(t)
for cfg.Server.ProxyHTTPSPort == cfg.Server.Port {
cfg.Server.ProxyHTTPSPort = applicationTestPort(t)
}
cfg.Server.TLSCert = filepath.Join(cfg.Paths.Certificate, "previous", "fullchain.pem")
cfg.Server.TLSKey = filepath.Join(cfg.Paths.Certificate, "previous", "key.pem")
if err := cfg.Save(filepath.Join(cfg.Paths.Config, "scert.json")); err != nil {
t.Fatal(err)
}
return loadApplicationTLSFixture(t, cfg.Paths.Install)
}
func loadApplicationTLSFixture(t *testing.T, root string) *applicationTLSFixture {
t.Helper()
path := filepath.Join(root, "conf", "scert.json")
cfg, err := config.Load(path)
if err != nil {
t.Fatal(err)
}
f := &applicationTLSFixture{
root: root, config: cfg, path: path, caPath: filepath.Join(root, "ca.pem"),
candidate: certificate.SavedResource{
FullChainPath: filepath.Join(cfg.Paths.Certificate, "candidate", "fullchain.pem"),
PrivateKeyPath: filepath.Join(cfg.Paths.Certificate, "candidate", "key.pem"),
},
}
caPEM, err := os.ReadFile(f.caPath)
if err != nil {
t.Fatal(err)
}
roots := x509.NewCertPool()
if !roots.AppendCertsFromPEM(caPEM) {
t.Fatal("测试 CA 无法解析")
}
previous, err := tls.LoadX509KeyPair(cfg.Server.TLSCert, cfg.Server.TLSKey)
if err != nil {
t.Fatal(err)
}
candidate, err := tls.LoadX509KeyPair(f.candidate.FullChainPath, f.candidate.PrivateKeyPath)
if err != nil {
t.Fatal(err)
}
f.oldLeaf, f.newLeaf = previous.Certificate[0], candidate.Certificate[0]
f.upstream = nginxactivation.Endpoint{Address: net.JoinHostPort("127.0.0.1", strconv.Itoa(cfg.Server.Port)), Hostname: cfg.Web.Hostname, Roots: roots}
f.proxy = nginxactivation.Endpoint{Address: net.JoinHostPort("127.0.0.1", strconv.Itoa(cfg.Server.ProxyHTTPSPort)), Hostname: cfg.Web.Hostname, Roots: roots}
return f
}
func (f *applicationTLSFixture) start(t *testing.T) (*App, func()) {
t.Helper()
cfg, err := config.Load(f.path)
if err != nil {
t.Fatal(err)
}
application, err := NewWithConfigPath(t.Context(), cfg, f.path)
if err != nil {
t.Fatal(err)
}
// 隔离测试不连接固定生产 socket;恢复场景随后接入本轮独立 IPC。
if application.managementStop != nil {
application.managementStop()
<-application.managementDone
}
done := make(chan error, 1)
var once sync.Once
stop := func() {
once.Do(func() {
if err := application.Close(); err != nil {
t.Error(err)
}
select {
case err := <-done:
if !errors.Is(err, http.ErrServerClosed) {
t.Errorf("应用 HTTPS 退出异常:%v", err)
}
case <-time.After(5 * time.Second):
t.Error("应用 HTTPS 未按时退出")
}
})
}
t.Cleanup(stop)
go func() { done <- application.HTTP.ListenAndServe() }()
select {
case <-application.HTTP.Listening():
case err := <-done:
done <- err
t.Fatalf("应用 HTTPS 启动失败:%v", err)
case <-time.After(5 * time.Second):
t.Fatal("应用 HTTPS 未按时启动")
}
return application, stop
}
func (f *applicationTLSFixture) seed(t *testing.T, application *App) {
t.Helper()
if err := application.SecretStore.Put(t.Context(), f.config.DNS.CredentialRef, map[string]string{
"access_key_id": "integration-test-id", "access_key_secret": "integration-test-secret",
}); err != nil {
t.Fatal(err)
}
if _, err := application.Database.DB.ExecContext(t.Context(), "CREATE TABLE activation_test (value TEXT NOT NULL); INSERT INTO activation_test VALUES ('preserved')"); err != nil {
t.Fatal(err)
}
}
func (f *applicationTLSFixture) assertDatabase(t *testing.T, application *App) {
t.Helper()
var value string
if err := application.Database.DB.QueryRowContext(t.Context(), "SELECT value FROM activation_test").Scan(&value); err != nil || value != "preserved" {
t.Fatal("应用重建后数据库内容未保留", err)
}
}
func TestApplicationManagementListenerRestartsWithPersistentReadiness(t *testing.T) {
f := newApplicationTLSFixture(t)
first, stop := f.start(t)
f.seed(t, first)
if _, err := f.upstream.Check(t.Context(), f.oldLeaf); err != nil {
t.Fatal(err)
}
stop()
second, _ := f.start(t)
if _, err := f.upstream.Check(t.Context(), f.oldLeaf); err != nil {
t.Fatal("重建应用后 HTTPS、凭据与数据库就绪检查失败", err)
}
f.assertDatabase(t, second)
ctx, cancel := context.WithTimeout(t.Context(), time.Second)
defer cancel()
if err := second.Database.Ping(ctx); err != nil {
t.Fatal(err)
}
}