feat(security): 完成配置密文存储前后端闭环

- 补齐连接与代理密文字段的保留替换清空语义

- 接通保存复制删除导入接口并返回 secretless 视图

- 刷新 Wails 绑定并补充实现留痕文档
This commit is contained in:
tianqijiuyun-latiao
2026-04-03 20:11:53 +08:00
parent 91b5b85904
commit 4718755208
17 changed files with 1207 additions and 186 deletions

View File

@@ -1,6 +1,7 @@
package app
import (
"reflect"
"testing"
"GoNavi-Wails/internal/connection"
@@ -11,8 +12,11 @@ func TestSaveConnectionMethodReturnsSecretlessView(t *testing.T) {
app.configDir = t.TempDir()
result, err := app.SaveConnection(connection.SavedConnectionInput{
ID: "conn-1",
Name: "Primary",
ID: "conn-1",
Name: "Primary",
IncludeDatabases: []string{"appdb"},
IconType: "postgres",
IconColor: "#1677ff",
Config: connection.ConnectionConfig{
ID: "conn-1",
Type: "postgres",
@@ -31,6 +35,79 @@ func TestSaveConnectionMethodReturnsSecretlessView(t *testing.T) {
if !result.HasPrimaryPassword {
t.Fatal("expected HasPrimaryPassword=true")
}
if !reflect.DeepEqual(result.IncludeDatabases, []string{"appdb"}) {
t.Fatalf("expected include databases to be preserved, got %#v", result.IncludeDatabases)
}
if result.IconType != "postgres" || result.IconColor != "#1677ff" {
t.Fatalf("expected icon metadata to be preserved, got type=%q color=%q", result.IconType, result.IconColor)
}
}
func TestSaveConnectionClearsRequestedSecretFields(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
_, err := app.SaveConnection(connection.SavedConnectionInput{
ID: "conn-1",
Name: "Primary",
Config: connection.ConnectionConfig{
ID: "conn-1",
Type: "postgres",
Host: "db.local",
Port: 5432,
User: "postgres",
Password: "postgres-secret",
UseSSH: true,
SSH: connection.SSHConfig{
Host: "jump.local",
Port: 22,
User: "ops",
Password: "ssh-secret",
},
},
})
if err != nil {
t.Fatal(err)
}
view, err := app.SaveConnection(connection.SavedConnectionInput{
ID: "conn-1",
Name: "Primary",
Config: connection.ConnectionConfig{
ID: "conn-1",
Type: "postgres",
Host: "db.local",
Port: 5432,
User: "postgres",
UseSSH: true,
SSH: connection.SSHConfig{
Host: "jump.local",
Port: 22,
User: "ops",
},
},
ClearPrimaryPassword: true,
})
if err != nil {
t.Fatal(err)
}
if view.HasPrimaryPassword {
t.Fatal("expected HasPrimaryPassword=false after clearing")
}
if !view.HasSSHPassword {
t.Fatal("expected SSH password to stay stored")
}
resolved, err := app.resolveConnectionSecrets(view.Config)
if err != nil {
t.Fatal(err)
}
if resolved.Password != "" {
t.Fatalf("expected cleared primary password, got %q", resolved.Password)
}
if resolved.SSH.Password != "ssh-secret" {
t.Fatalf("expected SSH password to stay stored, got %q", resolved.SSH.Password)
}
}
func TestDuplicateConnectionClonesSecretBundle(t *testing.T) {
@@ -38,8 +115,12 @@ func TestDuplicateConnectionClonesSecretBundle(t *testing.T) {
app.configDir = t.TempDir()
_, err := app.SaveConnection(connection.SavedConnectionInput{
ID: "conn-1",
Name: "Primary",
ID: "conn-1",
Name: "Primary",
IncludeDatabases: []string{"appdb"},
IncludeRedisDatabases: []int{0, 1},
IconType: "postgres",
IconColor: "#1677ff",
Config: connection.ConnectionConfig{
ID: "conn-1",
Type: "postgres",
@@ -60,6 +141,18 @@ func TestDuplicateConnectionClonesSecretBundle(t *testing.T) {
if duplicate.ID == "conn-1" {
t.Fatal("duplicate should have a new id")
}
if duplicate.Name != "Primary - 副本" {
t.Fatalf("expected duplicate name to keep existing UX, got %q", duplicate.Name)
}
if !reflect.DeepEqual(duplicate.IncludeDatabases, []string{"appdb"}) {
t.Fatalf("expected include databases to be cloned, got %#v", duplicate.IncludeDatabases)
}
if !reflect.DeepEqual(duplicate.IncludeRedisDatabases, []int{0, 1}) {
t.Fatalf("expected redis include databases to be cloned, got %#v", duplicate.IncludeRedisDatabases)
}
if duplicate.IconType != "postgres" || duplicate.IconColor != "#1677ff" {
t.Fatalf("expected icon metadata to be cloned, got type=%q color=%q", duplicate.IconType, duplicate.IconColor)
}
resolved, err := app.resolveConnectionSecrets(duplicate.Config)
if err != nil {

View File

@@ -95,6 +95,53 @@ func mergeConnectionSecretBundles(base, overlay connectionSecretBundle) connecti
return merged
}
func applyConnectionSecretClears(bundle connectionSecretBundle, input connection.SavedConnectionInput) connectionSecretBundle {
cleared := bundle
if input.ClearPrimaryPassword {
cleared.Password = ""
}
if input.ClearSSHPassword {
cleared.SSHPassword = ""
}
if input.ClearProxyPassword {
cleared.ProxyPassword = ""
}
if input.ClearHTTPTunnelPassword {
cleared.HTTPTunnelPassword = ""
}
if input.ClearMySQLReplicaPassword {
cleared.MySQLReplicaPassword = ""
}
if input.ClearMongoReplicaPassword {
cleared.MongoReplicaPassword = ""
}
if input.ClearOpaqueURI {
cleared.OpaqueURI = ""
}
if input.ClearOpaqueDSN {
cleared.OpaqueDSN = ""
}
return cleared
}
func cloneStringSlice(input []string) []string {
if len(input) == 0 {
return nil
}
cloned := make([]string, len(input))
copy(cloned, input)
return cloned
}
func cloneIntSlice(input []int) []int {
if len(input) == 0 {
return nil
}
cloned := make([]int, len(input))
copy(cloned, input)
return cloned
}
func splitConnectionSecrets(input connection.SavedConnectionInput) (connection.SavedConnectionView, connectionSecretBundle) {
id := strings.TrimSpace(input.ID)
if id == "" {
@@ -143,6 +190,10 @@ func splitConnectionSecrets(input connection.SavedConnectionInput) (connection.S
ID: id,
Name: strings.TrimSpace(input.Name),
Config: meta,
IncludeDatabases: cloneStringSlice(input.IncludeDatabases),
IncludeRedisDatabases: cloneIntSlice(input.IncludeRedisDatabases),
IconType: strings.TrimSpace(input.IconType),
IconColor: strings.TrimSpace(input.IconColor),
HasPrimaryPassword: strings.TrimSpace(bundle.Password) != "",
HasSSHPassword: strings.TrimSpace(bundle.SSHPassword) != "",
HasProxyPassword: strings.TrimSpace(bundle.ProxyPassword) != "",
@@ -223,6 +274,7 @@ func (r *savedConnectionRepository) Save(input connection.SavedConnectionInput)
mergedBundle = mergeConnectionSecretBundles(existingBundle, bundle)
view.SecretRef = existing.SecretRef
}
mergedBundle = applyConnectionSecretClears(mergedBundle, input)
if mergedBundle.hasAny() {
ref, storeErr := r.storeSecretBundle(view.ID, view.SecretRef, mergedBundle)
@@ -332,6 +384,27 @@ func applyConnectionBundleFlags(view *connection.SavedConnectionView, bundle con
view.HasOpaqueDSN = strings.TrimSpace(bundle.OpaqueDSN) != ""
}
func buildDuplicateConnectionName(baseName string, existing []connection.SavedConnectionView) string {
trimmedBaseName := strings.TrimSpace(baseName)
if trimmedBaseName == "" {
trimmedBaseName = "连接"
}
suffix := " - 副本"
usedNames := make(map[string]struct{}, len(existing))
for _, item := range existing {
usedNames[strings.TrimSpace(item.Name)] = struct{}{}
}
candidate := trimmedBaseName + suffix
counter := 2
for {
if _, exists := usedNames[candidate]; !exists {
return candidate
}
candidate = fmt.Sprintf("%s%s %d", trimmedBaseName, suffix, counter)
counter++
}
}
func (r *savedConnectionRepository) List() ([]connection.SavedConnectionView, error) {
return r.load()
}
@@ -357,15 +430,27 @@ func (r *savedConnectionRepository) Delete(id string) error {
}
func (r *savedConnectionRepository) Duplicate(id string) (connection.SavedConnectionView, error) {
original, err := r.Find(id)
connections, err := r.load()
if err != nil {
return connection.SavedConnectionView{}, err
}
index := -1
for i, item := range connections {
if item.ID == strings.TrimSpace(id) {
index = i
break
}
}
if index < 0 {
return connection.SavedConnectionView{}, fmt.Errorf("saved connection not found: %s", id)
}
original := connections[index]
duplicate := original
duplicate.ID = "conn-" + uuid.New().String()[:8]
duplicate.Config.ID = duplicate.ID
duplicate.Name = original.Name + " Copy"
duplicate.Name = buildDuplicateConnectionName(original.Name, connections)
bundle, err := r.loadSecretBundle(original)
if err != nil {
@@ -383,10 +468,6 @@ func (r *savedConnectionRepository) Duplicate(id string) (connection.SavedConnec
applyConnectionBundleFlags(&duplicate, connectionSecretBundle{})
}
connections, err := r.load()
if err != nil {
return connection.SavedConnectionView{}, err
}
connections = append(connections, duplicate)
if err := r.saveAll(connections); err != nil {
return connection.SavedConnectionView{}, err

View File

@@ -1,15 +1,31 @@
package connection
type SavedConnectionInput struct {
ID string `json:"id,omitempty"`
Name string `json:"name"`
Config ConnectionConfig `json:"config"`
ID string `json:"id,omitempty"`
Name string `json:"name"`
Config ConnectionConfig `json:"config"`
IncludeDatabases []string `json:"includeDatabases,omitempty"`
IncludeRedisDatabases []int `json:"includeRedisDatabases,omitempty"`
IconType string `json:"iconType,omitempty"`
IconColor string `json:"iconColor,omitempty"`
ClearPrimaryPassword bool `json:"clearPrimaryPassword,omitempty"`
ClearSSHPassword bool `json:"clearSSHPassword,omitempty"`
ClearProxyPassword bool `json:"clearProxyPassword,omitempty"`
ClearHTTPTunnelPassword bool `json:"clearHttpTunnelPassword,omitempty"`
ClearMySQLReplicaPassword bool `json:"clearMySQLReplicaPassword,omitempty"`
ClearMongoReplicaPassword bool `json:"clearMongoReplicaPassword,omitempty"`
ClearOpaqueURI bool `json:"clearOpaqueURI,omitempty"`
ClearOpaqueDSN bool `json:"clearOpaqueDSN,omitempty"`
}
type SavedConnectionView struct {
ID string `json:"id"`
Name string `json:"name"`
Config ConnectionConfig `json:"config"`
IncludeDatabases []string `json:"includeDatabases,omitempty"`
IncludeRedisDatabases []int `json:"includeRedisDatabases,omitempty"`
IconType string `json:"iconType,omitempty"`
IconColor string `json:"iconColor,omitempty"`
SecretRef string `json:"secretRef,omitempty"`
HasPrimaryPassword bool `json:"hasPrimaryPassword,omitempty"`
HasSSHPassword bool `json:"hasSSHPassword,omitempty"`