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"]); + +