-
Notifications
You must be signed in to change notification settings - Fork 262
Expand file tree
/
Copy pathdevice-authorization.test.ts
More file actions
229 lines (195 loc) · 9.43 KB
/
Copy pathdevice-authorization.test.ts
File metadata and controls
229 lines (195 loc) · 9.43 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
import {
DeviceAuthorizationResponse,
pollForDeviceAuthorization,
requestDeviceAuthorization,
} from './device-authorization.js'
import {clientId} from './identity.js'
import {IdentityToken} from './schema.js'
import {exchangeDeviceCodeForAccessToken} from './exchange.js'
import {identityFqdn} from '../../../public/node/context/fqdn.js'
import {shopifyFetch} from '../../../public/node/http.js'
import {isTTY, keypress} from '../../../public/node/ui.js'
import {err, ok} from '../../../public/node/result.js'
import {AbortError} from '../../../public/node/error.js'
import {isCI, openURL} from '../../../public/node/system.js'
import {mockAndCaptureOutput} from '../../../public/node/testing/output.js'
import {beforeEach, describe, expect, test, vi} from 'vitest'
import {Response} from 'node-fetch'
vi.mock('../../../public/node/context/fqdn.js')
vi.mock('./identity')
vi.mock('../../../public/node/http.js')
vi.mock('../../../public/node/ui.js')
vi.mock('./exchange.js')
vi.mock('../../../public/node/system.js')
beforeEach(() => {
vi.mocked(isTTY).mockReturnValue(true)
vi.mocked(isCI).mockReturnValue(false)
})
describe('requestDeviceAuthorization', () => {
const data: any = {
device_code: 'device_code',
user_code: 'user_code',
verification_uri: 'verification_uri',
expires_in: 3600,
verification_uri_complete: 'verification_uri_complete',
interval: 5,
}
const dataExpected: DeviceAuthorizationResponse = {
deviceCode: data.device_code,
userCode: data.user_code,
verificationUri: data.verification_uri,
expiresIn: data.expires_in,
verificationUriComplete: data.verification_uri_complete,
interval: data.interval,
}
test('requests an authorization code to initiate the device auth', async () => {
// Given
const response = new Response(JSON.stringify(data))
vi.mocked(shopifyFetch).mockResolvedValue(response)
vi.mocked(identityFqdn).mockResolvedValue('fqdn.com')
vi.mocked(clientId).mockReturnValue('clientId')
// When
const got = await requestDeviceAuthorization(['scope1', 'scope2'])
// Then
expect(shopifyFetch).toBeCalledWith('https://fqdn.com/oauth/device_authorization', {
method: 'POST',
headers: {'Content-type': 'application/x-www-form-urlencoded'},
body: 'client_id=clientId&scope=scope1 scope2',
})
expect(got).toEqual(dataExpected)
})
test('can request a device auth code without prompting or polling', async () => {
// Given
const outputMock = mockAndCaptureOutput()
const response = new Response(JSON.stringify(data))
vi.mocked(shopifyFetch).mockResolvedValue(response)
vi.mocked(identityFqdn).mockResolvedValue('fqdn.com')
vi.mocked(clientId).mockReturnValue('clientId')
vi.mocked(isCI).mockReturnValue(true)
// When
const got = await requestDeviceAuthorization(['scope1', 'scope2'], {noPrompt: true})
// Then
expect(got).toEqual(dataExpected)
expect(keypress).not.toHaveBeenCalled()
expect(openURL).not.toHaveBeenCalled()
expect(outputMock.info()).toContain('User verification code: user_code')
expect(outputMock.info()).toContain('verification_uri_complete')
})
test('when the response is not valid JSON, throw an error with context', async () => {
// Given
const response = new Response('not valid JSON')
Object.defineProperty(response, 'status', {value: 200})
Object.defineProperty(response, 'statusText', {value: 'OK'})
vi.mocked(shopifyFetch).mockResolvedValue(response)
vi.mocked(identityFqdn).mockResolvedValue('fqdn.com')
vi.mocked(clientId).mockReturnValue('clientId')
// When/Then
await expect(requestDeviceAuthorization(['scope1', 'scope2'])).rejects.toThrowError(
'Received invalid response from authorization service (HTTP 200). Response could not be parsed as valid JSON. If this issue persists, please contact support at https://help.shopify.com',
)
})
test('when the response is empty, throw an error with empty body message', async () => {
// Given
const response = new Response('')
Object.defineProperty(response, 'status', {value: 200})
Object.defineProperty(response, 'statusText', {value: 'OK'})
vi.mocked(shopifyFetch).mockResolvedValue(response)
vi.mocked(identityFqdn).mockResolvedValue('fqdn.com')
vi.mocked(clientId).mockReturnValue('clientId')
// When/Then
await expect(requestDeviceAuthorization(['scope1', 'scope2'])).rejects.toThrowError(
'Received invalid response from authorization service (HTTP 200). Received empty response body. If this issue persists, please contact support at https://help.shopify.com',
)
})
test('when the response is HTML instead of JSON, throw an error with HTML detection', async () => {
// Given
const htmlResponse = '<!DOCTYPE html><html><body>Error page</body></html>'
const response = new Response(htmlResponse)
Object.defineProperty(response, 'status', {value: 404})
Object.defineProperty(response, 'statusText', {value: 'Not Found'})
vi.mocked(shopifyFetch).mockResolvedValue(response)
vi.mocked(identityFqdn).mockResolvedValue('fqdn.com')
vi.mocked(clientId).mockReturnValue('clientId')
// When/Then
await expect(requestDeviceAuthorization(['scope1', 'scope2'])).rejects.toThrowError(
'Received invalid response from authorization service (HTTP 404). The request may be malformed or unauthorized. Received HTML instead of JSON - the service endpoint may have changed. If this issue persists, please contact support at https://help.shopify.com',
)
})
test('when the server returns a 500 error with non-JSON response, throw an error with server issue message', async () => {
// Given
const response = new Response('Internal Server Error')
Object.defineProperty(response, 'status', {value: 500})
Object.defineProperty(response, 'statusText', {value: 'Internal Server Error'})
vi.mocked(shopifyFetch).mockResolvedValue(response)
vi.mocked(identityFqdn).mockResolvedValue('fqdn.com')
vi.mocked(clientId).mockReturnValue('clientId')
// When/Then
await expect(requestDeviceAuthorization(['scope1', 'scope2'])).rejects.toThrowError(
'Received invalid response from authorization service (HTTP 500). The service may be experiencing issues. Response could not be parsed as valid JSON. If this issue persists, please contact support at https://help.shopify.com',
)
})
test('when response.text() fails, throw an error about network/streaming issue', async () => {
// Given
const response = new Response('some content')
Object.defineProperty(response, 'status', {value: 200})
Object.defineProperty(response, 'statusText', {value: 'OK'})
// Mock text() to throw an error
response.text = vi.fn().mockRejectedValue(new Error('Network error'))
vi.mocked(shopifyFetch).mockResolvedValue(response)
vi.mocked(identityFqdn).mockResolvedValue('fqdn.com')
vi.mocked(clientId).mockReturnValue('clientId')
// When/Then
await expect(requestDeviceAuthorization(['scope1', 'scope2'])).rejects.toThrowError(
'Failed to read response from authorization service (HTTP 200). Network or streaming error occurred.',
)
})
})
describe('pollForDeviceAuthorization', () => {
const identityToken: IdentityToken = {
accessToken: 'access_token',
refreshToken: 'refresh_token',
expiresAt: new Date(2022, 1, 1, 11),
scopes: ['scope', 'scope2'],
userId: '1234-5678',
alias: '1234-5678',
}
test('poll until a valid token is received', async () => {
// Given
vi.mocked(exchangeDeviceCodeForAccessToken).mockResolvedValueOnce(err('authorization_pending'))
vi.mocked(exchangeDeviceCodeForAccessToken).mockResolvedValueOnce(err('authorization_pending'))
vi.mocked(exchangeDeviceCodeForAccessToken).mockResolvedValueOnce(err('authorization_pending'))
vi.mocked(exchangeDeviceCodeForAccessToken).mockResolvedValueOnce(ok(identityToken))
// When
const got = await pollForDeviceAuthorization('device_code', 0.05)
// Then
expect(exchangeDeviceCodeForAccessToken).toBeCalledTimes(4)
expect(got).toEqual(identityToken)
})
test('when polling, if an error is received, stop polling and throw error', async () => {
// Given
vi.mocked(exchangeDeviceCodeForAccessToken).mockResolvedValueOnce(err('authorization_pending'))
vi.mocked(exchangeDeviceCodeForAccessToken).mockResolvedValueOnce(err('authorization_pending'))
vi.mocked(exchangeDeviceCodeForAccessToken).mockResolvedValueOnce(err('access_denied'))
// When
const got = pollForDeviceAuthorization('device_code', 0.05)
// Then
await expect(got).rejects.toThrow()
expect(exchangeDeviceCodeForAccessToken).toBeCalledTimes(3)
})
test('when polling, if an access denied error is received, stop polling and throw AbortError', async () => {
// Given
vi.mocked(exchangeDeviceCodeForAccessToken).mockResolvedValueOnce(err('access_denied'))
// When
const got = pollForDeviceAuthorization('device_code', 0.05)
// Then
await expect(got).rejects.toThrow(new AbortError(`Device authorization failed: Access denied.`))
})
test('when polling, if an expired token error is received, stop polling and throw AbortError', async () => {
// Given
vi.mocked(exchangeDeviceCodeForAccessToken).mockResolvedValueOnce(err('expired_token'))
// When
const got = pollForDeviceAuthorization('device_code', 0.05)
// Then
await expect(got).rejects.toThrow(new AbortError(`Device authorization failed: Token expired. Please try again.`))
})
})