mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-12 09:29:43 +08:00
- 密文存储:新增 dailysecret 本地存储引擎,连接/代理/AI 密钥不再依赖系统钥匙串 - 启动迁移:自动将已有钥匙串密文迁移到本地 JSON,用户无感知 - WebKit 迁移:从旧版 Wails WebKit LocalStorage 中恢复连接与代理数据 - DMG 修复:移除 --sandbox-safe 避免扩展属性污染签名,新增 xattr 清理与签名校验 - 安全适配:钥匙串不可用时标记完成而非回滚,消除无钥匙串环境下的阻塞 - 出口脱敏:所有连接/代理 API 返回前统一 sanitize 防止密文泄漏
480 lines
14 KiB
Go
480 lines
14 KiB
Go
package aiservice
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"GoNavi-Wails/internal/ai"
|
|
"GoNavi-Wails/internal/secretstore"
|
|
)
|
|
|
|
func withTestAIGOOS(t *testing.T, goos string) {
|
|
t.Helper()
|
|
previous := aiRuntimeGOOS
|
|
aiRuntimeGOOS = func() string {
|
|
return goos
|
|
}
|
|
t.Cleanup(func() {
|
|
aiRuntimeGOOS = previous
|
|
})
|
|
}
|
|
|
|
func TestSplitProviderSecretsStripsAPIKeyAndSensitiveHeaders(t *testing.T) {
|
|
input := ai.ProviderConfig{
|
|
ID: "openai-main",
|
|
APIKey: "sk-test",
|
|
BaseURL: "https://api.openai.com/v1",
|
|
Headers: map[string]string{
|
|
"Authorization": "Bearer test",
|
|
"X-Team": "db",
|
|
},
|
|
}
|
|
|
|
meta, bundle := splitProviderSecrets(input)
|
|
if meta.APIKey != "" {
|
|
t.Fatal("apiKey should not stay in metadata")
|
|
}
|
|
if meta.Headers["Authorization"] != "" {
|
|
t.Fatal("sensitive header should not stay in metadata")
|
|
}
|
|
if meta.Headers["X-Team"] != "db" {
|
|
t.Fatal("non-sensitive header should stay in metadata")
|
|
}
|
|
if bundle.APIKey != "sk-test" {
|
|
t.Fatal("bundle should keep apiKey")
|
|
}
|
|
if bundle.SensitiveHeaders["Authorization"] != "Bearer test" {
|
|
t.Fatal("bundle should keep sensitive header")
|
|
}
|
|
}
|
|
|
|
func TestResolveProviderConfigSecretsRestoresStoredSecretBundle(t *testing.T) {
|
|
service := NewServiceWithSecretStore(failOnUseSecretStore{})
|
|
service.configDir = t.TempDir()
|
|
if err := service.dailySecretStore().PutAIProvider("openai-main", toDailyProviderBundle(providerSecretBundle{
|
|
APIKey: "sk-test",
|
|
SensitiveHeaders: map[string]string{
|
|
"Authorization": "Bearer test",
|
|
},
|
|
})); err != nil {
|
|
t.Fatalf("PutAIProvider returned error: %v", err)
|
|
}
|
|
|
|
resolved, err := service.resolveProviderConfigSecrets(ai.ProviderConfig{
|
|
ID: "openai-main",
|
|
HasSecret: true,
|
|
Headers: map[string]string{
|
|
"X-Team": "db",
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("resolveProviderConfigSecrets returned error: %v", err)
|
|
}
|
|
if resolved.APIKey != "sk-test" {
|
|
t.Fatalf("expected restored apiKey, got %q", resolved.APIKey)
|
|
}
|
|
if resolved.Headers["Authorization"] != "Bearer test" {
|
|
t.Fatalf("expected restored sensitive header, got %#v", resolved.Headers)
|
|
}
|
|
if resolved.Headers["X-Team"] != "db" {
|
|
t.Fatalf("expected non-sensitive header to survive, got %#v", resolved.Headers)
|
|
}
|
|
}
|
|
|
|
func TestLoadConfigUsesPlaintextProviderSecretsWithoutSilentMigration(t *testing.T) {
|
|
service := NewServiceWithSecretStore(failOnUseSecretStore{})
|
|
service.configDir = t.TempDir()
|
|
|
|
legacy := aiConfig{
|
|
Providers: []ai.ProviderConfig{
|
|
{
|
|
ID: "openai-main",
|
|
Type: "openai",
|
|
Name: "OpenAI",
|
|
APIKey: "sk-test",
|
|
BaseURL: "https://api.openai.com/v1",
|
|
Headers: map[string]string{
|
|
"Authorization": "Bearer test",
|
|
"X-Team": "db",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
data, err := json.MarshalIndent(legacy, "", " ")
|
|
if err != nil {
|
|
t.Fatalf("MarshalIndent returned error: %v", err)
|
|
}
|
|
configPath := filepath.Join(service.configDir, "ai_config.json")
|
|
if err := os.WriteFile(configPath, data, 0o644); err != nil {
|
|
t.Fatalf("WriteFile returned error: %v", err)
|
|
}
|
|
|
|
service.loadConfig()
|
|
|
|
providers := service.AIGetProviders()
|
|
if len(providers) != 1 {
|
|
t.Fatalf("expected 1 provider, got %d", len(providers))
|
|
}
|
|
if providers[0].APIKey != "" {
|
|
t.Fatalf("expected provider view to stay secretless, got %q", providers[0].APIKey)
|
|
}
|
|
if !providers[0].HasSecret {
|
|
t.Fatal("expected provider view to report HasSecret=true")
|
|
}
|
|
|
|
if len(service.providers) != 1 {
|
|
t.Fatalf("expected runtime providers to be loaded, got %d", len(service.providers))
|
|
}
|
|
if service.providers[0].APIKey != "sk-test" {
|
|
t.Fatalf("expected runtime provider to keep plaintext apiKey, got %q", service.providers[0].APIKey)
|
|
}
|
|
if service.providers[0].Headers["Authorization"] != "Bearer test" {
|
|
t.Fatalf("expected runtime provider to keep sensitive header, got %#v", service.providers[0].Headers)
|
|
}
|
|
|
|
stored, ok, err := service.dailySecretStore().GetAIProvider("openai-main")
|
|
if err != nil {
|
|
t.Fatalf("GetAIProvider returned error: %v", err)
|
|
}
|
|
if !ok {
|
|
t.Fatal("expected startup load to migrate plaintext provider secret to daily store")
|
|
}
|
|
if stored.APIKey != "sk-test" || stored.SensitiveHeaders["Authorization"] != "Bearer test" {
|
|
t.Fatalf("unexpected migrated provider bundle: %#v", stored)
|
|
}
|
|
|
|
rewritten, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile returned error: %v", err)
|
|
}
|
|
text := string(rewritten)
|
|
if strings.Contains(text, "sk-test") {
|
|
t.Fatalf("expected config file to be rewritten secretless, got %s", text)
|
|
}
|
|
if strings.Contains(text, "Bearer test") {
|
|
t.Fatalf("expected config file to remove sensitive header, got %s", text)
|
|
}
|
|
}
|
|
|
|
func TestAISaveProviderKeepsLegacyPlaintextSecretAfterStartupLoad(t *testing.T) {
|
|
service := NewServiceWithSecretStore(failOnUseSecretStore{})
|
|
service.configDir = t.TempDir()
|
|
|
|
legacy := aiConfig{
|
|
Providers: []ai.ProviderConfig{
|
|
{
|
|
ID: "openai-main",
|
|
Type: "custom",
|
|
Name: "OpenAI",
|
|
APIKey: "sk-test",
|
|
BaseURL: "",
|
|
Headers: map[string]string{
|
|
"Authorization": "Bearer test",
|
|
"X-Team": "db",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
data, err := json.MarshalIndent(legacy, "", " ")
|
|
if err != nil {
|
|
t.Fatalf("MarshalIndent returned error: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(service.configDir, aiConfigFileName), data, 0o644); err != nil {
|
|
t.Fatalf("WriteFile returned error: %v", err)
|
|
}
|
|
|
|
service.loadConfig()
|
|
|
|
if err := service.AISaveProvider(ai.ProviderConfig{
|
|
ID: "openai-main",
|
|
Type: "custom",
|
|
Name: "OpenAI Updated",
|
|
BaseURL: "",
|
|
HasSecret: true,
|
|
Headers: map[string]string{
|
|
"X-Team": "platform",
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("AISaveProvider returned error: %v", err)
|
|
}
|
|
|
|
if service.providers[0].APIKey != "sk-test" {
|
|
t.Fatalf("expected runtime provider to keep legacy plaintext apiKey, got %q", service.providers[0].APIKey)
|
|
}
|
|
if service.providers[0].Headers["Authorization"] != "Bearer test" {
|
|
t.Fatalf("expected runtime provider to keep legacy sensitive header, got %#v", service.providers[0].Headers)
|
|
}
|
|
|
|
stored, ok, err := service.dailySecretStore().GetAIProvider("openai-main")
|
|
if err != nil {
|
|
t.Fatalf("GetAIProvider returned error: %v", err)
|
|
}
|
|
if !ok {
|
|
t.Fatal("expected provider secret to stay in daily store")
|
|
}
|
|
if stored.APIKey != "sk-test" {
|
|
t.Fatalf("expected persisted apiKey, got %q", stored.APIKey)
|
|
}
|
|
}
|
|
|
|
func TestAITestProviderUsesLegacyPlaintextSecretAfterStartupLoad(t *testing.T) {
|
|
service := NewServiceWithSecretStore(failOnUseSecretStore{})
|
|
service.configDir = t.TempDir()
|
|
|
|
legacy := aiConfig{
|
|
Providers: []ai.ProviderConfig{
|
|
{
|
|
ID: "openai-main",
|
|
Type: "custom",
|
|
Name: "OpenAI",
|
|
APIKey: "sk-test",
|
|
BaseURL: "",
|
|
Headers: map[string]string{
|
|
"Authorization": "Bearer test",
|
|
"X-Team": "db",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
data, err := json.MarshalIndent(legacy, "", " ")
|
|
if err != nil {
|
|
t.Fatalf("MarshalIndent returned error: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(service.configDir, aiConfigFileName), data, 0o644); err != nil {
|
|
t.Fatalf("WriteFile returned error: %v", err)
|
|
}
|
|
|
|
service.loadConfig()
|
|
|
|
result := service.AITestProvider(ai.ProviderConfig{
|
|
ID: "openai-main",
|
|
Type: "custom",
|
|
Name: "OpenAI",
|
|
BaseURL: "",
|
|
HasSecret: true,
|
|
Headers: map[string]string{
|
|
"X-Team": "db",
|
|
},
|
|
})
|
|
|
|
if success, _ := result["success"].(bool); !success {
|
|
t.Fatalf("expected test provider to use in-memory legacy secret, got %#v", result)
|
|
}
|
|
}
|
|
|
|
func TestAISaveProviderPersistsSecretlessConfigAndReturnsSecretlessView(t *testing.T) {
|
|
service := NewServiceWithSecretStore(failOnUseSecretStore{})
|
|
service.configDir = t.TempDir()
|
|
|
|
err := service.AISaveProvider(ai.ProviderConfig{
|
|
ID: "openai-main",
|
|
Type: "openai",
|
|
Name: "OpenAI",
|
|
APIKey: "sk-test",
|
|
BaseURL: "https://api.openai.com/v1",
|
|
Headers: map[string]string{
|
|
"Authorization": "Bearer test",
|
|
"X-Team": "db",
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("AISaveProvider returned error: %v", err)
|
|
}
|
|
|
|
providers := service.AIGetProviders()
|
|
if len(providers) != 1 {
|
|
t.Fatalf("expected 1 provider, got %d", len(providers))
|
|
}
|
|
if providers[0].APIKey != "" {
|
|
t.Fatalf("expected secretless provider view, got %q", providers[0].APIKey)
|
|
}
|
|
if !providers[0].HasSecret {
|
|
t.Fatal("expected saved provider view to report HasSecret=true")
|
|
}
|
|
if providers[0].Headers["Authorization"] != "" {
|
|
t.Fatalf("expected secretless provider headers, got %#v", providers[0].Headers)
|
|
}
|
|
if service.providers[0].APIKey != "sk-test" {
|
|
t.Fatalf("expected runtime provider to keep apiKey, got %q", service.providers[0].APIKey)
|
|
}
|
|
if service.providers[0].Headers["Authorization"] != "Bearer test" {
|
|
t.Fatalf("expected runtime provider to keep sensitive header, got %#v", service.providers[0].Headers)
|
|
}
|
|
|
|
configPath := filepath.Join(service.configDir, "ai_config.json")
|
|
data, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile returned error: %v", err)
|
|
}
|
|
text := string(data)
|
|
if strings.Contains(text, "sk-test") {
|
|
t.Fatalf("expected config file to be secretless, got %s", text)
|
|
}
|
|
if strings.Contains(text, "Bearer test") {
|
|
t.Fatalf("expected config file to remove sensitive headers, got %s", text)
|
|
}
|
|
}
|
|
|
|
func TestAISaveProviderKeepsExistingSecretWhenInputOmitsAPIKey(t *testing.T) {
|
|
service := NewServiceWithSecretStore(failOnUseSecretStore{})
|
|
service.configDir = t.TempDir()
|
|
|
|
if err := service.AISaveProvider(ai.ProviderConfig{
|
|
ID: "openai-main",
|
|
Type: "openai",
|
|
Name: "OpenAI",
|
|
APIKey: "sk-original",
|
|
BaseURL: "https://api.openai.com/v1",
|
|
Headers: map[string]string{
|
|
"Authorization": "Bearer original",
|
|
"X-Team": "db",
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("initial AISaveProvider returned error: %v", err)
|
|
}
|
|
|
|
if err := service.AISaveProvider(ai.ProviderConfig{
|
|
ID: "openai-main",
|
|
Type: "openai",
|
|
Name: "OpenAI Updated",
|
|
BaseURL: "https://gateway.openai.com/v1",
|
|
HasSecret: true,
|
|
Headers: map[string]string{
|
|
"X-Team": "platform",
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("update AISaveProvider returned error: %v", err)
|
|
}
|
|
|
|
if service.providers[0].APIKey != "sk-original" {
|
|
t.Fatalf("expected runtime provider to keep original apiKey, got %q", service.providers[0].APIKey)
|
|
}
|
|
if service.providers[0].Headers["Authorization"] != "Bearer original" {
|
|
t.Fatalf("expected runtime provider to keep original sensitive header, got %#v", service.providers[0].Headers)
|
|
}
|
|
if service.providers[0].Headers["X-Team"] != "platform" {
|
|
t.Fatalf("expected runtime provider to update non-sensitive headers, got %#v", service.providers[0].Headers)
|
|
}
|
|
if service.providers[0].BaseURL != "https://gateway.openai.com/v1" {
|
|
t.Fatalf("expected runtime provider to update metadata, got %q", service.providers[0].BaseURL)
|
|
}
|
|
|
|
providers := service.AIGetProviders()
|
|
if len(providers) != 1 || !providers[0].HasSecret {
|
|
t.Fatalf("expected provider view to keep HasSecret=true, got %#v", providers)
|
|
}
|
|
if providers[0].APIKey != "" {
|
|
t.Fatalf("expected provider view to stay secretless, got %q", providers[0].APIKey)
|
|
}
|
|
}
|
|
|
|
func TestAISaveProviderMergesStoredSensitiveHeadersWhenUpdatingOnlyAPIKey(t *testing.T) {
|
|
service := NewServiceWithSecretStore(failOnUseSecretStore{})
|
|
service.configDir = t.TempDir()
|
|
|
|
if err := service.AISaveProvider(ai.ProviderConfig{
|
|
ID: "openai-main",
|
|
Type: "openai",
|
|
Name: "OpenAI",
|
|
APIKey: "sk-original",
|
|
BaseURL: "https://api.openai.com/v1",
|
|
Headers: map[string]string{
|
|
"Authorization": "Bearer original",
|
|
"X-Team": "db",
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("initial AISaveProvider returned error: %v", err)
|
|
}
|
|
|
|
if err := service.AISaveProvider(ai.ProviderConfig{
|
|
ID: "openai-main",
|
|
Type: "openai",
|
|
Name: "OpenAI",
|
|
APIKey: "sk-updated",
|
|
HasSecret: true,
|
|
BaseURL: "https://api.openai.com/v1",
|
|
Headers: map[string]string{
|
|
"X-Team": "db",
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("update AISaveProvider returned error: %v", err)
|
|
}
|
|
|
|
if service.providers[0].APIKey != "sk-updated" {
|
|
t.Fatalf("expected updated apiKey, got %q", service.providers[0].APIKey)
|
|
}
|
|
if service.providers[0].Headers["Authorization"] != "Bearer original" {
|
|
t.Fatalf("expected existing sensitive header to be kept, got %#v", service.providers[0].Headers)
|
|
}
|
|
|
|
stored, ok, err := service.dailySecretStore().GetAIProvider("openai-main")
|
|
if err != nil {
|
|
t.Fatalf("GetAIProvider returned error: %v", err)
|
|
}
|
|
if !ok {
|
|
t.Fatal("expected merged secret bundle in daily store")
|
|
}
|
|
if stored.APIKey != "sk-updated" {
|
|
t.Fatalf("expected store to keep updated apiKey, got %q", stored.APIKey)
|
|
}
|
|
if stored.SensitiveHeaders["Authorization"] != "Bearer original" {
|
|
t.Fatalf("expected store to keep existing sensitive header, got %#v", stored.SensitiveHeaders)
|
|
}
|
|
}
|
|
|
|
type fakeProviderSecretStore struct {
|
|
items map[string][]byte
|
|
}
|
|
|
|
func newFakeProviderSecretStore() *fakeProviderSecretStore {
|
|
return &fakeProviderSecretStore{items: make(map[string][]byte)}
|
|
}
|
|
|
|
func (s *fakeProviderSecretStore) Put(ref string, payload []byte) error {
|
|
s.items[ref] = append([]byte(nil), payload...)
|
|
return nil
|
|
}
|
|
|
|
func (s *fakeProviderSecretStore) Get(ref string) ([]byte, error) {
|
|
payload, ok := s.items[ref]
|
|
if !ok {
|
|
return nil, os.ErrNotExist
|
|
}
|
|
return append([]byte(nil), payload...), nil
|
|
}
|
|
|
|
func (s *fakeProviderSecretStore) Delete(ref string) error {
|
|
delete(s.items, ref)
|
|
return nil
|
|
}
|
|
|
|
func (s *fakeProviderSecretStore) HealthCheck() error {
|
|
return nil
|
|
}
|
|
|
|
var _ secretstore.SecretStore = (*fakeProviderSecretStore)(nil)
|
|
|
|
type failOnUseSecretStore struct{}
|
|
|
|
func (s failOnUseSecretStore) Put(string, []byte) error {
|
|
return fmt.Errorf("secret store should not be used")
|
|
}
|
|
|
|
func (s failOnUseSecretStore) Get(string) ([]byte, error) {
|
|
return nil, fmt.Errorf("secret store should not be used")
|
|
}
|
|
|
|
func (s failOnUseSecretStore) Delete(string) error {
|
|
return fmt.Errorf("secret store should not be used")
|
|
}
|
|
|
|
func (s failOnUseSecretStore) HealthCheck() error {
|
|
return fmt.Errorf("secret store should not be used")
|
|
}
|
|
|
|
var _ secretstore.SecretStore = (*failOnUseSecretStore)(nil)
|