diff --git a/packages/client/ui-settings-account/src/client/index.ts b/packages/client/ui-settings-account/src/client/index.ts index 65a989b700..ec53fdc901 100644 --- a/packages/client/ui-settings-account/src/client/index.ts +++ b/packages/client/ui-settings-account/src/client/index.ts @@ -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++ diff --git a/packages/client/ui-settings-account/tests/access-revocation.client.spec.ts b/packages/client/ui-settings-account/tests/access-revocation.client.spec.ts index 90735f78fd..99ab65b4f3 100644 --- a/packages/client/ui-settings-account/tests/access-revocation.client.spec.ts +++ b/packages/client/ui-settings-account/tests/access-revocation.client.spec.ts @@ -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() + let operations: AccountSectionInjected | undefined + const profile = Promise.withResolvers<{ ok: true; value: { status: 'failed' } }>() + const firstFrames: { release: () => void; accepted: Promise }[] = [] + 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() + const accepted = Promise.withResolvers() + 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() + } +}) diff --git a/packages/client/ui-settings-account/tests/apply.client.spec.ts b/packages/client/ui-settings-account/tests/apply.client.spec.ts index c944e6ce3f..300641d109 100644 --- a/packages/client/ui-settings-account/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-account/tests/apply.client.spec.ts @@ -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 }) => {