Add Vault settings UI and go-core KV bridge

This commit is contained in:
Haitao Pan 2026-03-29 17:04:01 +08:00
parent 31d83cfde9
commit 7c68bcdc39
14 changed files with 788 additions and 54 deletions

View File

@ -0,0 +1,325 @@
package shared
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
type VaultKVResult struct {
Operation string `json:"operation"`
Mount string `json:"mount"`
Path string `json:"path"`
Data map[string]any `json:"data,omitempty"`
Keys []string `json:"keys,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
func HandleVaultKVTool(arguments map[string]any) (string, error) {
request, err := buildVaultKVRequest(arguments)
if err != nil {
return "", err
}
result, err := executeVaultKVRequest(request)
if err != nil {
return "", err
}
encoded, err := json.MarshalIndent(result, "", " ")
if err != nil {
return "", err
}
return string(encoded), nil
}
type vaultKVRequest struct {
baseURL string
token string
namespace string
operation string
mount string
path string
data map[string]any
cas int
}
func buildVaultKVRequest(arguments map[string]any) (vaultKVRequest, error) {
baseURL := strings.TrimSpace(EnvOrDefault("VAULT_SERVER_URL", ""))
if baseURL == "" {
return vaultKVRequest{}, errors.New("VAULT_SERVER_URL environment variable not set")
}
token := strings.TrimSpace(EnvOrDefault("VAULT_SERVER_ROOT_ACCESS_TOKEN", ""))
if token == "" {
return vaultKVRequest{}, errors.New("VAULT_SERVER_ROOT_ACCESS_TOKEN environment variable not set")
}
operation := strings.ToLower(strings.TrimSpace(StringArg(arguments, "operation", "")))
if operation == "" {
return vaultKVRequest{}, errors.New("operation is required")
}
path := normalizeVaultPath(StringArg(arguments, "path", ""))
if path == "" {
return vaultKVRequest{}, errors.New("path is required")
}
data, err := vaultDataArg(arguments["data"])
if err != nil {
return vaultKVRequest{}, err
}
return vaultKVRequest{
baseURL: strings.TrimRight(baseURL, "/"),
token: token,
namespace: strings.TrimSpace(EnvOrDefault("VAULT_NAMESPACE", "")),
operation: operation,
mount: normalizeVaultMount(StringArg(arguments, "mount", "secret")),
path: path,
data: data,
cas: vaultCASArg(arguments["cas"]),
}, nil
}
func executeVaultKVRequest(request vaultKVRequest) (VaultKVResult, error) {
switch request.operation {
case "get", "read":
return vaultKVRead(request)
case "put", "write":
return vaultKVWrite(request)
case "list":
return vaultKVList(request)
case "delete":
return vaultKVDelete(request)
default:
return VaultKVResult{}, fmt.Errorf("unsupported operation: %s", request.operation)
}
}
func vaultKVRead(request vaultKVRequest) (VaultKVResult, error) {
response, err := doVaultRequest(
request,
http.MethodGet,
vaultDataURL(request.mount, request.path),
nil,
)
if err != nil {
return VaultKVResult{}, err
}
dataBlock := mapArg(response["data"])
return VaultKVResult{
Operation: "read",
Mount: request.mount,
Path: request.path,
Data: mapArg(dataBlock["data"]),
Metadata: mapArg(dataBlock["metadata"]),
}, nil
}
func vaultKVWrite(request vaultKVRequest) (VaultKVResult, error) {
if len(request.data) == 0 {
return VaultKVResult{}, errors.New("data is required for write operations")
}
payload := map[string]any{"data": request.data}
if request.cas > 0 {
payload["options"] = map[string]any{"cas": request.cas}
}
response, err := doVaultRequest(
request,
http.MethodPost,
vaultDataURL(request.mount, request.path),
payload,
)
if err != nil {
return VaultKVResult{}, err
}
return VaultKVResult{
Operation: "write",
Mount: request.mount,
Path: request.path,
Data: request.data,
Metadata: mapArg(mapArg(response["data"])["metadata"]),
}, nil
}
func vaultKVList(request vaultKVRequest) (VaultKVResult, error) {
response, err := doVaultRequest(
request,
"LIST",
vaultMetadataURL(request.mount, request.path),
nil,
)
if err != nil {
return VaultKVResult{}, err
}
dataBlock := mapArg(response["data"])
return VaultKVResult{
Operation: "list",
Mount: request.mount,
Path: request.path,
Keys: stringSliceArg(dataBlock["keys"]),
}, nil
}
func vaultKVDelete(request vaultKVRequest) (VaultKVResult, error) {
_, err := doVaultRequest(
request,
http.MethodDelete,
vaultDataURL(request.mount, request.path),
nil,
)
if err != nil {
return VaultKVResult{}, err
}
return VaultKVResult{
Operation: "delete",
Mount: request.mount,
Path: request.path,
}, nil
}
func doVaultRequest(
request vaultKVRequest,
method string,
target string,
payload map[string]any,
) (map[string]any, error) {
var body io.Reader
if payload != nil {
encoded, err := json.Marshal(payload)
if err != nil {
return nil, err
}
body = bytes.NewReader(encoded)
}
httpRequest, err := http.NewRequest(method, target, body)
if err != nil {
return nil, err
}
httpRequest.Header.Set("X-Vault-Token", request.token)
if request.namespace != "" {
httpRequest.Header.Set("X-Vault-Namespace", request.namespace)
}
if payload != nil {
httpRequest.Header.Set("Content-Type", "application/json")
}
client := &http.Client{Timeout: 30 * time.Second}
response, err := client.Do(httpRequest)
if err != nil {
return nil, err
}
defer response.Body.Close()
bodyBytes, err := io.ReadAll(response.Body)
if err != nil {
return nil, err
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return nil, fmt.Errorf(
"vault api error %d: %s",
response.StatusCode,
strings.TrimSpace(string(bodyBytes)),
)
}
if len(strings.TrimSpace(string(bodyBytes))) == 0 {
return map[string]any{}, nil
}
var decoded map[string]any
if err := json.Unmarshal(bodyBytes, &decoded); err != nil {
return nil, err
}
return decoded, nil
}
func vaultDataURL(mount, path string) string {
return fmt.Sprintf("%s/data/%s", vaultBasePath(mount), vaultPathSegments(path))
}
func vaultMetadataURL(mount, path string) string {
return fmt.Sprintf("%s/metadata/%s", vaultBasePath(mount), vaultPathSegments(path))
}
func vaultBasePath(mount string) string {
return fmt.Sprintf("%s/v1/%s", strings.TrimRight(strings.TrimSpace(EnvOrDefault("VAULT_SERVER_URL", "")), "/"), url.PathEscape(normalizeVaultMount(mount)))
}
func vaultPathSegments(path string) string {
segments := strings.Split(normalizeVaultPath(path), "/")
for index, segment := range segments {
segments[index] = url.PathEscape(segment)
}
return strings.Join(segments, "/")
}
func normalizeVaultMount(raw string) string {
trimmed := strings.Trim(strings.TrimSpace(raw), "/")
if trimmed == "" {
return "secret"
}
return trimmed
}
func normalizeVaultPath(raw string) string {
return strings.Trim(strings.TrimSpace(raw), "/")
}
func vaultDataArg(raw any) (map[string]any, error) {
if raw == nil {
return nil, nil
}
switch typed := raw.(type) {
case map[string]any:
return typed, nil
case string:
trimmed := strings.TrimSpace(typed)
if trimmed == "" {
return nil, nil
}
var decoded map[string]any
if err := json.Unmarshal([]byte(trimmed), &decoded); err != nil {
return nil, errors.New("data must be a JSON object")
}
return decoded, nil
default:
return nil, errors.New("data must be an object")
}
}
func vaultCASArg(raw any) int {
switch typed := raw.(type) {
case int:
return typed
case int64:
return int(typed)
case float64:
return int(typed)
case string:
return IntArg(typed, 0)
default:
return 0
}
}
func mapArg(raw any) map[string]any {
switch typed := raw.(type) {
case map[string]any:
return typed
default:
return map[string]any{}
}
}
func stringSliceArg(raw any) []string {
values, ok := raw.([]any)
if !ok {
return nil
}
result := make([]string, 0, len(values))
for _, value := range values {
text := strings.TrimSpace(fmt.Sprint(value))
if text == "" || text == "<nil>" {
continue
}
result = append(result, text)
}
return result
}

View File

@ -0,0 +1,142 @@
package shared
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestHandleVaultKVToolReadsSecretData(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Fatalf("unexpected method: %s", r.Method)
}
if got := r.Header.Get("X-Vault-Token"); got != "root-token" {
t.Fatalf("unexpected token header: %s", got)
}
if got := r.Header.Get("X-Vault-Namespace"); got != "platform/team-a" {
t.Fatalf("unexpected namespace header: %s", got)
}
if got := r.URL.Path; got != "/v1/secret/data/apps/demo" {
t.Fatalf("unexpected request path: %s", got)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"data": map[string]any{
"api_key": "demo-key",
},
"metadata": map[string]any{
"version": 3,
},
},
})
}))
defer server.Close()
t.Setenv("VAULT_SERVER_URL", server.URL)
t.Setenv("VAULT_SERVER_ROOT_ACCESS_TOKEN", "root-token")
t.Setenv("VAULT_NAMESPACE", "platform/team-a")
output, err := HandleVaultKVTool(map[string]any{
"operation": "read",
"path": "apps/demo",
})
if err != nil {
t.Fatalf("HandleVaultKVTool returned error: %v", err)
}
if !strings.Contains(output, `"api_key": "demo-key"`) {
t.Fatalf("expected secret data in output, got %s", output)
}
}
func TestHandleVaultKVToolWritesSecretData(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Fatalf("unexpected method: %s", r.Method)
}
if got := r.URL.Path; got != "/v1/secret/data/apps/demo" {
t.Fatalf("unexpected request path: %s", got)
}
var payload map[string]any
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode payload: %v", err)
}
data := mapArg(payload["data"])
if got := data["enabled"]; got != true {
t.Fatalf("unexpected data payload: %v", payload)
}
options := mapArg(payload["options"])
if got := options["cas"]; got != float64(2) {
t.Fatalf("unexpected cas payload: %v", payload)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"metadata": map[string]any{
"version": 4,
},
},
})
}))
defer server.Close()
t.Setenv("VAULT_SERVER_URL", server.URL)
t.Setenv("VAULT_SERVER_ROOT_ACCESS_TOKEN", "root-token")
output, err := HandleVaultKVTool(map[string]any{
"operation": "write",
"path": "apps/demo",
"data": map[string]any{
"enabled": true,
},
"cas": 2,
})
if err != nil {
t.Fatalf("HandleVaultKVTool returned error: %v", err)
}
if !strings.Contains(output, `"version": 4`) {
t.Fatalf("expected metadata in output, got %s", output)
}
}
func TestHandleVaultKVToolListsSecretKeys(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "LIST" {
t.Fatalf("unexpected method: %s", r.Method)
}
if got := r.URL.Path; got != "/v1/secret/metadata/apps" {
t.Fatalf("unexpected request path: %s", got)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"keys": []string{"demo", "prod"},
},
})
}))
defer server.Close()
t.Setenv("VAULT_SERVER_URL", server.URL)
t.Setenv("VAULT_SERVER_ROOT_ACCESS_TOKEN", "root-token")
output, err := HandleVaultKVTool(map[string]any{
"operation": "list",
"path": "apps",
})
if err != nil {
t.Fatalf("HandleVaultKVTool returned error: %v", err)
}
if !strings.Contains(output, `"demo"`) || !strings.Contains(output, `"prod"`) {
t.Fatalf("expected listed keys in output, got %s", output)
}
}
func TestHandleVaultKVToolRequiresEnvironment(t *testing.T) {
_, err := HandleVaultKVTool(map[string]any{
"operation": "read",
"path": "apps/demo",
})
if err == nil || !strings.Contains(err.Error(), "VAULT_SERVER_URL") {
t.Fatalf("expected missing environment error, got %v", err)
}
}

View File

@ -131,6 +131,21 @@ func handleRequest(request shared.RPCRequest) map[string]any {
"required": []string{"prompt"},
},
},
{
"name": "vault_kv",
"description": "HashiCorp Vault K/V v2 bridge",
"inputSchema": map[string]any{
"type": "object",
"properties": map[string]any{
"operation": map[string]any{"type": "string"},
"mount": map[string]any{"type": "string"},
"path": map[string]any{"type": "string"},
"data": map[string]any{"type": "object"},
"cas": map[string]any{"type": "number"},
},
"required": []string{"operation", "path"},
},
},
},
})
case "tools/call":
@ -156,6 +171,12 @@ func handleRequest(request shared.RPCRequest) map[string]any {
return shared.ToolErrorResult(request.ID, err)
}
return shared.ToolTextResult(request.ID, content)
case "vault_kv":
content, err := shared.HandleVaultKVTool(params.Arguments)
if err != nil {
return shared.ToolErrorResult(request.ID, err)
}
return shared.ToolTextResult(request.ID, content)
default:
return shared.ErrorResponse(
request.ID,

View File

@ -0,0 +1,80 @@
package toolbridge
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"xworkmate/go_core/internal/shared"
)
func TestHandleRequestListsVaultKVTool(t *testing.T) {
t.Parallel()
response := handleRequest(sharedRequest("tools/list", nil))
result := mapStringAny(response["result"])
tools := result["tools"].([]map[string]any)
found := false
for _, tool := range tools {
if tool["name"] == "vault_kv" {
found = true
break
}
}
if !found {
t.Fatalf("expected vault_kv tool in %v", tools)
}
}
func TestHandleRequestCallsVaultKVTool(t *testing.T) {
var requestPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestPath = r.URL.Path
_ = json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"data": map[string]any{
"demo": "value",
},
},
})
}))
defer server.Close()
t.Setenv("VAULT_SERVER_URL", server.URL)
t.Setenv("VAULT_SERVER_ROOT_ACCESS_TOKEN", "root-token")
response := handleRequest(sharedRequest("tools/call", map[string]any{
"name": "vault_kv",
"arguments": map[string]any{
"operation": "read",
"path": "apps/demo",
},
}))
result := mapStringAny(response["result"])
content := result["content"].([]map[string]any)
text := strings.TrimSpace(content[0]["text"].(string))
if !strings.Contains(text, `"demo": "value"`) {
t.Fatalf("unexpected tool output: %s", text)
}
if requestPath != "/v1/secret/data/apps/demo" {
t.Fatalf("unexpected request path: %s", requestPath)
}
}
func sharedRequest(method string, params map[string]any) shared.RPCRequest {
return shared.RPCRequest{
JSONRPC: "2.0",
ID: 1,
Method: method,
Params: params,
}
}
func mapStringAny(raw any) map[string]any {
if typed, ok := raw.(map[string]any); ok {
return typed
}
return map[string]any{}
}

View File

@ -23,6 +23,10 @@ func handleChatTool(arguments map[string]any) (string, error) {
return shared.HandleChatTool(arguments)
}
func handleVaultKVTool(arguments map[string]any) (string, error) {
return shared.HandleVaultKVTool(arguments)
}
func runClaudeReview(
prompt,
model,

View File

@ -362,8 +362,8 @@ desktop:
description: Desktop account access section
ui_surface: settings_page
vault_server:
enabled: false
release_tier: experimental
enabled: true
release_tier: stable
build_modes: [debug, profile, release]
description: Desktop Vault server integration section
ui_surface: settings_page

View File

@ -332,11 +332,21 @@ extension SettingsPageGatewayConnectionMixinInternal
) {
final hasStoredVaultToken =
controller.settingsController.secureRefs['vault_token'] != null;
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText(
'这里维护 Vault K/V 接入的服务地址与 root token。URL 进入设置草稿root token 只会进入安全存储,不会写入普通 settings 持久层。',
'Manage the Vault K/V endpoint and root token here. The URL stays in the settings draft, while the root token is persisted only through secure storage.',
),
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
EditableFieldInternal(
label: appText('地址', 'Address'),
fieldKey: const ValueKey('vault-server-url-field'),
label: 'VAULT_SERVER_URL',
value: settings.vault.address,
onSubmitted: (value) => saveSettingsInternal(
controller,
@ -344,33 +354,34 @@ extension SettingsPageGatewayConnectionMixinInternal
),
),
EditableFieldInternal(
label: appText('命名空间', 'Namespace'),
fieldKey: const ValueKey('vault-namespace-field'),
label: appText('Namespace可选', 'Namespace (optional)'),
value: settings.vault.namespace,
onSubmitted: (value) => saveSettingsInternal(
controller,
settings.copyWith(vault: settings.vault.copyWith(namespace: value)),
),
),
EditableFieldInternal(
label: appText('认证模式', 'Auth Mode'),
value: settings.vault.authMode,
onSubmitted: (value) => saveSettingsInternal(
controller,
settings.copyWith(vault: settings.vault.copyWith(authMode: value)),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 14),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(16),
),
),
EditableFieldInternal(
label: appText('Token 引用', 'Token Ref'),
value: settings.vault.tokenRef,
onSubmitted: (value) => saveSettingsInternal(
controller,
settings.copyWith(vault: settings.vault.copyWith(tokenRef: value)),
child: Text(
appText(
'当前固定使用 token 模式,安全引用保持为 vault_token。',
'The current integration uses token auth, with the secure reference fixed to vault_token.',
),
style: theme.textTheme.bodySmall,
),
),
buildSecureFieldInternal(
fieldKey: const ValueKey('vault-root-access-token-field'),
controller: vaultTokenControllerInternal,
label:
'${appText('Vault Token', 'Vault Token')} (${settings.vault.tokenRef})',
label: 'VAULT_SERVER_ROOT_ACCESS_TOKEN (${settings.vault.tokenRef})',
hasStoredValue: hasStoredVaultToken,
fieldState: vaultTokenStateInternal,
onStateChanged: (value) =>
@ -378,12 +389,12 @@ extension SettingsPageGatewayConnectionMixinInternal
loadValue: controller.settingsController.loadVaultToken,
onSubmitted: (value) async => controller.saveVaultTokenDraft(value),
storedHelperText: appText(
'已安全保存,默认以 **** 显示,点击查看后读取真实值',
'Stored securely. Shows as **** until you reveal it.',
'已安全保存Vault root token 只会在测试连接或显式保存时使用',
'Stored securely. The Vault root token is only used for test/apply flows.',
),
emptyHelperText: appText(
'输入后先进入草稿;保存后才会写入安全存储。',
'Values stage into draft first and only persist to secure storage after Save.',
'输入后先进入草稿;点击 Save / Apply 后才会写入安全存储。',
'Values stage into draft first and only persist to secure storage after Save / Apply.',
),
),
const SizedBox(height: 12),

View File

@ -118,8 +118,8 @@ extension SettingsPageSectionsMixinInternal on SettingsPageStateInternal {
context,
title: detail.label,
description: appText(
'只在这里维护 Vault 地址、命名空间和安全 token 引用',
'Maintain Vault endpoint, namespace, and secure token references here.',
'在这里维护 Vault 服务地址、可选 namespace以及只进入安全存储的 root token',
'Maintain the Vault server URL, optional namespace, and the root token that only persists in secure storage here.',
),
),
const SizedBox(height: 16),

View File

@ -30,11 +30,13 @@ import 'settings_page_device.dart';
class EditableFieldInternal extends StatefulWidget {
const EditableFieldInternal({
super.key,
this.fieldKey,
required this.label,
required this.value,
required this.onSubmitted,
});
final Key? fieldKey;
final String label;
final String value;
final ValueChanged<String> onSubmitted;
@ -76,7 +78,7 @@ class EditableFieldStateInternal extends State<EditableFieldInternal> {
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: TextFormField(
key: ValueKey('${widget.label}:${widget.value}'),
key: widget.fieldKey ?? ValueKey('${widget.label}:${widget.value}'),
controller: controllerInternal,
decoration: InputDecoration(labelText: widget.label),
onChanged: widget.onSubmitted,

View File

@ -383,7 +383,7 @@ class VaultConfig {
factory VaultConfig.defaults() {
return const VaultConfig(
address: 'http://127.0.0.1:8200',
namespace: 'default',
namespace: '',
authMode: 'token',
tokenRef: 'vault_token',
);

View File

@ -199,23 +199,24 @@ void main() {
expect(find.text('账号本地模式'), findsOneWidget);
});
testWidgets('SettingsPage workspace tab no longer exposes remote project root', (
WidgetTester tester,
) async {
final controller = await createTestController(tester);
testWidgets(
'SettingsPage workspace tab no longer exposes remote project root',
(WidgetTester tester) async {
final controller = await createTestController(tester);
await pumpPage(
tester,
child: SettingsPage(controller: controller),
platform: TargetPlatform.macOS,
);
await pumpPage(
tester,
child: SettingsPage(controller: controller),
platform: TargetPlatform.macOS,
);
await tester.tap(find.text('工作区'));
await tester.pumpAndSettle();
await tester.tap(find.text('工作区'));
await tester.pumpAndSettle();
expect(find.text('远程项目根目录'), findsNothing);
expect(find.text('Remote Project Root'), findsNothing);
});
expect(find.text('远程项目根目录'), findsNothing);
expect(find.text('Remote Project Root'), findsNothing);
},
);
testWidgets('SettingsPage integration tab exposes unified gateway controls', (
WidgetTester tester,
@ -234,7 +235,15 @@ void main() {
expect(find.text('OpenClaw Gateway'), findsWidgets);
expect(find.text('LLM 接入点'), findsOneWidget);
expect(find.text('ACP 外部接入'), findsOneWidget);
expect(find.text('Vault Server'), findsNothing);
expect(find.text('Vault Server'), findsOneWidget);
expect(
find.byKey(const ValueKey('vault-server-url-field')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('vault-root-access-token-field')),
findsOneWidget,
);
expect(find.byKey(const ValueKey('ai-gateway-url-field')), findsNothing);
expect(find.byKey(const ValueKey('gateway-mode-field')), findsNothing);
expect(find.text('认证诊断'), findsNothing);
@ -275,20 +284,10 @@ void main() {
);
});
testWidgets('SettingsPage can expose vault section when feature enabled', (
testWidgets('SettingsPage vault card exposes concrete K/V fields', (
WidgetTester tester,
) async {
final manifest = UiFeatureManifest.fallback().copyWithFeature(
platform: UiFeaturePlatform.desktop,
module: 'settings',
feature: 'vault_server',
enabled: true,
releaseTier: UiFeatureReleaseTier.experimental,
);
final controller = await createTestController(
tester,
uiFeatureManifest: manifest,
);
final controller = await createTestController(tester);
await pumpPage(
tester,
@ -300,6 +299,13 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('Vault Server'), findsOneWidget);
expect(find.text('VAULT_SERVER_URL'), findsOneWidget);
expect(
find.textContaining('VAULT_SERVER_ROOT_ACCESS_TOKEN'),
findsOneWidget,
);
expect(find.byKey(const ValueKey('vault-save-button')), findsOneWidget);
expect(find.byKey(const ValueKey('vault-apply-button')), findsOneWidget);
});
testWidgets('SettingsPage integration tab exposes ACP provider endpoints', (

View File

@ -0,0 +1,107 @@
@TestOn('vm')
library;
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:xworkmate/app/app_controller.dart';
import 'package:xworkmate/features/settings/settings_page.dart';
import 'package:xworkmate/runtime/secure_config_store.dart';
import '../test_support.dart';
void main() {
testWidgets(
'SettingsPage Vault card updates the draft URL and token input state',
(WidgetTester tester) async {
late _VaultSettingsTestController controller;
late Directory testRoot;
await tester.runAsync(() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
testRoot = await Directory.systemTemp.createTemp(
'xworkmate-vault-widget-tests-',
);
controller = _VaultSettingsTestController(
store: SecureConfigStore(
enableSecureStorage: false,
databasePathResolver: () async =>
'${testRoot.path}/settings.sqlite3',
fallbackDirectoryPathResolver: () async => testRoot.path,
),
);
await _waitFor(() => !controller.initializing);
});
addTearDown(controller.dispose);
addTearDown(() async {
if (await testRoot.exists()) {
await testRoot.delete(recursive: true);
}
});
await pumpPage(
tester,
child: SettingsPage(controller: controller),
platform: TargetPlatform.macOS,
);
await tester.tap(find.text('集成'));
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('vault-server-url-field')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('vault-namespace-field')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('vault-root-access-token-field')),
findsOneWidget,
);
await tester.enterText(
find.byKey(const ValueKey('vault-server-url-field')),
'https://vault.example.com',
);
await tester.enterText(
find.byKey(const ValueKey('vault-namespace-field')),
'platform/team-a',
);
await tester.enterText(
find.byKey(const ValueKey('vault-root-access-token-field')),
'vault-root-secret',
);
expect(
controller.settingsDraft.vault.address,
'https://vault.example.com',
);
expect(
controller.settings.vault.address,
isNot('https://vault.example.com'),
);
expect(controller.settingsDraft.vault.namespace, 'platform/team-a');
expect(controller.hasSettingsDraftChanges, isTrue);
},
);
}
class _VaultSettingsTestController extends AppController {
_VaultSettingsTestController({super.store});
@override
Future<void> refreshMultiAgentMounts({bool sync = false}) async {}
}
Future<void> _waitFor(bool Function() predicate) async {
final deadline = DateTime.now().add(const Duration(seconds: 10));
while (!predicate()) {
if (DateTime.now().isAfter(deadline)) {
throw StateError('condition not met before timeout');
}
await Future<void>.delayed(const Duration(milliseconds: 20));
}
}

View File

@ -0,0 +1,7 @@
import '../test_suite_stub.dart'
if (dart.library.io) 'settings_vault_persistence_suite.dart'
as suite;
void main() {
suite.main();
}

View File

@ -108,6 +108,35 @@ void registerSecureConfigStoreSuiteSecretsTestsInternal() {
},
);
test(
'SecureConfigStore keeps Vault root token out of the settings snapshot payload',
() async {
final tempDirectory = await createTempDirectoryInternal(
'xworkmate-config-store-vault-secret-',
);
final store = createStoreFromTempDirectoryInternal(tempDirectory);
final snapshot = SettingsSnapshot.defaults().copyWith(
vault: SettingsSnapshot.defaults().vault.copyWith(
address: 'https://vault.example.com',
namespace: 'platform/team-a',
),
);
await store.saveSettingsSnapshot(snapshot);
await store.saveVaultToken('vault-root-secret');
expect(await store.loadVaultToken(), 'vault-root-secret');
expect(
(await store.loadSecureRefs())['vault_token'],
'vault-root-secret',
);
expect(
(await store.loadSettingsSnapshot()).toJsonString(),
isNot(contains('vault-root-secret')),
);
},
);
test(
'SecureConfigStore exposes an explicit secrets write failure when durable secret storage is unavailable',
() async {