Files
MyGoNavi/internal/ai/service/provider_secret_test.go
Syngnat c7cf9526de 🐛 fix(security): 修复 macOS 无法打开应用及三平台依赖系统钥匙串的问题
- 密文存储:新增 dailysecret 本地存储引擎,连接/代理/AI 密钥不再依赖系统钥匙串
- 启动迁移:自动将已有钥匙串密文迁移到本地 JSON,用户无感知
- WebKit 迁移:从旧版 Wails WebKit LocalStorage 中恢复连接与代理数据
- DMG 修复:移除 --sandbox-safe 避免扩展属性污染签名,新增 xattr 清理与签名校验
- 安全适配:钥匙串不可用时标记完成而非回滚,消除无钥匙串环境下的阻塞
- 出口脱敏:所有连接/代理 API 返回前统一 sanitize 防止密文泄漏
2026-04-13 12:40:25 +08:00

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)