Add request validation to HistoryController endpoints (#35730)

* Add schemas for file restoration and label management in HistoryController

* Add request schemas for proxying to history API and resyncing project history

* prevent transforming version:null into version:0 with coerce

* move jsDoc comments above the handler

* check for undefined resyncProjectStructureOnly

* Add tests for restoreFileFromV2 and revertFile

* Set default value for resyncProjectStructureOnly in resyncProjectHistorySchema

GitOrigin-RevId: 7b8b8a4c3834b8c8a83f93052ab1f59e86c529a6
This commit is contained in:
Domagoj Kriskovic
2026-09-17 11:34:33 +00:00
committed by Copybot
parent c821cc695c
commit d8de261370
2 changed files with 286 additions and 22 deletions
@@ -119,7 +119,17 @@ describe('HistoryController', function () {
vi.doMock(
'../../../../app/src/Features/History/RestoreManager.mjs',
() => ({
default: (ctx.RestoreManager = {}),
default: (ctx.RestoreManager = {
promises: {
restoreFileFromV2: sinon
.stub()
.resolves({ _id: 'restored-id', type: 'doc' }),
revertFile: sinon
.stub()
.resolves({ _id: 'reverted-id', type: 'doc' }),
revertProject: sinon.stub().resolves([]),
},
}),
})
)
@@ -137,7 +147,12 @@ describe('HistoryController', function () {
describe('proxyToHistoryApi', function () {
beforeEach(async function (ctx) {
ctx.req = { url: '/mock/url', method: 'POST', session: sinon.stub() }
ctx.req = {
url: '/mock/url',
method: 'POST',
session: sinon.stub(),
params: {},
}
ctx.res = {
set: sinon.stub(),
}
@@ -188,7 +203,11 @@ describe('HistoryController', function () {
describe('proxyToHistoryApiAndInjectUserDetails', function () {
beforeEach(async function (ctx) {
ctx.req = { url: '/mock/url', method: 'POST' }
ctx.req = {
url: '/mock/url',
method: 'POST',
params: { Project_id: ctx.project_id },
}
ctx.res = { json: sinon.stub() }
ctx.data = 'mock-data'
ctx.dataWithUsers = 'mock-injected-data'
@@ -233,7 +252,11 @@ describe('HistoryController', function () {
describe('proxyToHistoryApiAndInjectUserDetails (with the history API failing)', function () {
beforeEach(async function (ctx) {
ctx.url = '/mock/url'
ctx.req = { url: ctx.url, method: 'POST' }
ctx.req = {
url: ctx.url,
method: 'POST',
params: { Project_id: ctx.project_id },
}
ctx.res = { json: sinon.stub() }
ctx.err = new RequestFailedError(ctx.url, {}, { status: 500 })
ctx.fetchJson.rejects(ctx.err)
@@ -308,6 +331,165 @@ describe('HistoryController', function () {
})
})
describe('restoreFileFromV2', function () {
beforeEach(function (ctx) {
ctx.res = { json: sinon.stub() }
ctx.ProjectAuditLogHandler.addEntryIfManagedInBackground = sinon.stub()
})
it('restores the file for a valid request', async function (ctx) {
ctx.req = {
params: { project_id: ctx.project_id },
body: { version: 42, pathname: 'foo/bar.tex' },
session: {},
ip: '1.2.3.4',
}
await ctx.HistoryController.restoreFileFromV2(ctx.req, ctx.res, ctx.next)
ctx.RestoreManager.promises.restoreFileFromV2.should.have.been.calledWith(
ctx.user_id,
ctx.project_id,
42,
'foo/bar.tex'
)
})
it('rejects a non-numeric version without invoking RestoreManager', async function (ctx) {
ctx.req = {
params: { project_id: ctx.project_id },
body: { version: 'not-a-number', pathname: 'foo/bar.tex' },
session: {},
ip: '1.2.3.4',
}
await ctx.HistoryController.restoreFileFromV2(ctx.req, ctx.res, ctx.next)
ctx.next.should.have.been.calledOnce
ctx.next.firstCall.args[0].should.be.an.instanceof(Error)
ctx.RestoreManager.promises.restoreFileFromV2.should.not.have.been.called
})
it('rejects a negative version without invoking RestoreManager', async function (ctx) {
ctx.req = {
params: { project_id: ctx.project_id },
body: { version: -1, pathname: 'foo/bar.tex' },
session: {},
ip: '1.2.3.4',
}
await ctx.HistoryController.restoreFileFromV2(ctx.req, ctx.res, ctx.next)
ctx.next.should.have.been.calledOnce
ctx.next.firstCall.args[0].should.be.an.instanceof(Error)
ctx.RestoreManager.promises.restoreFileFromV2.should.not.have.been.called
})
it('rejects a pathname containing .. without invoking RestoreManager', async function (ctx) {
ctx.req = {
params: { project_id: ctx.project_id },
body: { version: 42, pathname: '../../etc/passwd' },
session: {},
ip: '1.2.3.4',
}
await ctx.HistoryController.restoreFileFromV2(ctx.req, ctx.res, ctx.next)
ctx.next.should.have.been.calledOnce
ctx.next.firstCall.args[0].should.be.an.instanceof(Error)
ctx.RestoreManager.promises.restoreFileFromV2.should.not.have.been.called
})
it('rejects an absolute pathname without invoking RestoreManager', async function (ctx) {
ctx.req = {
params: { project_id: ctx.project_id },
body: { version: 42, pathname: '/etc/passwd' },
session: {},
ip: '1.2.3.4',
}
await ctx.HistoryController.restoreFileFromV2(ctx.req, ctx.res, ctx.next)
ctx.next.should.have.been.calledOnce
ctx.next.firstCall.args[0].should.be.an.instanceof(Error)
ctx.RestoreManager.promises.restoreFileFromV2.should.not.have.been.called
})
it('rejects a path-traversal string as version without invoking RestoreManager', async function (ctx) {
ctx.req = {
params: { project_id: ctx.project_id },
body: { version: '0/../../../ObjectId', pathname: 'foo/bar.tex' },
session: {},
ip: '1.2.3.4',
}
await ctx.HistoryController.restoreFileFromV2(ctx.req, ctx.res, ctx.next)
ctx.next.should.have.been.calledOnce
ctx.next.firstCall.args[0].should.be.an.instanceof(Error)
ctx.RestoreManager.promises.restoreFileFromV2.should.not.have.been.called
})
})
describe('revertFile', function () {
beforeEach(function (ctx) {
ctx.res = { json: sinon.stub() }
ctx.ProjectAuditLogHandler.addEntryIfManagedInBackground = sinon.stub()
})
it('reverts the file for a valid request', async function (ctx) {
ctx.req = {
params: { project_id: ctx.project_id },
body: { version: 42, pathname: 'foo/bar.tex' },
session: {},
ip: '1.2.3.4',
}
await ctx.HistoryController.revertFile(ctx.req, ctx.res, ctx.next)
ctx.RestoreManager.promises.revertFile.should.have.been.calledWith(
ctx.user_id,
ctx.project_id,
42,
'foo/bar.tex'
)
})
it('rejects a malformed pathname without invoking RestoreManager', async function (ctx) {
ctx.req = {
params: { project_id: ctx.project_id },
body: { version: 42, pathname: '../secret.tex' },
session: {},
ip: '1.2.3.4',
}
await ctx.HistoryController.revertFile(ctx.req, ctx.res, ctx.next)
ctx.next.should.have.been.calledOnce
ctx.next.firstCall.args[0].should.be.an.instanceof(Error)
ctx.RestoreManager.promises.revertFile.should.not.have.been.called
})
})
describe('revertProject', function () {
beforeEach(function (ctx) {
ctx.res = { json: sinon.stub() }
ctx.ProjectAuditLogHandler.addEntryIfManagedInBackground = sinon.stub()
})
it('reverts the project for a valid request', async function (ctx) {
ctx.req = {
params: { project_id: ctx.project_id },
body: { version: 42 },
session: {},
ip: '1.2.3.4',
}
await ctx.HistoryController.revertProject(ctx.req, ctx.res, ctx.next)
ctx.RestoreManager.promises.revertProject.should.have.been.calledWith(
ctx.user_id,
ctx.project_id,
42
)
})
it('rejects a non-integer version without invoking RestoreManager', async function (ctx) {
ctx.req = {
params: { project_id: ctx.project_id },
body: { version: 1.5 },
session: {},
ip: '1.2.3.4',
}
await ctx.HistoryController.revertProject(ctx.req, ctx.res, ctx.next)
ctx.next.should.have.been.calledOnce
ctx.next.firstCall.args[0].should.be.an.instanceof(Error)
ctx.RestoreManager.promises.revertProject.should.not.have.been.called
})
})
describe('requestBlob', function () {
describe('With Range header', function () {
beforeEach(async function (ctx) {