diff --git a/src/security-rules/security-rules.ts b/src/security-rules/security-rules.ts index e7c1a78545..85686e22d1 100644 --- a/src/security-rules/security-rules.ts +++ b/src/security-rules/security-rules.ts @@ -116,10 +116,30 @@ export class SecurityRules implements FirebaseServiceInterface { return this.getRulesetForRelease(SecurityRules.CLOUD_FIRESTORE); } + /** + * Creates a new ruleset from the given source, and applies it to Cloud Firestore. + * + * @param {string|Buffer} source Rules source to apply. + * @returns {Promise} A promise that fulfills when the ruleset is created and released. + */ + public releaseFirestoreRulesetFromSource(source: string | Buffer): Promise { + return Promise.resolve() + .then(() => { + const rulesFile = this.createRulesFileFromSource('firestore.rules', source); + return this.createRuleset(rulesFile); + }) + .then((ruleset) => { + return this.releaseFirestoreRuleset(ruleset) + .then(() => { + return ruleset; + }); + }); + } + /** * Makes the specified ruleset the currently applied ruleset for Cloud Firestore. * - * @param {string|RulesetMetadata} ruleset Name of the ruleset to release or a RulesetMetadata object containing + * @param {string|RulesetMetadata} ruleset Name of the ruleset to apply or a RulesetMetadata object containing * the name. * @returns {Promise} A promise that fulfills when the ruleset is released. */ @@ -131,7 +151,7 @@ export class SecurityRules implements FirebaseServiceInterface { * Gets the Ruleset currently applied to a Cloud Storage bucket. Rejects with a `not-found` error if no Ruleset is * applied on the bucket. * - * @param {string=} bucket Optional name of the Cloud Storage bucket to be retrieved. If name is not specified, + * @param {string=} bucket Optional name of the Cloud Storage bucket to be retrieved. If not specified, * retrieves the ruleset applied on the default bucket configured via `AppOptions`. * @returns {Promise} A promise that fulfills with the Cloud Storage Ruleset. */ @@ -145,6 +165,50 @@ export class SecurityRules implements FirebaseServiceInterface { }); } + /** + * Creates a new ruleset from the given source, and applies it to a Cloud Storage bucket. + * + * @param {string|Buffer} source Rules source to apply. + * @param {string=} bucket Optional name of the Cloud Storage bucket to apply the rules on. If not specified, + * applies the ruleset on the default bucket configured via `AppOptions`. + * @returns {Promise} A promise that fulfills when the ruleset is created and released. + */ + public releaseStorageRulesetFromSource(source: string | Buffer, bucket?: string): Promise { + return Promise.resolve() + .then(() => { + // Bucket name is not required until the last step. But since there's a createRuleset step + // before then, make sure to run this check and fail early if the bucket name is invalid. + this.getBucketName(bucket); + const rulesFile = this.createRulesFileFromSource('storage.rules', source); + return this.createRuleset(rulesFile); + }) + .then((ruleset) => { + return this.releaseStorageRuleset(ruleset, bucket) + .then(() => { + return ruleset; + }); + }); + } + + /** + * Makes the specified ruleset the currently applied ruleset for a Cloud Storage bucket. + * + * @param {string|RulesetMetadata} ruleset Name of the ruleset to apply or a RulesetMetadata object containing + * the name. + * @param {string=} bucket Optional name of the Cloud Storage bucket to apply the rules on. If not specified, + * applies the ruleset on the default bucket configured via `AppOptions`. + * @returns {Promise} A promise that fulfills when the ruleset is released. + */ + public releaseStorageRuleset(ruleset: string | RulesetMetadata, bucket?: string): Promise { + return Promise.resolve() + .then(() => { + return this.getBucketName(bucket); + }) + .then((bucketName) => { + return this.releaseRuleset(ruleset, `${SecurityRules.FIREBASE_STORAGE}/${bucketName}`); + }); + } + /** * Creates a `RulesFile` with the given name and source. Throws if any of the arguments are invalid. This is a * local operation, and does not involve any network API calls. diff --git a/test/unit/security-rules/security-rules.spec.ts b/test/unit/security-rules/security-rules.spec.ts index f3d024f8d3..df76c1c9b6 100644 --- a/test/unit/security-rules/security-rules.spec.ts +++ b/test/unit/security-rules/security-rules.spec.ts @@ -45,6 +45,24 @@ describe('SecurityRules', () => { }; const CREATE_TIME_UTC = 'Fri, 08 Mar 2019 23:45:23 GMT'; + const INVALID_RULESET_ERROR = new FirebaseSecurityRulesError( + 'invalid-argument', + 'ruleset must be a non-empty name or a RulesetMetadata object.', + ); + const INVALID_RULESETS: any[] = [null, undefined, '', 1, true, {}, [], {name: ''}]; + + const INVALID_BUCKET_ERROR = new FirebaseSecurityRulesError( + 'invalid-argument', + 'Bucket name not specified or invalid. Specify a default bucket name via the ' + + 'storageBucket option when initializing the app, or specify the bucket name ' + + 'explicitly when calling the rules API.', + ); + const INVALID_BUCKET_NAMES: any[] = [null, '', true, false, 1, 0, {}, []]; + + const INVALID_SOURCES: any[] = [null, undefined, '', 1, true, {}, []]; + const INVALID_SOURCE_ERROR = new FirebaseSecurityRulesError( + 'invalid-argument', 'Source must be a non-empty string or a Buffer.'); + let securityRules: SecurityRules; let mockApp: FirebaseApp; let mockCredentialApp: FirebaseApp; @@ -67,6 +85,19 @@ describe('SecurityRules', () => { stubs = []; }); + function stubReleaseFromSource(): [sinon.SinonStub, sinon.SinonStub] { + const createRuleset = sinon + .stub(SecurityRulesApiClient.prototype, 'createRuleset') + .resolves(FIRESTORE_RULESET_RESPONSE); + const updateRelease = sinon + .stub(SecurityRulesApiClient.prototype, 'updateRelease') + .resolves({ + rulesetName: 'projects/test-project/rulesets/foo', + }); + stubs.push(createRuleset, updateRelease); + return [createRuleset, updateRelease]; + } + describe('Constructor', () => { const invalidApps = [null, NaN, 0, 1, true, false, '', 'a', [], [1, 'a'], {}, { a: 1 }, _.noop]; invalidApps.forEach((invalidApp) => { @@ -239,17 +270,10 @@ describe('SecurityRules', () => { }); describe('getStorageRuleset', () => { - const invalidBucketNames: any[] = [null, '', true, false, 1, 0, {}, []]; - const invalidBucketError = new FirebaseSecurityRulesError( - 'invalid-argument', - 'Bucket name not specified or invalid. Specify a default bucket name via the ' + - 'storageBucket option when initializing the app, or specify the bucket name ' + - 'explicitly when calling the rules API.', - ); - invalidBucketNames.forEach((bucketName) => { + INVALID_BUCKET_NAMES.forEach((bucketName) => { it(`should reject when called with: ${JSON.stringify(bucketName)}`, () => { return securityRules.getStorageRuleset(bucketName) - .should.eventually.be.rejected.and.deep.equal(invalidBucketError); + .should.eventually.be.rejected.and.deep.equal(INVALID_BUCKET_ERROR); }); }); @@ -327,15 +351,10 @@ describe('SecurityRules', () => { }); describe('releaseFirestoreRuleset', () => { - const invalidRulesetError = new FirebaseSecurityRulesError( - 'invalid-argument', - 'ruleset must be a non-empty name or a RulesetMetadata object.', - ); - const invalidRulesets: any[] = [null, undefined, '', 1, true, {}, [], {name: ''}]; - invalidRulesets.forEach((invalidRuleset) => { + INVALID_RULESETS.forEach((invalidRuleset) => { it(`should reject when called with: ${JSON.stringify(invalidRuleset)}`, () => { return securityRules.releaseFirestoreRuleset(invalidRuleset) - .should.eventually.be.rejected.and.deep.equal(invalidRulesetError); + .should.eventually.be.rejected.and.deep.equal(INVALID_RULESET_ERROR); }); }); @@ -377,6 +396,213 @@ describe('SecurityRules', () => { }); }); + describe('releaseFirestoreRulesetFromSource', () => { + const RULES_FILE = { + name: 'firestore.rules', + content: 'test source {}', + }; + + INVALID_SOURCES.forEach((invalidSource) => { + it(`should reject when called with: ${JSON.stringify(invalidSource)}`, () => { + return securityRules.releaseFirestoreRulesetFromSource(invalidSource) + .should.eventually.be.rejected.and.deep.equal(INVALID_SOURCE_ERROR); + }); + }); + + it('should propagate API errors', () => { + const stub = sinon + .stub(SecurityRulesApiClient.prototype, 'createRuleset') + .rejects(EXPECTED_ERROR); + stubs.push(stub); + return securityRules.releaseFirestoreRulesetFromSource('foo') + .should.eventually.be.rejected.and.deep.equal(EXPECTED_ERROR); + }); + + const sources: {[key: string]: string | Buffer} = { + string: RULES_FILE.content, + buffer: Buffer.from(RULES_FILE.content), + }; + Object.keys(sources).forEach((key) => { + it(`should resolve on success when source specified as a ${key}`, () => { + const [createRuleset, updateRelease] = stubReleaseFromSource(); + + return securityRules.releaseFirestoreRulesetFromSource(sources[key]) + .then((ruleset) => { + expect(ruleset.name).to.equal('foo'); + expect(ruleset.createTime).to.equal(CREATE_TIME_UTC); + expect(ruleset.source.length).to.equal(1); + + const file = ruleset.source[0]; + expect(file.name).equals('firestore.rules'); + expect(file.content).equals('service cloud.firestore{\n}\n'); + + const request: RulesetContent = { + source: { + files: [ + RULES_FILE, + ], + }, + }; + expect(createRuleset).to.have.been.called.calledOnce.and.calledWith(request); + expect(updateRelease).to.have.been.calledOnce.and.calledWith('cloud.firestore', ruleset.name); + }); + }); + }); + }); + + describe('releaseStorageRuleset', () => { + INVALID_RULESETS.forEach((invalidRuleset) => { + it(`should reject when called with: ${JSON.stringify(invalidRuleset)}`, () => { + return securityRules.releaseStorageRuleset(invalidRuleset) + .should.eventually.be.rejected.and.deep.equal(INVALID_RULESET_ERROR); + }); + }); + + INVALID_BUCKET_NAMES.forEach((bucketName) => { + it(`should reject when called with: ${JSON.stringify(bucketName)}`, () => { + return securityRules.releaseStorageRuleset('foo', bucketName) + .should.eventually.be.rejected.and.deep.equal(INVALID_BUCKET_ERROR); + }); + }); + + it('should propagate API errors', () => { + const stub = sinon + .stub(SecurityRulesApiClient.prototype, 'updateRelease') + .rejects(EXPECTED_ERROR); + stubs.push(stub); + return securityRules.releaseStorageRuleset('foo') + .should.eventually.be.rejected.and.deep.equal(EXPECTED_ERROR); + }); + + it('should resolve on success when the ruleset specified by name', () => { + const stub = sinon + .stub(SecurityRulesApiClient.prototype, 'updateRelease') + .resolves({ + rulesetName: 'projects/test-project/rulesets/foo', + }); + stubs.push(stub); + + return securityRules.releaseStorageRuleset('foo') + .then(() => { + expect(stub).to.have.been.calledOnce.and.calledWith( + 'firebase.storage/bucketName.appspot.com', 'foo'); + }); + }); + + it('should resolve on success when a custom bucket name is specified', () => { + const stub = sinon + .stub(SecurityRulesApiClient.prototype, 'updateRelease') + .resolves({ + rulesetName: 'projects/test-project/rulesets/foo', + }); + stubs.push(stub); + + return securityRules.releaseStorageRuleset('foo', 'other.appspot.com') + .then(() => { + expect(stub).to.have.been.calledOnce.and.calledWith( + 'firebase.storage/other.appspot.com', 'foo'); + }); + }); + + it('should resolve on success when the ruleset specified as an object', () => { + const stub = sinon + .stub(SecurityRulesApiClient.prototype, 'updateRelease') + .resolves({ + rulesetName: 'projects/test-project/rulesets/foo', + }); + stubs.push(stub); + + return securityRules.releaseStorageRuleset({name: 'foo', createTime: 'time'}) + .then(() => { + expect(stub).to.have.been.calledOnce.and.calledWith( + 'firebase.storage/bucketName.appspot.com', 'foo'); + }); + }); + }); + + describe('releaseStorageRulesetFromSource', () => { + const RULES_FILE = { + name: 'storage.rules', + content: 'test source {}', + }; + const RULES_CONTENT: RulesetContent = { + source: { + files: [ + RULES_FILE, + ], + }, + }; + + INVALID_SOURCES.forEach((invalidSource) => { + it(`should reject when called with source: ${JSON.stringify(invalidSource)}`, () => { + return securityRules.releaseStorageRulesetFromSource(invalidSource) + .should.eventually.be.rejected.and.deep.equal(INVALID_SOURCE_ERROR); + }); + }); + + INVALID_BUCKET_NAMES.forEach((invalidBucket) => { + it(`should reject when called with bucket: ${JSON.stringify(invalidBucket)}`, () => { + return securityRules.releaseStorageRulesetFromSource(RULES_FILE.content, invalidBucket) + .should.eventually.be.rejected.and.deep.equal(INVALID_BUCKET_ERROR); + }); + }); + + it('should propagate API errors', () => { + const stub = sinon + .stub(SecurityRulesApiClient.prototype, 'createRuleset') + .rejects(EXPECTED_ERROR); + stubs.push(stub); + return securityRules.releaseStorageRulesetFromSource('foo') + .should.eventually.be.rejected.and.deep.equal(EXPECTED_ERROR); + }); + + const sources: {[key: string]: string | Buffer} = { + string: RULES_FILE.content, + buffer: Buffer.from(RULES_FILE.content), + }; + Object.keys(sources).forEach((key) => { + it(`should resolve on success when source specified as a ${key} for default bucket`, () => { + const [createRuleset, updateRelease] = stubReleaseFromSource(); + + return securityRules.releaseStorageRulesetFromSource(sources[key]) + .then((ruleset) => { + expect(ruleset.name).to.equal('foo'); + expect(ruleset.createTime).to.equal(CREATE_TIME_UTC); + expect(ruleset.source.length).to.equal(1); + + const file = ruleset.source[0]; + expect(file.name).equals('firestore.rules'); + expect(file.content).equals('service cloud.firestore{\n}\n'); + + expect(createRuleset).to.have.been.called.calledOnce.and.calledWith(RULES_CONTENT); + expect(updateRelease).to.have.been.calledOnce.and.calledWith( + 'firebase.storage/bucketName.appspot.com', ruleset.name); + }); + }); + }); + + Object.keys(sources).forEach((key) => { + it(`should resolve on success when source specified as a ${key} for a custom bucket`, () => { + const [createRuleset, updateRelease] = stubReleaseFromSource(); + + return securityRules.releaseStorageRulesetFromSource(sources[key], 'other.appspot.com') + .then((ruleset) => { + expect(ruleset.name).to.equal('foo'); + expect(ruleset.createTime).to.equal(CREATE_TIME_UTC); + expect(ruleset.source.length).to.equal(1); + + const file = ruleset.source[0]; + expect(file.name).equals('firestore.rules'); + expect(file.content).equals('service cloud.firestore{\n}\n'); + + expect(createRuleset).to.have.been.called.calledOnce.and.calledWith(RULES_CONTENT); + expect(updateRelease).to.have.been.calledOnce.and.calledWith( + 'firebase.storage/other.appspot.com', ruleset.name); + }); + }); + }); + }); + describe('createRulesFileFromSource', () => { const INVALID_STRINGS: any[] = [null, undefined, '', 1, true, {}, []];