fix(settings-account): 撤权时失效在途账号读取并清空账号快照
This commit is contained in:
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 }) => {
|
||||
|
||||
Reference in new issue
Block a user