Skip to content

Commit 6042333

Browse files
authored
Implementing the remaining releaseRuleset APIs (#616)
1 parent 77a26fb commit 6042333

File tree

2 files changed

+308
-18
lines changed

2 files changed

+308
-18
lines changed

src/security-rules/security-rules.ts

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,10 +116,30 @@ export class SecurityRules implements FirebaseServiceInterface {
116116
return this.getRulesetForRelease(SecurityRules.CLOUD_FIRESTORE);
117117
}
118118

119+
/**
120+
* Creates a new ruleset from the given source, and applies it to Cloud Firestore.
121+
*
122+
* @param {string|Buffer} source Rules source to apply.
123+
* @returns {Promise<Ruleset>} A promise that fulfills when the ruleset is created and released.
124+
*/
125+
public releaseFirestoreRulesetFromSource(source: string | Buffer): Promise<Ruleset> {
126+
return Promise.resolve()
127+
.then(() => {
128+
const rulesFile = this.createRulesFileFromSource('firestore.rules', source);
129+
return this.createRuleset(rulesFile);
130+
})
131+
.then((ruleset) => {
132+
return this.releaseFirestoreRuleset(ruleset)
133+
.then(() => {
134+
return ruleset;
135+
});
136+
});
137+
}
138+
119139
/**
120140
* Makes the specified ruleset the currently applied ruleset for Cloud Firestore.
121141
*
122-
* @param {string|RulesetMetadata} ruleset Name of the ruleset to release or a RulesetMetadata object containing
142+
* @param {string|RulesetMetadata} ruleset Name of the ruleset to apply or a RulesetMetadata object containing
123143
* the name.
124144
* @returns {Promise<void>} A promise that fulfills when the ruleset is released.
125145
*/
@@ -131,7 +151,7 @@ export class SecurityRules implements FirebaseServiceInterface {
131151
* Gets the Ruleset currently applied to a Cloud Storage bucket. Rejects with a `not-found` error if no Ruleset is
132152
* applied on the bucket.
133153
*
134-
* @param {string=} bucket Optional name of the Cloud Storage bucket to be retrieved. If name is not specified,
154+
* @param {string=} bucket Optional name of the Cloud Storage bucket to be retrieved. If not specified,
135155
* retrieves the ruleset applied on the default bucket configured via `AppOptions`.
136156
* @returns {Promise<Ruleset>} A promise that fulfills with the Cloud Storage Ruleset.
137157
*/
@@ -145,6 +165,50 @@ export class SecurityRules implements FirebaseServiceInterface {
145165
});
146166
}
147167

168+
/**
169+
* Creates a new ruleset from the given source, and applies it to a Cloud Storage bucket.
170+
*
171+
* @param {string|Buffer} source Rules source to apply.
172+
* @param {string=} bucket Optional name of the Cloud Storage bucket to apply the rules on. If not specified,
173+
* applies the ruleset on the default bucket configured via `AppOptions`.
174+
* @returns {Promise<Ruleset>} A promise that fulfills when the ruleset is created and released.
175+
*/
176+
public releaseStorageRulesetFromSource(source: string | Buffer, bucket?: string): Promise<Ruleset> {
177+
return Promise.resolve()
178+
.then(() => {
179+
// Bucket name is not required until the last step. But since there's a createRuleset step
180+
// before then, make sure to run this check and fail early if the bucket name is invalid.
181+
this.getBucketName(bucket);
182+
const rulesFile = this.createRulesFileFromSource('storage.rules', source);
183+
return this.createRuleset(rulesFile);
184+
})
185+
.then((ruleset) => {
186+
return this.releaseStorageRuleset(ruleset, bucket)
187+
.then(() => {
188+
return ruleset;
189+
});
190+
});
191+
}
192+
193+
/**
194+
* Makes the specified ruleset the currently applied ruleset for a Cloud Storage bucket.
195+
*
196+
* @param {string|RulesetMetadata} ruleset Name of the ruleset to apply or a RulesetMetadata object containing
197+
* the name.
198+
* @param {string=} bucket Optional name of the Cloud Storage bucket to apply the rules on. If not specified,
199+
* applies the ruleset on the default bucket configured via `AppOptions`.
200+
* @returns {Promise<void>} A promise that fulfills when the ruleset is released.
201+
*/
202+
public releaseStorageRuleset(ruleset: string | RulesetMetadata, bucket?: string): Promise<void> {
203+
return Promise.resolve()
204+
.then(() => {
205+
return this.getBucketName(bucket);
206+
})
207+
.then((bucketName) => {
208+
return this.releaseRuleset(ruleset, `${SecurityRules.FIREBASE_STORAGE}/${bucketName}`);
209+
});
210+
}
211+
148212
/**
149213
* Creates a `RulesFile` with the given name and source. Throws if any of the arguments are invalid. This is a
150214
* local operation, and does not involve any network API calls.

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

Lines changed: 242 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,24 @@ describe('SecurityRules', () => {
4545
};
4646
const CREATE_TIME_UTC = 'Fri, 08 Mar 2019 23:45:23 GMT';
4747

48+
const INVALID_RULESET_ERROR = new FirebaseSecurityRulesError(
49+
'invalid-argument',
50+
'ruleset must be a non-empty name or a RulesetMetadata object.',
51+
);
52+
const INVALID_RULESETS: any[] = [null, undefined, '', 1, true, {}, [], {name: ''}];
53+
54+
const INVALID_BUCKET_ERROR = new FirebaseSecurityRulesError(
55+
'invalid-argument',
56+
'Bucket name not specified or invalid. Specify a default bucket name via the ' +
57+
'storageBucket option when initializing the app, or specify the bucket name ' +
58+
'explicitly when calling the rules API.',
59+
);
60+
const INVALID_BUCKET_NAMES: any[] = [null, '', true, false, 1, 0, {}, []];
61+
62+
const INVALID_SOURCES: any[] = [null, undefined, '', 1, true, {}, []];
63+
const INVALID_SOURCE_ERROR = new FirebaseSecurityRulesError(
64+
'invalid-argument', 'Source must be a non-empty string or a Buffer.');
65+
4866
let securityRules: SecurityRules;
4967
let mockApp: FirebaseApp;
5068
let mockCredentialApp: FirebaseApp;
@@ -67,6 +85,19 @@ describe('SecurityRules', () => {
6785
stubs = [];
6886
});
6987

88+
function stubReleaseFromSource(): [sinon.SinonStub, sinon.SinonStub] {
89+
const createRuleset = sinon
90+
.stub(SecurityRulesApiClient.prototype, 'createRuleset')
91+
.resolves(FIRESTORE_RULESET_RESPONSE);
92+
const updateRelease = sinon
93+
.stub(SecurityRulesApiClient.prototype, 'updateRelease')
94+
.resolves({
95+
rulesetName: 'projects/test-project/rulesets/foo',
96+
});
97+
stubs.push(createRuleset, updateRelease);
98+
return [createRuleset, updateRelease];
99+
}
100+
70101
describe('Constructor', () => {
71102
const invalidApps = [null, NaN, 0, 1, true, false, '', 'a', [], [1, 'a'], {}, { a: 1 }, _.noop];
72103
invalidApps.forEach((invalidApp) => {
@@ -239,17 +270,10 @@ describe('SecurityRules', () => {
239270
});
240271

241272
describe('getStorageRuleset', () => {
242-
const invalidBucketNames: any[] = [null, '', true, false, 1, 0, {}, []];
243-
const invalidBucketError = new FirebaseSecurityRulesError(
244-
'invalid-argument',
245-
'Bucket name not specified or invalid. Specify a default bucket name via the ' +
246-
'storageBucket option when initializing the app, or specify the bucket name ' +
247-
'explicitly when calling the rules API.',
248-
);
249-
invalidBucketNames.forEach((bucketName) => {
273+
INVALID_BUCKET_NAMES.forEach((bucketName) => {
250274
it(`should reject when called with: ${JSON.stringify(bucketName)}`, () => {
251275
return securityRules.getStorageRuleset(bucketName)
252-
.should.eventually.be.rejected.and.deep.equal(invalidBucketError);
276+
.should.eventually.be.rejected.and.deep.equal(INVALID_BUCKET_ERROR);
253277
});
254278
});
255279

@@ -327,15 +351,10 @@ describe('SecurityRules', () => {
327351
});
328352

329353
describe('releaseFirestoreRuleset', () => {
330-
const invalidRulesetError = new FirebaseSecurityRulesError(
331-
'invalid-argument',
332-
'ruleset must be a non-empty name or a RulesetMetadata object.',
333-
);
334-
const invalidRulesets: any[] = [null, undefined, '', 1, true, {}, [], {name: ''}];
335-
invalidRulesets.forEach((invalidRuleset) => {
354+
INVALID_RULESETS.forEach((invalidRuleset) => {
336355
it(`should reject when called with: ${JSON.stringify(invalidRuleset)}`, () => {
337356
return securityRules.releaseFirestoreRuleset(invalidRuleset)
338-
.should.eventually.be.rejected.and.deep.equal(invalidRulesetError);
357+
.should.eventually.be.rejected.and.deep.equal(INVALID_RULESET_ERROR);
339358
});
340359
});
341360

@@ -377,6 +396,213 @@ describe('SecurityRules', () => {
377396
});
378397
});
379398

399+
describe('releaseFirestoreRulesetFromSource', () => {
400+
const RULES_FILE = {
401+
name: 'firestore.rules',
402+
content: 'test source {}',
403+
};
404+
405+
INVALID_SOURCES.forEach((invalidSource) => {
406+
it(`should reject when called with: ${JSON.stringify(invalidSource)}`, () => {
407+
return securityRules.releaseFirestoreRulesetFromSource(invalidSource)
408+
.should.eventually.be.rejected.and.deep.equal(INVALID_SOURCE_ERROR);
409+
});
410+
});
411+
412+
it('should propagate API errors', () => {
413+
const stub = sinon
414+
.stub(SecurityRulesApiClient.prototype, 'createRuleset')
415+
.rejects(EXPECTED_ERROR);
416+
stubs.push(stub);
417+
return securityRules.releaseFirestoreRulesetFromSource('foo')
418+
.should.eventually.be.rejected.and.deep.equal(EXPECTED_ERROR);
419+
});
420+
421+
const sources: {[key: string]: string | Buffer} = {
422+
string: RULES_FILE.content,
423+
buffer: Buffer.from(RULES_FILE.content),
424+
};
425+
Object.keys(sources).forEach((key) => {
426+
it(`should resolve on success when source specified as a ${key}`, () => {
427+
const [createRuleset, updateRelease] = stubReleaseFromSource();
428+
429+
return securityRules.releaseFirestoreRulesetFromSource(sources[key])
430+
.then((ruleset) => {
431+
expect(ruleset.name).to.equal('foo');
432+
expect(ruleset.createTime).to.equal(CREATE_TIME_UTC);
433+
expect(ruleset.source.length).to.equal(1);
434+
435+
const file = ruleset.source[0];
436+
expect(file.name).equals('firestore.rules');
437+
expect(file.content).equals('service cloud.firestore{\n}\n');
438+
439+
const request: RulesetContent = {
440+
source: {
441+
files: [
442+
RULES_FILE,
443+
],
444+
},
445+
};
446+
expect(createRuleset).to.have.been.called.calledOnce.and.calledWith(request);
447+
expect(updateRelease).to.have.been.calledOnce.and.calledWith('cloud.firestore', ruleset.name);
448+
});
449+
});
450+
});
451+
});
452+
453+
describe('releaseStorageRuleset', () => {
454+
INVALID_RULESETS.forEach((invalidRuleset) => {
455+
it(`should reject when called with: ${JSON.stringify(invalidRuleset)}`, () => {
456+
return securityRules.releaseStorageRuleset(invalidRuleset)
457+
.should.eventually.be.rejected.and.deep.equal(INVALID_RULESET_ERROR);
458+
});
459+
});
460+
461+
INVALID_BUCKET_NAMES.forEach((bucketName) => {
462+
it(`should reject when called with: ${JSON.stringify(bucketName)}`, () => {
463+
return securityRules.releaseStorageRuleset('foo', bucketName)
464+
.should.eventually.be.rejected.and.deep.equal(INVALID_BUCKET_ERROR);
465+
});
466+
});
467+
468+
it('should propagate API errors', () => {
469+
const stub = sinon
470+
.stub(SecurityRulesApiClient.prototype, 'updateRelease')
471+
.rejects(EXPECTED_ERROR);
472+
stubs.push(stub);
473+
return securityRules.releaseStorageRuleset('foo')
474+
.should.eventually.be.rejected.and.deep.equal(EXPECTED_ERROR);
475+
});
476+
477+
it('should resolve on success when the ruleset specified by name', () => {
478+
const stub = sinon
479+
.stub(SecurityRulesApiClient.prototype, 'updateRelease')
480+
.resolves({
481+
rulesetName: 'projects/test-project/rulesets/foo',
482+
});
483+
stubs.push(stub);
484+
485+
return securityRules.releaseStorageRuleset('foo')
486+
.then(() => {
487+
expect(stub).to.have.been.calledOnce.and.calledWith(
488+
'firebase.storage/bucketName.appspot.com', 'foo');
489+
});
490+
});
491+
492+
it('should resolve on success when a custom bucket name is specified', () => {
493+
const stub = sinon
494+
.stub(SecurityRulesApiClient.prototype, 'updateRelease')
495+
.resolves({
496+
rulesetName: 'projects/test-project/rulesets/foo',
497+
});
498+
stubs.push(stub);
499+
500+
return securityRules.releaseStorageRuleset('foo', 'other.appspot.com')
501+
.then(() => {
502+
expect(stub).to.have.been.calledOnce.and.calledWith(
503+
'firebase.storage/other.appspot.com', 'foo');
504+
});
505+
});
506+
507+
it('should resolve on success when the ruleset specified as an object', () => {
508+
const stub = sinon
509+
.stub(SecurityRulesApiClient.prototype, 'updateRelease')
510+
.resolves({
511+
rulesetName: 'projects/test-project/rulesets/foo',
512+
});
513+
stubs.push(stub);
514+
515+
return securityRules.releaseStorageRuleset({name: 'foo', createTime: 'time'})
516+
.then(() => {
517+
expect(stub).to.have.been.calledOnce.and.calledWith(
518+
'firebase.storage/bucketName.appspot.com', 'foo');
519+
});
520+
});
521+
});
522+
523+
describe('releaseStorageRulesetFromSource', () => {
524+
const RULES_FILE = {
525+
name: 'storage.rules',
526+
content: 'test source {}',
527+
};
528+
const RULES_CONTENT: RulesetContent = {
529+
source: {
530+
files: [
531+
RULES_FILE,
532+
],
533+
},
534+
};
535+
536+
INVALID_SOURCES.forEach((invalidSource) => {
537+
it(`should reject when called with source: ${JSON.stringify(invalidSource)}`, () => {
538+
return securityRules.releaseStorageRulesetFromSource(invalidSource)
539+
.should.eventually.be.rejected.and.deep.equal(INVALID_SOURCE_ERROR);
540+
});
541+
});
542+
543+
INVALID_BUCKET_NAMES.forEach((invalidBucket) => {
544+
it(`should reject when called with bucket: ${JSON.stringify(invalidBucket)}`, () => {
545+
return securityRules.releaseStorageRulesetFromSource(RULES_FILE.content, invalidBucket)
546+
.should.eventually.be.rejected.and.deep.equal(INVALID_BUCKET_ERROR);
547+
});
548+
});
549+
550+
it('should propagate API errors', () => {
551+
const stub = sinon
552+
.stub(SecurityRulesApiClient.prototype, 'createRuleset')
553+
.rejects(EXPECTED_ERROR);
554+
stubs.push(stub);
555+
return securityRules.releaseStorageRulesetFromSource('foo')
556+
.should.eventually.be.rejected.and.deep.equal(EXPECTED_ERROR);
557+
});
558+
559+
const sources: {[key: string]: string | Buffer} = {
560+
string: RULES_FILE.content,
561+
buffer: Buffer.from(RULES_FILE.content),
562+
};
563+
Object.keys(sources).forEach((key) => {
564+
it(`should resolve on success when source specified as a ${key} for default bucket`, () => {
565+
const [createRuleset, updateRelease] = stubReleaseFromSource();
566+
567+
return securityRules.releaseStorageRulesetFromSource(sources[key])
568+
.then((ruleset) => {
569+
expect(ruleset.name).to.equal('foo');
570+
expect(ruleset.createTime).to.equal(CREATE_TIME_UTC);
571+
expect(ruleset.source.length).to.equal(1);
572+
573+
const file = ruleset.source[0];
574+
expect(file.name).equals('firestore.rules');
575+
expect(file.content).equals('service cloud.firestore{\n}\n');
576+
577+
expect(createRuleset).to.have.been.called.calledOnce.and.calledWith(RULES_CONTENT);
578+
expect(updateRelease).to.have.been.calledOnce.and.calledWith(
579+
'firebase.storage/bucketName.appspot.com', ruleset.name);
580+
});
581+
});
582+
});
583+
584+
Object.keys(sources).forEach((key) => {
585+
it(`should resolve on success when source specified as a ${key} for a custom bucket`, () => {
586+
const [createRuleset, updateRelease] = stubReleaseFromSource();
587+
588+
return securityRules.releaseStorageRulesetFromSource(sources[key], 'other.appspot.com')
589+
.then((ruleset) => {
590+
expect(ruleset.name).to.equal('foo');
591+
expect(ruleset.createTime).to.equal(CREATE_TIME_UTC);
592+
expect(ruleset.source.length).to.equal(1);
593+
594+
const file = ruleset.source[0];
595+
expect(file.name).equals('firestore.rules');
596+
expect(file.content).equals('service cloud.firestore{\n}\n');
597+
598+
expect(createRuleset).to.have.been.called.calledOnce.and.calledWith(RULES_CONTENT);
599+
expect(updateRelease).to.have.been.calledOnce.and.calledWith(
600+
'firebase.storage/other.appspot.com', ruleset.name);
601+
});
602+
});
603+
});
604+
});
605+
380606
describe('createRulesFileFromSource', () => {
381607
const INVALID_STRINGS: any[] = [null, undefined, '', 1, true, {}, []];
382608

0 commit comments

Comments
 (0)