WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
+76
View File
@@ -0,0 +1,76 @@
package dto
import "github.com/Tencent/WeKnora/internal/types"
// AuthLoginResponse is the HTTP-safe login / switch-tenant response shape.
type AuthLoginResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
User *types.User `json:"user,omitempty"`
ActiveTenant *TenantResponse `json:"active_tenant,omitempty"`
Memberships []types.Membership `json:"memberships"`
Token string `json:"token,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
}
// AuthOIDCCallbackResponse is the HTTP-safe OIDC callback payload shape.
type AuthOIDCCallbackResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
User *types.User `json:"user,omitempty"`
Tenant *TenantResponse `json:"tenant,omitempty"`
Memberships []types.Membership `json:"memberships"`
Token string `json:"token,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
IsNewUser bool `json:"is_new_user,omitempty"`
}
// NewAuthLoginResponse converts a service-layer login response for HTTP output.
func NewAuthLoginResponse(resp *types.LoginResponse) *AuthLoginResponse {
if resp == nil {
return nil
}
var role types.TenantRole
if resp.ActiveTenant != nil {
role = membershipRoleForTenant(resp.Memberships, resp.ActiveTenant.ID)
}
return &AuthLoginResponse{
Success: resp.Success,
Message: resp.Message,
User: resp.User,
ActiveTenant: NewTenantResponseWithRole(resp.ActiveTenant, role),
Memberships: resp.Memberships,
Token: resp.Token,
RefreshToken: resp.RefreshToken,
}
}
// NewAuthOIDCCallbackResponse converts an OIDC callback response for HTTP output.
func NewAuthOIDCCallbackResponse(resp *types.OIDCCallbackResponse) *AuthOIDCCallbackResponse {
if resp == nil {
return nil
}
var role types.TenantRole
if resp.Tenant != nil {
role = membershipRoleForTenant(resp.Memberships, resp.Tenant.ID)
}
return &AuthOIDCCallbackResponse{
Success: resp.Success,
Message: resp.Message,
User: resp.User,
Tenant: NewTenantResponseWithRole(resp.Tenant, role),
Memberships: resp.Memberships,
Token: resp.Token,
RefreshToken: resp.RefreshToken,
IsNewUser: resp.IsNewUser,
}
}
func membershipRoleForTenant(memberships []types.Membership, tenantID uint64) types.TenantRole {
for _, m := range memberships {
if m.TenantID == tenantID && m.Role.IsValid() {
return m.Role
}
}
return ""
}
+60
View File
@@ -0,0 +1,60 @@
package dto
import (
"encoding/json"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAuthLoginResponse_ViewerOmitsActiveTenantSecrets(t *testing.T) {
tenant := sampleSecretTenant()
resp := NewAuthLoginResponse(&types.LoginResponse{
Success: true,
ActiveTenant: tenant,
Memberships: []types.Membership{{
TenantID: tenant.ID,
Role: types.TenantRoleViewer,
}},
})
body, err := json.Marshal(resp)
require.NoError(t, err)
s := string(body)
assert.NotContains(t, s, "tenant-api-key-123")
assert.NotContains(t, s, "parser-secret-123")
}
func TestAuthLoginResponse_OwnerOmitsLegacyTenantAPIKey(t *testing.T) {
tenant := sampleSecretTenant()
resp := NewAuthLoginResponse(&types.LoginResponse{
Success: true,
ActiveTenant: tenant,
Memberships: []types.Membership{{
TenantID: tenant.ID,
Role: types.TenantRoleOwner,
}},
})
body, err := json.Marshal(resp)
require.NoError(t, err)
s := string(body)
assert.NotContains(t, s, `"api_key"`)
assert.NotContains(t, s, "legacy-search-secret-999")
assert.Contains(t, s, "web_search_config")
}
func TestAuthOIDCCallbackResponse_ViewerOmitsTenantSecrets(t *testing.T) {
tenant := sampleSecretTenant()
resp := NewAuthOIDCCallbackResponse(&types.OIDCCallbackResponse{
Success: true,
Tenant: tenant,
Memberships: []types.Membership{{
TenantID: tenant.ID,
Role: types.TenantRoleViewer,
}},
})
body, err := json.Marshal(resp)
require.NoError(t, err)
assert.NotContains(t, string(body), "tenant-api-key-123")
}
+131
View File
@@ -0,0 +1,131 @@
package dto
import (
"encoding/json"
"strings"
"time"
"github.com/Tencent/WeKnora/internal/types"
)
// DataSourceResponse mirrors types.DataSource for response bodies, with the
// connector Credentials map stripped from the Config jsonb. Credential
// presence is exposed via the dedicated /credentials subresource.
//
// Unlike MCP / Model / WebSearch (which have a flat set of named credential
// fields), DataSource credentials are a per-connector atomic map — an OAuth
// token pair, a Confluence email+token bundle, etc. Splitting them at the
// field level would leave half-configured states that can't actually
// authenticate. The subresource therefore exposes only one logical field,
// "credentials", with PUT replacing the whole map and DELETE wiping it.
type DataSourceResponse struct {
ID string `json:"id"`
TenantID uint64 `json:"tenant_id"`
KnowledgeBaseID string `json:"knowledge_base_id"`
Name string `json:"name"`
Type string `json:"type"`
Config *DataSourceConfigDTO `json:"config,omitempty"`
SyncSchedule string `json:"sync_schedule"`
SyncMode string `json:"sync_mode"`
Status string `json:"status"`
ConflictStrategy string `json:"conflict_strategy"`
SyncDeletions bool `json:"sync_deletions"`
LastSyncAt *time.Time `json:"last_sync_at"`
LastSyncCursor json.RawMessage `json:"last_sync_cursor,omitempty"`
LastSyncResult json.RawMessage `json:"last_sync_result,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
SyncLogRetentionDays int `json:"sync_log_retention_days"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
TotalItemsSynced int64 `json:"total_items_synced"`
LatestSyncLog *types.SyncLog `json:"latest_sync_log,omitempty"`
// Single logical credential field — DataSource credentials are a
// per-connector atomic map, so "configured?" applies to the whole set.
Credentials map[string]CredentialFieldMetadata `json:"credentials,omitempty"`
}
// DataSourceConfigDTO is types.DataSourceConfig with the Credentials map
// removed by construction. Type, ResourceIDs and Settings remain visible.
type DataSourceConfigDTO struct {
Type string `json:"type"`
ResourceIDs []string `json:"resource_ids,omitempty"`
Settings map[string]interface{} `json:"settings,omitempty"`
}
// NewDataSourceResponse converts a stored entity into its response shape.
// Returns nil for nil input.
func NewDataSourceResponse(ds *types.DataSource) *DataSourceResponse {
if ds == nil {
return nil
}
var cfgDTO *DataSourceConfigDTO
configured := false
if parsed, err := ds.ParseConfig(); err == nil && parsed != nil {
cfgDTO = &DataSourceConfigDTO{
Type: parsed.Type,
ResourceIDs: parsed.ResourceIDs,
Settings: parsed.Settings,
}
enrichRSSFeedURLsInSettings(ds.Type, parsed, cfgDTO)
configured = parsed.HasConfiguredCredentials(ds.Type)
}
return &DataSourceResponse{
ID: ds.ID,
TenantID: ds.TenantID,
KnowledgeBaseID: ds.KnowledgeBaseID,
Name: ds.Name,
Type: ds.Type,
Config: cfgDTO,
SyncSchedule: ds.SyncSchedule,
SyncMode: ds.SyncMode,
Status: ds.Status,
ConflictStrategy: ds.ConflictStrategy,
SyncDeletions: ds.SyncDeletions,
LastSyncAt: ds.LastSyncAt,
LastSyncCursor: json.RawMessage(ds.LastSyncCursor),
LastSyncResult: json.RawMessage(ds.LastSyncResult),
ErrorMessage: ds.ErrorMessage,
SyncLogRetentionDays: ds.SyncLogRetentionDays,
CreatedAt: ds.CreatedAt,
UpdatedAt: ds.UpdatedAt,
TotalItemsSynced: ds.TotalItemsSynced,
LatestSyncLog: ds.LatestSyncLog,
Credentials: map[string]CredentialFieldMetadata{
"credentials": {Configured: configured},
},
}
}
// enrichRSSFeedURLsInSettings copies feed_urls from credentials into settings
// for API responses. Feed URLs are not secrets but may still live in the
// encrypted credentials blob on rows created before they moved to settings.
func enrichRSSFeedURLsInSettings(dsType string, parsed *types.DataSourceConfig, cfgDTO *DataSourceConfigDTO) {
if dsType != types.ConnectorTypeRSS || parsed == nil || cfgDTO == nil {
return
}
if cfgDTO.Settings != nil {
if v, ok := cfgDTO.Settings["feed_urls"].(string); ok && strings.TrimSpace(v) != "" {
return
}
}
raw, ok := parsed.Credentials["feed_urls"]
if !ok {
return
}
feedURLs, ok := raw.(string)
if !ok || strings.TrimSpace(feedURLs) == "" {
return
}
if cfgDTO.Settings == nil {
cfgDTO.Settings = make(map[string]interface{})
}
cfgDTO.Settings["feed_urls"] = feedURLs
}
func NewDataSourceResponses(dss []*types.DataSource) []*DataSourceResponse {
out := make([]*DataSourceResponse, 0, len(dss))
for _, d := range dss {
out = append(out, NewDataSourceResponse(d))
}
return out
}
+112
View File
@@ -0,0 +1,112 @@
package dto
import (
"encoding/json"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/stretchr/testify/assert"
)
func TestDataSourceResponse_OmitsCredentials(t *testing.T) {
cfg := types.DataSourceConfig{
Type: "github",
Credentials: map[string]interface{}{
"token": "ghp-secret-do-not-leak",
},
ResourceIDs: []string{"repo-1"},
Settings: map[string]interface{}{"branch": "main"},
}
blob, _ := cfg.ToJSON()
ds := &types.DataSource{
ID: "ds-1",
Name: "github-prod",
Type: "github",
Config: blob,
}
body, err := json.Marshal(NewDataSourceResponse(ds))
assert.NoError(t, err)
s := string(body)
assert.NotContains(t, s, "ghp-secret-do-not-leak")
// The inner config object must not carry the credentials map (the
// DataSourceConfigDTO type omits it structurally).
var raw map[string]json.RawMessage
assert.NoError(t, json.Unmarshal(body, &raw))
if cfgRaw, ok := raw["config"]; ok {
var inner map[string]json.RawMessage
assert.NoError(t, json.Unmarshal(cfgRaw, &inner))
_, hasCredsInConfig := inner["credentials"]
assert.False(t, hasCredsInConfig,
"credentials map must not appear inside the config DTO")
}
// Top-level credentials map is just the "configured?" indicator,
// replaces the removed GET /credentials endpoint.
assert.Contains(t, s, `"credentials":{"credentials":{"configured":true}}`)
// Non-secret config fields pass through.
assert.Contains(t, s, "repo-1")
assert.Contains(t, s, "branch")
assert.Contains(t, s, "main")
}
func TestDataSourceResponse_NilSafe(t *testing.T) {
assert.Nil(t, NewDataSourceResponse(nil))
assert.Equal(t, []*DataSourceResponse{}, NewDataSourceResponses(nil))
}
func TestDataSourceResponse_RSSFeedURLsFromCredentials(t *testing.T) {
cfg := types.DataSourceConfig{
Type: types.ConnectorTypeRSS,
Credentials: map[string]interface{}{
"feed_urls": "https://example.com/a.xml\nhttps://example.com/b.xml",
},
ResourceIDs: []string{"https://example.com/a.xml"},
}
blob, _ := cfg.ToJSON()
ds := &types.DataSource{
ID: "ds-rss",
Name: "my-rss",
Type: types.ConnectorTypeRSS,
Config: blob,
}
resp := NewDataSourceResponse(ds)
assert.NotNil(t, resp.Config)
assert.Equal(t, "https://example.com/a.xml\nhttps://example.com/b.xml", resp.Config.Settings["feed_urls"])
assert.False(t, resp.Credentials["credentials"].Configured)
body, err := json.Marshal(resp)
assert.NoError(t, err)
s := string(body)
assert.Contains(t, s, "https://example.com/a.xml")
assert.NotContains(t, s, "Bearer secret")
}
func TestDataSourceResponse_RSSAuthHeadersConfigured(t *testing.T) {
cfg := types.DataSourceConfig{
Type: types.ConnectorTypeRSS,
Credentials: map[string]interface{}{
"auth_headers": "Authorization: Bearer secret",
},
Settings: map[string]interface{}{
"feed_urls": "https://example.com/feed.xml",
},
}
blob, _ := cfg.ToJSON()
ds := &types.DataSource{
ID: "ds-rss-auth",
Name: "my-rss",
Type: types.ConnectorTypeRSS,
Config: blob,
}
resp := NewDataSourceResponse(ds)
assert.True(t, resp.Credentials["credentials"].Configured)
body, err := json.Marshal(resp)
assert.NoError(t, err)
assert.NotContains(t, string(body), "Bearer secret")
}
func TestDataSourceResponse_NoConfig(t *testing.T) {
ds := &types.DataSource{ID: "x", Name: "x"}
body, err := json.Marshal(NewDataSourceResponse(ds))
assert.NoError(t, err)
// No config jsonb stored → no config object in the response.
assert.NotContains(t, string(body), `"config":`)
}
+145
View File
@@ -0,0 +1,145 @@
// Package dto holds response shapes for handler responses that need to differ
// from the persisted GORM model — most notably, response types that must NOT
// carry secret fields.
//
// Why a separate package: response DTOs are deliberately distinct from the
// internal model so the "no secret in responses" guarantee is a compile-time
// invariant rather than a runtime redaction step. If a future contributor
// wants to expose a credential in a response, they must add it to the DTO
// explicitly, which makes the leak surface review-able in a single diff.
package dto
import (
"context"
"time"
"github.com/Tencent/WeKnora/internal/types"
)
// MCPServiceResponse mirrors types.MCPService for response bodies, omitting
// every secret field (api_key, token). Credential presence/absence is exposed
// separately via the /credentials subresource endpoint; this shape carries
// only a boolean per credential field so the frontend can render a
// "configured / not configured" badge without an additional round-trip.
type MCPServiceResponse struct {
ID string `json:"id"`
TenantID uint64 `json:"tenant_id"`
Name string `json:"name"`
Description string `json:"description"`
Enabled bool `json:"enabled"`
TransportType types.MCPTransportType `json:"transport_type"`
URL *string `json:"url,omitempty"`
Headers types.MCPHeaders `json:"headers,omitempty"`
AuthConfig *MCPAuthConfigResponse `json:"auth_config,omitempty"`
AdvancedConfig *types.MCPAdvancedConfig `json:"advanced_config,omitempty"`
StdioConfig *types.MCPStdioConfig `json:"stdio_config,omitempty"`
EnvVars types.MCPEnvVars `json:"env_vars,omitempty"`
IsBuiltin bool `json:"is_builtin"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Credentials is the per-field "configured?" map. Embedded on the main
// response so the credential UI doesn't need a follow-up GET. The
// frontend never sees the actual secret value — only whether one is
// stored. Omitted entirely for builtin services (they can't have
// per-tenant credentials).
Credentials map[string]CredentialFieldMetadata `json:"credentials,omitempty"`
}
// MCPAuthConfigResponse intentionally has no APIKey or Token fields. Their
// presence is signalled via MCPServiceResponse.Credentials. AuthType, Scopes
// and AuthServerMetadataURL are non-secret OAuth configuration and are safe to
// echo back so the UI can render the current strategy.
type MCPAuthConfigResponse struct {
AuthType types.MCPAuthType `json:"auth_type,omitempty"`
APIKeyHeader string `json:"api_key_header,omitempty"`
CustomHeaders map[string]string `json:"custom_headers,omitempty"`
Scopes []string `json:"scopes,omitempty"`
AuthServerMetadataURL string `json:"auth_server_metadata_url,omitempty"`
}
// CredentialFieldMetadata reports whether a credential field has a value
// stored server-side, without exposing the value itself.
type CredentialFieldMetadata struct {
Configured bool `json:"configured"`
}
// NewMCPServiceResponse converts a stored MCPService into its response shape.
//
// Builtin MCP services have their tenant-specific transport details (URL,
// Headers, EnvVars, StdioConfig) stripped — these reveal how the tenant
// configured an upstream provider and must not be visible to other tenants
// that see the same builtin row via the cross-tenant list.
func NewMCPServiceResponse(ctx context.Context, svc *types.MCPService) *MCPServiceResponse {
if svc == nil {
return nil
}
includeDetail := CanViewIntegrationSecrets(ctx)
resp := &MCPServiceResponse{
ID: svc.ID,
TenantID: svc.TenantID,
Name: svc.Name,
Description: svc.Description,
Enabled: svc.Enabled,
TransportType: svc.TransportType,
URL: svc.URL,
Headers: svc.Headers,
AdvancedConfig: svc.AdvancedConfig,
StdioConfig: svc.StdioConfig,
EnvVars: svc.EnvVars,
IsBuiltin: svc.IsBuiltin,
CreatedAt: svc.CreatedAt,
UpdatedAt: svc.UpdatedAt,
}
if !includeDetail {
resp.Headers = nil
resp.EnvVars = nil
resp.URL = nil
resp.StdioConfig = nil
resp.AdvancedConfig = nil
}
if svc.AuthConfig != nil {
auth := &MCPAuthConfigResponse{
AuthType: svc.AuthConfig.AuthType,
APIKeyHeader: svc.AuthConfig.APIKeyHeader,
CustomHeaders: svc.AuthConfig.CustomHeaders,
Scopes: svc.AuthConfig.Scopes,
AuthServerMetadataURL: svc.AuthConfig.AuthServerMetadataURL,
}
if !includeDetail {
auth.CustomHeaders = nil
}
resp.AuthConfig = auth
}
if svc.IsBuiltin {
// Builtin services are shared across tenants — strip everything that
// could leak how this tenant configured the underlying provider.
resp.URL = nil
resp.Headers = nil
resp.EnvVars = nil
resp.StdioConfig = nil
resp.AuthConfig = nil
} else {
resp.Credentials = map[string]CredentialFieldMetadata{
"api_key": {Configured: svc.AuthConfig != nil && svc.AuthConfig.APIKey != ""},
"token": {Configured: svc.AuthConfig != nil && svc.AuthConfig.Token != ""},
}
}
return resp
}
// NewMCPServiceResponses is the slice convenience wrapper used by ListMCPServices.
func NewMCPServiceResponses(ctx context.Context, svcs []*types.MCPService) []*MCPServiceResponse {
out := make([]*MCPServiceResponse, 0, len(svcs))
for _, s := range svcs {
out = append(out, NewMCPServiceResponse(ctx, s))
}
return out
}
// CredentialsResponse is the shared shape returned by PUT
// /{resource}/{id}/credentials. Keyed by field name (e.g. "api_key",
// "token"). The frontend uses this to update its in-memory metadata after a
// successful save without needing to re-fetch the whole resource.
type CredentialsResponse struct {
Fields map[string]CredentialFieldMetadata `json:"fields"`
}
+105
View File
@@ -0,0 +1,105 @@
package dto
import (
"encoding/json"
"strings"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/stretchr/testify/assert"
)
// The most important guarantee of the DTO layer is structural: the serialized
// response body must NEVER contain api_key or token under any condition,
// regardless of the underlying entity's state. We assert this via the
// serialized JSON (not just struct shape) because reflection-based
// custom-marshalers anywhere downstream could otherwise reintroduce a leak.
func TestMCPServiceResponse_OmitsSecrets(t *testing.T) {
svc := &types.MCPService{
ID: "svc-1",
Name: "svc",
AuthConfig: &types.MCPAuthConfig{
APIKey: "sk-real-api-key-do-not-leak",
Token: "tok-real-bearer-token-do-not-leak",
CustomHeaders: map[string]string{"X-Trace": "abc"},
},
}
body, err := json.Marshal(NewMCPServiceResponse(adminContext(), svc))
assert.NoError(t, err)
s := string(body)
assert.NotContains(t, s, "sk-real-api-key-do-not-leak",
"raw api_key must never appear in MCPServiceResponse")
assert.NotContains(t, s, "tok-real-bearer-token-do-not-leak",
"raw token must never appear in MCPServiceResponse")
// auth_config no longer has api_key/token fields (only custom_headers
// survived). Verify the auth_config sub-object contains no secret keys.
var raw map[string]json.RawMessage
assert.NoError(t, json.Unmarshal(body, &raw))
if ac, ok := raw["auth_config"]; ok {
acStr := string(ac)
assert.NotContains(t, acStr, `"api_key"`)
assert.NotContains(t, acStr, `"token"`)
}
// The new credentials map exposes "configured?" booleans by design
// (replaces the standalone GET /credentials endpoint). Verify the
// values are booleans, not strings.
assert.Contains(t, s, `"credentials"`)
assert.Contains(t, s, `"api_key":{"configured":true}`)
assert.Contains(t, s, `"token":{"configured":true}`)
// CustomHeaders is structural metadata and SHOULD pass through.
assert.Contains(t, s, `"custom_headers"`)
assert.Contains(t, s, `"X-Trace"`)
}
func TestMCPServiceResponse_BuiltinStripsTenantConfig(t *testing.T) {
url := "https://tenant-private.example.com"
svc := &types.MCPService{
ID: "builtin-1",
IsBuiltin: true,
URL: &url,
Headers: types.MCPHeaders{"X-Tenant-Secret": "shhh"},
AuthConfig: &types.MCPAuthConfig{
APIKey: "should-not-leak-via-builtin",
},
}
resp := NewMCPServiceResponse(adminContext(), svc)
assert.Nil(t, resp.URL, "builtin must not leak per-tenant URL")
assert.Nil(t, resp.Headers, "builtin must not leak per-tenant headers")
assert.Nil(t, resp.AuthConfig, "builtin must not leak auth config")
body, _ := json.Marshal(resp)
assert.False(t, strings.Contains(string(body), "should-not-leak-via-builtin"))
assert.False(t, strings.Contains(string(body), "X-Tenant-Secret"))
}
func TestMCPServiceResponse_ViewerStripsIntegrationDetail(t *testing.T) {
url := "https://tenant-private.example.com"
svc := &types.MCPService{
ID: "svc-2",
URL: &url,
Headers: types.MCPHeaders{"Authorization": "Bearer secret"},
EnvVars: types.MCPEnvVars{"TOKEN": "secret"},
StdioConfig: &types.MCPStdioConfig{
Command: "npx",
Args: []string{"-y", "mcp-server"},
},
AdvancedConfig: &types.MCPAdvancedConfig{},
AuthConfig: &types.MCPAuthConfig{
CustomHeaders: map[string]string{"X-Auth": "secret"},
},
}
resp := NewMCPServiceResponse(viewerContext(), svc)
assert.Nil(t, resp.URL)
assert.Nil(t, resp.Headers)
assert.Nil(t, resp.EnvVars)
assert.Nil(t, resp.StdioConfig)
assert.Nil(t, resp.AdvancedConfig)
assert.NotNil(t, resp.AuthConfig)
assert.Nil(t, resp.AuthConfig.CustomHeaders)
}
func TestMCPServiceResponse_NilSafe(t *testing.T) {
assert.Nil(t, NewMCPServiceResponse(adminContext(), nil))
assert.Equal(t, []*MCPServiceResponse{}, NewMCPServiceResponses(adminContext(), nil))
}
+127
View File
@@ -0,0 +1,127 @@
package dto
import (
"context"
"time"
"github.com/Tencent/WeKnora/internal/types"
)
// ModelResponse mirrors types.Model for response bodies, with all secret
// fields (APIKey, AppSecret) removed by construction. Credential presence
// metadata lives behind the /credentials subresource, not inlined here.
//
// BaseURL is preserved for tenant-owned models (the frontend needs it to
// render which endpoint a custom model points at). For builtin models it is
// stripped along with every other field that could leak how a particular
// tenant configured the upstream provider.
type ModelResponse struct {
ID string `json:"id"`
TenantID uint64 `json:"tenant_id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Type types.ModelType `json:"type"`
Source types.ModelSource `json:"source"`
Description string `json:"description"`
Parameters ModelParametersDTO `json:"parameters"`
IsDefault bool `json:"is_default"`
IsBuiltin bool `json:"is_builtin"`
Status types.ModelStatus `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Per-field "configured?" map. Omitted for builtin models unless the
// caller is a system administrator. See MCPServiceResponse.Credentials.
Credentials map[string]CredentialFieldMetadata `json:"credentials,omitempty"`
}
// ModelParametersDTO carries every parameter field EXCEPT the two secret
// ones (APIKey, AppSecret). AppID is non-secret and stays — it's an account
// identifier the WeKnora Cloud frontend renders. CustomHeaders is also kept
// (structural metadata, not a credential).
type ModelParametersDTO struct {
BaseURL string `json:"base_url"`
InterfaceType string `json:"interface_type"`
EmbeddingParameters types.EmbeddingParameters `json:"embedding_parameters"`
ParameterSize string `json:"parameter_size"`
Provider string `json:"provider"`
ExtraConfig map[string]string `json:"extra_config,omitempty"`
CustomHeaders map[string]string `json:"custom_headers,omitempty"`
SupportsVision bool `json:"supports_vision"`
ContextWindow int `json:"context_window,omitempty"`
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
MaxConcurrency int `json:"max_concurrency,omitempty"`
AppID string `json:"app_id,omitempty"`
}
// NewModelResponse converts a stored Model into its response shape.
//
// Builtin models are shared across tenants — strip BaseURL (which can leak
// the tenant's private endpoint) and any non-shared parameters.
func NewModelResponse(ctx context.Context, m *types.Model) *ModelResponse {
if m == nil {
return nil
}
params := ModelParametersDTO{
BaseURL: m.Parameters.BaseURL,
InterfaceType: m.Parameters.InterfaceType,
EmbeddingParameters: m.Parameters.EmbeddingParameters,
ParameterSize: m.Parameters.ParameterSize,
Provider: m.Parameters.Provider,
ExtraConfig: m.Parameters.ExtraConfig,
CustomHeaders: m.Parameters.CustomHeaders,
SupportsVision: m.Parameters.SupportsVision,
ContextWindow: m.Parameters.ContextWindow,
MaxOutputTokens: m.Parameters.MaxOutputTokens,
MaxConcurrency: m.Parameters.MaxConcurrency,
AppID: m.Parameters.AppID,
}
canManageBuiltin := m.IsBuiltin && types.IsSystemAdminFromContext(ctx)
if !CanViewIntegrationSecrets(ctx) && !canManageBuiltin {
params.ExtraConfig = nil
params.CustomHeaders = nil
params.BaseURL = ""
}
if m.IsBuiltin && !canManageBuiltin {
// Builtin: strip everything that could reveal per-tenant config.
// EmbeddingParameters and ParameterSize / Provider / InterfaceType /
// SupportsVision / ContextWindow / MaxOutputTokens are intentionally
// preserved (they describe the capability surface, not the configured
// endpoint).
params.BaseURL = ""
params.ExtraConfig = nil
params.CustomHeaders = nil
params.AppID = ""
}
var creds map[string]CredentialFieldMetadata
if !m.IsBuiltin || canManageBuiltin {
creds = map[string]CredentialFieldMetadata{
"api_key": {Configured: m.Parameters.APIKey != ""},
"app_secret": {Configured: m.Parameters.AppSecret != ""},
}
}
return &ModelResponse{
ID: m.ID,
TenantID: m.TenantID,
Name: m.Name,
DisplayName: m.DisplayName,
Type: m.Type,
Source: m.Source,
Description: m.Description,
Parameters: params,
IsDefault: m.IsDefault,
IsBuiltin: m.IsBuiltin,
Status: m.Status,
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
Credentials: creds,
}
}
// NewModelResponses is the slice convenience wrapper.
func NewModelResponses(ctx context.Context, models []*types.Model) []*ModelResponse {
out := make([]*ModelResponse, 0, len(models))
for _, m := range models {
out = append(out, NewModelResponse(ctx, m))
}
return out
}
+122
View File
@@ -0,0 +1,122 @@
package dto
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestModelResponse_OmitsSecrets(t *testing.T) {
m := &types.Model{
ID: "m-1",
Name: "gpt-x",
DisplayName: "Support QA",
Parameters: types.ModelParameters{
APIKey: "sk-real-api-key-do-not-leak",
AppSecret: "app-real-secret-do-not-leak",
AppID: "appid-public-ok-to-show",
BaseURL: "https://api.example.com",
Provider: "openai",
},
}
body, err := json.Marshal(NewModelResponse(adminContext(), m))
assert.NoError(t, err)
s := string(body)
assert.NotContains(t, s, "sk-real-api-key-do-not-leak")
assert.NotContains(t, s, "app-real-secret-do-not-leak")
// Parameters sub-object must contain no secret keys.
var raw map[string]json.RawMessage
assert.NoError(t, json.Unmarshal(body, &raw))
params := string(raw["parameters"])
assert.NotContains(t, params, `"api_key"`)
assert.NotContains(t, params, `"app_secret"`)
// Credential metadata map exposes booleans only.
assert.Contains(t, s, `"credentials"`)
assert.Contains(t, s, `"api_key":{"configured":true}`)
assert.Contains(t, s, `"app_secret":{"configured":true}`)
// Non-secret fields pass through.
assert.Contains(t, s, "appid-public-ok-to-show")
assert.Contains(t, s, "api.example.com")
assert.Contains(t, s, `"display_name":"Support QA"`)
}
func TestModelResponse_BuiltinStripsTenantConfig(t *testing.T) {
m := &types.Model{
ID: "builtin-1",
IsBuiltin: true,
Parameters: types.ModelParameters{
BaseURL: "https://tenant-private.example.com",
APIKey: "should-not-leak",
AppID: "tenant-app-id",
SupportsVision: true,
ExtraConfig: map[string]string{"region": "cn-hangzhou"},
},
}
resp := NewModelResponse(adminContext(), m)
assert.Empty(t, resp.Parameters.BaseURL,
"builtin must not leak per-tenant base URL")
assert.Empty(t, resp.Parameters.AppID,
"builtin must not leak per-tenant app_id")
assert.Nil(t, resp.Parameters.ExtraConfig,
"builtin must not leak per-tenant extra_config")
assert.True(t, resp.Parameters.SupportsVision,
"capability metadata must survive (not per-tenant)")
body, _ := json.Marshal(resp)
assert.False(t, strings.Contains(string(body), "should-not-leak"))
assert.False(t, strings.Contains(string(body), "tenant-private.example.com"))
}
func TestModelResponse_SystemAdminCanManageBuiltinConfig(t *testing.T) {
ctx := context.WithValue(viewerContext(), types.SystemAdminContextKey, true)
m := &types.Model{
ID: "builtin-1",
IsBuiltin: true,
Parameters: types.ModelParameters{
BaseURL: "https://global-provider.example.com",
APIKey: "should-never-be-returned",
AppSecret: "also-never-returned",
AppID: "global-app-id",
ExtraConfig: map[string]string{"region": "ap-guangzhou"},
CustomHeaders: map[string]string{"X-Route": "global"},
},
}
resp := NewModelResponse(ctx, m)
assert.Equal(t, "https://global-provider.example.com", resp.Parameters.BaseURL)
assert.Equal(t, "global-app-id", resp.Parameters.AppID)
assert.Equal(t, map[string]string{"region": "ap-guangzhou"}, resp.Parameters.ExtraConfig)
assert.Equal(t, map[string]string{"X-Route": "global"}, resp.Parameters.CustomHeaders)
assert.True(t, resp.Credentials["api_key"].Configured)
assert.True(t, resp.Credentials["app_secret"].Configured)
body, err := json.Marshal(resp)
require.NoError(t, err)
assert.NotContains(t, string(body), "should-never-be-returned")
assert.NotContains(t, string(body), "also-never-returned")
}
func TestModelResponse_ViewerStripsIntegrationDetail(t *testing.T) {
m := &types.Model{
ID: "m-2",
Parameters: types.ModelParameters{
BaseURL: "https://tenant-private.example.com",
CustomHeaders: map[string]string{"Authorization": "Bearer secret"},
ExtraConfig: map[string]string{"region": "cn-hangzhou"},
},
}
resp := NewModelResponse(viewerContext(), m)
assert.Empty(t, resp.Parameters.BaseURL)
assert.Nil(t, resp.Parameters.CustomHeaders)
assert.Nil(t, resp.Parameters.ExtraConfig)
}
func TestModelResponse_NilSafe(t *testing.T) {
assert.Nil(t, NewModelResponse(adminContext(), nil))
assert.Equal(t, []*ModelResponse{}, NewModelResponses(adminContext(), nil))
}
+42
View File
@@ -0,0 +1,42 @@
package dto
import (
"context"
"github.com/Tencent/WeKnora/internal/types"
)
// RoleFromContext returns the caller's tenant role from ctx.
func RoleFromContext(ctx context.Context) types.TenantRole {
return types.TenantRoleFromContext(ctx)
}
// CanViewIntegrationSecrets is true for Admin+ tenant members and for API keys
// with full tenant access or the manage_tenant_settings capability.
func CanViewIntegrationSecrets(ctx context.Context) bool {
if RoleFromContext(ctx).HasPermission(types.TenantRoleAdmin) {
return true
}
return apiKeyCanManageIntegrationSecrets(ctx)
}
func apiKeyCanManageIntegrationSecrets(ctx context.Context) bool {
scope, ok := types.TenantAPIKeyScopeFromContext(ctx)
if !ok {
return false
}
if scope.FullAccess {
return true
}
return scope.HasCapability(types.APIKeyCapabilityManageTenantSettings)
}
// RoleCanViewTenantAPIKey is true for Owner+ only.
func RoleCanViewTenantAPIKey(role types.TenantRole) bool {
return role.HasPermission(types.TenantRoleOwner)
}
// CanViewTenantAPIKey is true for Owner+ only.
func CanViewTenantAPIKey(ctx context.Context) bool {
return RoleCanViewTenantAPIKey(RoleFromContext(ctx))
}
+42
View File
@@ -0,0 +1,42 @@
package dto
import (
"context"
"testing"
"github.com/Tencent/WeKnora/internal/types"
)
func TestCanViewIntegrationSecretsAdminRole(t *testing.T) {
ctx := context.WithValue(context.Background(), types.TenantRoleContextKey, types.TenantRoleAdmin)
if !CanViewIntegrationSecrets(ctx) {
t.Fatal("admin should view integration secrets")
}
}
func TestCanViewIntegrationSecretsViewerDenied(t *testing.T) {
ctx := context.WithValue(context.Background(), types.TenantRoleContextKey, types.TenantRoleViewer)
if CanViewIntegrationSecrets(ctx) {
t.Fatal("viewer should not view integration secrets")
}
}
func TestCanViewIntegrationSecretsScopedAPIKeyWithManageTenantSettings(t *testing.T) {
ctx := types.WithTenantAPIKeyScope(context.Background(), types.TenantAPIKeyScope{
Capabilities: types.StringArray{string(types.APIKeyCapabilityManageTenantSettings)},
})
ctx = context.WithValue(ctx, types.TenantRoleContextKey, types.TenantRoleViewer)
if !CanViewIntegrationSecrets(ctx) {
t.Fatal("manage_tenant_settings API key should view integration secrets")
}
}
func TestCanViewIntegrationSecretsScopedAPIKeyWithoutCapabilityDenied(t *testing.T) {
ctx := types.WithTenantAPIKeyScope(context.Background(), types.TenantAPIKeyScope{
Capabilities: types.StringArray{string(types.APIKeyCapabilityChat)},
})
ctx = context.WithValue(ctx, types.TenantRoleContextKey, types.TenantRoleViewer)
if CanViewIntegrationSecrets(ctx) {
t.Fatal("chat-only API key should not view integration secrets")
}
}
+91
View File
@@ -0,0 +1,91 @@
package dto
import (
"context"
"time"
"github.com/Tencent/WeKnora/internal/types"
"gorm.io/gorm"
)
// TenantResponse is the viewer-safe tenant profile shape. Secret-bearing
// columns are omitted or redacted unless the caller has Admin+.
type TenantResponse struct {
ID uint64 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Status string `json:"status"`
RetrieverEngines types.RetrieverEngines `json:"retriever_engines"`
Business string `json:"business"`
StorageQuota int64 `json:"storage_quota"`
StorageUsed int64 `json:"storage_used"`
ContextConfig *types.ContextConfig `json:"context_config,omitempty"`
WebSearchConfig *types.WebSearchConfig `json:"web_search_config,omitempty"`
ParserEngineConfig *types.ParserEngineConfig `json:"parser_engine_config,omitempty"`
Credentials *types.CredentialsConfig `json:"credentials,omitempty"`
StorageEngineConfig *types.StorageEngineConfig `json:"storage_engine_config,omitempty"`
ChatHistoryConfig *types.ChatHistoryConfig `json:"chat_history_config,omitempty"`
RetrievalConfig *types.RetrievalConfig `json:"retrieval_config,omitempty"`
MemoryConfig *types.MemoryConfig `json:"memory_config,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"deleted_at"`
}
// NewTenantResponse converts a stored tenant into its HTTP response shape.
func NewTenantResponse(ctx context.Context, tenant *types.Tenant) *TenantResponse {
return NewTenantResponseWithRole(tenant, RoleFromContext(ctx))
}
// NewTenantResponseWithRole converts a stored tenant using an explicit role
// (for auth flows where tenant role is not yet in request context).
func NewTenantResponseWithRole(tenant *types.Tenant, role types.TenantRole) *TenantResponse {
if tenant == nil {
return nil
}
includeSecrets := role.HasPermission(types.TenantRoleAdmin)
resp := &TenantResponse{
ID: tenant.ID,
Name: tenant.Name,
Description: tenant.Description,
Status: tenant.Status,
RetrieverEngines: tenant.RetrieverEngines,
Business: tenant.Business,
StorageQuota: tenant.StorageQuota,
StorageUsed: tenant.StorageUsed,
ContextConfig: tenant.ContextConfig,
ChatHistoryConfig: tenant.ChatHistoryConfig,
RetrievalConfig: tenant.RetrievalConfig,
MemoryConfig: tenant.MemoryConfig,
CreatedAt: tenant.CreatedAt,
UpdatedAt: tenant.UpdatedAt,
DeletedAt: tenant.DeletedAt,
}
if includeSecrets {
resp.WebSearchConfig = types.WebSearchConfigForResponse(tenant.WebSearchConfig, true)
resp.ParserEngineConfig = types.ParserEngineConfigForResponse(tenant.ParserEngineConfig, true)
resp.Credentials = types.CredentialsConfigForResponse(tenant.Credentials, true)
resp.StorageEngineConfig = types.StorageEngineConfigForResponse(tenant.StorageEngineConfig, true)
}
return resp
}
// NewTenantResponses is the slice convenience wrapper.
func NewTenantResponses(ctx context.Context, tenants []*types.Tenant) []*TenantResponse {
out := make([]*TenantResponse, 0, len(tenants))
for _, t := range tenants {
out = append(out, NewTenantResponse(ctx, t))
}
return out
}
// NewTenantResponsesCrossTenant redacts every tenant as Viewer regardless of
// the caller's active-tenant role. Used by cross-tenant list/search endpoints
// where the caller's home-tenant role must not unlock other tenants' secrets.
func NewTenantResponsesCrossTenant(tenants []*types.Tenant) []*TenantResponse {
out := make([]*TenantResponse, 0, len(tenants))
for _, t := range tenants {
out = append(out, NewTenantResponseWithRole(t, types.TenantRoleViewer))
}
return out
}
+86
View File
@@ -0,0 +1,86 @@
package dto
import (
"encoding/json"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTenantResponse_ViewerOmitsSecrets(t *testing.T) {
tenant := sampleSecretTenant()
body, err := json.Marshal(NewTenantResponse(viewerContext(), tenant))
require.NoError(t, err)
s := string(body)
assert.NotContains(t, s, "tenant-api-key-123")
assert.NotContains(t, s, "legacy-search-secret-999")
assert.NotContains(t, s, "wk-app-secret-def")
assert.NotContains(t, s, "parser-secret-123")
assert.NotContains(t, s, "minio-secret-789")
assert.NotContains(t, s, "web_search_config")
assert.NotContains(t, s, "parser_engine_config")
assert.NotContains(t, s, "storage_engine_config")
assert.NotContains(t, s, "credentials")
}
func TestTenantResponse_OwnerOmitsLegacyTenantAPIKey(t *testing.T) {
tenant := sampleSecretTenant()
body, err := json.Marshal(NewTenantResponse(ownerContext(), tenant))
require.NoError(t, err)
s := string(body)
assert.NotContains(t, s, `"api_key"`)
assert.NotContains(t, s, "legacy-search-secret-999")
assert.NotContains(t, s, "parser-secret-123")
assert.Contains(t, s, "web_search_config")
}
func TestTenantResponse_AdminGetsRedactedIntegrationConfigs(t *testing.T) {
tenant := sampleSecretTenant()
resp := NewTenantResponse(adminContext(), tenant)
require.NotNil(t, resp.WebSearchConfig)
assert.Equal(t, types.RedactedSecretPlaceholder, resp.WebSearchConfig.ProxyURL)
assert.Empty(t, resp.WebSearchConfig.APIKey)
require.NotNil(t, resp.ParserEngineConfig)
assert.Equal(t, types.RedactedSecretPlaceholder, resp.ParserEngineConfig.MinerUAPIKey)
require.NotNil(t, resp.StorageEngineConfig.MinIO)
assert.Equal(t, types.RedactedSecretPlaceholder, resp.StorageEngineConfig.MinIO.SecretAccessKey)
}
func TestTenantResponsesCrossTenant_RedactsEvenForOwnerContext(t *testing.T) {
tenant := sampleSecretTenant()
body, err := json.Marshal(NewTenantResponsesCrossTenant([]*types.Tenant{tenant}))
require.NoError(t, err)
s := string(body)
assert.NotContains(t, s, "tenant-api-key-123")
assert.NotContains(t, s, "parser-secret-123")
}
func sampleSecretTenant() *types.Tenant {
return &types.Tenant{
ID: 42,
Name: "tenant",
WebSearchConfig: &types.WebSearchConfig{
APIKey: "legacy-search-secret-999",
ProxyURL: "http://proxy.internal:8080",
},
Credentials: &types.CredentialsConfig{
WeKnoraCloud: &types.WeKnoraCloudCredentials{
AppID: "wk-app-id-abc",
AppSecret: "wk-app-secret-def",
},
},
ParserEngineConfig: &types.ParserEngineConfig{
MinerUAPIKey: "parser-secret-123",
PaddleOCRVLCloudToken: "paddle-secret-456",
},
StorageEngineConfig: &types.StorageEngineConfig{
DefaultProvider: "minio",
MinIO: &types.MinIOEngineConfig{
AccessKeyID: "minio-access-id",
SecretAccessKey: "minio-secret-789",
},
},
}
}
+22
View File
@@ -0,0 +1,22 @@
package dto
import (
"context"
"github.com/Tencent/WeKnora/internal/types"
)
func adminContext() context.Context {
ctx := context.Background()
return context.WithValue(ctx, types.TenantRoleContextKey, types.TenantRoleAdmin)
}
func viewerContext() context.Context {
ctx := context.Background()
return context.WithValue(ctx, types.TenantRoleContextKey, types.TenantRoleViewer)
}
func ownerContext() context.Context {
ctx := context.Background()
return context.WithValue(ctx, types.TenantRoleContextKey, types.TenantRoleOwner)
}
@@ -0,0 +1,74 @@
package dto
import (
"context"
"time"
"github.com/Tencent/WeKnora/internal/types"
)
// WebSearchProviderResponse mirrors types.WebSearchProviderEntity for
// response bodies, with the APIKey field removed by construction. Credential
// presence is exposed via the /credentials subresource.
type WebSearchProviderResponse struct {
ID string `json:"id"`
TenantID uint64 `json:"tenant_id"`
Name string `json:"name"`
Provider types.WebSearchProviderType `json:"provider"`
Description string `json:"description"`
Parameters WebSearchProviderParametersDTO `json:"parameters"`
IsDefault bool `json:"is_default"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Per-field "configured?" map. See MCPServiceResponse.Credentials.
Credentials map[string]CredentialFieldMetadata `json:"credentials,omitempty"`
}
// WebSearchProviderParametersDTO holds every parameter field except APIKey.
// EngineID, BaseURL, ProxyURL and ExtraConfig are not secrets (they describe
// where to send the request, not how to authenticate) and remain visible.
type WebSearchProviderParametersDTO struct {
EngineID string `json:"engine_id,omitempty"`
BaseURL string `json:"base_url,omitempty"`
ProxyURL string `json:"proxy_url,omitempty"`
ExtraConfig map[string]string `json:"extra_config,omitempty"`
}
// NewWebSearchProviderResponse converts a stored entity into its response shape.
func NewWebSearchProviderResponse(ctx context.Context, e *types.WebSearchProviderEntity) *WebSearchProviderResponse {
if e == nil {
return nil
}
params := WebSearchProviderParametersDTO{
EngineID: e.Parameters.EngineID,
BaseURL: e.Parameters.BaseURL,
ProxyURL: e.Parameters.ProxyURL,
ExtraConfig: e.Parameters.ExtraConfig,
}
if !CanViewIntegrationSecrets(ctx) {
params.ProxyURL = ""
params.ExtraConfig = nil
}
return &WebSearchProviderResponse{
ID: e.ID,
TenantID: e.TenantID,
Name: e.Name,
Provider: e.Provider,
Description: e.Description,
Parameters: params,
IsDefault: e.IsDefault,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
Credentials: map[string]CredentialFieldMetadata{
"api_key": {Configured: e.Parameters.APIKey != ""},
},
}
}
func NewWebSearchProviderResponses(ctx context.Context, es []*types.WebSearchProviderEntity) []*WebSearchProviderResponse {
out := make([]*WebSearchProviderResponse, 0, len(es))
for _, e := range es {
out = append(out, NewWebSearchProviderResponse(ctx, e))
}
return out
}
@@ -0,0 +1,54 @@
package dto
import (
"encoding/json"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/stretchr/testify/assert"
)
func TestWebSearchProviderResponse_OmitsSecrets(t *testing.T) {
e := &types.WebSearchProviderEntity{
ID: "wsp-1",
Name: "bing prod",
Provider: types.WebSearchProviderTypeBing,
Parameters: types.WebSearchProviderParameters{
APIKey: "bing-secret-do-not-leak",
EngineID: "engine-public-id",
BaseURL: "https://example.com",
},
}
body, err := json.Marshal(NewWebSearchProviderResponse(adminContext(), e))
assert.NoError(t, err)
s := string(body)
assert.NotContains(t, s, "bing-secret-do-not-leak")
// Parameters sub-object must contain no secret keys.
var raw map[string]json.RawMessage
assert.NoError(t, json.Unmarshal(body, &raw))
assert.NotContains(t, string(raw["parameters"]), `"api_key"`)
// Credential metadata map exposes booleans only.
assert.Contains(t, s, `"credentials"`)
assert.Contains(t, s, `"api_key":{"configured":true}`)
// Non-secret fields pass through.
assert.Contains(t, s, "engine-public-id")
assert.Contains(t, s, "example.com")
}
func TestWebSearchProviderResponse_ViewerStripsIntegrationDetail(t *testing.T) {
e := &types.WebSearchProviderEntity{
ID: "wsp-2",
Parameters: types.WebSearchProviderParameters{
ProxyURL: "http://proxy:8080",
ExtraConfig: map[string]string{"token": "secret"},
},
}
resp := NewWebSearchProviderResponse(viewerContext(), e)
assert.Empty(t, resp.Parameters.ProxyURL)
assert.Nil(t, resp.Parameters.ExtraConfig)
}
func TestWebSearchProviderResponse_NilSafe(t *testing.T) {
assert.Nil(t, NewWebSearchProviderResponse(adminContext(), nil))
assert.Equal(t, []*WebSearchProviderResponse{}, NewWebSearchProviderResponses(adminContext(), nil))
}