|
@@ -0,0 +1,290 @@
|
|
|
|
|
+import { promisify } from 'node:util'
|
|
|
|
|
+import { exec } from 'node:child_process'
|
|
|
|
|
+import { expect } from 'chai'
|
|
|
|
|
+import { filterOutput } from './helpers/settings.mjs'
|
|
|
|
|
+import { db, ObjectId } from '../../../app/src/infrastructure/mongodb.js'
|
|
|
|
|
+import fs from 'node:fs/promises'
|
|
|
|
|
+
|
|
|
|
|
+const CSV_FILENAME = '/tmp/remove_unconfirmed_emails.csv'
|
|
|
|
|
+
|
|
|
|
|
+async function runScript(mode, commit) {
|
|
|
|
|
+ const result = await promisify(exec)(
|
|
|
|
|
+ [
|
|
|
|
|
+ 'node',
|
|
|
|
|
+ 'scripts/remove_unconfirmed_emails.mjs',
|
|
|
|
|
+ mode === 'generate' ? '--generate' : '--consume',
|
|
|
|
|
+ commit && '--commit',
|
|
|
|
|
+ ]
|
|
|
|
|
+ .filter(Boolean)
|
|
|
|
|
+ .join(' ')
|
|
|
|
|
+ )
|
|
|
|
|
+ return {
|
|
|
|
|
+ ...result,
|
|
|
|
|
+ stdout: result.stdout.split('\n').filter(filterOutput),
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function createUser(signUpDate, emails, userIdx) {
|
|
|
|
|
+ const email = `primary${userIdx ?? ''}@overleaf.com`
|
|
|
|
|
+ return {
|
|
|
|
|
+ _id: new ObjectId(),
|
|
|
|
|
+ email,
|
|
|
|
|
+ emails,
|
|
|
|
|
+ signUpDate,
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+describe('scripts/remove_unconfirmed_emails', function () {
|
|
|
|
|
+ let user
|
|
|
|
|
+
|
|
|
|
|
+ afterEach(async function () {
|
|
|
|
|
+ try {
|
|
|
|
|
+ await fs.unlink(CSV_FILENAME)
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ // Ignore errors if file doesn't exist
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ describe('when removing unconfirmed secondary emails', function () {
|
|
|
|
|
+ beforeEach(async function () {
|
|
|
|
|
+ user = createUser(new Date('2000-01-01'), [
|
|
|
|
|
+ { email: 'primary@overleaf.com', confirmedAt: new Date() },
|
|
|
|
|
+ { email: 'unconfirmed1@overleaf.com' },
|
|
|
|
|
+ { email: 'unconfirmed-special-,\'"@overleaf.com' },
|
|
|
|
|
+ ])
|
|
|
|
|
+ await db.users.insertOne(user)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it('should remove all unconfirmed secondary emails', async function () {
|
|
|
|
|
+ await runScript('generate')
|
|
|
|
|
+ const r = await runScript('consume', true)
|
|
|
|
|
+
|
|
|
|
|
+ expect(r.stdout).to.include('Total emails in the CSV: 2')
|
|
|
|
|
+ expect(r.stdout).to.include('Total users processed: 1')
|
|
|
|
|
+
|
|
|
|
|
+ const updatedUser = await db.users.findOne({ _id: user._id })
|
|
|
|
|
+ expect(updatedUser.emails).to.have.length(1)
|
|
|
|
|
+ expect(updatedUser.emails[0].email).to.equal(user.email)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it('should not modify anything in dry run mode', async function () {
|
|
|
|
|
+ await runScript('generate')
|
|
|
|
|
+ const r = await runScript('consume', false)
|
|
|
|
|
+
|
|
|
|
|
+ expect(r.stdout).to.include('Total emails in the CSV: 2')
|
|
|
|
|
+ expect(r.stdout).to.include('Total users processed: 1')
|
|
|
|
|
+ expect(r.stdout).to.include(
|
|
|
|
|
+ 'Note: this was a dry-run. No changes were made.'
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ const updatedUser = await db.users.findOne({ _id: user._id })
|
|
|
|
|
+ expect(updatedUser.emails).to.have.length(3)
|
|
|
|
|
+ })
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ describe('when handling confirmed secondary emails', function () {
|
|
|
|
|
+ beforeEach(async function () {
|
|
|
|
|
+ user = createUser(new Date('2000-01-01'), [
|
|
|
|
|
+ { email: 'primary@overleaf.com', confirmedAt: new Date() },
|
|
|
|
|
+ { email: 'confirmed@overleaf.com', confirmedAt: new Date() },
|
|
|
|
|
+ ])
|
|
|
|
|
+ await db.users.insertOne(user)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it('should preserve confirmed secondary emails', async function () {
|
|
|
|
|
+ await runScript('generate')
|
|
|
|
|
+ const r = await runScript('consume', true)
|
|
|
|
|
+
|
|
|
|
|
+ expect(r.stdout).to.include('Total emails in the CSV: 0')
|
|
|
|
|
+ expect(r.stdout).to.include('Total users processed: 0')
|
|
|
|
|
+
|
|
|
|
|
+ const updatedUser = await db.users.findOne({ _id: user._id })
|
|
|
|
|
+ expect(updatedUser.emails).to.have.length(2)
|
|
|
|
|
+ expect(updatedUser.emails[1].confirmedAt).to.exist
|
|
|
|
|
+ })
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ describe('when handling unconfirmed primary emails', function () {
|
|
|
|
|
+ beforeEach(async function () {
|
|
|
|
|
+ user = createUser(new Date('2000-01-01'), [
|
|
|
|
|
+ { email: 'primary@overleaf.com' },
|
|
|
|
|
+ ])
|
|
|
|
|
+ await db.users.insertOne(user)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it('should not remove unconfirmed primary emails', async function () {
|
|
|
|
|
+ await runScript('generate')
|
|
|
|
|
+ const r = await runScript('consume', true)
|
|
|
|
|
+
|
|
|
|
|
+ expect(r.stdout).to.include('Total emails in the CSV: 0')
|
|
|
|
|
+ expect(r.stdout).to.include('Total users processed: 0')
|
|
|
|
|
+
|
|
|
|
|
+ const updatedUser = await db.users.findOne({ _id: user._id })
|
|
|
|
|
+ expect(updatedUser.emails).to.have.length(1)
|
|
|
|
|
+ expect(updatedUser.emails[0].email).to.equal('primary@overleaf.com')
|
|
|
|
|
+ })
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ describe('when users confirmed their email in between', function () {
|
|
|
|
|
+ beforeEach(async function () {
|
|
|
|
|
+ user = createUser(new Date('2000-01-01'), [
|
|
|
|
|
+ { email: 'primary@overleaf.com' },
|
|
|
|
|
+ { email: 'secondary@overleaf.com' },
|
|
|
|
|
+ ])
|
|
|
|
|
+ await db.users.insertOne(user)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it('should not remove emails from users who confirmed their email in between', async function () {
|
|
|
|
|
+ await runScript('generate')
|
|
|
|
|
+
|
|
|
|
|
+ await db.users.updateOne(
|
|
|
|
|
+ { _id: user._id },
|
|
|
|
|
+ { $set: { 'emails.1.confirmedAt': new Date() } }
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ const r = await runScript('consume', true)
|
|
|
|
|
+
|
|
|
|
|
+ expect(r.stdout).to.include('Total emails in the CSV: 1')
|
|
|
|
|
+ expect(r.stdout).to.include('Skipped emails: 1')
|
|
|
|
|
+ expect(r.stdout).to.include(' - Email now confirmed: 1')
|
|
|
|
|
+
|
|
|
|
|
+ const updatedUser = await db.users.findOne({ _id: user._id })
|
|
|
|
|
+ expect(updatedUser.emails).to.have.length(2)
|
|
|
|
|
+ })
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ describe('when users changed their primary email in between', function () {
|
|
|
|
|
+ beforeEach(async function () {
|
|
|
|
|
+ user = createUser(new Date('2000-01-01'), [
|
|
|
|
|
+ { email: 'primary@overleaf.com' },
|
|
|
|
|
+ { email: 'secondary@overleaf.com' },
|
|
|
|
|
+ ])
|
|
|
|
|
+ await db.users.insertOne(user)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it('should not remove emails from users who changed their primary email in between', async function () {
|
|
|
|
|
+ await runScript('generate')
|
|
|
|
|
+
|
|
|
|
|
+ await db.users.updateOne(
|
|
|
|
|
+ { _id: user._id },
|
|
|
|
|
+ { $set: { email: 'secondary@overleaf.com' } }
|
|
|
|
|
+ )
|
|
|
|
|
+ const r = await runScript('consume', true)
|
|
|
|
|
+
|
|
|
|
|
+ expect(r.stdout).to.include('Total emails in the CSV: 1')
|
|
|
|
|
+ expect(r.stdout).to.include('Skipped emails: 1')
|
|
|
|
|
+ expect(r.stdout).to.include(' - Email now primary: 1')
|
|
|
|
|
+
|
|
|
|
|
+ const updatedUser = await db.users.findOne({ _id: user._id })
|
|
|
|
|
+ expect(updatedUser.emails).to.have.length(2)
|
|
|
|
|
+ })
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ describe('when users account was deleted in between', function () {
|
|
|
|
|
+ beforeEach(async function () {
|
|
|
|
|
+ user = createUser(new Date('2000-01-01'), [
|
|
|
|
|
+ { email: 'primary@overleaf.com' },
|
|
|
|
|
+ { email: 'secondary@overleaf.com' },
|
|
|
|
|
+ ])
|
|
|
|
|
+ await db.users.insertOne(user)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it('should skip emails from users whose account was deleted', async function () {
|
|
|
|
|
+ await runScript('generate')
|
|
|
|
|
+
|
|
|
|
|
+ // Delete the user
|
|
|
|
|
+ await db.users.deleteOne({ _id: user._id })
|
|
|
|
|
+
|
|
|
|
|
+ const r = await runScript('consume', true)
|
|
|
|
|
+
|
|
|
|
|
+ expect(r.stdout).to.include('Total emails in the CSV: 1')
|
|
|
|
|
+ expect(r.stdout).to.include('Skipped emails: 1')
|
|
|
|
|
+ expect(r.stdout).to.include(' - User not found: 1')
|
|
|
|
|
+ })
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ describe('when users email was deleted in between', function () {
|
|
|
|
|
+ beforeEach(async function () {
|
|
|
|
|
+ user = createUser(new Date('2000-01-01'), [
|
|
|
|
|
+ { email: 'primary@overleaf.com' },
|
|
|
|
|
+ { email: 'secondary@overleaf.com' },
|
|
|
|
|
+ ])
|
|
|
|
|
+ await db.users.insertOne(user)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it('should skip emails that were already removed', async function () {
|
|
|
|
|
+ await runScript('generate')
|
|
|
|
|
+
|
|
|
|
|
+ // Remove the secondary email
|
|
|
|
|
+ await db.users.updateOne(
|
|
|
|
|
+ { _id: user._id },
|
|
|
|
|
+ { $pull: { emails: { email: 'secondary@overleaf.com' } } }
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ const r = await runScript('consume', true)
|
|
|
|
|
+
|
|
|
|
|
+ expect(r.stdout).to.include('Total emails in the CSV: 1')
|
|
|
|
|
+ expect(r.stdout).to.include('Skipped emails: 1')
|
|
|
|
|
+ expect(r.stdout).to.include(' - Email now removed: 1')
|
|
|
|
|
+
|
|
|
|
|
+ const updatedUser = await db.users.findOne({ _id: user._id })
|
|
|
|
|
+ expect(updatedUser.emails).to.have.length(1)
|
|
|
|
|
+ expect(updatedUser.emails[0].email).to.equal('primary@overleaf.com')
|
|
|
|
|
+ })
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ describe('when handling confirmation field edge cases', function () {
|
|
|
|
|
+ beforeEach(async function () {
|
|
|
|
|
+ user = createUser(new Date('2000-01-01'), [
|
|
|
|
|
+ { email: 'primary@overleaf.com', confirmedAt: new Date() },
|
|
|
|
|
+ { email: 'secondary1@overleaf.com', confirmedAt: null },
|
|
|
|
|
+ { email: 'secondary2@overleaf.com' },
|
|
|
|
|
+ ])
|
|
|
|
|
+ await db.users.insertOne(user)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it('should remove emails with both missing and null confirmedAt', async function () {
|
|
|
|
|
+ await runScript('generate')
|
|
|
|
|
+ const r = await runScript('consume', true)
|
|
|
|
|
+
|
|
|
|
|
+ expect(r.stdout).to.include('Total emails in the CSV: 2')
|
|
|
|
|
+ expect(r.stdout).to.include('Total users processed: 1')
|
|
|
|
|
+
|
|
|
|
|
+ const updatedUser = await db.users.findOne({ _id: user._id })
|
|
|
|
|
+ expect(updatedUser.emails).to.have.length(1)
|
|
|
|
|
+ expect(updatedUser.emails[0].email).to.equal(user.email)
|
|
|
|
|
+ })
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ describe('CSV file generation', function () {
|
|
|
|
|
+ beforeEach(async function () {
|
|
|
|
|
+ user = createUser(new Date('2000-01-01'), [
|
|
|
|
|
+ { email: 'primary@overleaf.com', confirmedAt: new Date() },
|
|
|
|
|
+ { email: 'unconfirmed1@overleaf.com' },
|
|
|
|
|
+ { email: 'confirmed1@overleaf.com', confirmedAt: new Date() },
|
|
|
|
|
+ { email: 'unconfirmed2@overleaf.com' },
|
|
|
|
|
+ { email: '!,@overleaf.com' },
|
|
|
|
|
+ { email: "!'@overleaf.com" },
|
|
|
|
|
+ { email: '!,\'"@overleaf.com' },
|
|
|
|
|
+ ])
|
|
|
|
|
+ await db.users.insertOne(user)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it('should generate a valid CSV file', async function () {
|
|
|
|
|
+ const r = await runScript('generate')
|
|
|
|
|
+
|
|
|
|
|
+ expect(r.stdout).to.include(
|
|
|
|
|
+ 'Generated CSV file: /tmp/remove_unconfirmed_emails.csv'
|
|
|
|
|
+ )
|
|
|
|
|
+ expect(r.stdout).to.include('Total emails in the CSV: 5')
|
|
|
|
|
+ const csvContent = await fs.readFile(CSV_FILENAME, 'utf8')
|
|
|
|
|
+ expect(csvContent).to.equal(`User ID,Email,Sign Up Date
|
|
|
|
|
+${user._id},unconfirmed1@overleaf.com,2000-01-01T00:00:00.000Z
|
|
|
|
|
+${user._id},unconfirmed2@overleaf.com,2000-01-01T00:00:00.000Z
|
|
|
|
|
+${user._id},"!,@overleaf.com",2000-01-01T00:00:00.000Z
|
|
|
|
|
+${user._id},!'@overleaf.com,2000-01-01T00:00:00.000Z
|
|
|
|
|
+${user._id},"!,'""@overleaf.com",2000-01-01T00:00:00.000Z
|
|
|
|
|
+`)
|
|
|
|
|
+ })
|
|
|
|
|
+ })
|
|
|
|
|
+})
|