diff --git a/services/web/app/src/Features/Project/ProjectGetter.mjs b/services/web/app/src/Features/Project/ProjectGetter.mjs index fc3eadfc44..e616c915da 100644 --- a/services/web/app/src/Features/Project/ProjectGetter.mjs +++ b/services/web/app/src/Features/Project/ProjectGetter.mjs @@ -146,7 +146,7 @@ const ProjectGetter = { async findUsersProjectsByName(userId, projectName) { const allProjects = await ProjectGetter.findAllUsersProjects( userId, - 'name archived trashed' + 'name archived trashed overleaf' ) const { owned, readAndWrite } = allProjects diff --git a/services/web/app/src/Features/ThirdPartyDataStore/TpdsController.mjs b/services/web/app/src/Features/ThirdPartyDataStore/TpdsController.mjs index 90472c98f5..3fb11a9d6d 100644 --- a/services/web/app/src/Features/ThirdPartyDataStore/TpdsController.mjs +++ b/services/web/app/src/Features/ThirdPartyDataStore/TpdsController.mjs @@ -11,6 +11,7 @@ import ProjectCreationHandler from '../Project/ProjectCreationHandler.mjs' import ProjectDetailsHandler from '../Project/ProjectDetailsHandler.mjs' import HttpErrorHandler from '../Errors/HttpErrorHandler.mjs' import TpdsQueueManager from './TpdsQueueManager.mjs' +import { parseReq, z, zz } from '@overleaf/validation-tools' async function createProject(req, res) { const { user_id: userId } = req.params @@ -30,6 +31,46 @@ async function createProject(req, res) { }) } +const resolveProjectSchema = z.object({ + params: z.object({ + user_id: zz.objectId(), + }), + body: z + .object({ + projectId: zz.objectId(), + }) + .or( + z.object({ + projectName: z.string().min(1), + }) + ), +}) + +// Resolve a project name (or id) to a project id, using the same +// get-or-create semantics as mergeUpdate: a blank project is created when no +// project matches the name, and duplicate names trigger the duplicate-name +// handling before rejecting the request. +async function resolveProject(req, res) { + const { + params: { user_id: userId }, + body: { projectId, projectName }, + } = parseReq(req, resolveProjectSchema) + const project = await TpdsUpdateHandler.promises.getOrCreateProject( + userId, + projectId, + projectName + ) + if (project == null) { + return res.json({ status: 'rejected' }) + } + res.json({ + status: 'success', + projectId: project._id.toString(), + historyId: project.overleaf?.history?.id, + otMigrationStage: project.overleaf?.history?.otMigrationStage ?? 0, + }) +} + // mergeUpdate and deleteUpdate are used by Dropbox, where the project is only // passed as the name, as the first part of the file path. They have to check // the project exists, find it, and create it if not. They also ignore 'noisy' @@ -207,6 +248,7 @@ function splitPath(projectId, path) { export default { createProject: expressify(createProject), + resolveProject: expressify(resolveProject), mergeUpdate: expressify(mergeUpdate), deleteUpdate: expressify(deleteUpdate), updateFolder: expressify(updateFolder), diff --git a/services/web/app/src/Features/ThirdPartyDataStore/TpdsUpdateHandler.mjs b/services/web/app/src/Features/ThirdPartyDataStore/TpdsUpdateHandler.mjs index b183bee09d..c9c0f0532d 100644 --- a/services/web/app/src/Features/ThirdPartyDataStore/TpdsUpdateHandler.mjs +++ b/services/web/app/src/Features/ThirdPartyDataStore/TpdsUpdateHandler.mjs @@ -94,7 +94,7 @@ async function getOrCreateProject(userId, projectId, projectName) { async function findProjectByIdWithRWAccess(userId, projectId) { const allProjects = await ProjectGetter.promises.findAllUsersProjects( userId, - 'name archived trashed' + 'name archived trashed overleaf' ) for (const projects of [allProjects.owned, allProjects.readAndWrite]) { for (const project of projects) { @@ -191,5 +191,6 @@ export default { newUpdate, deleteUpdate, createFolder, + getOrCreateProject, }, } diff --git a/services/web/app/src/router.mjs b/services/web/app/src/router.mjs index ed73e7bfa4..86df8ce04c 100644 --- a/services/web/app/src/router.mjs +++ b/services/web/app/src/router.mjs @@ -980,6 +980,11 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) { AuthenticationController.requirePrivateApiAuth(), TpdsController.createProject ) + privateApiRouter.post( + '/user/:user_id/project/resolve', + AuthenticationController.requirePrivateApiAuth(), + TpdsController.resolveProject + ) privateApiRouter.post( '/tpds/folder-update', AuthenticationController.requirePrivateApiAuth(), diff --git a/services/web/test/acceptance/src/TpdsUpdateTests.mjs b/services/web/test/acceptance/src/TpdsUpdateTests.mjs index ba7b5fa6f0..7a829fefde 100644 --- a/services/web/test/acceptance/src/TpdsUpdateTests.mjs +++ b/services/web/test/acceptance/src/TpdsUpdateTests.mjs @@ -1,9 +1,26 @@ import { expect } from 'chai' +import sinon from 'sinon' import ProjectGetter from '../../../app/src/Features/Project/ProjectGetter.mjs' +import ProjectOptionsHandler from '../../../app/src/Features/Project/ProjectOptionsHandler.mjs' +import ProjectRootDocManager from '../../../app/src/Features/Project/ProjectRootDocManager.mjs' import request from './helpers/request.js' import User from './helpers/User.mjs' describe('TpdsUpdateTests', function () { + beforeEach(function () { + // Creating a project via TPDS schedules a root doc reset on a 30s timer, + // which graceful shutdown waits for, exceeding the "after all" hook + // timeout when stopping the app. + this.setRootDocStub = sinon.stub( + ProjectRootDocManager, + 'setRootDocAutomaticallyInBackground' + ) + }) + + afterEach(function () { + this.setRootDocStub.restore() + }) + beforeEach(function (done) { this.owner = new User() this.owner.login(error => { @@ -139,6 +156,261 @@ describe('TpdsUpdateTests', function () { }) }) + describe('resolving a project by name', function () { + it('should return the id of the matching project', function (done) { + request( + { + method: 'POST', + url: `/user/${this.owner._id}/project/resolve`, + auth: { + username: 'overleaf', + password: 'password', + sendImmediately: true, + }, + json: { projectName: 'test-project' }, + }, + (error, response, body) => { + if (error) { + throw error + } + expect(response.statusCode).to.equal(200) + expect(body).to.deep.equal({ + status: 'success', + projectId: this.projectId, + historyId: 1, + otMigrationStage: 0, + }) + done() + } + ) + }) + + it('should create a project when the name does not match', function (done) { + request( + { + method: 'POST', + url: `/user/${this.owner._id}/project/resolve`, + auth: { + username: 'overleaf', + password: 'password', + sendImmediately: true, + }, + json: { projectName: 'brand-new-project' }, + }, + (error, response, body) => { + if (error) { + throw error + } + expect(response.statusCode).to.equal(200) + expect(body.status).to.equal('success') + expect(body.projectId).to.exist + expect(body.projectId).to.not.equal(this.projectId) + expect(body.otMigrationStage).to.equal(0) + ProjectGetter.findAllUsersProjects( + this.owner._id, + 'name', + (err, projects) => { + expect(err).to.not.exist + const created = projects.owned.find( + project => project._id.toString() === body.projectId + ) + expect(created).to.exist + expect(created.name).to.equal('brand-new-project') + done() + } + ) + } + ) + }) + + it('should return the otMigrationStage of the matching project', function (done) { + ProjectOptionsHandler.promises + .setOTMigrationStage(this.projectId, 1) + .then(() => { + request( + { + method: 'POST', + url: `/user/${this.owner._id}/project/resolve`, + auth: { + username: 'overleaf', + password: 'password', + sendImmediately: true, + }, + json: { projectName: 'test-project' }, + }, + (error, response, body) => { + if (error) { + throw error + } + expect(response.statusCode).to.equal(200) + expect(body).to.deep.equal({ + status: 'success', + projectId: this.projectId, + historyId: 1, + otMigrationStage: 1, + }) + done() + } + ) + }) + .catch(done) + }) + + it('should create a project when the user only has read-only access to the matching project', function (done) { + this.reader = new User() + this.reader.login(error => { + expect(error).to.not.exist + this.owner.addUserToProject( + this.projectId, + this.reader, + 'readOnly', + error => { + expect(error).to.not.exist + request( + { + method: 'POST', + url: `/user/${this.reader._id}/project/resolve`, + auth: { + username: 'overleaf', + password: 'password', + sendImmediately: true, + }, + json: { projectName: 'test-project' }, + }, + (error, response, body) => { + if (error) { + throw error + } + expect(response.statusCode).to.equal(200) + expect(body.status).to.equal('success') + expect(body.projectId).to.exist + expect(body.projectId).to.not.equal(this.projectId) + ProjectGetter.findAllUsersProjects( + this.reader._id, + 'name', + (err, projects) => { + expect(err).to.not.exist + const created = projects.owned.find( + project => project._id.toString() === body.projectId + ) + expect(created).to.exist + expect(created.name).to.equal('test-project') + done() + } + ) + } + ) + } + ) + }) + }) + + it('should reject when the project is archived', function (done) { + this.owner.request( + { + url: `/Project/${this.projectId}/archive`, + method: 'post', + }, + (err, response, body) => { + expect(err).to.not.exist + request( + { + method: 'POST', + url: `/user/${this.owner._id}/project/resolve`, + auth: { + username: 'overleaf', + password: 'password', + sendImmediately: true, + }, + json: { projectName: 'test-project' }, + }, + (error, response, body) => { + if (error) { + throw error + } + expect(response.statusCode).to.equal(200) + expect(body).to.deep.equal({ status: 'rejected' }) + ProjectGetter.findAllUsersProjects( + this.owner._id, + 'name', + (err, projects) => { + expect(err).to.not.exist + expect(projects.owned.length).to.equal(1) + done() + } + ) + } + ) + } + ) + }) + }) + + describe('resolving a project by id', function () { + it('should return the id of the matching project', function (done) { + request( + { + method: 'POST', + url: `/user/${this.owner._id}/project/resolve`, + auth: { + username: 'overleaf', + password: 'password', + sendImmediately: true, + }, + json: { projectId: this.projectId }, + }, + (error, response, body) => { + if (error) { + throw error + } + expect(response.statusCode).to.equal(200) + expect(body).to.deep.equal({ + status: 'success', + projectId: this.projectId, + historyId: 1, + otMigrationStage: 0, + }) + done() + } + ) + }) + + it('should reject when the user only has read-only access', function (done) { + this.reader = new User() + this.reader.login(error => { + expect(error).to.not.exist + this.owner.addUserToProject( + this.projectId, + this.reader, + 'readOnly', + error => { + expect(error).to.not.exist + request( + { + method: 'POST', + url: `/user/${this.reader._id}/project/resolve`, + auth: { + username: 'overleaf', + password: 'password', + sendImmediately: true, + }, + json: { projectId: this.projectId }, + }, + (error, response, body) => { + if (error) { + throw error + } + expect(response.statusCode).to.equal(200) + expect(body).to.deep.equal({ status: 'rejected' }) + done() + } + ) + } + ) + }) + }) + }) + describe('update when the project is archived', function () { beforeEach(function (done) { this.owner.request( diff --git a/services/web/test/unit/src/ThirdPartyDataStore/TpdsController.test.mjs b/services/web/test/unit/src/ThirdPartyDataStore/TpdsController.test.mjs index a62284b3d4..90a48dbc17 100644 --- a/services/web/test/unit/src/ThirdPartyDataStore/TpdsController.test.mjs +++ b/services/web/test/unit/src/ThirdPartyDataStore/TpdsController.test.mjs @@ -4,6 +4,7 @@ import sinon from 'sinon' import Errors from '../../../../app/src/Features/Errors/Errors.js' import MockResponse from '../helpers/MockResponse.mjs' import MockRequest from '../helpers/MockRequest.mjs' +import { asZodError } from '@overleaf/validation-tools/testUtils.js' const ObjectId = mongodb.ObjectId @@ -19,11 +20,16 @@ describe('TpdsController', function () { entityType: 'doc', rev: 2, } + ctx.resolvedProject = { + _id: new ObjectId(), + overleaf: { history: { id: 42, otMigrationStage: 1 } }, + } ctx.TpdsUpdateHandler = { promises: { newUpdate: sinon.stub().resolves(ctx.metadata), deleteUpdate: sinon.stub().resolves(ctx.metadata.entityId), createFolder: sinon.stub().resolves(), + getOrCreateProject: sinon.stub().resolves(ctx.resolvedProject), }, } ctx.UpdateMerger = { @@ -118,7 +124,7 @@ describe('TpdsController', function () { ctx.TpdsController = (await import(MODULE_PATH)).default - ctx.user_id = 'dsad29jlkjas' + ctx.user_id = new ObjectId().toString() }) describe('creating a project', function () { @@ -151,6 +157,129 @@ describe('TpdsController', function () { }) }) + describe('resolving a project', function () { + beforeEach(function (ctx) { + ctx.req = { + params: { user_id: ctx.user_id }, + body: {}, + } + }) + + it('should throw without any input', async function (ctx) { + const next = sinon.stub() + await ctx.TpdsController.resolveProject(ctx.req, {}, next) + expect(next).to.have.been.calledWithMatch({ + name: 'InvalidRequestError', + zodError: asZodError({ + code: 'invalid_union', + errors: [ + [ + { + expected: 'string', + code: 'invalid_type', + path: ['projectId'], + message: 'Invalid input: expected string, received undefined', + }, + ], + [ + { + expected: 'string', + code: 'invalid_type', + path: ['projectName'], + message: 'Invalid input: expected string, received undefined', + }, + ], + ], + path: ['body'], + message: 'Invalid input', + }), + }) + }) + + it('should resolve by name', async function (ctx) { + await new Promise(resolve => { + ctx.req.body = { projectName: 'projectName' } + const res = { + json: payload => { + expect(payload).to.deep.equal({ + status: 'success', + projectId: ctx.resolvedProject._id.toString(), + historyId: 42, + otMigrationStage: 1, + }) + ctx.TpdsUpdateHandler.promises.getOrCreateProject.should.have.been.calledWith( + ctx.user_id, + undefined, + 'projectName' + ) + resolve() + }, + } + ctx.TpdsController.resolveProject(ctx.req, res) + }) + }) + + it('should resolve by id', async function (ctx) { + await new Promise(resolve => { + const projectId = ctx.resolvedProject._id.toString() + ctx.req.body = { projectId } + const res = { + json: payload => { + expect(payload).to.deep.equal({ + status: 'success', + projectId: ctx.resolvedProject._id.toString(), + historyId: 42, + otMigrationStage: 1, + }) + ctx.TpdsUpdateHandler.promises.getOrCreateProject.should.have.been.calledWith( + ctx.user_id, + projectId, + undefined + ) + resolve() + }, + } + ctx.TpdsController.resolveProject(ctx.req, res) + }) + }) + + it('should default the otMigrationStage to zero when unset', async function (ctx) { + await new Promise(resolve => { + ctx.TpdsUpdateHandler.promises.getOrCreateProject.resolves({ + _id: ctx.resolvedProject._id, + overleaf: { history: { id: 1337 } }, + }) + ctx.req.body = { projectName: 'projectName' } + const res = { + json: payload => { + expect(payload).to.deep.equal({ + status: 'success', + projectId: ctx.resolvedProject._id.toString(), + historyId: 1337, + otMigrationStage: 0, + }) + resolve() + }, + } + ctx.TpdsController.resolveProject(ctx.req, res) + }) + }) + + it('should indicate in the response when the project could not be resolved', async function (ctx) { + await new Promise(resolve => { + ctx.TpdsUpdateHandler.promises.getOrCreateProject.resolves(null) + ctx.req.body = { projectName: 'projectName' } + const res = { + json: payload => { + expect(payload).to.deep.equal({ status: 'rejected' }) + resolve() + }, + } + ctx.TpdsController.resolveProject(ctx.req, res) + }) + }) + }) + describe('getting an update', function () { beforeEach(function (ctx) { ctx.projectName = 'projectName' diff --git a/services/web/test/unit/src/ThirdPartyDataStore/TpdsUpdateHandler.test.mjs b/services/web/test/unit/src/ThirdPartyDataStore/TpdsUpdateHandler.test.mjs index f00a91e5d7..0a40d0b05e 100644 --- a/services/web/test/unit/src/ThirdPartyDataStore/TpdsUpdateHandler.test.mjs +++ b/services/web/test/unit/src/ThirdPartyDataStore/TpdsUpdateHandler.test.mjs @@ -424,6 +424,84 @@ describe('TpdsUpdateHandler', function () { expectDropboxUnlinked() }) }) + + describe('getting or creating a project', function () { + describe('byId', function () { + describe('with no matching project', function () { + beforeEach(function (ctx) { + ctx.projectId = new ObjectId().toString() + }) + resolveProjectById() + expectProjectNotCreated() + expectNoProjectResolved() + }) + + describe('with one matching active project', function () { + beforeEach(function (ctx) { + ctx.projectId = ctx.projects.active1._id.toString() + }) + resolveProjectById() + expectProjectNotCreated() + expectProjectResolved() + }) + + describe('with one matching archived project', function () { + beforeEach(function (ctx) { + ctx.projectId = ctx.projects.archived1._id.toString() + }) + resolveProjectById() + expectProjectNotCreated() + expectNoProjectResolved() + }) + + describe('with one matching trashed project', function () { + beforeEach(function (ctx) { + ctx.projectId = ctx.projects.trashed._id.toString() + }) + resolveProjectById() + expectProjectNotCreated() + expectNoProjectResolved() + }) + }) + + describe('with no matching project', function () { + setupMatchingProjects([]) + resolveProjectByName() + expectProjectCreated() + expectProjectResolved() + }) + + describe('with one matching active project', function () { + setupMatchingProjects(['active1']) + resolveProjectByName() + expectProjectNotCreated() + expectProjectResolved() + }) + + describe('with one matching archived project', function () { + setupMatchingProjects(['archived1']) + resolveProjectByName() + expectProjectNotCreated() + expectNoProjectResolved() + expectDropboxNotUnlinked() + }) + + describe('with two matching active projects', function () { + setupMatchingProjects(['active1', 'active2']) + resolveProjectByName() + expectProjectNotCreated() + expectNoProjectResolved() + expectDropboxUnlinked() + }) + + describe('with one matching active and one matching archived project', function () { + setupMatchingProjects(['active1', 'archived1']) + resolveProjectByName() + expectProjectNotCreated() + expectNoProjectResolved() + expectDropboxUnlinked() + }) + }) }) /* Setup helpers */ @@ -512,6 +590,28 @@ function receiveFolderUpdate() { }) } +function resolveProjectByName() { + beforeEach(async function (ctx) { + ctx.resolvedProject = + await ctx.TpdsUpdateHandler.promises.getOrCreateProject( + ctx.userId, + null, // projectId + ctx.projectName + ) + }) +} + +function resolveProjectById() { + beforeEach(async function (ctx) { + ctx.resolvedProject = + await ctx.TpdsUpdateHandler.promises.getOrCreateProject( + ctx.userId, + ctx.projectId, + '' // projectName + ) + }) +} + /* Expectations */ function expectProjectCreated() { @@ -619,6 +719,18 @@ function expectDeleteNotProcessed() { }) } +function expectProjectResolved() { + it('resolves the project', function (ctx) { + expect(ctx.resolvedProject._id).to.equal(ctx.projects.active1._id) + }) +} + +function expectNoProjectResolved() { + it('does not resolve a project', function (ctx) { + expect(ctx.resolvedProject).to.not.exist + }) +} + function expectProjectDeleted() { it('deletes the project', function (ctx) { expect(