mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-15 06:33:55 +01:00
* [editor-core] share the blob hash and the doc-vs-file rule The git blob hash and the rule that decides whether a file is a doc or a binary file both have to reach the same answer in every service that writes to history, so they move into overleaf-editor-core rather than being spelled out again on each side. blob_utils takes over lib/blob_string_length.js: hashing and the string-length rule answered two halves of the same question -- what history needs to know about content before it can store it -- and each carried the same warning about staying off index.js, which is bundled for the browser. One module states it once, and blobForFile joins them, describing content on disk as the blob it amounts to. The hashing functions are flat rather than a namespace of their own: in a module that also holds the string-length rule, a bare fromStream says nothing about what it makes. history-v1's blob store and recover_zip follow the module to its new name. file_type_detector holds the doc-vs-file rule, Node-only and deep-imported for the same reason. It takes textExtensions, editableFilenames and maxDocLength as configuration rather than reading settings: the limit is web's 2 MiB and not editor-core's own 3 MiB, and the surrogate check covers both halves of a pair where editor-core's covers the high half. buildSetContentOperations' test spelled the blob header out for itself, with the separator as a literal NUL byte, which had git reading the file as binary. It hashes through blobHashFromString now, so it does not spell the header at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [editor-core] name the client a change came from on the origin itself `EditorOrigin` carried an `editorId` that only an editor change could have. What the id is for -- recognising a change again, so history does not apply a resend twice and the client does not apply its own change back -- is a property of the client that submitted it rather than of what it did, so it moves onto `Origin` as an optional `historyClientId` and `EditorOrigin` goes. - `Origin` takes `(kind, historyClientId)` and answers `getHistoryClientId()`; `dropHistoryClientId()` is what the chunk store uses to strip the id from all but a client's latest change, which now leaves the rest of the origin alone -- a restore that a client submitted stays a restore. - `toRaw` is built a layer at a time: the base writes the kind and the id, a subclass spreads that and adds the fields it owns. - The named kinds are their own schemas and discriminate on `kind`, so a payload claiming one is checked against that shape and the error says what it got wrong. The catch-all cannot join them -- its kind is any string -- so it sits alongside and is refined to refuse the named kinds, which it used to swallow: `{kind: 'restore'}` validated as a kind and nothing else, then read back as a restore that recorded nothing about what it restored. - `lib/origin/*.js` gains `// @ts-check`. Without it the annotations in there were decorative: the lazily required subclasses were `any`, so nothing handed to them was checked, and `fromRaw` narrows with `in` to drop the catch-all variant, which a kind check cannot. The callers that stamp the id -- real-time's handshake flag, web's applyChange body -- come with the rest of the branch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [editor-core] recognise a change a writer that is not an editor placed The counterpart of `isSameEditorChange` for writers that are not an editor client: an integration that commits a batch on a user's behalf has to recognise that batch coming back, because a lost response does not say whether the commit landed and resubmitting one that did applies its removes twice. It compares the same three fields, and for the same reason -- a rebase rewrites a change's operations but leaves its origin, authors and timestamp alone -- with the timestamp compared as a point in time rather than as a string, so that two spellings of the same instant cannot read as different changes. github-sync is the first caller, and comes with the rest of the branch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [editor-core] load an editable file's content, or say it has none Only an editable file has content: any other kind is bytes addressed by hash, and hollow data records the size of content it does not have. Both answer `getContent()` with null, so every caller that wants the content of a doc loaded it, checked the data's class, and reached for its own error when the check failed. `File#loadEager` does that once and answers the data that always has the content. `buildSetContentOperations` takes a `ReadWriteBlobStore` -- everything but looking a blob up by hash, which is all it takes to build operations over content -- so a caller holding a store that cannot answer `getBlob` can still use it. Its metadata argument is `FileMetadata` rather than `Object`, which is what the schema already says a file's metadata is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [editor-core] say that a pair of operations that do not fit is unprocessable compose and transform threw a bare Error when the two operations do not describe the same content. Everything that answers a client for these -- history-v1's import routes, the git-bridge push, project-history's resync -- decides on UnprocessableError, so a bare Error reached them as an internal failure: a 500, which reads as worth retrying, for a pair that fails the same way every time. A client that resubmits on a 500 resubmits forever. The guards that cannot be reached through the API keep their bare Error: a TextOperation composed with something that is not one, and the branch whose message says it should not happen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * [history-v1] hash blobs with the rule editor-core holds storage/lib/blob_hash.js was a bluebird-era implementation of the same git blob hash editor-core holds, exported from the storage barrel and used across the storage layer, the api and the tests. All of it reads the shared rule now, and the test that existed only to check the copy is gone -- editor-core's suite covers the same non-ASCII case. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> GitOrigin-RevId: 5a0c1e9a06170a0164c01a26a7bb2d205726d531
1032 lines
33 KiB
JavaScript
1032 lines
33 KiB
JavaScript
const BPromise = require('bluebird')
|
|
const { expect } = require('chai')
|
|
const fs = BPromise.promisifyAll(require('node:fs'))
|
|
const HTTPStatus = require('http-status')
|
|
const fetch = require('node-fetch')
|
|
|
|
const cleanup = require('../storage/support/cleanup')
|
|
const fixtures = require('../storage/support/fixtures')
|
|
const testFiles = require('../storage/support/test_files')
|
|
const expectResponse = require('./support/expect_response')
|
|
const testServer = require('./support/test_server')
|
|
|
|
const core = require('overleaf-editor-core')
|
|
const testProjects = require('./support/test_projects')
|
|
const Change = core.Change
|
|
const ChunkResponse = core.ChunkResponse
|
|
const File = core.File
|
|
const Operation = core.Operation
|
|
const Origin = core.Origin
|
|
const Snapshot = core.Snapshot
|
|
const TextOperation = core.TextOperation
|
|
const V2DocVersions = core.V2DocVersions
|
|
|
|
const knex = require('../../../../storage').knex
|
|
const { blobHashFromString } = require('overleaf-editor-core/lib/blob_utils')
|
|
|
|
describe('history import', function () {
|
|
beforeEach(cleanup.everything)
|
|
beforeEach(fixtures.create)
|
|
|
|
function changeToRaw(change) {
|
|
return change.toRaw()
|
|
}
|
|
|
|
function makeChange(operation) {
|
|
return new Change([operation], new Date(), [])
|
|
}
|
|
|
|
let basicAuthClient
|
|
let pseudoJwtBasicAuthClient
|
|
let clientForProject
|
|
|
|
before(async function () {
|
|
basicAuthClient = testServer.basicAuthClient
|
|
pseudoJwtBasicAuthClient = testServer.pseudoJwtBasicAuthClient
|
|
clientForProject = await testServer.createClientForProject('1')
|
|
})
|
|
|
|
it('creates blobs and then imports a snapshot and history', function () {
|
|
// We need to be able to set the projectId to match an existing doc ID.
|
|
const testProjectId = '1'
|
|
const testFilePathname = 'main.tex'
|
|
const testAuthors = [123, null]
|
|
const testTextOperation0 = TextOperation.fromJSON({ textOperation: ['a'] })
|
|
const testTextOperation1 = TextOperation.fromJSON({
|
|
textOperation: [1, 'b'],
|
|
})
|
|
|
|
let testSnapshot
|
|
|
|
return fetch(
|
|
testServer.url(
|
|
`/api/projects/${testProjectId}/blobs/${File.EMPTY_FILE_HASH}`
|
|
),
|
|
{
|
|
method: 'PUT',
|
|
body: fs.createReadStream(testFiles.path('empty.tex')),
|
|
headers: {
|
|
Authorization: testServer.basicAuthHeader,
|
|
},
|
|
}
|
|
)
|
|
.then(response => {
|
|
expect(response.ok).to.be.true
|
|
})
|
|
.then(() => {
|
|
// Import project
|
|
testSnapshot = new Snapshot()
|
|
testSnapshot.addFile(
|
|
testFilePathname,
|
|
File.fromHash(File.EMPTY_FILE_HASH)
|
|
)
|
|
return basicAuthClient.apis.ProjectImport.importSnapshot1({
|
|
project_id: testProjectId,
|
|
snapshot: testSnapshot.toRaw(),
|
|
})
|
|
})
|
|
.then(response => {
|
|
// Check project is valid
|
|
expect(response.obj.projectId).to.equal(testProjectId)
|
|
})
|
|
.then(() => {
|
|
// Try importing the project again
|
|
return basicAuthClient.apis.ProjectImport.importSnapshot1({
|
|
project_id: testProjectId,
|
|
snapshot: testSnapshot.toRaw(),
|
|
})
|
|
})
|
|
.then(() => {
|
|
// Check that importing a duplicate fails
|
|
expect.fail()
|
|
})
|
|
.catch(expectResponse.conflict)
|
|
.then(() => {
|
|
// Get project history
|
|
return clientForProject.apis.Project.getLatestHistory({
|
|
project_id: testProjectId,
|
|
})
|
|
})
|
|
.then(response => {
|
|
// Check that the imported history is valid
|
|
const chunk = ChunkResponse.fromRaw(response.obj).getChunk()
|
|
const snapshot = chunk.getSnapshot()
|
|
expect(snapshot.countFiles()).to.equal(1)
|
|
const file = snapshot.getFile(testFilePathname)
|
|
expect(file.getHash()).to.eql(File.EMPTY_FILE_HASH)
|
|
expect(chunk.getChanges().length).to.equal(0)
|
|
expect(chunk.getEndVersion()).to.equal(0)
|
|
})
|
|
.then(() => {
|
|
// Import changes with an end version
|
|
const changes = [
|
|
makeChange(Operation.editFile(testFilePathname, testTextOperation0)),
|
|
makeChange(Operation.editFile(testFilePathname, testTextOperation1)),
|
|
]
|
|
changes[0].setAuthors(testAuthors)
|
|
return basicAuthClient.apis.ProjectImport.importChanges1({
|
|
project_id: testProjectId,
|
|
changes: changes.map(changeToRaw),
|
|
end_version: 0,
|
|
return_snapshot: 'hashed',
|
|
})
|
|
})
|
|
.then(response => {
|
|
expect(response.status).to.equal(HTTPStatus.CREATED)
|
|
const snapshot = Snapshot.fromRaw(response.obj)
|
|
expect(snapshot.countFiles()).to.equal(1)
|
|
expect(snapshot.getFile('main.tex').getHash()).to.equal(
|
|
testFiles.STRING_AB_HASH
|
|
)
|
|
})
|
|
|
|
.then(() => {
|
|
// Get project history
|
|
return clientForProject.apis.Project.getLatestHistory({
|
|
project_id: testProjectId,
|
|
})
|
|
})
|
|
.then(response => {
|
|
// Check that the history is valid
|
|
const chunkResponse = ChunkResponse.fromRaw(response.obj)
|
|
const chunk = chunkResponse.getChunk()
|
|
const snapshot = chunk.getSnapshot()
|
|
expect(snapshot.countFiles()).to.equal(1)
|
|
const file = snapshot.getFile(testFilePathname)
|
|
expect(file.getHash()).to.equal(File.EMPTY_FILE_HASH)
|
|
expect(chunk.getChanges().length).to.equal(2)
|
|
const changeWithAuthors = chunk.getChanges()[0]
|
|
expect(changeWithAuthors.getAuthors().length).to.equal(2)
|
|
expect(changeWithAuthors.getAuthors()).to.deep.equal(testAuthors)
|
|
expect(chunk.getStartVersion()).to.equal(0)
|
|
expect(chunk.getEndVersion()).to.equal(2)
|
|
})
|
|
.then(() => {
|
|
return clientForProject.apis.Project.getLatestHistory({
|
|
project_id: testProjectId,
|
|
})
|
|
})
|
|
.then(response => {
|
|
// it should retrieve the same chunk
|
|
const chunkResponse = ChunkResponse.fromRaw(response.obj)
|
|
const chunk = chunkResponse.getChunk()
|
|
expect(chunk.getChanges().length).to.equal(2)
|
|
expect(chunk.getStartVersion()).to.equal(0)
|
|
expect(chunk.getEndVersion()).to.equal(2)
|
|
})
|
|
.then(() => {
|
|
// Get project's latest content
|
|
return clientForProject.apis.Project.getLatestContent({
|
|
project_id: testProjectId,
|
|
})
|
|
})
|
|
.then(response => {
|
|
// Check that the content is valid
|
|
const snapshot = Snapshot.fromRaw(response.obj)
|
|
expect(snapshot.countFiles()).to.equal(1)
|
|
const file = snapshot.getFile(testFilePathname)
|
|
expect(file.getContent()).to.equal('ab')
|
|
})
|
|
})
|
|
|
|
it('rejects invalid changes in history', function () {
|
|
const testProjectId = '1'
|
|
const testFilePathname = 'main.tex'
|
|
const testTextOperation = TextOperation.fromJSON({
|
|
textOperation: ['a', 10],
|
|
})
|
|
|
|
let testSnapshot
|
|
|
|
return fetch(
|
|
testServer.url(
|
|
`/api/projects/${testProjectId}/blobs/${File.EMPTY_FILE_HASH}`
|
|
),
|
|
{
|
|
method: 'PUT',
|
|
body: fs.createReadStream(testFiles.path('empty.tex')),
|
|
headers: {
|
|
Authorization: testServer.basicAuthHeader,
|
|
},
|
|
}
|
|
)
|
|
.then(response => {
|
|
expect(response.ok).to.be.true
|
|
})
|
|
.then(() => {
|
|
// Import project
|
|
testSnapshot = new Snapshot()
|
|
testSnapshot.addFile(
|
|
testFilePathname,
|
|
File.fromHash(File.EMPTY_FILE_HASH)
|
|
)
|
|
return basicAuthClient.apis.ProjectImport.importSnapshot1({
|
|
project_id: testProjectId,
|
|
snapshot: testSnapshot.toRaw(),
|
|
})
|
|
})
|
|
.then(response => {
|
|
// Check project is valid
|
|
expect(response.obj.projectId).to.equal(testProjectId)
|
|
})
|
|
.then(() => {
|
|
// Import invalid changes
|
|
const changes = [
|
|
makeChange(Operation.editFile(testFilePathname, testTextOperation)),
|
|
]
|
|
return basicAuthClient.apis.ProjectImport.importChanges1({
|
|
project_id: testProjectId,
|
|
end_version: 0,
|
|
return_snapshot: 'hashed',
|
|
changes: changes.map(changeToRaw),
|
|
})
|
|
})
|
|
.then(() => {
|
|
// Check that this fails
|
|
expect.fail()
|
|
})
|
|
.catch(expectResponse.unprocessableEntity)
|
|
.then(() => {
|
|
// Get the latest content
|
|
return clientForProject.apis.Project.getLatestContent({
|
|
project_id: testProjectId,
|
|
})
|
|
})
|
|
.then(response => {
|
|
// Check that no changes have been stored
|
|
const snapshot = Snapshot.fromRaw(response.obj)
|
|
expect(snapshot.countFiles()).to.equal(1)
|
|
const file = snapshot.getFile(testFilePathname)
|
|
expect(file.getContent()).to.equal('')
|
|
})
|
|
.then(() => {
|
|
// Send a change with the wrong end version that is not conflicting
|
|
// with the latest snapshot
|
|
const changes = [makeChange(Operation.removeFile(testFilePathname))]
|
|
return basicAuthClient.apis.ProjectImport.importChanges1({
|
|
project_id: testProjectId,
|
|
end_version: 10000,
|
|
changes,
|
|
})
|
|
})
|
|
.then(() => {
|
|
// Check that this fails
|
|
expect.fail()
|
|
})
|
|
.catch(expectResponse.unprocessableEntity)
|
|
.then(() => {
|
|
// Get the latest project history
|
|
return clientForProject.apis.Project.getLatestHistory({
|
|
project_id: testProjectId,
|
|
})
|
|
})
|
|
.then(response => {
|
|
// Check that no changes have been stored
|
|
const chunkResponse = ChunkResponse.fromRaw(response.obj)
|
|
const changes = chunkResponse.getChunk().getChanges()
|
|
expect(changes).to.have.length(0)
|
|
})
|
|
})
|
|
|
|
it('creates and edits a file using changes', function () {
|
|
const testProjectId = '1'
|
|
const mainFilePathname = 'main.tex'
|
|
const testFilePathname = 'test.tex'
|
|
const testTextOperation = TextOperation.fromJSON({ textOperation: ['a'] })
|
|
const inexistentAuthors = [1234, 5678]
|
|
const projectVersion = '12345.0'
|
|
const v2DocVersions = new V2DocVersions({
|
|
'random-doc-id': { pathname: 'doc-path.tex', v: 123 },
|
|
})
|
|
const testLabelOrigin = Origin.fromRaw({
|
|
kind: 'saved ver',
|
|
})
|
|
const testRestoreOrigin = Origin.fromRaw({
|
|
kind: 'restore',
|
|
timestamp: '2016-01-01T00:00:00',
|
|
version: 1,
|
|
})
|
|
|
|
let testSnapshot
|
|
|
|
return fetch(
|
|
testServer.url(
|
|
`/api/projects/${testProjectId}/blobs/${File.EMPTY_FILE_HASH}`
|
|
),
|
|
{
|
|
method: 'PUT',
|
|
body: fs.createReadStream(testFiles.path('empty.tex')),
|
|
headers: {
|
|
Authorization: testServer.basicAuthHeader,
|
|
},
|
|
}
|
|
)
|
|
.then(response => {
|
|
expect(response.ok).to.be.true
|
|
})
|
|
.then(() => {
|
|
// Import a project
|
|
testSnapshot = new Snapshot()
|
|
testSnapshot.addFile(
|
|
mainFilePathname,
|
|
File.fromHash(File.EMPTY_FILE_HASH)
|
|
)
|
|
return basicAuthClient.apis.ProjectImport.importSnapshot1({
|
|
project_id: testProjectId,
|
|
snapshot: testSnapshot.toRaw(),
|
|
})
|
|
})
|
|
.then(response => {
|
|
// Check that the project is valid
|
|
expect(response.obj.projectId).to.equal(testProjectId)
|
|
})
|
|
.then(() => {
|
|
// Import changes
|
|
const testFile = File.fromHash(File.EMPTY_FILE_HASH)
|
|
const changes = [
|
|
makeChange(Operation.addFile(testFilePathname, testFile)),
|
|
makeChange(Operation.editFile(testFilePathname, testTextOperation)),
|
|
]
|
|
changes[0].setProjectVersion(projectVersion)
|
|
changes[1].setAuthors(inexistentAuthors)
|
|
changes[1].setV2DocVersions(v2DocVersions)
|
|
return basicAuthClient.apis.ProjectImport.importChanges1({
|
|
project_id: testProjectId,
|
|
end_version: 0,
|
|
return_snapshot: 'hashed',
|
|
changes: changes.map(changeToRaw),
|
|
})
|
|
})
|
|
.then(response => {
|
|
expect(response.status).to.equal(HTTPStatus.CREATED)
|
|
const snapshot = Snapshot.fromRaw(response.obj)
|
|
expect(snapshot.countFiles()).to.equal(2)
|
|
expect(snapshot.getFile('main.tex').getHash()).to.equal(
|
|
File.EMPTY_FILE_HASH
|
|
)
|
|
expect(snapshot.getFile('test.tex').getHash()).to.equal(
|
|
testFiles.STRING_A_HASH
|
|
)
|
|
})
|
|
.then(() => {
|
|
// Get the project history
|
|
return clientForProject.apis.Project.getLatestHistory({
|
|
project_id: testProjectId,
|
|
})
|
|
})
|
|
.then(response => {
|
|
// it should not fail when the some of the authors do not exist anymore
|
|
const chunkResponse = ChunkResponse.fromRaw(response.obj)
|
|
const changes = chunkResponse.getChunk().getChanges()
|
|
expect(changes.length).to.equal(2)
|
|
const changeWithAuthor = changes[1]
|
|
expect(changeWithAuthor.getAuthors()).to.deep.equal(inexistentAuthors)
|
|
})
|
|
.then(() => {
|
|
// it should retrieve the latest snapshot when the changes set is empty
|
|
return basicAuthClient.apis.ProjectImport.importChanges1({
|
|
project_id: testProjectId,
|
|
end_version: 0,
|
|
return_snapshot: 'hashed',
|
|
changes: [],
|
|
})
|
|
})
|
|
.then(response => {
|
|
// Check latest snapshot
|
|
const snapshot = Snapshot.fromRaw(response.obj)
|
|
expect(snapshot.countFiles()).to.equal(2)
|
|
expect(snapshot.getFile('main.tex').getHash()).to.equal(
|
|
File.EMPTY_FILE_HASH
|
|
)
|
|
expect(snapshot.getFile('test.tex').getHash()).to.equal(
|
|
testFiles.STRING_A_HASH
|
|
)
|
|
expect(snapshot.getProjectVersion()).to.equal(projectVersion)
|
|
expect(snapshot.getV2DocVersions()).to.deep.equal(v2DocVersions)
|
|
})
|
|
.then(() => {
|
|
// Import changes with origin
|
|
const testFile = File.fromHash(File.EMPTY_FILE_HASH)
|
|
const changes = [
|
|
makeChange(Operation.removeFile(testFilePathname)),
|
|
makeChange(Operation.addFile(testFilePathname, testFile)),
|
|
]
|
|
changes[0].setOrigin(testLabelOrigin)
|
|
changes[1].setOrigin(testRestoreOrigin)
|
|
return basicAuthClient.apis.ProjectImport.importChanges1({
|
|
project_id: testProjectId,
|
|
end_version: 0,
|
|
changes: changes.map(changeToRaw),
|
|
})
|
|
})
|
|
.then(() => {
|
|
// Get the latest history
|
|
return clientForProject.apis.Project.getLatestHistory({
|
|
project_id: testProjectId,
|
|
})
|
|
})
|
|
.then(response => {
|
|
// Check that the origin is stored
|
|
const chunkResponse = ChunkResponse.fromRaw(response.obj)
|
|
const changes = chunkResponse.getChunk().getChanges()
|
|
expect(changes).to.have.length(4)
|
|
expect(changes[2].getOrigin()).to.deep.equal(testLabelOrigin)
|
|
expect(changes[3].getOrigin()).to.deep.equal(testRestoreOrigin)
|
|
})
|
|
.then(() => {
|
|
// Import invalid changes
|
|
const testFile = File.fromHash(File.EMPTY_FILE_HASH)
|
|
const changes = [makeChange(Operation.addFile('../../a.tex', testFile))]
|
|
return basicAuthClient.apis.ProjectImport.importChanges1({
|
|
project_id: testProjectId,
|
|
end_version: 0,
|
|
changes: changes.map(changeToRaw),
|
|
})
|
|
})
|
|
.then(() => {
|
|
// Check that this fails and returns a 422
|
|
expect.fail()
|
|
})
|
|
.catch(expectResponse.unprocessableEntity)
|
|
})
|
|
|
|
it('imports changes with git-bridge origin', function () {
|
|
const testProjectId = '1'
|
|
const testFilePathname = 'git.tex'
|
|
const testFile = File.fromHash(File.EMPTY_FILE_HASH)
|
|
const testGitOrigin = Origin.fromRaw({
|
|
kind: 'git-bridge',
|
|
})
|
|
|
|
let testSnapshot
|
|
|
|
return fetch(
|
|
testServer.url(
|
|
`/api/projects/${testProjectId}/blobs/${File.EMPTY_FILE_HASH}`
|
|
),
|
|
{
|
|
method: 'PUT',
|
|
body: fs.createReadStream(testFiles.path('empty.tex')),
|
|
headers: {
|
|
Authorization: testServer.basicAuthHeader,
|
|
},
|
|
}
|
|
)
|
|
.then(response => {
|
|
expect(response.ok).to.be.true
|
|
})
|
|
.then(() => {
|
|
testSnapshot = new Snapshot()
|
|
testSnapshot.addFile(testFilePathname, testFile)
|
|
return basicAuthClient.apis.ProjectImport.importSnapshot1({
|
|
project_id: testProjectId,
|
|
snapshot: testSnapshot.toRaw(),
|
|
})
|
|
})
|
|
.then(response => {
|
|
expect(response.obj.projectId).to.equal(testProjectId)
|
|
})
|
|
.then(() => {
|
|
const changes = [
|
|
makeChange(Operation.addFile(testFilePathname, testFile)),
|
|
]
|
|
changes[0].setOrigin(testGitOrigin)
|
|
return basicAuthClient.apis.ProjectImport.importChanges1({
|
|
project_id: testProjectId,
|
|
end_version: 0,
|
|
changes: changes.map(changeToRaw),
|
|
})
|
|
})
|
|
.then(response => {
|
|
expect(response.status).to.equal(HTTPStatus.CREATED)
|
|
})
|
|
.then(() => {
|
|
return clientForProject.apis.Project.getLatestHistory({
|
|
project_id: testProjectId,
|
|
})
|
|
})
|
|
.then(response => {
|
|
const chunkResponse = ChunkResponse.fromRaw(response.obj)
|
|
const changes = chunkResponse.getChunk().getChanges()
|
|
expect(changes.length).to.be.at.least(1)
|
|
const lastChange = changes[changes.length - 1]
|
|
expect(lastChange.getOrigin()).to.deep.equal(testGitOrigin)
|
|
})
|
|
})
|
|
|
|
it('preserves rangesHash when importing a file', async function () {
|
|
const testProjectId = '1'
|
|
const testFilePathname = 'main.tex'
|
|
// hello.txt contains "Olá mundo\n" (10 UTF-8 chars).
|
|
const rangesContent = JSON.stringify({
|
|
comments: [
|
|
{
|
|
id: 'comment-1',
|
|
ranges: [{ pos: 0, length: 3 }],
|
|
resolved: false,
|
|
},
|
|
],
|
|
trackedChanges: [
|
|
{
|
|
range: { pos: 4, length: 5 },
|
|
tracking: {
|
|
type: 'insert',
|
|
userId: 'user-1',
|
|
ts: '2024-01-01T00:00:00.000Z',
|
|
},
|
|
},
|
|
],
|
|
})
|
|
const testRangesHash = blobHashFromString(rangesContent)
|
|
const testFile = File.fromHash(testFiles.HELLO_TXT_HASH, testRangesHash)
|
|
|
|
const [contentResponse, rangesResponse] = await Promise.all([
|
|
fetch(
|
|
testServer.url(
|
|
`/api/projects/${testProjectId}/blobs/${testFiles.HELLO_TXT_HASH}`
|
|
),
|
|
{
|
|
method: 'PUT',
|
|
body: fs.createReadStream(testFiles.path('hello.txt')),
|
|
headers: { Authorization: testServer.basicAuthHeader },
|
|
}
|
|
),
|
|
fetch(
|
|
testServer.url(
|
|
`/api/projects/${testProjectId}/blobs/${testRangesHash}`
|
|
),
|
|
{
|
|
method: 'PUT',
|
|
body: rangesContent,
|
|
headers: { Authorization: testServer.basicAuthHeader },
|
|
}
|
|
),
|
|
])
|
|
expect(contentResponse.ok).to.be.true
|
|
expect(rangesResponse.ok).to.be.true
|
|
|
|
const testSnapshot = new Snapshot()
|
|
testSnapshot.addFile(testFilePathname, testFile)
|
|
const importResponse =
|
|
await basicAuthClient.apis.ProjectImport.importSnapshot1({
|
|
project_id: testProjectId,
|
|
snapshot: testSnapshot.toRaw(),
|
|
})
|
|
expect(importResponse.obj.projectId).to.equal(testProjectId)
|
|
|
|
const historyResponse =
|
|
await clientForProject.apis.Project.getLatestHistory({
|
|
project_id: testProjectId,
|
|
})
|
|
const chunk = ChunkResponse.fromRaw(historyResponse.obj).getChunk()
|
|
const file = chunk.getSnapshot().getFile(testFilePathname)
|
|
expect(file.getRangesHash()).to.equal(testRangesHash)
|
|
})
|
|
|
|
it('preserves projectVersion, v2DocVersions and timestamp on snapshot import', async function () {
|
|
const testProjectId = '1'
|
|
const testFilePathname = 'empty.tex'
|
|
const testDocId = '000000000000000000000001'
|
|
const testProjectVersion = '12345.0'
|
|
const testV2DocVersions = new V2DocVersions({
|
|
[testDocId]: { pathname: testFilePathname, v: 123 },
|
|
})
|
|
const testTimestamp = new Date('2024-01-01T00:00:00.000Z')
|
|
|
|
const response = await fetch(
|
|
testServer.url(
|
|
`/api/projects/${testProjectId}/blobs/${File.EMPTY_FILE_HASH}`
|
|
),
|
|
{
|
|
method: 'PUT',
|
|
body: fs.createReadStream(testFiles.path('empty.tex')),
|
|
headers: { Authorization: testServer.basicAuthHeader },
|
|
}
|
|
)
|
|
expect(response.ok).to.be.true
|
|
|
|
const testSnapshot = new Snapshot()
|
|
testSnapshot.addFile(testFilePathname, File.fromHash(File.EMPTY_FILE_HASH))
|
|
testSnapshot.setProjectVersion(testProjectVersion)
|
|
testSnapshot.setV2DocVersions(testV2DocVersions)
|
|
testSnapshot.setTimestamp(testTimestamp)
|
|
|
|
const importResponse =
|
|
await basicAuthClient.apis.ProjectImport.importSnapshot1({
|
|
project_id: testProjectId,
|
|
snapshot: testSnapshot.toRaw(),
|
|
})
|
|
expect(importResponse.obj.projectId).to.equal(testProjectId)
|
|
|
|
const historyResponse =
|
|
await clientForProject.apis.Project.getLatestHistory({
|
|
project_id: testProjectId,
|
|
})
|
|
const snapshot = ChunkResponse.fromRaw(historyResponse.obj)
|
|
.getChunk()
|
|
.getSnapshot()
|
|
expect(snapshot.getProjectVersion()).to.equal(testProjectVersion)
|
|
expect(snapshot.getV2DocVersions()).to.deep.equal(testV2DocVersions)
|
|
expect(snapshot.getTimestamp()?.toISOString()).to.equal(
|
|
testTimestamp.toISOString()
|
|
)
|
|
})
|
|
|
|
it('rejects text operations on binary files', function () {
|
|
const testProjectId = '1'
|
|
const testFilePathname = 'main.tex'
|
|
const testTextOperation = TextOperation.fromJSON({ textOperation: ['bb'] })
|
|
|
|
let testSnapshot
|
|
|
|
return fetch(
|
|
testServer.url(
|
|
`/api/projects/${testProjectId}/blobs/${testFiles.NON_BMP_TXT_HASH}`
|
|
),
|
|
{
|
|
method: 'PUT',
|
|
body: fs.createReadStream(testFiles.path('non_bmp.txt')),
|
|
headers: {
|
|
Authorization: testServer.basicAuthHeader,
|
|
},
|
|
}
|
|
)
|
|
.then(response => {
|
|
expect(response.ok).to.be.true
|
|
})
|
|
.then(() => {
|
|
// Import a project
|
|
testSnapshot = new Snapshot()
|
|
testSnapshot.addFile(
|
|
testFilePathname,
|
|
File.fromHash(testFiles.NON_BMP_TXT_HASH)
|
|
)
|
|
return basicAuthClient.apis.ProjectImport.importSnapshot1({
|
|
project_id: testProjectId,
|
|
snapshot: testSnapshot.toRaw(),
|
|
})
|
|
})
|
|
.then(response => {
|
|
// Check that the project is valid
|
|
expect(response.obj.projectId).to.equal(testProjectId)
|
|
})
|
|
.then(() => {
|
|
// Import invalid changes
|
|
const changes = [
|
|
makeChange(Operation.editFile(testFilePathname, testTextOperation)),
|
|
]
|
|
return basicAuthClient.apis.ProjectImport.importChanges1({
|
|
project_id: testProjectId,
|
|
end_version: 0,
|
|
changes: changes.map(changeToRaw),
|
|
})
|
|
})
|
|
.then(() => {
|
|
// Expect invalid changes to fail
|
|
expect.fail()
|
|
})
|
|
.catch(expectResponse.unprocessableEntity)
|
|
.then(() => {
|
|
// Get latest content
|
|
return clientForProject.apis.Project.getLatestContent({
|
|
project_id: testProjectId,
|
|
})
|
|
})
|
|
.then(response => {
|
|
// Check that no changes were stored
|
|
const snapshot = Snapshot.fromRaw(response.obj)
|
|
expect(snapshot.countFiles()).to.equal(1)
|
|
expect(snapshot.getFile(testFilePathname).getHash()).to.equal(
|
|
testFiles.NON_BMP_TXT_HASH
|
|
)
|
|
})
|
|
})
|
|
|
|
it('accepts text operation on files with null characters if stringLength is present', function () {
|
|
const testProjectId = '1'
|
|
const mainFilePathname = 'main.tex'
|
|
const testTextOperation = TextOperation.fromJSON({
|
|
textOperation: [3, 'a'],
|
|
})
|
|
|
|
let testSnapshot
|
|
|
|
function importChanges() {
|
|
const changes = [
|
|
makeChange(Operation.editFile(mainFilePathname, testTextOperation)),
|
|
]
|
|
return basicAuthClient.apis.ProjectImport.importChanges1({
|
|
project_id: testProjectId,
|
|
end_version: 0,
|
|
changes: changes.map(changeToRaw),
|
|
})
|
|
}
|
|
|
|
function getLatestContent() {
|
|
return clientForProject.apis.Project.getLatestContent({
|
|
project_id: testProjectId,
|
|
})
|
|
}
|
|
|
|
return fetch(
|
|
testServer.url(
|
|
`/api/projects/${testProjectId}/blobs/${testFiles.NULL_CHARACTERS_TXT_HASH}`
|
|
),
|
|
{
|
|
method: 'PUT',
|
|
body: fs.createReadStream(testFiles.path('null_characters.txt')),
|
|
headers: {
|
|
Authorization: testServer.basicAuthHeader,
|
|
},
|
|
}
|
|
)
|
|
.then(response => {
|
|
expect(response.ok).to.be.true
|
|
})
|
|
.then(() => {
|
|
// Import project
|
|
testSnapshot = new Snapshot()
|
|
testSnapshot.addFile(
|
|
mainFilePathname,
|
|
File.fromHash(testFiles.NULL_CHARACTERS_TXT_HASH)
|
|
)
|
|
return basicAuthClient.apis.ProjectImport.importSnapshot1({
|
|
project_id: testProjectId,
|
|
snapshot: testSnapshot.toRaw(),
|
|
})
|
|
})
|
|
.then(importChanges)
|
|
.then(() => {
|
|
// Expect invalid changes to fail
|
|
expect.fail()
|
|
})
|
|
.catch(expectResponse.unprocessableEntity)
|
|
.then(getLatestContent)
|
|
.then(response => {
|
|
// Check that no changes were made
|
|
const snapshot = Snapshot.fromRaw(response.obj)
|
|
expect(snapshot.countFiles()).to.equal(1)
|
|
expect(snapshot.getFile(mainFilePathname).getHash()).to.equal(
|
|
testFiles.NULL_CHARACTERS_TXT_HASH
|
|
)
|
|
})
|
|
.then(() => {
|
|
// Set string length
|
|
return knex('project_blobs').update(
|
|
'string_length',
|
|
testFiles.NULL_CHARACTERS_TXT_BYTE_LENGTH
|
|
)
|
|
})
|
|
.then(importChanges)
|
|
.then(getLatestContent)
|
|
.then(response => {
|
|
const snapshot = Snapshot.fromRaw(response.obj)
|
|
expect(snapshot.countFiles()).to.equal(1)
|
|
expect(snapshot.getFile(mainFilePathname).getContent()).to.equal(
|
|
'\x00\x00\x00a'
|
|
)
|
|
})
|
|
})
|
|
|
|
it('returns 404 when chunk is not found in bucket', function () {
|
|
const testProjectId = '1'
|
|
const fooChange = makeChange(Operation.removeFile('foo.tex'))
|
|
|
|
return knex('chunks')
|
|
.insert({
|
|
doc_id: testProjectId,
|
|
start_version: 0,
|
|
end_version: 100,
|
|
end_timestamp: null,
|
|
})
|
|
.then(() => {
|
|
// Import changes
|
|
return basicAuthClient.apis.ProjectImport.importChanges1({
|
|
project_id: testProjectId,
|
|
end_version: 100,
|
|
changes: [fooChange.toRaw()],
|
|
})
|
|
})
|
|
.then(() => {
|
|
// Expect invalid changes to fail
|
|
expect.fail()
|
|
})
|
|
.catch(expectResponse.notFound)
|
|
})
|
|
|
|
it('creates and returns changes with v2 author ids', function () {
|
|
const testFilePathname = 'test.tex'
|
|
const testTextOperation = TextOperation.fromJSON({ textOperation: ['a'] })
|
|
const v2Authors = ['5a296963ad5e82432674c839', null]
|
|
|
|
let testProjectId
|
|
|
|
return testProjects
|
|
.createEmptyProject()
|
|
.then(projectId => {
|
|
testProjectId = projectId
|
|
expect(testProjectId).to.be.a('string')
|
|
})
|
|
.then(() => {
|
|
return fetch(
|
|
testServer.url(
|
|
`/api/projects/${testProjectId}/blobs/${File.EMPTY_FILE_HASH}`
|
|
),
|
|
{
|
|
method: 'PUT',
|
|
body: fs.createReadStream(testFiles.path('empty.tex')),
|
|
headers: {
|
|
Authorization: testServer.basicAuthHeader,
|
|
},
|
|
}
|
|
)
|
|
})
|
|
.then(response => {
|
|
expect(response.ok).to.be.true
|
|
})
|
|
.then(() => {
|
|
// Import changes
|
|
const testFile = File.fromHash(File.EMPTY_FILE_HASH)
|
|
const changes = [
|
|
makeChange(Operation.addFile(testFilePathname, testFile)),
|
|
makeChange(Operation.editFile(testFilePathname, testTextOperation)),
|
|
]
|
|
changes[1].setV2Authors(v2Authors)
|
|
return basicAuthClient.apis.ProjectImport.importChanges1({
|
|
project_id: testProjectId,
|
|
end_version: 0,
|
|
return_snapshot: 'hashed',
|
|
changes: changes.map(changeToRaw),
|
|
})
|
|
})
|
|
.then(response => {
|
|
expect(response.status).to.equal(HTTPStatus.CREATED)
|
|
const snapshot = Snapshot.fromRaw(response.obj)
|
|
expect(snapshot.countFiles()).to.equal(1)
|
|
expect(snapshot.getFile('test.tex').getHash()).to.equal(
|
|
testFiles.STRING_A_HASH
|
|
)
|
|
})
|
|
.then(() => {
|
|
// Get project history
|
|
return pseudoJwtBasicAuthClient.apis.Project.getLatestHistory({
|
|
project_id: testProjectId,
|
|
})
|
|
})
|
|
.then(response => {
|
|
// it should not fail when the some of the authors do not exist anymore
|
|
const chunkResponse = ChunkResponse.fromRaw(response.obj)
|
|
const changes = chunkResponse.getChunk().getChanges()
|
|
expect(changes.length).to.equal(2)
|
|
const changeWithAuthor = changes[1]
|
|
expect(changeWithAuthor.getV2Authors()).to.deep.equal(v2Authors)
|
|
})
|
|
})
|
|
|
|
it('should reject invalid v2 author ids', function () {
|
|
const testFilePathname = 'test.tex'
|
|
const v2Authors = ['not-a-v2-id']
|
|
|
|
let testProjectId
|
|
|
|
return testProjects
|
|
.createEmptyProject()
|
|
.then(projectId => {
|
|
testProjectId = projectId
|
|
expect(testProjectId).to.be.a('string')
|
|
})
|
|
.then(() => {
|
|
return fetch(
|
|
testServer.url(
|
|
`/api/projects/${testProjectId}/blobs/${File.EMPTY_FILE_HASH}`
|
|
),
|
|
{
|
|
method: 'PUT',
|
|
body: fs.createReadStream(testFiles.path('empty.tex')),
|
|
headers: {
|
|
Authorization: testServer.basicAuthHeader,
|
|
},
|
|
}
|
|
)
|
|
})
|
|
.then(response => {
|
|
expect(response.ok).to.be.true
|
|
})
|
|
.then(() => {
|
|
// Import changes
|
|
const testFile = File.fromHash(File.EMPTY_FILE_HASH)
|
|
const changes = [
|
|
makeChange(Operation.addFile(testFilePathname, testFile)),
|
|
]
|
|
|
|
changes[0].v2Authors = v2Authors
|
|
return basicAuthClient.apis.ProjectImport.importChanges1({
|
|
project_id: testProjectId,
|
|
end_version: 0,
|
|
changes: changes.map(changeToRaw),
|
|
})
|
|
})
|
|
.then(() => {
|
|
// Check that invalid changes fail
|
|
expect.fail()
|
|
})
|
|
.catch(expectResponse.unprocessableEntity)
|
|
})
|
|
|
|
it('should reject changes with both v1 and v2 authors ids', function () {
|
|
const testFilePathname = 'test.tex'
|
|
const v1Authors = [456]
|
|
const v2Authors = ['5a296963ad5e82432674c839', null]
|
|
|
|
let testProjectId
|
|
|
|
return testProjects
|
|
.createEmptyProject()
|
|
.then(projectId => {
|
|
testProjectId = projectId
|
|
expect(testProjectId).to.be.a('string')
|
|
})
|
|
.then(() => {
|
|
return fetch(
|
|
testServer.url(
|
|
`/api/projects/${testProjectId}/blobs/${File.EMPTY_FILE_HASH}`
|
|
),
|
|
{
|
|
method: 'PUT',
|
|
body: fs.createReadStream(testFiles.path('empty.tex')),
|
|
headers: {
|
|
Authorization: testServer.basicAuthHeader,
|
|
},
|
|
}
|
|
)
|
|
})
|
|
.then(response => {
|
|
expect(response.ok).to.be.true
|
|
})
|
|
.then(() => {
|
|
// Import changes
|
|
const testFile = File.fromHash(File.EMPTY_FILE_HASH)
|
|
const changes = [
|
|
makeChange(Operation.addFile(testFilePathname, testFile)),
|
|
]
|
|
|
|
changes[0].authors = v1Authors
|
|
changes[0].v2Authors = v2Authors
|
|
return basicAuthClient.apis.ProjectImport.importChanges1({
|
|
project_id: testProjectId,
|
|
end_version: 0,
|
|
changes: changes.map(changeToRaw),
|
|
})
|
|
})
|
|
.then(() => {
|
|
// Check that invalid changes fail
|
|
expect.fail()
|
|
})
|
|
.catch(expectResponse.unprocessableEntity)
|
|
})
|
|
|
|
it("returns unprocessable if end_version isn't provided", function () {
|
|
return testProjects
|
|
.createEmptyProject()
|
|
.then(projectId => {
|
|
expect(projectId).to.be.a('string')
|
|
return projectId
|
|
})
|
|
.then(projectId => {
|
|
// Import changes
|
|
return basicAuthClient.apis.ProjectImport.importChanges1({
|
|
project_id: projectId,
|
|
changes: [],
|
|
})
|
|
})
|
|
.then(() => {
|
|
// Check that invalid changes fail
|
|
expect.fail()
|
|
})
|
|
.catch(error => {
|
|
expect(error.message).to.equal('request failed with status 422')
|
|
})
|
|
})
|
|
|
|
it('returns unprocessable if return_snapshot is invalid', function () {
|
|
return testProjects
|
|
.createEmptyProject()
|
|
.then(projectId => {
|
|
// Import changes
|
|
return basicAuthClient.apis.ProjectImport.importChanges1({
|
|
project_id: projectId,
|
|
changes: [],
|
|
end_version: 0,
|
|
return_snapshot: 'not_a_valid_value',
|
|
})
|
|
})
|
|
.then(() => {
|
|
// Check that invalid changes fail
|
|
expect.fail()
|
|
})
|
|
.catch(error => {
|
|
expect(error.status).to.equal(HTTPStatus.UNPROCESSABLE_ENTITY)
|
|
})
|
|
})
|
|
})
|