保存桌面初始化自动切换与正式登录回归检查点
This commit is contained in:
parent
f277e101f2
commit
a1d1b03db0
@ -177,7 +177,7 @@ func TestHTTPSBootstrapCompletesSQLiteAndStartsApplication(t *testing.T) {
|
||||
MinVersion: tls.VersionTLS12, RootCAs: formalRoots, ServerName: input.WebPublicIP,
|
||||
}}
|
||||
defer formalTransport.CloseIdleConnections()
|
||||
formalClient := &http.Client{Transport: formalTransport, Timeout: time.Second}
|
||||
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")
|
||||
@ -207,6 +207,34 @@ func TestHTTPSBootstrapCompletesSQLiteAndStartsApplication(t *testing.T) {
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/app"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
|
||||
@ -20,6 +21,10 @@ import (
|
||||
)
|
||||
|
||||
type Runtime struct {
|
||||
mu sync.RWMutex
|
||||
closed bool
|
||||
ctx context.Context
|
||||
configPath string
|
||||
core *app.App
|
||||
cancel context.CancelFunc
|
||||
initial http.Handler
|
||||
@ -73,7 +78,7 @@ func Open(configPath, baseDir string) (*Runtime, error) {
|
||||
}
|
||||
http.NotFound(writer, request)
|
||||
})
|
||||
return &Runtime{cancel: cancel, initial: handler, initialDNS: dns}, nil
|
||||
return &Runtime{ctx: ctx, configPath: configPath, cancel: cancel, initial: handler, initialDNS: dns}, nil
|
||||
}
|
||||
core, err := app.NewWithConfigPath(ctx, cfg, configPath)
|
||||
if err != nil {
|
||||
@ -83,28 +88,94 @@ func Open(configPath, baseDir string) (*Runtime, error) {
|
||||
if core.Renewal != nil && cfg.SetupReady {
|
||||
core.Renewal.Start(ctx)
|
||||
}
|
||||
return &Runtime{core: core, cancel: cancel}, nil
|
||||
return &Runtime{ctx: ctx, configPath: configPath, core: core, cancel: cancel}, nil
|
||||
}
|
||||
|
||||
func (r *Runtime) Handler() http.Handler {
|
||||
if r != nil && r.initial != nil {
|
||||
return r.initial
|
||||
}
|
||||
if r == nil || r.core == nil || r.core.HTTP == nil {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if r == nil {
|
||||
http.Error(writer, "Scert 核心服务未初始化", http.StatusServiceUnavailable)
|
||||
})
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(request.Context())
|
||||
stop := context.AfterFunc(r.ctx, cancel)
|
||||
defer stop()
|
||||
defer cancel()
|
||||
request = request.WithContext(ctx)
|
||||
r.mu.RLock()
|
||||
if !r.closed && r.core != nil {
|
||||
defer r.mu.RUnlock()
|
||||
r.core.HTTP.Handler().ServeHTTP(writer, request)
|
||||
return
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
// 桌面初始化请求串行处理,保存后下一次请求才切换,避免关闭在途验证的凭据。
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.closed {
|
||||
http.Error(writer, "Scert 核心服务已关闭", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if r.core == nil {
|
||||
if err := r.activateCompletedSetup(); err != nil {
|
||||
http.Error(writer, "初始化配置已保存,但核心服务启动失败,请检查配置和运行目录后重试", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
}
|
||||
if r.core != nil {
|
||||
r.core.HTTP.Handler().ServeHTTP(writer, request)
|
||||
return
|
||||
}
|
||||
if r.initial == nil {
|
||||
http.Error(writer, "Scert 核心服务未初始化", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
r.initial.ServeHTTP(writer, request)
|
||||
})
|
||||
}
|
||||
|
||||
// 调用方持有运行时写锁;不重新创建窗口,原有处理器即可切换到正式服务。
|
||||
func (r *Runtime) activateCompletedSetup() error {
|
||||
cfg, err := config.LoadWithEnv(r.configPath)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return r.core.HTTP.Handler()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !cfg.SetupReady {
|
||||
return nil
|
||||
}
|
||||
core, err := app.NewWithConfigPath(r.ctx, cfg, r.configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if core.Renewal != nil {
|
||||
core.Renewal.Start(r.ctx)
|
||||
}
|
||||
r.core = core
|
||||
r.initial = nil
|
||||
if r.initialDNS != nil {
|
||||
r.initialDNS.DiscardAllExcept(cfg.DNS.CredentialRef)
|
||||
r.initialDNS = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Runtime) Close() error {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
// 先取消长操作再等待处理器退出,避免关闭过程被初始化验证阻塞。
|
||||
if r.cancel != nil {
|
||||
r.cancel()
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.closed {
|
||||
return nil
|
||||
}
|
||||
r.closed = true
|
||||
if r.initialDNS != nil {
|
||||
r.initialDNS.DiscardAll()
|
||||
}
|
||||
|
||||
125
backend/service/runtime_transition_test.go
Normal file
125
backend/service/runtime_transition_test.go
Normal file
@ -0,0 +1,125 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/setup"
|
||||
)
|
||||
|
||||
func prepareDesktopTransition(t *testing.T) (*Runtime, http.Handler, string, func()) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "config", "scert.json")
|
||||
runtime, err := Open(path, root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := runtime.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
cfg := defaultConfig(root)
|
||||
if err := setup.Apply(&cfg, setup.Input{
|
||||
AdminUsername: "test-admin", AdminEmail: "admin@example.test", AdminPassword: "Test1234!",
|
||||
ServerPort: 8080, WebHostname: "scert.example.test", WebEnabled: true,
|
||||
DNSProvider: "aliyun", DNSCredentialRef: "secret://dns/test", DNSVerified: true,
|
||||
CertificateRoot: cfg.Paths.Certificate, HTTPSMode: "direct",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.SetupReady = true
|
||||
cfg.Web.InitReady = true
|
||||
return runtime, runtime.Handler(), path, func() {
|
||||
t.Helper()
|
||||
// 只模拟旧向导已经完成的配置提交,不连接真实 DNS 或证书签发服务。
|
||||
if err := cfg.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopSameHandlerActivatesSavedSetupAndAllowsLogin(t *testing.T) {
|
||||
runtime, handler, _, save := prepareDesktopTransition(t)
|
||||
if runtime.core != nil {
|
||||
t.Fatal("未保存配置前不应启动正式服务")
|
||||
}
|
||||
save()
|
||||
request := httptest.NewRequest(http.MethodPost, "http://localhost/api/v1/auth/login", strings.NewReader(`{"identifier":"test-admin","password":"Test1234!"}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.RemoteAddr = "127.0.0.1:12345"
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"authenticated":true`) {
|
||||
t.Fatalf("保存后原处理器不能登录:%d", response.Code)
|
||||
}
|
||||
if runtime.core == nil || runtime.initial != nil || runtime.initialDNS != nil {
|
||||
t.Fatal("初始化运行时没有切换为正式服务")
|
||||
}
|
||||
if len(response.Result().Cookies()) == 0 {
|
||||
t.Fatal("登录没有返回会话")
|
||||
}
|
||||
cookie := response.Result().Cookies()[0]
|
||||
request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/session", nil)
|
||||
request.AddCookie(cookie)
|
||||
response = httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatal("正式登录会话不可用")
|
||||
}
|
||||
response = httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/api/v1/setup", nil))
|
||||
if response.Code != http.StatusNotFound {
|
||||
t.Fatal("切换后旧初始化写接口仍可访问")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopConcurrentTransitionAndIdempotentClose(t *testing.T) {
|
||||
runtime, handler, _, save := prepareDesktopTransition(t)
|
||||
save()
|
||||
var pending sync.WaitGroup
|
||||
for index := 0; index < 16; index++ {
|
||||
pending.Go(func() {
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/v1/auth/session", nil))
|
||||
if response.Code != http.StatusUnauthorized {
|
||||
t.Errorf("并发切换后的路由状态错误:%d", response.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
pending.Wait()
|
||||
for index := 0; index < 8; index++ {
|
||||
pending.Go(func() {
|
||||
if err := runtime.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
pending.Wait()
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/v1/setup/status", nil))
|
||||
if response.Code != http.StatusServiceUnavailable {
|
||||
t.Fatal("关闭后不应重新创建正式服务")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopTransitionRejectsCorruptedSavedConfig(t *testing.T) {
|
||||
runtime, handler, path, _ := prepareDesktopTransition(t)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(`{invalid`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/v1/setup/status", nil))
|
||||
if response.Code != http.StatusServiceUnavailable || runtime.core != nil {
|
||||
t.Fatal("损坏配置不得回退到新的初始化或启动正式服务")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user