From c9d56d5073bf8c7378ff116d899b5e525a00f694 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:52:55 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(account):=20=E4=BB=85=E5=9C=A8=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E6=8E=A5=E5=8F=A3=E5=A4=B1=E8=B4=A5=E6=97=B6=E8=BE=93?= =?UTF-8?q?=E5=87=BA=20stderr=20=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui-settings-account/README.i18n.yaml | 4 +- packages/client/ui-settings-account/README.md | 2 +- .../client/ui-settings-account/README.zh.md | 2 +- .../ui-settings-account/src/client/index.ts | 5 +- .../tests/apply.client.spec.ts | 35 ++++++++ .../tests/initialization.client.spec.ts | 7 +- .../README.i18n.yaml | 4 +- .../deepseek-account-platform/README.md | 2 +- .../deepseek-account-platform/README.zh.md | 2 +- .../deepseek-account-platform/src/details.ts | 15 +++- .../deepseek-account-platform/src/index.ts | 5 +- .../deepseek-account-platform/src/protocol.ts | 26 +++--- .../tests/diagnostics.spec.ts | 88 +++++++++++++++++-- 13 files changed, 161 insertions(+), 36 deletions(-) diff --git a/packages/client/ui-settings-account/README.i18n.yaml b/packages/client/ui-settings-account/README.i18n.yaml index a0ed274b05..0a138e27c6 100644 --- a/packages/client/ui-settings-account/README.i18n.yaml +++ b/packages/client/ui-settings-account/README.i18n.yaml @@ -36,5 +36,5 @@ en: 7afbd3f7afc20bd8 zh: f54c57e3d6738c7c /deepseek-ai-dsh-client-ui-settings-account/known-limitations-and-deferred-work/dev-note: - en: 2facb0fc2e6c1a9c - zh: bd04803f5c9e132c + en: ebc93259d9f494e2 + zh: dcc679e43cdbd594 diff --git a/packages/client/ui-settings-account/README.md b/packages/client/ui-settings-account/README.md index 913eddfa4b..abeab862a3 100644 --- a/packages/client/ui-settings-account/README.md +++ b/packages/client/ui-settings-account/README.md @@ -114,6 +114,6 @@ The [desktop login decision](../../../.agents/notes/implemented/architecture/202 Usage and top-up show a centered 24px loading indicator without visible loading text until the native document loads; the return action remains available. The loading SVG is embedded locally from Figma node 2957:72553. -Default-model initialization runs after publishing and accepting the sign-in frame, without delaying subsequent account frames. Failures are recorded in diagnostics and do not mark the signed-in account as failed. +Default-model initialization runs after publishing and accepting the sign-in frame, without delaying subsequent account frames. Failures are recorded with `console.error` in the renderer Console and do not mark the signed-in account as failed. Profile and balance reads also report only failures, without account data or raw exceptions; successful refreshes and account frames are silent. Read-only onboarding settings and a failed initial account stream leave the workspace accessible. A failed initial settings read exposes Retry, which reissues the shared settings read. Overlapping onboarding and Platform overlays release background interaction only after their last owner unmounts. diff --git a/packages/client/ui-settings-account/README.zh.md b/packages/client/ui-settings-account/README.zh.md index 0015b1ffe5..0db85a2a4e 100644 --- a/packages/client/ui-settings-account/README.zh.md +++ b/packages/client/ui-settings-account/README.zh.md @@ -114,6 +114,6 @@ Desktop preload 标记在账号凭证已存储后启用引导。进度属于本 用量和充值在原生页面加载完成前显示居中的 24px 加载图标,不显示加载文字;返回操作始终可用。加载 SVG 从 Figma 节点 2957:72553 内嵌到本地样式。 -默认模型初始化在登录帧发布并确认接收后运行,不延迟后续账号帧。初始化失败会记录诊断日志,不会将已登录账号标记为失败。 +默认模型初始化在登录帧发布并确认接收后运行,不延迟后续账号帧。初始化失败通过 `console.error` 记录在渲染进程 Console 中,不会将已登录账号标记为失败。资料和余额读取也仅在失败时记录日志,不包含账号数据或原始异常;成功刷新和账号状态帧不输出日志。 引导设置只读或初始账号流失败时,工作区保持可访问。首次设置读取失败会显示重试入口,重新发起共享设置读取。引导与 Platform 覆盖层重叠时,最后一个持有者卸载后才恢复背景交互。 diff --git a/packages/client/ui-settings-account/src/client/index.ts b/packages/client/ui-settings-account/src/client/index.ts index b28b3e7747..f71f8be04b 100644 --- a/packages/client/ui-settings-account/src/client/index.ts +++ b/packages/client/ui-settings-account/src/client/index.ts @@ -91,6 +91,7 @@ export function apply(ctx: Context): void { let value: AccountDetails[K] | null try { value = await query() } catch { value = { status: 'failed' } } + if (value?.status === 'failed') console.error('[deepseek-account] account read failed', { field }) if (generation === revision && value !== null) publish({ ...snapshot, details: { ...snapshot.details, [field]: value } }) } const request = Promise.all([ @@ -131,9 +132,9 @@ export function apply(ctx: Context): void { if (initialize) void (async () => { try { const initialized = await ctx.remote.session.initializeDefaultModel() - if (!initialized.ok) console.info('[deepseek-account] default model initialization failed', { reason: 'refused' }) + if (!initialized.ok) console.error('[deepseek-account] default model initialization failed', { reason: 'refused' }) } catch (_error) { - console.info('[deepseek-account] default model initialization failed', { reason: 'disconnected' }) + console.error('[deepseek-account] default model initialization failed', { reason: 'disconnected' }) } })() void refresh() 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 71657076f5..bb78e20e89 100644 --- a/packages/client/ui-settings-account/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-account/tests/apply.client.spec.ts @@ -206,6 +206,41 @@ it('reports the account sampled by the click when a native read outlasts a sign- expect(new URL(String(open.mock.calls.at(-1)![0])).searchParams.get('prefill_uid')).toBe('account-user') }, 60_000) +it('keeps successful account refreshes silent and reports only failed reads', async ({ start }) => { + vi.stubGlobal('dshDesktop', {}) + const output = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const info = vi.spyOn(console, 'info').mockImplementation(() => undefined) + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined) + const c = await start() + const actions = operations(c) + await actions.refreshAccount() + c.mock.remote.account.getProfile.mockResolvedValue(ok(profile)) + c.mock.remote.account.getBalance.mockResolvedValue(ok({ status: 'ready', value: [], bonusWallets: [] })) + c.mock.streams.push('account/watch', stored) + await vi.waitFor(() => { expect(actions.hooks.account.getSnapshot().details?.profile).toEqual(profile) }) + await actions.refreshAccount() + for (const spy of [output, info, log]) { + expect(spy.mock.calls.filter(call => String(call[0]).startsWith('[account-debug]') + || String(call[0]).startsWith('[deepseek-account]'))).toEqual([]) + } + c.mock.remote.account.getProfile.mockResolvedValueOnce(ok({ status: 'failed' })) + c.mock.remote.account.getBalance.mockRejectedValueOnce(new Error('private-transport-detail')) + await actions.refreshAccount() + expect(output).toHaveBeenCalledWith('[deepseek-account] account read failed', expect.objectContaining({ + field: 'profile', + })) + expect(output).toHaveBeenCalledWith('[deepseek-account] account read failed', expect.objectContaining({ + field: 'balance', + })) + expect(JSON.stringify(output.mock.calls)).not.toContain('private-transport-detail') + c.mock.remote.account.getBalance.mockResolvedValueOnce({ ok: false, error: new RemoteError('gateway/internal', 'private-rpc-detail', {}) }) + await actions.refreshAccount() + expect(output).toHaveBeenCalledWith('[deepseek-account] account read failed', expect.objectContaining({ + field: 'balance', + })) + expect(JSON.stringify(output.mock.calls)).not.toContain('private-rpc-detail') +}, 60_000) + it('coalesces refreshes, publishes independent failures, and rejects stale responses after sign-out or unload', async ({ start }) => { vi.stubGlobal('dshDesktop', {}) const c = await start() diff --git a/packages/client/ui-settings-account/tests/initialization.client.spec.ts b/packages/client/ui-settings-account/tests/initialization.client.spec.ts index ccd394d506..daa962d263 100644 --- a/packages/client/ui-settings-account/tests/initialization.client.spec.ts +++ b/packages/client/ui-settings-account/tests/initialization.client.spec.ts @@ -4,11 +4,12 @@ import { expect, it, vi, afterEach } from 'vitest' import type { AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types' import { apply, type AccountSectionInjected } from '../src/client/index.ts' -afterEach(() => vi.unstubAllGlobals()) +afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs() }) it.each(['accepted', 'refused', 'disconnected', 'pending'] as const)('initializes account models and keeps reading account state: %s', async (outcome) => { vi.stubGlobal('dshDesktop', {}) - const log = vi.spyOn(console, 'info').mockImplementation(() => {}) + vi.stubEnv('DSH_CLIENT_VERSION', '0.0.0-test') + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) const observed: { status: string | undefined; failed: boolean | undefined }[] = [] const abort = new AbortController() const done = Promise.withResolvers() @@ -28,7 +29,7 @@ it.each(['accepted', 'refused', 'disconnected', 'pending'] as const)('initialize let operations: AccountSectionInjected | undefined const ctx = { effect: (install: () => (() => void) | undefined) => { const dispose = install(); if (dispose) disposers.push(dispose) }, - locale: { register: () => () => {}, bind: () => (key: string) => key }, + locale: { register: () => () => {}, bind: () => (key: string) => key, getSnapshot: () => ({ active: 'en' }) }, configForms: { get: () => ({ subscribe: () => () => {}, getSnapshot: () => ({ status: 'loading', value: undefined }) }), describe: () => ({ subscribe: () => () => {}, getSnapshot: () => ({ error: null }) }), diff --git a/packages/credentials/deepseek-account-platform/README.i18n.yaml b/packages/credentials/deepseek-account-platform/README.i18n.yaml index a61c10fc13..74d983de48 100644 --- a/packages/credentials/deepseek-account-platform/README.i18n.yaml +++ b/packages/credentials/deepseek-account-platform/README.i18n.yaml @@ -15,8 +15,8 @@ en: af856ba79fe31b8e zh: 15b7d1a5d1856de2 /deepseek-ai-dsh-deepseek-account-platform/use-this-package: - en: ec9bb416eec2dcae - zh: 15b9c498388be972 + en: dfeda5f1385bc499 + zh: 03ba6a8e8071d5ea /deepseek-ai-dsh-deepseek-account-platform/understand-the-implementation: en: 76d2da2dab100ac3 zh: 2c9404f27915daab diff --git a/packages/credentials/deepseek-account-platform/README.md b/packages/credentials/deepseek-account-platform/README.md index b117299f36..74b3dbfa9f 100644 --- a/packages/credentials/deepseek-account-platform/README.md +++ b/packages/credentials/deepseek-account-platform/README.md @@ -59,7 +59,7 @@ A private patch can read environment variables through Cordis expressions: The authenticated client supplies its browser-accessible callback origin, including the local SSH forwarding port, and its UI kind. The provider fixes the callback path and binds the resulting redirect_uri to both initialization and exchange. Callback requests use state and PKCE validation rather than RPC authentication. Completion, cancellation and disposal remove only the attempt route. Exchange failures publish failed state without retrying: Web closes its authorization tab and keeps the failure dialog in the original tab; the callback page attempts to close itself and offers a manual-close message. Desktop receives HTTP 204 and focuses its existing login UI through the account stream. -Host diagnostics use the `[deepseek-account]` prefix on standard output and in the Host inspector Console. They include endpoint paths, HTTP status, numeric response codes, failure stages, invalid field paths, received types and validation codes, and browser URL rejection rules; headers, request and response bodies, authorization URLs, and raw exceptions are excluded. +Host diagnostics use `console.error` with the `[deepseek-account]` prefix on standard error and in the Host inspector Console, only for request, response-validation, or sign-in failures. Successful requests and normal account state changes are silent. They include endpoint paths, HTTP status, numeric response codes, failure stages, invalid field paths, received types and validation codes, and browser URL rejection rules; headers, request and response bodies, authorization URLs, and raw exceptions are excluded. `rewriteBrowserOrigin` defaults to `false`, requiring same-origin browser URLs. A private development patch may set it to `true` to map authorization and completion URLs to `platformOrigin`, preserving their fixed paths and query strings. Source URLs must use HTTPS or already match the configured origin; user information, fragments and unexpected paths remain rejected. Shipped profiles retain strict same-origin validation. diff --git a/packages/credentials/deepseek-account-platform/README.zh.md b/packages/credentials/deepseek-account-platform/README.zh.md index 41da32c5c5..a206c53960 100644 --- a/packages/credentials/deepseek-account-platform/README.zh.md +++ b/packages/credentials/deepseek-account-platform/README.zh.md @@ -59,7 +59,7 @@ getUnnotifiedBonuses 读取同一来源上的 GET /api/v0/users/get_unnotified_b 已鉴权客户端提供浏览器可访问的回调来源(含 SSH 本地转发端口)和 UI 类型。提供者固定回调路径,并在初始化与兑换时使用同一个 redirect_uri。回调请求使用 state 和 PKCE 校验,不要求 RPC 鉴权。完成、取消和卸载仅移除本次尝试的路由。兑换失败发布 failed 状态,不自动重试:Web 关闭授权标签页,原标签页显示失败弹窗;回调页尝试自行关闭,并提供手动关闭提示。Desktop 收到 HTTP 204,并通过账号状态流聚焦现有登录界面。 -Host 诊断通过标准输出及 Host 调试器 Console 输出,前缀为 `[deepseek-account]`。日志包含接口路径、HTTP 状态、数值响应码、失败阶段、校验失败的字段路径、实际类型和校验码,以及浏览器 URL 拒绝规则,不包含请求头、请求和响应正文、授权 URL 或原始异常。 +Host 诊断仅在请求、响应校验或登录失败时,通过 `console.error` 写入标准错误及 Host 调试器 Console,前缀为 `[deepseek-account]`。成功请求和正常账号状态变化不输出日志。日志包含接口路径、HTTP 状态、数值响应码、失败阶段、校验失败的字段路径、实际类型和校验码,以及浏览器 URL 拒绝规则,不包含请求头、请求和响应正文、授权 URL 或原始异常。 `rewriteBrowserOrigin` 默认为 `false`,要求浏览器地址同源。私有开发 patch 可设为 `true`,将授权页和完成页地址映射到 `platformOrigin`,保留固定路径和查询字符串。原地址必须使用 HTTPS 或已匹配配置来源;仍拒绝用户名密码、片段和非预期路径。发布的 profile 保持严格同源校验。 diff --git a/packages/credentials/deepseek-account-platform/src/details.ts b/packages/credentials/deepseek-account-platform/src/details.ts index a93a383ee0..7a242d7d61 100644 --- a/packages/credentials/deepseek-account-platform/src/details.ts +++ b/packages/credentials/deepseek-account-platform/src/details.ts @@ -44,7 +44,12 @@ const queries: { [K in keyof AccountDetails]: { profile: { path: '/auth-api/v0/users/current', parse: value => ({ status: 'ready', value: profile(value) }) }, balance: { path: '/api/v0/users/get_user_summary', parse: (value) => { const parsed = summary.safeParse(value) - if (!parsed.success) throw new PlatformAuthError('protocol') + if (!parsed.success) { + console.error('[deepseek-account] balance schema rejected', { + issues: parsed.error.issues.map(issue => ({ path: issue.path, code: issue.code })), + }) + throw new PlatformAuthError('protocol') + } return { status: 'ready', value: parsed.data.normal_wallets, bonusWallets: parsed.data.bonus_wallets } } }, } @@ -64,6 +69,7 @@ export async function readAccountDetail(field: K const query = queries[field] try { return query.parse(await requestAccount(origin, query.path, token, signal, headers)) } catch (error) { + console.error('[deepseek-account] detail failed', { field }) if (error instanceof AccountUnauthorizedError) throw error return { status: 'failed' } } @@ -80,7 +86,12 @@ export async function readAccountDetail(field: K export async function readUnnotifiedBonuses(origin: string, token: string, signal: AbortSignal, headers: Record): Promise { const parsed = unnotified.safeParse(await requestUnnotifiedBonuses(origin, token, signal, headers)) - if (!parsed.success) throw new PlatformAuthError('protocol') + if (!parsed.success) { + console.error('[deepseek-account] bonus schema rejected', { + issues: parsed.error.issues.map(issue => ({ path: issue.path, code: issue.code })), + }) + throw new PlatformAuthError('protocol') + } return parsed.data.map(row => ({ orderId: row.order_id as AccountBonusOrderId, campaign: row.campaign, amount: row.amount, currency: row.currency, grantedAt: row.granted_at, expiresAt: row.expires_at, message: row.msg, diff --git a/packages/credentials/deepseek-account-platform/src/index.ts b/packages/credentials/deepseek-account-platform/src/index.ts index 382cd41dcf..7e45577d31 100644 --- a/packages/credentials/deepseek-account-platform/src/index.ts +++ b/packages/credentials/deepseek-account-platform/src/index.ts @@ -172,7 +172,6 @@ export class PlatformAccount extends DeepSeekAccount { if (!parsed.success) throw new PlatformAuthError('storage') if (parsed.data.issuer === this.origin) return await this.ctx.credentials.deleteRecord(KEY) - console.info('[deepseek-account] stored grant discarded', { reason: 'issuer-mismatch' }) } override async getState(): Promise { @@ -433,7 +432,7 @@ export class PlatformAccount extends DeepSeekAccount { } else attempt.callback?.writeHead(204, { 'cache-control': 'no-store' }).end() }).catch((error: unknown) => { const code = error instanceof PlatformAuthError ? error.code : 'protocol' - console.info('[deepseek-account] sign-in failed', { errorCode: code }) + console.error('[deepseek-account] sign-in failed', { errorCode: code }) this.update(attempt, { phase: code === 'expired' ? 'expired' : 'failed', errorCode: code }) this.finishFailedCallback(attempt) }).then(async () => { @@ -612,7 +611,7 @@ export class PlatformAccount extends DeepSeekAccount { } private rejectPayload(stage: string, error: z.ZodError): never { - console.info('[deepseek-account] payload rejected', { + console.error('[deepseek-account] payload rejected', { stage, issues: error.issues.map(issue => ({ path: issue.path, code: issue.code, receivedType: issue.input === null ? 'null' : Array.isArray(issue.input) ? 'array' : typeof issue.input, diff --git a/packages/credentials/deepseek-account-platform/src/protocol.ts b/packages/credentials/deepseek-account-platform/src/protocol.ts index a8ab9b3d3c..e9b5b9ffa3 100644 --- a/packages/credentials/deepseek-account-platform/src/protocol.ts +++ b/packages/credentials/deepseek-account-platform/src/protocol.ts @@ -40,12 +40,12 @@ export function platformOrigin(value: string, allowLoopbackHttp: boolean): strin export function browserUrl(value: string, origin: string, path: string, rewriteOrigin = false): string { let url: URL try { url = new URL(value) } catch { - console.info('[deepseek-account] browser URL rejected', { path, reason: 'invalid-url' }) + console.error('[deepseek-account] browser URL rejected', { path, reason: 'invalid-url' }) throw new PlatformAuthError('protocol') } const allowedOrigin = url.origin === origin || (rewriteOrigin && url.protocol === 'https:') if (!allowedOrigin || url.pathname !== path || url.username || url.password || url.hash) { - console.info('[deepseek-account] browser URL rejected', { + console.error('[deepseek-account] browser URL rejected', { path, originMismatch: !allowedOrigin, pathMismatch: url.pathname !== path, hasCredentials: Boolean(url.username || url.password), hasFragment: Boolean(url.hash), }) @@ -82,7 +82,7 @@ export const initialization = z.object({ export const exchange = z.object({ token: z.string().regex(/^[\x21-\x7e]+$/), authorized_url: z.url(), user: z.unknown().optional() }) /** - * Read one bounded platform response with stable, non-secret diagnostics. + * Read one bounded platform response; failures emit non-secret diagnostics to stderr. * @param origin - validated platform origin. * @param method - platform endpoint suffix. * @param body - protocol request, never logged. @@ -165,20 +165,20 @@ export async function logoutAccount(origin: string, token: string, async function platformRequest(url: string, init: RequestInit, signal: AbortSignal): Promise { const path = new URL(url).pathname - console.info('[deepseek-account] request', { path, method: init.method }) let response: Response try { response = await fetch(url, { ...init, redirect: 'error', signal }) } catch { - console.info('[deepseek-account] request failed', { path, errorCode: 'no-response', aborted: signal.aborted }) + console.error('[deepseek-account] request failed', { path, errorCode: 'no-response', aborted: signal.aborted }) throw new PlatformAuthError('no-response') } - console.info('[deepseek-account] response', { path, status: response.status }) if (response.status === 401 && new Headers(init.headers).has('x-dsh-auth-token')) { + console.error('[deepseek-account] request failed', { path, status: response.status, errorCode: 'expired' }) await response.body?.cancel() throw new AccountUnauthorizedError() } if (!response.ok || response.body === null) { + console.error('[deepseek-account] request failed', { path, status: response.status, errorCode: 'network' }) await response.body?.cancel() throw new PlatformAuthError('network') } @@ -199,18 +199,20 @@ async function platformRequest(url: string, init: RequestInit, signal: AbortSign } stage = 'parse-json' const payload: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8')) + const codes = z.object({ code: z.number().int(), data: z.object({ biz_code: z.number().int() }).optional() }).safeParse(payload) + if (codes.success && (codes.data.code !== 0 || (codes.data.data !== undefined && codes.data.data.biz_code !== 0))) { + console.error('[deepseek-account] response codes', { + path, status: response.status, code: codes.data.code, bizCode: codes.data.data?.biz_code, + }) + } const authorization = z.object({ code: z.literal(40003) }).safeParse(payload) if (authorization.success && new Headers(init.headers).has('x-dsh-auth-token')) { throw new AccountUnauthorizedError() } - const codes = z.object({ code: z.number().int(), data: z.object({ biz_code: z.number().int() }).optional() }).safeParse(payload) - if (codes.success) console.info('[deepseek-account] response codes', { - path, code: codes.data.code, bizCode: codes.data.data?.biz_code, - }) stage = 'envelope' const parsed = envelope.safeParse(payload) if (!parsed.success) { - console.info('[deepseek-account] envelope rejected', { + console.error('[deepseek-account] envelope rejected', { path, issues: parsed.error.issues.map(issue => ({ path: issue.path, code: issue.code })), }) throw new PlatformAuthError('protocol') @@ -223,7 +225,7 @@ async function platformRequest(url: string, init: RequestInit, signal: AbortSign } return parsed.data.data.biz_data } catch (error) { - console.info('[deepseek-account] response rejected', { path, stage, + console.error('[deepseek-account] response rejected', { path, status: response.status, stage, errorCode: error instanceof PlatformAuthError ? error.code : 'protocol' }) if (error instanceof PlatformAuthError) throw error throw new PlatformAuthError('protocol') diff --git a/packages/credentials/deepseek-account-platform/tests/diagnostics.spec.ts b/packages/credentials/deepseek-account-platform/tests/diagnostics.spec.ts index 55e41ac62b..36732e5a3a 100644 --- a/packages/credentials/deepseek-account-platform/tests/diagnostics.spec.ts +++ b/packages/credentials/deepseek-account-platform/tests/diagnostics.spec.ts @@ -1,5 +1,7 @@ import { expect, it, vi } from 'vitest' -import { browserUrl, requestPlatform } from '../src/protocol.ts' +import { browserUrl, logoutAccount, requestAccount, requestBonusNotified, requestPlatform, requestUnnotifiedBonuses } from '../src/protocol.ts' +import type { AccountBonusOrderId } from '@deepseek-ai/dsh-deepseek-account/types' +import { readAccountDetail, readUnnotifiedBonuses } from '../src/details.ts' it.each(['/dsh/authorize', '/dsh/authorized'])('maps %s to the configured development origin and preserves query bytes', (path) => { const origin = 'http://localhost:8081' @@ -18,7 +20,7 @@ it.each(['/dsh/authorize', '/dsh/authorized'])('maps %s to the configured develo }) it('reports business failure codes without credentials or response messages', async () => { - const output = vi.spyOn(console, 'info').mockImplementation(() => undefined) + const output = vi.spyOn(console, 'error').mockImplementation(() => undefined) const fetcher = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ code: 0, data: { biz_code: 40123, biz_data: { token: 'response-secret' } }, message: 'private-message', }))) @@ -40,7 +42,7 @@ it('reports business failure codes without credentials or response messages', as }) it('reports transport failure without exposing the thrown error', async () => { - const output = vi.spyOn(console, 'info').mockImplementation(() => undefined) + const output = vi.spyOn(console, 'error').mockImplementation(() => undefined) const fetcher = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('private-network-detail')) try { await expect(requestPlatform('https://platform.deepseek.com', 'auth_init', {}, @@ -56,7 +58,7 @@ it('reports transport failure without exposing the thrown error', async () => { it('identifies invalid envelope fields without logging response values', async () => { - const output = vi.spyOn(console, 'info').mockImplementation(() => undefined) + const output = vi.spyOn(console, 'error').mockImplementation(() => undefined) const fetcher = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ code: 0, data: { biz_code: 'private-code', biz_data: { token: 'private-token' } }, }))) @@ -76,7 +78,7 @@ it('identifies invalid envelope fields without logging response values', async ( }) it('reports browser URL rejection rules without exposing the destination', () => { - const output = vi.spyOn(console, 'info').mockImplementation(() => undefined) + const output = vi.spyOn(console, 'error').mockImplementation(() => undefined) try { expect(() => browserUrl('https://private.example/wrong?code=private-code', 'https://platform.deepseek.com', '/dsh/authorize')).toThrow('account: protocol') @@ -102,11 +104,85 @@ it.each([ start(controller) { controller.error(new Error('private-stream-error')) }, })), code: 'protocol' }, ])('rejects $name without exposing response data', async ({ response, code }) => { - const output = vi.spyOn(console, 'info').mockImplementation(() => undefined) + const output = vi.spyOn(console, 'error').mockImplementation(() => undefined) const fetcher = vi.spyOn(globalThis, 'fetch').mockResolvedValue(response()) try { await expect(requestPlatform('https://platform.deepseek.com', 'auth_init', {}, new AbortController().signal, {})).rejects.toThrow(`account: ${code}`) + expect(output).toHaveBeenCalled() + expect(JSON.stringify(output.mock.calls)).not.toContain('private-') + } finally { + fetcher.mockRestore() + output.mockRestore() + } +}) + +it('keeps successful authorization, account, bonus and logout requests silent', async () => { + const output = ['log', 'info', 'error'] as const + const spies = output.map(method => vi.spyOn(console, method).mockImplementation(() => undefined)) + const fetcher = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => new Response(JSON.stringify({ + code: 0, data: { biz_code: 0, biz_data: null }, + }))) + try { + const origin = 'https://platform.deepseek.com' + const signal = new AbortController().signal + for (const method of ['auth_init', 'auth_exchange', 'auth_cancel']) { + await requestPlatform(origin, method, {}, signal, {}) + } + for (const path of ['/auth-api/v0/users/current', '/api/v0/users/get_user_summary'] as const) { + await requestAccount(origin, path, 'private-token', signal, {}) + } + await requestUnnotifiedBonuses(origin, 'private-token', signal, {}) + await requestBonusNotified(origin, 'private-token', 'fixture-order' as AccountBonusOrderId, signal, {}) + await logoutAccount(origin, 'private-token', signal, {}) + fetcher.mockResolvedValueOnce(new Response(JSON.stringify({ + code: 0, data: { biz_code: 0, biz_data: { email: 'private-email' } }, + }))) + expect(await readAccountDetail('profile', origin, 'private-token', signal, {})).toMatchObject({ status: 'ready' }) + fetcher.mockResolvedValueOnce(new Response(JSON.stringify({ + code: 0, data: { biz_code: 0, biz_data: { normal_wallets: [], bonus_wallets: [] } }, + }))) + expect(await readAccountDetail('balance', origin, 'private-token', signal, {})).toMatchObject({ status: 'ready' }) + for (const spy of spies) expect(spy).not.toHaveBeenCalled() + } finally { + fetcher.mockRestore() + for (const spy of spies) spy.mockRestore() + } +}) + +it.each([ + { name: 'HTTP rejection', status: 401, payload: 'private-body' }, + { name: 'authorization code rejection', status: 200, payload: JSON.stringify({ code: 40003 }) }, +])('reports $name on the error console without the credential or body', async ({ status, payload }) => { + const output = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const info = vi.spyOn(console, 'info').mockImplementation(() => undefined) + const fetcher = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(payload, { status })) + try { + await expect(requestAccount('https://platform.deepseek.com', '/auth-api/v0/users/current', + 'private-token', new AbortController().signal, {})).rejects.toThrow('account: expired') + expect(output).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ + path: '/auth-api/v0/users/current', status, + })) + expect(JSON.stringify(output.mock.calls)).not.toContain('private-') + expect(info).not.toHaveBeenCalled() + } finally { + fetcher.mockRestore() + info.mockRestore() + output.mockRestore() + } +}) + + +it('reports invalid bonus fields without logging bonus data', async () => { + const output = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const fetcher = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ + code: 0, data: { biz_code: 0, biz_data: [{ order_id: 'private-order', msg: 'private-message' }] }, + }))) + try { + await expect(readUnnotifiedBonuses('https://platform.deepseek.com', 'private-token', + new AbortController().signal, {})).rejects.toThrow('account: protocol') + expect(output.mock.calls[0]?.[0]).toBe('[deepseek-account] bonus schema rejected') + expect(JSON.stringify(output.mock.calls)).toContain('order_id') expect(JSON.stringify(output.mock.calls)).not.toContain('private-') } finally { fetcher.mockRestore() From d0b0bfcd79b64ca7227e4f1cf3cf7446e4a4223d Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:11:13 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(account):=20=E4=BF=9D=E6=8C=81=E5=8F=96?= =?UTF-8?q?=E6=B6=88=E9=9D=99=E9=BB=98=E5=B9=B6=E5=90=88=E5=B9=B6=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E8=AF=8A=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests/apply.client.spec.ts | 3 +- .../README.i18n.yaml | 4 +- .../deepseek-account-platform/README.md | 2 +- .../deepseek-account-platform/README.zh.md | 2 +- .../deepseek-account-platform/src/details.ts | 44 +++---- .../deepseek-account-platform/src/protocol.ts | 35 +++--- .../tests/diagnostics.spec.ts | 111 ++++++++++++++++++ 7 files changed, 160 insertions(+), 41 deletions(-) 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 bb78e20e89..994297d0b5 100644 --- a/packages/client/ui-settings-account/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-account/tests/apply.client.spec.ts @@ -220,8 +220,7 @@ it('keeps successful account refreshes silent and reports only failed reads', as await vi.waitFor(() => { expect(actions.hooks.account.getSnapshot().details?.profile).toEqual(profile) }) await actions.refreshAccount() for (const spy of [output, info, log]) { - expect(spy.mock.calls.filter(call => String(call[0]).startsWith('[account-debug]') - || String(call[0]).startsWith('[deepseek-account]'))).toEqual([]) + expect(spy.mock.calls.filter(call => String(call[0]).startsWith('[deepseek-account]'))).toEqual([]) } c.mock.remote.account.getProfile.mockResolvedValueOnce(ok({ status: 'failed' })) c.mock.remote.account.getBalance.mockRejectedValueOnce(new Error('private-transport-detail')) diff --git a/packages/credentials/deepseek-account-platform/README.i18n.yaml b/packages/credentials/deepseek-account-platform/README.i18n.yaml index 74d983de48..21c0ce26ce 100644 --- a/packages/credentials/deepseek-account-platform/README.i18n.yaml +++ b/packages/credentials/deepseek-account-platform/README.i18n.yaml @@ -15,8 +15,8 @@ en: af856ba79fe31b8e zh: 15b7d1a5d1856de2 /deepseek-ai-dsh-deepseek-account-platform/use-this-package: - en: dfeda5f1385bc499 - zh: 03ba6a8e8071d5ea + en: 03ce750274b355f6 + zh: 0bfb86570c686e17 /deepseek-ai-dsh-deepseek-account-platform/understand-the-implementation: en: 76d2da2dab100ac3 zh: 2c9404f27915daab diff --git a/packages/credentials/deepseek-account-platform/README.md b/packages/credentials/deepseek-account-platform/README.md index 74b3dbfa9f..c4590107f0 100644 --- a/packages/credentials/deepseek-account-platform/README.md +++ b/packages/credentials/deepseek-account-platform/README.md @@ -59,7 +59,7 @@ A private patch can read environment variables through Cordis expressions: The authenticated client supplies its browser-accessible callback origin, including the local SSH forwarding port, and its UI kind. The provider fixes the callback path and binds the resulting redirect_uri to both initialization and exchange. Callback requests use state and PKCE validation rather than RPC authentication. Completion, cancellation and disposal remove only the attempt route. Exchange failures publish failed state without retrying: Web closes its authorization tab and keeps the failure dialog in the original tab; the callback page attempts to close itself and offers a manual-close message. Desktop receives HTTP 204 and focuses its existing login UI through the account stream. -Host diagnostics use `console.error` with the `[deepseek-account]` prefix on standard error and in the Host inspector Console, only for request, response-validation, or sign-in failures. Successful requests and normal account state changes are silent. They include endpoint paths, HTTP status, numeric response codes, failure stages, invalid field paths, received types and validation codes, and browser URL rejection rules; headers, request and response bodies, authorization URLs, and raw exceptions are excluded. +Host diagnostics use `console.error` with the `[deepseek-account]` prefix on standard error and in the Host inspector Console, only for request, response-validation, or sign-in failures. Successful requests, caller cancellation, and normal account state changes are silent; request timeouts remain failures. Failure diagnostics include endpoint paths, HTTP status, numeric response codes, failure stages, invalid field paths, received types and validation codes, and browser URL rejection rules; headers, request and response bodies, authorization URLs, and raw exceptions are excluded. `rewriteBrowserOrigin` defaults to `false`, requiring same-origin browser URLs. A private development patch may set it to `true` to map authorization and completion URLs to `platformOrigin`, preserving their fixed paths and query strings. Source URLs must use HTTPS or already match the configured origin; user information, fragments and unexpected paths remain rejected. Shipped profiles retain strict same-origin validation. diff --git a/packages/credentials/deepseek-account-platform/README.zh.md b/packages/credentials/deepseek-account-platform/README.zh.md index a206c53960..9b36aecc25 100644 --- a/packages/credentials/deepseek-account-platform/README.zh.md +++ b/packages/credentials/deepseek-account-platform/README.zh.md @@ -59,7 +59,7 @@ getUnnotifiedBonuses 读取同一来源上的 GET /api/v0/users/get_unnotified_b 已鉴权客户端提供浏览器可访问的回调来源(含 SSH 本地转发端口)和 UI 类型。提供者固定回调路径,并在初始化与兑换时使用同一个 redirect_uri。回调请求使用 state 和 PKCE 校验,不要求 RPC 鉴权。完成、取消和卸载仅移除本次尝试的路由。兑换失败发布 failed 状态,不自动重试:Web 关闭授权标签页,原标签页显示失败弹窗;回调页尝试自行关闭,并提供手动关闭提示。Desktop 收到 HTTP 204,并通过账号状态流聚焦现有登录界面。 -Host 诊断仅在请求、响应校验或登录失败时,通过 `console.error` 写入标准错误及 Host 调试器 Console,前缀为 `[deepseek-account]`。成功请求和正常账号状态变化不输出日志。日志包含接口路径、HTTP 状态、数值响应码、失败阶段、校验失败的字段路径、实际类型和校验码,以及浏览器 URL 拒绝规则,不包含请求头、请求和响应正文、授权 URL 或原始异常。 +Host 诊断仅在请求、响应校验或登录失败时,通过 `console.error` 写入标准错误及 Host 调试器 Console,前缀为 `[deepseek-account]`。成功请求、调用方主动取消和正常账号状态变化不输出日志;请求超时仍记录为失败。失败诊断包含接口路径、HTTP 状态、数值响应码、失败阶段、校验失败的字段路径、实际类型和校验码,以及浏览器 URL 拒绝规则,不包含请求头、请求和响应正文、授权 URL 或原始异常。 `rewriteBrowserOrigin` 默认为 `false`,要求浏览器地址同源。私有开发 patch 可设为 `true`,将授权页和完成页地址映射到 `platformOrigin`,保留固定路径和查询字符串。原地址必须使用 HTTPS 或已匹配配置来源;仍拒绝用户名密码、片段和非预期路径。发布的 profile 保持严格同源校验。 diff --git a/packages/credentials/deepseek-account-platform/src/details.ts b/packages/credentials/deepseek-account-platform/src/details.ts index 7a242d7d61..f80cdee953 100644 --- a/packages/credentials/deepseek-account-platform/src/details.ts +++ b/packages/credentials/deepseek-account-platform/src/details.ts @@ -29,28 +29,39 @@ const unnotified = z.array(bonus) export function profile(value: unknown): AccountProfile { const parsed = user.safeParse(value) if (!parsed.success) throw new PlatformAuthError('protocol') - const { email, mobile, mobile_number: mobileNumber, id_profile: identity } = parsed.data + return projectProfile(parsed.data) +} + +function projectProfile(value: z.infer): AccountProfile { + const { email, mobile, mobile_number: mobileNumber, id_profile: identity } = value return { - id: parsed.data.id == null ? null : parsed.data.id as AccountUserId, + id: value.id == null ? null : value.id as AccountUserId, avatarUrl: identity?.picture || null, name: identity?.name || null, contact: mobile || mobileNumber || email || null, } } +function accountPayload(schema: z.ZodType, value: unknown, field: 'profile' | 'balance' | 'bonus'): T { + const parsed = schema.safeParse(value) + if (!parsed.success) { + console.error(`[deepseek-account] ${field} schema rejected`, { + issues: parsed.error.issues.map(issue => ({ path: issue.path, code: issue.code })), + }) + throw new PlatformAuthError('protocol') + } + return parsed.data +} + const queries: { [K in keyof AccountDetails]: { path: '/auth-api/v0/users/current' | '/api/v0/users/get_user_summary' parse: (value: unknown) => AccountDetails[K] } } = { - profile: { path: '/auth-api/v0/users/current', parse: value => ({ status: 'ready', value: profile(value) }) }, + profile: { path: '/auth-api/v0/users/current', parse: value => ({ + status: 'ready', value: projectProfile(accountPayload(user, value, 'profile')), + }) }, balance: { path: '/api/v0/users/get_user_summary', parse: (value) => { - const parsed = summary.safeParse(value) - if (!parsed.success) { - console.error('[deepseek-account] balance schema rejected', { - issues: parsed.error.issues.map(issue => ({ path: issue.path, code: issue.code })), - }) - throw new PlatformAuthError('protocol') - } - return { status: 'ready', value: parsed.data.normal_wallets, bonusWallets: parsed.data.bonus_wallets } + const parsed = accountPayload(summary, value, 'balance') + return { status: 'ready', value: parsed.normal_wallets, bonusWallets: parsed.bonus_wallets } } }, } @@ -69,7 +80,6 @@ export async function readAccountDetail(field: K const query = queries[field] try { return query.parse(await requestAccount(origin, query.path, token, signal, headers)) } catch (error) { - console.error('[deepseek-account] detail failed', { field }) if (error instanceof AccountUnauthorizedError) throw error return { status: 'failed' } } @@ -85,14 +95,8 @@ export async function readAccountDetail(field: K */ export async function readUnnotifiedBonuses(origin: string, token: string, signal: AbortSignal, headers: Record): Promise { - const parsed = unnotified.safeParse(await requestUnnotifiedBonuses(origin, token, signal, headers)) - if (!parsed.success) { - console.error('[deepseek-account] bonus schema rejected', { - issues: parsed.error.issues.map(issue => ({ path: issue.path, code: issue.code })), - }) - throw new PlatformAuthError('protocol') - } - return parsed.data.map(row => ({ + const parsed = accountPayload(unnotified, await requestUnnotifiedBonuses(origin, token, signal, headers), 'bonus') + return parsed.map(row => ({ orderId: row.order_id as AccountBonusOrderId, campaign: row.campaign, amount: row.amount, currency: row.currency, grantedAt: row.granted_at, expiresAt: row.expires_at, message: row.msg, })) diff --git a/packages/credentials/deepseek-account-platform/src/protocol.ts b/packages/credentials/deepseek-account-platform/src/protocol.ts index e9b5b9ffa3..54d1afd9ad 100644 --- a/packages/credentials/deepseek-account-platform/src/protocol.ts +++ b/packages/credentials/deepseek-account-platform/src/protocol.ts @@ -82,7 +82,7 @@ export const initialization = z.object({ export const exchange = z.object({ token: z.string().regex(/^[\x21-\x7e]+$/), authorized_url: z.url(), user: z.unknown().optional() }) /** - * Read one bounded platform response; failures emit non-secret diagnostics to stderr. + * Read one bounded platform response; non-cancellation failures emit non-secret diagnostics to stderr. * @param origin - validated platform origin. * @param method - platform endpoint suffix. * @param body - protocol request, never logged. @@ -163,22 +163,28 @@ export async function logoutAccount(origin: string, token: string, }, signal) } +/** Caller cancellation stays silent; standard TimeoutError aborts remain failures. */ +function callerCancelled(signal: AbortSignal): boolean { + const reason: unknown = signal.reason + return signal.aborted && !(reason instanceof Error && reason.name === 'TimeoutError') +} + async function platformRequest(url: string, init: RequestInit, signal: AbortSignal): Promise { const path = new URL(url).pathname let response: Response try { response = await fetch(url, { ...init, redirect: 'error', signal }) } catch { - console.error('[deepseek-account] request failed', { path, errorCode: 'no-response', aborted: signal.aborted }) + if (!callerCancelled(signal)) console.error('[deepseek-account] request failed', { path, errorCode: 'no-response', aborted: signal.aborted }) throw new PlatformAuthError('no-response') } if (response.status === 401 && new Headers(init.headers).has('x-dsh-auth-token')) { - console.error('[deepseek-account] request failed', { path, status: response.status, errorCode: 'expired' }) + if (!callerCancelled(signal)) console.error('[deepseek-account] request failed', { path, status: response.status, errorCode: 'expired' }) await response.body?.cancel() throw new AccountUnauthorizedError() } if (!response.ok || response.body === null) { - console.error('[deepseek-account] request failed', { path, status: response.status, errorCode: 'network' }) + if (!callerCancelled(signal)) console.error('[deepseek-account] request failed', { path, status: response.status, errorCode: 'network' }) await response.body?.cancel() throw new PlatformAuthError('network') } @@ -186,6 +192,8 @@ async function platformRequest(url: string, init: RequestInit, signal: AbortSign const chunks: Uint8Array[] = [] let size = 0 let stage = 'read-body' + let codes: { code: number; data?: { biz_code: number } | undefined } | undefined + let validationError: z.ZodError | undefined try { while (true) { const next = await reader.read() @@ -199,12 +207,8 @@ async function platformRequest(url: string, init: RequestInit, signal: AbortSign } stage = 'parse-json' const payload: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8')) - const codes = z.object({ code: z.number().int(), data: z.object({ biz_code: z.number().int() }).optional() }).safeParse(payload) - if (codes.success && (codes.data.code !== 0 || (codes.data.data !== undefined && codes.data.data.biz_code !== 0))) { - console.error('[deepseek-account] response codes', { - path, status: response.status, code: codes.data.code, bizCode: codes.data.data?.biz_code, - }) - } + const responseCodes = z.object({ code: z.number().int(), data: z.object({ biz_code: z.number().int() }).optional() }).safeParse(payload) + if (responseCodes.success) codes = responseCodes.data const authorization = z.object({ code: z.literal(40003) }).safeParse(payload) if (authorization.success && new Headers(init.headers).has('x-dsh-auth-token')) { throw new AccountUnauthorizedError() @@ -212,9 +216,7 @@ async function platformRequest(url: string, init: RequestInit, signal: AbortSign stage = 'envelope' const parsed = envelope.safeParse(payload) if (!parsed.success) { - console.error('[deepseek-account] envelope rejected', { - path, issues: parsed.error.issues.map(issue => ({ path: issue.path, code: issue.code })), - }) + validationError = parsed.error throw new PlatformAuthError('protocol') } stage = 'business-code' @@ -225,8 +227,11 @@ async function platformRequest(url: string, init: RequestInit, signal: AbortSign } return parsed.data.data.biz_data } catch (error) { - console.error('[deepseek-account] response rejected', { path, status: response.status, stage, - errorCode: error instanceof PlatformAuthError ? error.code : 'protocol' }) + if (!callerCancelled(signal)) console.error('[deepseek-account] response rejected', { + path, status: response.status, stage, code: codes?.code, bizCode: codes?.data?.biz_code, + issues: validationError?.issues.map(issue => ({ path: issue.path, code: issue.code })), + errorCode: error instanceof PlatformAuthError ? error.code : 'protocol', + }) if (error instanceof PlatformAuthError) throw error throw new PlatformAuthError('protocol') } finally { diff --git a/packages/credentials/deepseek-account-platform/tests/diagnostics.spec.ts b/packages/credentials/deepseek-account-platform/tests/diagnostics.spec.ts index 36732e5a3a..7ade4bc6f0 100644 --- a/packages/credentials/deepseek-account-platform/tests/diagnostics.spec.ts +++ b/packages/credentials/deepseek-account-platform/tests/diagnostics.spec.ts @@ -28,6 +28,7 @@ it('reports business failure codes without credentials or response messages', as await expect(requestPlatform('https://platform.deepseek.com', 'auth_init', { code_verifier: 'request-secret' }, new AbortController().signal, { Cookie: 'cookie-secret' })).rejects.toThrow('account: protocol') + expect(output).toHaveBeenCalledOnce() const logged = JSON.stringify(output.mock.calls) expect(logged).toContain('/auth-api/v0/dsh/auth_init') expect(logged).toContain('40123') @@ -189,3 +190,113 @@ it('reports invalid bonus fields without logging bonus data', async () => { output.mockRestore() } }) + + +it.each([ + { stage: 'fetch', code: 'no-response' }, + { stage: 'unauthorized', code: 'expired' }, + { stage: 'http', code: 'network' }, + { stage: 'body', code: 'protocol' }, +])('keeps caller cancellation silent during $stage', async ({ stage, code }) => { + const output = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const fetcher = vi.spyOn(globalThis, 'fetch') + const cancellation = new AbortController() + const reading = Promise.withResolvers() + let body: ReadableStreamDefaultController | undefined + try { + if (stage === 'body') { + fetcher.mockResolvedValue(new Response(new ReadableStream({ + start(controller) { body = controller }, + pull() { reading.resolve(undefined) }, + }))) + } else if (stage === 'fetch') { + cancellation.abort() + fetcher.mockRejectedValue(cancellation.signal.reason) + } else { + fetcher.mockImplementation(async () => { + cancellation.abort() + return new Response('private-response', { status: stage === 'unauthorized' ? 401 : 503 }) + }) + } + const request = requestAccount('https://platform.deepseek.com', '/auth-api/v0/users/current', + 'private-token', cancellation.signal, {}) + if (stage === 'body') { + await reading.promise + cancellation.abort() + body!.error(cancellation.signal.reason) + } + await expect(request).rejects.toThrow(`account: ${code}`) + expect(output).not.toHaveBeenCalled() + } finally { + body?.error() + fetcher.mockRestore() + output.mockRestore() + } +}) + +it('keeps cancellation with a non-error reason silent', async () => { + const output = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const fetcher = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('private-network-error')) + const cancellation = new AbortController() + cancellation.abort('private-cancel-reason') + try { + expect(await readAccountDetail('profile', 'https://platform.deepseek.com', 'private-token', + cancellation.signal, {})).toEqual({ status: 'failed' }) + expect(output).not.toHaveBeenCalled() + } finally { + fetcher.mockRestore() + output.mockRestore() + } +}) + +it.each(['fetch', 'body'] as const)('reports request timeouts during %s once without raw exceptions', async (stage) => { + const output = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const fetcher = vi.spyOn(globalThis, 'fetch') + const timeout = new AbortController() + const reading = Promise.withResolvers() + let body: ReadableStreamDefaultController | undefined + try { + if (stage === 'body') { + fetcher.mockResolvedValue(new Response(new ReadableStream({ + start(controller) { body = controller }, + pull() { reading.resolve(undefined) }, + }))) + } else { + timeout.abort(new DOMException('private-timeout-message', 'TimeoutError')) + fetcher.mockRejectedValue(timeout.signal.reason) + } + const request = readAccountDetail('profile', 'https://platform.deepseek.com', 'private-token', timeout.signal, {}) + if (stage === 'body') { + await reading.promise + timeout.abort(new DOMException('private-timeout-message', 'TimeoutError')) + body!.error(timeout.signal.reason) + } + expect(await request).toEqual({ status: 'failed' }) + expect(output).toHaveBeenCalledOnce() + expect(JSON.stringify(output.mock.calls)).not.toContain('private-') + } finally { + body?.error() + fetcher.mockRestore() + output.mockRestore() + } +}) + +it('reports invalid profile fields once without logging profile values', async () => { + const output = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const fetcher = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ + code: 0, data: { biz_code: 0, biz_data: { email: 42, id: 'private-id' } }, + }))) + try { + expect(await readAccountDetail('profile', 'https://platform.deepseek.com', 'private-token', + new AbortController().signal, {})).toEqual({ status: 'failed' }) + expect(output).toHaveBeenCalledOnce() + expect(output.mock.calls[0]?.[0]).toBe('[deepseek-account] profile schema rejected') + const logged = JSON.stringify(output.mock.calls) + expect(logged).toContain('email') + expect(logged).toContain('invalid_type') + expect(logged).not.toContain('private-') + } finally { + fetcher.mockRestore() + output.mockRestore() + } +})