diff --git a/api_server/CHANGELOG.md b/api_server/CHANGELOG.md index f92c42c27..a63bdfc05 100644 --- a/api_server/CHANGELOG.md +++ b/api_server/CHANGELOG.md @@ -1,10 +1,11 @@ # Change Log -## [15.4.0] - 2024-4-4 +## [15.4.0] - 2024-4-17 ### Changed - Updates to telemetry events +- A/B test open-in-IDE redirect pages ## [15.3.0] - 2024-3-14 diff --git a/api_server/bin/api_server.js b/api_server/bin/api_server.js index a2f954d33..8c28a5882 100755 --- a/api_server/bin/api_server.js +++ b/api_server/bin/api_server.js @@ -35,7 +35,8 @@ const DataCollections = { msteams_conversations: require(ModuleDirectory + '/msteams_conversations/msteams_conversation'), msteams_states: require(ModuleDirectory + '/msteams_states/msteams_state'), msteams_teams: require(ModuleDirectory + '/msteams_teams/msteams_team'), - reposByCommitHash: require(ModuleDirectory + '/repos/repo_by_commit_hash') + reposByCommitHash: require(ModuleDirectory + '/repos/repo_by_commit_hash'), + entities: require(ModuleDirectory + '/entities/entity') }; // establish our mongo collections, these include our DataCollections, but @@ -67,7 +68,6 @@ const MongoCollections = Object.keys(DataCollections).concat([ // changes to Config will be available globally via the /config/writeable.js module const Config = await ApiConfig.loadPreferredConfig({ wait: true }); -Config.telemetry.segment.telemetryEndpoint = 'https://taxonomy-enforcer.service.newrelic.com'; // for now // establish our logger const Logger = new SimpleFileLogger(Config.apiServer.logger); diff --git a/api_server/bin/ensure-indexes.js b/api_server/bin/ensure-indexes.js index bb102fbf0..bbe239daf 100755 --- a/api_server/bin/ensure-indexes.js +++ b/api_server/bin/ensure-indexes.js @@ -32,7 +32,8 @@ const AllModuleIndexes = { msteams_teams: require(process.env.CSSVC_BACKEND_ROOT + '/api_server/modules/msteams_teams/indexes'), reposByCommitHash: require(process.env.CSSVC_BACKEND_ROOT + '/api_server/modules/repos/repo_by_commit_hash_indexes'), gitLensUsers: require(process.env.CSSVC_BACKEND_ROOT + '/api_server/modules/users/gitlens_user_indexes'), - newRelicOrgs: require(process.env.CSSVC_BACKEND_ROOT + '/api_server/modules/newrelic_comments/new_relic_org_indexes') + newRelicOrgs: require(process.env.CSSVC_BACKEND_ROOT + '/api_server/modules/newrelic_comments/new_relic_org_indexes'), + entities: require(process.env.CSSVC_BACKEND_ROOT + '/api_server/modules/entities/indexes') }; const AllFinished = { diff --git a/api_server/config/dev-secrets.json b/api_server/config/dev-secrets.json index b8b866fc9..4013b7151 100644 --- a/api_server/config/dev-secrets.json +++ b/api_server/config/dev-secrets.json @@ -47,6 +47,7 @@ "STORAGE_MONGO_URL", "TELEMETRY_SEGMENT_TOKEN", "TELEMETRY_SEGMENT_WEB_TOKEN", + "TELEMETRY_ENDPOINT", "UNIVERSAL_SECRETS_TELEMETRY", "UNIVERSAL_SECRETS_TELEMETRY", "SHARED_SECRETS_COMMENT_ENGINE", diff --git a/api_server/lib/test_base/codestream_api_test.js b/api_server/lib/test_base/codestream_api_test.js index cec9c1ca3..491fe11d1 100644 --- a/api_server/lib/test_base/codestream_api_test.js +++ b/api_server/lib/test_base/codestream_api_test.js @@ -15,6 +15,7 @@ const RandomCodemarkFactory = require(process.env.CSSVC_BACKEND_ROOT + '/api_ser const RandomReviewFactory = require(process.env.CSSVC_BACKEND_ROOT + '/api_server/modules/reviews/test/random_review_factory'); const RandomCodeErrorFactory = require(process.env.CSSVC_BACKEND_ROOT + '/api_server/modules/code_errors/test/random_code_error_factory'); const RandomNRCommentFactory = require(process.env.CSSVC_BACKEND_ROOT + '/api_server/modules/newrelic_comments/test/random_nr_comment_factory'); +const RandomEntityFactory = require(process.env.CSSVC_BACKEND_ROOT + '/api_server/modules/entities/test/random_entity_factory'); const Assert = require('assert'); const BoundAsync = require(process.env.CSSVC_BACKEND_ROOT + '/shared/server_utils/bound_async'); const TestTeamCreator = require('./test_team_creator'); @@ -75,7 +76,10 @@ class CodeStreamAPITest extends APIRequestTest { userFactory: this.userFactory, codeErrorFactory: this.codeErrorFactory }); - + this.entityFactory = new RandomEntityFactory({ + apiRequester: this + }); + this.userOptions = { numRegistered: 2, numUnregistered: 0, diff --git a/api_server/lib/util/deactivator.js b/api_server/lib/util/deactivator.js index 21c05f144..2b7151565 100644 --- a/api_server/lib/util/deactivator.js +++ b/api_server/lib/util/deactivator.js @@ -4,9 +4,10 @@ const ObjectId = require('mongodb').ObjectId; const UserIndexes = require(process.env.CSSVC_BACKEND_ROOT + '/api_server/modules/users/indexes'); const RepoIndexes = require(process.env.CSSVC_BACKEND_ROOT + '/api_server/modules/repos/indexes'); const StreamIndexes = require(process.env.CSSVC_BACKEND_ROOT + '/api_server/modules/streams/indexes'); +const EntityIndexes = require(process.env.CSSVC_BACKEND_ROOT + '/api_server/modules/entities/indexes'); -const COLLECTIONS = ['companies', 'teams', 'repos', 'users', 'streams', 'posts', 'codemarks', 'reviews', 'codeErrors', 'markers', 'markerLocations']; -const COLLECTIONS_FOR_TEAM = ['streams', 'posts', 'codemarks', 'reviews', 'codeErrors', 'markers', 'markerLocations']; +const COLLECTIONS = ['companies', 'teams', 'repos', 'users', 'streams', 'posts', 'codemarks', 'reviews', 'codeErrors', 'markers', 'markerLocations', 'entities']; +const COLLECTIONS_FOR_TEAM = ['streams', 'posts', 'codemarks', 'reviews', 'codeErrors', 'markers', 'markerLocations', 'entities']; class Deleter { diff --git a/api_server/lib/util/deleter.js b/api_server/lib/util/deleter.js index 2c8c42060..ec3205650 100644 --- a/api_server/lib/util/deleter.js +++ b/api_server/lib/util/deleter.js @@ -3,8 +3,9 @@ const ApiConfig = require(process.env.CSSVC_BACKEND_ROOT + '/api_server/config/c const ObjectId = require('mongodb').ObjectId; const UserIndexes = require(process.env.CSSVC_BACKEND_ROOT + '/api_server/modules/users/indexes'); const RepoIndexes = require(process.env.CSSVC_BACKEND_ROOT + '/api_server/modules/repos/indexes'); +const EntityIndexes = require(process.env.CSSVC_BACKEND_ROOT + '/api_server/modules/entities/indexes'); -const COLLECTIONS = ['companies', 'teams', 'repos', 'users', 'streams', 'posts', 'codemarks', 'markers', 'markerLocations']; +const COLLECTIONS = ['companies', 'teams', 'repos', 'users', 'streams', 'posts', 'codemarks', 'markers', 'markerLocations', 'entities']; class Deleter { @@ -25,6 +26,7 @@ class Deleter { await this.deleteCodemarks(); await this.deleteMarkers(); await this.deleteMarkerLocations(); + await this.deleteEntities(); } async openMongoClient () { @@ -365,6 +367,19 @@ class Deleter { throw `unable to delete marker locations: ${JSON.stringify(error)}`; } } + + async deleteEntities () { + this.logger.log(`Deleting entities in team ${this.teamId}...`); + try { + await this.mongoClient.mongoCollections.entities.deleteByQuery( + { teamId: this.teamId }, + { overrideHintRequired: true } + ); + } + catch (error) { + throw `unable to delete entities: ${JSON.stringify(error)}`; + } + } } module.exports = Deleter; diff --git a/api_server/lib/util/restful/post_request.js b/api_server/lib/util/restful/post_request.js index 1b2431886..b7e8046e3 100644 --- a/api_server/lib/util/restful/post_request.js +++ b/api_server/lib/util/restful/post_request.js @@ -25,7 +25,9 @@ class PostRequest extends RestfulRequest { // after the request has been processed and response returned to the client.... async postProcess () { - await this.creator.postCreate(); + if (this.creator) { + await this.creator.postCreate(); + } } // describe this route for help diff --git a/api_server/modules/analytics/analytics_client.js b/api_server/modules/analytics/analytics_client.js index f8eceb193..f1d3b613f 100644 --- a/api_server/modules/analytics/analytics_client.js +++ b/api_server/modules/analytics/analytics_client.js @@ -37,15 +37,15 @@ class AnalyticsClient { properties: data, messageId: UUID(), timestamp: new Date(), - type: "track" + type: "track", + anonymousId: UUID(), }; + data.session_id = data.session_id || UUID(); + const nrUserId = options.user ? options.user.get('nrUserId') : options.nrUserId; if (nrUserId) { trackData.userId = nrUserId; //userId; } - if (options.anonymousId) { - trackData.anonymousId = options.anonymousId; - } if (this._requestSaysToTestTracking(options)) { // we received a header in the request asking us to divert this tracking event @@ -59,6 +59,10 @@ class AnalyticsClient { } //this.segment.track(trackData); + if (options.request) { + options.request.log('TRACKING TO ' + this.config.telemetryEndpoint + '/events'); + options.request.log('trackData: ' + JSON.stringify(trackData)); + } Fetch( this.config.telemetryEndpoint + '/events', { diff --git a/api_server/modules/apiweb/apiweb.js b/api_server/modules/apiweb/apiweb.js index e4f79b787..d6feca9e5 100644 --- a/api_server/modules/apiweb/apiweb.js +++ b/api_server/modules/apiweb/apiweb.js @@ -102,6 +102,8 @@ class Web extends APIServerModule { return JSON.stringify(obj); }); + Handlebars.registerHelper('eq', (a, b) => a == b); + // Not in use, but might be useful in the future /** * Format string with data diff --git a/api_server/modules/apiweb/newrelic_ide_redirect_request.js b/api_server/modules/apiweb/newrelic_ide_redirect_request.js index ad0b466a7..4bffd7ef5 100644 --- a/api_server/modules/apiweb/newrelic_ide_redirect_request.js +++ b/api_server/modules/apiweb/newrelic_ide_redirect_request.js @@ -4,7 +4,12 @@ const IdeRedirectRequest = require('./ide_redirect_request'); const { defaultCookieName, ides} = require('./config'); + class NewRelicIdeRedirectRequest extends IdeRedirectRequest { + constructor(options) { + super(options); + this.abTest = Math.random() < 0.5 ? "feature_tabs" : "feature_bullets"; + } async prepareTemplateProps () { this.redirectType = this.request.params.type.toLowerCase(); @@ -48,6 +53,7 @@ class NewRelicIdeRedirectRequest extends IdeRedirectRequest { } const launcherModel = this.createLauncherModel(''); this.templateProps = { + abTest: this.abTest, pageType, pageWhat, analyticsContentType, @@ -107,6 +113,7 @@ class NewRelicIdeRedirectRequest extends IdeRedirectRequest { } }).bind(this))(); const result = { + abTest: this.abTest, environment, ides: ides, src: decodeURIComponent(this.parsedPayload.src || ''), diff --git a/api_server/modules/apiweb/styles/web.css b/api_server/modules/apiweb/styles/web.css index d6b1d89d9..b6024021d 100644 --- a/api_server/modules/apiweb/styles/web.css +++ b/api_server/modules/apiweb/styles/web.css @@ -192,6 +192,42 @@ h6 { color: white!important; text-decoration: underline; } +.content-body-container { + max-width: 80%; + margin: 0 auto; + text-align: left; +} +.content-body-section-header { + margin-top: 28px; +} +.content-body-header { + font-size: xx-large; +} +.content-body-tab-container { + margin-top: 32px; + margin-bottom: 10px; + border-bottom: 1px solid #3c3c3c; + display: flex; + justify-content: space-between; +} +.content-body-tab-header { + font-size: larger; + cursor: pointer; + display: inline-block; + text-align: center; + padding-bottom: 5px; + margin-bottom: -1px; +} +.content-body-copy { + margin-bottom: 12px; +} +.content-body-tab { + display: none; +} +.content-active-tab { + /* display: block !important; */ + border-bottom: 1px solid; +} .box-border { margin-top: 1rem; padding: 1.5rem; @@ -550,7 +586,7 @@ hr { .interstitial-detail-wrapper { padding: 30px; - margin: 0 0 40px 0; + margin: 10px 0 10px 0; max-width: 704px; margin-right: auto; margin-left: auto; @@ -637,8 +673,21 @@ hr { .observability-gif { width: 100%; + border-radius: 8px; + margin-top: 10px; } +@media (max-width: 768px) { + .content-body-tab-icon { + display: none !important; + } + .content-body-tab-header { + display: flex; + align-items: center; + justify-content: center; + } + } + @media (min-width: 1px) { .btn-block-wrap { width: 100%; @@ -767,11 +816,11 @@ span.dropdown-item:hover { } .navbar-light.bg-light { background: #fff !important; - margin: 0 0 80px 0; + margin: 0 0 0 0; } .navbar-dark.bg-dark { /* background: #1D252C !important; */ - margin: 0 0 80px 0; + margin: 0 0 0 0; border-bottom: 1px solid #2a3036; padding: 15px 0 15px 0; } @@ -796,10 +845,11 @@ span.dropdown-item:hover { } .form-control { border: 1px solid #383838 !important; + padding: 6px 0 0 0 !important; } .btn-light.form-control { border: 1px solid rgb(227, 228, 228) !important; - height: 50px; + /* height: 50px; */ } .lines-added { color: #66aa66; diff --git a/api_server/modules/apiweb/templates/ide_redirect.hbs b/api_server/modules/apiweb/templates/ide_redirect.hbs index 3706e353a..2cd280777 100644 --- a/api_server/modules/apiweb/templates/ide_redirect.hbs +++ b/api_server/modules/apiweb/templates/ide_redirect.hbs @@ -8,17 +8,17 @@ {{> partial_html_head }} + \ No newline at end of file diff --git a/api_server/modules/apiweb/web_track_request.js b/api_server/modules/apiweb/web_track_request.js index 71a809a52..13f19b579 100644 --- a/api_server/modules/apiweb/web_track_request.js +++ b/api_server/modules/apiweb/web_track_request.js @@ -3,9 +3,9 @@ const RestfulRequest = require(process.env.CSSVC_BACKEND_ROOT + '/api_server/lib/util/restful/restful_request.js'); const ALLOWED_EVENTS = [ - 'codestream/ide_redirect displayed', - 'codestream/ide_redirect failed', - 'codestream/ide selected' + 'codestream/ide_redirect displayed', + 'codestream/ide_redirect failed', + 'codestream/ide selected' ]; class WebTrackRequest extends RestfulRequest { @@ -24,23 +24,30 @@ class WebTrackRequest extends RestfulRequest { { required: { string: ['event'], - object:['properties'] + object:['properties'] }, + optional: { + string: ['nrUserId'] + } } ); - const { event } = this.request.body; - if (!ALLOWED_EVENTS.includes(event)) { - throw this.errorHandler.error('invalidParameter', { info: 'not a valid event' }); - } - } + const { event } = this.request.body; + if (!ALLOWED_EVENTS.includes(event)) { + throw this.errorHandler.error('invalidParameter', { info: 'not a valid event' }); + } + } async process () { await this.requireAndAllow(); - const { event, properties } = this.request.body; - this.api.services.analytics.track(event, properties); - } + const { event, properties, nrUserId } = this.request.body; + const options = { request: this }; + if (nrUserId) { + options.nrUserId = nrUserId; + } + this.api.services.analytics.track(event, properties, options); + } } module.exports = WebTrackRequest; diff --git a/api_server/modules/code_errors/test/unfollow_link/tracking_test.js b/api_server/modules/code_errors/test/unfollow_link/tracking_test.js index 1bfc361f2..6759be57a 100644 --- a/api_server/modules/code_errors/test/unfollow_link/tracking_test.js +++ b/api_server/modules/code_errors/test/unfollow_link/tracking_test.js @@ -73,12 +73,14 @@ class TrackingTest extends Aggregation(CodeStreamMessageTest, CommonInit) { event: 'Notification Change', messageId: data.messageId || '', timestamp: data.timestamp || '', + anonymousId: data.anonymousId || '', type: 'track', properties: { //user_id: this.currentUser.user.nrUserId, platform: 'codestream', path: 'N/A (codestream)', section: 'N/A (codestream)', + session_id: data.properties.session_id || '', meta_data_15: JSON.stringify(expectedMetaData), meta_data_14: 'change: code_error_unfollowed', meta_data_13: 'source_of_change: email_link' diff --git a/api_server/modules/codemarks/test/unfollow_link/tracking_test.js b/api_server/modules/codemarks/test/unfollow_link/tracking_test.js index 31a4c782c..20e4627b4 100644 --- a/api_server/modules/codemarks/test/unfollow_link/tracking_test.js +++ b/api_server/modules/codemarks/test/unfollow_link/tracking_test.js @@ -73,12 +73,14 @@ class TrackingTest extends Aggregation(CodeStreamMessageTest, CommonInit) { event: 'Notification Change', messageId: data.messageId || '', timestamp: data.timestamp || '', + anonymousId: data.anonymousId || '', type: 'track', properties: { //user_id: this.currentUser.user.nrUserId, platform: 'codestream', path: 'N/A (codestream)', section: 'N/A (codestream)', + session_id: data.properties.session_id || '', meta_data_15: JSON.stringify(expectedMetaData), meta_data_14: 'change: codemark_unfollowed', meta_data_13: 'source_of_change: email_link' diff --git a/api_server/modules/entities/entities.js b/api_server/modules/entities/entities.js new file mode 100644 index 000000000..0c6fbef77 --- /dev/null +++ b/api_server/modules/entities/entities.js @@ -0,0 +1,60 @@ +// provide a module to handle requests associated with repos + +'use strict'; + +const Restful = require(process.env.CSSVC_BACKEND_ROOT + '/api_server/lib/util/restful/restful'); +const Entity = require('./entity'); +const EntityCreator = require('./entity_creator'); +const Errors = require('./errors'); + +// expose these restful routes +const ENTITIES_STANDARD_ROUTES = { + want: ['get', 'getMany', 'post'], + baseRouteName: 'entities', + requestClasses: { + 'get': require('./get_entity_request'), + 'getMany': require('./get_entities_request'), + 'post': require('./post_entity_request'), + } +}; + +// expose additional routes +const ENTITIES_ADDITIONAL_ROUTES = [ +]; + +class Entities extends Restful { + + get collectionName () { + return 'entities'; // name of the data collection + } + + get modelName () { + return 'entity'; // name of the data model + } + + get creatorClass () { + return EntityCreator; // use this class to instantiate entities + } + + get modelClass () { + return Entity; // use this class for the data model + } + + get modelDescription () { + return 'A single entity, identified by its GUID'; + } + + // compile all the routes to expose + getRoutes () { + let standardRoutes = super.getRoutes(ENTITIES_STANDARD_ROUTES); + return [...standardRoutes, ...ENTITIES_ADDITIONAL_ROUTES]; + } + + describeErrors () { + return { + 'Entities': Errors + }; + } +} + +module.exports = Entities; diff --git a/api_server/modules/entities/entity.js b/api_server/modules/entities/entity.js new file mode 100644 index 000000000..841f13d09 --- /dev/null +++ b/api_server/modules/entities/entity.js @@ -0,0 +1,16 @@ +// provides the Entity model for handling New Relic entities + +'use strict'; + +const CodeStreamModel = require(process.env.CSSVC_BACKEND_ROOT + '/api_server/lib/models/codestream_model'); +const CodeStreamModelValidator = require(process.env.CSSVC_BACKEND_ROOT + '/api_server/lib/models/codestream_model_validator'); +const EntityAttributes = require('./entity_attributes'); + +class Entity extends CodeStreamModel { + + getValidator () { + return new CodeStreamModelValidator(EntityAttributes); + } +} + +module.exports = Entity; diff --git a/api_server/modules/entities/entity_attributes.js b/api_server/modules/entities/entity_attributes.js new file mode 100644 index 000000000..de95578ff --- /dev/null +++ b/api_server/modules/entities/entity_attributes.js @@ -0,0 +1,32 @@ +// attributes for entity documents/models + +'use strict'; + +module.exports = { + companyId: { + type: 'id', + required: true, + description: 'ID of the @@#company#company@@ with which the repo is associated' + }, + teamId: { + type: 'id', + required: true, + description: 'ID of the @@#team#team@@ with which the repo is associated' + }, + entityId: { + type: 'string', + required: true, + maxLength: 128, + description: 'Entity GUID of the entity' + }, + lastUpdated: { + type: 'timestamp', + required: true, + description: 'Last time the entity was viewed in CodeStream' + }, + lastUserId: { + type: 'id', + required: true, + description: 'ID of the last user to view the entity in CodeStream' + } +}; diff --git a/api_server/modules/entities/entity_creator.js b/api_server/modules/entities/entity_creator.js new file mode 100644 index 000000000..60d51c884 --- /dev/null +++ b/api_server/modules/entities/entity_creator.js @@ -0,0 +1,57 @@ +// this class should be used to create all entity documents in the database + +'use strict'; + +const ModelCreator = require(process.env.CSSVC_BACKEND_ROOT + '/api_server/lib/util/restful/model_creator'); +const Entity = require('./entity'); +const Errors = require('./errors'); +const Path = require('path'); + +class EntityCreator extends ModelCreator { + + constructor (options) { + super(options); + this.errorHandler.add(Errors); + } + + get modelClass () { + return Entity; // class to use to create an Entity model + } + + get collectionName () { + return 'entities'; // data collection to use + } + + // convenience wrapper + async createEntity (attributes) { + return await this.createModel(attributes); + } + + // get attributes that are required for entity creation, and those that are optional, + // along with their types + getRequiredAndOptionalAttributes () { + return { + required: { + string: ['entityId', 'teamId', 'companyId'] + }, + }; + } + + // validate attributes for the entity we are creating + async validateAttributes () { + } + + // right before we save the model... + async preSave () { + this.attributes.creatorId = this.user.id; // establish creator of the entity as originator of the request + this.attributes.lastUserId = this.user.id; // establish this user as the last user to "update" the entity + this.attributes.lastUpdated = this.attributes.createdAt = Date.now(); // establish date the entity was last "updated" + if (this.request.isForTesting()) { // special for-testing header for easy wiping of test data + this.attributes._forTesting = true; + } + this.createId(); // requisition an ID for the entity + await super.preSave(); // proceed with the save... + } +} + +module.exports = EntityCreator; diff --git a/api_server/modules/entities/entity_updater.js b/api_server/modules/entities/entity_updater.js new file mode 100644 index 000000000..cd4950497 --- /dev/null +++ b/api_server/modules/entities/entity_updater.js @@ -0,0 +1,37 @@ +// this class should be used to update entity documents in the database + +'use strict'; + +const ModelUpdater = require(process.env.CSSVC_BACKEND_ROOT + '/api_server/lib/util/restful/model_updater'); +const Entity = require('./entity'); + +class EntityUpdater extends ModelUpdater { + + get modelClass () { + return Entity; // class to use to create an entity model + } + + get collectionName () { + return 'entities'; // data collection to use + } + + // convenience wrapper + async updateEntity (id, attributes) { + return await this.updateModel(id, attributes); + } + + // get attributes that are allowed, we will ignore all others + getAllowedAttributes () { + return { + string: ['lastUserId'], + }; + } + + // called before the entity is actually saved + async preSave () { + this.attributes.modifiedAt = this.attributes.lastUpdated = Date.now(); + await super.preSave(); // base-class preSave + } +} + +module.exports = EntityUpdater; diff --git a/api_server/modules/entities/errors.js b/api_server/modules/entities/errors.js new file mode 100644 index 000000000..a28110071 --- /dev/null +++ b/api_server/modules/entities/errors.js @@ -0,0 +1,6 @@ +// Errors related to the repos module + +'use strict'; + +module.exports = { +}; diff --git a/api_server/modules/entities/get_entities_request.js b/api_server/modules/entities/get_entities_request.js new file mode 100644 index 000000000..ca091d2d5 --- /dev/null +++ b/api_server/modules/entities/get_entities_request.js @@ -0,0 +1,32 @@ +// handle the 'GET /entities' request, to fetch one or more New Relic entities + +'use strict'; + +const GetManyRequest = require(process.env.CSSVC_BACKEND_ROOT + '/api_server/lib/util/restful/get_many_request'); +const Indexes = require('./indexes'); + +class GetEntitiesRequest extends GetManyRequest { + + async authorize () { + // you have access to the entities if you have access to the team ... teamId is required + await this.user.authorizeFromTeamId(this.request.query, this); + } + + // build the query to fetch multiple entities from the database + buildQuery () { + if (!this.request.query.teamId) { + return this.errorHandler.error('parameterRequired', { info: 'teamId' }); + } + let query = { + teamId: decodeURIComponent(this.request.query.teamId).toLowerCase() + }; + return query; + } + + // get options associated with the database query to fetch multiple entities + getQueryOptions () { + return { hint: Indexes.byTeamId }; + } +} + +module.exports = GetEntitiesRequest; diff --git a/api_server/modules/entities/get_entity_request.js b/api_server/modules/entities/get_entity_request.js new file mode 100644 index 000000000..0be693aa5 --- /dev/null +++ b/api_server/modules/entities/get_entity_request.js @@ -0,0 +1,11 @@ +// handle a GET /entities/:id request to fetch a single New Relic entity + +'use strict'; + +const GetRequest = require(process.env.CSSVC_BACKEND_ROOT + '/api_server/lib/util/restful/get_request'); + +class GetEntityRequest extends GetRequest { + +} + +module.exports = GetEntityRequest; diff --git a/api_server/modules/entities/indexes.js b/api_server/modules/entities/indexes.js new file mode 100644 index 000000000..609b68a28 --- /dev/null +++ b/api_server/modules/entities/indexes.js @@ -0,0 +1,13 @@ +// these database indexes are in place for the entities module, all fetch queries +// must use one of these + +'use strict'; + +module.exports = { + byTeamId: { + teamId: 1 + }, + byEntityId: { + entityId: 1 + } +}; diff --git a/api_server/modules/entities/module.js b/api_server/modules/entities/module.js new file mode 100644 index 000000000..7da6293f8 --- /dev/null +++ b/api_server/modules/entities/module.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = require('./entities.js'); diff --git a/api_server/modules/entities/post_entity_request.js b/api_server/modules/entities/post_entity_request.js new file mode 100644 index 000000000..a2bc80139 --- /dev/null +++ b/api_server/modules/entities/post_entity_request.js @@ -0,0 +1,72 @@ +// handle the POST /entities request to create a new New Relic entity + +'use strict'; + +const PostRequest = require(process.env.CSSVC_BACKEND_ROOT + '/api_server/lib/util/restful/post_request'); +const Indexes = require('./indexes'); +const ModelSaver = require(process.env.CSSVC_BACKEND_ROOT + '/api_server/lib/util/restful/model_saver'); + +class PostEntityRequest extends PostRequest { + + // authorize the request for the current user + async authorize () { + // user must be a member of the team + let teamId = this.request.body.teamId; + if (!teamId) { + throw this.errorHandler.error('parameterRequired', { info: 'teamId' }); + } + if (typeof teamId !== 'string') { + throw this.errorHandler.error('invalidParameter', { info: 'teamId must be a string' }); + } + teamId = teamId.toLowerCase(); + if (!await this.user.authorizeTeam(teamId, this)) { + throw this.errorHandler.error('createAuth'); + } + this.team = await this.data.teams.getById(teamId); + if (!this.team || this.team.get('deactivated')) { + throw this.errorHandler.error('notFound'); // shouldn't happen + } + this.request.body.companyId = this.team.get('companyId'); + } + + // process the request + async process () { + // does the entity already exist for this team? if so, just update it + const entities = await this.data.entities.getByQuery( + { + entityId: this.request.body.entityId, + deactivated: false + }, + { + hint: Indexes.byEntityId + } + ); + this.entity = entities.find(entity => entity.get('teamId') === this.team.id); + if (this.entity) { + const now = Date.now(); + const op = { + $set: { + lastUserId: this.user.id, + lastUpdated: now, + modifiedAt: now + } + }; + await new ModelSaver({ + request: this, + collection: this.data.entities, + id: this.entity.id + }).save(op); + } else { + return super.process(); + } + } + + async handleResponse () { + if (!this.gotError && this.entity) { + this.responseData = { entity: this.entity.getSanitizedObject({ request: this }) }; + } + return super.handleResponse(); + } +} + +module.exports = PostEntityRequest; diff --git a/api_server/modules/entities/test/entity_test_constants.js b/api_server/modules/entities/test/entity_test_constants.js new file mode 100644 index 000000000..723300e19 --- /dev/null +++ b/api_server/modules/entities/test/entity_test_constants.js @@ -0,0 +1,32 @@ +// test constants for testing the entities module + +'use strict'; + +const EntityAttributes = require(process.env.CSSVC_BACKEND_ROOT + '/api_server/modules/entities/entity_attributes'); + +// fields expected in all entities +const EXPECTED_ENTITY_FIELDS = [ + 'id', + 'deactivated', + 'createdAt', + 'modifiedAt', + 'creatorId', + 'companyId', + 'teamId', + 'entityId', + 'lastUserId', + 'lastUpdated' +]; + +const EXPECTED_ENTITY_RESPONSE = { + repo: EXPECTED_ENTITY_FIELDS +}; + +const UNSANITIZED_ATTRIBUTES = Object.keys(EntityAttributes).filter(attribute => { + return EntityAttributes[attribute].serverOnly; +}); + +module.exports = { + EXPECTED_ENTITY_RESPONSE, + UNSANITIZED_ATTRIBUTES +}; diff --git a/api_server/modules/entities/test/get_entities/acl_test.js b/api_server/modules/entities/test/get_entities/acl_test.js new file mode 100644 index 000000000..ce9b573a5 --- /dev/null +++ b/api_server/modules/entities/test/get_entities/acl_test.js @@ -0,0 +1,24 @@ +'use strict'; + +const GetEntitiesTest = require('./get_entities_test'); + +class ACLTest extends GetEntitiesTest { + + constructor (options) { + super(options); + this.currentUserNotOnTeam = true; + this.teamOptions.members = []; + } + + get description () { + return 'should return an error when trying to fetch entities from a team i\'m not a member of'; + } + + getExpectedError () { + return { + code: 'RAPI-1009' + }; + } +} + +module.exports = ACLTest; diff --git a/api_server/modules/entities/test/get_entities/get_entities_test.js b/api_server/modules/entities/test/get_entities/get_entities_test.js new file mode 100644 index 000000000..35fb6e2a9 --- /dev/null +++ b/api_server/modules/entities/test/get_entities/get_entities_test.js @@ -0,0 +1,79 @@ +'use strict'; + +const CodeStreamAPITest = require(process.env.CSSVC_BACKEND_ROOT + '/api_server/lib/test_base/codestream_api_test'); +const BoundAsync = require(process.env.CSSVC_BACKEND_ROOT + '/shared/server_utils/bound_async'); +const Assert = require('assert'); + +class GetEntitiesTest extends CodeStreamAPITest { + + constructor (options) { + super(options); + this.teamOptions.creatorIndex = 1; + this.numEntities = 5; + } + + get description () { + return 'should return New Relic entities when requested'; + } + + // before the test runs... + before (callback) { + BoundAsync.series(this, [ + super.before, + this.setPath, + this.createEntities + ], callback); + } + + // set the path to use for the test request + setPath (callback) { + this.path = `/entities?teamId=${this.team.id}`; + callback(); + } + + // create the entities + createEntities (callback) { + this.expectedResponse = { entities: [] }; + BoundAsync.timesSeries( + this, + this.numEntities, + this.createEntity, + callback + ); + } + + createEntity (n, callback) { + const entityData = this.entityFactory.getRandomEntityData(); + entityData.teamId = this.team.id; + const token = (this.currentUserNotOnTeam || (n % 2)) ? this.users[1].accessToken : this.currentUser.accessToken; + this.doApiRequest( + { + method: 'post', + path: '/entities', + data: entityData, + token + }, + (error, response) => { + if (error) { return callback(error); } + this.expectedResponse.entities.push(response.entity); + callback(); + } + ); + } + + // validate the request response + validateResponse (data) { + // sort both arrays by id, to make sure they match + this.expectedResponse.entities.sort((a, b) => { + return b.id - a.id; + }); + data.entities.sort((a, b) => { + return b.id - a.id; + }); + + // response should exactly equal the response we got when we created the entities + Assert.deepStrictEqual(data, this.expectedResponse, 'incorrect response'); + } +} + +module.exports = GetEntitiesTest; diff --git a/api_server/modules/entities/test/get_entities/team_id_required_test.js b/api_server/modules/entities/test/get_entities/team_id_required_test.js new file mode 100644 index 000000000..a68714d3d --- /dev/null +++ b/api_server/modules/entities/test/get_entities/team_id_required_test.js @@ -0,0 +1,24 @@ +'use strict'; + +const GetEntitiesTest = require('./get_entities_test'); + +class TeamIDRequiredTest extends GetEntitiesTest { + + get description () { + return 'should return an error if teamId is not provided to a query for entities'; + } + + getExpectedError () { + return { + code: 'RAPI-1001', + info: 'teamId' + }; + } + + setPath (callback) { + this.path = '/entities'; // no teamId + callback(); + } +} + +module.exports = TeamIDRequiredTest; diff --git a/api_server/modules/entities/test/get_entities/test.js b/api_server/modules/entities/test/get_entities/test.js new file mode 100644 index 000000000..97ec6089a --- /dev/null +++ b/api_server/modules/entities/test/get_entities/test.js @@ -0,0 +1,18 @@ +// handle unit tests for the "GET /entities" request + +'use strict'; + +const GetEntitiesTest = require('./get_entities_test'); +const TeamIdRequiredTest = require('./team_id_required_test'); +const ACLTest = require('./acl_test'); + +class GetEntitiesRequestTester { + + test () { + new GetEntitiesTest().test(); + new TeamIdRequiredTest().test(); + new ACLTest().test(); + } +} + +module.exports = new GetEntitiesRequestTester(); diff --git a/api_server/modules/entities/test/get_entity/acl_test.js b/api_server/modules/entities/test/get_entity/acl_test.js new file mode 100644 index 000000000..a1c8e8a3a --- /dev/null +++ b/api_server/modules/entities/test/get_entity/acl_test.js @@ -0,0 +1,24 @@ +'use strict'; + +const GetEntityTest = require('./get_entity_test'); + +class ACLTest extends GetEntityTest { + + constructor (options) { + super(options); + this.teamOptions.members = []; + this.teamCreatorCreatesEntity = true; + } + + get description () { + return 'should return an error when trying to fetch a New Relic entity from a team the current user is not a member of'; + } + + getExpectedError () { + return { + code: 'RAPI-1009' + }; + } +} + +module.exports = ACLTest; diff --git a/api_server/modules/entities/test/get_entity/get_entity_test.js b/api_server/modules/entities/test/get_entity/get_entity_test.js new file mode 100644 index 000000000..5e21d7f91 --- /dev/null +++ b/api_server/modules/entities/test/get_entity/get_entity_test.js @@ -0,0 +1,67 @@ +'use strict'; + +const CodeStreamAPITest = require(process.env.CSSVC_BACKEND_ROOT + '/api_server/lib/test_base/codestream_api_test'); +const BoundAsync = require(process.env.CSSVC_BACKEND_ROOT + '/shared/server_utils/bound_async'); +const EntityTestConstants = require('../entity_test_constants'); + +class GetEntityTest extends CodeStreamAPITest { + + constructor (options) { + super(options); + this.teamOptions.creatorIndex = 1; + } + + get description () { + return 'should return a valid entity when requesting an entity created by me'; + } + + getExpectedFields () { + return { entity: EntityTestConstants.EXPECTED_ENTITY_FIELDS }; + } + + // before the test runs... + before (callback) { + BoundAsync.series(this, [ + super.before, + this.createEntity, + this.setPath + ], callback); + } + + // create an entity to fetch + createEntity (callback) { + const entityData = this.entityFactory.getRandomEntityData(); + entityData.teamId = this.team.id; + const token = this.teamCreatorCreatesEntity ? this.users[1].accessToken : this.currentUser.accessToken; + this.doApiRequest( + { + method: 'post', + path: '/entities', + data: entityData, + token + }, + (error, response) => { + if (error) { return callback(error); } + this.entity = response.entity; + callback(); + } + ); + } + + // set the path for the test request + setPath (callback) { + // fetch the entity created + this.path = '/entities/' + this.entity.id; + callback(); + } + + // validate the response to the test request + validateResponse (data) { + // make sure we got the expected entity + this.validateMatchingObject(this.entity.id, data.entity, 'entity'); + // make sure we didn't get attributes not suitable for the client + this.validateSanitized(data.entity, EntityTestConstants.UNSANITIZED_ATTRIBUTES); + } +} + +module.exports = GetEntityTest; diff --git a/api_server/modules/entities/test/get_entity/get_other_entity_test.js b/api_server/modules/entities/test/get_entity/get_other_entity_test.js new file mode 100644 index 000000000..d13990c06 --- /dev/null +++ b/api_server/modules/entities/test/get_entity/get_other_entity_test.js @@ -0,0 +1,17 @@ +'use strict'; + +const GetEntityTest = require('./get_entity_test'); + +class GetOtherEntityTest extends GetEntityTest { + + constructor (options) { + super(options); + this.teamCreatorCreatesEntity = true; + } + + get description () { + return 'should return a valid entity when requesting an entity created by another user on a team that i am on'; + } +} + +module.exports = GetOtherEntityTest; diff --git a/api_server/modules/entities/test/get_entity/not_found_test.js b/api_server/modules/entities/test/get_entity/not_found_test.js new file mode 100644 index 000000000..7e95ea159 --- /dev/null +++ b/api_server/modules/entities/test/get_entity/not_found_test.js @@ -0,0 +1,29 @@ +'use strict'; + +const GetEntityTest = require('./get_entity_test'); +const ObjectId = require('mongodb').ObjectId; + +class NotFoundTest extends GetEntityTest { + + get description () { + return 'should return an error when trying to fetch a New Relic entity that doesn\'t exist'; + } + + getExpectedError () { + return { + code: 'RAPI-1003' + }; + } + + // before the test runs... + before (callback) { + super.before (error => { + // substitute a non-existent entity ID + if (error) { return callback(error); } + this.path = '/entities/' + ObjectId(); + callback(); + }); + } +} + +module.exports = NotFoundTest; diff --git a/api_server/modules/entities/test/get_entity/test.js b/api_server/modules/entities/test/get_entity/test.js new file mode 100644 index 000000000..2751d18d5 --- /dev/null +++ b/api_server/modules/entities/test/get_entity/test.js @@ -0,0 +1,21 @@ +// handle unit tests for the "GET /entities/:id" request, +// to fetch a New Relic entity + +'use strict'; + +const GetEntityTest = require('./get_entity_test'); +const NotFoundTest = require('./not_found_test'); +const ACLTest = require('./acl_test'); +const GetOtherEntityTest = require('./get_other_entity_test'); + +class GetEntityRequestTester { + + test () { + new GetEntityTest().test(); + new NotFoundTest().test(); + new ACLTest().test(); + new GetOtherEntityTest().test(); + } +} + +module.exports = new GetEntityRequestTester(); diff --git a/api_server/modules/entities/test/post_entity/acl_test.js b/api_server/modules/entities/test/post_entity/acl_test.js new file mode 100644 index 000000000..4772b8c97 --- /dev/null +++ b/api_server/modules/entities/test/post_entity/acl_test.js @@ -0,0 +1,25 @@ +'use strict'; + +const PostEntityTest = require('./post_entity_test'); + +class ACLTest extends PostEntityTest { + + setTestOptions (callback) { + super.setTestOptions(() => { + this.teamOptions.members = []; + callback(); + }); + } + + get description () { + return 'should return an error when trying to create a New Relic entity in a team the current user is not a member of'; + } + + getExpectedError () { + return { + code: 'RAPI-1011' + }; + } +} + +module.exports = ACLTest; diff --git a/api_server/modules/entities/test/post_entity/common_init.js b/api_server/modules/entities/test/post_entity/common_init.js new file mode 100644 index 000000000..e3ca570ef --- /dev/null +++ b/api_server/modules/entities/test/post_entity/common_init.js @@ -0,0 +1,59 @@ +// base class for many tests of the "POST /entities" requests + +'use strict'; + +const BoundAsync = require(process.env.CSSVC_BACKEND_ROOT + '/shared/server_utils/bound_async'); +const CodeStreamAPITest = require(process.env.CSSVC_BACKEND_ROOT + '/api_server/lib/test_base/codestream_api_test'); + +class CommonInit { + + init (callback) { + BoundAsync.series(this, [ + this.setTestOptions, + CodeStreamAPITest.prototype.before.bind(this), + this.setPath, + this.makeEntityData // make the data to be used during the request + ], callback); + } + + setTestOptions (callback) { + this.entityOptions = {}; + this.teamOptions.creatorIndex = 1; + callback(); + } + + // form the data for the generating the entity + makeEntityData (callback) { + this.data = this.entityFactory.getRandomEntityData(this.entityOptions); + this.data.teamId = this.team.id; + this.createdAfter = Date.now(); + callback(); + } + + // set the path to use during the test request + setPath (callback) { + this.path = '/entities'; + callback(); + } + + // create the entity for real + createEntity (callback) { + const token = this.useToken || (this.otherUserCreatesEntity ? this.users[1].accessToken : this.currentUser.accessToken); + this.doApiRequest( + { + method: 'post', + path: `/entities`, + data: this.data, + token + }, + (error, response) => { + if (error) { return callback(error); } + this.entityResponse = response; + delete this.data; // don't need this anymore + callback(); + } + ); + } +} + +module.exports = CommonInit; diff --git a/api_server/modules/entities/test/post_entity/deactivated_team_test.js b/api_server/modules/entities/test/post_entity/deactivated_team_test.js new file mode 100644 index 000000000..b78e1dd9b --- /dev/null +++ b/api_server/modules/entities/test/post_entity/deactivated_team_test.js @@ -0,0 +1,34 @@ +'use strict'; + +const PostEntityTest = require('./post_entity_test'); + +class DeactivatedTeamTest extends PostEntityTest { + + get description () { + return 'should return an error when trying to create a New Relic entity in a deactivated team'; + } + + getExpectedError () { + return { + code: 'RAPI-1003' + }; + } + + before (callback) { + super.before(error => { + if (error) { return callback(error); } + this.doApiRequest({ + method: 'delete', + path: '/teams/' + this.team.id, + token: this.currentUser.accessToken, + requestOptions: { + headers: { + 'X-Delete-Team-Secret': this.apiConfig.sharedSecrets.confirmationCheat + } + } + }, callback); + }); + } +} + +module.exports = DeactivatedTeamTest; diff --git a/api_server/modules/entities/test/post_entity/entity_exists_other_team_test.js b/api_server/modules/entities/test/post_entity/entity_exists_other_team_test.js new file mode 100644 index 000000000..9e55ec9e3 --- /dev/null +++ b/api_server/modules/entities/test/post_entity/entity_exists_other_team_test.js @@ -0,0 +1,58 @@ +'use strict'; + +const PostEntityTest = require('./post_entity_test'); +const Assert = require('assert'); +const BoundAsync = require(process.env.CSSVC_BACKEND_ROOT + '/shared/server_utils/bound_async'); + +class EntityExistsOtherTeamTest extends PostEntityTest { + + constructor (options) { + super(options); + this.otherUserCreatesEntity = true; + } + + get description () { + return 'when creating a New Relic entity, if the entity already exists but for another team, the entity will be created for this team'; + } + + before (callback) { + BoundAsync.series(this, [ + super.before, + this.createOtherTeam, + this.createEntity, + this.restoreData + ], callback); + } + + // create another team that the current user is not on + createOtherTeam (callback) { + this.companyFactory.createRandomCompany( + (error, response) => { + if (error) { return callback(error); } + this.data.teamId = response.team.id; + this.useToken = response.accessToken; + callback(); + }, + { + token: this.users[1].accessToken + } + ); + } + + // restore data to be used in the test request after munging it + restoreData (callback) { + this.data = { + entityId: this.entityResponse.entity.entityId, + teamId: this.team.id + }; + this.otherUserCreatesEntity = false; + callback(); + } + + validateResponse (data) { + Assert.notStrictEqual(data.entity.id, this.entityResponse.entity.id, 'entity created by test request is equal to the entity created on the other team'); + return super.validateResponse(data); + } +} + +module.exports = EntityExistsOtherTeamTest; diff --git a/api_server/modules/entities/test/post_entity/entity_exists_other_user_test.js b/api_server/modules/entities/test/post_entity/entity_exists_other_user_test.js new file mode 100644 index 000000000..30de6b0f2 --- /dev/null +++ b/api_server/modules/entities/test/post_entity/entity_exists_other_user_test.js @@ -0,0 +1,23 @@ +'use strict'; + +const EntityExistsTest = require('./entity_exists_test'); +const Assert = require('assert'); + +class EntityExistsOtherUserTest extends EntityExistsTest { + + constructor (options) { + super(options); + this.otherUserCreatesEntity = true; + } + + get description () { + return 'when creating a New Relic entity, if the entity already exists for the team, the existing entity should be updated, and the user ID should be updated to the user making the request'; + } + + validateResponse (data) { + Assert.strictEqual(this.entityResponse.entity.lastUserId, this.users[1].user.id, 'entity originally created does not have id of entity creator for creatorId'); + return super.validateResponse(data); + } +} + +module.exports = EntityExistsOtherUserTest; diff --git a/api_server/modules/entities/test/post_entity/entity_exists_test.js b/api_server/modules/entities/test/post_entity/entity_exists_test.js new file mode 100644 index 000000000..6ffde4ec1 --- /dev/null +++ b/api_server/modules/entities/test/post_entity/entity_exists_test.js @@ -0,0 +1,35 @@ +'use strict'; + +const PostEntityTest = require('./post_entity_test'); +const Assert = require('assert'); + +class EntityExistsTest extends PostEntityTest { + + get description () { + return 'when creating a New Relic entity, if the entity already exists for the team, the existing entity should be updated'; + } + + before (callback) { + // create the entity before making the test request + this.expectedVersion = 2; + super.before(error => { + if (error) { return callback(error); } + this.savedData = this.data; // the base test deletes this.data, but we need it for the second try + setTimeout(() => { // give a little time for our timestamps to change + this.createEntity(error => { + if (error) { return callback(error); } + this.updatedAfter = Date.now(); + this.data = this.savedData; + callback(); + }); + }, 2); + }); + } + + validateResponse (data) { + Assert.strictEqual(data.entity.id, this.entityResponse.entity.id, 'id of returned entity does not match the previously created entity'); + return super.validateResponse(data); + } +} + +module.exports = EntityExistsTest; diff --git a/api_server/modules/entities/test/post_entity/fetch_test.js b/api_server/modules/entities/test/post_entity/fetch_test.js new file mode 100644 index 000000000..790dd5e12 --- /dev/null +++ b/api_server/modules/entities/test/post_entity/fetch_test.js @@ -0,0 +1,39 @@ +'use strict'; + +const PostEntityTest = require('./post_entity_test'); +const BoundAsync = require(process.env.CSSVC_BACKEND_ROOT + '/shared/server_utils/bound_async'); +const Assert = require('assert'); + +class FetchTest extends PostEntityTest { + + get description () { + return 'should persist an entity when creating a New Relic entity, checked by fetching the entity'; + } + + get method () { + return 'get'; + } + + // before the test runs... + before (callback) { + BoundAsync.series(this, [ + super.before, + this.createEntity, + this.setPath + ], callback); + } + + setPath (callback) { + if (!this.entityResponse) { return callback(); } + this.path = `/entities/${this.entityResponse.entity.id}`; + callback(); + } + + // validate that the response is correct + validateResponse (data) { + Assert.strictEqual(data.entity.id, this.entityResponse.entity.id, 'fetched entity not equal to the entity given in the response'); + Assert.strictEqual(data.entity.entityId, this.entityResponse.entity.entityId, 'entityId of fetched entity does not match the entityId sent'); + } +} + +module.exports = FetchTest; diff --git a/api_server/modules/entities/test/post_entity/invalid_parameter_test.js b/api_server/modules/entities/test/post_entity/invalid_parameter_test.js new file mode 100644 index 000000000..c4bc44244 --- /dev/null +++ b/api_server/modules/entities/test/post_entity/invalid_parameter_test.js @@ -0,0 +1,28 @@ +'use strict'; + +const PostEntityTest = require('./post_entity_test'); + +class InvalidParameterTest extends PostEntityTest { + + get description () { + return `should return an error when trying to create a New Relic entity with an invalid ${this.parameter}`; + } + + getExpectedError () { + return { + code: 'RAPI-1012', + info: this.parameter + }; + } + + // before the test runs... + before (callback) { + super.before(error => { + if (error) { return callback(error); } + this.data[this.parameter] = Math.floor(Math.random() * 100000000); + callback(); + }); + } +} + +module.exports = InvalidParameterTest; diff --git a/api_server/modules/entities/test/post_entity/parameter_required_test.js b/api_server/modules/entities/test/post_entity/parameter_required_test.js new file mode 100644 index 000000000..7f1841fbc --- /dev/null +++ b/api_server/modules/entities/test/post_entity/parameter_required_test.js @@ -0,0 +1,28 @@ +'use strict'; + +const PostEntityTest = require('./post_entity_test'); + +class ParameterRequiredTest extends PostEntityTest { + + get description () { + return `should return an error when trying to create a New Relic entity with no ${this.parameter}`; + } + + getExpectedError () { + return { + code: 'RAPI-1001', + info: this.parameter + }; + } + + // before the test runs... + before (callback) { + super.before(error => { + if (error) { return callback(error); } + delete this.data[this.parameter]; + callback(); + }); + } +} + +module.exports = ParameterRequiredTest; diff --git a/api_server/modules/entities/test/post_entity/post_entity_test.js b/api_server/modules/entities/test/post_entity/post_entity_test.js new file mode 100644 index 000000000..5a5b71ecc --- /dev/null +++ b/api_server/modules/entities/test/post_entity/post_entity_test.js @@ -0,0 +1,65 @@ +// base class for many tests of the "POST /entities" requests + +'use strict'; + +const Aggregation = require(process.env.CSSVC_BACKEND_ROOT + '/shared/server_utils/aggregation'); +const Assert = require('assert'); +const CodeStreamAPITest = require(process.env.CSSVC_BACKEND_ROOT + '/api_server/lib/test_base/codestream_api_test'); +const EntityTestConstants = require('../entity_test_constants'); +const CommonInit = require('./common_init'); + +class PostEntityTest extends Aggregation(CodeStreamAPITest, CommonInit) { + + constructor (options) { + super(options); + } + + get description () { + return 'should return a valid entity when creating a New Relic entity'; + } + + get method () { + return 'post'; + } + + getExpectedFields () { + return { entity: EntityTestConstants.EXPECTED_ENTITY_FIELDS }; + } + + // before the test runs... + before (callback) { + this.init(callback); + } + + // validate the response to the test request + validateResponse (data) { + // verify we got back an entity with the attributes we specified + const { entity } = data; + const errors = []; + const lastUpdated = this.updatedAfter ? entity.modifiedAt : entity.createdAt; + const updatedAfter = this.updatedAfter || this.createdAfter; + const creatorId = this.otherUserCreatesEntity ? this.users[1].user.id : this.currentUser.user.id; + const expectedVersion = this.expectedVersion || 1; + const result = ( + ((entity.id === entity._id) || errors.push('id not set to _id')) && // DEPRECATE ME + ((entity.entityId === this.data.entityId) || errors.push('entity does not match')) && + ((entity.teamId === this.team.id) || errors.push('teamId does not match the test team')) && + ((entity.companyId === this.company.id) || errors.push('companyId does not match the test company')) && + ((entity.deactivated === false) || errors.push('deactivated not false')) && + ((typeof entity.createdAt === 'number') || errors.push('createdAt is not a number')) && + ((entity.createdAt >= this.createdAfter) || errors.push('createdAt not greater than or equal to when the entity should have been created')) && + ((typeof entity.lastUpdated === 'number') || errors.push('lastUpdated is not a number')) && + ((entity.modifiedAt >= updatedAfter) || errors.push('modifiedAt not greater than or equal to after the entity should have been updated')) && + ((entity.lastUpdated === lastUpdated) || errors.push('lastUpdated not equal to expected value')) && + ((entity.creatorId === creatorId) || errors.push('creatorId not equal to expected creator')) && + ((entity.lastUserId === this.currentUser.user.id) || errors.push('lastUserId not equal to current user id')) && + ((entity.version === expectedVersion) || errors.push('version is incorrect')) + ); + Assert(result === true && errors.length === 0, 'response not valid: ' + errors.join(', ')); + + // verify the entity in the response has no attributes that should not go to clients + this.validateSanitized(entity, EntityTestConstants.UNSANITIZED_ATTRIBUTES); + } +} + +module.exports = PostEntityTest; diff --git a/api_server/modules/entities/test/post_entity/test.js b/api_server/modules/entities/test/post_entity/test.js new file mode 100644 index 000000000..39b4e0db9 --- /dev/null +++ b/api_server/modules/entities/test/post_entity/test.js @@ -0,0 +1,33 @@ +// handle unit tests for the "POST /entities" request, +// to create a New Relic entity + +'use strict'; + +const PostEntityTest = require('./post_entity_test'); +const ParameterRequiredTest = require('./parameter_required_test'); +const InvalidParameterTest = require('./invalid_parameter_test'); +const ACLTest = require('./acl_test'); +const DeactivatedTeamTest = require('./deactivated_team_test'); +const FetchTest = require('./fetch_test'); +const EntityExistsTest = require('./entity_exists_test'); +const EntityExistsOtherUserTest = require('./entity_exists_other_user_test'); +const EntityExistsOtherTeamTest = require('./entity_exists_other_team_test'); + +class PostEntityRequestTester { + + test () { + new PostEntityTest().test(); + new ParameterRequiredTest({ parameter: 'teamId' }).test(); + new ParameterRequiredTest({ parameter: 'entityId' }).test(); + new InvalidParameterTest({ parameter: 'teamId' }).test(); + new InvalidParameterTest({ parameter: 'entityId' }).test(); + new ACLTest().test(); + new DeactivatedTeamTest().test(); + new FetchTest().test(); + new EntityExistsTest().test(); + new EntityExistsOtherUserTest().test(); + new EntityExistsOtherTeamTest().test(); + } +} + +module.exports = new PostEntityRequestTester(); diff --git a/api_server/modules/entities/test/random_entity_factory.js b/api_server/modules/entities/test/random_entity_factory.js new file mode 100644 index 000000000..2a47fa2c5 --- /dev/null +++ b/api_server/modules/entities/test/random_entity_factory.js @@ -0,0 +1,43 @@ +// provide a factory for creating random repos, for testing purposes + +'use strict'; + +const RandomString = require('randomstring'); + +class RandomEntityFactory { + + constructor (options) { + Object.assign(this, options); + } + + // create the entity by submitting a request to the server + createEntity (data, token, callback) { + this.apiRequester.doApiRequest({ + method: 'post', + path: '/entities', + data: data, + token: token + }, callback); + } + + // generate a random entity guid + randomEntityGuid (options = {}) { + return RandomString.generate(79); + } + + // get some random attributes to create a random entity + getRandomEntityData (options = {}) { + let data = { + entityId: this.randomEntityGuid(options) + }; + return data; + } + + // create a random entity in the database + createRandomEntity (callback, options = {}) { + const data = this.getRandomEntityData(options); + this.createEntity(data, options.token, callback); + } +} + +module.exports = RandomEntityFactory; diff --git a/api_server/modules/entities/test/test.js b/api_server/modules/entities/test/test.js new file mode 100644 index 000000000..f86dcd3b3 --- /dev/null +++ b/api_server/modules/entities/test/test.js @@ -0,0 +1,17 @@ +// handle unit tests for the entities module + +'use strict'; + +// make eslint happy +/* globals describe */ + +const GetEntityRequestTester = require('./get_entity/test'); +const GetEntitiesRequestTester = require('./get_entities/test'); +const PostEntityRequestTester = require('./post_entity/test'); + +describe('entity requests', function() { + + describe('GET /entities/:id', GetEntityRequestTester.test); + describe('GET /entities', GetEntitiesRequestTester.test); + describe('POST /entities', PostEntityRequestTester.test); +}); diff --git a/api_server/modules/inbound_emails/test/tracking_test.js b/api_server/modules/inbound_emails/test/tracking_test.js index 4fd965e15..68b10df4e 100644 --- a/api_server/modules/inbound_emails/test/tracking_test.js +++ b/api_server/modules/inbound_emails/test/tracking_test.js @@ -106,12 +106,14 @@ class TrackingTest extends InboundEmailMessageTest { event: 'Reply Created', messageId: data.messageId || '', timestamp: data.timestamp || '', + anonymousId: data.anonymousId || '', type: 'track', properties: { //user_id: this.currentUser.user.nrUserId, platform: 'codestream', path: 'N/A (codestream)', section: 'N/A (codestream)', + session_id: data.properties.session_id || '', meta_data_15: JSON.stringify(expectedMetaData), 'Parent ID': parentId, 'Parent Type': parentType, diff --git a/api_server/modules/reviews/test/unfollow_link/tracking_test.js b/api_server/modules/reviews/test/unfollow_link/tracking_test.js index 5b6d3c325..743d9de23 100644 --- a/api_server/modules/reviews/test/unfollow_link/tracking_test.js +++ b/api_server/modules/reviews/test/unfollow_link/tracking_test.js @@ -73,12 +73,14 @@ class TrackingTest extends Aggregation(CodeStreamMessageTest, CommonInit) { event: 'Notification Change', messageId: data.messageId || '', timestamp: data.timestamp || '', + anonymousId: data.anonymousId || '', type: 'track', properties: { //user_id: this.currentUser.user.nrUserId, platform: 'codestream', path: 'N/A (codestream)', section: 'N/A (codestream)', + session_id: data.properties.session_id || '', meta_data_15: JSON.stringify(expectedMetaData), meta_data_14: 'change: review_unfollowed', meta_data_13: 'source_of_change: email_link' diff --git a/api_server/modules/test.js b/api_server/modules/test.js index c7bd82a47..fa7150229 100644 --- a/api_server/modules/test.js +++ b/api_server/modules/test.js @@ -27,4 +27,5 @@ describe('modules', () => { require('./marker_locations/test/test.js'); require('./newrelic_comments/test/test.js'); require('./environment_manager/test/test.js'); + require('./entities/test/test.js'); }); diff --git a/api_server/modules/users/test/unsubscribe_notification/tracking_test.js b/api_server/modules/users/test/unsubscribe_notification/tracking_test.js index b02d5df4b..a73bcb832 100644 --- a/api_server/modules/users/test/unsubscribe_notification/tracking_test.js +++ b/api_server/modules/users/test/unsubscribe_notification/tracking_test.js @@ -64,12 +64,14 @@ class TrackingTest extends Aggregation(CodeStreamMessageTest, CommonInit) { event: 'codestream/email_unsubscribe succeeded', messageId: data.messageId || '', timestamp: data.timestamp || '', + anonymousId: data.anonymousId || '', type: 'track', properties: { //user_id: this.currentUser.user.nrUserId, platform: 'codestream', path: 'N/A (codestream)', section: 'N/A (codestream)', + session_id: data.properties.session_id || '', meta_data_15: JSON.stringify(expectedMetaData), 'meta_data': 'email_type: discussion', 'event_type': 'response' diff --git a/api_server/modules/users/test/unsubscribe_reminder/tracking_test.js b/api_server/modules/users/test/unsubscribe_reminder/tracking_test.js index 8f9de8bc2..d1fa624f0 100644 --- a/api_server/modules/users/test/unsubscribe_reminder/tracking_test.js +++ b/api_server/modules/users/test/unsubscribe_reminder/tracking_test.js @@ -64,12 +64,14 @@ class TrackingTest extends Aggregation(CodeStreamMessageTest, CommonInit) { event: 'codestream/email_unsubscribe succeeded', messageId: data.messageId || '', timestamp: data.timestamp || '', + anonymousId: data.anonymousId || '', type: 'track', properties: { //user_id: this.currentUser.user.nrUserId, platform: 'codestream', path: 'N/A (codestream)', section: 'N/A (codestream)', + session_id: data.properties.session_id || '', meta_data_15: JSON.stringify(expectedMetaData), 'meta_data': 'email_type: reminder', 'event_type': 'response' diff --git a/api_server/modules/users/test/unsubscribe_weekly/tracking_test.js b/api_server/modules/users/test/unsubscribe_weekly/tracking_test.js index c33d94514..c48e2b3f8 100644 --- a/api_server/modules/users/test/unsubscribe_weekly/tracking_test.js +++ b/api_server/modules/users/test/unsubscribe_weekly/tracking_test.js @@ -68,12 +68,14 @@ class TrackingTest extends Aggregation(CodeStreamMessageTest, CommonInit) { event: 'codestream/email_unsubscribe succeeded', messageId: data.messageId || '', timestamp: data.timestamp || '', + anonymousId: data.anonymousId || '', type: 'track', properties: { //user_id: this.currentUser.user.nrUserId, platform: 'codestream', path: 'N/A (codestream)', section: 'N/A (codestream)', + session_id: data.properties.session_id || '', meta_data_15: JSON.stringify(expectedMetaData), 'meta_data': 'email_type: weekly_activity', 'event_type': 'response' diff --git a/api_server/modules/users/user.js b/api_server/modules/users/user.js index ff41431d6..ea972d408 100644 --- a/api_server/modules/users/user.js +++ b/api_server/modules/users/user.js @@ -82,6 +82,8 @@ class User extends CodeStreamModel { return await this.authorizeReview(id, request, options); case 'codeError': return await this.authorizeCodeError(id, request, options); + case 'entity': + return await this.authorizeEntity(id, request, options); case 'user': return await this.authorizeUser(id, request, options); default: @@ -240,6 +242,17 @@ class User extends CodeStreamModel { return authorized ? codeError : false; } + // authorize the user to "access" a New Relic entity, based on ID + async authorizeEntity (id, request, options) { + const entity = await request.data.entities.getById(id, options); + if (!entity) { + throw request.errorHandler.error('notFound', { info: 'entity' }); + } + // to access an entity, the user must be on the team that owns it + const authorized = entity.get('teamId') && this.hasTeam(entity.get('teamId')); + return authorized ? entity : false; + } + // authorize the user to "access" a user model, based on ID async authorizeUser (id, request) { // user can always access their own me-object diff --git a/api_server/modules/web/styles/web.css b/api_server/modules/web/styles/web.css index d6b1d89d9..b6024021d 100644 --- a/api_server/modules/web/styles/web.css +++ b/api_server/modules/web/styles/web.css @@ -192,6 +192,42 @@ h6 { color: white!important; text-decoration: underline; } +.content-body-container { + max-width: 80%; + margin: 0 auto; + text-align: left; +} +.content-body-section-header { + margin-top: 28px; +} +.content-body-header { + font-size: xx-large; +} +.content-body-tab-container { + margin-top: 32px; + margin-bottom: 10px; + border-bottom: 1px solid #3c3c3c; + display: flex; + justify-content: space-between; +} +.content-body-tab-header { + font-size: larger; + cursor: pointer; + display: inline-block; + text-align: center; + padding-bottom: 5px; + margin-bottom: -1px; +} +.content-body-copy { + margin-bottom: 12px; +} +.content-body-tab { + display: none; +} +.content-active-tab { + /* display: block !important; */ + border-bottom: 1px solid; +} .box-border { margin-top: 1rem; padding: 1.5rem; @@ -550,7 +586,7 @@ hr { .interstitial-detail-wrapper { padding: 30px; - margin: 0 0 40px 0; + margin: 10px 0 10px 0; max-width: 704px; margin-right: auto; margin-left: auto; @@ -637,8 +673,21 @@ hr { .observability-gif { width: 100%; + border-radius: 8px; + margin-top: 10px; } +@media (max-width: 768px) { + .content-body-tab-icon { + display: none !important; + } + .content-body-tab-header { + display: flex; + align-items: center; + justify-content: center; + } + } + @media (min-width: 1px) { .btn-block-wrap { width: 100%; @@ -767,11 +816,11 @@ span.dropdown-item:hover { } .navbar-light.bg-light { background: #fff !important; - margin: 0 0 80px 0; + margin: 0 0 0 0; } .navbar-dark.bg-dark { /* background: #1D252C !important; */ - margin: 0 0 80px 0; + margin: 0 0 0 0; border-bottom: 1px solid #2a3036; padding: 15px 0 15px 0; } @@ -796,10 +845,11 @@ span.dropdown-item:hover { } .form-control { border: 1px solid #383838 !important; + padding: 6px 0 0 0 !important; } .btn-light.form-control { border: 1px solid rgb(227, 228, 228) !important; - height: 50px; + /* height: 50px; */ } .lines-added { color: #66aa66; diff --git a/api_server/package-lock.json b/api_server/package-lock.json index 35126c983..1e09c0822 100644 --- a/api_server/package-lock.json +++ b/api_server/package-lock.json @@ -1,12 +1,12 @@ { "name": "api-server", - "version": "15.3.0", + "version": "15.4.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "api-server", - "version": "15.3.0", + "version": "15.4.0", "dependencies": { "@microsoft/microsoft-graph-client": "3.0.7", "@octokit/rest": "18.12.0", @@ -61,7 +61,7 @@ "strftime": "0.10.2", "stripe": "8.217.0", "toposort": "2.0.2", - "undici": "5.28.3", + "undici": "5.28.4", "uuid": "8.3.2", "xregexp": "5.1.1" }, @@ -11588,9 +11588,9 @@ } }, "node_modules/tar": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz", - "integrity": "sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", @@ -11880,9 +11880,9 @@ "dev": true }, "node_modules/undici": { - "version": "5.28.3", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.3.tgz", - "integrity": "sha512-3ItfzbrhDlINjaP0duwnNsKpDQk3acHI3gVJ1z4fmwMK31k5G9OVIAMLSIaP6w4FaGkaAkN6zaQO9LUvZ1t7VA==", + "version": "5.28.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz", + "integrity": "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==", "dependencies": { "@fastify/busboy": "^2.0.0" }, @@ -21541,9 +21541,9 @@ } }, "tar": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz", - "integrity": "sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", "requires": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", @@ -21772,9 +21772,9 @@ "dev": true }, "undici": { - "version": "5.28.3", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.3.tgz", - "integrity": "sha512-3ItfzbrhDlINjaP0duwnNsKpDQk3acHI3gVJ1z4fmwMK31k5G9OVIAMLSIaP6w4FaGkaAkN6zaQO9LUvZ1t7VA==", + "version": "5.28.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz", + "integrity": "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==", "requires": { "@fastify/busboy": "^2.0.0" } diff --git a/api_server/package.json b/api_server/package.json index d98b89a4d..213457b58 100644 --- a/api_server/package.json +++ b/api_server/package.json @@ -55,7 +55,7 @@ "strftime": "0.10.2", "stripe": "8.217.0", "toposort": "2.0.2", - "undici": "5.28.3", + "undici": "5.28.4", "uuid": "8.3.2", "xregexp": "5.1.1" }, diff --git a/codestream-docker.json b/codestream-docker.json index 950940454..d0c666343 100644 --- a/codestream-docker.json +++ b/codestream-docker.json @@ -248,7 +248,8 @@ "telemetry": { "segment": { "token": "${TELEMETRY_SEGMENT_TOKEN}", - "webToken": "${TELEMETRY_SEGMENT_WEB_TOKEN}" + "webToken": "${TELEMETRY_SEGMENT_WEB_TOKEN}", + "telemetryEndpoint": "${TELEMETRY_ENDPOINT}" } }, "universalSecrets": { diff --git a/docker-compose.yml b/docker-compose.yml index a9368e4da..0c86ce57f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -63,6 +63,8 @@ services: - INTEGRATIONS_TRELLO_CLOUD_APP_CLIENT_ID - TELEMETRY_SEGMENT_TOKEN - TELEMETRY_SEGMENT_WEB_TOKEN + - TELEMETRY_ENDPOINT + - TELEMETRY_ - UNIVERSAL_SECRETS_TELEMETRY volumes: mongodata: diff --git a/outbound_email/CHANGELOG.md b/outbound_email/CHANGELOG.md index 5614d76e0..21b7772ed 100644 --- a/outbound_email/CHANGELOG.md +++ b/outbound_email/CHANGELOG.md @@ -1,6 +1,6 @@ # Change Log -## [15.4.0] - 2024-4-4 +## [15.4.0] - 2024-4-17 ### Changed