Skip to content

Commit 236d816

Browse files
Remove dependency axios-mock-adapter and fix the token unit tests (#5369)
* Update dependency axios-mock-adapter to v2 * Remove axios-mock-adapter * Capture token exception in Sentry instead of re-throwing * Fix the unit test * Update the lock file
1 parent b818059 commit 236d816

9 files changed

+457
-1114
lines changed

frontend/package.json

-1
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,6 @@
6969
"@wordpress/is-shallow-equal": "^5.3.0",
7070
"async-mutex": "^0.5.0",
7171
"axios": "^1.7.9",
72-
"axios-mock-adapter": "^1.22.0",
7372
"clipboard": "^2.0.11",
7473
"focus-trap": "^7.5.4",
7574
"focus-visible": "^5.2.0",

frontend/src/plugins/01.api-token.server.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ const refreshApiAccessToken = async (
9999
(e as AxiosError).message
100100
}`
101101
warn((e as AxiosError).message)
102-
throw e
102+
Sentry.captureException(e)
103103
}
104104
}
105105

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
import { describe, expect, it, vi } from "vitest"
2+
import axios from "axios"
3+
import { useRuntimeConfig } from "#app/nuxt"
4+
5+
import {
6+
expiryThreshold,
7+
getApiAccessToken,
8+
} from "~/plugins/01.api-token.server"
9+
10+
vi.mock("axios", async (importOriginal) => {
11+
const original = await importOriginal()
12+
return {
13+
default: {
14+
...original,
15+
post: vi.fn(() => Promise.resolve({ data: {} })),
16+
},
17+
}
18+
})
19+
20+
const mocks = vi.hoisted(() => {
21+
return {
22+
useRuntimeConfig: vi.fn(),
23+
}
24+
})
25+
vi.mock("#app/nuxt", async (importOriginal) => {
26+
const original = await importOriginal()
27+
return {
28+
...original,
29+
useRuntimeConfig: mocks.useRuntimeConfig,
30+
}
31+
})
32+
33+
const frozenNow = Date.now()
34+
vi.spyOn(global.Date, "now").mockReturnValue(frozenNow)
35+
36+
const defaultConfig = {
37+
public: {
38+
apiUrl: "https://api.openverse.org/",
39+
},
40+
apiClientId: "abcdefg_client_i_d",
41+
apiClientSecret: "shhhhhhhhh_1234_super_secret",
42+
}
43+
44+
const defaultPromise = Promise.resolve()
45+
const iAmATeapotError = new axios.AxiosError(
46+
"I'm a teapot",
47+
{},
48+
{ status: 418 }
49+
)
50+
const frozenSeconds = Math.floor(frozenNow / 1e3)
51+
const twelveHoursInSeconds = 12 * 3600
52+
53+
let tokenCount = 1
54+
const getMockTokenResponse = (expires_in = twelveHoursInSeconds) => ({
55+
access_token: `abcd1234_${tokenCount++}`,
56+
expires_in,
57+
})
58+
59+
describe("token retrieval", () => {
60+
beforeEach(() => {
61+
vi.resetAllMocks()
62+
process.tokenData = {
63+
accessToken: "",
64+
accessTokenExpiry: 0,
65+
}
66+
process.tokenFetching = defaultPromise
67+
})
68+
69+
describe("unsuccessful", () => {
70+
beforeEach(() => {
71+
vi.mocked(useRuntimeConfig).mockReturnValue({ ...defaultConfig })
72+
})
73+
it("should empty the token data", async () => {
74+
const firstTokenResponse = getMockTokenResponse(expiryThreshold - 1)
75+
76+
// Mock first request success, second request failure
77+
axios.post
78+
.mockImplementationOnce(() =>
79+
Promise.resolve({ data: firstTokenResponse })
80+
)
81+
.mockImplementationOnce(() => Promise.reject(iAmATeapotError))
82+
83+
// First call should get valid token
84+
const firstToken = await getApiAccessToken()
85+
expect(firstToken).toBe(firstTokenResponse.access_token)
86+
87+
// Second call should fail and clear token
88+
const secondToken = await getApiAccessToken()
89+
expect(secondToken).toBe("")
90+
expect(process.tokenData.accessToken).toBe("")
91+
expect(process.tokenData.accessTokenExpiry).toBe(0)
92+
})
93+
94+
it("should properly release the mutex and allow for subsequent requests to retry the token refresh", async () => {
95+
const firstTokenResponse = getMockTokenResponse(expiryThreshold - 1)
96+
const finalTokenResponse = getMockTokenResponse()
97+
98+
// Mock sequence: success -> failure -> success
99+
axios.post
100+
.mockImplementationOnce(() =>
101+
Promise.resolve({ data: firstTokenResponse })
102+
)
103+
.mockImplementationOnce(() => Promise.reject(iAmATeapotError))
104+
.mockImplementationOnce(() =>
105+
Promise.resolve({ data: finalTokenResponse })
106+
)
107+
108+
// First successful call
109+
const token1 = await getApiAccessToken()
110+
expect(token1).toBe(firstTokenResponse.access_token)
111+
112+
// Failed refresh should return empty string
113+
const token2 = await getApiAccessToken()
114+
expect(token2).toBe("")
115+
116+
// New successful call with fresh token
117+
const token3 = await getApiAccessToken()
118+
expect(token3).toBe(finalTokenResponse.access_token)
119+
})
120+
})
121+
122+
describe("missing client credentials", () => {
123+
it("completely missing: should not make any requests and fall back to tokenless", async () => {
124+
vi.mocked(useRuntimeConfig).mockReturnValue({
125+
public: { ...defaultConfig.public },
126+
})
127+
128+
const token = await getApiAccessToken()
129+
130+
expect(token).toEqual(undefined)
131+
})
132+
133+
it("explicitly undefined: should not make any requests and fall back to tokenless", async () => {
134+
vi.mocked(useRuntimeConfig).mockReturnValue({
135+
public: { ...defaultConfig.public },
136+
apiClientId: undefined,
137+
apiClientSecret: undefined,
138+
})
139+
const token = await getApiAccessToken()
140+
expect(token).toEqual(undefined)
141+
})
142+
})
143+
144+
describe("successful token retrieval", () => {
145+
beforeEach(() => {
146+
vi.clearAllMocks()
147+
vi.mocked(useRuntimeConfig).mockReturnValue({ ...defaultConfig })
148+
})
149+
it("should save the token into the process and inject into the context", async () => {
150+
const mockTokenResponse = getMockTokenResponse()
151+
axios.post.mockImplementationOnce(() =>
152+
Promise.resolve({ data: mockTokenResponse })
153+
)
154+
155+
const token = await getApiAccessToken()
156+
157+
expect(token).toEqual(mockTokenResponse.access_token)
158+
expect(process.tokenData).toMatchObject({
159+
accessToken: mockTokenResponse.access_token,
160+
accessTokenExpiry: frozenSeconds + twelveHoursInSeconds,
161+
})
162+
})
163+
164+
it("should re-retrieve the token when about to expire", async () => {
165+
const mockTokenResponse = getMockTokenResponse(expiryThreshold - 1)
166+
const nextMockTokenResponse = getMockTokenResponse()
167+
168+
axios.post
169+
.mockImplementationOnce(() =>
170+
Promise.resolve({ data: mockTokenResponse })
171+
)
172+
.mockImplementationOnce(() =>
173+
Promise.resolve({ data: nextMockTokenResponse })
174+
)
175+
176+
await getApiAccessToken(defaultConfig)
177+
const token = await getApiAccessToken(defaultConfig)
178+
179+
expect(token).toEqual(nextMockTokenResponse.access_token)
180+
expect(process.tokenData).toMatchObject({
181+
accessToken: nextMockTokenResponse.access_token,
182+
accessTokenExpiry: frozenSeconds + twelveHoursInSeconds,
183+
})
184+
})
185+
186+
it("should not request a new token if the token is not about to expire", async () => {
187+
const mockTokenResponse = getMockTokenResponse(twelveHoursInSeconds)
188+
const nextMockTokenResponse = getMockTokenResponse()
189+
190+
axios.post
191+
.mockImplementationOnce(() =>
192+
Promise.resolve({ data: mockTokenResponse })
193+
)
194+
.mockImplementationOnce(() =>
195+
Promise.resolve({ data: nextMockTokenResponse })
196+
)
197+
198+
await getApiAccessToken()
199+
const token = await getApiAccessToken()
200+
201+
expect(token).toEqual(mockTokenResponse.access_token)
202+
expect(process.tokenData.accessTokenExpiry).toEqual(
203+
frozenSeconds + twelveHoursInSeconds
204+
)
205+
})
206+
})
207+
208+
it("subsequent requests should all block on the same token retrieval promise", async () => {
209+
/**
210+
* This test is pretty complicated because we need to simulate
211+
* multiple requests coming in at the same time with requests
212+
* to the token API resolving only after the multiple
213+
* requests have come in. If we didn't cause the request for the
214+
* token to block until we'd fired off all three requests then
215+
* the first request could resolve before the other two had a chance
216+
* to check the mutex and await on the fetching promise.
217+
*
218+
* This relies on the behavior of the Node event loop where
219+
* several async functions called synchronously in succession will execute
220+
* up until the first blocking `await` and then return the promise. This allows
221+
* us to effectively get all three of the async api token plugin function
222+
* calls up to the first blocking await which will either be the call to
223+
* `refreshApiAccessToken` which makes the axios call (blocked by the adapter
224+
* mock in this test) _or_ awaiting the promise shared by the entire process.
225+
*/
226+
vi.mocked(useRuntimeConfig).mockReturnValue({ ...defaultConfig })
227+
const mockTokenResponse = getMockTokenResponse()
228+
const nextMockTokenResponse = getMockTokenResponse()
229+
let resolveFirstRequestPromise = undefined
230+
const resolveFirstRequest = async () => {
231+
while (!resolveFirstRequestPromise) {
232+
await new Promise((r) => setTimeout(r, 1))
233+
}
234+
resolveFirstRequestPromise({})
235+
}
236+
237+
axios.post.mockImplementationOnce(async () => {
238+
const promise = new Promise((resolve) => {
239+
resolveFirstRequestPromise = resolve
240+
})
241+
242+
await promise
243+
return { data: mockTokenResponse }
244+
})
245+
axios.post.mockImplementationOnce(() =>
246+
Promise.resolve({ data: nextMockTokenResponse })
247+
)
248+
249+
const promises = [
250+
getApiAccessToken(defaultConfig),
251+
getApiAccessToken(defaultConfig),
252+
getApiAccessToken(defaultConfig),
253+
]
254+
255+
await resolveFirstRequest()
256+
await Promise.all(promises)
257+
258+
// If the process tokenData still matches the first
259+
// request's return then we know that all three requests
260+
// used the same response.
261+
expect(process.tokenData).toMatchObject({
262+
accessToken: mockTokenResponse.access_token,
263+
accessTokenExpiry: mockTokenResponse.expires_in + frozenSeconds,
264+
})
265+
})
266+
})

frontend/test/unit/specs/utils/api-token/api-token-missing-credentials.spec.js

-25
This file was deleted.

0 commit comments

Comments
 (0)