Skip to content

Implementing the remaining releaseRuleset APIs #616

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Aug 16, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 66 additions & 2 deletions src/security-rules/security-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Ruleset>} A promise that fulfills when the ruleset is created and released.
*/
public releaseFirestoreRulesetFromSource(source: string | Buffer): Promise<Ruleset> {
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<void>} A promise that fulfills when the ruleset is released.
*/
Expand All @@ -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<Ruleset>} A promise that fulfills with the Cloud Storage Ruleset.
*/
Expand All @@ -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<Ruleset>} A promise that fulfills when the ruleset is created and released.
*/
public releaseStorageRulesetFromSource(source: string | Buffer, bucket?: string): Promise<Ruleset> {
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<void>} A promise that fulfills when the ruleset is released.
*/
public releaseStorageRuleset(ruleset: string | RulesetMetadata, bucket?: string): Promise<void> {
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.
Expand Down
258 changes: 242 additions & 16 deletions test/unit/security-rules/security-rules.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) => {
Expand Down Expand Up @@ -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);
});
});

Expand Down Expand Up @@ -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);
});
});

Expand Down Expand Up @@ -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, {}, []];

Expand Down