Skip to content

Commit 6a427ed

Browse files
committed
test(greenhouse): cover the client credential token exchange
1 parent 2ed9ff6 commit 6a427ed

1 file changed

Lines changed: 98 additions & 0 deletions

File tree

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { jsonResponse } from '@sim/testing/helpers/http'
2+
import { beforeEach, describe, expect, it, vi } from 'vitest'
3+
import { mintGreenhouseServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/greenhouse'
4+
5+
const FIELDS = { clientId: 'greenhouse-client', clientSecret: 'greenhouse-secret', orgId: '' }
6+
7+
const mockFetch = vi.fn<typeof fetch>()
8+
9+
describe('Greenhouse client credential exchange', () => {
10+
beforeEach(() => {
11+
mockFetch.mockReset()
12+
vi.stubGlobal('fetch', mockFetch)
13+
})
14+
15+
it('exchanges the credential with a scope-free client credentials grant', async () => {
16+
const requests: Request[] = []
17+
mockFetch.mockImplementation(async (input, init) => {
18+
const request = new Request(input, init)
19+
requests.push(request)
20+
expect(request.url).toBe('https://auth.greenhouse.io/token')
21+
expect(request.method).toBe('POST')
22+
expect(request.redirect).toBe('error')
23+
const [scheme, encodedCredentials = ''] = (request.headers.get('authorization') ?? '').split(
24+
' '
25+
)
26+
expect(scheme).toBe('Basic')
27+
expect(Buffer.from(encodedCredentials, 'base64').toString('utf8')).toBe(
28+
'greenhouse-client:greenhouse-secret'
29+
)
30+
expect(request.headers.get('content-type')).toBe('application/x-www-form-urlencoded')
31+
expect([...new URLSearchParams(await request.text())]).toEqual([
32+
['grant_type', 'client_credentials'],
33+
])
34+
return jsonResponse({
35+
access_token: 'greenhouse-access',
36+
token_type: 'Bearer',
37+
expires_in: 3600,
38+
})
39+
})
40+
41+
const result = await mintGreenhouseServiceAccountToken(FIELDS)
42+
43+
expect(requests).toHaveLength(1)
44+
expect(result).toMatchObject({ accessToken: 'greenhouse-access', expiresInSeconds: 3600 })
45+
expect(result.identity).toMatchObject({
46+
principal: null,
47+
auditMetadata: { greenhouseClientId: 'greenhouse-client' },
48+
})
49+
expect(JSON.stringify(result.identity)).not.toContain(FIELDS.clientSecret)
50+
expect(JSON.stringify(result.identity)).not.toContain('greenhouse-access')
51+
})
52+
53+
it.each([
54+
[401, 'invalid_credentials'],
55+
[403, 'invalid_credentials'],
56+
[429, 'provider_unavailable'],
57+
[503, 'provider_unavailable'],
58+
])('maps an HTTP %i token response to %s', async (status, code) => {
59+
mockFetch.mockResolvedValue(jsonResponse({ error: 'denied' }, { status }))
60+
61+
await expect(mintGreenhouseServiceAccountToken(FIELDS)).rejects.toMatchObject({ code, status })
62+
})
63+
64+
it.each([
65+
['no access token', { token_type: 'Bearer', expires_in: 3600 }],
66+
['a blank access token', { access_token: ' ', expires_in: 3600 }],
67+
['no lifetime', { access_token: 'greenhouse-access' }],
68+
['a non-positive lifetime', { access_token: 'greenhouse-access', expires_in: 0 }],
69+
['a fractional lifetime', { access_token: 'greenhouse-access', expires_in: 1.5 }],
70+
])('refuses a token response with %s', async (_label, body) => {
71+
mockFetch.mockResolvedValue(jsonResponse(body))
72+
73+
await expect(mintGreenhouseServiceAccountToken(FIELDS)).rejects.toMatchObject({
74+
code: 'provider_unavailable',
75+
status: 502,
76+
})
77+
})
78+
79+
it('refuses an oversized token response', async () => {
80+
mockFetch.mockResolvedValue(
81+
new Response(JSON.stringify({ access_token: 'x'.repeat(70 * 1024), expires_in: 3600 }), {
82+
headers: { 'Content-Type': 'application/json' },
83+
})
84+
)
85+
86+
await expect(mintGreenhouseServiceAccountToken(FIELDS)).rejects.toMatchObject({
87+
code: 'provider_unavailable',
88+
status: 502,
89+
})
90+
})
91+
92+
it('never sends a request without a client secret', async () => {
93+
await expect(
94+
mintGreenhouseServiceAccountToken({ ...FIELDS, clientSecret: undefined })
95+
).rejects.toMatchObject({ code: 'invalid_credentials', status: 400 })
96+
expect(mockFetch).not.toHaveBeenCalled()
97+
})
98+
})

0 commit comments

Comments
 (0)