Skip to content

Commit d6c7af9

Browse files
authored
Implemented the API for releasing rulesets (#610)
* Implemented the API for releasing rulesets * Removed createRelease logic * Updated comment
1 parent 83b8a78 commit d6c7af9

File tree

4 files changed

+167
-6
lines changed

4 files changed

+167
-6
lines changed

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

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ const RULES_V1_API = 'https://firebaserules.googleapis.com/v1';
2424
export interface Release {
2525
readonly name: string;
2626
readonly rulesetName: string;
27-
readonly createTime: string;
28-
readonly updateTime: string;
27+
readonly createTime?: string;
28+
readonly updateTime?: string;
2929
}
3030

3131
export interface RulesetContent {
@@ -46,6 +46,7 @@ export interface RulesetResponse extends RulesetContent {
4646
*/
4747
export class SecurityRulesApiClient {
4848

49+
private readonly projectIdPrefix: string;
4950
private readonly url: string;
5051

5152
constructor(private readonly httpClient: HttpClient, projectId: string) {
@@ -62,7 +63,8 @@ export class SecurityRulesApiClient {
6263
+ 'environment variable.');
6364
}
6465

65-
this.url = `${RULES_V1_API}/projects/${projectId}`;
66+
this.projectIdPrefix = `projects/${projectId}`;
67+
this.url = `${RULES_V1_API}/${this.projectIdPrefix}`;
6668
}
6769

6870
public getRuleset(name: string): Promise<RulesetResponse> {
@@ -121,6 +123,18 @@ export class SecurityRulesApiClient {
121123
return this.getResource<Release>(`releases/${name}`);
122124
}
123125

126+
public updateRelease(name: string, rulesetName: string): Promise<Release> {
127+
const data = {
128+
release: this.getReleaseDescription(name, rulesetName),
129+
};
130+
const request: HttpRequestConfig = {
131+
method: 'PATCH',
132+
url: `${this.url}/releases/${name}`,
133+
data,
134+
};
135+
return this.sendRequest<Release>(request);
136+
}
137+
124138
/**
125139
* Gets the specified resource from the rules API. Resource names must be the short names without project
126140
* ID prefix (e.g. `rulesets/ruleset-name`).
@@ -136,6 +150,13 @@ export class SecurityRulesApiClient {
136150
return this.sendRequest<T>(request);
137151
}
138152

153+
private getReleaseDescription(name: string, rulesetName: string): Release {
154+
return {
155+
name: `${this.projectIdPrefix}/releases/${name}`,
156+
rulesetName: `${this.projectIdPrefix}/${this.getRulesetName(rulesetName)}`,
157+
};
158+
}
159+
139160
private getRulesetName(name: string): string {
140161
if (!validator.isNonEmptyString(name)) {
141162
throw new FirebaseSecurityRulesError(

src/security-rules/security-rules.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ 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 } from './security-rules-api-client';
21+
import { SecurityRulesApiClient, RulesetResponse, RulesetContent, Release } from './security-rules-api-client';
2222
import { AuthorizedHttpClient } from '../utils/api-request';
2323
import { FirebaseSecurityRulesError } from './security-rules-utils';
2424

@@ -115,6 +115,17 @@ export class SecurityRules implements FirebaseServiceInterface {
115115
return this.getRulesetForRelease(SecurityRules.CLOUD_FIRESTORE);
116116
}
117117

118+
/**
119+
* Makes the specified ruleset the currently applied ruleset for Cloud Firestore.
120+
*
121+
* @param {string|RulesetMetadata} ruleset Name of the ruleset to release or a RulesetMetadata object containing
122+
* the name.
123+
* @returns {Promise<void>} A promise that fulfills when the ruleset is released.
124+
*/
125+
public releaseFirestoreRuleset(ruleset: string | RulesetMetadata): Promise<void> {
126+
return this.releaseRuleset(ruleset, SecurityRules.CLOUD_FIRESTORE);
127+
}
128+
118129
/**
119130
* Creates a `RulesFile` with the given name and source. Throws if any of the arguments are invalid. This is a
120131
* local operation, and does not involve any network API calls.
@@ -188,6 +199,21 @@ export class SecurityRules implements FirebaseServiceInterface {
188199
return this.getRuleset(stripProjectIdPrefix(rulesetName));
189200
});
190201
}
202+
203+
private releaseRuleset(ruleset: string | RulesetMetadata, releaseName: string): Promise<void> {
204+
if (!validator.isNonEmptyString(ruleset) &&
205+
(!validator.isNonNullObject(ruleset) || !validator.isNonEmptyString(ruleset.name))) {
206+
const err = new FirebaseSecurityRulesError(
207+
'invalid-argument', 'ruleset must be a non-empty name or a RulesetMetadata object.');
208+
return Promise.reject(err);
209+
}
210+
211+
const rulesetName = validator.isString(ruleset) ? ruleset : ruleset.name;
212+
return this.client.updateRelease(releaseName, rulesetName)
213+
.then(() => {
214+
return;
215+
});
216+
}
191217
}
192218

193219
class SecurityRulesInternals implements FirebaseServiceInternalsInterface {

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

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const expect = chai.expect;
3030
describe('SecurityRulesApiClient', () => {
3131

3232
const RULESET_NAME = 'ruleset-id';
33+
const RELEASE_NAME = 'test.service';
3334
const ERROR_RESPONSE = {
3435
error: {
3536
code: 404,
@@ -246,8 +247,6 @@ describe('SecurityRulesApiClient', () => {
246247
});
247248

248249
describe('getRelease', () => {
249-
const RELEASE_NAME = 'test.service';
250-
251250
it('should resolve with the requested release on success', () => {
252251
const stub = sinon
253252
.stub(HttpClient.prototype, 'send')
@@ -305,6 +304,70 @@ describe('SecurityRulesApiClient', () => {
305304
});
306305
});
307306

307+
describe('updateRelease', () => {
308+
it('should resolve with the updated release on success', () => {
309+
const stub = sinon
310+
.stub(HttpClient.prototype, 'send')
311+
.resolves(utils.responseFrom({name: 'bar'}));
312+
stubs.push(stub);
313+
return apiClient.updateRelease(RELEASE_NAME, RULESET_NAME)
314+
.then((resp) => {
315+
expect(resp.name).to.equal('bar');
316+
expect(stub).to.have.been.calledOnce.and.calledWith({
317+
method: 'PATCH',
318+
url: 'https://firebaserules.googleapis.com/v1/projects/test-project/releases/test.service',
319+
data: {
320+
release: {
321+
name: 'projects/test-project/releases/test.service',
322+
rulesetName: 'projects/test-project/rulesets/ruleset-id',
323+
},
324+
},
325+
});
326+
});
327+
});
328+
329+
it('should throw when a full platform error response is received', () => {
330+
const stub = sinon
331+
.stub(HttpClient.prototype, 'send')
332+
.rejects(utils.errorFrom(ERROR_RESPONSE, 404));
333+
stubs.push(stub);
334+
const expected = new FirebaseSecurityRulesError('not-found', 'Requested entity not found');
335+
return apiClient.updateRelease(RELEASE_NAME, RULESET_NAME)
336+
.should.eventually.be.rejected.and.deep.equal(expected);
337+
});
338+
339+
it('should throw unknown-error when error code is not present', () => {
340+
const stub = sinon
341+
.stub(HttpClient.prototype, 'send')
342+
.rejects(utils.errorFrom({}, 404));
343+
stubs.push(stub);
344+
const expected = new FirebaseSecurityRulesError('unknown-error', 'Unknown server error: {}');
345+
return apiClient.updateRelease(RELEASE_NAME, RULESET_NAME)
346+
.should.eventually.be.rejected.and.deep.equal(expected);
347+
});
348+
349+
it('should throw unknown-error for non-json response', () => {
350+
const stub = sinon
351+
.stub(HttpClient.prototype, 'send')
352+
.rejects(utils.errorFrom('not json', 404));
353+
stubs.push(stub);
354+
const expected = new FirebaseSecurityRulesError(
355+
'unknown-error', 'Unexpected response with status: 404 and body: not json');
356+
return apiClient.updateRelease(RELEASE_NAME, RULESET_NAME)
357+
.should.eventually.be.rejected.and.deep.equal(expected);
358+
});
359+
360+
it('should throw when rejected with a FirebaseAppError', () => {
361+
const expected = new FirebaseAppError('network-error', 'socket hang up');
362+
const stub = sinon
363+
.stub(HttpClient.prototype, 'send')
364+
.rejects(expected);
365+
stubs.push(stub);
366+
return apiClient.updateRelease(RELEASE_NAME, RULESET_NAME)
367+
.should.eventually.be.rejected.and.deep.equal(expected);
368+
});
369+
});
370+
308371
describe('deleteRuleset', () => {
309372
const INVALID_NAMES: any[] = [null, undefined, '', 1, true, {}, []];
310373
INVALID_NAMES.forEach((invalidName) => {

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

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,57 @@ describe('SecurityRules', () => {
235235
});
236236
});
237237

238+
describe('releaseFirestoreRuleset', () => {
239+
const invalidRulesetError = new FirebaseSecurityRulesError(
240+
'invalid-argument',
241+
'ruleset must be a non-empty name or a RulesetMetadata object.',
242+
);
243+
const invalidRulesets: any[] = [null, undefined, '', 1, true, {}, [], {name: ''}];
244+
invalidRulesets.forEach((invalidRuleset) => {
245+
it(`should reject when called with: ${JSON.stringify(invalidRuleset)}`, () => {
246+
return securityRules.releaseFirestoreRuleset(invalidRuleset)
247+
.should.eventually.be.rejected.and.deep.equal(invalidRulesetError);
248+
});
249+
});
250+
251+
it('should propagate API errors', () => {
252+
const stub = sinon
253+
.stub(SecurityRulesApiClient.prototype, 'updateRelease')
254+
.rejects(EXPECTED_ERROR);
255+
stubs.push(stub);
256+
return securityRules.releaseFirestoreRuleset('foo')
257+
.should.eventually.be.rejected.and.deep.equal(EXPECTED_ERROR);
258+
});
259+
260+
it('should resolve on success when the ruleset specified by name', () => {
261+
const stub = sinon
262+
.stub(SecurityRulesApiClient.prototype, 'updateRelease')
263+
.resolves({
264+
rulesetName: 'projects/test-project/rulesets/foo',
265+
});
266+
stubs.push(stub);
267+
268+
return securityRules.releaseFirestoreRuleset('foo')
269+
.then(() => {
270+
expect(stub).to.have.been.calledOnce.and.calledWith('cloud.firestore', 'foo');
271+
});
272+
});
273+
274+
it('should resolve on success when the ruleset specified as an object', () => {
275+
const stub = sinon
276+
.stub(SecurityRulesApiClient.prototype, 'updateRelease')
277+
.resolves({
278+
rulesetName: 'projects/test-project/rulesets/foo',
279+
});
280+
stubs.push(stub);
281+
282+
return securityRules.releaseFirestoreRuleset({name: 'foo', createTime: 'time'})
283+
.then(() => {
284+
expect(stub).to.have.been.calledOnce.and.calledWith('cloud.firestore', 'foo');
285+
});
286+
});
287+
});
288+
238289
describe('createRulesFileFromSource', () => {
239290
const INVALID_STRINGS: any[] = [null, undefined, '', 1, true, {}, []];
240291

0 commit comments

Comments
 (0)