zotero/test/tests/mendeleyDecryptTest.js

293 lines
9.9 KiB
JavaScript

describe('Mendeley Decrypt', function () {
var {
decryptDatabase,
getProfileUUID,
isDatabaseInUse,
isEncryptedDatabase
} = ChromeUtils.importESModule("chrome://zotero/content/import/mendeley/mendeleyDecrypt.mjs");
const PROFILE_UUID = '57173eea-07fc-3382-a3f7-548dff05def8';
const ENCRYPTED_DB = `${PROFILE_UUID}@www.mendeley.com.sqlite`;
var encryptedPath, tmpDirectory;
before(function () {
encryptedPath = OS.Path.join(getTestDataDirectory().path, ENCRYPTED_DB);
});
beforeEach(async function () {
tmpDirectory = OS.Path.join(Zotero.getTempDirectory().path, Zotero.Utilities.randomString());
await Zotero.File.createDirectoryIfMissingAsync(tmpDirectory);
});
afterEach(async function () {
await IOUtils.remove(tmpDirectory, { recursive: true, ignoreAbsent: true });
});
describe('#getProfileUUID()', function () {
it('should return the UUID from a Mendeley database filename', function () {
assert.equal(getProfileUUID(encryptedPath), PROFILE_UUID);
});
it("should return null if the filename doesn't contain a UUID", function () {
assert.isNull(getProfileUUID('/tmp/1@www.mendeley.com.sqlite'));
assert.isNull(getProfileUUID('/tmp/online.sqlite'));
});
});
describe('#isEncryptedDatabase()', function () {
it('should detect an encrypted Mendeley database', async function () {
assert.isTrue(await isEncryptedDatabase(encryptedPath));
});
it('should not detect an unencrypted SQLite database', async function () {
assert.isFalse(
await isEncryptedDatabase(OS.Path.join(getTestDataDirectory().path, 'test.sqlite'))
);
});
it('should not detect a missing file', async function () {
assert.isFalse(await isEncryptedDatabase(OS.Path.join(tmpDirectory, 'nonexistent.sqlite')));
});
});
describe('#isDatabaseInUse()', function () {
var dbPath;
beforeEach(async function () {
dbPath = OS.Path.join(tmpDirectory, ENCRYPTED_DB);
await IOUtils.copy(encryptedPath, dbPath);
});
it('should return false without a write-ahead log', async function () {
assert.isFalse(await isDatabaseInUse(dbPath));
});
it('should return true with a non-empty write-ahead log', async function () {
await IOUtils.write(dbPath + '-wal', new Uint8Array(32));
assert.isTrue(await isDatabaseInUse(dbPath));
});
it('should return false with an empty write-ahead log', async function () {
await IOUtils.write(dbPath + '-wal', new Uint8Array());
assert.isFalse(await isDatabaseInUse(dbPath));
});
it('should return true with a shared-memory file', async function () {
await IOUtils.write(dbPath + '-shm', new Uint8Array(32));
assert.isTrue(await isDatabaseInUse(dbPath));
});
});
describe('#decryptDatabase()', function () {
it('should decrypt a database into one Zotero can read', async function () {
var decryptedPath = OS.Path.join(tmpDirectory, ENCRYPTED_DB);
await decryptDatabase(encryptedPath, decryptedPath);
var db = new Zotero.DBConnection(decryptedPath);
try {
assert.equal(await db.valueQueryAsync("PRAGMA integrity_check"), 'ok');
assert.isTrue(await db.tableExists('Documents'));
assert.equal(
await db.valueQueryAsync("SELECT COUNT(*) FROM Documents"), 3
);
assert.equal(
await db.valueQueryAsync("SELECT uuid FROM Profiles"), PROFILE_UUID
);
}
finally {
await db.closeDatabase();
}
});
it('should leave the encrypted database untouched', async function () {
var before = await IOUtils.read(encryptedPath);
await decryptDatabase(encryptedPath, OS.Path.join(tmpDirectory, ENCRYPTED_DB));
assert.deepEqual(Array.from(await IOUtils.read(encryptedPath)), Array.from(before));
});
it('should reject a database whose key cannot be derived', async function () {
// The key is folded with the profile UUID, so a renamed database can't
// be decrypted
var renamedPath = OS.Path.join(tmpDirectory, '1@www.mendeley.com.sqlite');
await IOUtils.copy(encryptedPath, renamedPath);
var error = await getPromiseError(
decryptDatabase(renamedPath, OS.Path.join(tmpDirectory, 'out.sqlite'))
);
assert.equal(error.message, 'Cannot derive Mendeley database key');
});
it('should reject a file that is not an encrypted database', async function () {
var notADatabasePath = OS.Path.join(tmpDirectory, 'x@www.mendeley.com.sqlite');
await Zotero.File.putContentsAsync(notADatabasePath, 'not a database');
var error = await getPromiseError(
decryptDatabase(notADatabasePath, OS.Path.join(tmpDirectory, 'out.sqlite'))
);
assert.equal(error.message, 'Unsupported Mendeley database encryption');
});
});
describe('Importing an encrypted database', function () {
const BITCOIN_TITLE = 'Bitcoin: A Peer-to-Peer Electronic Cash System';
const BITCOIN_PDF = 'Nakamoto - Unknown - Bitcoin A Peer-to-Peer Electronic Cash System.pdf';
var win;
const getDecryptedCopies = async function () {
let dirs = [];
for (let entry of await IOUtils.getChildren(Zotero.getTempDirectory().path)) {
if (PathUtils.filename(entry).startsWith('mendeley-')) {
dirs.push(entry);
}
}
return dirs;
};
before(async function () {
win = await loadZoteroPane();
});
after(function () {
win.close();
});
it('should import items and remove the decrypted copy', async function () {
this.timeout(60000);
assert.lengthOf(await getDecryptedCopies(), 0);
await win.Zotero_File_Interface.importFile({
file: Zotero.File.pathToFile(encryptedPath),
createNewCollection: true
});
let collections = Zotero.Collections
.getByLibrary(Zotero.Libraries.userLibraryID)
.filter(c => c.name.startsWith('Mendeley Import'));
assert.lengthOf(collections, 1);
let titles = collections[0].getChildItems().map(item => item.getField('title'));
assert.include(titles, BITCOIN_TITLE);
assert.lengthOf(await getDecryptedCopies(), 0);
});
it('should look for attachments beside the encrypted database', async function () {
this.timeout(60000);
// A backup from another machine, whose absolute Downloaded paths don't exist here
let backupPath = OS.Path.join(tmpDirectory, ENCRYPTED_DB);
await IOUtils.copy(encryptedPath, backupPath);
let downloadedDirectory = OS.Path.join(tmpDirectory, 'Downloaded');
await Zotero.File.createDirectoryIfMissingAsync(downloadedDirectory);
await IOUtils.copy(
OS.Path.join(getTestDataDirectory().path, 'test.pdf'),
OS.Path.join(downloadedDirectory, BITCOIN_PDF)
);
var translation;
await win.Zotero_File_Interface.importFile({
file: Zotero.File.pathToFile(backupPath),
createNewCollection: false,
onBeforeImport: (t) => {
translation = t;
}
});
let item = translation.newItems.find(i => i.getField('title') == BITCOIN_TITLE);
let attachments = Zotero.Items.get(item.getAttachments());
assert.lengthOf(attachments, 1);
assert.isTrue(await OS.File.exists(await attachments[0].getFilePathAsync()));
});
it('should remove the decrypted copy if the import fails', async function () {
this.timeout(60000);
// Fails after the database has been decrypted but before it's read
var error = await getPromiseError(
win.Zotero_File_Interface.importFile({
file: Zotero.File.pathToFile(encryptedPath),
createNewCollection: false,
onBeforeImport: () => {
throw new Error('Import failed');
}
})
);
assert.equal(error.message, 'Import failed');
assert.lengthOf(await getDecryptedCopies(), 0);
});
it('should not import if cancelled while decrypting', async function () {
this.timeout(60000);
var numCollections = Zotero.Collections
.getByLibrary(Zotero.Libraries.userLibraryID).length;
var controller = new AbortController();
var onBeforeImport = sinon.stub();
// Cancelled once the decrypted database is being written out
var createDirectory = Zotero.File.createDirectoryIfMissingAsync;
Zotero.File.createDirectoryIfMissingAsync = async function (path, options) {
await createDirectory.call(Zotero.File, path, options);
if (PathUtils.filename(path).startsWith('mendeley-')) {
controller.abort();
}
};
var result;
try {
result = await win.Zotero_File_Interface.importFile({
file: Zotero.File.pathToFile(encryptedPath),
createNewCollection: true,
onBeforeImport,
signal: controller.signal
});
}
finally {
Zotero.File.createDirectoryIfMissingAsync = createDirectory;
}
assert.isFalse(result);
assert.isFalse(onBeforeImport.called);
assert.lengthOf(
Zotero.Collections.getByLibrary(Zotero.Libraries.userLibraryID), numCollections
);
assert.lengthOf(await getDecryptedCopies(), 0);
});
it('should keep the decrypted copy unreadable by other users', async function () {
if (Zotero.isWin) {
this.skip();
}
this.timeout(60000);
// The directory is removed once the import finishes, so check its
// permissions as it's created
var permissions;
var createDirectory = Zotero.File.createDirectoryIfMissingAsync;
Zotero.File.createDirectoryIfMissingAsync = async function (path, options) {
await createDirectory.call(Zotero.File, path, options);
if (PathUtils.filename(path).startsWith('mendeley-')) {
({ permissions } = await IOUtils.stat(path));
}
};
try {
await win.Zotero_File_Interface.importFile({
file: Zotero.File.pathToFile(encryptedPath),
createNewCollection: false
});
}
finally {
Zotero.File.createDirectoryIfMissingAsync = createDirectory;
}
assert.equal(permissions, 0o700);
});
it('should refuse to import while Mendeley has the database open', async function () {
let inUsePath = OS.Path.join(tmpDirectory, ENCRYPTED_DB);
await IOUtils.copy(encryptedPath, inUsePath);
await IOUtils.write(inUsePath + '-wal', new Uint8Array(32));
let error = await getPromiseError(
win.Zotero_File_Interface.importFile({
file: Zotero.File.pathToFile(inUsePath),
createNewCollection: true
})
);
assert.equal(error.message, 'Mendeley database in use');
assert.lengthOf(await getDecryptedCopies(), 0);
});
});
});