diff --git a/backend/internal/bootstrap/origin_test.go b/backend/internal/bootstrap/origin_test.go
new file mode 100644
index 0000000..9a75039
--- /dev/null
+++ b/backend/internal/bootstrap/origin_test.go
@@ -0,0 +1,52 @@
+package bootstrap
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestBootstrapRejectsCrossOriginBeforeTokenExchange(t *testing.T) {
+ for _, origin := range []string{"https://attacker.example", "http://127.0.0.1:8443", "null", "https://127.0.0.1:8443/path"} {
+ t.Run(origin, func(t *testing.T) {
+ sessions, token, err := NewSessionManager()
+ if err != nil {
+ t.Fatal(err)
+ }
+ router := NewRouter(sessions, nil, nil, nil)
+ request := httptest.NewRequest(http.MethodPost, "https://127.0.0.1:8443/api/v1/bootstrap/session", strings.NewReader(`{"token":"`+token+`"}`))
+ request.Header.Set("Origin", origin)
+ response := httptest.NewRecorder()
+ router.ServeHTTP(response, request)
+ if response.Code != http.StatusForbidden {
+ t.Fatalf("跨源写入未拒绝:%d", response.Code)
+ }
+ if _, err := sessions.Exchange(token, "127.0.0.1"); err != nil {
+ t.Fatalf("被拒绝的请求消耗了令牌:%v", err)
+ }
+ })
+ }
+}
+
+func TestBootstrapSameOriginPolicy(t *testing.T) {
+ for _, item := range []struct {
+ origin string
+ site string
+ allowed bool
+ }{
+ {"https://127.0.0.1:8443", "same-origin", true},
+ {"", "", true},
+ {"", "cross-site", false},
+ {"https://127.0.0.1:8443", "same-site", false},
+ {"https://user@127.0.0.1:8443", "", false},
+ } {
+ request := httptest.NewRequest(http.MethodPost, "https://127.0.0.1:8443/", nil)
+ request.Header.Set("Origin", item.origin)
+ request.Header.Set("Sec-Fetch-Site", item.site)
+ request.Header.Set("X-Forwarded-Host", "attacker.example")
+ if bootstrapSameOrigin(request) != item.allowed {
+ t.Fatalf("来源策略不符:%+v", item)
+ }
+ }
+}
diff --git a/backend/internal/bootstrap/router.go b/backend/internal/bootstrap/router.go
index d38b09c..465ba8e 100644
--- a/backend/internal/bootstrap/router.go
+++ b/backend/internal/bootstrap/router.go
@@ -8,6 +8,7 @@ import (
"io"
"mime"
"net/http"
+ "net/url"
"strings"
"sync"
"time"
@@ -70,6 +71,10 @@ func (router *Router) ServeHTTP(writer http.ResponseWriter, request *http.Reques
writeBootstrapError(writer, http.StatusBadRequest, "请求无效")
return
}
+ if request.Method != http.MethodGet && request.Method != http.MethodHead && !bootstrapSameOrigin(request) {
+ writeBootstrapError(writer, http.StatusForbidden, "初始化写入请求必须来自当前管理页面")
+ return
+ }
switch request.URL.Path {
case "/api/v1/bootstrap/status":
if !requireBootstrapMethod(writer, request, http.MethodGet) {
@@ -366,6 +371,21 @@ func requireBootstrapMethod(writer http.ResponseWriter, request *http.Request, m
return false
}
+// 不信任转发头;同站子域也不能向初始化服务提交写入请求。
+func bootstrapSameOrigin(request *http.Request) bool {
+ if site := request.Header.Get("Sec-Fetch-Site"); site != "" && site != "same-origin" && site != "none" {
+ return false
+ }
+ origin := request.Header.Get("Origin")
+ if origin == "" {
+ return true
+ }
+ parsed, err := url.Parse(origin)
+ return err == nil && parsed.Scheme == "https" && parsed.User == nil &&
+ parsed.Path == "" && parsed.RawQuery == "" && parsed.Fragment == "" &&
+ strings.EqualFold(parsed.Host, request.Host)
+}
+
func setBootstrapSecurityHeaders(writer http.ResponseWriter) {
writer.Header().Set("Cache-Control", "no-store")
writer.Header().Set("Referrer-Policy", "no-referrer")
diff --git a/backend/internal/bootstrap/server_lifecycle_test.go b/backend/internal/bootstrap/server_lifecycle_test.go
new file mode 100644
index 0000000..a8f842a
--- /dev/null
+++ b/backend/internal/bootstrap/server_lifecycle_test.go
@@ -0,0 +1,261 @@
+package bootstrap
+
+import (
+ "bytes"
+ "context"
+ "crypto/tls"
+ "crypto/x509"
+ "encoding/json"
+ "errors"
+ "io"
+ "net"
+ "net/http"
+ "net/http/cookiejar"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "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, strings.Split(address, ":")[1])+"/" {
+ 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)
+ 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, Timeout: 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)
+ }
+ defer application.Close()
+ 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)
+ }
+ }
+ 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("正式服务关闭超时")
+ }
+}
+
+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,
+ })
+ 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("一次性令牌缺失或泄露到日志")
+ }
+ 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)
+ }
+ }
+}
diff --git a/backend/internal/bootstrap/tls.go b/backend/internal/bootstrap/tls.go
index 709f9da..e0a1859 100644
--- a/backend/internal/bootstrap/tls.go
+++ b/backend/internal/bootstrap/tls.go
@@ -204,18 +204,24 @@ func WriteBootstrapURL(path, value string) error {
return fmt.Errorf("检查 Bootstrap 地址文件失败:%w", err)
}
temporary, err := os.CreateTemp(directory, ".bootstrap-url-")
- if err != nil { return fmt.Errorf("创建 Bootstrap 临时地址文件失败:%w", err) }
+ if err != nil {
+ return fmt.Errorf("创建 Bootstrap 临时地址文件失败:%w", err)
+ }
temporaryPath := temporary.Name()
defer os.Remove(temporaryPath)
defer temporary.Close()
- if _, err := temporary.WriteString(value+"\n"); err != nil {
+ if _, err := temporary.WriteString(value + "\n"); err != nil {
return fmt.Errorf("写入 Bootstrap 临时地址文件失败:%w", err)
}
if err := temporary.Chmod(0o600); err != nil {
return fmt.Errorf("设置 Bootstrap 临时地址文件权限失败:%w", err)
}
- if err := temporary.Sync(); err != nil { return fmt.Errorf("同步 Bootstrap 地址文件失败:%w", err) }
- if err := temporary.Close(); err != nil { return fmt.Errorf("关闭 Bootstrap 地址文件失败:%w", err) }
+ if err := temporary.Sync(); err != nil {
+ return fmt.Errorf("同步 Bootstrap 地址文件失败:%w", err)
+ }
+ if err := temporary.Close(); err != nil {
+ return fmt.Errorf("关闭 Bootstrap 地址文件失败:%w", err)
+ }
if err := os.Rename(temporaryPath, path); err != nil {
_ = os.Remove(temporaryPath)
return fmt.Errorf("替换 Bootstrap 地址文件失败:%w", err)
diff --git a/backend/service/runtime.go b/backend/service/runtime.go
index 4a86d6c..4d2278f 100644
--- a/backend/service/runtime.go
+++ b/backend/service/runtime.go
@@ -10,8 +10,8 @@ import (
"strings"
"git.pcux.cn/xuans/Scert/backend/internal/app"
- "git.pcux.cn/xuans/Scert/backend/internal/config"
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
+ "git.pcux.cn/xuans/Scert/backend/internal/config"
"git.pcux.cn/xuans/Scert/backend/internal/dnsprovider"
"git.pcux.cn/xuans/Scert/backend/internal/firewall"
"git.pcux.cn/xuans/Scert/backend/internal/httpapi"
@@ -20,9 +20,9 @@ import (
)
type Runtime struct {
- core *app.App
- cancel context.CancelFunc
- initial http.Handler
+ core *app.App
+ cancel context.CancelFunc
+ initial http.Handler
initialDNS *setup.DNSVerificationManager
}
@@ -54,7 +54,10 @@ func Open(configPath, baseDir string) (*Runtime, error) {
if !cfg.SetupReady {
// 桌面端保留本机向导,但不再为初始化预先创建正式数据库和后台任务。
store, err := secretstore.Open(cfg.Paths.Config)
- if err != nil { cancel(); return nil, err }
+ if err != nil {
+ cancel()
+ return nil, err
+ }
dns := setup.NewDNSVerificationManager(dnsprovider.NewTester(dnsprovider.NewLegoFactory()), store)
issuer := certificate.NewService(cfg, nil, store, dnsprovider.NewLegoFactory(), certificate.NewAccountRepository(store))
issuer.SetDNSAuthorizationChecker(dnsprovider.NewAliyunAuthorizationChecker())
@@ -84,7 +87,9 @@ func Open(configPath, baseDir string) (*Runtime, error) {
}
func (r *Runtime) Handler() http.Handler {
- if r != nil && r.initial != nil { return r.initial }
+ 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) {
http.Error(writer, "Scert 核心服务未初始化", http.StatusServiceUnavailable)
@@ -100,7 +105,9 @@ func (r *Runtime) Close() error {
if r.cancel != nil {
r.cancel()
}
- if r.initialDNS != nil { r.initialDNS.DiscardAll() }
+ if r.initialDNS != nil {
+ r.initialDNS.DiscardAll()
+ }
if r.core != nil {
return r.core.Close()
}
diff --git a/backend/service/runtime_test.go b/backend/service/runtime_test.go
index 0507499..f3130e9 100644
--- a/backend/service/runtime_test.go
+++ b/backend/service/runtime_test.go
@@ -14,8 +14,12 @@ func TestOpenCreatesUninitializedRuntime(t *testing.T) {
t.Fatalf("打开桌面核心服务失败:%v", err)
}
defer runtime.Close()
- if runtime.core != nil { t.Fatal("桌面首次初始化不应创建正式应用") }
- if _, err := os.Stat(defaultConfig(baseDir).Database.DSN); !os.IsNotExist(err) { t.Fatalf("桌面初始化提前创建数据库:%v", err) }
+ if runtime.core != nil {
+ t.Fatal("桌面首次初始化不应创建正式应用")
+ }
+ if _, err := os.Stat(defaultConfig(baseDir).Database.DSN); !os.IsNotExist(err) {
+ t.Fatalf("桌面初始化提前创建数据库:%v", err)
+ }
request, err := http.NewRequest(http.MethodGet, "http://127.0.0.1/api/v1/setup/status", nil)
if err != nil {
diff --git a/frontend/package.json b/frontend/package.json
index f7a0c2e..b8076d9 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -4,7 +4,7 @@
"version": "0.1.0",
"type": "module",
"scripts": {
- "test": "node --test src/views/SetupWizard/SetupWizard.test.js src/views/Dashboard/Dashboard.test.js",
+ "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",
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
diff --git a/frontend/src/App.js b/frontend/src/App.js
index 8bb4282..5b24c8f 100644
--- a/frontend/src/App.js
+++ b/frontend/src/App.js
@@ -71,7 +71,13 @@ export function useAppShell() {
function handleInitialized(result) {
if (result?.completed && result?.management_url) {
- const destination = new URL(result.management_url);
+ let destination;
+ try {
+ destination = new URL(result.management_url);
+ } catch {
+ error.value = "正式管理地址无效,请检查服务器配置。";
+ return;
+ }
if (destination.protocol !== "https:" || destination.username || destination.password) {
error.value = "正式管理地址无效,请检查服务器配置。";
return;
diff --git a/frontend/src/App.test.js b/frontend/src/App.test.js
new file mode 100644
index 0000000..d3f88bc
--- /dev/null
+++ b/frontend/src/App.test.js
@@ -0,0 +1,60 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { useAppShell } from "./App.js";
+import { mountComposable } from "./test-support/mount.js";
+
+test("初始化令牌兑换前清除地址片段,后续请求只用同源会话", async (t) => {
+ const calls = [];
+ const { state } = await mountComposable(t, () => {
+ window.location.hash = "#token=test-bootstrap-token";
+ window.history.replaceState = (_, __, url) => calls.push({ replace: url });
+ return useAppShell();
+ }, async (path, options) => {
+ calls.push({ path, options });
+ if (path.endsWith("/status")) return Response.json({ mode: "bootstrap" });
+ if (path.endsWith("/session")) return Response.json({ authenticated: true });
+ return Response.json({ mode: "bootstrap", initialized: false });
+ });
+ assert.equal(state.error.value, "");
+ assert.equal(state.setupState.value.mode, "bootstrap");
+ assert.equal(calls[1].replace, "/");
+ assert.equal(calls[2].path, "/api/v1/bootstrap/session");
+ assert.equal(calls[2].options.credentials, "same-origin");
+ assert.deepEqual(JSON.parse(calls[2].options.body), { token: "test-bootstrap-token" });
+ assert.equal(calls[3].path, "/api/v1/bootstrap/settings");
+});
+
+test("没有令牌和有效会话时不暴露初始化表单", async (t) => {
+ const { state } = await mountComposable(t, useAppShell, async (path) => path.endsWith("/status")
+ ? Response.json({ mode: "bootstrap" }) : new Response(null, { status: 401 }));
+ assert.equal(state.setupState.value, null);
+ assert.match(state.error.value, /完整一次性地址/);
+});
+
+test("正式服务回退状态接口并检查登录态", async (t) => {
+ const calls = [];
+ const { state } = await mountComposable(t, useAppShell, async (path) => {
+ calls.push(path);
+ if (path.includes("bootstrap")) return new Response(null, { status: 404 });
+ if (path.includes("setup")) return Response.json({ initialized: true });
+ return new Response(null, { status: 401 });
+ });
+ assert.deepEqual(calls, ["/api/v1/bootstrap/status", "/api/v1/setup/status", "/api/v1/auth/session"]);
+ assert.equal(state.authenticated.value, false);
+});
+
+test("完成跳转拒绝畸形地址、非 HTTPS 和带密码地址", async (t) => {
+ const { state } = await mountComposable(t, () => {
+ window.location.pathname = "/login";
+ return useAppShell();
+ }, () => { throw new Error("不应发送请求"); });
+ for (const address of ["invalid", "http://example.test", "https://owner:password@example.test"]) {
+ assert.doesNotThrow(() => state.handleInitialized({ completed: true, management_url: address }));
+ assert.match(state.error.value, /正式管理地址无效/);
+ }
+ let destination;
+ window.setTimeout = (callback) => callback();
+ window.location.assign = (url) => { destination = url; };
+ state.handleInitialized({ completed: true, management_url: "https://127.0.0.1:8443/" });
+ assert.equal(destination, "https://127.0.0.1:8443/");
+});
diff --git a/frontend/src/test-support/mount.js b/frontend/src/test-support/mount.js
index 348ff90..14c2e6d 100644
--- a/frontend/src/test-support/mount.js
+++ b/frontend/src/test-support/mount.js
@@ -43,7 +43,10 @@ export async function mountComposable(t, factory, fetcher) {
clearInterval(intervalID) {
intervals.delete(intervalID);
},
- location: { assign() {} },
+ location: { pathname: "/", search: "", hash: "", assign() {} },
+ history: { replaceState() {} },
+ setTimeout,
+ clearTimeout,
},
document: { body: { style: { overflow: "" } } },
})) {
diff --git a/frontend/src/views/SetupWizard/BootstrapPayload.test.js b/frontend/src/views/SetupWizard/BootstrapPayload.test.js
new file mode 100644
index 0000000..9de6707
--- /dev/null
+++ b/frontend/src/views/SetupWizard/BootstrapPayload.test.js
@@ -0,0 +1,24 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { bootstrapPayload } from "./BootstrapPayload.js";
+
+test("初始化只提交服务端验证令牌,通知字段保持兼容且不带凭据引用", () => {
+ const payload = bootstrapPayload({
+ admin_username: "owner", dns_verification_token: "test-dns-token",
+ dns_credential_ref: "secret://untrusted/dns", certificate_root: "/untrusted",
+ email_http_enabled: true, email_http_credential_ref: "secret://untrusted/mail",
+ email_http_credentials: { token: "test-notification-token" },
+ email_smtp_password: "test-notification-password", email_retry_count: 0,
+ recipients: ["owner@example.test"],
+ }, "test-database-token", "staging");
+ assert.equal(payload.database_verification_token, "test-database-token");
+ assert.equal(payload.dns_verification_token, "test-dns-token");
+ assert.equal(payload.acme_environment, "staging");
+ assert.equal("certificate_root" in payload, false);
+ assert.equal("dns_credential_ref" in payload, false);
+ assert.equal("http_credential_ref" in payload.notification, false);
+ assert.deepEqual(payload.notification.http_credentials, { token: "test-notification-token" });
+ assert.equal(payload.notification.smtp_password, "test-notification-password");
+ assert.equal(payload.notification.retry_count, 0);
+ assert.deepEqual(payload.notification.recipients, ["owner@example.test"]);
+});
diff --git a/frontend/src/views/SetupWizard/DatabaseSetup.css b/frontend/src/views/SetupWizard/DatabaseSetup.css
new file mode 100644
index 0000000..1b332f8
--- /dev/null
+++ b/frontend/src/views/SetupWizard/DatabaseSetup.css
@@ -0,0 +1,88 @@
+.database-setup {
+ display: grid;
+ gap: 12px;
+ margin-top: 16px;
+ padding-top: 16px;
+ border-top: 1px solid #e4ebf5;
+ color: #32405a;
+ font-size: 12px;
+}
+
+.setup-section-heading {
+ display: grid;
+ gap: 5px;
+}
+
+.setup-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px;
+}
+
+label {
+ display: grid;
+ gap: 6px;
+ min-width: 0;
+}
+
+input,
+select {
+ box-sizing: border-box;
+ width: 100%;
+ min-width: 0;
+ min-height: 38px;
+ padding: 8px 10px;
+ border: 1px solid #d5deeb;
+ border-radius: 8px;
+ background: #fff;
+ color: #172033;
+ font: inherit;
+}
+
+.dns-test-actions {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 10px;
+}
+
+button {
+ padding: 9px 14px;
+ border: 1px solid #315faa;
+ border-radius: 8px;
+ background: #315faa;
+ color: #fff;
+ font: inherit;
+ cursor: pointer;
+}
+
+button:disabled {
+ opacity: 0.65;
+ cursor: wait;
+}
+
+input:focus-visible,
+select:focus-visible,
+button:focus-visible {
+ outline: 2px solid #315faa;
+ outline-offset: 2px;
+}
+
+p {
+ margin: 0;
+ line-height: 1.6;
+}
+
+.inline-error {
+ color: #b42318;
+}
+
+[role="status"] {
+ color: #26754f;
+}
+
+@media (max-width: 760px) {
+ .setup-grid {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
diff --git a/frontend/src/views/SetupWizard/DatabaseSetup.js b/frontend/src/views/SetupWizard/DatabaseSetup.js
index dc36420..3b99509 100644
--- a/frontend/src/views/SetupWizard/DatabaseSetup.js
+++ b/frontend/src/views/SetupWizard/DatabaseSetup.js
@@ -1,7 +1,15 @@
import { computed, onUnmounted, ref, watch } from "vue";
export function useDatabaseSetup(clock) {
- const databaseForm = ref({ driver: "sqlite", host: "", port: 3306, database: "scert", username: "", password: "", root_certificate: "" });
+ const databaseForm = ref({
+ driver: "sqlite",
+ host: "",
+ port: 3306,
+ database: "scert",
+ username: "",
+ password: "",
+ root_certificate: "",
+ });
const databaseTesting = ref(false);
const databaseError = ref("");
const databaseVerification = ref(null);
@@ -38,7 +46,8 @@ export function useDatabaseSetup(clock) {
});
const result = await response.json().catch(() => ({}));
if (current !== version) return;
- if (!response.ok || !result.verified || !result.verification_token) {
+ if (!response.ok || result.verified !== true || !result.verification_token ||
+ result.driver !== payload.driver || !(new Date(result.expires_at).getTime() > Date.now())) {
throw new Error(result.error || "数据库连接和写权限验证失败。");
}
databaseVerification.value = result;
diff --git a/frontend/src/views/SetupWizard/DatabaseSetup.test.js b/frontend/src/views/SetupWizard/DatabaseSetup.test.js
new file mode 100644
index 0000000..f13049c
--- /dev/null
+++ b/frontend/src/views/SetupWizard/DatabaseSetup.test.js
@@ -0,0 +1,96 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { ref } from "vue";
+import { useDatabaseSetup } from "./DatabaseSetup.js";
+import { deferred, mountComposable } from "../../test-support/mount.js";
+
+function verified(driver = "sqlite", token = "test-database-token") {
+ return Response.json({
+ driver,
+ verified: true,
+ verification_token: token,
+ expires_at: new Date(Date.now() + 60000).toISOString(),
+ });
+}
+
+for (const driver of ["sqlite", "mysql", "postgres"]) {
+ test(`数据库 ${driver} 使用结构化参数与严格 TLS`, async (t) => {
+ const requests = [];
+ const { state } = await mountComposable(t, () => useDatabaseSetup(ref(Date.now())), async (path, options) => {
+ requests.push({ path, payload: JSON.parse(options.body), credentials: options.credentials });
+ return verified(driver);
+ });
+ state.databaseForm.value.driver = driver;
+ Object.assign(state.databaseForm.value, {
+ host: "db.example.test", username: "test-user", password: "test-password",
+ });
+ await state.testDatabase();
+ assert.equal(state.databaseVerified.value, true);
+ assert.equal(requests[0].path, "/api/v1/bootstrap/database/test");
+ assert.equal(requests[0].credentials, "same-origin");
+ if (driver === "sqlite") {
+ assert.deepEqual(requests[0].payload, { driver: "sqlite" });
+ } else {
+ assert.equal(requests[0].payload.port, driver === "mysql" ? 3306 : 5432);
+ assert.equal(requests[0].payload.tls_mode, driver === "mysql" ? "true" : "verify-full");
+ }
+ });
+}
+
+test("数据库输入变化立即作废令牌并忽略旧响应", async (t) => {
+ const pending = deferred();
+ const { state } = await mountComposable(t, () => useDatabaseSetup(ref(Date.now())), () => pending.promise);
+ const task = state.testDatabase();
+ state.databaseForm.value.password = "changed-test-password";
+ pending.resolve(verified());
+ await task;
+ assert.equal(state.databaseVerified.value, false);
+ assert.equal(state.databaseVerification.value, null);
+ assert.equal(state.databaseTesting.value, false);
+});
+
+test("数据库旧失败不覆盖新成功,卸载后不接受在途结果", async (t) => {
+ const first = deferred();
+ const second = deferred();
+ const third = deferred();
+ const pending = [first, second, third];
+ const { state, unmount } = await mountComposable(t, () => useDatabaseSetup(ref(Date.now())), () => pending.shift().promise);
+ const oldTask = state.testDatabase();
+ const newTask = state.testDatabase();
+ second.resolve(verified());
+ await newTask;
+ first.reject(new Error("旧请求失败"));
+ await oldTask;
+ assert.equal(state.databaseVerified.value, true);
+ assert.equal(state.databaseError.value, "");
+ const closingTask = state.testDatabase();
+ unmount();
+ third.resolve(verified());
+ await closingTask;
+ assert.equal(state.databaseVerification.value, null);
+});
+
+test("数据库令牌到期后不可继续提交", async (t) => {
+ const clock = ref(Date.now());
+ const { state, advanceTime } = await mountComposable(t, () => useDatabaseSetup(clock), () => verified());
+ await state.testDatabase();
+ assert.equal(state.databaseVerified.value, true);
+ advanceTime(60001);
+ clock.value = Date.now();
+ assert.equal(state.databaseVerified.value, false);
+});
+
+test("拒绝数据库类型不匹配、非法有效期与畸形响应", async (t) => {
+ const responses = [
+ () => verified("mysql"),
+ () => Response.json({ verified: true, driver: "sqlite", verification_token: "test", expires_at: "invalid" }),
+ () => Response.json({ verified: "true", driver: "sqlite", verification_token: "test" }),
+ () => new Response("not-json", { status: 502 }),
+ ];
+ const { state } = await mountComposable(t, () => useDatabaseSetup(ref(Date.now())), () => responses.shift()());
+ for (let index = 0; index < 4; index += 1) {
+ await state.testDatabase();
+ assert.equal(state.databaseVerified.value, false);
+ assert.notEqual(state.databaseError.value, "");
+ }
+});
diff --git a/frontend/src/views/SetupWizard/DatabaseSetup.vue b/frontend/src/views/SetupWizard/DatabaseSetup.vue
index e2d6714..3d8bbf7 100644
--- a/frontend/src/views/SetupWizard/DatabaseSetup.vue
+++ b/frontend/src/views/SetupWizard/DatabaseSetup.vue
@@ -24,11 +24,26 @@ const emit = defineEmits(["test"]);
-
-
-
-
-
+
+
+
+
+
+
+