@@ -3,21 +3,16 @@ import fs from "node:fs";
33import os from "node:os" ;
44import path from "node:path" ;
55import { afterEach , beforeEach , describe , expect , it , vi } from "vitest" ;
6+ import type { AuthProfileStore } from "../agents/auth-profiles/types.js" ;
67import type { OpenClawConfig } from "../config/types.openclaw.js" ;
78import type { DoctorPrompter } from "./doctor-prompter.js" ;
89
9- type MockAuthProfileStore = {
10- version : number ;
11- profiles : Record <
12- string ,
13- | { type : "oauth" ; provider : string ; access : string ; refresh : string ; expires : number }
14- | { type : "api_key" ; provider : string ; key : string }
15- > ;
16- } ;
17-
1810const authProfileMocks = vi . hoisted ( ( ) => ( {
1911 ensureAuthProfileStore : vi . fn <
20- ( agentDir ?: string , options ?: { allowKeychainPrompt ?: boolean } ) => MockAuthProfileStore
12+ (
13+ agentDir ?: string ,
14+ options ?: { allowKeychainPrompt ?: boolean ; readOnly ?: boolean } ,
15+ ) => AuthProfileStore
2116 > ( ( ) => {
2217 throw new Error ( "unexpected auth profile load" ) ;
2318 } ) ,
@@ -38,7 +33,7 @@ vi.mock("../agents/auth-profiles.js", () => ({
3833vi . mock ( "../../packages/terminal-core/src/note.js" , ( ) => ( { note : vi . fn ( ) } ) ) ;
3934
4035import { note } from "../../packages/terminal-core/src/note.js" ;
41- import { noteAuthProfileHealth } from "./doctor-auth.js" ;
36+ import { collectAuthProfileHealthFindings , noteAuthProfileHealth } from "./doctor-auth.js" ;
4237
4338const noteMock = vi . mocked ( note ) ;
4439
@@ -67,6 +62,10 @@ describe("noteAuthProfileHealth", () => {
6762 fs . writeFileSync ( path . join ( agentDir , "auth-profiles.json" ) , "{}\n" , "utf8" ) ;
6863 }
6964
65+ function expectedAuthStorePath ( agentDir : string ) : string {
66+ return path . join ( agentDir , "openclaw-agent.sqlite" ) ;
67+ }
68+
7069 function expiredStore ( profileId : string , expires : number ) {
7170 return {
7271 version : 1 ,
@@ -79,8 +78,142 @@ describe("noteAuthProfileHealth", () => {
7978 expires,
8079 } ,
8180 } ,
82- } ;
81+ } satisfies AuthProfileStore ;
8382 }
83+
84+ it ( "maps expired stored auth profiles to structured findings without refreshing" , async ( ) => {
85+ const now = 1_700_000_000_000 ;
86+ vi . spyOn ( Date , "now" ) . mockReturnValue ( now ) ;
87+ const mainDir = path . join ( tempDir , "main-agent" ) ;
88+ authProfileMocks . hasAnyAuthProfileStoreSource . mockReturnValue ( true ) ;
89+ authProfileMocks . ensureAuthProfileStore . mockReturnValue (
90+ expiredStore ( "openai:default" , now - 60_000 ) ,
91+ ) ;
92+
93+ const findings = await collectAuthProfileHealthFindings ( {
94+ cfg : {
95+ agents : {
96+ list : [ { id : "main" , default : true , agentDir : mainDir } ] ,
97+ } ,
98+ } as OpenClawConfig ,
99+ } ) ;
100+
101+ expect ( authProfileMocks . resolveApiKeyForProfile ) . not . toHaveBeenCalled ( ) ;
102+ expect ( findings ) . toEqual ( [
103+ expect . objectContaining ( {
104+ checkId : "core/doctor/auth-profiles" ,
105+ severity : "warning" ,
106+ message : "Auth profile openai:default is expired (0m)." ,
107+ path : expectedAuthStorePath ( mainDir ) ,
108+ target : "openai:default" ,
109+ } ) ,
110+ ] ) ;
111+ } ) ;
112+
113+ it ( "maps disabled auth profiles to structured findings" , async ( ) => {
114+ const now = 1_700_000_000_000 ;
115+ vi . spyOn ( Date , "now" ) . mockReturnValue ( now ) ;
116+ const mainDir = path . join ( tempDir , "main-agent" ) ;
117+ authProfileMocks . hasAnyAuthProfileStoreSource . mockReturnValue ( true ) ;
118+ authProfileMocks . resolveProfileUnusableUntilForDisplay . mockReturnValue ( now + 5 * 60_000 ) ;
119+ authProfileMocks . ensureAuthProfileStore . mockReturnValue ( {
120+ version : 1 ,
121+ profiles : { } ,
122+ usageStats : {
123+ "openai:billing" : {
124+ disabledUntil : now + 5 * 60_000 ,
125+ disabledReason : "billing" ,
126+ } ,
127+ } ,
128+ } satisfies AuthProfileStore ) ;
129+
130+ const findings = await collectAuthProfileHealthFindings ( {
131+ cfg : {
132+ agents : {
133+ list : [ { id : "main" , default : true , agentDir : mainDir } ] ,
134+ } ,
135+ } as OpenClawConfig ,
136+ } ) ;
137+
138+ expect ( findings ) . toEqual ( [
139+ expect . objectContaining ( {
140+ checkId : "core/doctor/auth-profiles" ,
141+ message : "Auth profile openai:billing is disabled:billing (5m)." ,
142+ path : expectedAuthStorePath ( mainDir ) ,
143+ target : "openai:billing" ,
144+ fixHint : "Top up credits (provider billing) or switch provider." ,
145+ } ) ,
146+ ] ) ;
147+ } ) ;
148+
149+ it ( "maps malformed API-key auth profiles to structured findings" , async ( ) => {
150+ const mainDir = path . join ( tempDir , "main-agent" ) ;
151+ authProfileMocks . hasAnyAuthProfileStoreSource . mockReturnValue ( true ) ;
152+ authProfileMocks . ensureAuthProfileStore . mockReturnValue ( {
153+ version : 1 ,
154+ profiles : {
155+ "zai:default" : {
156+ type : "api_key" ,
157+ provider : "zai" ,
158+ key : "openclaw onboard --auth-choice zai-coding-global" ,
159+ } ,
160+ } ,
161+ } satisfies AuthProfileStore ) ;
162+
163+ const findings = await collectAuthProfileHealthFindings ( {
164+ cfg : {
165+ agents : {
166+ list : [ { id : "main" , default : true , agentDir : mainDir } ] ,
167+ } ,
168+ } as OpenClawConfig ,
169+ } ) ;
170+
171+ expect ( findings ) . toEqual ( [
172+ expect . objectContaining ( {
173+ checkId : "core/doctor/auth-profiles" ,
174+ severity : "warning" ,
175+ message : "Auth profile zai:default is missing [malformed_api_key]." ,
176+ path : expectedAuthStorePath ( mainDir ) ,
177+ target : "zai:default" ,
178+ requirement : "malformed_api_key" ,
179+ fixHint : "Paste the API key value, not an OpenClaw onboarding command." ,
180+ } ) ,
181+ ] ) ;
182+ } ) ;
183+
184+ it ( "labels structured auth profile findings by agent when multiple stores are checked" , async ( ) => {
185+ const now = 1_700_000_000_000 ;
186+ vi . spyOn ( Date , "now" ) . mockReturnValue ( now ) ;
187+ const mainDir = path . join ( tempDir , "main-agent" ) ;
188+ const coderDir = path . join ( tempDir , "coder-agent" ) ;
189+ authProfileMocks . hasAnyAuthProfileStoreSource . mockReturnValue ( true ) ;
190+ authProfileMocks . hasLocalAuthProfileStoreSource . mockReturnValue ( true ) ;
191+ authProfileMocks . ensureAuthProfileStore . mockImplementation ( ( agentDir ) => {
192+ if ( agentDir === mainDir ) {
193+ return expiredStore ( "openai:main" , now - 60_000 ) ;
194+ }
195+ if ( agentDir === coderDir ) {
196+ return expiredStore ( "openai:coder" , now - 60_000 ) ;
197+ }
198+ throw new Error ( `unexpected agent dir: ${ agentDir ?? "<default>" } ` ) ;
199+ } ) ;
200+
201+ const findings = await collectAuthProfileHealthFindings ( {
202+ cfg : {
203+ agents : {
204+ list : [
205+ { id : "main" , default : true , agentDir : mainDir } ,
206+ { id : "coder" , agentDir : coderDir } ,
207+ ] ,
208+ } ,
209+ } as OpenClawConfig ,
210+ } ) ;
211+
212+ expect ( findings . map ( ( finding ) => finding . message ) ) . toEqual ( [
213+ "Agent main auth profile openai:main is expired (0m)." ,
214+ "Agent coder auth profile openai:coder is expired (0m)." ,
215+ ] ) ;
216+ } ) ;
84217 it ( "skips external auth profile resolution when no auth source exists" , async ( ) => {
85218 await noteAuthProfileHealth ( {
86219 cfg : { channels : { telegram : { enabled : true } } } as OpenClawConfig ,
@@ -209,7 +342,7 @@ describe("noteAuthProfileHealth", () => {
209342 writeAuthStore ( agentDir ) ;
210343 authProfileMocks . hasAnyAuthProfileStoreSource . mockReturnValue ( true ) ;
211344 authProfileMocks . ensureAuthProfileStore . mockImplementation (
212- ( receivedAgentDir ) : MockAuthProfileStore => {
345+ ( receivedAgentDir ) : AuthProfileStore => {
213346 if ( receivedAgentDir === agentDir ) {
214347 return {
215348 version : 1 ,
0 commit comments