AnalyticsManagerTests.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456
  1. const SandboxedModule = require('sandboxed-module')
  2. const path = require('path')
  3. const sinon = require('sinon')
  4. const MockRequest = require('../helpers/MockRequest')
  5. const MockResponse = require('../helpers/MockResponse')
  6. const { assert } = require('chai')
  7. const { ObjectId } = require('mongodb-legacy')
  8. const MODULE_PATH = path.join(
  9. __dirname,
  10. '../../../../app/src/Features/Analytics/AnalyticsManager'
  11. )
  12. describe('AnalyticsManager', function () {
  13. beforeEach(function () {
  14. this.fakeUserId = 'dbfc9438d14996f73dd172fb'
  15. this.analyticsId = 'ecdb935a-52f3-4f91-aebc-7a70d2ffbb55'
  16. this.Settings = {
  17. analytics: { enabled: true, hashedEmailSalt: 'salt' },
  18. }
  19. this.analyticsEventsQueue = {
  20. add: sinon.stub().resolves(),
  21. process: sinon.stub().resolves(),
  22. }
  23. this.analyticsEditingSessionQueue = {
  24. add: sinon.stub().resolves(),
  25. process: sinon.stub().resolves(),
  26. }
  27. this.onboardingEmailsQueue = {
  28. add: sinon.stub().resolves(),
  29. process: sinon.stub().resolves(),
  30. }
  31. this.analyticsUserPropertiesQueue = {
  32. add: sinon.stub().resolves(),
  33. process: sinon.stub().resolves(),
  34. }
  35. this.analyticsAccountMappingQueue = {
  36. add: sinon.stub().resolves(),
  37. process: sinon.stub().resolves(),
  38. }
  39. this.analyticsEmailChangeQueue = {
  40. add: sinon.stub().resolves(),
  41. process: sinon.stub().resolves(),
  42. }
  43. this.Queues = {
  44. getQueue: queueName => {
  45. switch (queueName) {
  46. case 'analytics-events':
  47. return this.analyticsEventsQueue
  48. case 'analytics-editing-sessions':
  49. return this.analyticsEditingSessionQueue
  50. case 'emails-onboarding':
  51. return this.onboardingEmailsQueue
  52. case 'analytics-user-properties':
  53. return this.analyticsUserPropertiesQueue
  54. case 'analytics-account-mapping':
  55. return this.analyticsAccountMappingQueue
  56. case 'analytics-email-change':
  57. return this.analyticsEmailChangeQueue
  58. default:
  59. throw new Error('Unexpected queue name')
  60. }
  61. },
  62. createScheduledJob: sinon.stub().resolves(),
  63. }
  64. this.backgroundRequest = sinon.stub().yields()
  65. this.request = sinon.stub().yields()
  66. this.AnalyticsManager = SandboxedModule.require(MODULE_PATH, {
  67. requires: {
  68. '@overleaf/settings': this.Settings,
  69. '../../infrastructure/Queues': this.Queues,
  70. './UserAnalyticsIdCache': (this.UserAnalyticsIdCache = {
  71. get: sinon.stub().resolves(this.analyticsId),
  72. }),
  73. },
  74. })
  75. })
  76. describe('ignores when', function () {
  77. it('user is smoke test user', function () {
  78. this.Settings.smokeTest = { userId: this.fakeUserId }
  79. this.AnalyticsManager.identifyUser(this.fakeUserId, '')
  80. sinon.assert.notCalled(this.Queues.createScheduledJob)
  81. })
  82. it('analytics service is disabled', function () {
  83. this.Settings.analytics.enabled = false
  84. this.AnalyticsManager.identifyUser(this.fakeUserId, '')
  85. sinon.assert.notCalled(this.Queues.createScheduledJob)
  86. })
  87. it('userId is missing', function () {
  88. this.AnalyticsManager.identifyUser(undefined, this.analyticsId)
  89. sinon.assert.notCalled(this.Queues.createScheduledJob)
  90. })
  91. it('analyticsId is missing', function () {
  92. this.AnalyticsManager.identifyUser(
  93. new ObjectId(this.fakeUserId),
  94. undefined
  95. )
  96. sinon.assert.notCalled(this.Queues.createScheduledJob)
  97. })
  98. it('analyticsId is not a valid UUID', function () {
  99. this.AnalyticsManager.identifyUser(
  100. new ObjectId(this.fakeUserId),
  101. this.fakeUserId
  102. )
  103. sinon.assert.notCalled(this.Queues.createScheduledJob)
  104. })
  105. it('userId and analyticsId are the same Mongo ID', function () {
  106. this.AnalyticsManager.identifyUser(
  107. new ObjectId(this.fakeUserId),
  108. new ObjectId(this.fakeUserId)
  109. )
  110. sinon.assert.notCalled(this.Queues.createScheduledJob)
  111. })
  112. it('editing session segmentation is not valid', function () {
  113. this.AnalyticsManager.updateEditingSession(
  114. this.fakeUserId,
  115. '789ghi',
  116. 'fr',
  117. { '<alert>': 'foo' }
  118. )
  119. sinon.assert.called(this.logger.info)
  120. sinon.assert.notCalled(this.analyticsEditingSessionQueue.add)
  121. })
  122. it('event is not valid', async function () {
  123. await this.AnalyticsManager.recordEventForUser(
  124. this.fakeUserId,
  125. 'not an event!'
  126. )
  127. sinon.assert.called(this.logger.info)
  128. sinon.assert.notCalled(this.analyticsEventsQueue.add)
  129. })
  130. it('event segmentation is not valid', async function () {
  131. await this.AnalyticsManager.recordEventForUser(
  132. this.fakeUserId,
  133. 'an_event',
  134. { 'not_a!': 'Valid Segmentation' }
  135. )
  136. sinon.assert.called(this.logger.info)
  137. sinon.assert.notCalled(this.analyticsEventsQueue.add)
  138. })
  139. it('user property name is not valid', async function () {
  140. await this.AnalyticsManager.setUserPropertyForUser(
  141. this.fakeUserId,
  142. 'an invalid property',
  143. 'a_value'
  144. )
  145. sinon.assert.called(this.logger.info)
  146. sinon.assert.notCalled(this.analyticsUserPropertiesQueue.add)
  147. })
  148. it('user property value is not valid', async function () {
  149. await this.AnalyticsManager.setUserPropertyForUser(
  150. this.fakeUserId,
  151. 'a_property',
  152. 'an invalid value'
  153. )
  154. sinon.assert.called(this.logger.info)
  155. sinon.assert.notCalled(this.analyticsUserPropertiesQueue.add)
  156. })
  157. })
  158. describe('queues the appropriate message for', function () {
  159. it('identifyUser', function () {
  160. const analyticsId = 'bd101c4c-722f-4204-9e2d-8303e5d9c120'
  161. this.AnalyticsManager.identifyUser(this.fakeUserId, analyticsId, true)
  162. sinon.assert.notCalled(this.logger.info)
  163. sinon.assert.calledWithMatch(
  164. this.Queues.createScheduledJob,
  165. 'analytics-events',
  166. {
  167. name: 'identify',
  168. data: {
  169. userId: this.fakeUserId,
  170. analyticsId,
  171. isNewUser: true,
  172. createdAt: sinon.match.date,
  173. },
  174. },
  175. 60000
  176. )
  177. })
  178. it('recordEventForUser', async function () {
  179. const event = 'fake-event'
  180. await this.AnalyticsManager.recordEventForUser(
  181. this.fakeUserId,
  182. event,
  183. null
  184. )
  185. sinon.assert.notCalled(this.logger.info)
  186. sinon.assert.calledWithMatch(this.analyticsEventsQueue.add, 'event', {
  187. analyticsId: this.analyticsId,
  188. event,
  189. segmentation: null,
  190. isLoggedIn: true,
  191. })
  192. })
  193. it('updateEditingSession', function () {
  194. const projectId = '789ghi'
  195. const countryCode = 'fr'
  196. const segmentation = { editorType: 'abc' }
  197. this.AnalyticsManager.updateEditingSession(
  198. this.fakeUserId,
  199. projectId,
  200. countryCode,
  201. segmentation
  202. )
  203. sinon.assert.notCalled(this.logger.info)
  204. sinon.assert.calledWithMatch(
  205. this.analyticsEditingSessionQueue.add,
  206. 'editing-session',
  207. {
  208. userId: this.fakeUserId,
  209. projectId,
  210. countryCode,
  211. segmentation,
  212. }
  213. )
  214. })
  215. it('empty field in event segmentation', async function () {
  216. const timings = null
  217. await this.AnalyticsManager.recordEventForUser(
  218. this.fakeUserId,
  219. 'an_event',
  220. { compileTime: timings?.compileE2E }
  221. )
  222. sinon.assert.notCalled(this.logger.info)
  223. sinon.assert.calledWithMatch(this.analyticsEventsQueue.add, 'event', {
  224. analyticsId: this.analyticsId,
  225. event: 'an_event',
  226. segmentation: { compileTime: undefined },
  227. isLoggedIn: true,
  228. })
  229. })
  230. it('empty space in event segmentation value', async function () {
  231. await this.AnalyticsManager.recordEventForUser(
  232. this.fakeUserId,
  233. 'an_event',
  234. { segment: 'a value with spaces' }
  235. )
  236. sinon.assert.notCalled(this.logger.info)
  237. sinon.assert.calledWithMatch(this.analyticsEventsQueue.add, 'event', {
  238. analyticsId: this.analyticsId,
  239. event: 'an_event',
  240. segmentation: { segment: 'a value with spaces' },
  241. isLoggedIn: true,
  242. })
  243. })
  244. it('percent sign in event segmentation value', async function () {
  245. await this.AnalyticsManager.recordEventForUser(
  246. this.fakeUserId,
  247. 'an_event',
  248. { segment: 'a value with escaped comma %2C' }
  249. )
  250. sinon.assert.notCalled(this.logger.info)
  251. sinon.assert.calledWithMatch(this.analyticsEventsQueue.add, 'event', {
  252. analyticsId: this.analyticsId,
  253. event: 'an_event',
  254. segmentation: { segment: 'a value with escaped comma %2C' },
  255. isLoggedIn: true,
  256. })
  257. })
  258. it('boolean field in event segmentation', async function () {
  259. await this.AnalyticsManager.recordEventForUser(
  260. this.fakeUserId,
  261. 'an_event',
  262. { isAutoCompile: false }
  263. )
  264. sinon.assert.notCalled(this.logger.info)
  265. sinon.assert.calledWithMatch(this.analyticsEventsQueue.add, 'event', {
  266. analyticsId: this.analyticsId,
  267. event: 'an_event',
  268. segmentation: { isAutoCompile: false },
  269. isLoggedIn: true,
  270. })
  271. })
  272. it('account mapping', async function () {
  273. const message = {
  274. source: 'salesforce',
  275. sourceEntity: 'account',
  276. sourceEntityId: 'abc123abc123abc123',
  277. target: 'v1',
  278. targetEntity: 'university',
  279. targetEntityId: 1,
  280. createdAt: '2021-01-01T00:00:00Z',
  281. }
  282. await this.AnalyticsManager.registerAccountMapping(message)
  283. sinon.assert.calledWithMatch(
  284. this.analyticsAccountMappingQueue.add,
  285. 'account-mapping',
  286. message
  287. )
  288. })
  289. it('email change', async function () {
  290. const message = {
  291. userId: this.fakeUserId,
  292. email: 'test@example.com',
  293. createdAt: '2021-01-01T00:00:00Z',
  294. action: 'created',
  295. emailCreatedAt: '2021-01-01T00:00:00Z',
  296. isPrimary: false,
  297. }
  298. this.AnalyticsManager.registerEmailChange(message)
  299. const convertedMessage = {
  300. ...message,
  301. emailConfirmedAt: undefined,
  302. emailDeletedAt: undefined,
  303. email:
  304. '1778d425d64c5259ef7b574a2488647eb51ca739a0b16bfa0e2e3e16fff362db', // sha256 hash of email + salt
  305. }
  306. sinon.assert.calledWithMatch(
  307. this.analyticsEmailChangeQueue.add,
  308. 'email-change',
  309. convertedMessage
  310. )
  311. })
  312. })
  313. describe('AnalyticsIdMiddleware', function () {
  314. beforeEach(function () {
  315. this.userId = '123abc'
  316. this.analyticsId = 'bccd308c-5d72-426e-a106-662e88557795'
  317. this.AnalyticsManager = SandboxedModule.require(MODULE_PATH, {
  318. requires: {
  319. '@overleaf/settings': {
  320. analytics: { hashedEmailSalt: 'test-salt' },
  321. },
  322. '../../infrastructure/Queues': {
  323. getQueue: queueName => {
  324. switch (queueName) {
  325. case 'analytics-events':
  326. return this.analyticsEventsQueue
  327. case 'analytics-editing-sessions':
  328. return this.analyticsEditingSessionQueue
  329. case 'emails-onboarding':
  330. return this.onboardingEmailsQueue
  331. case 'analytics-user-properties':
  332. return this.analyticsUserPropertiesQueue
  333. case 'analytics-account-mapping':
  334. return this.analyticsAccountMappingQueue
  335. case 'analytics-email-change':
  336. return this.analyticsEmailChangeQueue
  337. default:
  338. throw new Error('Unexpected queue name')
  339. }
  340. },
  341. },
  342. './UserAnalyticsIdCache': (this.UserAnalyticsIdCache = {
  343. get: sinon.stub().resolves(this.analyticsId),
  344. }),
  345. crypto: {
  346. randomUUID: () => this.analyticsId,
  347. },
  348. },
  349. })
  350. this.req = new MockRequest()
  351. this.req.session = {}
  352. this.res = new MockResponse()
  353. this.next = () => {}
  354. })
  355. it('sets session.analyticsId with no user in session', async function () {
  356. await this.AnalyticsManager.analyticsIdMiddleware(
  357. this.req,
  358. this.res,
  359. this.next
  360. )
  361. assert.equal(this.analyticsId, this.req.session.analyticsId)
  362. })
  363. it('does not update analyticsId when existing, with no user in session', async function () {
  364. this.req.session.analyticsId = 'foo'
  365. await this.AnalyticsManager.analyticsIdMiddleware(
  366. this.req,
  367. this.res,
  368. this.next
  369. )
  370. assert.equal('foo', this.req.session.analyticsId)
  371. })
  372. it('sets session.analyticsId with a logged in user in session having an analyticsId', async function () {
  373. this.req.session.user = {
  374. _id: this.userId,
  375. analyticsId: this.analyticsId,
  376. }
  377. await this.AnalyticsManager.analyticsIdMiddleware(
  378. this.req,
  379. this.res,
  380. () => {
  381. assert.equal(this.analyticsId, this.req.session.analyticsId)
  382. }
  383. )
  384. })
  385. it('sets session.analyticsId with a legacy user session without an analyticsId', async function () {
  386. this.UserAnalyticsIdCache.get.resolves(this.userId)
  387. this.req.session.user = {
  388. _id: this.userId,
  389. analyticsId: undefined,
  390. }
  391. await this.AnalyticsManager.analyticsIdMiddleware(
  392. this.req,
  393. this.res,
  394. () => {
  395. assert.equal(this.userId, this.req.session.analyticsId)
  396. }
  397. )
  398. })
  399. it('updates session.analyticsId with a legacy user session without an analyticsId if different', async function () {
  400. this.UserAnalyticsIdCache.get.resolves(this.userId)
  401. this.req.session.user = {
  402. _id: this.userId,
  403. analyticsId: undefined,
  404. }
  405. this.req.analyticsId = 'foo'
  406. this.AnalyticsManager.analyticsIdMiddleware(this.req, this.res, () => {
  407. assert.equal(this.userId, this.req.session.analyticsId)
  408. })
  409. })
  410. it('does not update session.analyticsId with a legacy user session without an analyticsId if same', async function () {
  411. this.UserAnalyticsIdCache.get.resolves(this.userId)
  412. this.req.session.user = {
  413. _id: this.userId,
  414. analyticsId: undefined,
  415. }
  416. this.req.analyticsId = this.userId
  417. await this.AnalyticsManager.analyticsIdMiddleware(
  418. this.req,
  419. this.res,
  420. () => {
  421. assert.equal(this.userId, this.req.session.analyticsId)
  422. }
  423. )
  424. })
  425. })
  426. })