Skip to content

Commit e4e9687

Browse files
committed
feat: implement CLI token generation endpoint with JWT encoding
1 parent f8849e8 commit e4e9687

3 files changed

Lines changed: 513 additions & 0 deletions

File tree

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { NextResponse } from 'next/server'
5+
import { decode } from 'next-auth/jwt'
6+
7+
const mockGetAuthSession = vi.fn()
8+
9+
vi.mock('@/lib/auth', () => ({
10+
getAuthSession: (...args: unknown[]) => mockGetAuthSession(...args),
11+
}))
12+
13+
const JWT_SALT = 'authjs.session-token'
14+
15+
function validSession() {
16+
return {
17+
user: {
18+
sub: 'user-123',
19+
name: 'Test User',
20+
email: 'test@example.com',
21+
picture: 'https://example.com/avatar.png',
22+
},
23+
speaker: {
24+
_id: 'speaker-abc',
25+
name: 'Test User',
26+
email: 'test@example.com',
27+
image: 'image-ref',
28+
isOrganizer: false,
29+
flags: [],
30+
},
31+
account: {
32+
provider: 'github',
33+
providerAccountId: '12345',
34+
type: 'oauth',
35+
},
36+
}
37+
}
38+
39+
describe('api/auth/cli/token', () => {
40+
const AUTH_SECRET = 'test-secret-long-enough-for-jwt-encryption'
41+
42+
beforeEach(() => {
43+
vi.clearAllMocks()
44+
process.env.AUTH_SECRET = AUTH_SECRET
45+
})
46+
47+
afterEach(() => {
48+
delete process.env.AUTH_SECRET
49+
})
50+
51+
describe('POST', () => {
52+
it('returns a token for an authenticated user', async () => {
53+
mockGetAuthSession.mockResolvedValue(validSession())
54+
const { POST } = await import('@/app/api/auth/cli/token/route')
55+
56+
const response = await POST()
57+
expect(response.status).toBe(200)
58+
59+
const body = await response.json()
60+
expect(body).toHaveProperty('token')
61+
expect(body).toHaveProperty('expiresAt')
62+
expect(typeof body.token).toBe('string')
63+
expect(body.token.length).toBeGreaterThan(0)
64+
})
65+
66+
it('returns a token that decodes to the correct payload', async () => {
67+
const session = validSession()
68+
mockGetAuthSession.mockResolvedValue(session)
69+
const { POST } = await import('@/app/api/auth/cli/token/route')
70+
71+
const response = await POST()
72+
const body = await response.json()
73+
74+
const decoded = await decode({
75+
token: body.token,
76+
secret: AUTH_SECRET,
77+
salt: JWT_SALT,
78+
})
79+
80+
expect(decoded).toMatchObject({
81+
sub: session.user.sub,
82+
name: session.user.name,
83+
email: session.user.email,
84+
picture: session.user.picture,
85+
speaker: session.speaker,
86+
account: session.account,
87+
})
88+
})
89+
90+
it('returns a token with 30-day expiry', async () => {
91+
mockGetAuthSession.mockResolvedValue(validSession())
92+
const { POST } = await import('@/app/api/auth/cli/token/route')
93+
94+
const response = await POST()
95+
const body = await response.json()
96+
97+
const decoded = await decode({
98+
token: body.token,
99+
secret: AUTH_SECRET,
100+
salt: JWT_SALT,
101+
})
102+
103+
const thirtyDays = 30 * 24 * 60 * 60
104+
expect(decoded!.exp! - decoded!.iat!).toBe(thirtyDays)
105+
})
106+
107+
it('returns expiresAt as a valid ISO date roughly 30 days out', async () => {
108+
mockGetAuthSession.mockResolvedValue(validSession())
109+
const { POST } = await import('@/app/api/auth/cli/token/route')
110+
111+
const response = await POST()
112+
const body = await response.json()
113+
114+
const expiresAt = new Date(body.expiresAt)
115+
const expectedMin = Date.now() + 29 * 24 * 60 * 60 * 1000
116+
const expectedMax = Date.now() + 31 * 24 * 60 * 60 * 1000
117+
expect(expiresAt.getTime()).toBeGreaterThan(expectedMin)
118+
expect(expiresAt.getTime()).toBeLessThan(expectedMax)
119+
})
120+
121+
it('returns 401 when not authenticated', async () => {
122+
mockGetAuthSession.mockResolvedValue(null)
123+
const { POST } = await import('@/app/api/auth/cli/token/route')
124+
125+
const response = await POST()
126+
expect(response.status).toBe(401)
127+
128+
const body = await response.json()
129+
expect(body.error).toBe('Unauthorized')
130+
})
131+
132+
it('returns 401 when session has no speaker', async () => {
133+
const session = validSession()
134+
delete (session as Record<string, unknown>).speaker
135+
mockGetAuthSession.mockResolvedValue(session)
136+
const { POST } = await import('@/app/api/auth/cli/token/route')
137+
138+
const response = await POST()
139+
expect(response.status).toBe(401)
140+
})
141+
142+
it('returns 401 when session has no account', async () => {
143+
const session = validSession()
144+
delete (session as Record<string, unknown>).account
145+
mockGetAuthSession.mockResolvedValue(session)
146+
const { POST } = await import('@/app/api/auth/cli/token/route')
147+
148+
const response = await POST()
149+
expect(response.status).toBe(401)
150+
})
151+
152+
it('returns 401 when session has no user', async () => {
153+
const session = validSession()
154+
delete (session as Record<string, unknown>).user
155+
mockGetAuthSession.mockResolvedValue(session)
156+
const { POST } = await import('@/app/api/auth/cli/token/route')
157+
158+
const response = await POST()
159+
expect(response.status).toBe(401)
160+
})
161+
162+
it('returns 500 when AUTH_SECRET is not set', async () => {
163+
delete process.env.AUTH_SECRET
164+
mockGetAuthSession.mockResolvedValue(validSession())
165+
const { POST } = await import('@/app/api/auth/cli/token/route')
166+
167+
const response = await POST()
168+
expect(response.status).toBe(500)
169+
170+
const body = await response.json()
171+
expect(body.error).toBe('Server configuration error')
172+
})
173+
})
174+
})

0 commit comments

Comments
 (0)