🐛 fix(security): 完善密文升级导入覆盖与安全更新链路

- 完善连接恢复包与 legacy 导入覆盖语义及密文兼容处理

- 修复安全更新详情高亮反馈与相关前后端链路

- 补强 keyring 误判边界与安全更新回归测试
This commit is contained in:
tianqijiuyun-latiao
2026-04-11 16:53:03 +08:00
parent 070ff72ad8
commit 82e06bd94d
35 changed files with 2021 additions and 110 deletions

View File

@@ -69,7 +69,13 @@ func encryptConnectionPackage(payload connectionPackagePayload, password string)
}
ciphertext := aead.Seal(nil, nonce, plain, aad)
if len(ciphertext) > connectionPackageMaxCiphertextBytes {
return connectionPackageFile{}, errConnectionPackagePayloadTooLarge
}
file.Payload = base64.StdEncoding.EncodeToString(ciphertext)
if len(file.Payload) > connectionPackageMaxPayloadBase64Bytes {
return connectionPackageFile{}, errConnectionPackagePayloadTooLarge
}
return file, nil
}
@@ -84,6 +90,9 @@ func decryptConnectionPackage(file connectionPackageFile, password string) (conn
plain, err := decryptConnectionPackagePlaintext(file, normalizedPassword)
if err != nil {
if errors.Is(err, errConnectionPackagePayloadTooLarge) {
return connectionPackagePayload{}, err
}
return connectionPackagePayload{}, errConnectionPackageDecryptFailed
}
@@ -127,10 +136,16 @@ func decryptConnectionPackagePlaintext(file connectionPackageFile, password stri
if err != nil || len(nonce) != connectionPackageNonceBytes {
return nil, errors.New("invalid nonce")
}
if len(file.Payload) > connectionPackageMaxPayloadBase64Bytes {
return nil, errConnectionPackagePayloadTooLarge
}
ciphertext, err := base64.StdEncoding.DecodeString(file.Payload)
if err != nil || len(ciphertext) == 0 {
return nil, errors.New("invalid payload")
}
if len(ciphertext) > connectionPackageMaxCiphertextBytes {
return nil, errConnectionPackagePayloadTooLarge
}
key, err := deriveConnectionPackageKey(password, file.KDF)
if err != nil {

View File

@@ -1,9 +1,11 @@
package app
import (
"encoding/base64"
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"GoNavi-Wails/internal/connection"
@@ -222,3 +224,74 @@ func TestValidateConnectionPackageKDFSpecRejectsOversizedParams(t *testing.T) {
}
})
}
func TestDecryptConnectionPackagePlaintextRejectsOversizedPayload(t *testing.T) {
nonce := base64.StdEncoding.EncodeToString(make([]byte, connectionPackageNonceBytes))
salt := base64.StdEncoding.EncodeToString(make([]byte, connectionPackageSaltBytes))
payload := base64.StdEncoding.EncodeToString(make([]byte, connectionPackageMaxCiphertextBytes+1))
file := connectionPackageFile{
SchemaVersion: connectionPackageSchemaVersion,
Kind: connectionPackageKind,
Cipher: connectionPackageCipher,
KDF: connectionPackageKDFSpec{
Name: connectionPackageKDFName,
MemoryKiB: connectionPackageKDFDefaultMemoryKiB,
TimeCost: connectionPackageKDFDefaultTimeCost,
Parallelism: connectionPackageKDFDefaultParallelism,
Salt: salt,
},
Nonce: nonce,
Payload: payload,
}
_, err := decryptConnectionPackagePlaintext(file, "correct-password")
if !errors.Is(err, errConnectionPackagePayloadTooLarge) {
t.Fatalf("oversized payload should return errConnectionPackagePayloadTooLarge, got: %v", err)
}
}
func TestDecryptConnectionPackagePlaintextRejectsOversizedBase64PayloadBeforeDecode(t *testing.T) {
nonce := base64.StdEncoding.EncodeToString(make([]byte, connectionPackageNonceBytes))
file := connectionPackageFile{
SchemaVersion: connectionPackageSchemaVersion,
Kind: connectionPackageKind,
Cipher: connectionPackageCipher,
KDF: connectionPackageKDFSpec{
Name: connectionPackageKDFName,
MemoryKiB: connectionPackageKDFDefaultMemoryKiB,
TimeCost: connectionPackageKDFDefaultTimeCost,
Parallelism: connectionPackageKDFDefaultParallelism,
Salt: base64.StdEncoding.EncodeToString(make([]byte, connectionPackageSaltBytes)),
},
Nonce: nonce,
Payload: strings.Repeat("A", connectionPackageMaxPayloadBase64Bytes+4),
}
_, err := decryptConnectionPackagePlaintext(file, "correct-password")
if !errors.Is(err, errConnectionPackagePayloadTooLarge) {
t.Fatalf("oversized base64 payload should return errConnectionPackagePayloadTooLarge, got: %v", err)
}
}
func TestEncryptConnectionPackageRejectsOversizedPayload(t *testing.T) {
_, err := encryptConnectionPackage(connectionPackagePayload{
Connections: []connectionPackageItem{
{
ID: "conn-large",
Name: strings.Repeat("x", connectionPackageMaxCiphertextBytes),
Config: connection.ConnectionConfig{
ID: "conn-large",
Type: "postgres",
Host: "db.large.local",
Port: 5432,
User: "postgres",
},
},
},
}, "correct-password")
if !errors.Is(err, errConnectionPackagePayloadTooLarge) {
t.Fatalf("oversized export payload should return errConnectionPackagePayloadTooLarge, got: %v", err)
}
}

View File

@@ -9,6 +9,8 @@ import (
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/secretstore"
"github.com/google/uuid"
)
func newConnectionPackageItem(view connection.SavedConnectionView, bundle connectionSecretBundle) connectionPackageItem {
@@ -86,25 +88,99 @@ func newSavedConnectionInputFromPackageItem(item connectionPackageItem) connecti
}
}
func (a *App) importConnectionPackagePayload(payload connectionPackagePayload) ([]connection.SavedConnectionView, error) {
func dedupeImportedSavedConnectionViews(views []connection.SavedConnectionView) []connection.SavedConnectionView {
if len(views) < 2 {
return views
}
lastIndexByID := make(map[string]int, len(views))
for index, view := range views {
id := strings.TrimSpace(view.ID)
if id == "" {
continue
}
lastIndexByID[id] = index
}
result := make([]connection.SavedConnectionView, 0, len(views))
for index, view := range views {
id := strings.TrimSpace(view.ID)
if id != "" && lastIndexByID[id] != index {
continue
}
result = append(result, view)
}
return result
}
func dedupeImportedSavedConnectionInputs(inputs []connection.SavedConnectionInput) []connection.SavedConnectionInput {
if len(inputs) < 2 {
return inputs
}
lastIndexByID := make(map[string]int, len(inputs))
for index, input := range inputs {
id := strings.TrimSpace(input.ID)
if id == "" {
continue
}
lastIndexByID[id] = index
}
result := make([]connection.SavedConnectionInput, 0, len(inputs))
for index, input := range inputs {
id := strings.TrimSpace(input.ID)
if id != "" && lastIndexByID[id] != index {
continue
}
result = append(result, input)
}
return result
}
func normalizeImportedSavedConnectionInput(input connection.SavedConnectionInput) connection.SavedConnectionInput {
if strings.TrimSpace(input.ID) == "" && strings.TrimSpace(input.Config.ID) == "" {
input.ID = "conn-" + uuid.New().String()[:8]
}
if strings.TrimSpace(input.ID) == "" {
input.ID = strings.TrimSpace(input.Config.ID)
}
input.Config.ID = input.ID
return input
}
func (a *App) importSavedConnectionsAtomically(inputs []connection.SavedConnectionInput) ([]connection.SavedConnectionView, error) {
repo := a.savedConnectionRepository()
rollbackSnapshot, err := captureConnectionPackageImportRollbackSnapshot(a, payload)
normalizedInputs := make([]connection.SavedConnectionInput, 0, len(inputs))
for _, input := range inputs {
normalizedInputs = append(normalizedInputs, normalizeImportedSavedConnectionInput(input))
}
finalInputs := dedupeImportedSavedConnectionInputs(normalizedInputs)
rollbackSnapshot, err := captureConnectionImportRollbackSnapshot(a, finalInputs)
if err != nil {
return nil, err
}
result := make([]connection.SavedConnectionView, 0, len(payload.Connections))
for _, item := range payload.Connections {
view, err := repo.Save(newSavedConnectionInputFromPackageItem(item))
result := make([]connection.SavedConnectionView, 0, len(finalInputs))
for _, input := range finalInputs {
view, err := repo.Save(input)
if err != nil {
if rollbackErr := rollbackSnapshot.restore(a); rollbackErr != nil {
return nil, errors.Join(err, fmt.Errorf("restore connection package rollback: %w", rollbackErr))
return nil, errors.Join(err, fmt.Errorf("restore connection import rollback: %w", rollbackErr))
}
return nil, err
}
result = append(result, view)
}
return result, nil
return dedupeImportedSavedConnectionViews(result), nil
}
func (a *App) importConnectionPackagePayload(payload connectionPackagePayload) ([]connection.SavedConnectionView, error) {
inputs := make([]connection.SavedConnectionInput, 0, len(payload.Connections))
for _, item := range payload.Connections {
inputs = append(inputs, newSavedConnectionInputFromPackageItem(item))
}
return a.importSavedConnectionsAtomically(inputs)
}
func (a *App) ImportConnectionsPayload(raw string, password string) ([]connection.SavedConnectionView, error) {
@@ -112,6 +188,9 @@ func (a *App) ImportConnectionsPayload(raw string, password string) ([]connectio
if trimmed == "" {
return nil, errConnectionPackageUnsupported
}
if len(trimmed) > connectionImportMaxFileBytes {
return nil, errConnectionImportFileTooLarge
}
if isConnectionPackageEnvelope(trimmed) {
var file connectionPackageFile
@@ -139,7 +218,7 @@ type connectionPackageImportRollbackSnapshot struct {
connectionCleanupRefs []string
}
func captureConnectionPackageImportRollbackSnapshot(a *App, payload connectionPackagePayload) (connectionPackageImportRollbackSnapshot, error) {
func captureConnectionImportRollbackSnapshot(a *App, inputs []connection.SavedConnectionInput) (connectionPackageImportRollbackSnapshot, error) {
snapshot := connectionPackageImportRollbackSnapshot{
connectionSecrets: make(map[string]securityUpdateSecretSnapshot),
}
@@ -163,9 +242,11 @@ func captureConnectionPackageImportRollbackSnapshot(a *App, payload connectionPa
cleanupSet := make(map[string]struct{})
seenIDs := make(map[string]struct{})
for _, item := range payload.Connections {
input := newSavedConnectionInputFromPackageItem(item)
for _, input := range inputs {
connectionID := strings.TrimSpace(input.ID)
if connectionID == "" {
connectionID = strings.TrimSpace(input.Config.ID)
}
if connectionID == "" {
continue
}

View File

@@ -173,7 +173,7 @@ func TestImportConnectionPackagePayloadLatestEntryWinsForSameID(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
_, err := app.importConnectionPackagePayload(connectionPackagePayload{
imported, err := app.importConnectionPackagePayload(connectionPackagePayload{
Connections: []connectionPackageItem{
{
ID: "conn-dup",
@@ -204,6 +204,12 @@ func TestImportConnectionPackagePayloadLatestEntryWinsForSameID(t *testing.T) {
if err != nil {
t.Fatalf("importConnectionPackagePayload returned error: %v", err)
}
if len(imported) != 1 {
t.Fatalf("expected duplicate ids to return 1 final imported item, got %d", len(imported))
}
if imported[0].Name != "Second" {
t.Fatalf("expected returned import result to keep latest entry, got %q", imported[0].Name)
}
saved, err := app.GetSavedConnections()
if err != nil {
@@ -225,6 +231,153 @@ func TestImportConnectionPackagePayloadLatestEntryWinsForSameID(t *testing.T) {
}
}
func TestImportConnectionsPayloadLegacyJSONRollsBackOnSaveFailure(t *testing.T) {
failRef, err := secretstore.BuildRef(savedConnectionSecretKind, "legacy-2")
if err != nil {
t.Fatalf("BuildRef returned error: %v", err)
}
store := newFailOnPutSecretStore(failRef)
app := NewAppWithSecretStore(store)
app.configDir = t.TempDir()
_, err = app.SaveConnection(connection.SavedConnectionInput{
ID: "legacy-1",
Name: "Existing Legacy",
Config: connection.ConnectionConfig{
ID: "legacy-1",
Type: "postgres",
Host: "db.old.local",
Port: 5432,
User: "postgres",
Password: "old-primary",
},
})
if err != nil {
t.Fatalf("SaveConnection returned error: %v", err)
}
raw, err := json.Marshal([]connection.LegacySavedConnection{
{
ID: "legacy-1",
Name: "Imported Existing Legacy",
Config: connection.ConnectionConfig{
ID: "legacy-1",
Type: "postgres",
Host: "db.new.local",
Port: 5432,
User: "postgres",
},
},
{
ID: "legacy-2",
Name: "Imported New Legacy",
Config: connection.ConnectionConfig{
ID: "legacy-2",
Type: "mysql",
Host: "db.second.local",
Port: 3306,
User: "root",
Password: "second-primary",
},
},
})
if err != nil {
t.Fatalf("json.Marshal returned error: %v", err)
}
imported, err := app.ImportConnectionsPayload(string(raw), "ignored")
if err == nil {
t.Fatal("expected ImportConnectionsPayload to return error")
}
if imported != nil {
t.Fatalf("expected no imported results after rollback, got %#v", imported)
}
saved, err := app.GetSavedConnections()
if err != nil {
t.Fatalf("GetSavedConnections returned error: %v", err)
}
if len(saved) != 1 {
t.Fatalf("expected rollback to restore exactly 1 legacy connection, got %d", len(saved))
}
if saved[0].ID != "legacy-1" || saved[0].Name != "Existing Legacy" {
t.Fatalf("expected rollback to restore original legacy metadata, got %#v", saved[0])
}
if saved[0].Config.Host != "db.old.local" {
t.Fatalf("expected rollback to restore original legacy host, got %q", saved[0].Config.Host)
}
resolved, err := app.resolveConnectionSecrets(saved[0].Config)
if err != nil {
t.Fatalf("resolveConnectionSecrets returned error: %v", err)
}
if resolved.Password != "old-primary" {
t.Fatalf("expected rollback to restore original legacy password, got %q", resolved.Password)
}
if _, err := store.Get(failRef); !os.IsNotExist(err) {
t.Fatalf("expected rollback to remove partially imported legacy secret ref, got err=%v", err)
}
}
func TestImportLegacyConnectionsRollbackRemovesGeneratedSecretRefs(t *testing.T) {
failRef, err := secretstore.BuildRef(savedConnectionSecretKind, "legacy-2")
if err != nil {
t.Fatalf("BuildRef returned error: %v", err)
}
store := newFailOnPutSecretStore(failRef)
app := NewAppWithSecretStore(store)
app.configDir = t.TempDir()
imported, err := app.ImportLegacyConnections([]connection.LegacySavedConnection{
{
Name: "Generated ID Legacy",
Config: connection.ConnectionConfig{
Type: "postgres",
Host: "db.generated.local",
Port: 5432,
User: "postgres",
Password: "generated-secret",
},
},
{
ID: "legacy-2",
Name: "Will Fail",
Config: connection.ConnectionConfig{
ID: "legacy-2",
Type: "mysql",
Host: "db.fail.local",
Port: 3306,
User: "root",
Password: "fail-secret",
},
},
})
if err == nil {
t.Fatal("expected ImportLegacyConnections to return error")
}
if imported != nil {
t.Fatalf("expected no imported results after rollback, got %#v", imported)
}
saved, err := app.GetSavedConnections()
if err != nil {
t.Fatalf("GetSavedConnections returned error: %v", err)
}
if len(saved) != 0 {
t.Fatalf("expected rollback to remove generated-id connection, got %d saved connections", len(saved))
}
if got := len(store.base.items); got != 0 {
t.Fatalf("expected rollback to remove generated secret refs, got %d remaining items", got)
}
if _, err := store.Get(failRef); !os.IsNotExist(err) {
t.Fatalf("expected rollback to remove failed explicit secret ref, got err=%v", err)
}
}
func TestImportConnectionPackagePayloadRollsBackOnSaveFailure(t *testing.T) {
failRef, err := secretstore.BuildRef(savedConnectionSecretKind, "conn-2")
if err != nil {
@@ -313,7 +466,7 @@ func TestImportConnectionPackagePayloadRollsBackOnSaveFailure(t *testing.T) {
}
}
func TestImportConnectionsPayloadLegacyJSONKeepsExistingSecretWhenMissing(t *testing.T) {
func TestImportConnectionsPayloadLegacyJSONClearsExistingSecretWhenMissing(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
@@ -365,8 +518,162 @@ func TestImportConnectionsPayloadLegacyJSONKeepsExistingSecretWhenMissing(t *tes
if err != nil {
t.Fatalf("resolveConnectionSecrets returned error: %v", err)
}
if resolved.Password != "legacy-secret" {
t.Fatalf("expected legacy import to preserve existing secret, got %q", resolved.Password)
if resolved.Password != "" {
t.Fatalf("expected legacy import to clear existing secret when the imported file omits it, got %q", resolved.Password)
}
}
func TestImportConnectionsPayloadLegacyJSONLatestEntryWinsForSameID(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
raw, err := json.Marshal([]connection.LegacySavedConnection{
{
ID: "legacy-dup",
Name: "First",
Config: connection.ConnectionConfig{
ID: "legacy-dup",
Type: "postgres",
Host: "db.first.local",
Port: 5432,
User: "postgres",
Password: "first-secret",
},
},
{
ID: "legacy-dup",
Name: "Second",
Config: connection.ConnectionConfig{
ID: "legacy-dup",
Type: "postgres",
Host: "db.second.local",
Port: 5432,
User: "postgres",
Password: "second-secret",
},
},
})
if err != nil {
t.Fatalf("json.Marshal returned error: %v", err)
}
imported, err := app.ImportConnectionsPayload(string(raw), "ignored")
if err != nil {
t.Fatalf("ImportConnectionsPayload returned error: %v", err)
}
if len(imported) != 1 {
t.Fatalf("expected duplicate legacy ids to return 1 final imported item, got %d", len(imported))
}
if imported[0].Name != "Second" {
t.Fatalf("expected returned import result to keep latest legacy entry, got %q", imported[0].Name)
}
saved, err := app.GetSavedConnections()
if err != nil {
t.Fatalf("GetSavedConnections returned error: %v", err)
}
if len(saved) != 1 {
t.Fatalf("expected 1 saved legacy item after duplicate id overwrite, got %d", len(saved))
}
if saved[0].Name != "Second" {
t.Fatalf("expected latest legacy item to win, got %q", saved[0].Name)
}
resolved, err := app.resolveConnectionSecrets(saved[0].Config)
if err != nil {
t.Fatalf("resolveConnectionSecrets returned error: %v", err)
}
if resolved.Password != "second-secret" {
t.Fatalf("expected latest legacy secret to win, got %q", resolved.Password)
}
}
func TestImportConnectionsPayloadLegacyJSONLatestEntryWithoutPasswordDoesNotKeepEarlierDuplicateSecret(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
raw, err := json.Marshal([]connection.LegacySavedConnection{
{
ID: "legacy-dup",
Name: "First",
Config: connection.ConnectionConfig{
ID: "legacy-dup",
Type: "postgres",
Host: "db.first.local",
Port: 5432,
User: "postgres",
Password: "first-secret",
},
},
{
ID: "legacy-dup",
Name: "Second",
Config: connection.ConnectionConfig{
ID: "legacy-dup",
Type: "postgres",
Host: "db.second.local",
Port: 5432,
User: "postgres",
},
},
})
if err != nil {
t.Fatalf("json.Marshal returned error: %v", err)
}
imported, err := app.ImportConnectionsPayload(string(raw), "ignored")
if err != nil {
t.Fatalf("ImportConnectionsPayload returned error: %v", err)
}
if len(imported) != 1 {
t.Fatalf("expected duplicate legacy ids to return 1 final imported item, got %d", len(imported))
}
saved, err := app.GetSavedConnections()
if err != nil {
t.Fatalf("GetSavedConnections returned error: %v", err)
}
if len(saved) != 1 {
t.Fatalf("expected 1 saved legacy item after duplicate id overwrite, got %d", len(saved))
}
if saved[0].HasPrimaryPassword {
t.Fatalf("expected latest legacy item without password to clear earlier duplicate secret, got view=%#v", saved[0])
}
resolved, err := app.resolveConnectionSecrets(saved[0].Config)
if err != nil {
t.Fatalf("resolveConnectionSecrets returned error: %v", err)
}
if resolved.Password != "" {
t.Fatalf("expected latest legacy item without password to keep empty secret, got %q", resolved.Password)
}
}
func TestImportConnectionsPayloadEnvelopeRejectsOversizedPayloadWithDedicatedError(t *testing.T) {
raw, err := json.Marshal(connectionPackageFile{
SchemaVersion: connectionPackageSchemaVersion,
Kind: connectionPackageKind,
Cipher: connectionPackageCipher,
KDF: connectionPackageKDFSpec{
Name: connectionPackageKDFName,
MemoryKiB: connectionPackageKDFDefaultMemoryKiB,
TimeCost: connectionPackageKDFDefaultTimeCost,
Parallelism: connectionPackageKDFDefaultParallelism,
Salt: "AAAAAAAAAAAAAAAAAAAAAA==",
},
Nonce: "AAAAAAAAAAAAAAAA",
Payload: strings.Repeat("A", connectionPackageMaxPayloadBase64Bytes+4),
})
if err != nil {
t.Fatalf("json.Marshal returned error: %v", err)
}
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
_, err = app.ImportConnectionsPayload(string(raw), "package-password")
if !errors.Is(err, errConnectionPackagePayloadTooLarge) {
t.Fatalf("expected errConnectionPackagePayloadTooLarge, got %v", err)
}
}

View File

@@ -21,12 +21,18 @@ const (
connectionPackageKDFMaxMemoryKiB = 262144
connectionPackageKDFMaxTimeCost = 10
connectionPackageKDFMaxParallelism = 16
connectionPackageMaxCiphertextBytes = 16 * 1024 * 1024
connectionPackageMaxPayloadBase64Bytes = ((connectionPackageMaxCiphertextBytes + 2) / 3) * 4
connectionImportMaxFileBytes = connectionPackageMaxPayloadBase64Bytes + (1 * 1024 * 1024)
)
var (
errConnectionPackagePasswordRequired = errors.New("恢复包密码不能为空")
errConnectionPackageDecryptFailed = errors.New("文件密码错误或文件已损坏")
errConnectionPackageUnsupported = errors.New("不支持的连接恢复包格式")
errConnectionImportFileTooLarge = errors.New("连接导入文件过大")
errConnectionPackagePayloadTooLarge = errors.New("连接恢复包过大")
errConnectionPackageNotImplemented = errors.New("connection package not implemented")
)

View File

@@ -259,6 +259,22 @@ func (cr *countingReader) Read(p []byte) (int, error) {
return n, err
}
func readImportedConnectionConfigFile(path string) (string, error) {
info, err := os.Stat(path)
if err != nil {
return "", err
}
if info.Size() > connectionImportMaxFileBytes {
return "", errConnectionImportFileTooLarge
}
content, err := os.ReadFile(path)
if err != nil {
return "", err
}
return string(content), nil
}
func (a *App) ImportConfigFile() connection.QueryResult {
selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
Title: "Select Config File",
@@ -282,12 +298,12 @@ func (a *App) ImportConfigFile() connection.QueryResult {
return connection.QueryResult{Success: false, Message: "已取消"}
}
content, err := os.ReadFile(selection)
content, err := readImportedConnectionConfigFile(selection)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Data: string(content)}
return connection.QueryResult{Success: true, Data: content}
}
func (a *App) ExportConnectionsPackage(password string) connection.QueryResult {
@@ -320,6 +336,9 @@ func (a *App) ExportConnectionsPackage(password string) connection.QueryResult {
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
if len(content) > connectionImportMaxFileBytes {
return connection.QueryResult{Success: false, Message: errConnectionImportFileTooLarge.Error()}
}
if err := os.WriteFile(filename, content, 0o644); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}

View File

@@ -0,0 +1,33 @@
package app
import (
"errors"
"os"
"path/filepath"
"testing"
)
func TestReadImportedConnectionConfigFileRejectsOversizedFiles(t *testing.T) {
for _, ext := range []string{connectionPackageExtension, ".json"} {
t.Run(ext, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "connections"+ext)
file, err := os.Create(path)
if err != nil {
t.Fatalf("Create returned error: %v", err)
}
if err := file.Truncate(connectionImportMaxFileBytes + 1); err != nil {
file.Close()
t.Fatalf("Truncate returned error: %v", err)
}
if err := file.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
_, err = readImportedConnectionConfigFile(path)
if !errors.Is(err, errConnectionImportFileTooLarge) {
t.Fatalf("oversized import file should return errConnectionImportFileTooLarge, got: %v", err)
}
})
}
}

View File

@@ -19,12 +19,20 @@ import (
var (
redisCache = make(map[string]redis.RedisClient)
redisCacheMu sync.Mutex
newRedisClientFunc = redis.NewRedisClient
)
// getRedisClient gets or creates a Redis client from cache
func (a *App) getRedisClient(config connection.ConnectionConfig) (redis.RedisClient, error) {
effectiveConfig := applyGlobalProxyToConnection(config)
connectConfig, proxyErr := resolveDialConfigWithProxy(effectiveConfig)
resolvedConfig, err := a.resolveConnectionSecrets(config)
if err != nil {
wrapped := wrapConnectError(config, err)
logger.Error(wrapped, "Redis 密文解析失败:%s", formatRedisConnSummary(config))
return nil, wrapped
}
effectiveConfig := applyGlobalProxyToConnection(resolvedConfig)
connectConfig, proxyErr := resolveDialConfigWithProxyFunc(effectiveConfig)
if proxyErr != nil {
wrapped := wrapConnectError(effectiveConfig, proxyErr)
logger.Error(wrapped, "Redis 代理准备失败:%s", formatRedisConnSummary(effectiveConfig))
@@ -54,7 +62,7 @@ func (a *App) getRedisClient(config connection.ConnectionConfig) (redis.RedisCli
}
logger.Infof("创建 Redis 客户端实例缓存Key=%s", shortKey)
client := redis.NewRedisClient()
client := newRedisClientFunc()
if err := client.Connect(connectConfig); err != nil {
wrapped := wrapConnectError(effectiveConfig, err)
logger.Error(wrapped, "Redis 连接失败:%s 缓存Key=%s", formatRedisConnSummary(effectiveConfig), shortKey)

View File

@@ -0,0 +1,258 @@
package app
import (
"testing"
"GoNavi-Wails/internal/connection"
redislib "GoNavi-Wails/internal/redis"
)
type capturingRedisClient struct {
connectConfig connection.ConnectionConfig
}
func (c *capturingRedisClient) Connect(config connection.ConnectionConfig) error {
c.connectConfig = config
return nil
}
func (c *capturingRedisClient) Close() error { return nil }
func (c *capturingRedisClient) Ping() error { return nil }
func (c *capturingRedisClient) ScanKeys(pattern string, cursor uint64, count int64) (*redislib.RedisScanResult, error) {
return &redislib.RedisScanResult{}, nil
}
func (c *capturingRedisClient) GetKeyType(key string) (string, error) { return "", nil }
func (c *capturingRedisClient) GetTTL(key string) (int64, error) { return 0, nil }
func (c *capturingRedisClient) SetTTL(key string, ttl int64) error { return nil }
func (c *capturingRedisClient) DeleteKeys(keys []string) (int64, error) { return 0, nil }
func (c *capturingRedisClient) RenameKey(oldKey, newKey string) error { return nil }
func (c *capturingRedisClient) KeyExists(key string) (bool, error) { return false, nil }
func (c *capturingRedisClient) GetValue(key string) (*redislib.RedisValue, error) {
return &redislib.RedisValue{}, nil
}
func (c *capturingRedisClient) GetString(key string) (string, error) { return "", nil }
func (c *capturingRedisClient) SetString(key, value string, ttl int64) error { return nil }
func (c *capturingRedisClient) GetHash(key string) (map[string]string, error) { return map[string]string{}, nil }
func (c *capturingRedisClient) SetHashField(key, field, value string) error { return nil }
func (c *capturingRedisClient) DeleteHashField(key string, fields ...string) error { return nil }
func (c *capturingRedisClient) GetList(key string, start, stop int64) ([]string, error) { return nil, nil }
func (c *capturingRedisClient) ListPush(key string, values ...string) error { return nil }
func (c *capturingRedisClient) ListSet(key string, index int64, value string) error { return nil }
func (c *capturingRedisClient) GetSet(key string) ([]string, error) { return nil, nil }
func (c *capturingRedisClient) SetAdd(key string, members ...string) error { return nil }
func (c *capturingRedisClient) SetRemove(key string, members ...string) error { return nil }
func (c *capturingRedisClient) GetZSet(key string, start, stop int64) ([]redislib.ZSetMember, error) {
return nil, nil
}
func (c *capturingRedisClient) ZSetAdd(key string, members ...redislib.ZSetMember) error { return nil }
func (c *capturingRedisClient) ZSetRemove(key string, members ...string) error { return nil }
func (c *capturingRedisClient) GetStream(key, start, stop string, count int64) ([]redislib.StreamEntry, error) {
return nil, nil
}
func (c *capturingRedisClient) StreamAdd(key string, fields map[string]string, id string) (string, error) {
return "", nil
}
func (c *capturingRedisClient) StreamDelete(key string, ids ...string) (int64, error) { return 0, nil }
func (c *capturingRedisClient) ExecuteCommand(args []string) (interface{}, error) { return nil, nil }
func (c *capturingRedisClient) GetServerInfo() (map[string]string, error) { return map[string]string{}, nil }
func (c *capturingRedisClient) GetDatabases() ([]redislib.RedisDBInfo, error) { return nil, nil }
func (c *capturingRedisClient) SelectDB(index int) error { return nil }
func (c *capturingRedisClient) GetCurrentDB() int { return 0 }
func (c *capturingRedisClient) FlushDB() error { return nil }
func TestRedisConnectResolvesSavedSecretsByConnectionID(t *testing.T) {
testCases := []struct {
name string
savedConfig connection.ConnectionConfig
runtimeConfig connection.ConnectionConfig
assertResolved func(t *testing.T, got connection.ConnectionConfig)
}{
{
name: "redis and ssh secrets",
savedConfig: connection.ConnectionConfig{
ID: "redis-1",
Type: "redis",
Host: "redis.local",
Port: 6379,
Password: "redis-secret",
UseSSH: true,
SSH: connection.SSHConfig{
Host: "ssh.local",
Port: 22,
User: "ops",
Password: "ssh-secret",
},
},
runtimeConfig: connection.ConnectionConfig{
ID: "redis-1",
Type: "redis",
Host: "redis.local",
Port: 6379,
UseSSH: true,
SSH: connection.SSHConfig{
Host: "ssh.local",
Port: 22,
User: "ops",
},
},
assertResolved: func(t *testing.T, got connection.ConnectionConfig) {
t.Helper()
if got.Password != "redis-secret" {
t.Fatalf("expected RedisConnect to resolve saved Redis password, got %q", got.Password)
}
if got.SSH.Password != "ssh-secret" {
t.Fatalf("expected RedisConnect to resolve saved SSH password, got %q", got.SSH.Password)
}
},
},
{
name: "proxy secret",
savedConfig: connection.ConnectionConfig{
ID: "redis-1",
Type: "redis",
Host: "redis.local",
Port: 6379,
Password: "redis-secret",
UseProxy: true,
Proxy: connection.ProxyConfig{
Type: "http",
Host: "proxy.local",
Port: 8080,
User: "proxy-user",
Password: "proxy-secret",
},
},
runtimeConfig: connection.ConnectionConfig{
ID: "redis-1",
Type: "redis",
Host: "redis.local",
Port: 6379,
UseProxy: true,
Proxy: connection.ProxyConfig{
Type: "http",
Host: "proxy.local",
Port: 8080,
User: "proxy-user",
},
},
assertResolved: func(t *testing.T, got connection.ConnectionConfig) {
t.Helper()
if got.Password != "redis-secret" {
t.Fatalf("expected RedisConnect to resolve saved Redis password, got %q", got.Password)
}
if got.Proxy.Password != "proxy-secret" {
t.Fatalf("expected RedisConnect to resolve saved proxy password, got %q", got.Proxy.Password)
}
},
},
{
name: "http tunnel secret",
savedConfig: connection.ConnectionConfig{
ID: "redis-1",
Type: "redis",
Host: "redis.local",
Port: 6379,
Password: "redis-secret",
UseHTTPTunnel: true,
HTTPTunnel: connection.HTTPTunnelConfig{
Host: "tunnel.local",
Port: 8443,
User: "tunnel-user",
Password: "tunnel-secret",
},
},
runtimeConfig: connection.ConnectionConfig{
ID: "redis-1",
Type: "redis",
Host: "redis.local",
Port: 6379,
UseHTTPTunnel: true,
HTTPTunnel: connection.HTTPTunnelConfig{
Host: "tunnel.local",
Port: 8443,
User: "tunnel-user",
},
},
assertResolved: func(t *testing.T, got connection.ConnectionConfig) {
t.Helper()
if got.Password != "redis-secret" {
t.Fatalf("expected RedisConnect to resolve saved Redis password, got %q", got.Password)
}
if got.HTTPTunnel.Password != "tunnel-secret" {
t.Fatalf("expected RedisConnect to resolve saved HTTP tunnel password, got %q", got.HTTPTunnel.Password)
}
},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
_, err := app.SaveConnection(connection.SavedConnectionInput{
ID: "redis-1",
Name: "Redis Saved",
Config: testCase.savedConfig,
})
if err != nil {
t.Fatalf("SaveConnection returned error: %v", err)
}
CloseAllRedisClients()
client := &capturingRedisClient{}
originalNewRedisClientFunc := newRedisClientFunc
originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc
defer func() {
newRedisClientFunc = originalNewRedisClientFunc
resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc
CloseAllRedisClients()
}()
newRedisClientFunc = func() redislib.RedisClient {
return client
}
resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
return raw, nil
}
result := app.RedisConnect(testCase.runtimeConfig)
if !result.Success {
t.Fatalf("RedisConnect returned failure: %+v", result)
}
testCase.assertResolved(t, client.connectConfig)
})
}
}

View File

@@ -1,6 +1,10 @@
package app
import "GoNavi-Wails/internal/connection"
import (
"strings"
"GoNavi-Wails/internal/connection"
)
func (a *App) savedConnectionRepository() *savedConnectionRepository {
return newSavedConnectionRepository(a.configDir, a.secretStore)
@@ -23,16 +27,20 @@ func (a *App) DuplicateConnection(id string) (connection.SavedConnectionView, er
}
func (a *App) ImportLegacyConnections(items []connection.LegacySavedConnection) ([]connection.SavedConnectionView, error) {
result := make([]connection.SavedConnectionView, 0, len(items))
repo := a.savedConnectionRepository()
inputs := make([]connection.SavedConnectionInput, 0, len(items))
for _, item := range items {
view, err := repo.Save(connection.SavedConnectionInput(item))
if err != nil {
return nil, err
}
result = append(result, view)
input := connection.SavedConnectionInput(item)
input.ClearPrimaryPassword = strings.TrimSpace(item.Config.Password) == ""
input.ClearSSHPassword = strings.TrimSpace(item.Config.SSH.Password) == ""
input.ClearProxyPassword = strings.TrimSpace(item.Config.Proxy.Password) == ""
input.ClearHTTPTunnelPassword = strings.TrimSpace(item.Config.HTTPTunnel.Password) == ""
input.ClearMySQLReplicaPassword = strings.TrimSpace(item.Config.MySQLReplicaPassword) == ""
input.ClearMongoReplicaPassword = strings.TrimSpace(item.Config.MongoReplicaPassword) == ""
input.ClearOpaqueURI = strings.TrimSpace(item.Config.URI) == ""
input.ClearOpaqueDSN = strings.TrimSpace(item.Config.DSN) == ""
inputs = append(inputs, input)
}
return result, nil
return a.importSavedConnectionsAtomically(inputs)
}
func (a *App) SaveGlobalProxy(input connection.SaveGlobalProxyInput) (connection.GlobalProxyView, error) {

View File

@@ -219,7 +219,7 @@ func TestImportLegacyConnectionsIsIdempotentForSameID(t *testing.T) {
}
}
func TestImportLegacyConnectionsKeepsExistingSecretWhenReimportOmitsPassword(t *testing.T) {
func TestImportLegacyConnectionsClearsExistingSecretWhenReimportOmitsPassword(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
@@ -267,7 +267,7 @@ func TestImportLegacyConnectionsKeepsExistingSecretWhenReimportOmitsPassword(t *
if err != nil {
t.Fatalf("resolveConnectionSecrets returned error: %v", err)
}
if resolved.Password != "secret-1" {
t.Fatalf("expected original password to be preserved, got %q", resolved.Password)
if resolved.Password != "" {
t.Fatalf("expected missing import password to clear existing secret, got %q", resolved.Password)
}
}

View File

@@ -30,6 +30,12 @@ const (
updateDownloadProgressEvent = "update:download-progress"
)
var (
updateFetchLatestRelease = fetchLatestRelease
updateFetchReleaseSHA256 = fetchReleaseSHA256
updateLogCheckError = func(err error) { logger.Error(err, "检查更新失败") }
)
type updateState struct {
lastCheck *UpdateInfo
downloading bool
@@ -100,9 +106,19 @@ type githubAsset struct {
}
func (a *App) CheckForUpdates() connection.QueryResult {
return a.checkForUpdates(true)
}
func (a *App) CheckForUpdatesSilently() connection.QueryResult {
return a.checkForUpdates(false)
}
func (a *App) checkForUpdates(logFailure bool) connection.QueryResult {
info, err := fetchLatestUpdateInfo()
if err != nil {
logger.Error(err, "检查更新失败")
if logFailure {
updateLogCheckError(err)
}
return connection.QueryResult{Success: false, Message: err.Error()}
}
@@ -359,7 +375,7 @@ func (a *App) downloadAndStageUpdate(info UpdateInfo) connection.QueryResult {
}
func fetchLatestUpdateInfo() (UpdateInfo, error) {
release, err := fetchLatestRelease()
release, err := updateFetchLatestRelease()
if err != nil {
return UpdateInfo{}, err
}
@@ -370,6 +386,17 @@ func fetchLatestUpdateInfo() (UpdateInfo, error) {
return UpdateInfo{}, errors.New("无法解析最新版本号")
}
hasUpdate := compareVersion(currentVersion, latestVersion) < 0
if !hasUpdate {
return UpdateInfo{
HasUpdate: false,
CurrentVersion: currentVersion,
LatestVersion: latestVersion,
ReleaseName: release.Name,
ReleaseNotesURL: release.HTMLURL,
}, nil
}
assetVersion := strings.TrimSpace(release.TagName)
if assetVersion == "" {
assetVersion = latestVersion
@@ -383,7 +410,7 @@ func fetchLatestUpdateInfo() (UpdateInfo, error) {
return UpdateInfo{}, err
}
hashMap, err := fetchReleaseSHA256(release.Assets)
hashMap, err := updateFetchReleaseSHA256(release.Assets)
if err != nil {
return UpdateInfo{}, err
}
@@ -391,9 +418,6 @@ func fetchLatestUpdateInfo() (UpdateInfo, error) {
if sha256Value == "" {
return UpdateInfo{}, errors.New("SHA256SUMS 未包含当前平台更新包")
}
hasUpdate := compareVersion(currentVersion, latestVersion) < 0
return UpdateInfo{
HasUpdate: hasUpdate,
CurrentVersion: currentVersion,
@@ -407,6 +431,30 @@ func fetchLatestUpdateInfo() (UpdateInfo, error) {
}, nil
}
func swapUpdateFetchLatestRelease(next func() (*githubRelease, error)) func() {
original := updateFetchLatestRelease
updateFetchLatestRelease = next
return func() {
updateFetchLatestRelease = original
}
}
func swapUpdateFetchReleaseSHA256(next func([]githubAsset) (map[string]string, error)) func() {
original := updateFetchReleaseSHA256
updateFetchReleaseSHA256 = next
return func() {
updateFetchReleaseSHA256 = original
}
}
func swapUpdateCheckErrorLogger(next func(error)) func() {
original := updateLogCheckError
updateLogCheckError = next
return func() {
updateLogCheckError = original
}
}
func getCurrentAuthor() string {
if env := strings.TrimSpace(os.Getenv("GONAVI_AUTHOR")); env != "" {
return env

View File

@@ -0,0 +1,160 @@
package app
import (
"errors"
stdRuntime "runtime"
"testing"
)
func TestFetchLatestUpdateInfoSkipsChecksumWhenCurrentVersionIsAlreadyLatest(t *testing.T) {
assetName, err := expectedAssetName(stdRuntime.GOOS, stdRuntime.GOARCH, "v0.6.5")
if err != nil {
t.Fatalf("expectedAssetName returned error: %v", err)
}
originalVersion := AppVersion
AppVersion = "0.6.5"
defer func() {
AppVersion = originalVersion
}()
releaseCalled := false
restoreRelease := swapUpdateFetchLatestRelease(func() (*githubRelease, error) {
releaseCalled = true
return &githubRelease{
TagName: "v0.6.5",
Name: "v0.6.5",
HTMLURL: "https://github.com/Syngnat/GoNavi/releases/tag/v0.6.5",
Assets: []githubAsset{
{
Name: assetName,
BrowserDownloadURL: "https://example.com/" + assetName,
Size: 1024,
},
},
}, nil
})
defer restoreRelease()
checksumCalled := false
restoreChecksum := swapUpdateFetchReleaseSHA256(func([]githubAsset) (map[string]string, error) {
checksumCalled = true
return nil, errors.New("checksum should not be fetched when no update is needed")
})
defer restoreChecksum()
info, err := fetchLatestUpdateInfo()
if err != nil {
t.Fatalf("fetchLatestUpdateInfo returned error: %v", err)
}
if !releaseCalled {
t.Fatal("expected latest release metadata to be fetched")
}
if checksumCalled {
t.Fatal("expected SHA256SUMS fetch to be skipped when current version is already latest")
}
if info.HasUpdate {
t.Fatalf("expected HasUpdate=false, got %#v", info)
}
if info.LatestVersion != "0.6.5" || info.CurrentVersion != "0.6.5" {
t.Fatalf("unexpected version info: %#v", info)
}
}
func TestFetchLatestUpdateInfoFetchesChecksumWhenUpdateIsAvailable(t *testing.T) {
assetName, err := expectedAssetName(stdRuntime.GOOS, stdRuntime.GOARCH, "v0.6.5")
if err != nil {
t.Fatalf("expectedAssetName returned error: %v", err)
}
originalVersion := AppVersion
AppVersion = "0.6.4"
defer func() {
AppVersion = originalVersion
}()
restoreRelease := swapUpdateFetchLatestRelease(func() (*githubRelease, error) {
return &githubRelease{
TagName: "v0.6.5",
Name: "v0.6.5",
HTMLURL: "https://github.com/Syngnat/GoNavi/releases/tag/v0.6.5",
Assets: []githubAsset{
{
Name: assetName,
BrowserDownloadURL: "https://example.com/" + assetName,
Size: 4096,
},
},
}, nil
})
defer restoreRelease()
checksumCalled := false
restoreChecksum := swapUpdateFetchReleaseSHA256(func([]githubAsset) (map[string]string, error) {
checksumCalled = true
return map[string]string{
assetName: "abc123",
}, nil
})
defer restoreChecksum()
info, err := fetchLatestUpdateInfo()
if err != nil {
t.Fatalf("fetchLatestUpdateInfo returned error: %v", err)
}
if !checksumCalled {
t.Fatal("expected SHA256SUMS fetch when update is available")
}
if !info.HasUpdate {
t.Fatalf("expected HasUpdate=true, got %#v", info)
}
if info.SHA256 != "abc123" || info.AssetName != assetName {
t.Fatalf("unexpected update info: %#v", info)
}
}
func TestCheckForUpdatesLogsFailuresForManualChecks(t *testing.T) {
app := &App{}
restoreRelease := swapUpdateFetchLatestRelease(func() (*githubRelease, error) {
return nil, errors.New("request timed out")
})
defer restoreRelease()
logged := 0
restoreLogger := swapUpdateCheckErrorLogger(func(error) {
logged++
})
defer restoreLogger()
result := app.CheckForUpdates()
if result.Success {
t.Fatalf("expected failure result, got %#v", result)
}
if logged != 1 {
t.Fatalf("expected manual check to log once, got %d", logged)
}
}
func TestCheckForUpdatesSilentlySkipsFailureLogs(t *testing.T) {
app := &App{}
restoreRelease := swapUpdateFetchLatestRelease(func() (*githubRelease, error) {
return nil, errors.New("request timed out")
})
defer restoreRelease()
logged := 0
restoreLogger := swapUpdateCheckErrorLogger(func(error) {
logged++
})
defer restoreLogger()
result := app.CheckForUpdatesSilently()
if result.Success {
t.Fatalf("expected failure result, got %#v", result)
}
if logged != 0 {
t.Fatalf("expected silent check to skip error logging, got %d", logged)
}
}

View File

@@ -3,7 +3,10 @@ package secretstore
import (
"errors"
"fmt"
"os"
"runtime"
"strings"
"syscall"
"github.com/99designs/keyring"
)
@@ -56,19 +59,32 @@ func (s *keyringStore) Delete(ref string) error {
func (s *keyringStore) HealthCheck() error {
_, err := s.ring.Get(healthCheckRef)
if err == nil || errors.Is(err, keyring.ErrKeyNotFound) {
if err == nil || isKeyringSecretNotFound(err) {
return nil
}
return wrapKeyringError(err)
}
func wrapKeyringError(err error) error {
if err == nil || errors.Is(err, keyring.ErrKeyNotFound) || IsUnavailable(err) {
if err == nil || IsUnavailable(err) {
return err
}
if isKeyringSecretNotFound(err) {
return os.ErrNotExist
}
return &UnavailableError{Reason: err.Error()}
}
func isKeyringSecretNotFound(err error) bool {
if err == nil {
return false
}
if errors.Is(err, keyring.ErrKeyNotFound) || errors.Is(err, syscall.Errno(1168)) {
return true
}
return strings.EqualFold(strings.TrimSpace(err.Error()), keyring.ErrKeyNotFound.Error())
}
func keyringConfigFor(goos string) (keyring.Config, error) {
backends := allowedBackendsFor(goos)
if len(backends) == 0 {

View File

@@ -2,6 +2,9 @@ package secretstore
import (
"errors"
"fmt"
"os"
"syscall"
"testing"
"github.com/99designs/keyring"
@@ -58,6 +61,33 @@ func TestKeyringStoreHealthCheckTreatsMissingProbeItemAsHealthy(t *testing.T) {
}
}
func TestKeyringStoreHealthCheckTreatsWinCredNotFoundMessageAsHealthy(t *testing.T) {
t.Parallel()
store := &keyringStore{ring: fakeKeyringClient{getErr: errors.New("The specified item could not be found in the keyring")}}
if err := store.HealthCheck(); err != nil {
t.Fatalf("HealthCheck should accept WinCred not-found errors, got %v", err)
}
}
func TestKeyringStoreHealthCheckDoesNotTreatWrappedOsErrNotExistAsHealthy(t *testing.T) {
t.Parallel()
store := &keyringStore{ring: fakeKeyringClient{getErr: fmt.Errorf("backend unavailable: %w", os.ErrNotExist)}}
if err := store.HealthCheck(); err == nil {
t.Fatal("HealthCheck should not accept unrelated wrapped os.ErrNotExist errors as healthy")
}
}
func TestKeyringStoreHealthCheckDoesNotTreatPlainOsErrNotExistAsHealthy(t *testing.T) {
t.Parallel()
store := &keyringStore{ring: fakeKeyringClient{getErr: os.ErrNotExist}}
if err := store.HealthCheck(); err == nil {
t.Fatal("HealthCheck should not accept plain os.ErrNotExist errors as healthy")
}
}
func TestKeyringStoreHealthCheckReturnsUnavailableErrorOnBackendFailure(t *testing.T) {
t.Parallel()
@@ -82,6 +112,67 @@ func TestNewKeyringStoreReturnsUnavailableStoreWhenOpenFails(t *testing.T) {
}
}
func TestWrapKeyringErrorNormalizesWinCredNotFoundMessage(t *testing.T) {
t.Parallel()
err := wrapKeyringError(errors.New("The specified item could not be found in the keyring"))
if err == nil {
t.Fatal("wrapKeyringError should preserve missing-secret semantics")
}
if !os.IsNotExist(err) {
t.Fatalf("wrapKeyringError should map WinCred not-found errors to os.ErrNotExist, got %v", err)
}
if IsUnavailable(err) {
t.Fatalf("wrapKeyringError should not treat WinCred not-found errors as unavailable, got %v", err)
}
}
func TestWrapKeyringErrorNormalizesWrappedKeyringErrKeyNotFound(t *testing.T) {
t.Parallel()
err := wrapKeyringError(fmt.Errorf("wrapped: %w", keyring.ErrKeyNotFound))
if err == nil {
t.Fatal("wrapKeyringError should preserve wrapped missing-secret semantics")
}
if !os.IsNotExist(err) {
t.Fatalf("wrapKeyringError should map wrapped ErrKeyNotFound to os.ErrNotExist, got %v", err)
}
if IsUnavailable(err) {
t.Fatalf("wrapKeyringError should not treat wrapped ErrKeyNotFound as unavailable, got %v", err)
}
}
func TestWrapKeyringErrorNormalizesWinCredErrno1168(t *testing.T) {
t.Parallel()
err := wrapKeyringError(syscall.Errno(1168))
if err == nil {
t.Fatal("wrapKeyringError should preserve WinCred errno missing-secret semantics")
}
if !os.IsNotExist(err) {
t.Fatalf("wrapKeyringError should map WinCred errno to os.ErrNotExist, got %v", err)
}
if IsUnavailable(err) {
t.Fatalf("wrapKeyringError should not treat WinCred errno as unavailable, got %v", err)
}
}
func TestWrapKeyringErrorDoesNotSwallowUnrelatedElementNotFoundMessages(t *testing.T) {
t.Parallel()
backendErr := errors.New("database element not found while enumerating providers")
err := wrapKeyringError(backendErr)
if err == nil {
t.Fatal("wrapKeyringError should preserve backend failures")
}
if os.IsNotExist(err) {
t.Fatalf("wrapKeyringError should not map unrelated element-not-found errors to os.ErrNotExist, got %v", err)
}
if !IsUnavailable(err) {
t.Fatalf("wrapKeyringError should keep unrelated backend failures unavailable, got %v", err)
}
}
type fakeKeyringClient struct {
getErr error
item keyring.Item