11import { Command } from 'commander'
2- import { afterEach , describe , expect , it , vi } from 'vitest'
2+ import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest'
33
44vi . mock ( '../lib/auth.js' , ( ) => ( {
55 getApiToken : async ( ) => 'test-token' ,
@@ -11,8 +11,17 @@ vi.mock('../lib/auth.js', () => ({
1111
1212vi . mock ( '../lib/api.js' , ( ) => ( { apiRequest : vi . fn ( ) } ) )
1313
14+ vi . mock ( '../lib/config.js' , ( ) => ( {
15+ getConfig : vi . fn ( async ( ) => ( { } ) ) ,
16+ setConfig : vi . fn ( ) ,
17+ updateConfig : vi . fn ( ) ,
18+ getConfigPath : ( ) => '/tmp/outline-cli-test-config.json' ,
19+ } ) )
20+
1421// Stub cli-core's `attachLoginCommand` so we can inspect the surface contract
1522// (chained flags, env-driven port, success hook) without running the flow.
23+ // `attachStatusCommand` and `attachLogoutCommand` fall through to the real
24+ // cli-core implementations so the integration is exercised end-to-end.
1625vi . mock ( '@doist/cli-core/auth' , async ( ) => ( {
1726 ...( await vi . importActual < typeof import ( '@doist/cli-core/auth' ) > ( '@doist/cli-core/auth' ) ) ,
1827 attachLoginCommand : vi . fn ( ) ,
@@ -26,12 +35,25 @@ async function captureAttachOptions() {
2635 const program = new Command ( )
2736 program . exitOverride ( )
2837 registerAuthCommand ( program )
29- return { options : vi . mocked ( attachLoginCommand ) . mock . calls [ 0 ] [ 1 ] , login }
38+ return { options : vi . mocked ( attachLoginCommand ) . mock . calls [ 0 ] [ 1 ] , login, program }
3039}
3140
41+ async function buildProgram ( ) : Promise < Command > {
42+ const { program } = await captureAttachOptions ( )
43+ return program
44+ }
45+
46+ beforeEach ( ( ) => {
47+ vi . resetModules ( )
48+ delete process . env . OUTLINE_API_TOKEN
49+ delete process . env . OUTLINE_URL
50+ } )
51+
3252afterEach ( ( ) => {
3353 vi . clearAllMocks ( )
3454 delete process . env . OUTLINE_OAUTH_CALLBACK_PORT
55+ delete process . env . OUTLINE_API_TOKEN
56+ delete process . env . OUTLINE_URL
3557} )
3658
3759describe ( 'registerAuthCommand' , ( ) => {
@@ -69,3 +91,145 @@ describe('registerAuthCommand', () => {
6991 expect ( options . preferredPort ) . toBe ( 54969 )
7092 } )
7193} )
94+
95+ describe ( 'auth status subcommand' , ( ) => {
96+ const AUTH_INFO = {
97+ user : { id : 'user-uuid' , name : 'Ada Lovelace' , email : 'ada@example.com' } ,
98+ team : { name : 'Analytics' , subdomain : 'analytics' } ,
99+ }
100+
101+ async function importApiMock ( ) {
102+ const { apiRequest } = await import ( '../lib/api.js' )
103+ return vi . mocked ( apiRequest )
104+ }
105+
106+ it ( 'renders the human status from the env-token snapshot path' , async ( ) => {
107+ process . env . OUTLINE_API_TOKEN = 'env-token'
108+ const logs : string [ ] = [ ]
109+ vi . spyOn ( console , 'log' ) . mockImplementation ( ( ...args : unknown [ ] ) => {
110+ logs . push ( args . join ( ' ' ) )
111+ } )
112+ const apiRequest = await importApiMock ( )
113+ apiRequest . mockResolvedValue ( { data : AUTH_INFO } )
114+
115+ const program = await buildProgram ( )
116+ await program . parseAsync ( [ 'node' , 'ol' , 'auth' , 'status' ] )
117+
118+ expect ( apiRequest ) . toHaveBeenCalledWith (
119+ 'auth.info' ,
120+ { } ,
121+ { token : 'env-token' , baseUrl : 'https://test.outline.com' } ,
122+ )
123+ expect ( logs . some ( ( l ) => l . includes ( 'Authenticated' ) ) ) . toBe ( true )
124+ expect ( logs . some ( ( l ) => l . includes ( 'Team:' ) && l . includes ( 'Analytics' ) ) ) . toBe ( true )
125+ expect ( logs . some ( ( l ) => l . includes ( 'Ada Lovelace' ) && l . includes ( 'ada@example.com' ) ) ) . toBe (
126+ true ,
127+ )
128+ expect ( logs . some ( ( l ) => l . includes ( 'Token source: env' ) ) ) . toBe ( true )
129+ } )
130+
131+ it ( 'emits a PII-free JSON envelope under --json' , async ( ) => {
132+ process . env . OUTLINE_API_TOKEN = 'env-token'
133+ const logs : string [ ] = [ ]
134+ vi . spyOn ( console , 'log' ) . mockImplementation ( ( ...args : unknown [ ] ) => {
135+ logs . push ( args . join ( ' ' ) )
136+ } )
137+ const apiRequest = await importApiMock ( )
138+ apiRequest . mockResolvedValue ( { data : AUTH_INFO } )
139+
140+ const program = await buildProgram ( )
141+ await program . parseAsync ( [ 'node' , 'ol' , 'auth' , 'status' , '--json' ] )
142+
143+ expect ( logs ) . toHaveLength ( 1 )
144+ const payload = JSON . parse ( logs [ 0 ] )
145+ expect ( payload ) . toEqual ( {
146+ id : 'user-uuid' ,
147+ team : 'Analytics' ,
148+ baseUrl : 'https://test.outline.com' ,
149+ source : 'env' ,
150+ } )
151+ expect ( payload ) . not . toHaveProperty ( 'name' )
152+ expect ( payload ) . not . toHaveProperty ( 'email' )
153+ } )
154+
155+ it ( 'emits a single newline-free NDJSON line under --ndjson' , async ( ) => {
156+ process . env . OUTLINE_API_TOKEN = 'env-token'
157+ const logs : string [ ] = [ ]
158+ vi . spyOn ( console , 'log' ) . mockImplementation ( ( ...args : unknown [ ] ) => {
159+ logs . push ( args . join ( ' ' ) )
160+ } )
161+ const apiRequest = await importApiMock ( )
162+ apiRequest . mockResolvedValue ( { data : AUTH_INFO } )
163+
164+ const program = await buildProgram ( )
165+ await program . parseAsync ( [ 'node' , 'ol' , 'auth' , 'status' , '--ndjson' ] )
166+
167+ expect ( logs ) . toHaveLength ( 1 )
168+ expect ( logs [ 0 ] ) . not . toContain ( '\n' )
169+ expect ( JSON . parse ( logs [ 0 ] ) ) . toEqual ( {
170+ id : 'user-uuid' ,
171+ team : 'Analytics' ,
172+ baseUrl : 'https://test.outline.com' ,
173+ source : 'env' ,
174+ } )
175+ } )
176+
177+ it ( 'translates a 401 from auth.info into a NO_TOKEN CliError' , async ( ) => {
178+ process . env . OUTLINE_API_TOKEN = 'expired-token'
179+ const apiRequest = await importApiMock ( )
180+ apiRequest . mockRejectedValue ( new Error ( 'API error: 401 Unauthorized' ) )
181+
182+ const program = await buildProgram ( )
183+ await expect ( program . parseAsync ( [ 'node' , 'ol' , 'auth' , 'status' ] ) ) . rejects . toMatchObject ( {
184+ code : 'NO_TOKEN' ,
185+ } )
186+ } )
187+
188+ it ( 'throws NOT_AUTHENTICATED when no token is stored at all' , async ( ) => {
189+ const program = await buildProgram ( )
190+ await expect ( program . parseAsync ( [ 'node' , 'ol' , 'auth' , 'status' ] ) ) . rejects . toMatchObject ( {
191+ code : 'NOT_AUTHENTICATED' ,
192+ } )
193+ } )
194+ } )
195+
196+ describe ( 'auth logout subcommand' , ( ) => {
197+ it ( 'clears the token and prints the registrar success line' , async ( ) => {
198+ const logs : string [ ] = [ ]
199+ vi . spyOn ( console , 'log' ) . mockImplementation ( ( ...args : unknown [ ] ) => {
200+ logs . push ( args . join ( ' ' ) )
201+ } )
202+ const { clearConfig } = await import ( '../lib/auth.js' )
203+
204+ const program = await buildProgram ( )
205+ await program . parseAsync ( [ 'node' , 'ol' , 'auth' , 'logout' ] )
206+
207+ expect ( clearConfig ) . toHaveBeenCalledTimes ( 1 )
208+ expect ( logs ) . toContain ( '✓ Logged out' )
209+ } )
210+
211+ it ( 'emits {"ok": true} under --json and skips the human success line' , async ( ) => {
212+ const logs : string [ ] = [ ]
213+ vi . spyOn ( console , 'log' ) . mockImplementation ( ( ...args : unknown [ ] ) => {
214+ logs . push ( args . join ( ' ' ) )
215+ } )
216+
217+ const program = await buildProgram ( )
218+ await program . parseAsync ( [ 'node' , 'ol' , 'auth' , 'logout' , '--json' ] )
219+
220+ expect ( logs ) . toHaveLength ( 1 )
221+ expect ( JSON . parse ( logs [ 0 ] ) ) . toEqual ( { ok : true } )
222+ } )
223+
224+ it ( 'stays silent on stdout under --ndjson' , async ( ) => {
225+ const logs : string [ ] = [ ]
226+ vi . spyOn ( console , 'log' ) . mockImplementation ( ( ...args : unknown [ ] ) => {
227+ logs . push ( args . join ( ' ' ) )
228+ } )
229+
230+ const program = await buildProgram ( )
231+ await program . parseAsync ( [ 'node' , 'ol' , 'auth' , 'logout' , '--ndjson' ] )
232+
233+ expect ( logs ) . toEqual ( [ ] )
234+ } )
235+ } )
0 commit comments