继续复审前保存初始化安全校验与链路测试检查点

This commit is contained in:
xuans 2026-09-16 20:06:36 +08:00
parent 199d727ecc
commit c6bfcfd9ae
15 changed files with 676 additions and 23 deletions

View 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)
}
}
}

View File

@ -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")

View 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)
}
}
}

View File

@ -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)

View File

@ -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()
}

View File

@ -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 {

View File

@ -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"

View File

@ -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
View 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/");
});

View File

@ -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: "" } } },
})) {

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

View 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);
}
}

View File

@ -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;

View 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, "");
}
});

View File

@ -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>