fix(settings-account): 撤权时失效在途账号读取并清空账号快照

This commit is contained in:
imccyu committed 2026-10-21 17:57:52 +08:00
1 parent 0c619816c3
commit 7f7e1efdff
3 files changed
+109 -6

No files matched your search

@@ -123,10 +123,20 @@ export function apply(ctx: Context): void {
name: 'account', open: signal => ctx.remote.account.watch(signal), ended: () => new Error('account stream ended'),
})
let disposed = false
// `notices.end()` disposes this generation's notice lifetime, which a frame
// of this stream begins; the notice controller stays outside and begins
// again under the next grant.
yield () => { disposed = true; notices.end(); return stream.dispose() }
// Withdrawing access invalidates the reads this grant started and clears its
// account view, synchronously, so the next grant starts from no account
// state. `notices.end()` disposes this generation's notice lifetime, which a
// frame of this stream begins; the notice controller stays outside and
// begins again under the next grant.
yield () => {
disposed = true
revision++
refreshing = undefined
avatar.reset()
publish({ ...snapshot, view: undefined, details: undefined, avatar: undefined, failed: false })
notices.end()
return stream.dispose()
}
void (async () => {
for await (const frame of stream) {
revision++
@@ -2,7 +2,7 @@
/** Account surfaces follow `account/watch` access: a revocation withdraws them and a later grant restores them. */
import { afterEach, expect, it, vi } from 'vitest'
import type { AccountView } from '@deepseek-ai/dsh-deepseek-account/types'
import { apply } from '../src/client/index.ts'
import { apply, type AccountSectionInjected } from '../src/client/index.ts'
afterEach(() => { vi.unstubAllEnvs() })
@@ -99,3 +99,95 @@ it('withdraws every account surface and the account stream on revocation, and re
for (const dispose of lifetime.reverse()) dispose()
}
})
it('drops reads a revoked grant started, and the next grant shows no account section before its first frame', async () => {
vi.stubEnv('DSH_CLIENT_VERSION', '0.0.0-test')
const links = { usageUrl: 'https://example.test/usage', topUpUrl: 'https://example.test/top_up' }
const signedIn: AccountView = { status: 'credential-stored', links, attempt: null }
const guards: Guard[] = []
const grant = (): void => {
for (const guard of guards) {
const effect = guard.install()
guard.installed = typeof effect === 'function' ? [effect] : [...effect]
}
}
const revoke = (): void => {
for (const guard of [...guards].reverse()) {
for (const dispose of (guard.installed ?? []).reverse()) dispose()
guard.installed = undefined
}
}
const registered = new Map<string, number>()
let operations: AccountSectionInjected | undefined
const profile = Promise.withResolvers<{ ok: true; value: { status: 'failed' } }>()
const firstFrames: { release: () => void; accepted: Promise<undefined> }[] = []
const lifetime: (() => void)[] = []
const ctx = {
effect: (install: () => (() => void) | undefined) => { const dispose = install(); if (dispose) lifetime.push(dispose) },
locale: { register: () => () => {}, bind: () => (key: string) => key, getSnapshot: () => ({ active: 'en' }) },
configForms: {
get: () => ({ subscribe: () => () => {}, getSnapshot: () => ({ status: 'loading', value: undefined }) }),
describe: () => ({ subscribe: () => () => {}, getSnapshot: () => ({ error: null }) }),
},
slots: {
inject: (_name: string, install: () => (() => void) | undefined) => {
const dispose = install()
return () => { dispose?.() }
},
register: (entry: { readonly name: string; readonly inject?: () => AccountSectionInjected }) => {
if (entry.name === 'settings.launcher') operations = entry.inject?.()
registered.set(entry.name, (registered.get(entry.name) ?? 0) + 1)
return () => { registered.set(entry.name, registered.get(entry.name)! - 1) }
},
},
remoteAccess: {
whenAccess: (action: string, install: () => AccessEffect) => {
guards.push({ action, install, installed: undefined })
return () => {}
},
},
remote: {
$on: () => () => {},
session: { initializeDefaultModel: async () => ({ ok: true, value: undefined }) },
account: { getProfile: async () => await profile.promise, getBalance: async () => ({ ok: true, value: null }) },
$stream: () => {
const abort = new AbortController()
const gate = Promise.withResolvers<undefined>()
const accepted = Promise.withResolvers<undefined>()
firstFrames.push({ release: () => { gate.resolve(undefined) }, accepted: accepted.promise })
return {
signal: abort.signal,
dispose: () => { abort.abort() },
async *[Symbol.asyncIterator]() {
await gate.promise
yield { value: signedIn, accept: () => { accepted.resolve(undefined) } }
await new Promise((resolve) => { abort.signal.addEventListener('abort', resolve) })
},
}
},
},
}
try {
apply(ctx as never)
grant()
firstFrames[0]!.release()
await firstFrames[0]!.accepted
expect(registered.get('settings.section')).toBe(1)
// The profile read started under the first grant settles only after the revocation.
revoke()
expect(operations!.hooks.account.getSnapshot()).toMatchObject({ view: undefined, details: undefined })
profile.resolve({ ok: true, value: { status: 'failed' } })
await new Promise(resolve => setTimeout(resolve, 0))
expect(operations!.hooks.account.getSnapshot().details).toBeUndefined()
grant()
expect(registered.get('settings.section')).toBe(0)
firstFrames[1]!.release()
await firstFrames[1]!.accepted
expect(registered.get('settings.section')).toBe(1)
} finally {
revoke()
for (const dispose of lifetime.reverse()) dispose()
}
})
@@ -322,7 +322,8 @@ it('coalesces refreshes, publishes independent failures, and rejects stale respo
await c.unload(SELF)
pendingAgain.resolve(ok(profile))
await request
expect(actions.hooks.account.getSnapshot().details?.profile).toEqual({ status: 'failed' })
// Unloading ends the grant, which clears the account state; the late profile publishes nothing.
expect(actions.hooks.account.getSnapshot()).toMatchObject({ view: undefined, details: undefined })
}, 60_000)
for (const desktop of [false, true]) it(`uses the browser or Desktop login carrier (Desktop: ${desktop}) and exposes operation errors`, async ({ start, mock }) => {