Skip to content

Commit b452a29

Browse files
authored
Implemented the listRulesetMetadata() API (#622)
1 parent 6042333 commit b452a29

File tree

4 files changed

+345
-1
lines changed

4 files changed

+345
-1
lines changed

src/security-rules/security-rules-api-client.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ export interface RulesetResponse extends RulesetContent {
3939
readonly createTime: string;
4040
}
4141

42+
export interface ListRulesetsResponse {
43+
readonly rulesets: Array<{name: string, createTime: string}>;
44+
readonly nextPageToken?: string;
45+
}
46+
4247
/**
4348
* Class that facilitates sending requests to the Firebase security rules backend API.
4449
*
@@ -119,6 +124,38 @@ export class SecurityRulesApiClient {
119124
});
120125
}
121126

127+
public listRulesets(pageSize: number = 100, pageToken?: string): Promise<ListRulesetsResponse> {
128+
if (!validator.isNumber(pageSize)) {
129+
const err = new FirebaseSecurityRulesError('invalid-argument', 'Invalid page size.');
130+
return Promise.reject(err);
131+
}
132+
if (pageSize < 1 || pageSize > 100) {
133+
const err = new FirebaseSecurityRulesError(
134+
'invalid-argument', 'Page size must be between 1 and 100.');
135+
return Promise.reject(err);
136+
}
137+
if (typeof pageToken !== 'undefined' && !validator.isNonEmptyString(pageToken)) {
138+
const err = new FirebaseSecurityRulesError(
139+
'invalid-argument', 'Next page token must be a non-empty string.');
140+
return Promise.reject(err);
141+
}
142+
143+
const data = {
144+
pageSize,
145+
pageToken,
146+
};
147+
if (!pageToken) {
148+
delete data.pageToken;
149+
}
150+
151+
const request: HttpRequestConfig = {
152+
method: 'GET',
153+
url: `${this.url}/rulesets`,
154+
data,
155+
};
156+
return this.sendRequest<ListRulesetsResponse>(request);
157+
}
158+
122159
public getRelease(name: string): Promise<Release> {
123160
return this.getResource<Release>(`releases/${name}`);
124161
}

src/security-rules/security-rules.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ import { FirebaseServiceInterface, FirebaseServiceInternalsInterface } from '../
1818
import { FirebaseApp } from '../firebase-app';
1919
import * as utils from '../utils/index';
2020
import * as validator from '../utils/validator';
21-
import { SecurityRulesApiClient, RulesetResponse, RulesetContent, Release } from './security-rules-api-client';
21+
import {
22+
SecurityRulesApiClient, RulesetResponse, RulesetContent, ListRulesetsResponse,
23+
} from './security-rules-api-client';
2224
import { AuthorizedHttpClient } from '../utils/api-request';
2325
import { FirebaseSecurityRulesError } from './security-rules-utils';
2426

@@ -38,6 +40,39 @@ export interface RulesetMetadata {
3840
readonly createTime: string;
3941
}
4042

43+
/**
44+
* A page of ruleset metadata.
45+
*/
46+
export interface RulesetMetadataList {
47+
readonly rulesets: RulesetMetadata[];
48+
readonly nextPageToken?: string;
49+
}
50+
51+
class RulesetMetadataListImpl implements RulesetMetadataList {
52+
53+
public readonly rulesets: RulesetMetadata[];
54+
public readonly nextPageToken?: string;
55+
56+
constructor(response: ListRulesetsResponse) {
57+
if (!validator.isNonNullObject(response) || !validator.isArray(response.rulesets)) {
58+
throw new FirebaseSecurityRulesError(
59+
'invalid-argument',
60+
`Invalid ListRulesets response: ${JSON.stringify(response)}`);
61+
}
62+
63+
this.rulesets = response.rulesets.map((rs) => {
64+
return {
65+
name: stripProjectIdPrefix(rs.name),
66+
createTime: new Date(rs.createTime).toUTCString(),
67+
};
68+
});
69+
70+
if (response.nextPageToken) {
71+
this.nextPageToken = response.nextPageToken;
72+
}
73+
}
74+
}
75+
4176
/**
4277
* Represents a set of Firebase security rules.
4378
*/
@@ -270,6 +305,21 @@ export class SecurityRules implements FirebaseServiceInterface {
270305
return this.client.deleteRuleset(name);
271306
}
272307

308+
/**
309+
* Retrieves a page of rulesets.
310+
*
311+
* @param {number=} pageSize The page size, 100 if undefined. This is also the maximum allowed limit.
312+
* @param {string=} nextPageToken The next page token. If not specified, returns rulesets starting
313+
* without any offset.
314+
* @returns {Promise<RulesetMetadataList>} A promise that fulfills a page of rulesets.
315+
*/
316+
public listRulesetMetadata(pageSize: number = 100, nextPageToken?: string): Promise<RulesetMetadataList> {
317+
return this.client.listRulesets(pageSize, nextPageToken)
318+
.then((response) => {
319+
return new RulesetMetadataListImpl(response);
320+
});
321+
}
322+
273323
private getRulesetForRelease(releaseName: string): Promise<Ruleset> {
274324
return this.client.getRelease(releaseName)
275325
.then((release) => {

test/unit/security-rules/security-rules-api-client.spec.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,134 @@ describe('SecurityRulesApiClient', () => {
246246
});
247247
});
248248

249+
describe('listRulesets', () => {
250+
const LIST_RESPONSE = {
251+
rulesets: [
252+
{
253+
name: 'rs1',
254+
createTime: 'date1',
255+
},
256+
],
257+
nextPageToken: 'next',
258+
};
259+
260+
const invalidPageSizes: any[] = [null, '', '10', true, {}, []];
261+
invalidPageSizes.forEach((invalidPageSize) => {
262+
it(`should reject when called with invalid page size: ${JSON.stringify(invalidPageSize)}`, () => {
263+
return apiClient.listRulesets(invalidPageSize)
264+
.should.eventually.be.rejected.and.have.property(
265+
'message', 'Invalid page size.');
266+
});
267+
});
268+
269+
const outOfRangePageSizes: number[] = [-1, 0, 101];
270+
outOfRangePageSizes.forEach((invalidPageSize) => {
271+
it(`should reject when called with invalid page size: ${invalidPageSize}`, () => {
272+
return apiClient.listRulesets(invalidPageSize)
273+
.should.eventually.be.rejected.and.have.property(
274+
'message', 'Page size must be between 1 and 100.');
275+
});
276+
});
277+
278+
const invalidPageTokens: any[] = [null, 0, '', true, {}, []];
279+
invalidPageTokens.forEach((invalidPageToken) => {
280+
it(`should reject when called with invalid page token: ${JSON.stringify(invalidPageToken)}`, () => {
281+
return apiClient.listRulesets(10, invalidPageToken)
282+
.should.eventually.be.rejected.and.have.property(
283+
'message', 'Next page token must be a non-empty string.');
284+
});
285+
});
286+
287+
it('should resolve on success when called without any arguments', () => {
288+
const stub = sinon
289+
.stub(HttpClient.prototype, 'send')
290+
.resolves(utils.responseFrom(LIST_RESPONSE));
291+
stubs.push(stub);
292+
return apiClient.listRulesets()
293+
.then((resp) => {
294+
expect(resp).to.deep.equal(LIST_RESPONSE);
295+
expect(stub).to.have.been.calledOnce.and.calledWith({
296+
method: 'GET',
297+
url: 'https://firebaserules.googleapis.com/v1/projects/test-project/rulesets',
298+
data: {pageSize: 100},
299+
});
300+
});
301+
});
302+
303+
it('should resolve on success when called with a page size', () => {
304+
const stub = sinon
305+
.stub(HttpClient.prototype, 'send')
306+
.resolves(utils.responseFrom(LIST_RESPONSE));
307+
stubs.push(stub);
308+
return apiClient.listRulesets(50)
309+
.then((resp) => {
310+
expect(resp).to.deep.equal(LIST_RESPONSE);
311+
expect(stub).to.have.been.calledOnce.and.calledWith({
312+
method: 'GET',
313+
url: 'https://firebaserules.googleapis.com/v1/projects/test-project/rulesets',
314+
data: {pageSize: 50},
315+
});
316+
});
317+
});
318+
319+
it('should resolve on success when called with a page token', () => {
320+
const stub = sinon
321+
.stub(HttpClient.prototype, 'send')
322+
.resolves(utils.responseFrom(LIST_RESPONSE));
323+
stubs.push(stub);
324+
return apiClient.listRulesets(50, 'next')
325+
.then((resp) => {
326+
expect(resp).to.deep.equal(LIST_RESPONSE);
327+
expect(stub).to.have.been.calledOnce.and.calledWith({
328+
method: 'GET',
329+
url: 'https://firebaserules.googleapis.com/v1/projects/test-project/rulesets',
330+
data: {pageSize: 50, pageToken: 'next'},
331+
});
332+
});
333+
});
334+
335+
it('should throw when a full platform error response is received', () => {
336+
const stub = sinon
337+
.stub(HttpClient.prototype, 'send')
338+
.rejects(utils.errorFrom(ERROR_RESPONSE, 404));
339+
stubs.push(stub);
340+
const expected = new FirebaseSecurityRulesError('not-found', 'Requested entity not found');
341+
return apiClient.listRulesets()
342+
.should.eventually.be.rejected.and.deep.equal(expected);
343+
});
344+
345+
it('should throw unknown-error when error code is not present', () => {
346+
const stub = sinon
347+
.stub(HttpClient.prototype, 'send')
348+
.rejects(utils.errorFrom({}, 404));
349+
stubs.push(stub);
350+
const expected = new FirebaseSecurityRulesError('unknown-error', 'Unknown server error: {}');
351+
return apiClient.listRulesets()
352+
.should.eventually.be.rejected.and.deep.equal(expected);
353+
});
354+
355+
it('should throw unknown-error for non-json response', () => {
356+
const stub = sinon
357+
.stub(HttpClient.prototype, 'send')
358+
.rejects(utils.errorFrom('not json', 404));
359+
stubs.push(stub);
360+
const expected = new FirebaseSecurityRulesError(
361+
'unknown-error', 'Unexpected response with status: 404 and body: not json');
362+
return apiClient.listRulesets()
363+
.should.eventually.be.rejected.and.deep.equal(expected);
364+
});
365+
366+
it('should throw when rejected with a FirebaseAppError', () => {
367+
const expected = new FirebaseAppError('network-error', 'socket hang up');
368+
const stub = sinon
369+
.stub(HttpClient.prototype, 'send')
370+
.rejects(expected);
371+
stubs.push(stub);
372+
return apiClient.listRulesets()
373+
.should.eventually.be.rejected.and.deep.equal(expected);
374+
});
375+
});
376+
249377
describe('getRelease', () => {
250378
it('should resolve with the requested release on success', () => {
251379
const stub = sinon

test/unit/security-rules/security-rules.spec.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -730,4 +730,133 @@ describe('SecurityRules', () => {
730730
return securityRules.deleteRuleset('foo');
731731
});
732732
});
733+
734+
describe('listRulesetMetadata', () => {
735+
const LIST_RULESETS_RESPONSE = {
736+
rulesets: [
737+
{
738+
name: 'projects/test-project/rulesets/rs1',
739+
createTime: '2019-03-08T23:45:23.288047Z',
740+
},
741+
{
742+
name: 'projects/test-project/rulesets/rs2',
743+
createTime: '2019-03-08T23:45:23.288047Z',
744+
},
745+
],
746+
nextPageToken: 'next',
747+
};
748+
749+
it('should propagate API errors', () => {
750+
const stub = sinon
751+
.stub(SecurityRulesApiClient.prototype, 'listRulesets')
752+
.rejects(EXPECTED_ERROR);
753+
stubs.push(stub);
754+
return securityRules.listRulesetMetadata()
755+
.should.eventually.be.rejected.and.deep.equal(EXPECTED_ERROR);
756+
});
757+
758+
it('should reject when API response is invalid', () => {
759+
const stub = sinon
760+
.stub(SecurityRulesApiClient.prototype, 'listRulesets')
761+
.resolves(null);
762+
stubs.push(stub);
763+
return securityRules.listRulesetMetadata()
764+
.should.eventually.be.rejected.and.have.property(
765+
'message', 'Invalid ListRulesets response: null');
766+
});
767+
768+
it('should reject when API response does not contain rulesets', () => {
769+
const response: any = deepCopy(LIST_RULESETS_RESPONSE);
770+
response.rulesets = '';
771+
const stub = sinon
772+
.stub(SecurityRulesApiClient.prototype, 'listRulesets')
773+
.resolves(response);
774+
stubs.push(stub);
775+
return securityRules.listRulesetMetadata()
776+
.should.eventually.be.rejected.and.have.property(
777+
'message', `Invalid ListRulesets response: ${JSON.stringify(response)}`);
778+
});
779+
780+
it('should resolve with RulesetMetadataList on success', () => {
781+
const stub = sinon
782+
.stub(SecurityRulesApiClient.prototype, 'listRulesets')
783+
.resolves(LIST_RULESETS_RESPONSE);
784+
stubs.push(stub);
785+
786+
return securityRules.listRulesetMetadata()
787+
.then((result) => {
788+
expect(result.rulesets.length).equals(2);
789+
expect(result.rulesets[0].name).equals('rs1');
790+
expect(result.rulesets[0].createTime).equals(CREATE_TIME_UTC);
791+
expect(result.rulesets[1].name).equals('rs2');
792+
expect(result.rulesets[1].createTime).equals(CREATE_TIME_UTC);
793+
794+
expect(result.nextPageToken).equals('next');
795+
796+
expect(stub).to.have.been.calledOnce.and.calledWith(100);
797+
});
798+
});
799+
800+
it('should resolve with RulesetMetadataList on success when called with page size', () => {
801+
const stub = sinon
802+
.stub(SecurityRulesApiClient.prototype, 'listRulesets')
803+
.resolves(LIST_RULESETS_RESPONSE);
804+
stubs.push(stub);
805+
806+
return securityRules.listRulesetMetadata(10)
807+
.then((result) => {
808+
expect(result.rulesets.length).equals(2);
809+
expect(result.rulesets[0].name).equals('rs1');
810+
expect(result.rulesets[0].createTime).equals(CREATE_TIME_UTC);
811+
expect(result.rulesets[1].name).equals('rs2');
812+
expect(result.rulesets[1].createTime).equals(CREATE_TIME_UTC);
813+
814+
expect(result.nextPageToken).equals('next');
815+
816+
expect(stub).to.have.been.calledOnce.and.calledWith(10);
817+
});
818+
});
819+
820+
it('should resolve with RulesetMetadataList on success when called with page token', () => {
821+
const stub = sinon
822+
.stub(SecurityRulesApiClient.prototype, 'listRulesets')
823+
.resolves(LIST_RULESETS_RESPONSE);
824+
stubs.push(stub);
825+
826+
return securityRules.listRulesetMetadata(10, 'next')
827+
.then((result) => {
828+
expect(result.rulesets.length).equals(2);
829+
expect(result.rulesets[0].name).equals('rs1');
830+
expect(result.rulesets[0].createTime).equals(CREATE_TIME_UTC);
831+
expect(result.rulesets[1].name).equals('rs2');
832+
expect(result.rulesets[1].createTime).equals(CREATE_TIME_UTC);
833+
834+
expect(result.nextPageToken).equals('next');
835+
836+
expect(stub).to.have.been.calledOnce.and.calledWith(10, 'next');
837+
});
838+
});
839+
840+
it('should resolve with RulesetMetadataList when the response contains no page token', () => {
841+
const response = deepCopy(LIST_RULESETS_RESPONSE);
842+
delete response.nextPageToken;
843+
const stub = sinon
844+
.stub(SecurityRulesApiClient.prototype, 'listRulesets')
845+
.resolves(response);
846+
stubs.push(stub);
847+
848+
return securityRules.listRulesetMetadata(10, 'next')
849+
.then((result) => {
850+
expect(result.rulesets.length).equals(2);
851+
expect(result.rulesets[0].name).equals('rs1');
852+
expect(result.rulesets[0].createTime).equals(CREATE_TIME_UTC);
853+
expect(result.rulesets[1].name).equals('rs2');
854+
expect(result.rulesets[1].createTime).equals(CREATE_TIME_UTC);
855+
856+
expect(result.nextPageToken).to.be.undefined;
857+
858+
expect(stub).to.have.been.calledOnce.and.calledWith(10, 'next');
859+
});
860+
});
861+
});
733862
});

0 commit comments

Comments
 (0)