Skip to content

Commit a6a900a

Browse files
committed
Add a provider test
1 parent 1fb21a6 commit a6a900a

File tree

4 files changed

+164
-61
lines changed

4 files changed

+164
-61
lines changed

packages/app-check/src/errors.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ const ERRORS: ErrorMap<AppCheckError> = {
5454
[AppCheckError.STORAGE_WRITE]:
5555
'Error thrown when writing to storage. Original error: {$originalErrorMessage}.',
5656
[AppCheckError.RECAPTCHA_ERROR]: 'ReCAPTCHA error.',
57-
[AppCheckError.THROTTLED]: `Requests throttled until {$time} due to {$httpStatus} error.`
57+
[AppCheckError.THROTTLED]: `Requests throttled due to {$httpStatus} error. Attempts allowed again after {$time}`
5858
};
5959

6060
interface ErrorParams {
@@ -66,7 +66,7 @@ interface ErrorParams {
6666
[AppCheckError.STORAGE_OPEN]: { originalErrorMessage?: string };
6767
[AppCheckError.STORAGE_GET]: { originalErrorMessage?: string };
6868
[AppCheckError.STORAGE_WRITE]: { originalErrorMessage?: string };
69-
[AppCheckError.THROTTLED]: { time: string, httpStatus: number };
69+
[AppCheckError.THROTTLED]: { time: string; httpStatus: number };
7070
}
7171

7272
export const ERROR_FACTORY = new ErrorFactory<AppCheckError, ErrorParams>(

packages/app-check/src/internal-api.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,7 @@ describe('internal api', () => {
387387
);
388388
expect(token).to.deep.equal({ token: fakeRecaptchaAppCheckToken.token });
389389
});
390+
390391
it('throttles exponentially on 503', async () => {
391392
const appCheck = initializeAppCheck(app, {
392393
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY)
@@ -408,6 +409,7 @@ describe('internal api', () => {
408409
expect(token.error?.message).to.include('00m');
409410
expect(warnStub.args[0][0]).to.include('503');
410411
});
412+
411413
it('throttles 1d on 403', async () => {
412414
const appCheck = initializeAppCheck(app, {
413415
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY)
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import '../test/setup';
2+
import { getFakeGreCAPTCHA, getFullApp } from '../test/util';
3+
import { ReCaptchaV3Provider } from './providers';
4+
import * as client from './client';
5+
import * as reCAPTCHA from './recaptcha';
6+
import * as util from './util';
7+
import { stub, useFakeTimers } from 'sinon';
8+
import { expect } from 'chai';
9+
import { FirebaseError } from '@firebase/util';
10+
import { AppCheckError } from './errors';
11+
import { clearState } from './state';
12+
import { deleteApp, FirebaseApp } from '@firebase/app';
13+
14+
describe('ReCaptchaV3Provider', () => {
15+
let app: FirebaseApp;
16+
let clock = useFakeTimers();
17+
beforeEach(() => {
18+
clock = useFakeTimers();
19+
app = getFullApp();
20+
stub(util, 'getRecaptcha').returns(getFakeGreCAPTCHA());
21+
stub(reCAPTCHA, 'getToken').returns(
22+
Promise.resolve('fake-recaptcha-token')
23+
);
24+
});
25+
26+
afterEach(() => {
27+
clock.restore();
28+
clearState();
29+
return deleteApp(app);
30+
});
31+
it('getToken() gets a token from the exchange endpoint', async () => {
32+
const app = getFullApp();
33+
const provider = new ReCaptchaV3Provider('fake-site-key');
34+
stub(client, 'exchangeToken').resolves({
35+
token: 'fake-exchange-token',
36+
issuedAtTimeMillis: 0,
37+
expireTimeMillis: 10
38+
});
39+
provider.initialize(app);
40+
const token = await provider.getToken();
41+
expect(token.token).to.equal('fake-exchange-token');
42+
});
43+
it('getToken() throttles 1d on 403', async () => {
44+
const app = getFullApp();
45+
const provider = new ReCaptchaV3Provider('fake-site-key');
46+
stub(client, 'exchangeToken').rejects(
47+
new FirebaseError(AppCheckError.FETCH_STATUS_ERROR, 'some-message', {
48+
httpStatus: 403
49+
})
50+
);
51+
provider.initialize(app);
52+
await expect(provider.getToken()).to.be.rejectedWith('1d');
53+
// Wait 10s and try again to see if wait time string decreases.
54+
clock.tick(10000);
55+
await expect(provider.getToken()).to.be.rejectedWith('23h');
56+
});
57+
it('getToken() throttles exponentially on 503', async () => {
58+
const app = getFullApp();
59+
const provider = new ReCaptchaV3Provider('fake-site-key');
60+
let exchangeTokenStub = stub(client, 'exchangeToken').rejects(
61+
new FirebaseError(AppCheckError.FETCH_STATUS_ERROR, 'some-message', {
62+
httpStatus: 503
63+
})
64+
);
65+
provider.initialize(app);
66+
await expect(provider.getToken()).to.be.rejectedWith('503');
67+
expect(exchangeTokenStub).to.be.called;
68+
exchangeTokenStub.resetHistory();
69+
// Try again immediately, should be rejected.
70+
await expect(provider.getToken()).to.be.rejectedWith('503');
71+
expect(exchangeTokenStub).not.to.be.called;
72+
exchangeTokenStub.resetHistory();
73+
// Wait for 1.5 seconds to pass, should call exchange endpoint again
74+
// (and be rejected again)
75+
clock.tick(1500);
76+
await expect(provider.getToken()).to.be.rejectedWith('503');
77+
expect(exchangeTokenStub).to.be.called;
78+
exchangeTokenStub.resetHistory();
79+
// Wait for 10 seconds to pass, should call exchange endpoint again
80+
// (and be rejected again)
81+
clock.tick(10000);
82+
await expect(provider.getToken()).to.be.rejectedWith('503');
83+
expect(exchangeTokenStub).to.be.called;
84+
// Wait for 10 seconds to pass, should call exchange endpoint again
85+
// (and succeed)
86+
clock.tick(10000);
87+
exchangeTokenStub.restore();
88+
exchangeTokenStub = stub(client, 'exchangeToken').resolves({
89+
token: 'fake-exchange-token',
90+
issuedAtTimeMillis: 0,
91+
expireTimeMillis: 10
92+
});
93+
const token = await provider.getToken();
94+
expect(token.token).to.equal('fake-exchange-token');
95+
});
96+
});

packages/app-check/src/providers.ts

Lines changed: 64 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -63,20 +63,8 @@ export class ReCaptchaV3Provider implements AppCheckProvider {
6363
* @internal
6464
*/
6565
async getToken(): Promise<AppCheckTokenInternal> {
66-
if (this._throttleData) {
67-
if (Date.now() - this._throttleData.allowRequestsAfter > 0) {
68-
// If after throttle timestamp, clear throttle data.
69-
this._throttleData = null;
70-
} else {
71-
// If before, throw.
72-
throw ERROR_FACTORY.create(AppCheckError.THROTTLED, {
73-
time: new Date(
74-
this._throttleData.allowRequestsAfter
75-
).toLocaleString(),
76-
httpStatus: this._throttleData.httpStatus
77-
});
78-
}
79-
}
66+
throwIfThrottled(this._throttleData);
67+
8068
if (!this._app || !this._platformLoggerProvider) {
8169
// This should only occur if user has not called initializeAppCheck().
8270
// We don't have an appName to provide if so.
@@ -101,61 +89,25 @@ export class ReCaptchaV3Provider implements AppCheckProvider {
10189
);
10290
} catch (e) {
10391
if ((e as FirebaseError).code === AppCheckError.FETCH_STATUS_ERROR) {
104-
const throttleData = this._setBackoff(
105-
Number((e as FirebaseError).customData?.httpStatus)
92+
this._throttleData = setBackoff(
93+
Number((e as FirebaseError).customData?.httpStatus),
94+
this._throttleData
10695
);
10796
throw ERROR_FACTORY.create(AppCheckError.THROTTLED, {
108-
time: getDurationString(throttleData.allowRequestsAfter - Date.now()),
109-
httpStatus: throttleData.httpStatus
97+
time: getDurationString(
98+
this._throttleData.allowRequestsAfter - Date.now()
99+
),
100+
httpStatus: this._throttleData.httpStatus
110101
});
111102
} else {
112103
throw e;
113104
}
114105
}
106+
// If successful, clear throttle data.
107+
this._throttleData = null;
115108
return result;
116109
}
117110

118-
/**
119-
* Set throttle data to block requests until after a certain time
120-
* depending on the failed request's status code.
121-
* @param httpStatus - Status code of failed request.
122-
* @returns Data about current throttle state and expiration time.
123-
*/
124-
private _setBackoff(httpStatus: number): ThrottleData {
125-
/**
126-
* Block retries for 1 day for the following error codes:
127-
*
128-
* 404: Likely malformed URL.
129-
*
130-
* 403:
131-
* - Attestation failed
132-
* - Wrong API key
133-
* - Project deleted
134-
*/
135-
if (httpStatus === 404 || httpStatus === 403) {
136-
this._throttleData = {
137-
backoffCount: 1,
138-
allowRequestsAfter: Date.now() + ONE_DAY,
139-
httpStatus
140-
};
141-
} else {
142-
/**
143-
* For all other error codes, the time when it is ok to retry again
144-
* is based on exponential backoff.
145-
*/
146-
const backoffCount = this._throttleData
147-
? this._throttleData.backoffCount
148-
: 0;
149-
const backoffMillis = calculateBackoffMillis(backoffCount, 1000, 2);
150-
this._throttleData = {
151-
backoffCount: backoffCount + 1,
152-
allowRequestsAfter: Date.now() + backoffMillis,
153-
httpStatus
154-
};
155-
}
156-
return this._throttleData;
157-
}
158-
159111
/**
160112
* @internal
161113
*/
@@ -290,3 +242,56 @@ export class CustomProvider implements AppCheckProvider {
290242
}
291243
}
292244
}
245+
246+
/**
247+
* Set throttle data to block requests until after a certain time
248+
* depending on the failed request's status code.
249+
* @param httpStatus - Status code of failed request.
250+
* @returns Data about current throttle state and expiration time.
251+
*/
252+
function setBackoff(
253+
httpStatus: number,
254+
throttleData: ThrottleData | null
255+
): ThrottleData {
256+
/**
257+
* Block retries for 1 day for the following error codes:
258+
*
259+
* 404: Likely malformed URL.
260+
*
261+
* 403:
262+
* - Attestation failed
263+
* - Wrong API key
264+
* - Project deleted
265+
*/
266+
if (httpStatus === 404 || httpStatus === 403) {
267+
return {
268+
backoffCount: 1,
269+
allowRequestsAfter: Date.now() + ONE_DAY,
270+
httpStatus
271+
};
272+
} else {
273+
/**
274+
* For all other error codes, the time when it is ok to retry again
275+
* is based on exponential backoff.
276+
*/
277+
const backoffCount = throttleData ? throttleData.backoffCount : 0;
278+
const backoffMillis = calculateBackoffMillis(backoffCount, 1000, 2);
279+
return {
280+
backoffCount: backoffCount + 1,
281+
allowRequestsAfter: Date.now() + backoffMillis,
282+
httpStatus
283+
};
284+
}
285+
}
286+
287+
function throwIfThrottled(throttleData: ThrottleData | null): void {
288+
if (throttleData) {
289+
if (Date.now() - throttleData.allowRequestsAfter <= 0) {
290+
// If before, throw.
291+
throw ERROR_FACTORY.create(AppCheckError.THROTTLED, {
292+
time: getDurationString(throttleData.allowRequestsAfter - Date.now()),
293+
httpStatus: throttleData.httpStatus
294+
});
295+
}
296+
}
297+
}

0 commit comments

Comments
 (0)