nextcloud-desktop/test/testsyncmove.cpp
Christian Kamm 9c7ee6ef85 Reconcile: Rename handling fixes: duplicate file ids
When users share the same tree several times (say A/ and A/B/ are both
shared) the remote tree can have several entries that have the same
file id. This needs to be respected in rename detection.

Also adds several tests and fixes for issues noticed during testing.

See #6096
2017-10-24 10:54:23 +02:00

569 lines
23 KiB
C++

/*
* This software is in the public domain, furnished "as is", without technical
* support, and with no warranty, express or implied, as to its usefulness for
* any purpose.
*
*/
#include <QtTest>
#include "syncenginetestutils.h"
#include <syncengine.h>
using namespace OCC;
SyncFileItemPtr findItem(const QSignalSpy &spy, const QString &path)
{
for (const QList<QVariant> &args : spy) {
auto item = args[0].value<SyncFileItemPtr>();
if (item->destination() == path)
return item;
}
return SyncFileItemPtr(new SyncFileItem);
}
bool itemSuccessful(const QSignalSpy &spy, const QString &path, const csync_instructions_e instr)
{
auto item = findItem(spy, path);
return item->_status == SyncFileItem::Success && item->_instruction == instr;
}
bool itemConflict(const QSignalSpy &spy, const QString &path)
{
auto item = findItem(spy, path);
return item->_status == SyncFileItem::Conflict && item->_instruction == CSYNC_INSTRUCTION_CONFLICT;
}
bool itemSuccessfulMove(const QSignalSpy &spy, const QString &path)
{
return itemSuccessful(spy, path, CSYNC_INSTRUCTION_RENAME);
}
QStringList findConflicts(const FileInfo &dir)
{
QStringList conflicts;
for (const auto &item : dir.children) {
if (item.name.contains("conflict")) {
conflicts.append(item.path());
}
}
return conflicts;
}
bool expectAndWipeConflict(FileModifier &local, FileInfo state, const QString path)
{
PathComponents pathComponents(path);
auto base = state.find(pathComponents.parentDirComponents());
if (!base)
return false;
for (const auto &item : base->children) {
if (item.name.startsWith(pathComponents.fileName()) && item.name.contains("_conflict")) {
local.remove(item.path());
return true;
}
}
return false;
}
class TestSyncMove : public QObject
{
Q_OBJECT
private slots:
void testRemoteChangeInMovedFolder()
{
// issue #5192
FakeFolder fakeFolder{ FileInfo{ QString(), { FileInfo{ QStringLiteral("folder"), { FileInfo{ QStringLiteral("folderA"), { { QStringLiteral("file.txt"), 400 } } }, QStringLiteral("folderB") } } } } };
QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
// Edit a file in a moved directory.
fakeFolder.remoteModifier().setContents("folder/folderA/file.txt", 'a');
fakeFolder.remoteModifier().rename("folder/folderA", "folder/folderB/folderA");
fakeFolder.syncOnce();
QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
auto oldState = fakeFolder.currentLocalState();
QVERIFY(oldState.find("folder/folderB/folderA/file.txt"));
QVERIFY(!oldState.find("folder/folderA/file.txt"));
// This sync should not remove the file
fakeFolder.syncOnce();
QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
QCOMPARE(fakeFolder.currentLocalState(), oldState);
}
void testSelectiveSyncMovedFolder()
{
// issue #5224
FakeFolder fakeFolder{ FileInfo{ QString(), { FileInfo{ QStringLiteral("parentFolder"), { FileInfo{ QStringLiteral("subFolderA"), { { QStringLiteral("fileA.txt"), 400 } } }, FileInfo{ QStringLiteral("subFolderB"), { { QStringLiteral("fileB.txt"), 400 } } } } } } } };
QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
auto expectedServerState = fakeFolder.currentRemoteState();
// Remove subFolderA with selectiveSync:
fakeFolder.syncEngine().journal()->setSelectiveSyncList(SyncJournalDb::SelectiveSyncBlackList,
{ "parentFolder/subFolderA/" });
fakeFolder.syncEngine().journal()->avoidReadFromDbOnNextSync(QByteArrayLiteral("parentFolder/subFolderA/"));
fakeFolder.syncOnce();
{
// Nothing changed on the server
QCOMPARE(fakeFolder.currentRemoteState(), expectedServerState);
// The local state should not have subFolderA
auto remoteState = fakeFolder.currentRemoteState();
remoteState.remove("parentFolder/subFolderA");
QCOMPARE(fakeFolder.currentLocalState(), remoteState);
}
// Rename parentFolder on the server
fakeFolder.remoteModifier().rename("parentFolder", "parentFolderRenamed");
expectedServerState = fakeFolder.currentRemoteState();
fakeFolder.syncOnce();
{
QCOMPARE(fakeFolder.currentRemoteState(), expectedServerState);
auto remoteState = fakeFolder.currentRemoteState();
// The subFolderA should still be there on the server.
QVERIFY(remoteState.find("parentFolderRenamed/subFolderA/fileA.txt"));
// But not on the client because of the selective sync
remoteState.remove("parentFolderRenamed/subFolderA");
QCOMPARE(fakeFolder.currentLocalState(), remoteState);
}
// Rename it again, locally this time.
fakeFolder.localModifier().rename("parentFolderRenamed", "parentThirdName");
fakeFolder.syncOnce();
{
auto remoteState = fakeFolder.currentRemoteState();
// The subFolderA should still be there on the server.
QVERIFY(remoteState.find("parentThirdName/subFolderA/fileA.txt"));
// But not on the client because of the selective sync
remoteState.remove("parentThirdName/subFolderA");
QCOMPARE(fakeFolder.currentLocalState(), remoteState);
expectedServerState = fakeFolder.currentRemoteState();
QSignalSpy completeSpy(&fakeFolder.syncEngine(), SIGNAL(itemCompleted(const SyncFileItemPtr &)));
fakeFolder.syncOnce(); // This sync should do nothing
QCOMPARE(completeSpy.count(), 0);
QCOMPARE(fakeFolder.currentRemoteState(), expectedServerState);
QCOMPARE(fakeFolder.currentLocalState(), remoteState);
}
}
void testLocalMoveDetection()
{
FakeFolder fakeFolder{ FileInfo::A12_B12_C12_S12() };
int nPUT = 0;
int nDELETE = 0;
fakeFolder.setServerOverride([&](QNetworkAccessManager::Operation op, const QNetworkRequest &) {
if (op == QNetworkAccessManager::PutOperation)
++nPUT;
if (op == QNetworkAccessManager::DeleteOperation)
++nDELETE;
return nullptr;
});
// For directly editing the remote checksum
FileInfo &remoteInfo = fakeFolder.remoteModifier();
// Simple move causing a remote rename
fakeFolder.localModifier().rename("A/a1", "A/a1m");
QVERIFY(fakeFolder.syncOnce());
QCOMPARE(fakeFolder.currentLocalState(), remoteInfo);
QCOMPARE(nPUT, 0);
// Move-and-change, causing a upload and delete
fakeFolder.localModifier().rename("A/a2", "A/a2m");
fakeFolder.localModifier().appendByte("A/a2m");
QVERIFY(fakeFolder.syncOnce());
QCOMPARE(fakeFolder.currentLocalState(), remoteInfo);
QCOMPARE(nPUT, 1);
QCOMPARE(nDELETE, 1);
// Move-and-change, mtime+content only
fakeFolder.localModifier().rename("B/b1", "B/b1m");
fakeFolder.localModifier().setContents("B/b1m", 'C');
QVERIFY(fakeFolder.syncOnce());
QCOMPARE(fakeFolder.currentLocalState(), remoteInfo);
QCOMPARE(nPUT, 2);
QCOMPARE(nDELETE, 2);
// Move-and-change, size+content only
auto mtime = fakeFolder.remoteModifier().find("B/b2")->lastModified;
fakeFolder.localModifier().rename("B/b2", "B/b2m");
fakeFolder.localModifier().appendByte("B/b2m");
fakeFolder.localModifier().setModTime("B/b2m", mtime);
QVERIFY(fakeFolder.syncOnce());
QCOMPARE(fakeFolder.currentLocalState(), remoteInfo);
QCOMPARE(nPUT, 3);
QCOMPARE(nDELETE, 3);
// Move-and-change, content only -- c1 has no checksum, so we fail to detect this!
// NOTE: This is an expected failure.
mtime = fakeFolder.remoteModifier().find("C/c1")->lastModified;
fakeFolder.localModifier().rename("C/c1", "C/c1m");
fakeFolder.localModifier().setContents("C/c1m", 'C');
fakeFolder.localModifier().setModTime("C/c1m", mtime);
QVERIFY(fakeFolder.syncOnce());
QCOMPARE(nPUT, 3);
QCOMPARE(nDELETE, 3);
QVERIFY(!(fakeFolder.currentLocalState() == remoteInfo));
// cleanup, and upload a file that will have a checksum in the db
fakeFolder.localModifier().remove("C/c1m");
fakeFolder.localModifier().insert("C/c3");
QVERIFY(fakeFolder.syncOnce());
QCOMPARE(fakeFolder.currentLocalState(), remoteInfo);
QCOMPARE(nPUT, 4);
QCOMPARE(nDELETE, 4);
// Move-and-change, content only, this time while having a checksum
mtime = fakeFolder.remoteModifier().find("C/c3")->lastModified;
fakeFolder.localModifier().rename("C/c3", "C/c3m");
fakeFolder.localModifier().setContents("C/c3m", 'C');
fakeFolder.localModifier().setModTime("C/c3m", mtime);
QVERIFY(fakeFolder.syncOnce());
QCOMPARE(nPUT, 5);
QCOMPARE(nDELETE, 5);
QCOMPARE(fakeFolder.currentLocalState(), remoteInfo);
}
// If the same folder is shared in two different ways with the same
// user, the target user will see duplicate file ids. We need to make
// sure the move detection and sync still do the right thing in that
// case.
void testDuplicateFileId()
{
FakeFolder fakeFolder{ FileInfo::A12_B12_C12_S12() };
auto &remote = fakeFolder.remoteModifier();
remote.mkdir("A/W");
remote.insert("A/W/w1");
remote.mkdir("A/Q");
// Duplicate every entry in A under O/A
remote.mkdir("O");
remote.children["O"].addChild(remote.children["A"]);
// This already checks that the rename detection doesn't get
// horribly confused if we add new files that have the same
// fileid as existing ones
QVERIFY(fakeFolder.syncOnce());
QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
int nGET = 0;
fakeFolder.setServerOverride([&](QNetworkAccessManager::Operation op, const QNetworkRequest &) {
if (op == QNetworkAccessManager::GetOperation)
++nGET;
return nullptr;
});
// Try a remote file move
remote.rename("A/a1", "A/W/a1m");
remote.rename("O/A/a1", "O/A/W/a1m");
QVERIFY(fakeFolder.syncOnce());
QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
QCOMPARE(nGET, 0);
// And a remote directory move
remote.rename("A/W", "A/Q/W");
remote.rename("O/A/W", "O/A/Q/W");
QVERIFY(fakeFolder.syncOnce());
QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
QCOMPARE(nGET, 0);
// Partial file removal (in practice, A/a2 may be moved to O/a2, but we don't care)
remote.rename("O/A/a2", "O/a2");
remote.remove("A/a2");
QVERIFY(fakeFolder.syncOnce());
QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
QCOMPARE(nGET, 0);
// Local change plus remote move at the same time
fakeFolder.localModifier().appendByte("O/a2");
remote.rename("O/a2", "O/a3");
QVERIFY(fakeFolder.syncOnce());
QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
QCOMPARE(nGET, 1);
}
void testMovePropagation()
{
FakeFolder fakeFolder{ FileInfo::A12_B12_C12_S12() };
auto &local = fakeFolder.localModifier();
auto &remote = fakeFolder.remoteModifier();
int nGET = 0;
int nPUT = 0;
int nMOVE = 0;
int nDELETE = 0;
fakeFolder.setServerOverride([&](QNetworkAccessManager::Operation op, const QNetworkRequest &req) {
if (op == QNetworkAccessManager::GetOperation)
++nGET;
if (op == QNetworkAccessManager::PutOperation)
++nPUT;
if (op == QNetworkAccessManager::DeleteOperation)
++nDELETE;
if (req.attribute(QNetworkRequest::CustomVerbAttribute) == "MOVE")
++nMOVE;
return nullptr;
});
auto resetCounters = [&]() {
nGET = nPUT = nMOVE = nDELETE = 0;
};
// Move
{
resetCounters();
local.rename("A/a1", "A/a1m");
remote.rename("B/b1", "B/b1m");
QSignalSpy completeSpy(&fakeFolder.syncEngine(), SIGNAL(itemCompleted(const SyncFileItemPtr &)));
QVERIFY(fakeFolder.syncOnce());
QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
QCOMPARE(nGET, 0);
QCOMPARE(nPUT, 0);
QCOMPARE(nMOVE, 1);
QCOMPARE(nDELETE, 0);
QVERIFY(itemSuccessfulMove(completeSpy, "A/a1m"));
QVERIFY(itemSuccessfulMove(completeSpy, "B/b1m"));
}
// Touch+Move on same side
resetCounters();
local.rename("A/a2", "A/a2m");
local.setContents("A/a2m", 'A');
remote.rename("B/b2", "B/b2m");
remote.setContents("B/b2m", 'A');
QVERIFY(fakeFolder.syncOnce());
QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
QCOMPARE(nGET, 1);
QCOMPARE(nPUT, 1);
QCOMPARE(nMOVE, 0);
QCOMPARE(nDELETE, 1);
QCOMPARE(remote.find("A/a2m")->contentChar, 'A');
QCOMPARE(remote.find("B/b2m")->contentChar, 'A');
// Touch+Move on opposite sides
resetCounters();
local.rename("A/a1m", "A/a1m2");
remote.setContents("A/a1m", 'B');
remote.rename("B/b1m", "B/b1m2");
local.setContents("B/b1m", 'B');
QVERIFY(fakeFolder.syncOnce());
QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
QCOMPARE(nGET, 2);
QCOMPARE(nPUT, 2);
QCOMPARE(nMOVE, 0);
QCOMPARE(nDELETE, 0);
// All these files existing afterwards is debatable. Should we propagate
// the rename in one direction and grab the new contents in the other?
// Currently there's no propagation job that would do that, and this does
// at least not lose data.
QCOMPARE(remote.find("A/a1m")->contentChar, 'B');
QCOMPARE(remote.find("B/b1m")->contentChar, 'B');
QCOMPARE(remote.find("A/a1m2")->contentChar, 'W');
QCOMPARE(remote.find("B/b1m2")->contentChar, 'W');
// Touch+create on one side, move on the other
{
resetCounters();
local.appendByte("A/a1m");
local.insert("A/a1mt");
remote.rename("A/a1m", "A/a1mt");
remote.appendByte("B/b1m");
remote.insert("B/b1mt");
local.rename("B/b1m", "B/b1mt");
QSignalSpy completeSpy(&fakeFolder.syncEngine(), SIGNAL(itemCompleted(const SyncFileItemPtr &)));
QVERIFY(fakeFolder.syncOnce());
QVERIFY(expectAndWipeConflict(local, fakeFolder.currentLocalState(), "A/a1mt"));
QVERIFY(expectAndWipeConflict(local, fakeFolder.currentLocalState(), "B/b1mt"));
QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
QCOMPARE(nGET, 3);
QCOMPARE(nPUT, 1);
QCOMPARE(nMOVE, 0);
QCOMPARE(nDELETE, 0);
QVERIFY(itemSuccessful(completeSpy, "A/a1m", CSYNC_INSTRUCTION_NEW));
QVERIFY(itemSuccessful(completeSpy, "B/b1m", CSYNC_INSTRUCTION_NEW));
QVERIFY(itemConflict(completeSpy, "A/a1mt"));
QVERIFY(itemConflict(completeSpy, "B/b1mt"));
}
// Create new on one side, move to new on the other
{
resetCounters();
local.insert("A/a1N", 13);
remote.rename("A/a1mt", "A/a1N");
remote.insert("B/b1N", 13);
local.rename("B/b1mt", "B/b1N");
QSignalSpy completeSpy(&fakeFolder.syncEngine(), SIGNAL(itemCompleted(const SyncFileItemPtr &)));
QVERIFY(fakeFolder.syncOnce());
QVERIFY(expectAndWipeConflict(local, fakeFolder.currentLocalState(), "A/a1N"));
QVERIFY(expectAndWipeConflict(local, fakeFolder.currentLocalState(), "B/b1N"));
QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
QCOMPARE(nGET, 2);
QCOMPARE(nPUT, 0);
QCOMPARE(nMOVE, 0);
QCOMPARE(nDELETE, 1);
QVERIFY(itemSuccessful(completeSpy, "A/a1mt", CSYNC_INSTRUCTION_REMOVE));
QVERIFY(itemSuccessful(completeSpy, "B/b1mt", CSYNC_INSTRUCTION_REMOVE));
QVERIFY(itemConflict(completeSpy, "A/a1N"));
QVERIFY(itemConflict(completeSpy, "B/b1N"));
}
// Local move, remote move
resetCounters();
local.rename("C/c1", "C/c1mL");
remote.rename("C/c1", "C/c1mR");
QVERIFY(fakeFolder.syncOnce());
// end up with both files
QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
QCOMPARE(nGET, 1);
QCOMPARE(nPUT, 1);
QCOMPARE(nMOVE, 0);
QCOMPARE(nDELETE, 0);
// Rename/rename conflict on a folder
resetCounters();
remote.rename("C", "CMR");
local.rename("C", "CML");
QVERIFY(fakeFolder.syncOnce());
// End up with both folders
QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
QCOMPARE(nGET, 3); // 3 files in C
QCOMPARE(nPUT, 3);
QCOMPARE(nMOVE, 0);
QCOMPARE(nDELETE, 0);
// Folder move
{
resetCounters();
local.rename("A", "AM");
remote.rename("B", "BM");
QSignalSpy completeSpy(&fakeFolder.syncEngine(), SIGNAL(itemCompleted(const SyncFileItemPtr &)));
QVERIFY(fakeFolder.syncOnce());
QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
QCOMPARE(nGET, 0);
QCOMPARE(nPUT, 0);
QCOMPARE(nMOVE, 1);
QCOMPARE(nDELETE, 0);
QVERIFY(itemSuccessfulMove(completeSpy, "AM"));
QVERIFY(itemSuccessfulMove(completeSpy, "BM"));
}
// Folder move with contents touched on the same side
{
resetCounters();
local.setContents("AM/a2m", 'C');
local.rename("AM", "A2");
remote.setContents("BM/b2m", 'C');
remote.rename("BM", "B2");
QSignalSpy completeSpy(&fakeFolder.syncEngine(), SIGNAL(itemCompleted(const SyncFileItemPtr &)));
QVERIFY(fakeFolder.syncOnce());
QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
QCOMPARE(nGET, 1);
QCOMPARE(nPUT, 1);
QCOMPARE(nMOVE, 1);
QCOMPARE(nDELETE, 0);
QCOMPARE(remote.find("A2/a2m")->contentChar, 'C');
QCOMPARE(remote.find("B2/b2m")->contentChar, 'C');
QVERIFY(itemSuccessfulMove(completeSpy, "A2"));
QVERIFY(itemSuccessfulMove(completeSpy, "B2"));
}
// Folder rename with contents touched on the other tree
resetCounters();
remote.setContents("A2/a2m", 'D');
// setContents alone may not produce updated mtime if the test is fast
// and since we don't use checksums here, that matters.
remote.appendByte("A2/a2m");
local.rename("A2", "A3");
local.setContents("B2/b2m", 'D');
local.appendByte("B2/b2m");
remote.rename("B2", "B3");
QVERIFY(fakeFolder.syncOnce());
QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
QCOMPARE(nGET, 1);
QCOMPARE(nPUT, 1);
QCOMPARE(nMOVE, 1);
QCOMPARE(nDELETE, 0);
QCOMPARE(remote.find("A3/a2m")->contentChar, 'D');
QCOMPARE(remote.find("B3/b2m")->contentChar, 'D');
// Folder rename with contents touched on both ends
resetCounters();
remote.setContents("A3/a2m", 'R');
remote.appendByte("A3/a2m");
local.setContents("A3/a2m", 'L');
local.appendByte("A3/a2m");
local.appendByte("A3/a2m");
local.rename("A3", "A4");
remote.setContents("B3/b2m", 'R');
remote.appendByte("B3/b2m");
local.setContents("B3/b2m", 'L');
local.appendByte("B3/b2m");
local.appendByte("B3/b2m");
remote.rename("B3", "B4");
QVERIFY(fakeFolder.syncOnce());
auto currentLocal = fakeFolder.currentLocalState();
auto conflicts = findConflicts(currentLocal.children["A4"]);
QCOMPARE(conflicts.size(), 1);
for (auto c : conflicts) {
QCOMPARE(currentLocal.find(c)->contentChar, 'L');
local.remove(c);
}
conflicts = findConflicts(currentLocal.children["B4"]);
QCOMPARE(conflicts.size(), 1);
for (auto c : conflicts) {
QCOMPARE(currentLocal.find(c)->contentChar, 'L');
local.remove(c);
}
QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
QCOMPARE(nGET, 2);
QCOMPARE(nPUT, 0);
QCOMPARE(nMOVE, 1);
QCOMPARE(nDELETE, 0);
QCOMPARE(remote.find("A4/a2m")->contentChar, 'R');
QCOMPARE(remote.find("B4/b2m")->contentChar, 'R');
// Rename a folder and rename the contents at the same time
resetCounters();
local.rename("A4/a2m", "A4/a2m2");
local.rename("A4", "A5");
remote.rename("B4/b2m", "B4/b2m2");
remote.rename("B4", "B5");
QVERIFY(fakeFolder.syncOnce());
QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
QCOMPARE(nGET, 0);
QCOMPARE(nPUT, 0);
QCOMPARE(nMOVE, 2);
QCOMPARE(nDELETE, 0);
}
// Check interaction of moves with file type changes
void testMoveAndTypeChange()
{
FakeFolder fakeFolder{ FileInfo::A12_B12_C12_S12() };
auto &local = fakeFolder.localModifier();
auto &remote = fakeFolder.remoteModifier();
// Touch on one side, rename and mkdir on the other
{
local.appendByte("A/a1");
remote.rename("A/a1", "A/a1mq");
remote.mkdir("A/a1");
remote.appendByte("B/b1");
local.rename("B/b1", "B/b1mq");
local.mkdir("B/b1");
QSignalSpy completeSpy(&fakeFolder.syncEngine(), SIGNAL(itemCompleted(const SyncFileItemPtr &)));
QVERIFY(fakeFolder.syncOnce());
// BUG: This doesn't behave right
//QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
}
}
};
QTEST_GUILESS_MAIN(TestSyncMove)
#include "testsyncmove.moc"