fix: harden scoped tools and settings persistence
This commit is contained in:
@@ -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 }
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user