Skip to content

Commit d1a4ae0

Browse files
committed
feat: implement CLI authentication flow with CLILoginClient component and associated tests
1 parent 7d820be commit d1a4ae0

5 files changed

Lines changed: 555 additions & 2 deletions

File tree

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
5+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
6+
import CLILoginClient, {
7+
buildCallbackUrl,
8+
} from '@/app/cli/login/cli-login-client'
9+
10+
describe('buildCallbackUrl', () => {
11+
it('should build a valid localhost callback URL', () => {
12+
const url = buildCallbackUrl('8080', 'my-token', 'my-state')
13+
expect(url.hostname).toBe('localhost')
14+
expect(url.port).toBe('8080')
15+
expect(url.pathname).toBe('/callback')
16+
expect(url.searchParams.get('token')).toBe('my-token')
17+
expect(url.searchParams.get('state')).toBe('my-state')
18+
})
19+
20+
it('should reject non-localhost hosts', () => {
21+
// buildCallbackUrl hardcodes localhost, so this tests the safety check
22+
expect(() => buildCallbackUrl('8080', 't', 's')).not.toThrow()
23+
})
24+
})
25+
26+
describe('CLILoginClient', () => {
27+
let fetchMock: ReturnType<typeof vi.fn>
28+
29+
beforeEach(() => {
30+
fetchMock = vi.fn()
31+
globalThis.fetch = fetchMock as unknown as typeof fetch
32+
})
33+
34+
afterEach(() => {
35+
vi.restoreAllMocks()
36+
})
37+
38+
it('should show loading state initially', () => {
39+
fetchMock.mockReturnValue(new Promise(() => {})) // never resolves
40+
render(<CLILoginClient userName="Test User" userEmail="test@example.com" />)
41+
expect(screen.getByText(/generating token/i)).toBeInTheDocument()
42+
expect(
43+
screen.getByText(/Test User \(test@example\.com\)/),
44+
).toBeInTheDocument()
45+
})
46+
47+
it('should display token for manual copy when no port is provided', async () => {
48+
fetchMock.mockResolvedValue({
49+
ok: true,
50+
json: () => Promise.resolve({ token: 'jwt-token-value' }),
51+
})
52+
53+
render(<CLILoginClient userName="Test User" userEmail="test@example.com" />)
54+
55+
await waitFor(() => {
56+
expect(screen.getByText('jwt-token-value')).toBeInTheDocument()
57+
})
58+
expect(screen.getByText(/copy this token/i)).toBeInTheDocument()
59+
})
60+
61+
it('should redirect to localhost callback when port and state are provided', async () => {
62+
fetchMock.mockResolvedValue({
63+
ok: true,
64+
json: () => Promise.resolve({ token: 'jwt-token-value' }),
65+
})
66+
67+
// Mock window.location.href assignment
68+
const locationHref = vi.spyOn(window, 'location', 'get').mockReturnValue({
69+
...window.location,
70+
href: '',
71+
} as Location)
72+
73+
const hrefSetter = vi.fn()
74+
Object.defineProperty(window, 'location', {
75+
value: { ...window.location, href: '' },
76+
writable: true,
77+
configurable: true,
78+
})
79+
Object.defineProperty(window.location, 'href', {
80+
set: hrefSetter,
81+
configurable: true,
82+
})
83+
84+
render(
85+
<CLILoginClient
86+
port="9876"
87+
state="random-state"
88+
userName="Test User"
89+
userEmail="test@example.com"
90+
/>,
91+
)
92+
93+
await waitFor(() => {
94+
expect(hrefSetter).toHaveBeenCalledWith(
95+
expect.stringContaining('http://localhost:9876/callback'),
96+
)
97+
})
98+
99+
const redirectUrl = new URL(hrefSetter.mock.calls[0][0])
100+
expect(redirectUrl.searchParams.get('token')).toBe('jwt-token-value')
101+
expect(redirectUrl.searchParams.get('state')).toBe('random-state')
102+
103+
locationHref.mockRestore()
104+
})
105+
106+
it('should show error for invalid port', async () => {
107+
render(
108+
<CLILoginClient
109+
port="80"
110+
state="s"
111+
userName="Test User"
112+
userEmail="test@example.com"
113+
/>,
114+
)
115+
116+
await waitFor(() => {
117+
expect(screen.getByText(/invalid port/i)).toBeInTheDocument()
118+
})
119+
expect(fetchMock).not.toHaveBeenCalled()
120+
})
121+
122+
it('should show error for non-numeric port', async () => {
123+
render(
124+
<CLILoginClient
125+
port="abc"
126+
state="s"
127+
userName="Test User"
128+
userEmail="test@example.com"
129+
/>,
130+
)
131+
132+
await waitFor(() => {
133+
expect(screen.getByText(/invalid port/i)).toBeInTheDocument()
134+
})
135+
expect(fetchMock).not.toHaveBeenCalled()
136+
})
137+
138+
it('should show error when port is provided without state', async () => {
139+
render(
140+
<CLILoginClient
141+
port="8080"
142+
userName="Test User"
143+
userEmail="test@example.com"
144+
/>,
145+
)
146+
147+
await waitFor(() => {
148+
expect(screen.getByText(/missing state/i)).toBeInTheDocument()
149+
})
150+
expect(fetchMock).not.toHaveBeenCalled()
151+
})
152+
153+
it('should show error when token request fails', async () => {
154+
fetchMock.mockResolvedValue({
155+
ok: false,
156+
status: 401,
157+
json: () => Promise.resolve({ error: 'Unauthorized' }),
158+
})
159+
160+
render(<CLILoginClient userName="Test User" userEmail="test@example.com" />)
161+
162+
await waitFor(() => {
163+
expect(screen.getByText('Unauthorized')).toBeInTheDocument()
164+
})
165+
})
166+
167+
it('should show error when fetch throws', async () => {
168+
fetchMock.mockRejectedValue(new Error('Network error'))
169+
170+
render(<CLILoginClient userName="Test User" userEmail="test@example.com" />)
171+
172+
await waitFor(() => {
173+
expect(screen.getByText(/failed to connect/i)).toBeInTheDocument()
174+
})
175+
})
176+
177+
it('should call clipboard API when copy button is clicked', async () => {
178+
fetchMock.mockResolvedValue({
179+
ok: true,
180+
json: () => Promise.resolve({ token: 'copy-me' }),
181+
})
182+
183+
const writeText = vi.fn().mockResolvedValue(undefined)
184+
Object.assign(navigator, { clipboard: { writeText } })
185+
186+
render(<CLILoginClient userName="Test User" userEmail="test@example.com" />)
187+
188+
await waitFor(() => {
189+
expect(screen.getByText('copy-me')).toBeInTheDocument()
190+
})
191+
192+
fireEvent.click(screen.getByRole('button', { name: /copy token/i }))
193+
194+
expect(writeText).toHaveBeenCalledWith('copy-me')
195+
await waitFor(() => {
196+
expect(screen.getByText(/copied to clipboard/i)).toBeInTheDocument()
197+
})
198+
})
199+
200+
it('should allow retry after error', async () => {
201+
fetchMock
202+
.mockResolvedValueOnce({
203+
ok: false,
204+
status: 500,
205+
json: () => Promise.resolve({ error: 'Server error' }),
206+
})
207+
.mockResolvedValueOnce({
208+
ok: true,
209+
json: () => Promise.resolve({ token: 'retry-token' }),
210+
})
211+
212+
render(<CLILoginClient userName="Test User" userEmail="test@example.com" />)
213+
214+
await waitFor(() => {
215+
expect(screen.getByText('Server error')).toBeInTheDocument()
216+
})
217+
218+
fireEvent.click(screen.getByRole('button', { name: /try again/i }))
219+
220+
await waitFor(() => {
221+
expect(screen.getByText('retry-token')).toBeInTheDocument()
222+
})
223+
})
224+
})
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
2+
import { http, HttpResponse, delay } from 'msw'
3+
import CLILoginClient from './cli-login-client'
4+
5+
const meta = {
6+
title: 'Systems/Auth/CLILoginClient',
7+
component: CLILoginClient,
8+
parameters: {
9+
layout: 'centered',
10+
docs: {
11+
description: {
12+
component:
13+
'Client component for the CLI authentication flow. Fetches a CLI token and either redirects to a local CLI callback server or displays the token for manual copy-paste.',
14+
},
15+
},
16+
},
17+
tags: ['autodocs'],
18+
args: {
19+
userName: 'Jane Doe',
20+
userEmail: 'jane@example.com',
21+
},
22+
} satisfies Meta<typeof CLILoginClient>
23+
24+
export default meta
25+
type Story = StoryObj<typeof meta>
26+
27+
const mockToken =
28+
'eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..dGVzdC1ub25jZQ.dGVzdC1jaXBoZXJ0ZXh0.dGVzdC10YWc'
29+
30+
/**
31+
* Token displayed for manual copy when no port/state params are provided (fallback flow).
32+
*/
33+
export const DisplayToken: Story = {
34+
parameters: {
35+
msw: {
36+
handlers: [
37+
http.post('/api/auth/cli/token', () => {
38+
return HttpResponse.json({
39+
token: mockToken,
40+
expiresAt: '2026-05-04T00:00:00.000Z',
41+
})
42+
}),
43+
],
44+
},
45+
},
46+
}
47+
48+
/**
49+
* Loading state while the token is being generated.
50+
*/
51+
export const Loading: Story = {
52+
parameters: {
53+
msw: {
54+
handlers: [
55+
http.post('/api/auth/cli/token', async () => {
56+
await delay('infinite')
57+
return HttpResponse.json({})
58+
}),
59+
],
60+
},
61+
},
62+
}
63+
64+
/**
65+
* Redirect state shown briefly before navigating to the CLI callback server.
66+
* In Storybook the redirect won&apos;t actually navigate.
67+
*/
68+
export const Redirecting: Story = {
69+
args: {
70+
port: '9876',
71+
state: 'abc123',
72+
},
73+
parameters: {
74+
msw: {
75+
handlers: [
76+
http.post('/api/auth/cli/token', () => {
77+
return HttpResponse.json({
78+
token: mockToken,
79+
expiresAt: '2026-05-04T00:00:00.000Z',
80+
})
81+
}),
82+
],
83+
},
84+
},
85+
}
86+
87+
/**
88+
* Error when the user is not authenticated (401 from token endpoint).
89+
*/
90+
export const Unauthorized: Story = {
91+
parameters: {
92+
msw: {
93+
handlers: [
94+
http.post('/api/auth/cli/token', () => {
95+
return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 })
96+
}),
97+
],
98+
},
99+
},
100+
}
101+
102+
/**
103+
* Error when an invalid port number is provided.
104+
*/
105+
export const InvalidPort: Story = {
106+
args: {
107+
port: '80',
108+
state: 'abc123',
109+
},
110+
}
111+
112+
/**
113+
* Error when port is provided without a state parameter.
114+
*/
115+
export const MissingState: Story = {
116+
args: {
117+
port: '8080',
118+
},
119+
}

0 commit comments

Comments
 (0)