fix: harden scoped tools and settings persistence

This commit is contained in:
lofyer
2026-08-13 14:56:53 +08:00
parent bf1ec5d2f1
commit aab961226f
62 changed files with 4343 additions and 1314 deletions
@@ -192,10 +192,14 @@ describe('ChannelSettingsStore', () => {
)
const initial = await store.snapshot()
expect(initial.warning).toContain('已损坏')
expect(initial.warnings).toContainEqual({
code: 'channel-settings-recovered'
})
expect(
await readdir(join(filePath, '..'))
).toContain('channel-settings.json.corrupt-1234')
(await readdir(join(filePath, '..'))).some((name) =>
name.startsWith('channel-settings.json.corrupt-1234-')
)
).toBe(true)
await store.apply({
dingtalk: {
@@ -215,6 +219,9 @@ describe('ChannelSettingsStore', () => {
expect((await readdir(join(filePath, '..'))).some(
(name) => name.endsWith('.tmp')
)).toBe(false)
await expect(store.snapshot()).resolves.not.toHaveProperty(
'warnings'
)
})
it('encrypts Weixin binding credentials and removes them on disconnect', async () => {
@@ -251,4 +258,293 @@ describe('ChannelSettingsStore', () => {
})
expect((await store.resolve('weixin')).token).toBeUndefined()
})
it('defers version 2 Weixin migration until safe storage recovers', async () => {
const filePath = await settingsPath()
let available = false
const cipher = createCipher()
const dynamicCipher: ChannelCredentialCipher = {
...cipher,
isAvailable: () => available
}
const legacyCredential = {
formatVersion: 1,
scheme: 'electron-safe-storage',
ciphertextBase64: cipher
.encrypt(
JSON.stringify({
version: 1,
channel: 'weixin',
secret: 'legacy-weixin-token'
})
)
.toString('base64')
}
const legacySettings = JSON.stringify({
version: 2,
weixin: {
enabled: true,
credential: legacyCredential,
accountId: 'account-legacy',
userId: 'user-legacy',
baseUrl: 'https://ilinkai.weixin.qq.com'
},
wecom: {
enabled: false,
botId: '',
allowedSenderIds: [],
allowGroupMessages: false
},
dingtalk: {
enabled: false,
clientId: '',
allowedSenderIds: [],
allowGroupMessages: false
}
})
await writeFile(filePath, legacySettings, 'utf8')
const store = new ChannelSettingsStore(filePath, dynamicCipher, {})
await expect(store.snapshot()).rejects.toThrow(
'安全存储暂不可用'
)
expect(await readFile(filePath, 'utf8')).toBe(legacySettings)
expect(
(await readdir(join(filePath, '..'))).some((name) =>
name.startsWith('channel-settings.json.corrupt-')
)
).toBe(false)
available = true
await expect(store.snapshot()).resolves.toMatchObject({
weixin: {
enabled: true,
bindingConfigured: true,
source: 'encrypted'
}
})
await expect(store.resolve('weixin')).resolves.toMatchObject({
accountId: 'account-legacy',
userId: 'user-legacy',
token: 'legacy-weixin-token'
})
expect(
JSON.parse(await readFile(filePath, 'utf8'))
).toMatchObject({
version: 3,
weixin: {
enabled: true,
credential: expect.any(Object)
}
})
})
it('preserves settings created by a newer unsupported version', async () => {
const filePath = await settingsPath()
const futureSettings = JSON.stringify({
version: 99,
futureField: 'keep-me'
})
await writeFile(filePath, futureSettings, 'utf8')
const store = new ChannelSettingsStore(
filePath,
createCipher(),
{}
)
await expect(store.snapshot()).rejects.toThrow(
'不支持通道设置版本 99'
)
expect(await readFile(filePath, 'utf8')).toBe(futureSettings)
expect(
(await readdir(join(filePath, '..'))).some((name) =>
name.startsWith('channel-settings.json.corrupt-')
)
).toBe(false)
})
it('does not start Weixin with a temporarily unavailable credential', async () => {
const filePath = await settingsPath()
const availableStore = new ChannelSettingsStore(
filePath,
createCipher(),
{}
)
await availableStore.saveWeixinBinding({
accountId: 'account-123',
userId: 'user-123',
baseUrl: 'https://ilinkai.weixin.qq.com',
token: 'private-token'
})
const unavailableStore = new ChannelSettingsStore(
filePath,
createCipher(false),
{}
)
await expect(unavailableStore.resolve('weixin')).resolves.toMatchObject({
enabled: false,
source: 'none'
})
await expect(unavailableStore.snapshot()).resolves.toMatchObject({
weixin: {
enabled: false,
bindingConfigured: false
},
warnings: expect.arrayContaining([
{ code: 'channel-weixin-secure-storage-unavailable' }
])
})
expect(
JSON.parse(await readFile(filePath, 'utf8'))
).toMatchObject({
version: 3,
weixin: {
enabled: true,
credential: expect.any(Object)
}
})
await unavailableStore.apply({
wecom: {
enabled: false,
botId: 'bot-id',
secret: { action: 'keep' },
allowedSenderIds: [],
allowGroupMessages: false
}
})
expect(
JSON.parse(await readFile(filePath, 'utf8'))
).toMatchObject({
weixin: {
enabled: true,
credential: expect.any(Object)
}
})
})
it('distinguishes unreadable channel credentials from missing secrets', async () => {
const filePath = await settingsPath()
const availableStore = new ChannelSettingsStore(
filePath,
createCipher(),
{}
)
await availableStore.apply({
wecom: {
enabled: false,
botId: 'bot-id',
secret: { action: 'replace', value: 'private-secret' },
allowedSenderIds: ['sender-a'],
allowGroupMessages: false
}
})
const unreadableStore = new ChannelSettingsStore(
filePath,
{
...createCipher(),
decrypt: () => {
throw new Error('cannot decrypt')
}
},
{}
)
await expect(unreadableStore.snapshot()).resolves.toMatchObject({
wecom: {
secretConfigured: false,
source: 'unreadable'
},
warnings: expect.arrayContaining([
{ code: 'channel-wecom-credential-unreadable' }
])
})
await unreadableStore.apply({
wecom: {
enabled: false,
botId: 'replacement-bot',
secret: { action: 'clear' },
allowedSenderIds: ['sender-a'],
allowGroupMessages: false
}
})
await expect(unreadableStore.snapshot()).resolves.toMatchObject({
wecom: {
source: 'none'
}
})
expect(
(await unreadableStore.snapshot()).warnings ?? []
).not.toContainEqual({
code: 'channel-wecom-credential-unreadable'
})
})
})
it.each(['wecom', 'dingtalk'] as const)(
'clears an unreadable %s credential warning after decryption recovers',
async (channel) => {
const filePath = await settingsPath()
const availableCipher = createCipher()
const availableStore = new ChannelSettingsStore(
filePath,
availableCipher,
{}
)
await availableStore.apply(
channel === 'wecom'
? {
wecom: {
enabled: false,
botId: 'bot-id',
secret: { action: 'replace', value: 'private-secret' },
allowedSenderIds: ['sender-a'],
allowGroupMessages: false
}
}
: {
dingtalk: {
enabled: false,
clientId: 'client-id',
secret: { action: 'replace', value: 'private-secret' },
allowedSenderIds: ['sender-a'],
allowGroupMessages: false
}
}
)
let decryptAvailable = false
const recoveringStore = new ChannelSettingsStore(
filePath,
{
...availableCipher,
decrypt: (value) => {
if (!decryptAvailable) {
throw new Error('secure storage is temporarily unavailable')
}
return availableCipher.decrypt(value)
}
},
{}
)
const warningCode =
channel === 'wecom'
? 'channel-wecom-credential-unreadable'
: 'channel-dingtalk-credential-unreadable'
await expect(recoveringStore.snapshot()).resolves.toMatchObject({
[channel]: { source: 'unreadable' },
warnings: expect.arrayContaining([{ code: warningCode }])
})
decryptAvailable = true
await expect(recoveringStore.resolve(channel)).resolves.toMatchObject({
source: 'encrypted',
secret: 'private-secret'
})
expect((await recoveringStore.snapshot()).warnings ?? []).not.toContainEqual(
{ code: warningCode }
)
}
)
+287 -167
View File
@@ -1,12 +1,4 @@
import { randomUUID } from 'node:crypto'
import {
mkdir,
readFile,
rename,
rm,
writeFile
} from 'node:fs/promises'
import { dirname } from 'node:path'
import { readFile } from 'node:fs/promises'
import { z } from 'zod'
import {
CHANNEL_SETTINGS_LIMITS,
@@ -21,17 +13,28 @@ import {
type WeComChannelSettingsInput
} from '../../shared/channel-settings-contracts'
import { weixinAccountDisplay } from '../../shared/weixin-channel-contracts'
import {
settingsWarningsEqual,
type SettingsWarning
} from '../../shared/settings-warning-contracts'
import {
assertSupportedSettingsVersion,
isolateCorruptSettingsFile,
isMissingFileError,
UnsupportedSettingsVersionError,
writeJsonFileAtomically
} from '../settings-file-utils'
import {
decryptSettingsCredential,
encryptedSettingsCredentialSchema,
encryptSettingsCredential,
type SettingsCredentialCipher
} from '../settings-credential-cipher'
export interface ChannelCredentialCipher {
isAvailable(): boolean
encrypt(value: string): Buffer
decrypt(value: Buffer): string
}
export type ChannelCredentialCipher = SettingsCredentialCipher
const encryptedCredentialSchema = z
.object({
formatVersion: z.literal(1),
scheme: z.literal('electron-safe-storage'),
const encryptedCredentialSchema = encryptedSettingsCredentialSchema
.extend({
ciphertextBase64: z
.string()
.min(1)
@@ -112,6 +115,8 @@ type StoredEncryptedCredential = z.infer<
typeof encryptedCredentialSchema
>
class DeferredWeixinMigrationError extends Error {}
const credentialPayloadSchema = z
.object({
version: z.literal(1),
@@ -161,7 +166,7 @@ type EnvironmentChannel = {
secret?: string
allowedSenderIds: readonly string[]
allowGroupMessages: boolean
error?: string
warning?: SettingsWarning
}
export type ResolvedChannelSettings =
@@ -184,7 +189,7 @@ export type ResolvedChannelSettings =
secret?: string
allowedSenderIds: readonly string[]
allowGroupMessages: boolean
source: 'none' | 'encrypted' | 'environment'
source: 'none' | 'encrypted' | 'environment' | 'unreadable'
readOnly: boolean
}
| {
@@ -194,7 +199,7 @@ export type ResolvedChannelSettings =
secret?: string
allowedSenderIds: readonly string[]
allowGroupMessages: boolean
source: 'none' | 'encrypted' | 'environment'
source: 'none' | 'encrypted' | 'environment' | 'unreadable'
readOnly: boolean
}
@@ -221,15 +226,6 @@ const defaultStatus = (enabled: boolean): ChannelRuntimeStatus => ({
state: enabled ? 'stopped' : 'disabled'
})
function isMissingFile(error: unknown): boolean {
return (
error !== null &&
typeof error === 'object' &&
'code' in error &&
error.code === 'ENOENT'
)
}
function boundedEnvironmentValue(
environment: NodeJS.ProcessEnv,
name: string,
@@ -319,15 +315,27 @@ export type WeixinBinding = z.infer<typeof weixinBindingSchema>
export class ChannelSettingsStore {
private settings?: StoredSettings
private warning?: string
private settingsLoad?: Promise<StoredSettings>
private temporarilyDisabledWeixin = false
private warnings: SettingsWarning[] = []
private runtimeRepairWarning?: SettingsWarning
private updateQueue: Promise<void> = Promise.resolve()
private readonly environmentChannels: Record<
CredentialChannel,
EnvironmentChannel
>
constructor(
private readonly filePath: string,
private readonly cipher: ChannelCredentialCipher,
private readonly environment: NodeJS.ProcessEnv = process.env,
private readonly now: () => number = Date.now
) {}
) {
this.environmentChannels = {
wecom: this.readEnvironmentChannel('wecom'),
dingtalk: this.readEnvironmentChannel('dingtalk')
}
}
async snapshot(
statuses: Partial<Record<ManagedChannel, ChannelRuntimeStatus>> = {}
@@ -339,9 +347,17 @@ export class ChannelSettingsStore {
])
const weComEnvironment = this.environmentChannel('wecom')
const dingTalkEnvironment = this.environmentChannel('dingtalk')
const environmentWarning =
weComEnvironment.error ?? dingTalkEnvironment.error
const warning = this.warning ?? environmentWarning
const warnings = [
...this.warnings,
...(this.runtimeRepairWarning ? [this.runtimeRepairWarning] : []),
...(weComEnvironment.warning ? [weComEnvironment.warning] : []),
...(dingTalkEnvironment.warning ? [dingTalkEnvironment.warning] : [])
].filter(
(warning, index, values) =>
values.findIndex(
(candidate) => settingsWarningsEqual(candidate, warning)
) === index
)
return {
weixin: {
enabled: weixin.enabled,
@@ -360,12 +376,9 @@ export class ChannelSettingsStore {
allowGroupMessages: wecom.allowGroupMessages,
status:
statuses.wecom ??
(weComEnvironment.error === undefined
(weComEnvironment.warning === undefined
? defaultStatus(wecom.enabled)
: {
state: 'error',
lastError: weComEnvironment.error
})
: { state: 'error' })
},
dingtalk: {
enabled: dingtalk.enabled,
@@ -377,17 +390,24 @@ export class ChannelSettingsStore {
allowGroupMessages: dingtalk.allowGroupMessages,
status:
statuses.dingtalk ??
(dingTalkEnvironment.error === undefined
(dingTalkEnvironment.warning === undefined
? defaultStatus(dingtalk.enabled)
: {
state: 'error',
lastError: dingTalkEnvironment.error
})
: { state: 'error' })
},
...(warning === undefined ? {} : { warning })
...(warnings.length > 0 ? { warnings } : {})
}
}
reportRuntimeSelectionRepairs(count: number): void {
this.runtimeRepairWarning =
count > 0
? {
code: 'channel-runtime-selections-repaired',
count
}
: undefined
}
getSnapshot(
statuses?: Partial<Record<ManagedChannel, ChannelRuntimeStatus>>
): Promise<ChannelSettingsSnapshot> {
@@ -409,9 +429,16 @@ export class ChannelSettingsStore {
const settings = await this.load()
const stored = settings.weixin
const binding = this.decryptWeixinBinding(stored)
if (this.temporarilyDisabledWeixin && binding) {
this.temporarilyDisabledWeixin = false
this.removeWarnings([
'channel-weixin-credential-unreadable',
'channel-weixin-secure-storage-unavailable'
])
}
return {
channel,
enabled: stored.enabled,
enabled: stored.enabled && !this.temporarilyDisabledWeixin,
accountId: binding?.accountId ?? '',
userId: binding?.userId ?? '',
baseUrl: binding?.baseUrl ?? '',
@@ -448,12 +475,18 @@ export class ChannelSettingsStore {
const settings = await this.load()
const stored = settings[channel]
const secret = this.decryptCredential(channel, stored)
const credentialUnreadable =
stored.credential !== undefined && secret === undefined
const common = {
enabled: stored.enabled,
...(secret === undefined ? {} : { secret }),
allowedSenderIds: [...stored.allowedSenderIds],
allowGroupMessages: stored.allowGroupMessages,
source: secret === undefined ? ('none' as const) : ('encrypted' as const),
source: credentialUnreadable
? ('unreadable' as const)
: secret === undefined
? ('none' as const)
: ('encrypted' as const),
readOnly: false
}
return channel === 'wecom'
@@ -484,7 +517,12 @@ export class ChannelSettingsStore {
}
await this.persist(current)
this.settings = current
this.warning = undefined
this.temporarilyDisabledWeixin = false
this.removeWarnings([
'channel-weixin-credential-unreadable',
'channel-weixin-secure-storage-unavailable',
'channel-weixin-legacy-binding-invalid'
])
snapshot = await this.snapshot()
}
const operation = this.updateQueue.then(update, update)
@@ -504,7 +542,12 @@ export class ChannelSettingsStore {
}
await this.persist(current)
this.settings = current
this.warning = undefined
this.temporarilyDisabledWeixin = false
this.removeWarnings([
'channel-weixin-credential-unreadable',
'channel-weixin-secure-storage-unavailable',
'channel-weixin-legacy-binding-invalid'
])
snapshot = await this.snapshot()
}
const operation = this.updateQueue.then(update, update)
@@ -557,12 +600,30 @@ export class ChannelSettingsStore {
)
}
this.validateEnabledWeixin(current.weixin)
if (!this.temporarilyDisabledWeixin || input.weixin !== undefined) {
this.validateEnabledWeixin(current.weixin)
}
this.validateEnabledCredentialChannel('wecom', current.wecom)
this.validateEnabledCredentialChannel('dingtalk', current.dingtalk)
await this.persist(current)
this.settings = current
this.warning = undefined
if (!this.temporarilyDisabledWeixin) {
this.removeWarnings([
'channel-weixin-credential-unreadable',
'channel-weixin-secure-storage-unavailable',
'channel-weixin-legacy-binding-invalid'
])
}
const resolvedWarningCodes: SettingsWarning['code'][] = [
'channel-settings-recovered'
]
if (input.wecom !== undefined) {
resolvedWarningCodes.push('channel-wecom-credential-unreadable')
}
if (input.dingtalk !== undefined) {
resolvedWarningCodes.push('channel-dingtalk-credential-unreadable')
}
this.removeWarnings(resolvedWarningCodes)
return this.snapshot()
}
@@ -652,34 +713,47 @@ export class ChannelSettingsStore {
if (!this.cipher.isAvailable()) {
throw new Error('系统安全存储不可用,无法保存通道 Secret')
}
const encrypted = this.cipher.encrypt(
JSON.stringify({ version: 1, channel, secret })
)
return {
formatVersion: 1,
scheme: 'electron-safe-storage',
ciphertextBase64: encrypted.toString('base64')
}
return encryptSettingsCredential(this.cipher, {
version: 1,
channel,
secret
})
}
private decryptCredential(
channel: CredentialChannel,
stored: StoredCredentialChannel
): string | undefined {
if (stored.credential === undefined || !this.cipher.isAvailable()) {
if (stored.credential === undefined) {
return undefined
}
const warn = (): undefined => {
this.addWarning({
code:
channel === 'wecom'
? 'channel-wecom-credential-unreadable'
: 'channel-dingtalk-credential-unreadable'
})
return undefined
}
if (!this.cipher.isAvailable()) {
return warn()
}
try {
const payload = credentialPayloadSchema.parse(
JSON.parse(
this.cipher.decrypt(
Buffer.from(stored.credential.ciphertextBase64, 'base64')
)
)
decryptSettingsCredential(this.cipher, stored.credential)
)
return payload.channel === channel ? payload.secret : undefined
if (payload.channel !== channel) {
return warn()
}
this.removeWarnings([
channel === 'wecom'
? 'channel-wecom-credential-unreadable'
: 'channel-dingtalk-credential-unreadable'
])
return payload.secret
} catch {
return undefined
return warn()
}
}
@@ -689,21 +763,14 @@ export class ChannelSettingsStore {
if (!this.cipher.isAvailable()) {
throw new Error('系统安全存储不可用,无法保存微信绑定')
}
const encrypted = this.cipher.encrypt(
JSON.stringify({
version: 2,
channel: 'weixin',
accountId: binding.accountId,
userId: binding.userId,
baseUrl: binding.baseUrl,
token: binding.token
})
)
return {
formatVersion: 1,
scheme: 'electron-safe-storage',
ciphertextBase64: encrypted.toString('base64')
}
return encryptSettingsCredential(this.cipher, {
version: 2,
channel: 'weixin',
accountId: binding.accountId,
userId: binding.userId,
baseUrl: binding.baseUrl,
token: binding.token
})
}
private decryptWeixinBinding(
@@ -714,81 +781,38 @@ export class ChannelSettingsStore {
}
try {
return weixinCredentialPayloadSchema.parse(
JSON.parse(
this.cipher.decrypt(
Buffer.from(stored.credential.ciphertextBase64, 'base64')
)
)
decryptSettingsCredential(this.cipher, stored.credential)
)
} catch {
return undefined
}
}
private async load(): Promise<StoredSettings> {
private load(): Promise<StoredSettings> {
if (this.settings !== undefined) {
return this.settings
return Promise.resolve(this.settings)
}
if (!this.settingsLoad) {
this.settingsLoad = this.readSettings().finally(() => {
this.settingsLoad = undefined
})
}
return this.settingsLoad
}
private async readSettings(): Promise<StoredSettings> {
try {
const raw: unknown = JSON.parse(await readFile(this.filePath, 'utf8'))
assertSupportedSettingsVersion(raw, 3, (version) =>
`当前 GoodBuddy 不支持通道设置版本 ${version},请升级应用后重试`
)
const current = storedSettingsSchema.safeParse(raw)
if (current.success) {
this.settings = current.data
this.settings = this.normalizeStoredSettings(current.data)
} else {
const versionTwo = versionTwoStoredSettingsSchema.safeParse(raw)
if (versionTwo.success) {
const legacyWeixin = versionTwo.data.weixin
let token: string | undefined
if (
legacyWeixin.credential &&
this.cipher.isAvailable()
) {
try {
const payload = credentialPayloadSchema.parse(
JSON.parse(
this.cipher.decrypt(
Buffer.from(
legacyWeixin.credential.ciphertextBase64,
'base64'
)
)
)
)
token =
payload.channel === 'weixin'
? payload.secret
: undefined
} catch {
token = undefined
}
}
const binding =
token &&
legacyWeixin.accountId &&
legacyWeixin.userId &&
legacyWeixin.baseUrl
? {
accountId: legacyWeixin.accountId,
userId: legacyWeixin.userId,
baseUrl: legacyWeixin.baseUrl,
token
}
: undefined
this.settings = {
version: 3,
weixin: {
enabled: binding ? legacyWeixin.enabled : false,
...(binding
? { credential: this.encryptWeixinBinding(binding) }
: {})
},
wecom: versionTwo.data.wecom,
dingtalk: versionTwo.data.dingtalk
}
if (legacyWeixin.enabled && !binding) {
this.warning =
'旧版微信绑定无法安全迁移,请重新扫码绑定'
}
this.settings = this.migrateVersionTwo(versionTwo.data)
} else {
const legacy = legacyStoredSettingsSchema.parse(raw)
this.settings = {
@@ -803,38 +827,114 @@ export class ChannelSettingsStore {
await this.persist(this.settings)
}
} catch (error) {
if (!isMissingFile(error)) {
this.warning = '通道设置文件已损坏,已隔离原文件并恢复默认设置'
await rename(
if (
error instanceof UnsupportedSettingsVersionError ||
error instanceof DeferredWeixinMigrationError
) {
throw error
}
if (!isMissingFileError(error)) {
await isolateCorruptSettingsFile(
this.filePath,
`${this.filePath}.corrupt-${this.now()}`
).catch(() => undefined)
'通道设置已损坏且无法隔离',
this.now
)
this.warnings = [{ code: 'channel-settings-recovered' }]
}
this.settings = cloneStored(defaultStoredSettings)
}
return this.settings
}
private async persist(settings: StoredSettings): Promise<void> {
await mkdir(dirname(this.filePath), { recursive: true })
const temporaryPath = `${this.filePath}.${process.pid}.${randomUUID()}.tmp`
try {
await writeFile(
temporaryPath,
`${JSON.stringify(settings, null, 2)}\n`,
{
encoding: 'utf8',
mode: 0o600,
flag: 'wx'
}
private normalizeStoredSettings(settings: StoredSettings): StoredSettings {
if (
settings.weixin.credential &&
this.decryptWeixinBinding(settings.weixin) === undefined
) {
this.temporarilyDisabledWeixin = true
this.addWarning({
code: this.cipher.isAvailable()
? 'channel-weixin-credential-unreadable'
: 'channel-weixin-secure-storage-unavailable'
})
} else {
this.temporarilyDisabledWeixin = false
}
return settings
}
private migrateVersionTwo(
settings: z.infer<typeof versionTwoStoredSettingsSchema>
): StoredSettings {
const legacyWeixin = settings.weixin
if (legacyWeixin.credential && !this.cipher.isAvailable()) {
throw new DeferredWeixinMigrationError(
'系统安全存储暂不可用,旧版微信绑定尚未迁移;原设置已保留,请恢复安全存储后重试'
)
await rename(temporaryPath, this.filePath)
} finally {
await rm(temporaryPath, { force: true })
}
let token: string | undefined
if (legacyWeixin.credential) {
try {
const payload = credentialPayloadSchema.parse(
decryptSettingsCredential(
this.cipher,
legacyWeixin.credential
)
)
token =
payload.channel === 'weixin' ? payload.secret : undefined
} catch {
throw new DeferredWeixinMigrationError(
'旧版微信绑定无法解密,原设置已保留;请恢复原安全存储后重试'
)
}
}
const binding =
token &&
legacyWeixin.accountId &&
legacyWeixin.userId &&
legacyWeixin.baseUrl
? {
accountId: legacyWeixin.accountId,
userId: legacyWeixin.userId,
baseUrl: legacyWeixin.baseUrl,
token
}
: undefined
if (legacyWeixin.credential && !binding) {
throw new DeferredWeixinMigrationError(
'旧版微信绑定信息不完整或无法验证,原设置已保留;请恢复原配置后重试'
)
}
if (legacyWeixin.enabled && !binding) {
this.addWarning({
code: 'channel-weixin-legacy-binding-invalid'
})
}
return {
version: 3,
weixin: {
enabled: binding ? legacyWeixin.enabled : false,
...(binding
? { credential: this.encryptWeixinBinding(binding) }
: {})
},
wecom: settings.wecom,
dingtalk: settings.dingtalk
}
}
private async persist(settings: StoredSettings): Promise<void> {
await writeJsonFileAtomically(this.filePath, settings)
}
private environmentChannel(channel: CredentialChannel): EnvironmentChannel {
return this.environmentChannels[channel]
}
private readEnvironmentChannel(
channel: CredentialChannel
): EnvironmentChannel {
const prefix =
channel === 'wecom' ? 'GOODBUDDY_WECOM' : 'GOODBUDDY_DINGTALK'
const idName =
@@ -903,11 +1003,31 @@ export class ChannelSettingsStore {
senders.value.length > 0
? {}
: {
error:
channel === 'wecom'
? '企业微信环境变量配置无效或不完整'
: '钉钉环境变量配置无效或不完整'
warning: {
code:
channel === 'wecom'
? 'channel-wecom-environment-invalid'
: 'channel-dingtalk-environment-invalid'
}
})
}
}
private addWarning(warning: SettingsWarning): void {
if (
!this.warnings.some(
(current) => settingsWarningsEqual(current, warning)
)
) {
this.warnings.push(warning)
}
}
private removeWarnings(
codes: readonly SettingsWarning['code'][]
): void {
this.warnings = this.warnings.filter(
(warning) => !codes.includes(warning.code)
)
}
}
@@ -0,0 +1,171 @@
import { describe, expect, it, vi } from 'vitest'
import { WechatBindingController } from './wechat-binding-controller'
import type { WechatSidecarChild } from './wechat-sidecar-client'
function createDeferred(): {
promise: Promise<void>
resolve: () => void
} {
let resolve!: () => void
const promise = new Promise<void>((done) => {
resolve = done
})
return { promise, resolve }
}
describe('WechatBindingController', () => {
it('coalesces duplicate credential messages from the same login', async () => {
const saveReleased = createDeferred()
const saveWeixinBinding = vi.fn(async () => {
await saveReleased.promise
return {} as never
})
let messageListener: ((message: unknown) => void) | undefined
const child: WechatSidecarChild = {
postMessage: vi.fn(),
kill: vi.fn(() => true),
on: vi.fn((_event, listener) => {
messageListener = listener
return child
}),
once: vi.fn(() => child)
}
const onChanged = vi.fn(async () => undefined)
const controller = new WechatBindingController(
{ saveWeixinBinding } as never,
() => child,
onChanged,
vi.fn()
)
const credential = {
type: 'credential' as const,
accountId: 'account-1',
userId: 'user-1',
baseUrl: 'https://ilinkai.weixin.qq.com',
token: 'binding-token'
}
controller.start()
messageListener?.(credential)
messageListener?.(credential)
await vi.waitFor(() =>
expect(saveWeixinBinding).toHaveBeenCalledOnce()
)
saveReleased.resolve()
await controller.stop()
expect(saveWeixinBinding).toHaveBeenCalledOnce()
expect(onChanged).not.toHaveBeenCalled()
})
it('accepts only the first credential from one login generation', async () => {
const firstSaveStarted = createDeferred()
const firstSaveReleased = createDeferred()
const saveWeixinBinding = vi
.fn()
.mockImplementationOnce(async () => {
firstSaveStarted.resolve()
await firstSaveReleased.promise
return {} as never
})
let messageListener: ((message: unknown) => void) | undefined
const child: WechatSidecarChild = {
postMessage: vi.fn(),
kill: vi.fn(() => true),
on: vi.fn((_event, listener) => {
messageListener = listener
return child
}),
once: vi.fn(() => child)
}
const onChanged = vi.fn(async () => undefined)
const controller = new WechatBindingController(
{ saveWeixinBinding } as never,
() => child,
onChanged,
vi.fn()
)
controller.start()
messageListener?.({
type: 'credential',
accountId: 'account-1',
userId: 'user-1',
baseUrl: 'https://ilinkai.weixin.qq.com',
token: 'binding-token-1'
})
messageListener?.({
type: 'credential',
accountId: 'account-2',
userId: 'user-2',
baseUrl: 'https://ilinkai.weixin.qq.com',
token: 'binding-token-2'
})
await firstSaveStarted.promise
expect(() => controller.start()).toThrow(
'微信绑定凭据正在保存,请稍后重试'
)
let stopped = false
const stop = controller.stop().then(() => {
stopped = true
})
await Promise.resolve()
expect(stopped).toBe(false)
firstSaveReleased.resolve()
await stop
expect(saveWeixinBinding).toHaveBeenCalledOnce()
expect(onChanged).not.toHaveBeenCalled()
})
it('waits for an in-flight credential save when stopping', async () => {
const saveStarted = createDeferred()
const saveReleased = createDeferred()
const saveWeixinBinding = vi.fn(async () => {
saveStarted.resolve()
await saveReleased.promise
return {} as never
})
let messageListener: ((message: unknown) => void) | undefined
const child: WechatSidecarChild = {
postMessage: vi.fn(),
kill: vi.fn(() => true),
on: vi.fn((_event, listener) => {
messageListener = listener
return child
}),
once: vi.fn(() => child)
}
const onChanged = vi.fn(async () => undefined)
const controller = new WechatBindingController(
{ saveWeixinBinding } as never,
() => child,
onChanged,
vi.fn()
)
controller.start()
messageListener?.({
type: 'credential',
accountId: 'account-1',
userId: 'user-1',
baseUrl: 'https://ilinkai.weixin.qq.com',
token: 'binding-token'
})
await saveStarted.promise
let stopped = false
const stop = controller.stop().then(() => {
stopped = true
})
await Promise.resolve()
expect(stopped).toBe(false)
saveReleased.resolve()
await stop
expect(saveWeixinBinding).toHaveBeenCalledOnce()
expect(onChanged).not.toHaveBeenCalled()
})
})
+12 -6
View File
@@ -69,10 +69,11 @@ export class WechatBindingController {
return this.snapshot()
}
stop(): void {
async stop(): Promise<void> {
this.generation += 1
this.stopClient()
this.snapshotValue = { status: 'stopped' }
await this.credentialSave
}
private handleMessage(
@@ -83,12 +84,13 @@ export class WechatBindingController {
return
}
if (message.type === 'credential') {
if (this.savingCredential) {
return
}
this.savingCredential = true
this.credentialSave = this.credentialSave
this.stopClient()
const save = this.credentialSave
.then(async () => {
if (generation !== this.generation) {
return
}
this.stopClient()
await this.store.saveWeixinBinding({
accountId: message.accountId,
@@ -120,9 +122,13 @@ export class WechatBindingController {
: '微信绑定保存失败'
})
})
const trackedSave = save
.finally(() => {
this.savingCredential = false
if (this.credentialSave === trackedSave) {
this.savingCredential = false
}
})
this.credentialSave = trackedSave
return
}
if (message.type === 'qr') {