修复快手appSecret意外修改问题
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { isMaskedSecretValue, resolveSecretPatch } from './kuaishou-industry-service.js'
|
||||
import type { KuaishouIndustrySourceConfig } from '../../platforms/kuaishou-industry/source-config-service.js'
|
||||
|
||||
test('isMaskedSecretValue correctly identifies masked values and prevents overwrite', () => {
|
||||
assert.equal(isMaskedSecretValue(''), true)
|
||||
assert.equal(isMaskedSecretValue(' '), true)
|
||||
assert.equal(isMaskedSecretValue('SK_ATD******XXrqmD'), true)
|
||||
assert.equal(isMaskedSecretValue('29a8f2****79a2'), true)
|
||||
assert.equal(isMaskedSecretValue('abc***xyz'), true)
|
||||
assert.equal(isMaskedSecretValue('real_secret_key_12345678'), false)
|
||||
assert.equal(isMaskedSecretValue('abcdef12****5678', 'abcdef12345678'), true)
|
||||
})
|
||||
|
||||
test('resolveSecretPatch preserves current secrets when input is empty or masked', () => {
|
||||
const current: KuaishouIndustrySourceConfig = {
|
||||
enabled: true,
|
||||
baseUrl: 'https://openapi.kwaixiaodian.com',
|
||||
authBaseUrl: 'https://open.kwaixiaodian.com',
|
||||
redirectUri: 'https://ks.khhao.com/oauth',
|
||||
scopes: 'merchant_item',
|
||||
authState: 'default',
|
||||
appKey: '1088715114',
|
||||
appSecret: 'real_kuaishou_secret_value_12345',
|
||||
signSecret: 'real_sign_secret_67890',
|
||||
messageSecret: 'real_message_secret_abcde',
|
||||
accessToken: '',
|
||||
refreshToken: '',
|
||||
accessTokenExpiresAt: '',
|
||||
refreshTokenExpiresAt: '',
|
||||
openId: '',
|
||||
grantedScopes: '',
|
||||
sellerId: '',
|
||||
provider: '',
|
||||
platform: '',
|
||||
shopId: '',
|
||||
shopName: '',
|
||||
version: '1',
|
||||
shops: [],
|
||||
lastRefreshedAt: '',
|
||||
lastRefreshError: '',
|
||||
}
|
||||
|
||||
// 1. Payload with empty string secrets (default when page loads and user doesn't edit)
|
||||
const emptyPatch = resolveSecretPatch(
|
||||
{
|
||||
appSecret: '',
|
||||
signSecret: '',
|
||||
messageSecret: '',
|
||||
},
|
||||
current,
|
||||
)
|
||||
assert.equal(emptyPatch.appSecret, 'real_kuaishou_secret_value_12345')
|
||||
assert.equal(emptyPatch.signSecret, 'real_sign_secret_67890')
|
||||
assert.equal(emptyPatch.messageSecret, 'real_message_secret_abcde')
|
||||
|
||||
// 2. Payload with masked string (e.g. browser autofill of placeholder or masked token)
|
||||
const maskedPatch = resolveSecretPatch(
|
||||
{
|
||||
appSecret: 'SK_ATD******XXrqmD',
|
||||
signSecret: 'real_s****7890',
|
||||
},
|
||||
current,
|
||||
)
|
||||
assert.equal(maskedPatch.appSecret, 'real_kuaishou_secret_value_12345')
|
||||
assert.equal(maskedPatch.signSecret, 'real_sign_secret_67890')
|
||||
|
||||
// 3. Payload with genuinely new secret
|
||||
const validPatch = resolveSecretPatch(
|
||||
{
|
||||
appSecret: 'new_valid_app_secret_value_99999',
|
||||
},
|
||||
current,
|
||||
)
|
||||
assert.equal(validPatch.appSecret, 'new_valid_app_secret_value_99999')
|
||||
assert.equal(validPatch.signSecret, 'real_sign_secret_67890')
|
||||
assert.equal(validPatch.messageSecret, 'real_message_secret_abcde')
|
||||
})
|
||||
@@ -213,14 +213,31 @@ function normalizeScopeText(value: unknown): string {
|
||||
.join(',')
|
||||
}
|
||||
|
||||
function resolveSecretPatch(
|
||||
export function isMaskedSecretValue(value: unknown, currentSecret = ''): boolean {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (text.includes('****') || text.includes('***')) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (currentSecret && text === maskSecret(currentSecret)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function resolveSecretPatch(
|
||||
payload: JsonObject,
|
||||
current: KuaishouIndustrySourceConfig,
|
||||
): Partial<KuaishouIndustrySourceConfig> {
|
||||
const patch: Partial<KuaishouIndustrySourceConfig> = {}
|
||||
for (const field of SECRET_FIELDS) {
|
||||
const text = String(payload[field] || '').trim()
|
||||
patch[field] = text || current[field]
|
||||
patch[field] = !text || isMaskedSecretValue(text, current[field]) ? current[field] : text
|
||||
}
|
||||
|
||||
return patch
|
||||
@@ -352,7 +369,7 @@ function resolveShopSecretPatch(
|
||||
|
||||
function readSecretString(payload: JsonObject, field: string, fallback: string): string {
|
||||
const text = String(payload[field] || '').trim()
|
||||
return text || fallback
|
||||
return !text || isMaskedSecretValue(text, fallback) ? fallback : text
|
||||
}
|
||||
|
||||
function resolveAccessTokenStatus(
|
||||
|
||||
@@ -2,12 +2,14 @@ import {
|
||||
CheckCircleOutlined,
|
||||
CopyOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
LinkOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
SaveOutlined,
|
||||
SearchOutlined,
|
||||
SendOutlined,
|
||||
UndoOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
Alert,
|
||||
@@ -1027,6 +1029,155 @@ function resolveHealthStatusColor(status: string) {
|
||||
return 'default'
|
||||
}
|
||||
|
||||
function SecretFieldControl({
|
||||
label,
|
||||
field,
|
||||
masked,
|
||||
hasSecret,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string
|
||||
field: string
|
||||
masked?: string | undefined
|
||||
hasSecret?: boolean | undefined
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string | undefined
|
||||
}) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [draft, setDraft] = useState('')
|
||||
|
||||
const isStaged = Boolean(value && value.trim())
|
||||
const configured = Boolean(hasSecret || (masked && masked.trim()))
|
||||
|
||||
function handleStartEdit() {
|
||||
setDraft('')
|
||||
setIsEditing(true)
|
||||
}
|
||||
|
||||
function handleApply() {
|
||||
const trimmed = draft.trim()
|
||||
if (trimmed) {
|
||||
onChange(trimmed)
|
||||
setIsEditing(false)
|
||||
setDraft('')
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
setDraft('')
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
function handleRevert() {
|
||||
onChange('')
|
||||
setDraft('')
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 4,
|
||||
minHeight: 22,
|
||||
}}
|
||||
>
|
||||
<Typography.Text type="secondary">{label}</Typography.Text>
|
||||
{isStaged ? (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<UndoOutlined />}
|
||||
style={{ padding: 0, height: 'auto', fontSize: 12 }}
|
||||
onClick={handleRevert}
|
||||
>
|
||||
撤销修改
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input.Password
|
||||
autoFocus
|
||||
autoComplete="new-password"
|
||||
data-1p-ignore="true"
|
||||
data-lpignore="true"
|
||||
data-bwignore="true"
|
||||
data-form-type="other"
|
||||
name={`secure_field_${field}`}
|
||||
id={`secure_field_${field}`}
|
||||
placeholder={placeholder || `输入新 ${label}(留空不修改)`}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onPressEnter={handleApply}
|
||||
/>
|
||||
<Button type="primary" onClick={handleApply} disabled={!draft.trim()}>
|
||||
应用
|
||||
</Button>
|
||||
<Button onClick={handleCancel}>取消</Button>
|
||||
</Space.Compact>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
height: 32,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0 11px',
|
||||
background: '#fafafa',
|
||||
border: isStaged ? '1px dashed #faad14' : '1px solid #d9d9d9',
|
||||
borderRadius: 6,
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
||||
fontSize: 12,
|
||||
color: isStaged ? '#d46b08' : configured ? '#262626' : '#8c8c8c',
|
||||
userSelect: 'none',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
marginRight: 8,
|
||||
}}
|
||||
>
|
||||
{isStaged
|
||||
? '已输入新密钥(待保存)'
|
||||
: masked || (configured ? '••••••••••••••••' : '未配置')}
|
||||
</span>
|
||||
{isStaged ? (
|
||||
<Tag color="warning" style={{ margin: 0, flexShrink: 0 }}>
|
||||
待保存
|
||||
</Tag>
|
||||
) : configured ? (
|
||||
<Tag color="success" style={{ margin: 0, flexShrink: 0 }}>
|
||||
已配置
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag style={{ margin: 0, flexShrink: 0 }}>未配置</Tag>
|
||||
)}
|
||||
</div>
|
||||
<Button icon={<EditOutlined />} onClick={handleStartEdit} style={{ flexShrink: 0 }}>
|
||||
{configured ? '修改' : '设置'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KuaishouIndustryPanel({
|
||||
config,
|
||||
onChange,
|
||||
@@ -1208,23 +1359,6 @@ function KuaishouIndustryPanel({
|
||||
})
|
||||
}
|
||||
|
||||
function renderSecretInput(
|
||||
label: string,
|
||||
field: 'appSecret' | 'signSecret' | 'messageSecret',
|
||||
masked: string,
|
||||
) {
|
||||
return (
|
||||
<div>
|
||||
<Typography.Text type="secondary">{label}</Typography.Text>
|
||||
<Input.Password
|
||||
value={source[field]}
|
||||
placeholder={masked || '未配置'}
|
||||
onChange={(event) => updateSource({ [field]: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const shopColumns: TableColumnsType<AdminKuaishouIndustryShopConfig> = [
|
||||
{
|
||||
title: '启用',
|
||||
@@ -1446,9 +1580,30 @@ function KuaishouIndustryPanel({
|
||||
value={source.version}
|
||||
onChange={(version) => updateSource({ version })}
|
||||
/>
|
||||
{renderSecretInput('appSecret', 'appSecret', source.appSecretMasked)}
|
||||
{renderSecretInput('signSecret', 'signSecret', source.signSecretMasked)}
|
||||
{renderSecretInput('messageSecret', 'messageSecret', source.messageSecretMasked)}
|
||||
<SecretFieldControl
|
||||
label="appSecret"
|
||||
field="appSecret"
|
||||
masked={source.appSecretMasked}
|
||||
hasSecret={source.hasAppSecret}
|
||||
value={source.appSecret}
|
||||
onChange={(appSecret) => updateSource({ appSecret })}
|
||||
/>
|
||||
<SecretFieldControl
|
||||
label="signSecret"
|
||||
field="signSecret"
|
||||
masked={source.signSecretMasked}
|
||||
hasSecret={source.hasSignSecret}
|
||||
value={source.signSecret}
|
||||
onChange={(signSecret) => updateSource({ signSecret })}
|
||||
/>
|
||||
<SecretFieldControl
|
||||
label="messageSecret"
|
||||
field="messageSecret"
|
||||
masked={source.messageSecretMasked}
|
||||
hasSecret={source.hasMessageSecret}
|
||||
value={source.messageSecret}
|
||||
onChange={(messageSecret) => updateSource({ messageSecret })}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -1519,24 +1674,24 @@ function KuaishouIndustryPanel({
|
||||
value={row.grantedScopes}
|
||||
onChange={(grantedScopes) => updateShop(index, { grantedScopes })}
|
||||
/>
|
||||
<div>
|
||||
<Typography.Text type="secondary">accessToken</Typography.Text>
|
||||
<Input.Password
|
||||
value={row.accessToken}
|
||||
placeholder={row.accessTokenMasked || '留空保持原值'}
|
||||
onChange={(event) => updateShop(index, { accessToken: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text type="secondary">refreshToken</Typography.Text>
|
||||
<Input.Password
|
||||
value={row.refreshToken}
|
||||
placeholder={row.refreshTokenMasked || '留空保持原值'}
|
||||
onChange={(event) =>
|
||||
updateShop(index, { refreshToken: event.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<SecretFieldControl
|
||||
label="accessToken"
|
||||
field={`shop_${index}_accessToken`}
|
||||
masked={row.accessTokenMasked}
|
||||
hasSecret={row.hasAccessToken}
|
||||
value={row.accessToken}
|
||||
placeholder="输入新 accessToken(留空保持原值)"
|
||||
onChange={(accessToken) => updateShop(index, { accessToken })}
|
||||
/>
|
||||
<SecretFieldControl
|
||||
label="refreshToken"
|
||||
field={`shop_${index}_refreshToken`}
|
||||
masked={row.refreshTokenMasked}
|
||||
hasSecret={row.hasRefreshToken}
|
||||
value={row.refreshToken}
|
||||
placeholder="输入新 refreshToken(留空保持原值)"
|
||||
onChange={(refreshToken) => updateShop(index, { refreshToken })}
|
||||
/>
|
||||
<LabeledInput
|
||||
label="accessToken 过期时间"
|
||||
value={row.accessTokenExpiresAt}
|
||||
@@ -1696,13 +1851,14 @@ function KuaishouFeifeiPlatformPanel({
|
||||
onChange={(event) => updateSource({ appKey: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text type="secondary">App Secret</Typography.Text>
|
||||
<Input.Password
|
||||
value={source.appSecret}
|
||||
onChange={(event) => updateSource({ appSecret: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<SecretFieldControl
|
||||
label="App Secret"
|
||||
field="feifei_appSecret"
|
||||
masked={config.effective.hasAppSecret ? '••••••••••••••••' : ''}
|
||||
hasSecret={config.effective.hasAppSecret}
|
||||
value={source.appSecret}
|
||||
onChange={(appSecret) => updateSource({ appSecret })}
|
||||
/>
|
||||
<NumberField
|
||||
label="超时毫秒"
|
||||
value={source.timeoutMs}
|
||||
@@ -2545,7 +2701,15 @@ function LabeledInput({
|
||||
return (
|
||||
<div>
|
||||
<Typography.Text type="secondary">{label}</Typography.Text>
|
||||
<InputComponent value={value} onChange={(event) => onChange(event.target.value)} />
|
||||
<InputComponent
|
||||
autoComplete={password ? 'new-password' : 'off'}
|
||||
data-1p-ignore="true"
|
||||
data-lpignore="true"
|
||||
data-bwignore="true"
|
||||
data-form-type="other"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2776,6 +2940,13 @@ function AffiliateDashPlatformPanel() {
|
||||
value={appSecret}
|
||||
onChange={(e) => setAppSecret(e.target.value)}
|
||||
placeholder="sk_..."
|
||||
autoComplete="new-password"
|
||||
data-1p-ignore="true"
|
||||
data-lpignore="true"
|
||||
data-bwignore="true"
|
||||
data-form-type="other"
|
||||
name="affiliate_dash_app_secret"
|
||||
id="affiliate_dash_app_secret"
|
||||
/>
|
||||
</Space>
|
||||
<Space wrap>
|
||||
@@ -2785,6 +2956,13 @@ function AffiliateDashPlatformPanel() {
|
||||
value={callbackSecret}
|
||||
onChange={(e) => setCallbackSecret(e.target.value)}
|
||||
placeholder="cb_..."
|
||||
autoComplete="new-password"
|
||||
data-1p-ignore="true"
|
||||
data-lpignore="true"
|
||||
data-bwignore="true"
|
||||
data-form-type="other"
|
||||
name="affiliate_dash_callback_secret"
|
||||
id="affiliate_dash_callback_secret"
|
||||
/>
|
||||
</Space>
|
||||
<Space wrap>
|
||||
|
||||
Reference in New Issue
Block a user