Skip to content

Commit 9193b36

Browse files
committed
Add a provider test
1 parent 5746587 commit 9193b36

File tree

4 files changed

+164
-59
lines changed

4 files changed

+164
-59
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
@@ -364,6 +364,7 @@ describe('internal api', () => {
364364
);
365365
expect(token).to.deep.equal({ token: fakeRecaptchaAppCheckToken.token });
366366
});
367+
367368
it('throttles exponentially on 503', async () => {
368369
const appCheck = initializeAppCheck(app, {
369370
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY)
@@ -385,6 +386,7 @@ describe('internal api', () => {
385386
expect(token.error?.message).to.include('00m');
386387
expect(warnStub.args[0][0]).to.include('503');
387388
});
389+
388390
it('throttles 1d on 403', async () => {
389391
const appCheck = initializeAppCheck(app, {
390392
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 & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -58,20 +58,8 @@ export class ReCaptchaV3Provider implements AppCheckProvider {
5858
* @internal
5959
*/
6060
async getToken(): Promise<AppCheckTokenInternal> {
61-
if (this._throttleData) {
62-
if (Date.now() - this._throttleData.allowRequestsAfter > 0) {
63-
// If after throttle timestamp, clear throttle data.
64-
this._throttleData = null;
65-
} else {
66-
// If before, throw.
67-
throw ERROR_FACTORY.create(AppCheckError.THROTTLED, {
68-
time: new Date(
69-
this._throttleData.allowRequestsAfter
70-
).toLocaleString(),
71-
httpStatus: this._throttleData.httpStatus
72-
});
73-
}
74-
}
61+
throwIfThrottled(this._throttleData);
62+
7563
if (!this._app || !this._platformLoggerProvider) {
7664
// This should only occur if user has not called initializeAppCheck().
7765
// We don't have an appName to provide if so.
@@ -92,59 +80,25 @@ export class ReCaptchaV3Provider implements AppCheckProvider {
9280
);
9381
} catch (e) {
9482
if ((e as FirebaseError).code === AppCheckError.FETCH_STATUS_ERROR) {
95-
const throttleData = this._setBackoff(
96-
Number((e as FirebaseError).customData?.httpStatus)
83+
this._throttleData = setBackoff(
84+
Number((e as FirebaseError).customData?.httpStatus),
85+
this._throttleData
9786
);
9887
throw ERROR_FACTORY.create(AppCheckError.THROTTLED, {
99-
time: getDurationString(throttleData.allowRequestsAfter - Date.now()),
100-
httpStatus: throttleData.httpStatus
88+
time: getDurationString(
89+
this._throttleData.allowRequestsAfter - Date.now()
90+
),
91+
httpStatus: this._throttleData.httpStatus
10192
});
10293
} else {
10394
throw e;
10495
}
10596
}
97+
// If successful, clear throttle data.
98+
this._throttleData = null;
10699
return result;
107100
}
108101

109-
/**
110-
* Set throttle data to block requests until after a certain time
111-
* depending on the failed request's status code.
112-
* @param httpStatus - Status code of failed request.
113-
* @returns Data about current throttle state and expiration time.
114-
*/
115-
private _setBackoff(httpStatus: number): ThrottleData {
116-
/**
117-
* Block retries for 1 day for the following error codes:
118-
*
119-
* 404: Likely malformed URL.
120-
*
121-
* 403:
122-
* - Attestation failed
123-
* - Wrong API key
124-
* - Project deleted
125-
*/
126-
if (httpStatus === 404 || httpStatus === 403) {
127-
this._throttleData = {
128-
backoffCount: 1,
129-
allowRequestsAfter: Date.now() + ONE_DAY,
130-
httpStatus
131-
};
132-
} else {
133-
/**
134-
* For all other error codes, the time when it is ok to retry again
135-
* is based on exponential backoff.
136-
*/
137-
const backoffCount = this._throttleData ? this._throttleData.backoffCount : 0;
138-
const backoffMillis = calculateBackoffMillis(backoffCount, 1000, 2);
139-
this._throttleData = {
140-
backoffCount: backoffCount + 1,
141-
allowRequestsAfter: Date.now() + backoffMillis,
142-
httpStatus
143-
};
144-
}
145-
return this._throttleData;
146-
}
147-
148102
/**
149103
* @internal
150104
*/
@@ -227,3 +181,56 @@ export class CustomProvider implements AppCheckProvider {
227181
}
228182
}
229183
}
184+
185+
/**
186+
* Set throttle data to block requests until after a certain time
187+
* depending on the failed request's status code.
188+
* @param httpStatus - Status code of failed request.
189+
* @returns Data about current throttle state and expiration time.
190+
*/
191+
function setBackoff(
192+
httpStatus: number,
193+
throttleData: ThrottleData | null
194+
): ThrottleData {
195+
/**
196+
* Block retries for 1 day for the following error codes:
197+
*
198+
* 404: Likely malformed URL.
199+
*
200+
* 403:
201+
* - Attestation failed
202+
* - Wrong API key
203+
* - Project deleted
204+
*/
205+
if (httpStatus === 404 || httpStatus === 403) {
206+
return {
207+
backoffCount: 1,
208+
allowRequestsAfter: Date.now() + ONE_DAY,
209+
httpStatus
210+
};
211+
} else {
212+
/**
213+
* For all other error codes, the time when it is ok to retry again
214+
* is based on exponential backoff.
215+
*/
216+
const backoffCount = throttleData ? throttleData.backoffCount : 0;
217+
const backoffMillis = calculateBackoffMillis(backoffCount, 1000, 2);
218+
return {
219+
backoffCount: backoffCount + 1,
220+
allowRequestsAfter: Date.now() + backoffMillis,
221+
httpStatus
222+
};
223+
}
224+
}
225+
226+
function throwIfThrottled(throttleData: ThrottleData | null): void {
227+
if (throttleData) {
228+
if (Date.now() - throttleData.allowRequestsAfter <= 0) {
229+
// If before, throw.
230+
throw ERROR_FACTORY.create(AppCheckError.THROTTLED, {
231+
time: getDurationString(throttleData.allowRequestsAfter - Date.now()),
232+
httpStatus: throttleData.httpStatus
233+
});
234+
}
235+
}
236+
}

0 commit comments

Comments
 (0)