mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-09 06:23:29 +08:00
✨ feat(security): 完成配置密文存储前后端闭环
- 补齐连接与代理密文字段的保留替换清空语义 - 接通保存复制删除导入接口并返回 secretless 视图 - 刷新 Wails 绑定并补充实现留痕文档
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user