Scert/backend/internal/bootstrap/server_lifecycle_test.go

305 lines
9.5 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 bootstrap
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"net/http/cookiejar"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/app"
"git.pcux.cn/xuans/Scert/backend/internal/config"
"git.pcux.cn/xuans/Scert/backend/internal/secretstore"
"git.pcux.cn/xuans/Scert/backend/internal/setup"
)
func TestHTTPSBootstrapCompletesSQLiteAndStartsApplication(t *testing.T) {
fixture := newCompletionFixture(t, "authorized.example.test")
store, err := secretstore.Open(fixture.base.Paths.Config)
if err != nil {
t.Fatal(err)
}
databases := NewDatabaseVerificationManager(fixture.base, store)
dns := setup.NewDNSVerificationManager(completionDNSTester{}, store)
sessions, token, err := NewSessionManager()
if err != nil {
t.Fatal(err)
}
completer := NewCompleter(CompleteDependencies{
BaseConfig: fixture.base, ConfigPath: fixture.configPath,
DatabaseSessions: databases, DNSSessions: dns, Credentials: store,
Sessions: sessions, Firewall: fixture.firewall, Certificate: fixture.issuer,
})
router := NewRouter(sessions, databases, dns, completer)
handler, closeFiles := bootstrapHandler(router, fixture.base)
defer closeFiles()
material, err := NewTemporaryTLSMaterial("127.0.0.1")
if err != nil {
t.Fatal(err)
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
address := listener.Addr().String()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
result := make(chan error, 1)
go func() {
ok, err := serveTemporaryHTTPS(ctx, listener, material, handler, router.Completed())
if err == nil && !ok {
err = errors.New("初始化未完成")
}
result <- err
}()
roots := x509.NewCertPool()
roots.AddCert(material.Leaf)
transport := &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: roots}}
defer transport.CloseIdleConnections()
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatal(err)
}
client := &http.Client{Transport: transport, Jar: jar, Timeout: 10 * time.Second}
origin := "https://" + address
post := func(path string, input any, output any) {
t.Helper()
body, err := json.Marshal(input)
if err != nil {
t.Fatal(err)
}
request, err := http.NewRequest(http.MethodPost, origin+path, bytes.NewReader(body))
if err != nil {
t.Fatal(err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Origin", origin)
response, err := client.Do(request)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
data, err := io.ReadAll(response.Body)
if err != nil || response.StatusCode != http.StatusOK {
t.Fatalf("初始化步骤 %s 失败:状态 %d,读取错误 %v", path, response.StatusCode, err)
}
if err := json.Unmarshal(data, output); err != nil {
t.Fatal(err)
}
}
var authenticated map[string]any
post("/api/v1/bootstrap/session", map[string]string{"token": token}, &authenticated)
var databaseResult, dnsResult struct {
Token string `json:"verification_token"`
}
post("/api/v1/bootstrap/database/test", DatabaseInput{Driver: "sqlite"}, &databaseResult)
post("/api/v1/bootstrap/dns/test", bootstrapDNSTestRequest{
Provider: "aliyun", TestDomain: "authorized.example.test",
Credentials: map[string]string{"access_key_id": "test-id", "access_key_secret": "test-secret"},
}, &dnsResult)
input := fixture.request("")
input.WebPublicIP = "203.0.113.20"
input.ServerPort = listener.Addr().(*net.TCPAddr).Port
input.DatabaseVerificationToken = databaseResult.Token
input.DNSVerificationToken = dnsResult.Token
var completed CompleteResult
post("/api/v1/bootstrap/complete", input, &completed)
if !completed.Completed || completed.ManagementURL != "https://"+net.JoinHostPort(input.WebPublicIP, strconv.Itoa(input.ServerPort))+"/" {
t.Fatal("初始化未返回正确管理地址")
}
select {
case err := <-result:
if err != nil {
t.Fatal(err)
}
case <-time.After(5 * time.Second):
t.Fatal("初始化服务未退出")
}
parsed, _ := url.Parse(origin)
if len(jar.Cookies(parsed)) != 0 {
t.Fatal("完成响应未清除初始化会话")
}
// 清理未领取凭据不能删除已提交配置持有的凭据。
databases.DiscardAllExcept()
dns.DiscardAll()
stored, err := config.Load(fixture.configPath)
if err != nil {
t.Fatal(err)
}
stored.Server.Host = "127.0.0.1"
application, err := app.NewWithConfigPath(ctx, stored, fixture.configPath)
if err != nil {
t.Fatal(err)
}
applicationResult := make(chan error, 1)
var closeOnce sync.Once
closeApplication := func() {
closeOnce.Do(func() {
if err := application.Close(); err != nil {
t.Error(err)
}
select {
case err := <-applicationResult:
if !errors.Is(err, http.ErrServerClosed) {
t.Errorf("正式服务关闭错误:%v", err)
}
case <-time.After(5 * time.Second):
t.Error("正式服务关闭超时")
}
})
}
defer closeApplication()
go func() { applicationResult <- application.HTTP.ListenAndServe() }()
// 正式 IP 证书与临时证书不同,只信任本次生成的正式证书。
certificatePEM, err := os.ReadFile(stored.Server.TLSCert)
if err != nil {
t.Fatal(err)
}
formalRoots := x509.NewCertPool()
if !formalRoots.AppendCertsFromPEM(certificatePEM) {
t.Fatal("正式管理证书无法解析")
}
// 仅连接回环套接字,证书身份仍按配置中的文档示例公网 IP 完整校验。
formalTransport := &http.Transport{TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12, RootCAs: formalRoots, ServerName: input.WebPublicIP,
}}
defer formalTransport.CloseIdleConnections()
formalClient := &http.Client{Transport: formalTransport, Jar: jar, Timeout: 5 * time.Second}
deadline := time.Now().Add(5 * time.Second)
for {
response, err := formalClient.Get(origin + "/healthz")
if err == nil {
response.Body.Close()
if response.StatusCode != http.StatusOK {
t.Fatal("正式服务健康检查失败")
}
break
}
if time.Now().After(deadline) {
t.Fatalf("正式 HTTPS 未就绪:%v", err)
}
time.Sleep(10 * time.Millisecond)
}
for path, expected := range map[string]int{
"/api/v1/bootstrap/status": http.StatusNotFound,
"/api/v1/bootstrap/settings": http.StatusNotFound,
"/api/v1/auth/session": http.StatusUnauthorized,
} {
response, err := formalClient.Get(origin + path)
if err != nil {
t.Fatal(err)
}
response.Body.Close()
if response.StatusCode != expected {
t.Fatalf("切换后 %s 状态错误:%d", path, response.StatusCode)
}
}
loginBody, err := json.Marshal(map[string]string{"identifier": input.AdminUsername, "password": input.AdminPassword})
if err != nil {
t.Fatal(err)
}
login, err := http.NewRequest(http.MethodPost, origin+"/api/v1/auth/login", bytes.NewReader(loginBody))
if err != nil {
t.Fatal(err)
}
login.Header.Set("Content-Type", "application/json")
login.Header.Set("Origin", origin)
response, err := formalClient.Do(login)
if err != nil {
t.Fatal(err)
}
response.Body.Close()
if response.StatusCode != http.StatusOK {
t.Fatalf("新管理员无法登录正式 HTTPS:%d", response.StatusCode)
}
for _, path := range []string{"/api/v1/auth/session", "/api/v1/users", "/readyz"} {
response, err := formalClient.Get(origin + path)
if err != nil {
t.Fatal(err)
}
response.Body.Close()
if response.StatusCode != http.StatusOK {
t.Fatalf("初始化后会话、管理员权限或本地就绪检查失败:%s %d", path, response.StatusCode)
}
}
closeApplication()
}
func TestBootstrapRunCancellationRemovesURLWithoutCreatingBusinessDatabase(t *testing.T) {
fixture := newCompletionFixture(t, "authorized.example.test")
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
fixture.base.Server.Host = "127.0.0.1"
fixture.base.Server.Port = listener.Addr().(*net.TCPAddr).Port
listener.Close()
var output bytes.Buffer
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
result := make(chan error, 1)
go func() {
ok, err := Run(ctx, ServerOptions{
Config: fixture.base, ConfigPath: fixture.configPath,
PublicIP: "127.0.0.1", URLPath: fixture.urlPath, Output: &output,
FirewallMode: "external",
})
if ok {
err = errors.New("取消操作误报初始化完成")
}
result <- err
}()
deadline := time.Now().Add(5 * time.Second)
var address []byte
for {
address, err = os.ReadFile(fixture.urlPath)
if err == nil {
break
}
if time.Now().After(deadline) {
t.Fatal("初始化地址文件没有生成")
}
time.Sleep(10 * time.Millisecond)
}
cancel()
select {
case err := <-result:
if err != nil {
t.Fatal(err)
}
case <-time.After(5 * time.Second):
t.Fatal("取消后初始化未退出")
}
parsed, err := url.Parse(strings.TrimSpace(string(address)))
if err != nil {
t.Fatal(err)
}
if parsed.Fragment == "" || strings.Contains(output.String(), parsed.Fragment) {
t.Fatal("一次性令牌缺失或泄露到日志")
}
if !strings.Contains(output.String(), "Bootstrap 临时证书 SHA-256 指纹:") {
t.Fatal("初始化启动日志缺少可通过可信运维通道核对的证书指纹")
}
if !strings.Contains(output.String(), "不检查或修改主机规则及云安全组") {
t.Fatal("外部防火墙管理策略必须明确记录,不得悄悄跳过")
}
for _, path := range []string{fixture.urlPath, fixture.configPath, filepath.Join(fixture.base.Paths.Data, "scert.db")} {
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("取消后不应保留初始化地址或提前产生正式文件:%s", path)
}
}
}