修复正式初始化边界与向导过期校验并补齐回归测试
This commit is contained in:
parent
c6bfcfd9ae
commit
f277e101f2
@ -14,7 +14,9 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@ -115,7 +117,7 @@ func TestHTTPSBootstrapCompletesSQLiteAndStartsApplication(t *testing.T) {
|
||||
input.DNSVerificationToken = dnsResult.Token
|
||||
var completed CompleteResult
|
||||
post("/api/v1/bootstrap/complete", input, &completed)
|
||||
if !completed.Completed || completed.ManagementURL != "https://"+net.JoinHostPort(input.WebPublicIP, strings.Split(address, ":")[1])+"/" {
|
||||
if !completed.Completed || completed.ManagementURL != "https://"+net.JoinHostPort(input.WebPublicIP, strconv.Itoa(input.ServerPort))+"/" {
|
||||
t.Fatal("初始化未返回正确管理地址")
|
||||
}
|
||||
select {
|
||||
@ -143,6 +145,23 @@ func TestHTTPSBootstrapCompletesSQLiteAndStartsApplication(t *testing.T) {
|
||||
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)
|
||||
@ -174,7 +193,6 @@ func TestHTTPSBootstrapCompletesSQLiteAndStartsApplication(t *testing.T) {
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
defer application.Close()
|
||||
for path, expected := range map[string]int{
|
||||
"/api/v1/bootstrap/status": http.StatusNotFound,
|
||||
"/api/v1/bootstrap/settings": http.StatusNotFound,
|
||||
@ -189,17 +207,7 @@ func TestHTTPSBootstrapCompletesSQLiteAndStartsApplication(t *testing.T) {
|
||||
t.Fatalf("切换后 %s 状态错误:%d", path, response.StatusCode)
|
||||
}
|
||||
}
|
||||
if err := application.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case err := <-applicationResult:
|
||||
if !errors.Is(err, http.ErrServerClosed) {
|
||||
t.Fatalf("正式服务关闭错误:%v", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("正式服务关闭超时")
|
||||
}
|
||||
closeApplication()
|
||||
}
|
||||
|
||||
func TestBootstrapRunCancellationRemovesURLWithoutCreatingBusinessDatabase(t *testing.T) {
|
||||
|
||||
@ -112,6 +112,8 @@ type Server struct {
|
||||
loginInFlight int
|
||||
loginPrunedAt time.Time
|
||||
now func() time.Time
|
||||
httpMu sync.Mutex
|
||||
httpStopped bool
|
||||
httpServer *http.Server
|
||||
router *gin.Engine
|
||||
}
|
||||
@ -221,17 +223,13 @@ func (s *Server) ListenAndServe() error {
|
||||
s.configMu.RLock()
|
||||
cfg := s.config
|
||||
s.configMu.RUnlock()
|
||||
if !cfg.SetupReady {
|
||||
return errors.New("初始化未完成,请使用独立 HTTPS 初始化服务")
|
||||
}
|
||||
address, err := listenAddress(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.httpServer = newHTTPServer(address, s.router)
|
||||
if !cfg.SetupReady {
|
||||
if s.logger != nil {
|
||||
s.logger.Info("HTTP 服务启动", "address", s.httpServer.Addr, "https_mode", "初始化明文 HTTP")
|
||||
}
|
||||
return s.httpServer.ListenAndServe()
|
||||
}
|
||||
certPath := strings.TrimSpace(cfg.Server.TLSCert)
|
||||
keyPath := strings.TrimSpace(cfg.Server.TLSKey)
|
||||
if certPath == "" || keyPath == "" {
|
||||
@ -243,10 +241,22 @@ func (s *Server) ListenAndServe() error {
|
||||
if _, err := os.Stat(filepath.Clean(keyPath)); err != nil {
|
||||
return fmt.Errorf("读取 HTTPS 私钥失败:%w", err)
|
||||
}
|
||||
if s.logger != nil {
|
||||
s.logger.Info("HTTPS 服务启动", "address", s.httpServer.Addr, "https_mode", cfg.Server.HTTPSMode)
|
||||
server := newHTTPServer(address, s.router)
|
||||
s.httpMu.Lock()
|
||||
if s.httpStopped {
|
||||
s.httpMu.Unlock()
|
||||
return http.ErrServerClosed
|
||||
}
|
||||
return s.httpServer.ListenAndServeTLS(certPath, keyPath)
|
||||
if s.httpServer != nil {
|
||||
s.httpMu.Unlock()
|
||||
return errors.New("HTTPS 服务已经启动")
|
||||
}
|
||||
s.httpServer = server
|
||||
s.httpMu.Unlock()
|
||||
if s.logger != nil {
|
||||
s.logger.Info("HTTPS 服务启动", "address", server.Addr, "https_mode", cfg.Server.HTTPSMode)
|
||||
}
|
||||
return server.ListenAndServeTLS(certPath, keyPath)
|
||||
}
|
||||
|
||||
func newHTTPServer(address string, handler http.Handler) *http.Server {
|
||||
@ -289,10 +299,14 @@ func isLoopbackHost(host string) bool {
|
||||
}
|
||||
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
if s.httpServer == nil {
|
||||
s.httpMu.Lock()
|
||||
s.httpStopped = true
|
||||
server := s.httpServer
|
||||
s.httpMu.Unlock()
|
||||
if server == nil {
|
||||
return nil
|
||||
}
|
||||
err := s.httpServer.Shutdown(ctx)
|
||||
err := server.Shutdown(ctx)
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -80,6 +80,10 @@ type dnsTestRequest struct {
|
||||
|
||||
func (s *Server) registerSetupRoutes() {
|
||||
s.router.GET("/api/v1/setup/status", s.setupStatus)
|
||||
// 正式服务只保留状态查询;旧写接口仅供桌面端本机初始化使用。
|
||||
if s.config.SetupReady {
|
||||
return
|
||||
}
|
||||
s.router.GET("/api/v1/setup/dns/providers", s.setupDNSProviders)
|
||||
s.router.POST("/api/v1/setup/dns/test", s.requireSameOrigin, s.requireSetupAccess, s.dnsOperation, s.testSetupDNS)
|
||||
s.router.POST("/api/v1/setup", s.requireSameOrigin, s.requireSetupAccess, s.longOperation(12*time.Minute), s.applySetup)
|
||||
|
||||
81
backend/internal/httpapi/setup_boundary_test.go
Normal file
81
backend/internal/httpapi/setup_boundary_test.go
Normal file
@ -0,0 +1,81 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/config"
|
||||
)
|
||||
|
||||
func TestReadyServerDoesNotRegisterLegacySetupWrites(t *testing.T) {
|
||||
server := New(Dependencies{Config: readyTestConfig(t.TempDir())})
|
||||
for _, item := range []struct {
|
||||
method string
|
||||
path string
|
||||
status int
|
||||
}{
|
||||
{http.MethodGet, "/api/v1/setup/status", http.StatusOK},
|
||||
{http.MethodGet, "/api/v1/setup/dns/providers", http.StatusNotFound},
|
||||
{http.MethodPost, "/api/v1/setup/dns/test", http.StatusNotFound},
|
||||
{http.MethodPost, "/api/v1/setup", http.StatusNotFound},
|
||||
{http.MethodGet, "/api/v1/bootstrap/status", http.StatusNotFound},
|
||||
} {
|
||||
for _, remote := range []string{"127.0.0.1:12345", "203.0.113.20:12345"} {
|
||||
request := httptest.NewRequest(item.method, item.path, strings.NewReader(`{}`))
|
||||
request.RemoteAddr = remote
|
||||
response := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(response, request)
|
||||
if response.Code != item.status {
|
||||
t.Fatalf("正式服务路由状态错误:%s %s %d", item.method, item.path, response.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUninitializedServerCannotStartPlainHTTP(t *testing.T) {
|
||||
server := New(Dependencies{Config: config.Default(t.TempDir())})
|
||||
if err := server.ListenAndServe(); err == nil || !strings.Contains(err.Error(), "独立 HTTPS") {
|
||||
t.Fatalf("未初始化服务应拒绝启动明文监听:%v", err)
|
||||
}
|
||||
if server.httpServer != nil {
|
||||
t.Fatal("未初始化不应创建正式监听服务")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShutdownBeforeStartPreventsLaterListener(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := readyTestConfig(dir)
|
||||
// 停止状态必须在加载证书和监听端口前生效,夹具不需要可用的证书。
|
||||
cfg.Server.TLSCert = filepath.Join(dir, "cert.pem")
|
||||
cfg.Server.TLSKey = filepath.Join(dir, "key.pem")
|
||||
for _, path := range []string{cfg.Server.TLSCert, cfg.Server.TLSKey} {
|
||||
if err := os.WriteFile(path, []byte("test-only"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
server := New(Dependencies{Config: cfg})
|
||||
if err := server.Shutdown(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var pending sync.WaitGroup
|
||||
for index := 0; index < 16; index++ {
|
||||
pending.Go(func() {
|
||||
if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
|
||||
t.Errorf("关闭后不应重新监听:%v", err)
|
||||
}
|
||||
})
|
||||
pending.Go(func() {
|
||||
if err := server.Shutdown(context.Background()); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
pending.Wait()
|
||||
}
|
||||
@ -66,6 +66,10 @@ require (
|
||||
github.com/googleapis/gax-go/v2 v2.22.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.205 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.8.0 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
@ -117,7 +121,7 @@ require (
|
||||
golang.org/x/tools v0.48.0 // indirect
|
||||
google.golang.org/api v0.288.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 // indirect
|
||||
google.golang.org/grpc v1.82.1 // indirect
|
||||
google.golang.org/grpc v1.83.1 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/ini.v1 v1.67.3 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
@ -197,6 +197,14 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.205 h1:YnI2+56S5oxByz8BYChRrsmqTUmCp1Ww2KIzZtF4XOU=
|
||||
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.205/go.mod h1:M+yna96Fx9o5GbIUnF3OvVvQGjgfVSyeJbV9Yb1z/wI=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
@ -342,10 +350,10 @@ go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
@ -508,16 +516,16 @@ google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoA
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0=
|
||||
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 h1:eM/YSd5bBFagF51o1E745Ta7RwzpW0h+z+QDNZOgmQ8=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
|
||||
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
|
||||
google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y=
|
||||
google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "node --test src/App.test.js src/views/SetupWizard/SetupWizard.test.js src/views/SetupWizard/DatabaseSetup.test.js src/views/SetupWizard/BootstrapPayload.test.js src/views/Dashboard/Dashboard.test.js",
|
||||
"test": "node --test src/App.test.js src/views/SetupWizard/SetupWizard.test.js src/views/SetupWizard/SetupWizardBootstrap.test.js src/views/SetupWizard/DatabaseSetup.test.js src/views/SetupWizard/BootstrapPayload.test.js src/views/Dashboard/Dashboard.test.js",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
|
||||
@ -501,6 +501,7 @@ export function useSetupWizard(props, emit) {
|
||||
}
|
||||
|
||||
function validateCurrentStep() {
|
||||
verificationClock.value = Date.now();
|
||||
if (currentStep.value === 1) synchronizeEmailField();
|
||||
error.value = "";
|
||||
resultMessage.value = "";
|
||||
@ -713,55 +714,21 @@ export function useSetupWizard(props, emit) {
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
verificationClock.value = Date.now();
|
||||
error.value = "";
|
||||
resultMessage.value = "";
|
||||
if (isBootstrap && !database.databaseVerified.value) {
|
||||
error.value = "数据库验证结果无效或已过期,请重新验证。";
|
||||
return;
|
||||
}
|
||||
if (
|
||||
form.value.admin_password.length < 8 ||
|
||||
form.value.admin_password.length > 12
|
||||
) {
|
||||
error.value = "超级管理员密码长度必须为 8 至 12 个字符。";
|
||||
return;
|
||||
}
|
||||
if (form.value.admin_password !== form.value.confirm_password) {
|
||||
error.value = "两次输入的超级管理员密码不一致。";
|
||||
return;
|
||||
}
|
||||
if (!dnsVerificationValid.value) {
|
||||
error.value = "必须先完成 DNS API 在线验证;验证结果已过期时请重新测试。";
|
||||
return;
|
||||
}
|
||||
if (form.value.email_smtp_enabled) {
|
||||
const smtpPort = Number(form.value.email_smtp_port);
|
||||
if (!form.value.email_smtp_host.trim()) {
|
||||
error.value = "启用 SMTP 通知后必须填写 SMTP 主机。";
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(smtpPort) || smtpPort < 1 || smtpPort > 65535) {
|
||||
error.value = "SMTP 端口必须在 1 至 65535 之间。";
|
||||
return;
|
||||
}
|
||||
if (!form.value.email_smtp_username.trim()) {
|
||||
error.value = "启用 SMTP 通知后必须填写 SMTP 用户名。";
|
||||
return;
|
||||
}
|
||||
if (!form.value.email_smtp_password) {
|
||||
error.value = "启用 SMTP 通知后必须填写 SMTP 密码。";
|
||||
return;
|
||||
}
|
||||
if (form.value.email_smtp_mode === "plain" && !form.value.email_smtp_allow_plaintext_auth) {
|
||||
error.value = "SMTP 明文模式必须明确允许明文认证,建议改用 STARTTLS 或隐式 TLS。";
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
emailCredentialPayload();
|
||||
} catch (err) {
|
||||
error.value = err.message || "HTTP 通知凭据格式无效。";
|
||||
return;
|
||||
// 提交前重新检查全部步骤,不能依赖之前点击“下一步”时的校验结果。
|
||||
for (let step = 1; step <= 4; step += 1) {
|
||||
currentStep.value = step;
|
||||
if (!validateCurrentStep()) return;
|
||||
}
|
||||
const hostname =
|
||||
targetType.value === "hostname" ? form.value.web_hostname.trim() : "";
|
||||
@ -833,6 +800,18 @@ export function useSetupWizard(props, emit) {
|
||||
});
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(result.error || "保存初始化配置失败");
|
||||
if (isBootstrap) {
|
||||
let destination;
|
||||
try {
|
||||
destination = new URL(result?.management_url);
|
||||
} catch {
|
||||
throw new Error("初始化响应不完整,请检查服务器状态后再继续。");
|
||||
}
|
||||
if (result?.completed !== true || destination.protocol !== "https:" ||
|
||||
destination.username || destination.password) {
|
||||
throw new Error("初始化响应不完整,请检查服务器状态后再继续。");
|
||||
}
|
||||
}
|
||||
completed.value = true;
|
||||
resultMessage.value = isBootstrap ? `初始化配置已保存,正在切换至 ${result.management_url}。请稍候;IP 自签名证书或测试证书需要人工核对。` : result.message || "初始化配置已保存。";
|
||||
emit("initialized", result);
|
||||
|
||||
@ -874,7 +874,7 @@ const {
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset v-if="currentStep === 4" class="setup-step-card">
|
||||
<fieldset v-if="currentStep === 4" class="setup-step-card" :disabled="submitting || completed">
|
||||
<legend><span>04</span>证书目录与剩余设置</legend>
|
||||
<div class="setup-grid two-columns">
|
||||
<label>
|
||||
|
||||
170
frontend/src/views/SetupWizard/SetupWizardBootstrap.test.js
Normal file
170
frontend/src/views/SetupWizard/SetupWizardBootstrap.test.js
Normal file
@ -0,0 +1,170 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { useSetupWizard } from "./SetupWizard.js";
|
||||
import { aliyunProvider, deferred, mountComposable } from "../../test-support/mount.js";
|
||||
|
||||
function completion() {
|
||||
return Response.json({ completed: true, management_url: "https://203.0.113.20:8443/" });
|
||||
}
|
||||
|
||||
async function setup(t, complete = completion, driver = "sqlite") {
|
||||
const submitted = [];
|
||||
const emitted = [];
|
||||
const mounted = await mountComposable(t, () => useSetupWizard({ status: {
|
||||
mode: "bootstrap",
|
||||
web_public_ip: "203.0.113.20",
|
||||
server_port: 8443,
|
||||
certificate_root: "/srv/scert/ssl",
|
||||
} }, (...args) => emitted.push(args)), async (path, options) => {
|
||||
if (path.endsWith("/providers")) return Response.json({ items: [aliyunProvider] });
|
||||
if (path.endsWith("/test")) return Response.json({
|
||||
verified: true,
|
||||
driver,
|
||||
verification_token: path.includes("database") ? "database-token" : "dns-token",
|
||||
expires_at: new Date(Date.now() + 60000).toISOString(),
|
||||
});
|
||||
assert.equal(path, "/api/v1/bootstrap/complete");
|
||||
submitted.push(JSON.parse(options.body));
|
||||
return complete();
|
||||
});
|
||||
const { state } = mounted;
|
||||
Object.assign(state.form.value, {
|
||||
admin_username: "test-admin",
|
||||
admin_password: "TestPass123",
|
||||
confirm_password: "TestPass123",
|
||||
});
|
||||
state.handleEmailInput("admin@example.test");
|
||||
state.dnsCredentials.value = { access_key_id: "test-key" };
|
||||
state.dnsTestDomain.value = "authorized.example.test";
|
||||
state.databaseForm.value.driver = driver;
|
||||
await state.testDatabase();
|
||||
await state.testDNS();
|
||||
assert.equal(state.databaseVerified.value, true);
|
||||
assert.equal(state.dnsVerificationValid.value, true);
|
||||
return { ...mounted, submitted, emitted };
|
||||
}
|
||||
|
||||
for (const driver of ["sqlite", "mysql", "postgres"]) {
|
||||
test(`首次向导 ${driver} 完整流程只提交验证令牌并保留通知配置`, async (t) => {
|
||||
const { state, submitted, emitted } = await setup(t, completion, driver);
|
||||
Object.assign(state.form.value, {
|
||||
email_http_enabled: true,
|
||||
email_endpoint: "https://mail.example.test",
|
||||
email_http_auth_mode: "bearer",
|
||||
email_retry_count: 0,
|
||||
recipients: "first@example.test,second@example.test",
|
||||
email_http_credential_ref: "secret://client/not-trusted",
|
||||
});
|
||||
state.emailCredentials.value = JSON.stringify({ token: "test-notification-token" });
|
||||
for (let step = 1; step < 4; step += 1) {
|
||||
assert.equal(state.currentStep.value, step);
|
||||
await state.handleSubmit();
|
||||
assert.equal(submitted.length, 0);
|
||||
assert.equal(state.currentStep.value, step + 1);
|
||||
}
|
||||
await state.handleSubmit();
|
||||
assert.equal(state.error.value, "");
|
||||
assert.equal(state.completed.value, true);
|
||||
assert.equal(submitted.length, 1);
|
||||
assert.equal(submitted[0].database_verification_token, "database-token");
|
||||
assert.equal(submitted[0].dns_verification_token, "dns-token");
|
||||
assert.equal(submitted[0].web_public_ip, "203.0.113.20");
|
||||
assert.equal(submitted[0].notification.http_enabled, true);
|
||||
assert.equal(submitted[0].notification.retry_count, 0);
|
||||
assert.deepEqual(submitted[0].notification.recipients, ["first@example.test", "second@example.test"]);
|
||||
assert.deepEqual(submitted[0].notification.http_credentials, { token: "test-notification-token" });
|
||||
for (const key of ["dns_credential_ref", "certificate_root", "database", "confirm_password"]) {
|
||||
assert.equal(Object.hasOwn(submitted[0], key), false);
|
||||
}
|
||||
assert.equal(Object.hasOwn(submitted[0].notification, "http_credential_ref"), false);
|
||||
assert.equal(emitted.length, 1);
|
||||
assert.equal(emitted[0][0], "initialized");
|
||||
});
|
||||
}
|
||||
|
||||
test("首次向导提交中和完成后禁止重复提交", async (t) => {
|
||||
const pending = deferred();
|
||||
const { state, submitted, emitted } = await setup(t, () => pending.promise);
|
||||
state.currentStep.value = 4;
|
||||
const task = state.handleSubmit();
|
||||
assert.equal(state.submitting.value, true);
|
||||
await state.handleSubmit();
|
||||
assert.equal(submitted.length, 1);
|
||||
pending.resolve(completion());
|
||||
await task;
|
||||
await state.handleSubmit();
|
||||
assert.equal(submitted.length, 1);
|
||||
assert.equal(emitted.length, 1);
|
||||
assert.equal(state.submitting.value, false);
|
||||
});
|
||||
|
||||
test("首次向导提交失败清除已领取令牌,重新验证后允许重试", async (t) => {
|
||||
let attempts = 0;
|
||||
const { state, submitted, emitted } = await setup(t, () => ++attempts === 1
|
||||
? Response.json({ error: "证书签发失败" }, { status: 502 }) : completion());
|
||||
state.currentStep.value = 4;
|
||||
await state.handleSubmit();
|
||||
assert.match(state.error.value, /证书签发失败/);
|
||||
assert.equal(state.completed.value, false);
|
||||
assert.equal(state.databaseVerification.value, null);
|
||||
assert.equal(state.dnsVerification.value, null);
|
||||
assert.equal(emitted.length, 0);
|
||||
await state.handleSubmit();
|
||||
assert.equal(submitted.length, 1);
|
||||
await state.testDatabase();
|
||||
await state.testDNS();
|
||||
await state.handleSubmit();
|
||||
assert.equal(submitted.length, 2);
|
||||
assert.equal(state.completed.value, true);
|
||||
});
|
||||
|
||||
test("首次向导验证到期即使未触发界面时钟也禁止提交", async (t) => {
|
||||
const { state, submitted, advanceTime } = await setup(t);
|
||||
state.currentStep.value = 4;
|
||||
advanceTime(60001);
|
||||
await state.handleSubmit();
|
||||
assert.equal(submitted.length, 0);
|
||||
assert.match(state.error.value, /过期/);
|
||||
});
|
||||
|
||||
test("首次向导最终提交重新校验全部步骤", async (t) => {
|
||||
const { state, submitted } = await setup(t);
|
||||
for (const [field, value, step] of [
|
||||
["server_port", 0, 1],
|
||||
["email_http_headers", "[]", 2],
|
||||
["propagation_polling_interval_secs", 99999, 3],
|
||||
["certificate_root", "", 4],
|
||||
]) {
|
||||
const original = state.form.value[field];
|
||||
state.form.value[field] = value;
|
||||
state.currentStep.value = 4;
|
||||
await state.handleSubmit();
|
||||
assert.equal(submitted.length, 0);
|
||||
assert.equal(state.currentStep.value, step);
|
||||
assert.notEqual(state.error.value, "");
|
||||
state.form.value[field] = original;
|
||||
}
|
||||
});
|
||||
|
||||
test("首次向导拒绝畸形成功响应而不是误报完成", async (t) => {
|
||||
const responses = [
|
||||
{},
|
||||
null,
|
||||
{ completed: false, management_url: "https://203.0.113.20/" },
|
||||
{ completed: true, management_url: "invalid" },
|
||||
{ completed: true, management_url: "http://203.0.113.20/" },
|
||||
{ completed: true, management_url: "https://user:password@example.test/" },
|
||||
];
|
||||
const { state, emitted } = await setup(t, () => Response.json(responses.shift()));
|
||||
while (responses.length) {
|
||||
await state.testDatabase();
|
||||
await state.testDNS();
|
||||
state.currentStep.value = 4;
|
||||
await state.handleSubmit();
|
||||
assert.equal(state.completed.value, false);
|
||||
assert.equal(emitted.length, 0);
|
||||
assert.match(state.error.value, /响应不完整/);
|
||||
assert.equal(state.databaseVerification.value, null);
|
||||
assert.equal(state.dnsVerification.value, null);
|
||||
}
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user