继续复审前保存初始化安全校验与链路测试检查点
This commit is contained in:
parent
199d727ecc
commit
c6bfcfd9ae
52
backend/internal/bootstrap/origin_test.go
Normal file
52
backend/internal/bootstrap/origin_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
|
||||
261
backend/internal/bootstrap/server_lifecycle_test.go
Normal file
261
backend/internal/bootstrap/server_lifecycle_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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;
|
||||
|
||||
60
frontend/src/App.test.js
Normal file
60
frontend/src/App.test.js
Normal file
@ -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/");
|
||||
});
|
||||
@ -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: "" } } },
|
||||
})) {
|
||||
|
||||
24
frontend/src/views/SetupWizard/BootstrapPayload.test.js
Normal file
24
frontend/src/views/SetupWizard/BootstrapPayload.test.js
Normal file
@ -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"]);
|
||||
});
|
||||
88
frontend/src/views/SetupWizard/DatabaseSetup.css
Normal file
88
frontend/src/views/SetupWizard/DatabaseSetup.css
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
96
frontend/src/views/SetupWizard/DatabaseSetup.test.js
Normal file
96
frontend/src/views/SetupWizard/DatabaseSetup.test.js
Normal file
@ -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, "");
|
||||
}
|
||||
});
|
||||
@ -24,11 +24,26 @@ const emit = defineEmits(["test"]);
|
||||
</select>
|
||||
</label>
|
||||
<template v-if="form.driver !== 'sqlite'">
|
||||
<label><span>数据库主机</span><input v-model.trim="form.host" autocomplete="off" placeholder="db.example.com" /></label>
|
||||
<label><span>数据库端口</span><input v-model.number="form.port" type="number" min="1" max="65535" /></label>
|
||||
<label><span>数据库名称</span><input v-model.trim="form.database" autocomplete="off" /></label>
|
||||
<label><span>数据库用户名</span><input v-model.trim="form.username" autocomplete="off" /></label>
|
||||
<label><span>数据库密码</span><input v-model="form.password" type="password" autocomplete="new-password" /></label>
|
||||
<label>
|
||||
<span>数据库主机</span>
|
||||
<input v-model.trim="form.host" autocomplete="off" placeholder="db.example.com" />
|
||||
</label>
|
||||
<label>
|
||||
<span>数据库端口</span>
|
||||
<input v-model.number="form.port" type="number" min="1" max="65535" />
|
||||
</label>
|
||||
<label>
|
||||
<span>数据库名称</span>
|
||||
<input v-model.trim="form.database" autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
<span>数据库用户名</span>
|
||||
<input v-model.trim="form.username" autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
<span>数据库密码</span>
|
||||
<input v-model="form.password" type="password" autocomplete="new-password" />
|
||||
</label>
|
||||
<label v-if="form.driver === 'postgres'">
|
||||
<span>根证书路径(服务器绝对路径,可选)</span>
|
||||
<input v-model.trim="form.root_certificate" placeholder="使用系统信任库时留空" />
|
||||
@ -45,3 +60,5 @@ const emit = defineEmits(["test"]);
|
||||
<p v-if="error" class="inline-error" role="alert">{{ error }}</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped src="./DatabaseSetup.css"></style>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user