[web] add endpoint for resolving a project for writes from tpds (#35408)

* [web] add endpoint for resolving a project for writes from tpds

* [web] return historyId from new endpoint for resolving projects

* [web] add zod validation to new endpoint for resolving projects

GitOrigin-RevId: 0e607e56442117a4150a08373169c44b53dd4fdf
This commit is contained in:
Jakob Ackermann
2026-09-17 11:26:03 +00:00
committed by Copybot
parent 528e587038
commit b68a013751
7 changed files with 564 additions and 3 deletions
@@ -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(